@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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());
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user