@@ -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
|
||||
|
||||
+17
-4
@@ -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.
|
||||
|
||||
@@ -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<SubtitleTrack?>().firstWhere(
|
||||
(t) => t?.id == storedId,
|
||||
orElse: () => null,
|
||||
);
|
||||
(t) => t?.id == storedId,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (track != null) {
|
||||
await selectSubtitleTrack(track);
|
||||
}
|
||||
|
||||
@@ -90,6 +90,15 @@ abstract class Player {
|
||||
/// Pass [SubtitleTrack.off] to disable subtitles.
|
||||
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.
|
||||
///
|
||||
/// [uri] - URL or path to the subtitle file.
|
||||
|
||||
@@ -103,11 +103,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
Future<void> 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>()
|
||||
.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<AudioDevice?>().firstWhere(
|
||||
final device =
|
||||
_state.audioDevices.cast<AudioDevice?>().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<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.
|
||||
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<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
|
||||
// ignore: no-empty-block - base no-op, overridden by platform subclasses
|
||||
Future<void> setAudioPassthrough(bool enabled) async {}
|
||||
|
||||
@@ -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<void> selectSecondarySubtitleTrack(SubtitleTrack track) async {
|
||||
checkDisposed();
|
||||
await setProperty('secondary-sid', track.id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async {
|
||||
checkDisposed();
|
||||
|
||||
@@ -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<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin
|
||||
final shaderProvider = context.read<ShaderProvider>();
|
||||
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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin
|
||||
metadata: episodeMetadata,
|
||||
preferredAudioTrack: currentAudioTrack,
|
||||
preferredSubtitleTrack: currentSubtitleTrack,
|
||||
preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack,
|
||||
usePushReplacement: true,
|
||||
isOffline: widget.isOffline,
|
||||
);
|
||||
@@ -2604,14 +2616,15 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> 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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -753,6 +753,7 @@ class TrackSelectionService {
|
||||
Future<void> 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);
|
||||
|
||||
@@ -38,6 +38,7 @@ Future<bool?> navigateToVideoPlayer(
|
||||
required PlexMetadata metadata,
|
||||
AudioTrack? preferredAudioTrack,
|
||||
SubtitleTrack? preferredSubtitleTrack,
|
||||
SubtitleTrack? preferredSecondarySubtitleTrack,
|
||||
int? selectedMediaIndex,
|
||||
bool usePushReplacement = false,
|
||||
bool isOffline = false,
|
||||
@@ -115,6 +116,7 @@ Future<bool?> navigateToVideoPlayer(
|
||||
metadata: metadata,
|
||||
preferredAudioTrack: preferredAudioTrack,
|
||||
preferredSubtitleTrack: preferredSubtitleTrack,
|
||||
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
|
||||
selectedMediaIndex: mediaIndex,
|
||||
isOffline: isOffline,
|
||||
playbackData: effectivePlaybackData,
|
||||
@@ -147,6 +149,7 @@ Future<bool?> navigateToVideoPlayerWithRefresh(
|
||||
VoidCallback? onRefresh,
|
||||
AudioTrack? preferredAudioTrack,
|
||||
SubtitleTrack? preferredSubtitleTrack,
|
||||
SubtitleTrack? preferredSecondarySubtitleTrack,
|
||||
int? selectedMediaIndex,
|
||||
bool usePushReplacement = false,
|
||||
PlexVideoPlaybackData? playbackData,
|
||||
@@ -157,6 +160,7 @@ Future<bool?> navigateToVideoPlayerWithRefresh(
|
||||
isOffline: isOffline,
|
||||
preferredAudioTrack: preferredAudioTrack,
|
||||
preferredSubtitleTrack: preferredSubtitleTrack,
|
||||
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
|
||||
selectedMediaIndex: selectedMediaIndex,
|
||||
usePushReplacement: usePushReplacement,
|
||||
playbackData: playbackData,
|
||||
|
||||
@@ -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<DesktopVideoControls> {
|
||||
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<DesktopVideoControls> {
|
||||
onSwitchVersion: widget.onSwitchVersion,
|
||||
onAudioTrackChanged: widget.onAudioTrackChanged,
|
||||
onSubtitleTrackChanged: widget.onSubtitleTrackChanged,
|
||||
onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged,
|
||||
onLoadSeekTimes: widget.onLoadSeekTimes,
|
||||
onCancelAutoHide: widget.onCancelAutoHide,
|
||||
onStartAutoHide: widget.onStartAutoHide,
|
||||
|
||||
@@ -20,9 +20,7 @@ class TrackSelectionHelper {
|
||||
|
||||
/// Build a centered empty state widget
|
||||
static Widget buildEmptyState<T>() {
|
||||
return Center(
|
||||
child: Text(getEmptyMessage<T>()),
|
||||
);
|
||||
return Center(child: Text(getEmptyMessage<T>()));
|
||||
}
|
||||
|
||||
/// 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<T>({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<T>({
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AudioTrack>(
|
||||
tracks,
|
||||
(t) => t?.audio ?? [],
|
||||
);
|
||||
final audioTracks = TrackFilterHelper.extractAndFilterTracks<AudioTrack>(tracks, (t) => t?.audio ?? []);
|
||||
final subtitleTracks = TrackFilterHelper.extractAndFilterTracks<SubtitleTrack>(
|
||||
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<SubtitleTrack>(
|
||||
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<SubtitleTrack>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<PlexVideoControls> 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<PlexVideoControls> with WindowListen
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final playbackState = context.watch<PlaybackStateProvider>();
|
||||
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<PlexVideoControls> 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<PlexVideoControls> 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<PlaybackStateProvider>();
|
||||
|
||||
@@ -2060,6 +2072,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
onSwitchVersion: _switchMediaVersion,
|
||||
onAudioTrackChanged: widget.onAudioTrackChanged,
|
||||
onSubtitleTrackChanged: _onSubtitleTrackChanged,
|
||||
onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged,
|
||||
subtitlesVisible: _subtitlesVisible,
|
||||
onLoadSeekTimes: () async {
|
||||
if (mounted) {
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user