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
/// 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
View File
@@ -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.
+6 -3
View File
@@ -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);
}
+9
View File
@@ -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.
+36 -14
View 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 {}
+7
View File
@@ -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();
+22 -8
View File
@@ -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,
);
}
+15
View File
@@ -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);
+4
View File
@@ -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),
),
),
);
+17 -4
View File
@@ -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());
},
),
);