feat: secondary subtitle tracks

close #625
This commit is contained in:
edde746
2026-03-04 09:35:36 +01:00
parent 3a76754af9
commit 13b5d7bae8
15 changed files with 353 additions and 96 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
/// Locales: 9 /// Locales: 9
/// Strings: 7236 (804 per locale) /// 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 // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import
+17 -4
View File
@@ -186,6 +186,9 @@ class Tracks {
String toString() => 'Tracks(audio: ${audio.length}, subtitle: ${subtitle.length})'; 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. /// Represents the currently selected tracks.
class TrackSelection { class TrackSelection {
/// Currently selected audio track. /// Currently selected audio track.
@@ -194,15 +197,25 @@ class TrackSelection {
/// Currently selected subtitle track. /// Currently selected subtitle track.
final SubtitleTrack? subtitle; 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. /// Creates a copy with the given fields replaced.
TrackSelection copyWith({AudioTrack? audio, SubtitleTrack? subtitle}) { /// Use [secondarySubtitle] with explicit null to clear the secondary subtitle.
return TrackSelection(audio: audio ?? this.audio, subtitle: subtitle ?? this.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 @override
String toString() => 'TrackSelection(audio: $audio, subtitle: $subtitle)'; String toString() => 'TrackSelection(audio: $audio, subtitle: $subtitle, secondarySubtitle: $secondarySubtitle)';
} }
/// Represents an audio output device. /// Represents an audio output device.
+6 -3
View File
@@ -27,6 +27,9 @@ class PlayerAndroid extends PlayerBase {
@override @override
String get playerType => 'exoplayer'; String get playerType => 'exoplayer';
@override
bool get supportsSecondarySubtitles => false;
// ============================================ // ============================================
// Platform-Specific Event Handling // Platform-Specific Event Handling
// ============================================ // ============================================
@@ -210,9 +213,9 @@ class PlayerAndroid extends PlayerBase {
if (storedId != null) { if (storedId != null) {
_hiddenSubtitleTrackId = null; _hiddenSubtitleTrackId = null;
final track = state.tracks.subtitle.cast<SubtitleTrack?>().firstWhere( final track = state.tracks.subtitle.cast<SubtitleTrack?>().firstWhere(
(t) => t?.id == storedId, (t) => t?.id == storedId,
orElse: () => null, orElse: () => null,
); );
if (track != null) { if (track != null) {
await selectSubtitleTrack(track); await selectSubtitleTrack(track);
} }
+9
View File
@@ -90,6 +90,15 @@ abstract class Player {
/// Pass [SubtitleTrack.off] to disable subtitles. /// Pass [SubtitleTrack.off] to disable subtitles.
Future<void> selectSubtitleTrack(SubtitleTrack track); Future<void> 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<void> selectSecondarySubtitleTrack(SubtitleTrack track);
/// Whether this player backend supports secondary subtitle tracks.
bool get supportsSecondarySubtitles;
/// Add an external subtitle track. /// Add an external subtitle track.
/// ///
/// [uri] - URL or path to the subtitle file. /// [uri] - URL or path to the subtitle file.
+36 -14
View File
@@ -103,11 +103,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
Future<void> observeProperty(String name, String format) async { Future<void> observeProperty(String name, String format) async {
final propId = _nextPropId++; final propId = _nextPropId++;
_propIdToName[propId] = name; _propIdToName[propId] = name;
await methodChannel.invokeMethod('observeProperty', { await methodChannel.invokeMethod('observeProperty', {'name': name, 'format': format, 'id': propId});
'name': name,
'format': format,
'id': propId,
});
} }
void _handleEvent(dynamic event) { void _handleEvent(dynamic event) {
@@ -235,6 +231,10 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
updateSelectedSubtitleTrack(value); updateSelectedSubtitleTrack(value);
break; break;
case 'secondary-sid':
updateSelectedSecondarySubtitleTrack(value);
break;
case 'audio-device-list': case 'audio-device-list':
List? deviceList; List? deviceList;
if (value is List) { if (value is List) {
@@ -248,10 +248,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
if (deviceList != null) { if (deviceList != null) {
final devices = deviceList final devices = deviceList
.whereType<Map>() .whereType<Map>()
.map((d) => AudioDevice( .map((d) => AudioDevice(name: d['name'] as String? ?? '', description: d['description'] as String? ?? ''))
name: d['name'] as String? ?? '',
description: d['description'] as String? ?? '',
))
.toList(); .toList();
_state = _state.copyWith(audioDevices: devices); _state = _state.copyWith(audioDevices: devices);
audioDevicesController.add(devices); audioDevicesController.add(devices);
@@ -260,7 +257,8 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
case 'audio-device': case 'audio-device':
if (value is String && value.isNotEmpty) { if (value is String && value.isNotEmpty) {
final device = _state.audioDevices.cast<AudioDevice?>().firstWhere( final device =
_state.audioDevices.cast<AudioDevice?>().firstWhere(
(d) => d?.name == value, (d) => d?.name == value,
orElse: () => AudioDevice(name: value), orElse: () => AudioDevice(name: value),
) ?? ) ??
@@ -306,10 +304,12 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
final start = range['start'] as num?; final start = range['start'] as num?;
final end = range['end'] as num?; final end = range['end'] as num?;
if (start != null && end != null) { if (start != null && end != null) {
ranges.add(BufferRange( ranges.add(
start: Duration(milliseconds: (start * 1000).toInt()), BufferRange(
end: Duration(milliseconds: (end * 1000).toInt()), 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); 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<SubtitleTrack?>().firstWhere((t) => t?.id == id, orElse: () => null);
}
_state = _state.copyWith(track: _state.track.copyWith(secondarySubtitle: selectedTrack));
trackController.add(_state.track);
}
/// Update the internal state. /// Update the internal state.
void updateState(PlayerState Function(PlayerState) update) { void updateState(PlayerState Function(PlayerState) update) {
_state = update(_state); _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 // ignore: no-empty-block - base no-op, overridden by platform subclasses
Future<void> setAudioDevice(AudioDevice device) async {} Future<void> setAudioDevice(AudioDevice device) async {}
@override
bool get supportsSecondarySubtitles => true;
@override
// ignore: no-empty-block - base no-op, overridden by platform subclasses
Future<void> selectSecondarySubtitleTrack(SubtitleTrack track) async {}
@override @override
// ignore: no-empty-block - base no-op, overridden by platform subclasses // ignore: no-empty-block - base no-op, overridden by platform subclasses
Future<void> setAudioPassthrough(bool enabled) async {} Future<void> setAudioPassthrough(bool enabled) async {}
+7
View File
@@ -68,6 +68,7 @@ class PlayerNative extends PlayerBase {
await observeProperty('speed', 'double'); await observeProperty('speed', 'double');
await observeProperty('aid', 'string'); await observeProperty('aid', 'string');
await observeProperty('sid', 'string'); await observeProperty('sid', 'string');
await observeProperty('secondary-sid', 'string');
await observeProperty('demuxer-cache-state', _nodeFormat); await observeProperty('demuxer-cache-state', _nodeFormat);
await observeProperty('audio-device-list', _nodeFormat); await observeProperty('audio-device-list', _nodeFormat);
await observeProperty('audio-device', 'string'); await observeProperty('audio-device', 'string');
@@ -191,6 +192,12 @@ class PlayerNative extends PlayerBase {
await setProperty('sid', track.id); await setProperty('sid', track.id);
} }
@override
Future<void> selectSecondarySubtitleTrack(SubtitleTrack track) async {
checkDisposed();
await setProperty('secondary-sid', track.id);
}
@override @override
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async { Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async {
checkDisposed(); checkDisposed();
+22 -8
View File
@@ -74,6 +74,7 @@ class VideoPlayerScreen extends StatefulWidget {
final PlexMetadata metadata; final PlexMetadata metadata;
final AudioTrack? preferredAudioTrack; final AudioTrack? preferredAudioTrack;
final SubtitleTrack? preferredSubtitleTrack; final SubtitleTrack? preferredSubtitleTrack;
final SubtitleTrack? preferredSecondarySubtitleTrack;
final int selectedMediaIndex; final int selectedMediaIndex;
final bool isOffline; final bool isOffline;
final PlexVideoPlaybackData? playbackData; final PlexVideoPlaybackData? playbackData;
@@ -94,6 +95,7 @@ class VideoPlayerScreen extends StatefulWidget {
required this.metadata, required this.metadata,
this.preferredAudioTrack, this.preferredAudioTrack,
this.preferredSubtitleTrack, this.preferredSubtitleTrack,
this.preferredSecondarySubtitleTrack,
this.selectedMediaIndex = 0, this.selectedMediaIndex = 0,
this.isOffline = false, this.isOffline = false,
this.playbackData, this.playbackData,
@@ -188,8 +190,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
/// iOS auto-PiP is system-initiated during the background transition, so /// iOS auto-PiP is system-initiated during the background transition, so
/// isPipActive may not be true yet — we also check the auto-PiP setting. /// isPipActive may not be true yet — we also check the auto-PiP setting.
bool get _shouldSkipForPip => bool get _shouldSkipForPip =>
PipService().isPipActive.value || PipService().isPipActive.value || ((Platform.isIOS || Platform.isMacOS) && _autoPipEnabled);
((Platform.isIOS || Platform.isMacOS) && _autoPipEnabled);
// Services // Services
MediaControlsManager? _mediaControlsManager; MediaControlsManager? _mediaControlsManager;
@@ -1292,7 +1293,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final shaderProvider = context.read<ShaderProvider>(); final shaderProvider = context.read<ShaderProvider>();
final settings = await SettingsService.getInstance(); final settings = await SettingsService.getInstance();
final presetId = settings.getGlobalShaderPreset(); 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); await _shaderService!.applyPreset(preset);
if (!mounted) return; if (!mounted) return;
shaderProvider.setCurrentPreset(preset); shaderProvider.setCurrentPreset(preset);
@@ -2230,6 +2233,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
await trackService.selectAndApplyTracks( await trackService.selectAndApplyTracks(
preferredAudioTrack: widget.preferredAudioTrack, preferredAudioTrack: widget.preferredAudioTrack,
preferredSubtitleTrack: widget.preferredSubtitleTrack, preferredSubtitleTrack: widget.preferredSubtitleTrack,
preferredSecondarySubtitleTrack: widget.preferredSecondarySubtitleTrack,
defaultPlaybackSpeed: settingsService.getDefaultPlaybackSpeed(), defaultPlaybackSpeed: settingsService.getDefaultPlaybackSpeed(),
onAudioTrackChanged: _onAudioTrackChanged, onAudioTrackChanged: _onAudioTrackChanged,
onSubtitleTrackChanged: _onSubtitleTrackChanged, onSubtitleTrackChanged: _onSubtitleTrackChanged,
@@ -2398,6 +2402,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
await _saveTrackPreferences(partId: partId, trackType: 'subtitle', languageCode: languageCode, streamID: streamID); 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 /// Set flag to skip orientation restoration when replacing with another video
void setReplacingWithVideo() { void setReplacingWithVideo() {
_isReplacingWithVideo = true; _isReplacingWithVideo = true;
@@ -2441,6 +2451,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final currentAudioTrack = currentPlayer.state.track.audio; final currentAudioTrack = currentPlayer.state.track.audio;
final currentSubtitleTrack = currentPlayer.state.track.subtitle; final currentSubtitleTrack = currentPlayer.state.track.subtitle;
final currentSecondarySubtitleTrack = currentPlayer.state.track.secondarySubtitle;
// Pause and stop current playback // Pause and stop current playback
currentPlayer.pause(); currentPlayer.pause();
@@ -2457,6 +2468,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
metadata: episodeMetadata, metadata: episodeMetadata,
preferredAudioTrack: currentAudioTrack, preferredAudioTrack: currentAudioTrack,
preferredSubtitleTrack: currentSubtitleTrack, preferredSubtitleTrack: currentSubtitleTrack,
preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack,
usePushReplacement: true, usePushReplacement: true,
isOffline: widget.isOffline, isOffline: widget.isOffline,
); );
@@ -2604,14 +2616,15 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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), const SizedBox(height: 12),
Text( Text(
t.videoControls.pipActive, t.videoControls.pipActive,
style: TextStyle( style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 14),
color: Colors.white.withValues(alpha: 0.5),
fontSize: 14,
),
), ),
], ],
), ),
@@ -2677,6 +2690,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
onCycleBoxFitMode: _cycleBoxFitMode, onCycleBoxFitMode: _cycleBoxFitMode,
onAudioTrackChanged: _onAudioTrackChanged, onAudioTrackChanged: _onAudioTrackChanged,
onSubtitleTrackChanged: _onSubtitleTrackChanged, onSubtitleTrackChanged: _onSubtitleTrackChanged,
onSecondarySubtitleTrackChanged: _onSecondarySubtitleTrackChanged,
onSeekCompleted: (position) { onSeekCompleted: (position) {
// Notify Watch Together of seek for sync // Notify Watch Together of seek for sync
// Note: canControl() check is done in sync manager, not here // Note: canControl() check is done in sync manager, not here
@@ -73,11 +73,13 @@ class EpisodeNavigationService {
// Capture current player state before navigation // Capture current player state before navigation
AudioTrack? currentAudioTrack; AudioTrack? currentAudioTrack;
SubtitleTrack? currentSubtitleTrack; SubtitleTrack? currentSubtitleTrack;
SubtitleTrack? currentSecondarySubtitleTrack;
double? currentPlaybackRate; double? currentPlaybackRate;
if (player != null) { if (player != null) {
currentAudioTrack = player.state.track.audio; currentAudioTrack = player.state.track.audio;
currentSubtitleTrack = player.state.track.subtitle; currentSubtitleTrack = player.state.track.subtitle;
currentSecondarySubtitleTrack = player.state.track.secondarySubtitle;
currentPlaybackRate = player.state.rate; currentPlaybackRate = player.state.rate;
appLogger.d( appLogger.d(
@@ -92,6 +94,7 @@ class EpisodeNavigationService {
metadata: episode, metadata: episode,
preferredAudioTrack: currentAudioTrack, preferredAudioTrack: currentAudioTrack,
preferredSubtitleTrack: currentSubtitleTrack, preferredSubtitleTrack: currentSubtitleTrack,
preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack,
usePushReplacement: usePushReplacement, usePushReplacement: usePushReplacement,
); );
} }
+15
View File
@@ -753,6 +753,7 @@ class TrackSelectionService {
Future<void> selectAndApplyTracks({ Future<void> selectAndApplyTracks({
AudioTrack? preferredAudioTrack, AudioTrack? preferredAudioTrack,
SubtitleTrack? preferredSubtitleTrack, SubtitleTrack? preferredSubtitleTrack,
SubtitleTrack? preferredSecondarySubtitleTrack,
double? defaultPlaybackSpeed, double? defaultPlaybackSpeed,
Function(AudioTrack)? onAudioTrackChanged, Function(AudioTrack)? onAudioTrackChanged,
Function(SubtitleTrack)? onSubtitleTrackChanged, Function(SubtitleTrack)? onSubtitleTrackChanged,
@@ -798,6 +799,20 @@ class TrackSelectionService {
onSubtitleTrackChanged(selectedSubtitleTrack); 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 // Apply default playback speed from settings
if (defaultPlaybackSpeed != null && defaultPlaybackSpeed != 1.0) { if (defaultPlaybackSpeed != null && defaultPlaybackSpeed != 1.0) {
player.setRate(defaultPlaybackSpeed); player.setRate(defaultPlaybackSpeed);
+4
View File
@@ -38,6 +38,7 @@ Future<bool?> navigateToVideoPlayer(
required PlexMetadata metadata, required PlexMetadata metadata,
AudioTrack? preferredAudioTrack, AudioTrack? preferredAudioTrack,
SubtitleTrack? preferredSubtitleTrack, SubtitleTrack? preferredSubtitleTrack,
SubtitleTrack? preferredSecondarySubtitleTrack,
int? selectedMediaIndex, int? selectedMediaIndex,
bool usePushReplacement = false, bool usePushReplacement = false,
bool isOffline = false, bool isOffline = false,
@@ -115,6 +116,7 @@ Future<bool?> navigateToVideoPlayer(
metadata: metadata, metadata: metadata,
preferredAudioTrack: preferredAudioTrack, preferredAudioTrack: preferredAudioTrack,
preferredSubtitleTrack: preferredSubtitleTrack, preferredSubtitleTrack: preferredSubtitleTrack,
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
selectedMediaIndex: mediaIndex, selectedMediaIndex: mediaIndex,
isOffline: isOffline, isOffline: isOffline,
playbackData: effectivePlaybackData, playbackData: effectivePlaybackData,
@@ -147,6 +149,7 @@ Future<bool?> navigateToVideoPlayerWithRefresh(
VoidCallback? onRefresh, VoidCallback? onRefresh,
AudioTrack? preferredAudioTrack, AudioTrack? preferredAudioTrack,
SubtitleTrack? preferredSubtitleTrack, SubtitleTrack? preferredSubtitleTrack,
SubtitleTrack? preferredSecondarySubtitleTrack,
int? selectedMediaIndex, int? selectedMediaIndex,
bool usePushReplacement = false, bool usePushReplacement = false,
PlexVideoPlaybackData? playbackData, PlexVideoPlaybackData? playbackData,
@@ -157,6 +160,7 @@ Future<bool?> navigateToVideoPlayerWithRefresh(
isOffline: isOffline, isOffline: isOffline,
preferredAudioTrack: preferredAudioTrack, preferredAudioTrack: preferredAudioTrack,
preferredSubtitleTrack: preferredSubtitleTrack, preferredSubtitleTrack: preferredSubtitleTrack,
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
selectedMediaIndex: selectedMediaIndex, selectedMediaIndex: selectedMediaIndex,
usePushReplacement: usePushReplacement, usePushReplacement: usePushReplacement,
playbackData: playbackData, playbackData: playbackData,
@@ -65,6 +65,7 @@ class DesktopVideoControls extends StatefulWidget {
final Function(int)? onSwitchVersion; final Function(int)? onSwitchVersion;
final Function(AudioTrack)? onAudioTrackChanged; final Function(AudioTrack)? onAudioTrackChanged;
final Function(SubtitleTrack)? onSubtitleTrackChanged; final Function(SubtitleTrack)? onSubtitleTrackChanged;
final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged;
final VoidCallback? onLoadSeekTimes; final VoidCallback? onLoadSeekTimes;
final VoidCallback? onCancelAutoHide; final VoidCallback? onCancelAutoHide;
final VoidCallback? onStartAutoHide; final VoidCallback? onStartAutoHide;
@@ -139,6 +140,7 @@ class DesktopVideoControls extends StatefulWidget {
this.onSwitchVersion, this.onSwitchVersion,
this.onAudioTrackChanged, this.onAudioTrackChanged,
this.onSubtitleTrackChanged, this.onSubtitleTrackChanged,
this.onSecondarySubtitleTrackChanged,
this.onLoadSeekTimes, this.onLoadSeekTimes,
this.onCancelAutoHide, this.onCancelAutoHide,
this.onStartAutoHide, this.onStartAutoHide,
@@ -637,7 +639,13 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
final rate = rateSnap.data ?? 1.0; final rate = rateSnap.data ?? 1.0;
if (remaining.inSeconds <= 0) return const SizedBox.shrink(); 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); const style = TextStyle(color: Colors.white70, fontSize: 13);
return LayoutBuilder( return LayoutBuilder(
@@ -692,6 +700,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
onSwitchVersion: widget.onSwitchVersion, onSwitchVersion: widget.onSwitchVersion,
onAudioTrackChanged: widget.onAudioTrackChanged, onAudioTrackChanged: widget.onAudioTrackChanged,
onSubtitleTrackChanged: widget.onSubtitleTrackChanged, onSubtitleTrackChanged: widget.onSubtitleTrackChanged,
onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged,
onLoadSeekTimes: widget.onLoadSeekTimes, onLoadSeekTimes: widget.onLoadSeekTimes,
onCancelAutoHide: widget.onCancelAutoHide, onCancelAutoHide: widget.onCancelAutoHide,
onStartAutoHide: widget.onStartAutoHide, onStartAutoHide: widget.onStartAutoHide,
@@ -20,9 +20,7 @@ class TrackSelectionHelper {
/// Build a centered empty state widget /// Build a centered empty state widget
static Widget buildEmptyState<T>() { static Widget buildEmptyState<T>() {
return Center( return Center(child: Text(getEmptyMessage<T>()));
child: Text(getEmptyMessage<T>()),
);
} }
/// Check if "Off" is selected for a track /// Check if "Off" is selected for a track
@@ -41,8 +39,25 @@ class TrackSelectionHelper {
} }
/// Build the "Off" list tile for track selection /// Build the "Off" list tile for track selection
static Widget buildOffTile<T>({required BuildContext context, required bool isSelected, required VoidCallback onTap, FocusNode? focusNode}) { static Widget buildOffTile<T>({
return _buildSelectableTile(context: context, label: 'Off', isSelected: isSelected, onTap: onTap, focusNode: focusNode); 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 /// Build a track selection list tile
@@ -52,8 +67,35 @@ class TrackSelectionHelper {
required bool isSelected, required bool isSelected,
required VoidCallback onTap, required VoidCallback onTap,
FocusNode? focusNode, 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({ static Widget _buildSelectableTile({
@@ -62,13 +104,30 @@ class TrackSelectionHelper {
required bool isSelected, required bool isSelected,
required VoidCallback onTap, required VoidCallback onTap,
FocusNode? focusNode, FocusNode? focusNode,
VoidCallback? onLongPress,
VoidCallback? onSecondaryTap,
Widget? badge,
}) { }) {
final primaryColor = Theme.of(context).colorScheme.primary; 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, focusNode: focusNode,
title: Text(label, style: TextStyle(color: isSelected ? primaryColor : null)), title: Text(label, style: TextStyle(color: isSelected ? primaryColor : null)),
trailing: isSelected ? AppIcon(Symbols.check_rounded, fill: 1, color: primaryColor) : null, trailing: trailing,
onTap: onTap, onTap: onTap,
onLongPress: onLongPress,
); );
if (onSecondaryTap != null) {
tile = GestureDetector(onSecondaryTap: onSecondaryTap, child: tile);
}
return tile;
} }
} }
@@ -14,12 +14,14 @@ class TrackSheet extends StatelessWidget {
final Player player; final Player player;
final Function(AudioTrack)? onAudioTrackChanged; final Function(AudioTrack)? onAudioTrackChanged;
final Function(SubtitleTrack)? onSubtitleTrackChanged; final Function(SubtitleTrack)? onSubtitleTrackChanged;
final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged;
const TrackSheet({ const TrackSheet({
super.key, super.key,
required this.player, required this.player,
this.onAudioTrackChanged, this.onAudioTrackChanged,
this.onSubtitleTrackChanged, this.onSubtitleTrackChanged,
this.onSecondarySubtitleTrackChanged,
}); });
@override @override
@@ -29,10 +31,7 @@ class TrackSheet extends StatelessWidget {
initialData: player.state.tracks, initialData: player.state.tracks,
builder: (context, tracksSnapshot) { builder: (context, tracksSnapshot) {
final tracks = tracksSnapshot.data; final tracks = tracksSnapshot.data;
final audioTracks = TrackFilterHelper.extractAndFilterTracks<AudioTrack>( final audioTracks = TrackFilterHelper.extractAndFilterTracks<AudioTrack>(tracks, (t) => t?.audio ?? []);
tracks,
(t) => t?.audio ?? [],
);
final subtitleTracks = TrackFilterHelper.extractAndFilterTracks<SubtitleTrack>( final subtitleTracks = TrackFilterHelper.extractAndFilterTracks<SubtitleTrack>(
tracks, tracks,
(t) => t?.subtitle ?? [], (t) => t?.subtitle ?? [],
@@ -64,6 +63,8 @@ class TrackSheet extends StatelessWidget {
builder: (context, selSnapshot) { builder: (context, selSnapshot) {
final selection = selSnapshot.data ?? player.state.track; final selection = selSnapshot.data ?? player.state.track;
final supportsSecondary = player.supportsSecondarySubtitles;
if (showAudio && showSubtitles) { if (showAudio && showSubtitles) {
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -87,6 +88,8 @@ class TrackSheet extends StatelessWidget {
selection: selection, selection: selection,
player: player, player: player,
onTrackChanged: onSubtitleTrackChanged, onTrackChanged: onSubtitleTrackChanged,
onSecondaryTrackChanged: onSecondarySubtitleTrackChanged,
supportsSecondary: supportsSecondary,
showHeader: true, showHeader: true,
), ),
), ),
@@ -110,6 +113,8 @@ class TrackSheet extends StatelessWidget {
selection: selection, selection: selection,
player: player, player: player,
onTrackChanged: onSubtitleTrackChanged, onTrackChanged: onSubtitleTrackChanged,
onSecondaryTrackChanged: onSecondarySubtitleTrackChanged,
supportsSecondary: supportsSecondary,
showHeader: false, showHeader: false,
); );
}, },
@@ -177,6 +182,8 @@ class _SubtitleColumn extends StatelessWidget {
final TrackSelection selection; final TrackSelection selection;
final Player player; final Player player;
final Function(SubtitleTrack)? onTrackChanged; final Function(SubtitleTrack)? onTrackChanged;
final Function(SubtitleTrack)? onSecondaryTrackChanged;
final bool supportsSecondary;
final bool showHeader; final bool showHeader;
const _SubtitleColumn({ const _SubtitleColumn({
@@ -184,49 +191,117 @@ class _SubtitleColumn extends StatelessWidget {
required this.selection, required this.selection,
required this.player, required this.player,
this.onTrackChanged, this.onTrackChanged,
this.onSecondaryTrackChanged,
this.supportsSecondary = false,
required this.showHeader, required this.showHeader,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final selectedSub = selection.subtitle; final selectedSub = selection.subtitle;
final secondarySub = selection.secondarySubtitle;
final isOffSelected = selectedSub == null || selectedSub.id == 'no'; final isOffSelected = selectedSub == null || selectedSub.id == 'no';
final hasSecondary = supportsSecondary && secondarySub != null;
// +1 for "Off" row
final itemCount = tracks.length + 1;
return Column( return Column(
children: [ children: [
if (showHeader) _ColumnHeader(label: t.videoControls.subtitlesLabel), if (showHeader) _ColumnHeader(label: t.videoControls.subtitlesLabel),
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
itemCount: tracks.length + 1, // +1 for "Off" itemCount: itemCount,
itemBuilder: (context, index) { itemBuilder: (context, index) {
// "Off" row
if (index == 0) { if (index == 0) {
return TrackSelectionHelper.buildOffTile<SubtitleTrack>( return TrackSelectionHelper.buildOffTile<SubtitleTrack>(
context: context, context: context,
isSelected: isOffSelected, isSelected: isOffSelected,
onTap: () { onTap: () {
// Turning off primary also clears secondary
if (hasSecondary) {
player.selectSecondarySubtitleTrack(SubtitleTrack.off);
onSecondaryTrackChanged?.call(SubtitleTrack.off);
}
player.selectSubtitleTrack(SubtitleTrack.off); player.selectSubtitleTrack(SubtitleTrack.off);
onTrackChanged?.call(SubtitleTrack.off); onTrackChanged?.call(SubtitleTrack.off);
OverlaySheetController.of(context).close(); 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 track = tracks[index - 1];
final isPrimary = !isOffSelected && track.id == selectedSub.id;
final isSecondary = hasSecondary && track.id == secondarySub.id;
final label = TrackLabelBuilder.buildSubtitleLabel( final label = TrackLabelBuilder.buildSubtitleLabel(
title: track.title, title: track.title,
language: track.language, language: track.language,
codec: track.codec, codec: track.codec,
index: index - 1, 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<SubtitleTrack>( return TrackSelectionHelper.buildTrackTile<SubtitleTrack>(
context: context, context: context,
label: label, label: label,
isSelected: !isOffSelected && track.id == selectedSub.id, isSelected: isPrimary,
badge: badge,
onTap: () { 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); player.selectSubtitleTrack(track);
onTrackChanged?.call(track); onTrackChanged?.call(track);
OverlaySheetController.of(context).close(); 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, alignment: Alignment.centerLeft,
child: Text( child: Text(
label, label,
style: Theme.of(context).textTheme.titleSmall?.copyWith( style: Theme.of(
color: Theme.of(context).colorScheme.onSurfaceVariant, context,
), ).textTheme.titleSmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
), ),
), ),
); );
+17 -4
View File
@@ -70,6 +70,7 @@ Widget plexVideoControlsBuilder(
VoidCallback? onCycleBoxFitMode, VoidCallback? onCycleBoxFitMode,
Function(AudioTrack)? onAudioTrackChanged, Function(AudioTrack)? onAudioTrackChanged,
Function(SubtitleTrack)? onSubtitleTrackChanged, Function(SubtitleTrack)? onSubtitleTrackChanged,
Function(SubtitleTrack)? onSecondarySubtitleTrackChanged,
Function(Duration position)? onSeekCompleted, Function(Duration position)? onSeekCompleted,
VoidCallback? onBack, VoidCallback? onBack,
bool canControl = true, bool canControl = true,
@@ -96,6 +97,7 @@ Widget plexVideoControlsBuilder(
onCycleBoxFitMode: onCycleBoxFitMode, onCycleBoxFitMode: onCycleBoxFitMode,
onAudioTrackChanged: onAudioTrackChanged, onAudioTrackChanged: onAudioTrackChanged,
onSubtitleTrackChanged: onSubtitleTrackChanged, onSubtitleTrackChanged: onSubtitleTrackChanged,
onSecondarySubtitleTrackChanged: onSecondarySubtitleTrackChanged,
onSeekCompleted: onSeekCompleted, onSeekCompleted: onSeekCompleted,
onBack: onBack, onBack: onBack,
canControl: canControl, canControl: canControl,
@@ -124,6 +126,7 @@ class PlexVideoControls extends StatefulWidget {
final VoidCallback? onCycleBoxFitMode; final VoidCallback? onCycleBoxFitMode;
final Function(AudioTrack)? onAudioTrackChanged; final Function(AudioTrack)? onAudioTrackChanged;
final Function(SubtitleTrack)? onSubtitleTrackChanged; final Function(SubtitleTrack)? onSubtitleTrackChanged;
final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged;
/// Called when a seek operation completes (for Watch Together sync) /// Called when a seek operation completes (for Watch Together sync)
final Function(Duration position)? onSeekCompleted; final Function(Duration position)? onSeekCompleted;
@@ -177,6 +180,7 @@ class PlexVideoControls extends StatefulWidget {
this.onCycleBoxFitMode, this.onCycleBoxFitMode,
this.onAudioTrackChanged, this.onAudioTrackChanged,
this.onSubtitleTrackChanged, this.onSubtitleTrackChanged,
this.onSecondarySubtitleTrackChanged,
this.onSeekCompleted, this.onSeekCompleted,
this.onBack, this.onBack,
this.canControl = true, this.canControl = true,
@@ -1001,13 +1005,16 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
subtitleSyncOffset: _subtitleSyncOffset, subtitleSyncOffset: _subtitleSyncOffset,
isRotationLocked: _isRotationLocked, isRotationLocked: _isRotationLocked,
isFullscreen: _isFullscreen, 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, onCycleBoxFitMode: widget.player.playerType != 'exoplayer' ? widget.onCycleBoxFitMode : null,
onToggleRotationLock: _toggleRotationLock, onToggleRotationLock: _toggleRotationLock,
onToggleFullscreen: _toggleFullscreen, onToggleFullscreen: _toggleFullscreen,
onSwitchVersion: _switchMediaVersion, onSwitchVersion: _switchMediaVersion,
onAudioTrackChanged: widget.onAudioTrackChanged, onAudioTrackChanged: widget.onAudioTrackChanged,
onSubtitleTrackChanged: _onSubtitleTrackChanged, onSubtitleTrackChanged: _onSubtitleTrackChanged,
onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged,
subtitlesVisible: _subtitlesVisible, subtitlesVisible: _subtitlesVisible,
onLoadSeekTimes: () async { onLoadSeekTimes: () async {
if (mounted) { if (mounted) {
@@ -1921,7 +1928,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
child: Builder( child: Builder(
builder: (context) { builder: (context) {
final playbackState = context.watch<PlaybackStateProvider>(); final playbackState = context.watch<PlaybackStateProvider>();
final hasStripContent = _chapters.isNotEmpty || playbackState.isQueueActive; final hasStripContent =
_chapters.isNotEmpty || playbackState.isQueueActive;
return MobileVideoControls( return MobileVideoControls(
player: widget.player, player: widget.player,
metadata: widget.metadata, metadata: widget.metadata,
@@ -1950,7 +1958,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
liveChannelName: widget.liveChannelName, liveChannelName: widget.liveChannelName,
serverId: widget.metadata.serverId, serverId: widget.metadata.serverId,
showQueueTab: playbackState.isQueueActive, showQueueTab: playbackState.isQueueActive,
onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, onQueueItemSelected: playbackState.isQueueActive
? _onQueueItemSelected
: null,
controlsVisible: widget.controlsVisible, controlsVisible: widget.controlsVisible,
onStripVisibilityChanged: (visible) { onStripVisibilityChanged: (visible) {
setState(() => _isContentStripVisible = visible); setState(() => _isContentStripVisible = visible);
@@ -2020,7 +2030,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
} }
Widget _buildDesktopControlsListener() { 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 boxFitMode = widget.player.playerType != 'exoplayer' ? widget.onCycleBoxFitMode : null;
final playbackState = context.watch<PlaybackStateProvider>(); final playbackState = context.watch<PlaybackStateProvider>();
@@ -2060,6 +2072,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
onSwitchVersion: _switchMediaVersion, onSwitchVersion: _switchMediaVersion,
onAudioTrackChanged: widget.onAudioTrackChanged, onAudioTrackChanged: widget.onAudioTrackChanged,
onSubtitleTrackChanged: _onSubtitleTrackChanged, onSubtitleTrackChanged: _onSubtitleTrackChanged,
onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged,
subtitlesVisible: _subtitlesVisible, subtitlesVisible: _subtitlesVisible,
onLoadSeekTimes: () async { onLoadSeekTimes: () async {
if (mounted) { if (mounted) {
@@ -43,6 +43,7 @@ class TrackChapterControls extends StatelessWidget {
final Function(int)? onSwitchVersion; final Function(int)? onSwitchVersion;
final Function(AudioTrack)? onAudioTrackChanged; final Function(AudioTrack)? onAudioTrackChanged;
final Function(SubtitleTrack)? onSubtitleTrackChanged; final Function(SubtitleTrack)? onSubtitleTrackChanged;
final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged;
final VoidCallback? onLoadSeekTimes; final VoidCallback? onLoadSeekTimes;
final VoidCallback? onCancelAutoHide; final VoidCallback? onCancelAutoHide;
final VoidCallback? onStartAutoHide; final VoidCallback? onStartAutoHide;
@@ -106,6 +107,7 @@ class TrackChapterControls extends StatelessWidget {
this.onSwitchVersion, this.onSwitchVersion,
this.onAudioTrackChanged, this.onAudioTrackChanged,
this.onSubtitleTrackChanged, this.onSubtitleTrackChanged,
this.onSecondarySubtitleTrackChanged,
this.onLoadSeekTimes, this.onLoadSeekTimes,
this.onCancelAutoHide, this.onCancelAutoHide,
this.onStartAutoHide, this.onStartAutoHide,
@@ -219,25 +221,27 @@ class TrackChapterControls extends StatelessWidget {
isDesktop: isDesktop, isDesktop: isDesktop,
onPressed: () { onPressed: () {
onCancelAutoHide?.call(); onCancelAutoHide?.call();
OverlaySheetController.of(context).show( OverlaySheetController.of(context)
builder: (_) => VideoSettingsSheet( .show(
player: player, builder: (_) => VideoSettingsSheet(
audioSyncOffset: audioSyncOffset, player: player,
subtitleSyncOffset: subtitleSyncOffset, audioSyncOffset: audioSyncOffset,
canControl: canControl, subtitleSyncOffset: subtitleSyncOffset,
isLive: isLive, canControl: canControl,
shaderService: shaderService, isLive: isLive,
onShaderChanged: onShaderChanged, shaderService: shaderService,
isAmbientLightingEnabled: isAmbientLightingEnabled, onShaderChanged: onShaderChanged,
onToggleAmbientLighting: onToggleAmbientLighting, isAmbientLightingEnabled: isAmbientLightingEnabled,
onCancelAutoHide: onCancelAutoHide, onToggleAmbientLighting: onToggleAmbientLighting,
onStartAutoHide: onStartAutoHide, onCancelAutoHide: onCancelAutoHide,
onSyncOffsetChanged: onSyncOffsetChanged, onStartAutoHide: onStartAutoHide,
), onSyncOffsetChanged: onSyncOffsetChanged,
).whenComplete(() { ),
onStartAutoHide?.call(); )
onLoadSeekTimes?.call(); .whenComplete(() {
}); onStartAutoHide?.call();
onLoadSeekTimes?.call();
});
}, },
); );
}, },
@@ -266,13 +270,16 @@ class TrackChapterControls extends StatelessWidget {
isDesktop: isDesktop, isDesktop: isDesktop,
onPressed: () { onPressed: () {
onCancelAutoHide?.call(); onCancelAutoHide?.call();
OverlaySheetController.of(context).show( OverlaySheetController.of(context)
builder: (_) => TrackSheet( .show(
player: player, builder: (_) => TrackSheet(
onAudioTrackChanged: onAudioTrackChanged, player: player,
onSubtitleTrackChanged: onSubtitleTrackChanged, onAudioTrackChanged: onAudioTrackChanged,
), onSubtitleTrackChanged: onSubtitleTrackChanged,
).whenComplete(() => onStartAutoHide?.call()); onSecondarySubtitleTrackChanged: onSecondarySubtitleTrackChanged,
),
)
.whenComplete(() => onStartAutoHide?.call());
}, },
), ),
); );
@@ -293,14 +300,16 @@ class TrackChapterControls extends StatelessWidget {
isDesktop: isDesktop, isDesktop: isDesktop,
onPressed: () { onPressed: () {
onCancelAutoHide?.call(); onCancelAutoHide?.call();
OverlaySheetController.of(context).show( OverlaySheetController.of(context)
builder: (_) => ChapterSheet( .show(
player: player, builder: (_) => ChapterSheet(
chapters: chapters, player: player,
chaptersLoaded: chaptersLoaded, chapters: chapters,
serverId: serverId, chaptersLoaded: chaptersLoaded,
), serverId: serverId,
).whenComplete(() => onStartAutoHide?.call()); ),
)
.whenComplete(() => onStartAutoHide?.call());
}, },
), ),
); );
@@ -321,9 +330,9 @@ class TrackChapterControls extends StatelessWidget {
isDesktop: isDesktop, isDesktop: isDesktop,
onPressed: () { onPressed: () {
onCancelAutoHide?.call(); onCancelAutoHide?.call();
OverlaySheetController.of(context).show( OverlaySheetController.of(context)
builder: (_) => QueueSheet(onItemSelected: onQueueItemSelected!), .show(builder: (_) => QueueSheet(onItemSelected: onQueueItemSelected!))
).whenComplete(() => onStartAutoHide?.call()); .whenComplete(() => onStartAutoHide?.call());
}, },
), ),
); );
@@ -344,13 +353,15 @@ class TrackChapterControls extends StatelessWidget {
isDesktop: isDesktop, isDesktop: isDesktop,
onPressed: () { onPressed: () {
onCancelAutoHide?.call(); onCancelAutoHide?.call();
OverlaySheetController.of(context).show( OverlaySheetController.of(context)
builder: (_) => VersionSheet( .show(
availableVersions: availableVersions, builder: (_) => VersionSheet(
selectedMediaIndex: selectedMediaIndex, availableVersions: availableVersions,
onVersionSelected: onSwitchVersion!, selectedMediaIndex: selectedMediaIndex,
), onVersionSelected: onSwitchVersion!,
).whenComplete(() => onStartAutoHide?.call()); ),
)
.whenComplete(() => onStartAutoHide?.call());
}, },
), ),
); );