From 13b5d7bae8a8869f02266a61e546f158a25878df Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:35:15 +0100 Subject: [PATCH] feat: secondary subtitle tracks close #625 --- lib/i18n/strings.g.dart | 2 +- lib/mpv/models.dart | 21 +++- lib/mpv/player/platform/player_android.dart | 9 +- lib/mpv/player/player.dart | 9 ++ lib/mpv/player/player_base.dart | 50 +++++++--- lib/mpv/player/player_native.dart | 7 ++ lib/screens/video_player_screen.dart | 30 ++++-- lib/services/episode_navigation_service.dart | 3 + lib/services/track_selection_service.dart | 15 +++ lib/utils/video_player_navigation.dart | 4 + .../desktop_video_controls.dart | 11 ++- .../helpers/track_selection_helper.dart | 75 ++++++++++++-- .../video_controls/sheets/track_sheet.dart | 93 +++++++++++++++-- .../video_controls/video_controls.dart | 21 +++- .../widgets/track_chapter_controls.dart | 99 ++++++++++--------- 15 files changed, 353 insertions(+), 96 deletions(-) diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 5ee5dd56..a3c4a027 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -6,7 +6,7 @@ /// Locales: 9 /// Strings: 7236 (804 per locale) /// -/// Built on 2026-03-04 at 07:38 UTC +/// Built on 2026-03-04 at 08:32 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/mpv/models.dart b/lib/mpv/models.dart index 3b54c9b0..0324d46e 100644 --- a/lib/mpv/models.dart +++ b/lib/mpv/models.dart @@ -186,6 +186,9 @@ class Tracks { String toString() => 'Tracks(audio: ${audio.length}, subtitle: ${subtitle.length})'; } +/// Sentinel value used to distinguish "not provided" from "explicitly set to null" in copyWith. +const _sentinel = Object(); + /// Represents the currently selected tracks. class TrackSelection { /// Currently selected audio track. @@ -194,15 +197,25 @@ class TrackSelection { /// Currently selected subtitle track. final SubtitleTrack? subtitle; - const TrackSelection({this.audio, this.subtitle}); + /// Currently selected secondary subtitle track (mpv secondary-sid). + final SubtitleTrack? secondarySubtitle; + + const TrackSelection({this.audio, this.subtitle, this.secondarySubtitle}); /// Creates a copy with the given fields replaced. - TrackSelection copyWith({AudioTrack? audio, SubtitleTrack? subtitle}) { - return TrackSelection(audio: audio ?? this.audio, subtitle: subtitle ?? this.subtitle); + /// Use [secondarySubtitle] with explicit null to clear the secondary subtitle. + TrackSelection copyWith({AudioTrack? audio, SubtitleTrack? subtitle, Object? secondarySubtitle = _sentinel}) { + return TrackSelection( + audio: audio ?? this.audio, + subtitle: subtitle ?? this.subtitle, + secondarySubtitle: identical(secondarySubtitle, _sentinel) + ? this.secondarySubtitle + : secondarySubtitle as SubtitleTrack?, + ); } @override - String toString() => 'TrackSelection(audio: $audio, subtitle: $subtitle)'; + String toString() => 'TrackSelection(audio: $audio, subtitle: $subtitle, secondarySubtitle: $secondarySubtitle)'; } /// Represents an audio output device. diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index b80cf2e8..2899c11b 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -27,6 +27,9 @@ class PlayerAndroid extends PlayerBase { @override String get playerType => 'exoplayer'; + @override + bool get supportsSecondarySubtitles => false; + // ============================================ // Platform-Specific Event Handling // ============================================ @@ -210,9 +213,9 @@ class PlayerAndroid extends PlayerBase { if (storedId != null) { _hiddenSubtitleTrackId = null; final track = state.tracks.subtitle.cast().firstWhere( - (t) => t?.id == storedId, - orElse: () => null, - ); + (t) => t?.id == storedId, + orElse: () => null, + ); if (track != null) { await selectSubtitleTrack(track); } diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index 4c512d18..3ed2e5bc 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -90,6 +90,15 @@ abstract class Player { /// Pass [SubtitleTrack.off] to disable subtitles. Future selectSubtitleTrack(SubtitleTrack track); + /// Select a secondary subtitle track (displayed simultaneously with primary). + /// + /// Only supported on mpv backends (desktop + Android mpv fallback). + /// Pass [SubtitleTrack.off] to disable secondary subtitles. + Future selectSecondarySubtitleTrack(SubtitleTrack track); + + /// Whether this player backend supports secondary subtitle tracks. + bool get supportsSecondarySubtitles; + /// Add an external subtitle track. /// /// [uri] - URL or path to the subtitle file. diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index ac64b754..5b8ba6ac 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -103,11 +103,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { Future observeProperty(String name, String format) async { final propId = _nextPropId++; _propIdToName[propId] = name; - await methodChannel.invokeMethod('observeProperty', { - 'name': name, - 'format': format, - 'id': propId, - }); + await methodChannel.invokeMethod('observeProperty', {'name': name, 'format': format, 'id': propId}); } void _handleEvent(dynamic event) { @@ -235,6 +231,10 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { updateSelectedSubtitleTrack(value); break; + case 'secondary-sid': + updateSelectedSecondarySubtitleTrack(value); + break; + case 'audio-device-list': List? deviceList; if (value is List) { @@ -248,10 +248,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { if (deviceList != null) { final devices = deviceList .whereType() - .map((d) => AudioDevice( - name: d['name'] as String? ?? '', - description: d['description'] as String? ?? '', - )) + .map((d) => AudioDevice(name: d['name'] as String? ?? '', description: d['description'] as String? ?? '')) .toList(); _state = _state.copyWith(audioDevices: devices); audioDevicesController.add(devices); @@ -260,7 +257,8 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { case 'audio-device': if (value is String && value.isNotEmpty) { - final device = _state.audioDevices.cast().firstWhere( + final device = + _state.audioDevices.cast().firstWhere( (d) => d?.name == value, orElse: () => AudioDevice(name: value), ) ?? @@ -306,10 +304,12 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { final start = range['start'] as num?; final end = range['end'] as num?; if (start != null && end != null) { - ranges.add(BufferRange( - start: Duration(milliseconds: (start * 1000).toInt()), - end: Duration(milliseconds: (end * 1000).toInt()), - )); + ranges.add( + BufferRange( + start: Duration(milliseconds: (start * 1000).toInt()), + end: Duration(milliseconds: (end * 1000).toInt()), + ), + ); } } } @@ -441,6 +441,21 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { trackController.add(_state.track); } + /// Update the selected secondary subtitle track. + void updateSelectedSecondarySubtitleTrack(dynamic trackId) { + final id = trackId?.toString(); + SubtitleTrack? selectedTrack; + + if (id == null || id == 'no') { + selectedTrack = null; + } else { + selectedTrack = _state.tracks.subtitle.cast().firstWhere((t) => t?.id == id, orElse: () => null); + } + + _state = _state.copyWith(track: _state.track.copyWith(secondarySubtitle: selectedTrack)); + trackController.add(_state.track); + } + /// Update the internal state. void updateState(PlayerState Function(PlayerState) update) { _state = update(_state); @@ -505,6 +520,13 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { // ignore: no-empty-block - base no-op, overridden by platform subclasses Future setAudioDevice(AudioDevice device) async {} + @override + bool get supportsSecondarySubtitles => true; + + @override + // ignore: no-empty-block - base no-op, overridden by platform subclasses + Future selectSecondarySubtitleTrack(SubtitleTrack track) async {} + @override // ignore: no-empty-block - base no-op, overridden by platform subclasses Future setAudioPassthrough(bool enabled) async {} diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 4bcf2fe1..cfe45cae 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -68,6 +68,7 @@ class PlayerNative extends PlayerBase { await observeProperty('speed', 'double'); await observeProperty('aid', 'string'); await observeProperty('sid', 'string'); + await observeProperty('secondary-sid', 'string'); await observeProperty('demuxer-cache-state', _nodeFormat); await observeProperty('audio-device-list', _nodeFormat); await observeProperty('audio-device', 'string'); @@ -191,6 +192,12 @@ class PlayerNative extends PlayerBase { await setProperty('sid', track.id); } + @override + Future selectSecondarySubtitleTrack(SubtitleTrack track) async { + checkDisposed(); + await setProperty('secondary-sid', track.id); + } + @override Future addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async { checkDisposed(); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 5105cd41..7a0ac127 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -74,6 +74,7 @@ class VideoPlayerScreen extends StatefulWidget { final PlexMetadata metadata; final AudioTrack? preferredAudioTrack; final SubtitleTrack? preferredSubtitleTrack; + final SubtitleTrack? preferredSecondarySubtitleTrack; final int selectedMediaIndex; final bool isOffline; final PlexVideoPlaybackData? playbackData; @@ -94,6 +95,7 @@ class VideoPlayerScreen extends StatefulWidget { required this.metadata, this.preferredAudioTrack, this.preferredSubtitleTrack, + this.preferredSecondarySubtitleTrack, this.selectedMediaIndex = 0, this.isOffline = false, this.playbackData, @@ -188,8 +190,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin /// iOS auto-PiP is system-initiated during the background transition, so /// isPipActive may not be true yet — we also check the auto-PiP setting. bool get _shouldSkipForPip => - PipService().isPipActive.value || - ((Platform.isIOS || Platform.isMacOS) && _autoPipEnabled); + PipService().isPipActive.value || ((Platform.isIOS || Platform.isMacOS) && _autoPipEnabled); // Services MediaControlsManager? _mediaControlsManager; @@ -1292,7 +1293,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin final shaderProvider = context.read(); final settings = await SettingsService.getInstance(); final presetId = settings.getGlobalShaderPreset(); - final preset = (shaderProvider.initialized ? shaderProvider.findPresetById(presetId) : ShaderPreset.fromId(presetId)) ?? ShaderPreset.none; + final preset = + (shaderProvider.initialized ? shaderProvider.findPresetById(presetId) : ShaderPreset.fromId(presetId)) ?? + ShaderPreset.none; await _shaderService!.applyPreset(preset); if (!mounted) return; shaderProvider.setCurrentPreset(preset); @@ -2230,6 +2233,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin await trackService.selectAndApplyTracks( preferredAudioTrack: widget.preferredAudioTrack, preferredSubtitleTrack: widget.preferredSubtitleTrack, + preferredSecondarySubtitleTrack: widget.preferredSecondarySubtitleTrack, defaultPlaybackSpeed: settingsService.getDefaultPlaybackSpeed(), onAudioTrackChanged: _onAudioTrackChanged, onSubtitleTrackChanged: _onSubtitleTrackChanged, @@ -2398,6 +2402,12 @@ class VideoPlayerScreenState extends State with WidgetsBindin await _saveTrackPreferences(partId: partId, trackType: 'subtitle', languageCode: languageCode, streamID: streamID); } + /// Handle secondary subtitle track changes - no server save needed, just preserve for episode navigation + void _onSecondarySubtitleTrackChanged(SubtitleTrack track) { + // Secondary subtitle preference is carried via player.state.track.secondarySubtitle + // which is automatically read during episode navigation. No additional state needed. + } + /// Set flag to skip orientation restoration when replacing with another video void setReplacingWithVideo() { _isReplacingWithVideo = true; @@ -2441,6 +2451,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin final currentAudioTrack = currentPlayer.state.track.audio; final currentSubtitleTrack = currentPlayer.state.track.subtitle; + final currentSecondarySubtitleTrack = currentPlayer.state.track.secondarySubtitle; // Pause and stop current playback currentPlayer.pause(); @@ -2457,6 +2468,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin metadata: episodeMetadata, preferredAudioTrack: currentAudioTrack, preferredSubtitleTrack: currentSubtitleTrack, + preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack, usePushReplacement: true, isOffline: widget.isOffline, ); @@ -2604,14 +2616,15 @@ class VideoPlayerScreenState extends State with WidgetsBindin child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Symbols.picture_in_picture_alt_rounded, size: 48, color: Colors.white.withValues(alpha: 0.5)), + Icon( + Symbols.picture_in_picture_alt_rounded, + size: 48, + color: Colors.white.withValues(alpha: 0.5), + ), const SizedBox(height: 12), Text( t.videoControls.pipActive, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.5), - fontSize: 14, - ), + style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 14), ), ], ), @@ -2677,6 +2690,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin onCycleBoxFitMode: _cycleBoxFitMode, onAudioTrackChanged: _onAudioTrackChanged, onSubtitleTrackChanged: _onSubtitleTrackChanged, + onSecondarySubtitleTrackChanged: _onSecondarySubtitleTrackChanged, onSeekCompleted: (position) { // Notify Watch Together of seek for sync // Note: canControl() check is done in sync manager, not here diff --git a/lib/services/episode_navigation_service.dart b/lib/services/episode_navigation_service.dart index 164c55dd..a8b992da 100644 --- a/lib/services/episode_navigation_service.dart +++ b/lib/services/episode_navigation_service.dart @@ -73,11 +73,13 @@ class EpisodeNavigationService { // Capture current player state before navigation AudioTrack? currentAudioTrack; SubtitleTrack? currentSubtitleTrack; + SubtitleTrack? currentSecondarySubtitleTrack; double? currentPlaybackRate; if (player != null) { currentAudioTrack = player.state.track.audio; currentSubtitleTrack = player.state.track.subtitle; + currentSecondarySubtitleTrack = player.state.track.secondarySubtitle; currentPlaybackRate = player.state.rate; appLogger.d( @@ -92,6 +94,7 @@ class EpisodeNavigationService { metadata: episode, preferredAudioTrack: currentAudioTrack, preferredSubtitleTrack: currentSubtitleTrack, + preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack, usePushReplacement: usePushReplacement, ); } diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index b2b983f5..9be8924c 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -753,6 +753,7 @@ class TrackSelectionService { Future selectAndApplyTracks({ AudioTrack? preferredAudioTrack, SubtitleTrack? preferredSubtitleTrack, + SubtitleTrack? preferredSecondarySubtitleTrack, double? defaultPlaybackSpeed, Function(AudioTrack)? onAudioTrackChanged, Function(SubtitleTrack)? onSubtitleTrackChanged, @@ -798,6 +799,20 @@ class TrackSelectionService { onSubtitleTrackChanged(selectedSubtitleTrack); } + // Apply preferred secondary subtitle track if provided (mpv-only) + if (preferredSecondarySubtitleTrack != null && + preferredSecondarySubtitleTrack.id != 'no' && + player.supportsSecondarySubtitles && + realSubtitleTracks.isNotEmpty) { + final secondaryMatch = findBestSubtitleMatch(realSubtitleTracks, preferredSecondarySubtitleTrack); + if (secondaryMatch != null && secondaryMatch.id != 'no') { + appLogger.d( + 'Secondary subtitle: ${secondaryMatch.title ?? secondaryMatch.language ?? "Track ${secondaryMatch.id}"}', + ); + player.selectSecondarySubtitleTrack(secondaryMatch); + } + } + // Apply default playback speed from settings if (defaultPlaybackSpeed != null && defaultPlaybackSpeed != 1.0) { player.setRate(defaultPlaybackSpeed); diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index eefdc835..5beaaf86 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -38,6 +38,7 @@ Future navigateToVideoPlayer( required PlexMetadata metadata, AudioTrack? preferredAudioTrack, SubtitleTrack? preferredSubtitleTrack, + SubtitleTrack? preferredSecondarySubtitleTrack, int? selectedMediaIndex, bool usePushReplacement = false, bool isOffline = false, @@ -115,6 +116,7 @@ Future navigateToVideoPlayer( metadata: metadata, preferredAudioTrack: preferredAudioTrack, preferredSubtitleTrack: preferredSubtitleTrack, + preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack, selectedMediaIndex: mediaIndex, isOffline: isOffline, playbackData: effectivePlaybackData, @@ -147,6 +149,7 @@ Future navigateToVideoPlayerWithRefresh( VoidCallback? onRefresh, AudioTrack? preferredAudioTrack, SubtitleTrack? preferredSubtitleTrack, + SubtitleTrack? preferredSecondarySubtitleTrack, int? selectedMediaIndex, bool usePushReplacement = false, PlexVideoPlaybackData? playbackData, @@ -157,6 +160,7 @@ Future navigateToVideoPlayerWithRefresh( isOffline: isOffline, preferredAudioTrack: preferredAudioTrack, preferredSubtitleTrack: preferredSubtitleTrack, + preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack, selectedMediaIndex: selectedMediaIndex, usePushReplacement: usePushReplacement, playbackData: playbackData, diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index cded5763..7b7c2d70 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -65,6 +65,7 @@ class DesktopVideoControls extends StatefulWidget { final Function(int)? onSwitchVersion; final Function(AudioTrack)? onAudioTrackChanged; final Function(SubtitleTrack)? onSubtitleTrackChanged; + final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged; final VoidCallback? onLoadSeekTimes; final VoidCallback? onCancelAutoHide; final VoidCallback? onStartAutoHide; @@ -139,6 +140,7 @@ class DesktopVideoControls extends StatefulWidget { this.onSwitchVersion, this.onAudioTrackChanged, this.onSubtitleTrackChanged, + this.onSecondarySubtitleTrackChanged, this.onLoadSeekTimes, this.onCancelAutoHide, this.onStartAutoHide, @@ -637,7 +639,13 @@ class DesktopVideoControlsState extends State { final rate = rateSnap.data ?? 1.0; if (remaining.inSeconds <= 0) return const SizedBox.shrink(); - final text = t.videoControls.endsAt(time: formatFinishTime(remaining, rate: rate, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context))); + final text = t.videoControls.endsAt( + time: formatFinishTime( + remaining, + rate: rate, + is24Hour: MediaQuery.alwaysUse24HourFormatOf(context), + ), + ); const style = TextStyle(color: Colors.white70, fontSize: 13); return LayoutBuilder( @@ -692,6 +700,7 @@ class DesktopVideoControlsState extends State { onSwitchVersion: widget.onSwitchVersion, onAudioTrackChanged: widget.onAudioTrackChanged, onSubtitleTrackChanged: widget.onSubtitleTrackChanged, + onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged, onLoadSeekTimes: widget.onLoadSeekTimes, onCancelAutoHide: widget.onCancelAutoHide, onStartAutoHide: widget.onStartAutoHide, diff --git a/lib/widgets/video_controls/helpers/track_selection_helper.dart b/lib/widgets/video_controls/helpers/track_selection_helper.dart index 1f4abec4..8d258719 100644 --- a/lib/widgets/video_controls/helpers/track_selection_helper.dart +++ b/lib/widgets/video_controls/helpers/track_selection_helper.dart @@ -20,9 +20,7 @@ class TrackSelectionHelper { /// Build a centered empty state widget static Widget buildEmptyState() { - return Center( - child: Text(getEmptyMessage()), - ); + return Center(child: Text(getEmptyMessage())); } /// Check if "Off" is selected for a track @@ -41,8 +39,25 @@ class TrackSelectionHelper { } /// Build the "Off" list tile for track selection - static Widget buildOffTile({required BuildContext context, required bool isSelected, required VoidCallback onTap, FocusNode? focusNode}) { - return _buildSelectableTile(context: context, label: 'Off', isSelected: isSelected, onTap: onTap, focusNode: focusNode); + static Widget buildOffTile({ + required BuildContext context, + required bool isSelected, + required VoidCallback onTap, + FocusNode? focusNode, + VoidCallback? onLongPress, + VoidCallback? onSecondaryTap, + Widget? badge, + }) { + return _buildSelectableTile( + context: context, + label: 'Off', + isSelected: isSelected, + onTap: onTap, + focusNode: focusNode, + onLongPress: onLongPress, + onSecondaryTap: onSecondaryTap, + badge: badge, + ); } /// Build a track selection list tile @@ -52,8 +67,35 @@ class TrackSelectionHelper { required bool isSelected, required VoidCallback onTap, FocusNode? focusNode, + VoidCallback? onLongPress, + VoidCallback? onSecondaryTap, + Widget? badge, }) { - return _buildSelectableTile(context: context, label: label, isSelected: isSelected, onTap: onTap, focusNode: focusNode); + return _buildSelectableTile( + context: context, + label: label, + isSelected: isSelected, + onTap: onTap, + focusNode: focusNode, + onLongPress: onLongPress, + onSecondaryTap: onSecondaryTap, + badge: badge, + ); + } + + /// Build a numbered badge for primary/secondary subtitle indicators. + static Widget buildTrackBadge(BuildContext context, int number) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + width: 18, + height: 18, + decoration: BoxDecoration(color: colorScheme.primary, borderRadius: BorderRadius.circular(4)), + alignment: Alignment.center, + child: Text( + number.toString(), + style: TextStyle(color: colorScheme.onPrimary, fontSize: 11, fontWeight: FontWeight.bold), + ), + ); } static Widget _buildSelectableTile({ @@ -62,13 +104,30 @@ class TrackSelectionHelper { required bool isSelected, required VoidCallback onTap, FocusNode? focusNode, + VoidCallback? onLongPress, + VoidCallback? onSecondaryTap, + Widget? badge, }) { final primaryColor = Theme.of(context).colorScheme.primary; - return FocusableListTile( + Widget? trailing; + if (badge != null) { + trailing = badge; + } else if (isSelected) { + trailing = AppIcon(Symbols.check_rounded, fill: 1, color: primaryColor); + } + + Widget tile = FocusableListTile( focusNode: focusNode, title: Text(label, style: TextStyle(color: isSelected ? primaryColor : null)), - trailing: isSelected ? AppIcon(Symbols.check_rounded, fill: 1, color: primaryColor) : null, + trailing: trailing, onTap: onTap, + onLongPress: onLongPress, ); + + if (onSecondaryTap != null) { + tile = GestureDetector(onSecondaryTap: onSecondaryTap, child: tile); + } + + return tile; } } diff --git a/lib/widgets/video_controls/sheets/track_sheet.dart b/lib/widgets/video_controls/sheets/track_sheet.dart index 104c37e5..6a6f4382 100644 --- a/lib/widgets/video_controls/sheets/track_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_sheet.dart @@ -14,12 +14,14 @@ class TrackSheet extends StatelessWidget { final Player player; final Function(AudioTrack)? onAudioTrackChanged; final Function(SubtitleTrack)? onSubtitleTrackChanged; + final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged; const TrackSheet({ super.key, required this.player, this.onAudioTrackChanged, this.onSubtitleTrackChanged, + this.onSecondarySubtitleTrackChanged, }); @override @@ -29,10 +31,7 @@ class TrackSheet extends StatelessWidget { initialData: player.state.tracks, builder: (context, tracksSnapshot) { final tracks = tracksSnapshot.data; - final audioTracks = TrackFilterHelper.extractAndFilterTracks( - tracks, - (t) => t?.audio ?? [], - ); + final audioTracks = TrackFilterHelper.extractAndFilterTracks(tracks, (t) => t?.audio ?? []); final subtitleTracks = TrackFilterHelper.extractAndFilterTracks( tracks, (t) => t?.subtitle ?? [], @@ -64,6 +63,8 @@ class TrackSheet extends StatelessWidget { builder: (context, selSnapshot) { final selection = selSnapshot.data ?? player.state.track; + final supportsSecondary = player.supportsSecondarySubtitles; + if (showAudio && showSubtitles) { return Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -87,6 +88,8 @@ class TrackSheet extends StatelessWidget { selection: selection, player: player, onTrackChanged: onSubtitleTrackChanged, + onSecondaryTrackChanged: onSecondarySubtitleTrackChanged, + supportsSecondary: supportsSecondary, showHeader: true, ), ), @@ -110,6 +113,8 @@ class TrackSheet extends StatelessWidget { selection: selection, player: player, onTrackChanged: onSubtitleTrackChanged, + onSecondaryTrackChanged: onSecondarySubtitleTrackChanged, + supportsSecondary: supportsSecondary, showHeader: false, ); }, @@ -177,6 +182,8 @@ class _SubtitleColumn extends StatelessWidget { final TrackSelection selection; final Player player; final Function(SubtitleTrack)? onTrackChanged; + final Function(SubtitleTrack)? onSecondaryTrackChanged; + final bool supportsSecondary; final bool showHeader; const _SubtitleColumn({ @@ -184,49 +191,117 @@ class _SubtitleColumn extends StatelessWidget { required this.selection, required this.player, this.onTrackChanged, + this.onSecondaryTrackChanged, + this.supportsSecondary = false, required this.showHeader, }); @override Widget build(BuildContext context) { final selectedSub = selection.subtitle; + final secondarySub = selection.secondarySubtitle; final isOffSelected = selectedSub == null || selectedSub.id == 'no'; + final hasSecondary = supportsSecondary && secondarySub != null; + + // +1 for "Off" row + final itemCount = tracks.length + 1; return Column( children: [ if (showHeader) _ColumnHeader(label: t.videoControls.subtitlesLabel), Expanded( child: ListView.builder( - itemCount: tracks.length + 1, // +1 for "Off" + itemCount: itemCount, itemBuilder: (context, index) { + // "Off" row if (index == 0) { return TrackSelectionHelper.buildOffTile( context: context, isSelected: isOffSelected, onTap: () { + // Turning off primary also clears secondary + if (hasSecondary) { + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + onSecondaryTrackChanged?.call(SubtitleTrack.off); + } player.selectSubtitleTrack(SubtitleTrack.off); onTrackChanged?.call(SubtitleTrack.off); OverlaySheetController.of(context).close(); }, + onLongPress: supportsSecondary && hasSecondary + ? () { + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + onSecondaryTrackChanged?.call(SubtitleTrack.off); + } + : null, + onSecondaryTap: supportsSecondary && hasSecondary + ? () { + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + onSecondaryTrackChanged?.call(SubtitleTrack.off); + } + : null, ); } final track = tracks[index - 1]; + final isPrimary = !isOffSelected && track.id == selectedSub.id; + final isSecondary = hasSecondary && track.id == secondarySub.id; final label = TrackLabelBuilder.buildSubtitleLabel( title: track.title, language: track.language, codec: track.codec, index: index - 1, ); + + // Determine badge + Widget? badge; + if (supportsSecondary && hasSecondary) { + if (isPrimary) { + badge = TrackSelectionHelper.buildTrackBadge(context, 1); + } else if (isSecondary) { + badge = TrackSelectionHelper.buildTrackBadge(context, 2); + } + } + return TrackSelectionHelper.buildTrackTile( context: context, label: label, - isSelected: !isOffSelected && track.id == selectedSub.id, + isSelected: isPrimary, + badge: badge, onTap: () { + // If tapping a track that is currently the secondary, clear secondary first + if (isSecondary) { + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + onSecondaryTrackChanged?.call(SubtitleTrack.off); + } player.selectSubtitleTrack(track); onTrackChanged?.call(track); OverlaySheetController.of(context).close(); }, + onLongPress: supportsSecondary + ? () { + if (isSecondary) { + // Already secondary — clear it + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + onSecondaryTrackChanged?.call(SubtitleTrack.off); + } else if (!isPrimary) { + // Set as secondary (don't close sheet so user sees badge update) + player.selectSecondarySubtitleTrack(track); + onSecondaryTrackChanged?.call(track); + } + } + : null, + onSecondaryTap: supportsSecondary + ? () { + if (isSecondary) { + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + onSecondaryTrackChanged?.call(SubtitleTrack.off); + } else if (!isPrimary) { + player.selectSecondarySubtitleTrack(track); + onSecondaryTrackChanged?.call(track); + } + } + : null, ); }, ), @@ -249,9 +324,9 @@ class _ColumnHeader extends StatelessWidget { alignment: Alignment.centerLeft, child: Text( label, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ), ); diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 8e026a2b..8c9b1d5c 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -70,6 +70,7 @@ Widget plexVideoControlsBuilder( VoidCallback? onCycleBoxFitMode, Function(AudioTrack)? onAudioTrackChanged, Function(SubtitleTrack)? onSubtitleTrackChanged, + Function(SubtitleTrack)? onSecondarySubtitleTrackChanged, Function(Duration position)? onSeekCompleted, VoidCallback? onBack, bool canControl = true, @@ -96,6 +97,7 @@ Widget plexVideoControlsBuilder( onCycleBoxFitMode: onCycleBoxFitMode, onAudioTrackChanged: onAudioTrackChanged, onSubtitleTrackChanged: onSubtitleTrackChanged, + onSecondarySubtitleTrackChanged: onSecondarySubtitleTrackChanged, onSeekCompleted: onSeekCompleted, onBack: onBack, canControl: canControl, @@ -124,6 +126,7 @@ class PlexVideoControls extends StatefulWidget { final VoidCallback? onCycleBoxFitMode; final Function(AudioTrack)? onAudioTrackChanged; final Function(SubtitleTrack)? onSubtitleTrackChanged; + final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged; /// Called when a seek operation completes (for Watch Together sync) final Function(Duration position)? onSeekCompleted; @@ -177,6 +180,7 @@ class PlexVideoControls extends StatefulWidget { this.onCycleBoxFitMode, this.onAudioTrackChanged, this.onSubtitleTrackChanged, + this.onSecondarySubtitleTrackChanged, this.onSeekCompleted, this.onBack, this.canControl = true, @@ -1001,13 +1005,16 @@ class _PlexVideoControlsState extends State with WindowListen subtitleSyncOffset: _subtitleSyncOffset, isRotationLocked: _isRotationLocked, isFullscreen: _isFullscreen, - onTogglePIPMode: (_isPipSupported && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) ? widget.onTogglePIPMode : null, + onTogglePIPMode: (_isPipSupported && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) + ? widget.onTogglePIPMode + : null, onCycleBoxFitMode: widget.player.playerType != 'exoplayer' ? widget.onCycleBoxFitMode : null, onToggleRotationLock: _toggleRotationLock, onToggleFullscreen: _toggleFullscreen, onSwitchVersion: _switchMediaVersion, onAudioTrackChanged: widget.onAudioTrackChanged, onSubtitleTrackChanged: _onSubtitleTrackChanged, + onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged, subtitlesVisible: _subtitlesVisible, onLoadSeekTimes: () async { if (mounted) { @@ -1921,7 +1928,8 @@ class _PlexVideoControlsState extends State with WindowListen child: Builder( builder: (context) { final playbackState = context.watch(); - final hasStripContent = _chapters.isNotEmpty || playbackState.isQueueActive; + final hasStripContent = + _chapters.isNotEmpty || playbackState.isQueueActive; return MobileVideoControls( player: widget.player, metadata: widget.metadata, @@ -1950,7 +1958,9 @@ class _PlexVideoControlsState extends State with WindowListen liveChannelName: widget.liveChannelName, serverId: widget.metadata.serverId, showQueueTab: playbackState.isQueueActive, - onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, + onQueueItemSelected: playbackState.isQueueActive + ? _onQueueItemSelected + : null, controlsVisible: widget.controlsVisible, onStripVisibilityChanged: (visible) { setState(() => _isContentStripVisible = visible); @@ -2020,7 +2030,9 @@ class _PlexVideoControlsState extends State with WindowListen } Widget _buildDesktopControlsListener() { - final pipMode = (_isPipSupported && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) ? widget.onTogglePIPMode : null; + final pipMode = (_isPipSupported && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) + ? widget.onTogglePIPMode + : null; final boxFitMode = widget.player.playerType != 'exoplayer' ? widget.onCycleBoxFitMode : null; final playbackState = context.watch(); @@ -2060,6 +2072,7 @@ class _PlexVideoControlsState extends State with WindowListen onSwitchVersion: _switchMediaVersion, onAudioTrackChanged: widget.onAudioTrackChanged, onSubtitleTrackChanged: _onSubtitleTrackChanged, + onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged, subtitlesVisible: _subtitlesVisible, onLoadSeekTimes: () async { if (mounted) { diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 049ed11d..5bd28451 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -43,6 +43,7 @@ class TrackChapterControls extends StatelessWidget { final Function(int)? onSwitchVersion; final Function(AudioTrack)? onAudioTrackChanged; final Function(SubtitleTrack)? onSubtitleTrackChanged; + final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged; final VoidCallback? onLoadSeekTimes; final VoidCallback? onCancelAutoHide; final VoidCallback? onStartAutoHide; @@ -106,6 +107,7 @@ class TrackChapterControls extends StatelessWidget { this.onSwitchVersion, this.onAudioTrackChanged, this.onSubtitleTrackChanged, + this.onSecondarySubtitleTrackChanged, this.onLoadSeekTimes, this.onCancelAutoHide, this.onStartAutoHide, @@ -219,25 +221,27 @@ class TrackChapterControls extends StatelessWidget { isDesktop: isDesktop, onPressed: () { onCancelAutoHide?.call(); - OverlaySheetController.of(context).show( - builder: (_) => VideoSettingsSheet( - player: player, - audioSyncOffset: audioSyncOffset, - subtitleSyncOffset: subtitleSyncOffset, - canControl: canControl, - isLive: isLive, - shaderService: shaderService, - onShaderChanged: onShaderChanged, - isAmbientLightingEnabled: isAmbientLightingEnabled, - onToggleAmbientLighting: onToggleAmbientLighting, - onCancelAutoHide: onCancelAutoHide, - onStartAutoHide: onStartAutoHide, - onSyncOffsetChanged: onSyncOffsetChanged, - ), - ).whenComplete(() { - onStartAutoHide?.call(); - onLoadSeekTimes?.call(); - }); + OverlaySheetController.of(context) + .show( + builder: (_) => VideoSettingsSheet( + player: player, + audioSyncOffset: audioSyncOffset, + subtitleSyncOffset: subtitleSyncOffset, + canControl: canControl, + isLive: isLive, + shaderService: shaderService, + onShaderChanged: onShaderChanged, + isAmbientLightingEnabled: isAmbientLightingEnabled, + onToggleAmbientLighting: onToggleAmbientLighting, + onCancelAutoHide: onCancelAutoHide, + onStartAutoHide: onStartAutoHide, + onSyncOffsetChanged: onSyncOffsetChanged, + ), + ) + .whenComplete(() { + onStartAutoHide?.call(); + onLoadSeekTimes?.call(); + }); }, ); }, @@ -266,13 +270,16 @@ class TrackChapterControls extends StatelessWidget { isDesktop: isDesktop, onPressed: () { onCancelAutoHide?.call(); - OverlaySheetController.of(context).show( - builder: (_) => TrackSheet( - player: player, - onAudioTrackChanged: onAudioTrackChanged, - onSubtitleTrackChanged: onSubtitleTrackChanged, - ), - ).whenComplete(() => onStartAutoHide?.call()); + OverlaySheetController.of(context) + .show( + builder: (_) => TrackSheet( + player: player, + onAudioTrackChanged: onAudioTrackChanged, + onSubtitleTrackChanged: onSubtitleTrackChanged, + onSecondarySubtitleTrackChanged: onSecondarySubtitleTrackChanged, + ), + ) + .whenComplete(() => onStartAutoHide?.call()); }, ), ); @@ -293,14 +300,16 @@ class TrackChapterControls extends StatelessWidget { isDesktop: isDesktop, onPressed: () { onCancelAutoHide?.call(); - OverlaySheetController.of(context).show( - builder: (_) => ChapterSheet( - player: player, - chapters: chapters, - chaptersLoaded: chaptersLoaded, - serverId: serverId, - ), - ).whenComplete(() => onStartAutoHide?.call()); + OverlaySheetController.of(context) + .show( + builder: (_) => ChapterSheet( + player: player, + chapters: chapters, + chaptersLoaded: chaptersLoaded, + serverId: serverId, + ), + ) + .whenComplete(() => onStartAutoHide?.call()); }, ), ); @@ -321,9 +330,9 @@ class TrackChapterControls extends StatelessWidget { isDesktop: isDesktop, onPressed: () { onCancelAutoHide?.call(); - OverlaySheetController.of(context).show( - builder: (_) => QueueSheet(onItemSelected: onQueueItemSelected!), - ).whenComplete(() => onStartAutoHide?.call()); + OverlaySheetController.of(context) + .show(builder: (_) => QueueSheet(onItemSelected: onQueueItemSelected!)) + .whenComplete(() => onStartAutoHide?.call()); }, ), ); @@ -344,13 +353,15 @@ class TrackChapterControls extends StatelessWidget { isDesktop: isDesktop, onPressed: () { onCancelAutoHide?.call(); - OverlaySheetController.of(context).show( - builder: (_) => VersionSheet( - availableVersions: availableVersions, - selectedMediaIndex: selectedMediaIndex, - onVersionSelected: onSwitchVersion!, - ), - ).whenComplete(() => onStartAutoHide?.call()); + OverlaySheetController.of(context) + .show( + builder: (_) => VersionSheet( + availableVersions: availableVersions, + selectedMediaIndex: selectedMediaIndex, + onVersionSelected: onSwitchVersion!, + ), + ) + .whenComplete(() => onStartAutoHide?.call()); }, ), );