fix(plex): support no-burn transcode subtitles
This commit is contained in:
@@ -151,15 +151,30 @@ class MediaSubtitleTrack with _TrackLabelMixin {
|
||||
});
|
||||
|
||||
String get label {
|
||||
return labelForIndex(_fallbackLabelIndex);
|
||||
}
|
||||
|
||||
String labelForIndex(int visibleIndex) {
|
||||
return TrackLabelBuilder.buildSubtitleLabel(
|
||||
title: displayTitle ?? title,
|
||||
title: _labelTitle,
|
||||
language: languageCode ?? language,
|
||||
codec: codec,
|
||||
forced: forced,
|
||||
index: (index ?? id) - 1,
|
||||
index: visibleIndex,
|
||||
);
|
||||
}
|
||||
|
||||
String? get _labelTitle {
|
||||
final explicitTitle = title;
|
||||
if (explicitTitle != null && explicitTitle.trim().isNotEmpty) return explicitTitle;
|
||||
return displayTitle;
|
||||
}
|
||||
|
||||
int get _fallbackLabelIndex {
|
||||
final streamIndex = index ?? id;
|
||||
return streamIndex > 0 ? streamIndex - 1 : 0;
|
||||
}
|
||||
|
||||
/// Returns true if this subtitle track is an external file (sidecar subtitle).
|
||||
/// Some backends provide a direct key/URL, others require constructing one
|
||||
/// from stream metadata.
|
||||
|
||||
@@ -97,10 +97,13 @@ class PlayerAndroid extends PlayerBase {
|
||||
bool play = true,
|
||||
bool isLive = false,
|
||||
List<SubtitleTrack>? externalSubtitles,
|
||||
Duration timelineOffset = Duration.zero,
|
||||
Duration? timelineDuration,
|
||||
}) async {
|
||||
if (disposed) return;
|
||||
await _ensureInitialized();
|
||||
final startPosition = media.start ?? Duration.zero;
|
||||
configureTimeline(offset: timelineOffset, duration: timelineDuration);
|
||||
clearTracks();
|
||||
resetPlaybackProgress(startPosition);
|
||||
setSeekable(false);
|
||||
@@ -141,7 +144,8 @@ class PlayerAndroid extends PlayerBase {
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) async {
|
||||
await runSeek(position, () => invoke('seek', {'positionMs': position.inMilliseconds}));
|
||||
final sourcePosition = sourceSeekPosition(position);
|
||||
await runSeek(position, () => invoke('seek', {'positionMs': sourcePosition.inMilliseconds}));
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -58,7 +58,14 @@ abstract class Player {
|
||||
///
|
||||
/// [media] - The media source to open.
|
||||
/// [play] - Whether to start playback immediately (default: true).
|
||||
Future<void> open(Media media, {bool play = true, bool isLive = false, List<SubtitleTrack>? externalSubtitles});
|
||||
Future<void> open(
|
||||
Media media, {
|
||||
bool play = true,
|
||||
bool isLive = false,
|
||||
List<SubtitleTrack>? externalSubtitles,
|
||||
Duration timelineOffset = Duration.zero,
|
||||
Duration? timelineDuration,
|
||||
});
|
||||
|
||||
/// Start or resume playback.
|
||||
Future<void> play();
|
||||
|
||||
@@ -46,6 +46,8 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
int _lastEmitMs = 0;
|
||||
int _lastCacheStateMs = 0;
|
||||
int _positionMs = 0;
|
||||
Duration _timelineOffset = Duration.zero;
|
||||
Duration? _timelineDuration;
|
||||
int _nextPropId = 0;
|
||||
final Map<int, String> _propIdToName = {};
|
||||
|
||||
@@ -142,13 +144,13 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
|
||||
case 'time-pos':
|
||||
if (value is num) {
|
||||
_positionMs = (value * 1000).round();
|
||||
final pos = _toTimelinePosition(Duration(milliseconds: (value * 1000).round()));
|
||||
_positionMs = pos.inMilliseconds;
|
||||
// Only allocate Duration + copyWith + emit at ~4Hz (250ms).
|
||||
// Raw int is stored every tick so synchronous reads via _positionMs stay current.
|
||||
final nowMs = _throttleSw.elapsedMilliseconds;
|
||||
if (nowMs - _lastEmitMs >= 250) {
|
||||
_lastEmitMs = nowMs;
|
||||
final pos = Duration(milliseconds: _positionMs);
|
||||
_state = _state.copyWith(position: pos);
|
||||
positionController.add(pos);
|
||||
}
|
||||
@@ -157,7 +159,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
|
||||
case 'duration':
|
||||
if (value is num) {
|
||||
final duration = Duration(milliseconds: (value * 1000).toInt());
|
||||
final duration = _timelineDuration ?? _toTimelinePosition(Duration(milliseconds: (value * 1000).toInt()));
|
||||
_state = _state.copyWith(duration: duration);
|
||||
durationController.add(duration);
|
||||
}
|
||||
@@ -174,7 +176,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
final nowMs = _throttleSw.elapsedMilliseconds;
|
||||
if (nowMs - _lastCacheStateMs < 250) break;
|
||||
_lastCacheStateMs = nowMs;
|
||||
final buffer = Duration(milliseconds: (value * 1000).toInt());
|
||||
final buffer = _toTimelinePosition(Duration(milliseconds: (value * 1000).toInt()));
|
||||
_state = _state.copyWith(buffer: buffer);
|
||||
bufferController.add(buffer);
|
||||
// Synthesize a single range for players without demuxer-cache-state (ExoPlayer).
|
||||
@@ -295,7 +297,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
// Extract cache-end for the single buffer duration (replaces demuxer-cache-time)
|
||||
final cacheEnd = cacheState['cache-end'] as num?;
|
||||
if (cacheEnd != null) {
|
||||
final buffer = Duration(milliseconds: (cacheEnd * 1000).toInt());
|
||||
final buffer = _toTimelinePosition(Duration(milliseconds: (cacheEnd * 1000).toInt()));
|
||||
_state = _state.copyWith(buffer: buffer);
|
||||
bufferController.add(buffer);
|
||||
}
|
||||
@@ -311,8 +313,8 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
if (start != null && end != null) {
|
||||
ranges.add(
|
||||
BufferRange(
|
||||
start: Duration(milliseconds: (start * 1000).toInt()),
|
||||
end: Duration(milliseconds: (end * 1000).toInt()),
|
||||
start: _toTimelinePosition(Duration(milliseconds: (start * 1000).toInt())),
|
||||
end: _toTimelinePosition(Duration(milliseconds: (end * 1000).toInt())),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -491,18 +493,35 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
}
|
||||
|
||||
@protected
|
||||
void resetPlaybackProgress(Duration position) {
|
||||
void configureTimeline({Duration offset = Duration.zero, Duration? duration}) {
|
||||
_timelineOffset = offset;
|
||||
_timelineDuration = duration;
|
||||
}
|
||||
|
||||
@protected
|
||||
Duration sourceSeekPosition(Duration timelinePosition) {
|
||||
final sourcePosition = timelinePosition - _timelineOffset;
|
||||
return sourcePosition.isNegative ? Duration.zero : sourcePosition;
|
||||
}
|
||||
|
||||
Duration _toTimelinePosition(Duration sourcePosition) {
|
||||
return sourcePosition + _timelineOffset;
|
||||
}
|
||||
|
||||
@protected
|
||||
void resetPlaybackProgress(Duration sourcePosition) {
|
||||
final position = _toTimelinePosition(sourcePosition);
|
||||
_positionMs = position.inMilliseconds;
|
||||
_state = _state.copyWith(
|
||||
completed: false,
|
||||
position: position,
|
||||
duration: Duration.zero,
|
||||
duration: _timelineDuration ?? Duration.zero,
|
||||
buffer: Duration.zero,
|
||||
bufferRanges: const [],
|
||||
);
|
||||
completedController.add(false);
|
||||
positionController.add(position);
|
||||
durationController.add(Duration.zero);
|
||||
durationController.add(_timelineDuration ?? Duration.zero);
|
||||
bufferController.add(Duration.zero);
|
||||
bufferRangesController.add(const []);
|
||||
}
|
||||
|
||||
@@ -102,10 +102,13 @@ class PlayerNative extends PlayerBase {
|
||||
bool play = true,
|
||||
bool isLive = false,
|
||||
List<SubtitleTrack>? externalSubtitles,
|
||||
Duration timelineOffset = Duration.zero,
|
||||
Duration? timelineDuration,
|
||||
}) async {
|
||||
if (disposed) return;
|
||||
await _ensureInitialized();
|
||||
final startPosition = media.start ?? Duration.zero;
|
||||
configureTimeline(offset: timelineOffset, duration: timelineDuration);
|
||||
clearTracks();
|
||||
resetPlaybackProgress(startPosition);
|
||||
setSeekable(false);
|
||||
@@ -160,7 +163,8 @@ class PlayerNative extends PlayerBase {
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) async {
|
||||
await runSeek(position, () => command(['seek', (position.inMilliseconds / 1000.0).toString(), 'absolute']));
|
||||
final sourcePosition = sourceSeekPosition(position);
|
||||
await runSeek(position, () => command(['seek', (sourcePosition.inMilliseconds / 1000.0).toString(), 'absolute']));
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -46,6 +46,25 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
||||
});
|
||||
}
|
||||
|
||||
int? _selectedSourceSubtitleStreamId(List<MediaSubtitleTrack> tracks) {
|
||||
if (tracks.isEmpty) return null;
|
||||
for (final track in tracks) {
|
||||
if (track.selected) return track.id;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
List<MediaSubtitleTrack> _sourceSubtitleTracksForControls() {
|
||||
final tracks = _currentMediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[];
|
||||
if (!_isTranscoding) return tracks;
|
||||
return tracks
|
||||
.where((track) {
|
||||
final hasKey = track.key != null && track.key!.isNotEmpty;
|
||||
return hasKey || CodecUtils.isTextSubtitleCodec(track.codec);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Widget _buildLoadingSpinner() {
|
||||
return const Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
@@ -201,6 +220,9 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
||||
onPrevious = (canRestartOrPrevious && _canNavigateEpisodes()) ? _restartOrPlayPrevious : null;
|
||||
}
|
||||
|
||||
final sourceAudioTracks = _currentMediaInfo?.audioTracks ?? const <MediaAudioTrack>[];
|
||||
final sourceSubtitleTracks = _sourceSubtitleTracksForControls();
|
||||
|
||||
return Video(
|
||||
player: player!,
|
||||
controls: (context) => plexVideoControlsBuilder(
|
||||
@@ -215,8 +237,11 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
||||
serverSupportsTranscoding: _serverSupportsTranscoding,
|
||||
isTranscoding: _isTranscoding,
|
||||
isOfflinePlayback: _isOfflinePlayback,
|
||||
sourceAudioTracks: _currentMediaInfo?.audioTracks ?? const [],
|
||||
sourceAudioTracks: sourceAudioTracks,
|
||||
selectedAudioStreamId: _selectedAudioStreamId,
|
||||
sourceSubtitleTracks: sourceSubtitleTracks,
|
||||
selectedSubtitleStreamId: _selectedSourceSubtitleStreamId(sourceSubtitleTracks),
|
||||
sourcePartId: _currentMediaInfo?.partId,
|
||||
onTogglePIPMode: _togglePIPMode,
|
||||
boxFitMode: _videoFilterManager?.boxFitMode ?? 0,
|
||||
onCycleBoxFitMode: _cycleBoxFitMode,
|
||||
@@ -225,6 +250,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
||||
onAudioTrackChanged: _onAudioTrackChanged,
|
||||
onSubtitleTrackChanged: _onSubtitleTrackChanged,
|
||||
onSecondarySubtitleTrackChanged: _onSecondarySubtitleTrackChanged,
|
||||
onSeekRequested: _seekPlayback,
|
||||
onSeekCompleted: _notifyWatchTogetherSeek,
|
||||
onBack: _handleBackButton,
|
||||
onReachedEnd: ({skipAutoPlayCountdown = false}) =>
|
||||
|
||||
@@ -21,7 +21,7 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState {
|
||||
return;
|
||||
}
|
||||
final target = clampSeekPosition(player!, player!.state.position + Duration(seconds: seekSeconds));
|
||||
await player!.seek(target);
|
||||
await _seekPlayback(target);
|
||||
};
|
||||
receiver.onSeekBackward = () async {
|
||||
if (player == null) return;
|
||||
@@ -32,7 +32,7 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState {
|
||||
return;
|
||||
}
|
||||
final target = clampSeekPosition(player!, player!.state.position - Duration(seconds: seekSeconds));
|
||||
await player!.seek(target);
|
||||
await _seekPlayback(target);
|
||||
};
|
||||
receiver.onVolumeUp = () async {
|
||||
if (player == null) return;
|
||||
|
||||
@@ -48,7 +48,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
});
|
||||
|
||||
final target = clampSeekPosition(currentPlayer, Duration.zero);
|
||||
await currentPlayer.seek(target);
|
||||
await _seekPlayback(target);
|
||||
if (!mounted || currentPlayer != player) return;
|
||||
|
||||
_notifyWatchTogetherSeek(target);
|
||||
@@ -216,10 +216,17 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
await currentPlayer.setDisplayCriteria(
|
||||
!result.isTranscoding && displayCriteria?.canPrimeNativeDisplayCriteria == true ? displayCriteria : null,
|
||||
);
|
||||
final transcodeTimelineOffset = result.isTranscoding ? resumePosition ?? Duration.zero : Duration.zero;
|
||||
final transcodeTimelineDuration = result.isTranscoding && episodeMetadata.durationMs != null
|
||||
? Duration(milliseconds: episodeMetadata.durationMs!)
|
||||
: null;
|
||||
await currentPlayer.setProperty('force-seekable', result.isTranscoding ? 'yes' : 'no');
|
||||
await currentPlayer.open(
|
||||
Media(result.videoUrl!, start: resumePosition, headers: streamHeaders),
|
||||
Media(result.videoUrl!, start: result.isTranscoding ? null : resumePosition, headers: streamHeaders),
|
||||
play: isExoPlayer || !hasExternalSubs,
|
||||
externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null,
|
||||
timelineOffset: transcodeTimelineOffset,
|
||||
timelineDuration: transcodeTimelineDuration,
|
||||
);
|
||||
|
||||
_completionTriggered = false;
|
||||
|
||||
@@ -79,7 +79,7 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState {
|
||||
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
final canNavigateEpisodes = _currentMetadata.isEpisode || playbackState.isPlaylistActive;
|
||||
final canSeek = !widget.isLive && currentPlayer.state.seekable;
|
||||
final canSeek = !widget.isLive && (currentPlayer.state.seekable || _shouldRestartPlexTranscodeForSeek);
|
||||
|
||||
if (!mounted || currentPlayer != player || manager != _mediaControlsManager) return;
|
||||
|
||||
@@ -93,7 +93,7 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState {
|
||||
Future<void> _seekBackForRewind(Player p) async {
|
||||
if (_rewindOnResume <= 0) return;
|
||||
final target = p.state.position - Duration(seconds: _rewindOnResume);
|
||||
await p.seek(clampSeekPosition(p, target));
|
||||
await _seekPlayback(clampSeekPosition(p, target));
|
||||
}
|
||||
|
||||
Future<void> _restoreMediaControlsAfterResume() async {
|
||||
|
||||
@@ -131,7 +131,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
_updateMediaControlsPlaybackState();
|
||||
} else if (event is SeekEvent) {
|
||||
appLogger.d('Media control: Seek event received to ${event.position}');
|
||||
unawaited(activePlayer!.seek(clampSeekPosition(activePlayer, event.position)));
|
||||
unawaited(_seekPlayback(clampSeekPosition(activePlayer!, event.position)));
|
||||
} else if (event is NextTrackEvent) {
|
||||
appLogger.d('Media control: Next track event received');
|
||||
if (_nextEpisode != null) _playNext();
|
||||
@@ -246,6 +246,6 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
if (_completionTriggered && !_showPlayNextDialog && _autoPlayTimer?.isActive != true) {
|
||||
_completionTriggered = false;
|
||||
}
|
||||
p.seek(pos);
|
||||
unawaited(_seekPlayback(pos));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
}
|
||||
|
||||
_livePlaybackStartTime = DateTime.now();
|
||||
await currentPlayer.setProperty('force-seekable', 'no');
|
||||
await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
|
||||
@@ -305,10 +306,21 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
// ExoPlayer: attach external subs at open time so it discovers
|
||||
// them in a single prepare() — no media reload needed for selection.
|
||||
// MPV (all platforms including Android): external subs added after open via sub-add.
|
||||
final transcodeTimelineOffset = result.isTranscoding ? resumePosition ?? Duration.zero : Duration.zero;
|
||||
final transcodeTimelineDuration = result.isTranscoding && _currentMetadata.durationMs != null
|
||||
? Duration(milliseconds: _currentMetadata.durationMs!)
|
||||
: null;
|
||||
// Plex's chunked HTTP/MKV transcode can be seekable even when MPV
|
||||
// cannot prove it from response headers. Force only for that path and
|
||||
// reset everywhere else so live/direct/offline streams keep native
|
||||
// seekability detection.
|
||||
await currentPlayer.setProperty('force-seekable', result.isTranscoding ? 'yes' : 'no');
|
||||
await currentPlayer.open(
|
||||
Media(result.videoUrl!, start: resumePosition, headers: streamHeaders),
|
||||
Media(result.videoUrl!, start: result.isTranscoding ? null : resumePosition, headers: streamHeaders),
|
||||
play: shouldAutoPlay,
|
||||
externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null,
|
||||
timelineOffset: transcodeTimelineOffset,
|
||||
timelineDuration: transcodeTimelineDuration,
|
||||
);
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
part of '../../video_player_screen.dart';
|
||||
|
||||
extension _VideoPlayerSeekingMethods on VideoPlayerScreenState {
|
||||
Future<void> _seekPlayback(Duration position) async {
|
||||
final currentPlayer = player;
|
||||
if (!mounted || currentPlayer == null) return;
|
||||
|
||||
final target = clampSeekPosition(currentPlayer, position);
|
||||
if (!_shouldRestartPlexTranscodeForSeek) {
|
||||
await currentPlayer.seek(target);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_canSeekWithinCurrentTranscodeBuffer(currentPlayer, target)) {
|
||||
await currentPlayer.seek(target);
|
||||
return;
|
||||
}
|
||||
|
||||
await _restartPlexTranscodeAt(target);
|
||||
}
|
||||
|
||||
bool get _shouldRestartPlexTranscodeForSeek {
|
||||
return _isTranscoding &&
|
||||
!widget.isLive &&
|
||||
!_isOfflinePlayback &&
|
||||
_currentMetadata.backend == MediaBackend.plex &&
|
||||
_selectedQualityPreset != TranscodeQualityPreset.original;
|
||||
}
|
||||
|
||||
bool _canSeekWithinCurrentTranscodeBuffer(Player currentPlayer, Duration target) {
|
||||
const edgeTolerance = Duration(milliseconds: 500);
|
||||
final targetMs = target.inMilliseconds;
|
||||
for (final range in currentPlayer.state.bufferRanges) {
|
||||
final startMs = range.start.inMilliseconds - edgeTolerance.inMilliseconds;
|
||||
final endMs = range.end.inMilliseconds - edgeTolerance.inMilliseconds;
|
||||
if (targetMs >= startMs && targetMs <= endMs) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _restartPlexTranscodeAt(Duration target) async {
|
||||
if (_isRestartingTranscodeSeek) return;
|
||||
|
||||
appLogger.d('Restarting Plex transcode at ${target.inSeconds}s');
|
||||
_isRestartingTranscodeSeek = true;
|
||||
_controlsVisible.value = true;
|
||||
|
||||
final currentPlayer = player;
|
||||
if (currentPlayer == null) {
|
||||
_isRestartingTranscodeSeek = false;
|
||||
return;
|
||||
}
|
||||
|
||||
final replacementMetadata = _currentMetadata.copyWith(viewOffsetMs: target.inMilliseconds);
|
||||
final wasPlaying = currentPlayer.state.playing;
|
||||
final nextTranscodeSessionId = generateSessionIdentifier();
|
||||
|
||||
try {
|
||||
final mediaClient = _getMediaServerClient(context);
|
||||
if (mediaClient == null) {
|
||||
throw StateError('No client registered for ${replacementMetadata.serverId}');
|
||||
}
|
||||
|
||||
_playbackTranscodeSessionId = nextTranscodeSessionId;
|
||||
final playbackService = PlaybackInitializationService(client: mediaClient, database: context.read<AppDatabase>());
|
||||
final result = await playbackService.getPlaybackData(
|
||||
metadata: replacementMetadata,
|
||||
selectedMediaIndex: widget.selectedMediaIndex,
|
||||
selectedMediaSourceId: widget.selectedMediaSourceId,
|
||||
preferOffline: false,
|
||||
qualityPreset: _selectedQualityPreset,
|
||||
selectedAudioStreamId: _selectedAudioStreamId,
|
||||
sessionIdentifier: _playbackSessionIdentifier,
|
||||
transcodeSessionId: _playbackTranscodeSessionId,
|
||||
);
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
if (result.videoUrl == null) {
|
||||
throw PlaybackException(t.messages.fileInfoNotAvailable);
|
||||
}
|
||||
|
||||
_currentMetadata = replacementMetadata;
|
||||
_isTranscoding = result.isTranscoding;
|
||||
_effectiveIsOffline = result.isOffline;
|
||||
_playbackPlaySessionId = result.playSessionId;
|
||||
_playbackPlayMethod = result.playMethod;
|
||||
_selectedAudioStreamId = result.activeAudioStreamId;
|
||||
_availableVersions = result.availableVersions;
|
||||
_currentMediaInfo = result.mediaInfo;
|
||||
|
||||
final isExoPlayer = currentPlayer is PlayerAndroid;
|
||||
final hasExternalSubs = result.externalSubtitles.isNotEmpty;
|
||||
final shouldAutoPlay = wasPlaying && (isExoPlayer || !hasExternalSubs);
|
||||
final timelineDuration = _currentMetadata.durationMs != null
|
||||
? Duration(milliseconds: _currentMetadata.durationMs!)
|
||||
: null;
|
||||
|
||||
await currentPlayer.setProperty('force-seekable', result.isTranscoding ? 'yes' : 'no');
|
||||
await currentPlayer.open(
|
||||
Media(result.videoUrl!, start: result.isTranscoding ? null : target, headers: _streamHeaders),
|
||||
play: shouldAutoPlay,
|
||||
externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null,
|
||||
timelineOffset: result.isTranscoding ? target : Duration.zero,
|
||||
timelineDuration: result.isTranscoding ? timelineDuration : null,
|
||||
);
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
|
||||
_setPlayerState(() {});
|
||||
|
||||
final trackManager = _trackManager;
|
||||
if (trackManager != null) {
|
||||
trackManager.metadata = _currentMetadata;
|
||||
trackManager.mediaInfo = _currentMediaInfo;
|
||||
trackManager.cacheExternalSubtitles(result.externalSubtitles);
|
||||
if (currentPlayer is! PlayerAndroid && result.externalSubtitles.isNotEmpty) {
|
||||
trackManager.waitingForExternalSubsTrackSelection = true;
|
||||
await trackManager.addExternalSubtitles(result.externalSubtitles);
|
||||
if (wasPlaying && mounted && player == currentPlayer) {
|
||||
await trackManager.resumeAfterSubtitleLoad();
|
||||
} else {
|
||||
trackManager.waitingForExternalSubsTrackSelection = false;
|
||||
trackManager.applyTrackSelectionWhenReady();
|
||||
}
|
||||
} else {
|
||||
trackManager.applyTrackSelectionWhenReady();
|
||||
}
|
||||
}
|
||||
|
||||
_updateMediaControlsPlaybackState();
|
||||
} catch (e, st) {
|
||||
appLogger.w('Failed to restart Plex transcode at ${target.inSeconds}s', error: e, stackTrace: st);
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
||||
}
|
||||
} finally {
|
||||
_isRestartingTranscodeSeek = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ import '../services/shader_service.dart';
|
||||
import '../providers/shader_provider.dart';
|
||||
import '../providers/user_profile_provider.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/codec_utils.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import '../utils/log_redaction_manager.dart';
|
||||
import '../utils/live_tv_player_navigation.dart';
|
||||
@@ -92,6 +93,7 @@ part 'video_player/parts/shader.dart';
|
||||
part 'video_player/parts/playback_prompts.dart';
|
||||
part 'video_player/parts/playback_services.dart';
|
||||
part 'video_player/parts/playback_start.dart';
|
||||
part 'video_player/parts/seeking.dart';
|
||||
part 'video_player/parts/build.dart';
|
||||
part 'video_player/parts/watch_together.dart';
|
||||
|
||||
@@ -262,7 +264,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// during otherwise-idle setup time.
|
||||
Future<void>? _audioFocusFuture;
|
||||
late final String _playbackSessionIdentifier;
|
||||
late final String _playbackTranscodeSessionId;
|
||||
late String _playbackTranscodeSessionId;
|
||||
String? _playbackPlaySessionId;
|
||||
String? _playbackPlayMethod;
|
||||
StreamSubscription<PlayerError>? _errorSubscription;
|
||||
@@ -273,6 +275,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
StreamSubscription<Duration>? _positionSubscription;
|
||||
StreamSubscription<void>? _playbackRestartSubscription;
|
||||
StreamSubscription<void>? _backendSwitchedSubscription;
|
||||
bool _isRestartingTranscodeSeek = false;
|
||||
TrackManager? _trackManager;
|
||||
StreamSubscription<PlayerLog>? _logSubscription;
|
||||
StreamSubscription<void>? _sleepTimerSubscription;
|
||||
|
||||
@@ -185,6 +185,7 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
||||
VoidCallback? onScreenshot,
|
||||
int? currentPositionEpoch,
|
||||
ValueChanged<int>? onLiveSeek,
|
||||
Future<void> Function(Duration position)? onSeekRequested,
|
||||
}) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
|
||||
@@ -262,6 +263,7 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
||||
onScreenshot: onScreenshot,
|
||||
currentPositionEpoch: currentPositionEpoch,
|
||||
onLiveSeek: onLiveSeek,
|
||||
onSeekRequested: onSeekRequested,
|
||||
);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
@@ -286,13 +288,14 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
||||
VoidCallback? onScreenshot,
|
||||
int? currentPositionEpoch,
|
||||
ValueChanged<int>? onLiveSeek,
|
||||
Future<void> Function(Duration position)? onSeekRequested,
|
||||
}) {
|
||||
void performSeek(int offsetSeconds) {
|
||||
if (onLiveSeek != null && currentPositionEpoch != null) {
|
||||
onLiveSeek(currentPositionEpoch + offsetSeconds);
|
||||
} else {
|
||||
final target = clampSeekPosition(player, player.state.position + Duration(seconds: offsetSeconds));
|
||||
unawaited(player.seek(target));
|
||||
unawaited((onSeekRequested ?? player.seek)(target));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+199
-121
@@ -1485,7 +1485,7 @@ class PlexClient
|
||||
// deleted-but-still-indexed versions before play.
|
||||
networkCall: () => _http.get(
|
||||
'/library/metadata/$ratingKey',
|
||||
queryParameters: {'includeMarkers': 1, 'includeChapters': 1, 'checkFiles': 1},
|
||||
queryParameters: {'includeMarkers': 1, 'includeChapters': 1, 'checkFiles': 1, 'includeStreams': 1},
|
||||
),
|
||||
parseCache: (cached) => cached as Map<String, dynamic>?,
|
||||
parseResponse: (response) => response.data as Map<String, dynamic>?,
|
||||
@@ -2830,9 +2830,9 @@ class PlexClient
|
||||
/// Build a VOD transcode stream URL (decision + start path).
|
||||
///
|
||||
/// Mirrors [buildLiveStreamPath] but for on-demand video with a quality
|
||||
/// preset, selected audio stream, and HLS protocol. Plex returns a single
|
||||
/// audio track and no subtitle tracks in the transcoded stream — callers
|
||||
/// are expected to sidecar additional subtitles separately.
|
||||
/// preset, selected audio stream, and Plex Desktop-style HTTP/MKV output.
|
||||
/// Text subtitles selected on the Plex part are embedded in the MKV stream;
|
||||
/// real external sidecars are still attached separately by callers.
|
||||
///
|
||||
/// [transcodeSessionId] and [sessionIdentifier] should be reused across
|
||||
/// seeks + quality/version/audio switches within one playback so the
|
||||
@@ -2844,96 +2844,20 @@ class PlexClient
|
||||
required String sessionIdentifier,
|
||||
required String transcodeSessionId,
|
||||
int? audioStreamId,
|
||||
MediaSubtitleTrack? selectedSubtitleTrack,
|
||||
int? offsetMs,
|
||||
}) async {
|
||||
try {
|
||||
final isOriginal = preset.isOriginal;
|
||||
final metadataPath = '/library/metadata/$ratingKey';
|
||||
|
||||
// Build the client profile from scratch via X-Plex-Client-Profile-Extra.
|
||||
// We use the `Generic` base platform (see [_transcodePlatformName]) which
|
||||
// has no pre-installed transcode targets, so we must `add-transcode-target`
|
||||
// rather than `append-transcode-target-codec` (which only edits existing
|
||||
// targets — empty on Generic, hence Plex returned decision code 2000
|
||||
// "neither direct play nor conversion is available").
|
||||
//
|
||||
// For non-original presets we also add a bitrate limitation that caps
|
||||
// the video codec; with `replace=true` it overrides any default limit.
|
||||
//
|
||||
// See openapi.md §"Profile Augmentations" for the DSL reference.
|
||||
final profileExtraClauses = <String>[];
|
||||
if (!isOriginal && preset.videoBitrateKbps != null) {
|
||||
profileExtraClauses.add(
|
||||
'add-limitation(scope=videoCodec&scopeName=*&type=upperBound'
|
||||
'&name=video.bitrate&value=${preset.videoBitrateKbps}&replace=true)',
|
||||
);
|
||||
}
|
||||
// Declare both h264 and hevc as allowed transcode targets. In practice
|
||||
// Plex's decision engine strongly prefers h264 for HLS output, so hevc
|
||||
// only gets chosen in edge cases (e.g. HDR content where the server
|
||||
// wants to preserve dynamic range). The codec-list comma is pre-encoded
|
||||
// as `%2C` — see the profile-extra encoding note above.
|
||||
profileExtraClauses.add(
|
||||
'add-transcode-target(type=videoProfile&context=streaming'
|
||||
'&protocol=hls&container=mpegts&videoCodec=h264%2Chevc&audioCodec=aac)',
|
||||
final allParams = _buildTranscodeParams(
|
||||
ratingKey: ratingKey,
|
||||
mediaIndex: mediaIndex,
|
||||
preset: preset,
|
||||
sessionIdentifier: sessionIdentifier,
|
||||
transcodeSessionId: transcodeSessionId,
|
||||
audioStreamId: audioStreamId,
|
||||
selectedSubtitleTrack: selectedSubtitleTrack,
|
||||
offsetMs: offsetMs,
|
||||
);
|
||||
final clientProfileExtra = profileExtraClauses.join('+');
|
||||
|
||||
// HLS protocol: seekable via manifest segments. We started with `dash`
|
||||
// (what Plex Web on Chrome uses) but Plex's server only has DASH
|
||||
// transcode profiles for Chrome/Firefox/Safari/Opera — mobile/desktop
|
||||
// platforms fall through with "No conversion profile found for
|
||||
// protocol dash". HLS profiles exist for every Plex-accepted platform.
|
||||
final allParams = <String, String>{
|
||||
'hasMDE': '1',
|
||||
'path': metadataPath,
|
||||
'mediaIndex': mediaIndex.toString(),
|
||||
'partIndex': '0',
|
||||
'protocol': 'hls',
|
||||
'fastSeek': '1',
|
||||
'directPlay': isOriginal ? '1' : '0',
|
||||
'directStream': isOriginal ? '1' : '0',
|
||||
'subtitleSize': '100',
|
||||
'audioBoost': '100',
|
||||
'location': 'lan',
|
||||
if (!isOriginal && preset.videoBitrateKbps != null) 'maxVideoBitrate': preset.videoBitrateKbps.toString(),
|
||||
'addDebugOverlay': '0',
|
||||
'autoAdjustQuality': '0',
|
||||
'directStreamAudio': '0',
|
||||
'mediaBufferSize': '102400',
|
||||
'session': transcodeSessionId,
|
||||
// Subtitles are delivered as client-side sidecars (see
|
||||
// [PlaybackInitializationService._buildTranscodeSidecarSubtitles]).
|
||||
// `subtitles=none` makes the server set the subtitle decision to
|
||||
// `ignore`, so nothing is embedded or burned into the video stream.
|
||||
'subtitles': 'none',
|
||||
// Preserve source timestamps in the transcoded segments. Without it,
|
||||
// Plex resets segment PTS to 0 — so mpv shows 0:00 and sidecar
|
||||
// subtitles desync even though the server is transcoding from the
|
||||
// `offset` position. With copyts=1 the first segment's PTS equals
|
||||
// the source offset and the player's clock lines up with source time.
|
||||
'copyts': '1',
|
||||
if (audioStreamId != null) 'audioStreamID': audioStreamId.toString(),
|
||||
'Accept-Language': 'en',
|
||||
'X-Plex-Session-Identifier': sessionIdentifier,
|
||||
'X-Plex-Client-Profile-Extra': clientProfileExtra,
|
||||
'X-Plex-Incomplete-Segments': '1',
|
||||
'X-Plex-Features': 'external-media,indirect-media',
|
||||
'X-Plex-Model': 'standalone',
|
||||
'X-Plex-Language': 'en',
|
||||
'X-Plex-Product': config.product,
|
||||
'X-Plex-Version': config.version,
|
||||
'X-Plex-Client-Identifier': config.clientIdentifier,
|
||||
// Plex's server rejects unknown platform names with HTTP 400 and maps
|
||||
// known names to codec/bitrate base profiles. Our usual "Flutter"
|
||||
// platform, plus "MacOSX" / "Linux", are all rejected; swap to a
|
||||
// Plex-recognized name just for transcode requests. See
|
||||
// [_transcodePlatformName] for the mapping.
|
||||
'X-Plex-Platform': _transcodePlatformName(),
|
||||
if (config.device != null) 'X-Plex-Device': config.device!,
|
||||
if (offsetMs != null) 'offset': (offsetMs ~/ 1000).toString(),
|
||||
if (config.token != null) 'X-Plex-Token': config.token!,
|
||||
};
|
||||
|
||||
final queryString = allParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&');
|
||||
|
||||
@@ -2957,16 +2881,12 @@ class PlexClient
|
||||
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
|
||||
}
|
||||
|
||||
final outcome = _parseTranscodeDecisionOutcome(decisionResponse.data, isOriginal: isOriginal);
|
||||
final outcome = _parseTranscodeDecisionOutcome(decisionResponse.data, isOriginal: preset.isOriginal);
|
||||
if (outcome == TranscodeDecisionOutcome.failed) {
|
||||
return (startPath: null, outcome: outcome);
|
||||
}
|
||||
|
||||
final startParams = Map<String, String>.from(allParams)..remove('X-Plex-Token');
|
||||
final startQuery = startParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&');
|
||||
|
||||
// `.m3u8` tells the server to return an HLS manifest.
|
||||
return (startPath: '/video/:/transcode/universal/start.m3u8?$startQuery', outcome: outcome);
|
||||
return (startPath: _buildTranscodeStartPathFromParams(allParams), outcome: outcome);
|
||||
} finally {
|
||||
decisionClient.close();
|
||||
}
|
||||
@@ -2976,6 +2896,135 @@ class PlexClient
|
||||
}
|
||||
}
|
||||
|
||||
String _buildTranscodeStartPathFromParams(Map<String, String> params) {
|
||||
final startParams = Map<String, String>.from(params)..remove('X-Plex-Token');
|
||||
final startQuery = startParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&');
|
||||
return '/video/:/transcode/universal/start?$startQuery';
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
String buildTranscodeStartPathFromParamsForTesting(Map<String, String> params) {
|
||||
return _buildTranscodeStartPathFromParams(params);
|
||||
}
|
||||
|
||||
Map<String, String> _buildTranscodeParams({
|
||||
required String ratingKey,
|
||||
required int mediaIndex,
|
||||
required TranscodeQualityPreset preset,
|
||||
required String sessionIdentifier,
|
||||
required String transcodeSessionId,
|
||||
int? audioStreamId,
|
||||
MediaSubtitleTrack? selectedSubtitleTrack,
|
||||
int? offsetMs,
|
||||
}) {
|
||||
final isOriginal = preset.isOriginal;
|
||||
final selectedEmbeddedTextSubtitle = _shouldEmbedSubtitleInHttpTranscode(selectedSubtitleTrack)
|
||||
? selectedSubtitleTrack
|
||||
: null;
|
||||
|
||||
// Build the client profile from scratch via X-Plex-Client-Profile-Extra.
|
||||
// We use the `Generic` base platform (see [_transcodePlatformName]) which
|
||||
// has no pre-installed transcode targets, so we must `add-transcode-target`
|
||||
// rather than `append-transcode-target-codec` (which only edits existing
|
||||
// targets — empty on Generic, hence Plex returned decision code 2000
|
||||
// "neither direct play nor conversion is available").
|
||||
//
|
||||
// For non-original presets we also add a bitrate limitation that caps
|
||||
// the video codec; with `replace=true` it overrides any default limit.
|
||||
//
|
||||
// See openapi.md §"Profile Augmentations" for the DSL reference.
|
||||
final profileExtraClauses = <String>['add-settings(DirectPlayStreamSelection=true)'];
|
||||
if (!isOriginal && preset.videoBitrateKbps != null) {
|
||||
profileExtraClauses.add(
|
||||
'add-limitation(scope=videoCodec&scopeName=*&type=upperBound'
|
||||
'&name=video.bitrate&value=${preset.videoBitrateKbps}&replace=true)',
|
||||
);
|
||||
}
|
||||
// Match Plex Desktop's stable HTTP/MKV transcode target. Codec-list commas
|
||||
// are pre-encoded as `%2C` — see the profile-extra encoding note above.
|
||||
profileExtraClauses.add(
|
||||
'add-transcode-target(type=videoProfile&context=streaming'
|
||||
'&protocol=http&container=mkv&videoCodec=h264%2Chevc%2C*'
|
||||
'&audioCodec=opus%2Cvorbis%2Cflac%2C*&subtitleCodec=ass%2Cpgs%2Cvobsub%2C*)',
|
||||
);
|
||||
final clientProfileExtra = profileExtraClauses.join('+');
|
||||
|
||||
// HTTP/MKV matches Plex Desktop and lets MPV see embedded subtitle streams.
|
||||
// HLS `subtitles=segmented` was accepted by Plex but produced manifests
|
||||
// with only video/audio renditions for MPV.
|
||||
return <String, String>{
|
||||
'hasMDE': '1',
|
||||
'path': '/library/metadata/$ratingKey',
|
||||
'mediaIndex': mediaIndex.toString(),
|
||||
'partIndex': '0',
|
||||
'protocol': 'http',
|
||||
'fastSeek': '1',
|
||||
'directPlay': isOriginal ? '1' : '0',
|
||||
'directStream': isOriginal ? '1' : '0',
|
||||
'subtitleSize': '100',
|
||||
'audioBoost': '100',
|
||||
'location': 'lan',
|
||||
if (!isOriginal && preset.videoBitrateKbps != null) 'maxVideoBitrate': preset.videoBitrateKbps.toString(),
|
||||
'addDebugOverlay': '0',
|
||||
'autoAdjustQuality': '0',
|
||||
'directStreamAudio': '0',
|
||||
'mediaBufferSize': '102400',
|
||||
'session': transcodeSessionId,
|
||||
// Embed selected text subtitles in the MKV stream. Bitmap subtitles and
|
||||
// unselected tracks stay at `none` so the server cannot burn them into
|
||||
// the video.
|
||||
'subtitles': selectedEmbeddedTextSubtitle != null ? 'embedded' : 'none',
|
||||
if (selectedEmbeddedTextSubtitle != null) 'subtitleStreamID': selectedEmbeddedTextSubtitle.id.toString(),
|
||||
if (selectedEmbeddedTextSubtitle != null) 'advancedSubtitles': 'text',
|
||||
// Preserve source timestamps for the HTTP/MKV stream so player seeks and
|
||||
// sidecar subtitles stay aligned with Plex source time.
|
||||
'copyts': '1',
|
||||
if (audioStreamId != null) 'audioStreamID': audioStreamId.toString(),
|
||||
'Accept-Language': 'en',
|
||||
'X-Plex-Session-Identifier': sessionIdentifier,
|
||||
'X-Plex-Client-Profile-Extra': clientProfileExtra,
|
||||
'X-Plex-Chunked': '1',
|
||||
'X-Plex-Features': 'external-media,indirect-media',
|
||||
'X-Plex-Model': 'standalone',
|
||||
'X-Plex-Language': 'en',
|
||||
'X-Plex-Product': config.product,
|
||||
'X-Plex-Version': config.version,
|
||||
'X-Plex-Client-Identifier': config.clientIdentifier,
|
||||
// Plex's server rejects unknown platform names with HTTP 400 and maps
|
||||
// known names to codec/bitrate base profiles. Our usual "Flutter"
|
||||
// platform, plus "MacOSX" / "Linux", are all rejected; swap to a
|
||||
// Plex-recognized name just for transcode requests. See
|
||||
// [_transcodePlatformName] for the mapping.
|
||||
'X-Plex-Platform': _transcodePlatformName(),
|
||||
if (config.device != null) 'X-Plex-Device': config.device!,
|
||||
if (offsetMs != null) 'offset': (offsetMs ~/ 1000).toString(),
|
||||
if (config.token != null) 'X-Plex-Token': config.token!,
|
||||
};
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Map<String, String> buildTranscodeParamsForTesting({
|
||||
required String ratingKey,
|
||||
required int mediaIndex,
|
||||
required TranscodeQualityPreset preset,
|
||||
required String sessionIdentifier,
|
||||
required String transcodeSessionId,
|
||||
int? audioStreamId,
|
||||
MediaSubtitleTrack? selectedSubtitleTrack,
|
||||
int? offsetMs,
|
||||
}) {
|
||||
return _buildTranscodeParams(
|
||||
ratingKey: ratingKey,
|
||||
mediaIndex: mediaIndex,
|
||||
preset: preset,
|
||||
sessionIdentifier: sessionIdentifier,
|
||||
transcodeSessionId: transcodeSessionId,
|
||||
audioStreamId: audioStreamId,
|
||||
selectedSubtitleTrack: selectedSubtitleTrack,
|
||||
offsetMs: offsetMs,
|
||||
);
|
||||
}
|
||||
|
||||
/// Platform name Plex Media Server accepts on the transcode decision
|
||||
/// endpoint for arbitrary clients. Our default "Flutter" returns HTTP 400,
|
||||
/// and the known-OS names (`MacOSX`, `Mac`, `Linux`) are also rejected.
|
||||
@@ -3148,8 +3197,9 @@ class PlexClient
|
||||
/// Plex playback resolution. Reuses [getVideoPlaybackData] for metadata,
|
||||
/// then either runs the transcode-decision flow or returns the direct-play
|
||||
/// URL. External subtitle tracks are absolutized with the server's auth
|
||||
/// token; when transcoding, every source subtitle is sidecar-attached so
|
||||
/// the player can hot-swap.
|
||||
/// token; when transcoding, keyed sidecars stay external and selected
|
||||
/// embedded text subtitles are embedded in the HTTP/MKV stream so subtitles
|
||||
/// are never burned in.
|
||||
@override
|
||||
Future<PlaybackInitializationResult> getPlaybackInitialization(PlaybackInitializationOptions options) async {
|
||||
try {
|
||||
@@ -3162,11 +3212,8 @@ class PlexClient
|
||||
final wantTranscode = !options.qualityPreset.isOriginal;
|
||||
if (wantTranscode && options.sessionIdentifier != null && options.transcodeSessionId != null) {
|
||||
final resolvedAudioId = _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo);
|
||||
// Note: no `offsetMs` — seeking is handled by the player via the HLS
|
||||
// manifest, matching Plex Web's behavior. Baking `offset=` into the URL
|
||||
// makes the server pre-position the transcoder, but the resulting
|
||||
// segments and mpv's native HLS positioning fight each other, leaving
|
||||
// the player clock at 0 and desyncing sidecar subtitles.
|
||||
final resumeOffsetMs = options.metadata.viewOffsetMs;
|
||||
final selectedSubtitleTrack = _selectedSubtitleTrack(data.mediaInfo);
|
||||
final result = await buildTranscodeStartPath(
|
||||
ratingKey: options.metadata.id,
|
||||
mediaIndex: options.selectedMediaIndex,
|
||||
@@ -3174,6 +3221,8 @@ class PlexClient
|
||||
sessionIdentifier: options.sessionIdentifier!,
|
||||
transcodeSessionId: options.transcodeSessionId!,
|
||||
audioStreamId: resolvedAudioId,
|
||||
selectedSubtitleTrack: selectedSubtitleTrack,
|
||||
offsetMs: resumeOffsetMs != null && resumeOffsetMs > 0 ? resumeOffsetMs : null,
|
||||
);
|
||||
|
||||
if (result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) {
|
||||
@@ -3236,6 +3285,14 @@ class PlexClient
|
||||
return tracks.first.id;
|
||||
}
|
||||
|
||||
MediaSubtitleTrack? _selectedSubtitleTrack(MediaSourceInfo? info) {
|
||||
if (info == null) return null;
|
||||
for (final track in info.subtitleTracks) {
|
||||
if (track.selected) return track;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Build the absolute URL for an external subtitle track on this Plex
|
||||
/// server. Returns `null` for tracks that aren't external (no `/library/
|
||||
/// streams/{id}` key) or when the server has no auth token.
|
||||
@@ -3243,28 +3300,50 @@ class PlexClient
|
||||
/// Used by the in-player OpenSubtitles polling flow which needs the URL
|
||||
/// after the new track shows up in the metadata response.
|
||||
String? buildExternalSubtitleUrl(MediaSubtitleTrack track) {
|
||||
if (!track.isExternal) return null;
|
||||
if (!track.isExternal || track.key == null || track.key!.isEmpty) return null;
|
||||
final token = config.token;
|
||||
if (token == null) return null;
|
||||
final ext = CodecUtils.getSubtitleExtension(track.codec);
|
||||
return '${config.baseUrl}${track.key}.$ext?encoding=utf-8&X-Plex-Token=$token';
|
||||
}
|
||||
|
||||
/// Sidecar URL for any subtitle track (internal or external), used in
|
||||
/// transcode mode where embedded subtitle streams are stripped. Falls
|
||||
/// back to `/library/streams/{id}.{ext}` when [track.key] is missing.
|
||||
/// Returns `null` when no auth token is available.
|
||||
/// Raw sidecar URL for real sidecar subtitle streams. Plex returns 501 for
|
||||
/// `/library/streams/{id}.{ext}` when the stream is embedded, so a Plex
|
||||
/// `Stream.key` is required here.
|
||||
String? _buildSidecarSubtitleUrl(MediaSubtitleTrack track) {
|
||||
if (track.key == null || track.key!.isEmpty) return null;
|
||||
final token = config.token;
|
||||
if (token == null) return null;
|
||||
final ext = CodecUtils.getSubtitleExtension(track.codec);
|
||||
final path = (track.key != null && track.key!.isNotEmpty) ? track.key! : '/library/streams/${track.id}';
|
||||
return '${config.baseUrl}$path.$ext?encoding=utf-8&X-Plex-Token=$token';
|
||||
return '${config.baseUrl}${track.key}.$ext?encoding=utf-8&X-Plex-Token=$token';
|
||||
}
|
||||
|
||||
/// Build sidecar SubtitleTracks for ALL source subtitle streams (internal +
|
||||
/// external) so the player can hot-swap between them when the main stream
|
||||
/// is transcoded and has no embedded subs.
|
||||
bool _canTranscodeSubtitleAsText(MediaSubtitleTrack track) {
|
||||
return CodecUtils.isTextSubtitleCodec(track.codec);
|
||||
}
|
||||
|
||||
bool _shouldEmbedSubtitleInHttpTranscode(MediaSubtitleTrack? track) {
|
||||
if (track == null) return false;
|
||||
if (track.key != null && track.key!.isNotEmpty) return false;
|
||||
return _canTranscodeSubtitleAsText(track);
|
||||
}
|
||||
|
||||
SubtitleTrack _subtitleTrackFromMediaTrack(MediaSubtitleTrack track, String url) {
|
||||
return SubtitleTrack(
|
||||
id: 'external:$url',
|
||||
title: track.displayTitle ?? track.title ?? track.language ?? 'Track ${track.id}',
|
||||
language: track.languageCode,
|
||||
codec: track.codec,
|
||||
isDefault: track.selected,
|
||||
isForced: track.forced,
|
||||
isExternal: true,
|
||||
uri: url,
|
||||
);
|
||||
}
|
||||
|
||||
/// Build subtitle sidecars for Plex transcode playback. Only real keyed
|
||||
/// sidecars are loaded externally; selected embedded text subtitles are
|
||||
/// carried by the main HTTP/MKV stream.
|
||||
List<SubtitleTrack> _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) {
|
||||
if (mediaInfo == null) return const [];
|
||||
if (config.token == null) {
|
||||
@@ -3277,13 +3356,7 @@ class PlexClient
|
||||
try {
|
||||
final url = _buildSidecarSubtitleUrl(sub);
|
||||
if (url == null) continue;
|
||||
tracks.add(
|
||||
SubtitleTrack.uri(
|
||||
url,
|
||||
title: sub.displayTitle ?? sub.language ?? 'Track ${sub.id}',
|
||||
language: sub.languageCode,
|
||||
),
|
||||
);
|
||||
tracks.add(_subtitleTrackFromMediaTrack(sub, url));
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to build sidecar subtitle for stream ${sub.id}', error: e);
|
||||
}
|
||||
@@ -3291,6 +3364,11 @@ class PlexClient
|
||||
return tracks;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
List<SubtitleTrack> buildTranscodeSidecarSubtitlesForTesting(MediaSourceInfo? mediaInfo) {
|
||||
return _buildTranscodeSidecarSubtitles(mediaInfo);
|
||||
}
|
||||
|
||||
/// Build list of external subtitle tracks from media info
|
||||
List<SubtitleTrack> _buildExternalSubtitles(MediaSourceInfo? mediaInfo) {
|
||||
final externalSubtitles = <SubtitleTrack>[];
|
||||
@@ -3316,7 +3394,7 @@ class PlexClient
|
||||
externalSubtitles.add(
|
||||
SubtitleTrack.uri(
|
||||
url,
|
||||
title: plexTrack.displayTitle ?? plexTrack.language ?? 'Track ${plexTrack.id}',
|
||||
title: plexTrack.displayTitle ?? plexTrack.title ?? plexTrack.language ?? 'Track ${plexTrack.id}',
|
||||
language: plexTrack.languageCode,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -66,6 +66,7 @@ class TrackManager {
|
||||
List<SubtitleTrack> _lastExternalSubtitles = const [];
|
||||
StreamSubscription<Tracks>? _trackLoadingSubscription;
|
||||
Timer? _subtitleFallbackTimer;
|
||||
Timer? _trackSelectionFallbackTimer;
|
||||
|
||||
/// Cached external subtitles for re-use after backend fallback.
|
||||
List<SubtitleTrack> get lastExternalSubtitles => _lastExternalSubtitles;
|
||||
@@ -109,7 +110,7 @@ class TrackManager {
|
||||
uri: subtitleTrack.uri!,
|
||||
title: subtitleTrack.title,
|
||||
language: subtitleTrack.language,
|
||||
select: false,
|
||||
select: subtitleTrack.isDefault,
|
||||
);
|
||||
appLogger.d('Added external subtitle: ${subtitleTrack.title ?? subtitleTrack.uri}');
|
||||
} catch (e) {
|
||||
@@ -156,13 +157,23 @@ class TrackManager {
|
||||
/// If tracks are not yet loaded, subscribes to the stream.
|
||||
void applyTrackSelectionWhenReady() {
|
||||
final currentTracks = player.state.tracks;
|
||||
if (currentTracks.audio.isNotEmpty || currentTracks.subtitle.isNotEmpty) {
|
||||
if (_tracksReadyForSelection(currentTracks)) {
|
||||
applyTrackSelection();
|
||||
} else {
|
||||
_trackLoadingSubscription?.cancel();
|
||||
_trackLoadingSubscription = player.streams.tracks.listen((tracks) {
|
||||
if (tracks.audio.isEmpty && tracks.subtitle.isEmpty) return;
|
||||
if (!_tracksReadyForSelection(tracks)) return;
|
||||
|
||||
_trackLoadingSubscription?.cancel();
|
||||
_trackLoadingSubscription = null;
|
||||
_trackSelectionFallbackTimer?.cancel();
|
||||
_trackSelectionFallbackTimer = null;
|
||||
applyTrackSelection();
|
||||
});
|
||||
|
||||
_trackSelectionFallbackTimer?.cancel();
|
||||
_trackSelectionFallbackTimer = Timer(const Duration(seconds: 5), () {
|
||||
if (!isActive()) return;
|
||||
_trackLoadingSubscription?.cancel();
|
||||
_trackLoadingSubscription = null;
|
||||
applyTrackSelection();
|
||||
@@ -170,6 +181,17 @@ class TrackManager {
|
||||
}
|
||||
}
|
||||
|
||||
bool _tracksReadyForSelection(Tracks tracks) {
|
||||
final hasAnyTracks = tracks.audio.isNotEmpty || tracks.subtitle.isNotEmpty;
|
||||
if (!hasAnyTracks) return false;
|
||||
|
||||
final info = mediaInfo;
|
||||
if (info == null || tracks.subtitle.isNotEmpty) return true;
|
||||
|
||||
final expectsSelectedSubtitle = info.subtitleTracks.any((track) => track.selected);
|
||||
return !expectsSelectedSubtitle;
|
||||
}
|
||||
|
||||
/// Core track selection: delegates to [TrackSelectionService].
|
||||
Future<void> applyTrackSelection() async {
|
||||
if (!isActive() || _isApplyingTrackSelection) return;
|
||||
@@ -460,5 +482,7 @@ class TrackManager {
|
||||
_trackLoadingSubscription = null;
|
||||
_subtitleFallbackTimer?.cancel();
|
||||
_subtitleFallbackTimer = null;
|
||||
_trackSelectionFallbackTimer?.cancel();
|
||||
_trackSelectionFallbackTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,14 @@ class CodecUtils {
|
||||
}
|
||||
}
|
||||
|
||||
static bool isTextSubtitleCodec(String? codec) {
|
||||
if (codec == null) return false;
|
||||
return switch (codec.toLowerCase()) {
|
||||
'srt' || 'subrip' || 'ass' || 'ssa' || 'webvtt' || 'vtt' || 'mov_text' => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Formats a subtitle codec name to a user-friendly display format.
|
||||
///
|
||||
/// Converts internal codec names like 'SUBRIP' to friendly names like 'SRT'.
|
||||
|
||||
@@ -97,6 +97,9 @@ class DesktopVideoControls extends StatefulWidget {
|
||||
/// Called when content strip visibility changes
|
||||
final ValueChanged<bool>? onContentStripVisibilityChanged;
|
||||
|
||||
/// Called when a seek should be executed by the owning screen.
|
||||
final Future<void> Function(Duration position)? onSeekRequested;
|
||||
|
||||
/// Called when a seek operation completes successfully.
|
||||
final Function(Duration position)? onSeekCompleted;
|
||||
|
||||
@@ -139,6 +142,7 @@ class DesktopVideoControls extends StatefulWidget {
|
||||
this.onCancelAutoHide,
|
||||
this.onStartAutoHide,
|
||||
this.onContentStripVisibilityChanged,
|
||||
this.onSeekRequested,
|
||||
this.onSeekCompleted,
|
||||
});
|
||||
|
||||
@@ -612,6 +616,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
serverId: widget.serverId,
|
||||
showQueueTab: widget.showQueueTab,
|
||||
onQueueItemSelected: widget.onQueueItemSelected,
|
||||
onSeekRequested: widget.onSeekRequested,
|
||||
onSeekCompleted: widget.onSeekCompleted,
|
||||
useFocusNavigation: true,
|
||||
onNavigateUp: _onContentStripNavigateUp,
|
||||
@@ -917,6 +922,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
chapters: widget.chapters,
|
||||
chaptersLoaded: widget.chaptersLoaded,
|
||||
trackControlsState: _trackControlsState,
|
||||
onSeekRequested: widget.onSeekRequested,
|
||||
onSeekCompleted: widget.onSeekCompleted,
|
||||
focusNodes: _trackControlFocusNodes,
|
||||
onFocusChange: _onFocusChange,
|
||||
|
||||
@@ -36,6 +36,7 @@ class MobileVideoControls extends StatefulWidget {
|
||||
final Widget trackChapterControls;
|
||||
final Function(Duration) onSeek;
|
||||
final Function(Duration) onSeekEnd;
|
||||
final Future<void> Function(Duration position)? onSeekRequested;
|
||||
final Function(Duration)? onSeekCompleted;
|
||||
final VoidCallback onPlayPause;
|
||||
final VoidCallback? onCancelAutoHide;
|
||||
@@ -92,6 +93,7 @@ class MobileVideoControls extends StatefulWidget {
|
||||
required this.onSeek,
|
||||
required this.onSeekEnd,
|
||||
required this.onPlayPause,
|
||||
this.onSeekRequested,
|
||||
this.onSeekCompleted,
|
||||
this.onCancelAutoHide,
|
||||
this.onStartAutoHide,
|
||||
@@ -281,6 +283,8 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
|
||||
serverId: widget.serverId,
|
||||
showQueueTab: widget.showQueueTab,
|
||||
onQueueItemSelected: widget.onQueueItemSelected,
|
||||
onSeekRequested: widget.onSeekRequested,
|
||||
onSeekCompleted: widget.onSeekCompleted,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -16,6 +16,9 @@ class TrackControlsState {
|
||||
final bool isTranscoding;
|
||||
final List<MediaAudioTrack> sourceAudioTracks;
|
||||
final int? selectedAudioStreamId;
|
||||
final List<MediaSubtitleTrack> sourceSubtitleTracks;
|
||||
final int? selectedSubtitleStreamId;
|
||||
final int? sourcePartId;
|
||||
|
||||
/// Total media duration in milliseconds. Used by the version/quality sheet
|
||||
/// to show estimated file sizes per preset (bitrate × duration).
|
||||
@@ -36,6 +39,7 @@ class TrackControlsState {
|
||||
final Function(int)? onSwitchVersion;
|
||||
final ValueChanged<TranscodeQualityPreset>? onSwitchQualityPreset;
|
||||
final ValueChanged<int>? onSwitchAudioStreamId;
|
||||
final ValueChanged<int>? onSwitchSubtitleStreamId;
|
||||
final Function(AudioTrack)? onAudioTrackChanged;
|
||||
final Function(SubtitleTrack)? onSubtitleTrackChanged;
|
||||
final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged;
|
||||
@@ -71,6 +75,9 @@ class TrackControlsState {
|
||||
this.isTranscoding = false,
|
||||
this.sourceAudioTracks = const [],
|
||||
this.selectedAudioStreamId,
|
||||
this.sourceSubtitleTracks = const [],
|
||||
this.selectedSubtitleStreamId,
|
||||
this.sourcePartId,
|
||||
this.sourceDurationMs,
|
||||
this.boxFitMode = 0,
|
||||
this.audioSyncOffset = 0,
|
||||
@@ -88,6 +95,7 @@ class TrackControlsState {
|
||||
this.onSwitchVersion,
|
||||
this.onSwitchQualityPreset,
|
||||
this.onSwitchAudioStreamId,
|
||||
this.onSwitchSubtitleStreamId,
|
||||
this.onAudioTrackChanged,
|
||||
this.onSubtitleTrackChanged,
|
||||
this.onSecondarySubtitleTrackChanged,
|
||||
|
||||
@@ -306,6 +306,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
||||
onScreenshot: _showScreenshotToast,
|
||||
currentPositionEpoch: widget.currentPositionEpoch,
|
||||
onLiveSeek: widget.onLiveSeek,
|
||||
onSeekRequested: widget.onSeekRequested,
|
||||
);
|
||||
// Let non-navigation keys (volume, etc.) pass through to the OS.
|
||||
if (!event.logicalKey.isNavigationKey) return KeyEventResult.ignored;
|
||||
|
||||
@@ -28,6 +28,7 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
|
||||
onSeekForward: () => unawaited(_seekByTime(forward: true)),
|
||||
onSeek: _throttledSeek,
|
||||
onSeekEnd: _finalizeSeek,
|
||||
onSeekRequested: widget.onSeekRequested,
|
||||
getReplayIcon: getReplayIcon,
|
||||
getForwardIcon: getForwardIcon,
|
||||
onFocusActivity: _restartHideTimerIfPlaying,
|
||||
@@ -146,10 +147,12 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
|
||||
int? newMediaIndex,
|
||||
TranscodeQualityPreset? newPreset,
|
||||
int? newAudioStreamId,
|
||||
int? newSubtitleStreamId,
|
||||
}) async {
|
||||
final effectiveMediaIndex = newMediaIndex ?? widget.selectedMediaIndex;
|
||||
final effectivePreset = newPreset ?? widget.selectedQualityPreset;
|
||||
final effectiveAudioStreamId = newAudioStreamId ?? widget.selectedAudioStreamId;
|
||||
final effectiveSubtitleStreamId = newSubtitleStreamId ?? widget.selectedSubtitleStreamId;
|
||||
final effectiveMediaSourceId = effectiveMediaIndex >= 0 && effectiveMediaIndex < widget.availableVersions.length
|
||||
? widget.availableVersions[effectiveMediaIndex].id
|
||||
: widget.selectedMediaSourceId;
|
||||
@@ -157,7 +160,9 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
|
||||
final isVersionChange = effectiveMediaIndex != widget.selectedMediaIndex;
|
||||
final isPresetChange = effectivePreset != widget.selectedQualityPreset;
|
||||
final isAudioChange = effectiveAudioStreamId != widget.selectedAudioStreamId;
|
||||
if (!isVersionChange && !isPresetChange && !isAudioChange) {
|
||||
final isSubtitleChange =
|
||||
newSubtitleStreamId != null && effectiveSubtitleStreamId != widget.selectedSubtitleStreamId;
|
||||
if (!isVersionChange && !isPresetChange && !isAudioChange && !isSubtitleChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -176,6 +181,19 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
|
||||
});
|
||||
}
|
||||
|
||||
if (isSubtitleChange) {
|
||||
final serverId = widget.metadata.serverId;
|
||||
final partId = widget.sourcePartId;
|
||||
if (serverId == null || partId == null || effectiveSubtitleStreamId == null) {
|
||||
throw StateError('No Plex part available for subtitle stream selection');
|
||||
}
|
||||
final client = context.getPlexClientForServer(serverId);
|
||||
final saved = await client.selectStreams(partId, subtitleStreamID: effectiveSubtitleStreamId, allParts: true);
|
||||
if (!saved) {
|
||||
throw StateError('Failed to select subtitle stream');
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve session identifiers across the reload so Plex reuses the
|
||||
// transcode session rather than spinning up a new one.
|
||||
final sessionId = videoPlayerState?.playbackSessionIdentifier;
|
||||
|
||||
@@ -54,7 +54,7 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
|
||||
|
||||
Future<void> _seekToPosition(Duration position, {bool notifyCompletion = true}) async {
|
||||
final clamped = clampSeekPosition(widget.player, position);
|
||||
await widget.player.seek(clamped);
|
||||
await (widget.onSeekRequested ?? widget.player.seek)(clamped);
|
||||
if (notifyCompletion && mounted) {
|
||||
widget.onSeekCompleted?.call(clamped);
|
||||
}
|
||||
@@ -68,7 +68,7 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
|
||||
}
|
||||
final target = widget.player.state.position + delta;
|
||||
final clamped = clampSeekPosition(widget.player, target);
|
||||
await widget.player.seek(clamped);
|
||||
await (widget.onSeekRequested ?? widget.player.seek)(clamped);
|
||||
if (notifyCompletion && mounted) {
|
||||
widget.onSeekCompleted?.call(clamped);
|
||||
}
|
||||
@@ -78,13 +78,16 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
|
||||
if (!widget.player.state.playing && _rewindOnResume > 0) {
|
||||
final target = widget.player.state.position - Duration(seconds: _rewindOnResume);
|
||||
final clamped = clampSeekPosition(widget.player, target);
|
||||
await widget.player.seek(clamped);
|
||||
await (widget.onSeekRequested ?? widget.player.seek)(clamped);
|
||||
}
|
||||
await widget.player.playOrPause();
|
||||
}
|
||||
|
||||
/// Throttled seek for timeline slider - executes immediately then throttles to 200ms
|
||||
void _throttledSeek(Duration position) => _seekThrottle([position]);
|
||||
void _throttledSeek(Duration position) {
|
||||
if (widget.isTranscoding) return;
|
||||
_seekThrottle([position]);
|
||||
}
|
||||
|
||||
/// Finalizes the seek when user stops scrubbing the timeline
|
||||
void _finalizeSeek(Duration position) {
|
||||
|
||||
@@ -93,7 +93,11 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
|
||||
isTranscoding: widget.isTranscoding,
|
||||
sourceAudioTracks: widget.sourceAudioTracks,
|
||||
selectedAudioStreamId: widget.selectedAudioStreamId,
|
||||
sourceSubtitleTracks: widget.sourceSubtitleTracks,
|
||||
selectedSubtitleStreamId: widget.selectedSubtitleStreamId,
|
||||
);
|
||||
final canSwitchSourceSubtitles =
|
||||
versionQuality.canSwitch && versionQuality.isTranscoding && widget.metadata.backend == MediaBackend.plex;
|
||||
return TrackControlsState(
|
||||
availableVersions: versionQuality.availableVersions,
|
||||
selectedMediaIndex: widget.selectedMediaIndex,
|
||||
@@ -102,6 +106,11 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
|
||||
isTranscoding: versionQuality.isTranscoding,
|
||||
sourceAudioTracks: versionQuality.sourceAudioTracks,
|
||||
selectedAudioStreamId: versionQuality.selectedAudioStreamId,
|
||||
sourceSubtitleTracks: canSwitchSourceSubtitles
|
||||
? versionQuality.sourceSubtitleTracks
|
||||
: const <MediaSubtitleTrack>[],
|
||||
selectedSubtitleStreamId: canSwitchSourceSubtitles ? versionQuality.selectedSubtitleStreamId : null,
|
||||
sourcePartId: canSwitchSourceSubtitles ? widget.sourcePartId : null,
|
||||
sourceDurationMs: widget.metadata.durationMs,
|
||||
boxFitMode: widget.boxFitMode,
|
||||
audioSyncOffset: _audioSyncOffset,
|
||||
@@ -119,6 +128,9 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
|
||||
onSwitchVersion: versionQuality.canSwitch ? (i) => _switchVersionAndQuality(newMediaIndex: i) : null,
|
||||
onSwitchQualityPreset: versionQuality.canSwitch ? (p) => _switchVersionAndQuality(newPreset: p) : null,
|
||||
onSwitchAudioStreamId: versionQuality.canSwitch ? (id) => _switchVersionAndQuality(newAudioStreamId: id) : null,
|
||||
onSwitchSubtitleStreamId: canSwitchSourceSubtitles
|
||||
? (id) => _switchVersionAndQuality(newSubtitleStreamId: id)
|
||||
: null,
|
||||
onAudioTrackChanged: widget.onAudioTrackChanged,
|
||||
onSubtitleTrackChanged: _onSubtitleTrackChanged,
|
||||
onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged,
|
||||
@@ -177,6 +189,7 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
|
||||
chapters: _chapters,
|
||||
chaptersLoaded: _chaptersLoaded,
|
||||
trackControlsState: trackControlsState,
|
||||
onSeekRequested: widget.onSeekRequested,
|
||||
onSeekCompleted: widget.onSeekCompleted,
|
||||
hideChaptersAndQueue: hideChaptersAndQueue,
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ class ChapterSheet extends StatefulWidget {
|
||||
final List<MediaChapter> chapters;
|
||||
final bool chaptersLoaded;
|
||||
final String? serverId; // Server ID for the metadata these chapters belong to
|
||||
final Future<void> Function(Duration position)? onSeekRequested;
|
||||
final Function(Duration position)? onSeekCompleted;
|
||||
|
||||
const ChapterSheet({
|
||||
@@ -34,6 +35,7 @@ class ChapterSheet extends StatefulWidget {
|
||||
required this.chapters,
|
||||
required this.chaptersLoaded,
|
||||
this.serverId,
|
||||
this.onSeekRequested,
|
||||
this.onSeekCompleted,
|
||||
});
|
||||
|
||||
@@ -52,7 +54,7 @@ class _ChapterSheetState extends State<ChapterSheet> {
|
||||
|
||||
Future<void> _handleChapterTap(Duration position) async {
|
||||
final clamped = clampSeekPosition(widget.player, position);
|
||||
await widget.player.seek(clamped);
|
||||
await (widget.onSeekRequested ?? widget.player.seek)(clamped);
|
||||
if (mounted) {
|
||||
widget.onSeekCompleted?.call(clamped);
|
||||
OverlaySheetController.of(context).close();
|
||||
|
||||
@@ -33,6 +33,9 @@ class TrackSheet extends StatelessWidget {
|
||||
final List<MediaAudioTrack> sourceAudioTracks;
|
||||
final int? selectedAudioStreamId;
|
||||
final ValueChanged<int>? onSwitchAudioStreamId;
|
||||
final List<MediaSubtitleTrack> sourceSubtitleTracks;
|
||||
final int? selectedSubtitleStreamId;
|
||||
final ValueChanged<int>? onSwitchSubtitleStreamId;
|
||||
|
||||
/// Whether OpenSubtitles search is supported by the active server. Plex
|
||||
/// proxies the OpenSubtitles plugin; Jellyfin doesn't expose an
|
||||
@@ -53,6 +56,9 @@ class TrackSheet extends StatelessWidget {
|
||||
this.sourceAudioTracks = const [],
|
||||
this.selectedAudioStreamId,
|
||||
this.onSwitchAudioStreamId,
|
||||
this.sourceSubtitleTracks = const [],
|
||||
this.selectedSubtitleStreamId,
|
||||
this.onSwitchSubtitleStreamId,
|
||||
this.subtitleSearchSupported = true,
|
||||
});
|
||||
|
||||
@@ -72,8 +78,9 @@ class TrackSheet extends StatelessWidget {
|
||||
final hasExternalSourceAudio = sourceAudioTracks.any((track) => track.isExternal);
|
||||
final useSourceAudio =
|
||||
(isTranscoding || hasExternalSourceAudio) && sourceAudioTracks.length > 1 && onSwitchAudioStreamId != null;
|
||||
final useSourceSubtitles = isTranscoding && sourceSubtitleTracks.isNotEmpty && onSwitchSubtitleStreamId != null;
|
||||
final showAudio = useSourceAudio || playerAudioTracks.length > 1;
|
||||
final showSubtitles = subtitleTracks.isNotEmpty;
|
||||
final showSubtitles = useSourceSubtitles || subtitleTracks.isNotEmpty;
|
||||
|
||||
final String title;
|
||||
final IconData icon;
|
||||
@@ -117,30 +124,43 @@ class TrackSheet extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget subtitleColumnFor(TrackSelection sel, bool showHeader) {
|
||||
if (useSourceSubtitles) {
|
||||
return _SourceSubtitleColumn(
|
||||
tracks: sourceSubtitleTracks,
|
||||
selectedStreamId: selectedSubtitleStreamId,
|
||||
onSelected: onSwitchSubtitleStreamId!,
|
||||
ratingKey: ratingKey,
|
||||
serverId: serverId,
|
||||
mediaTitle: mediaTitle,
|
||||
onSubtitleDownloaded: onSubtitleDownloaded,
|
||||
showHeader: showHeader,
|
||||
subtitleSearchSupported: subtitleSearchSupported,
|
||||
);
|
||||
}
|
||||
return _SubtitleColumn(
|
||||
tracks: subtitleTracks,
|
||||
selection: sel,
|
||||
player: player,
|
||||
ratingKey: ratingKey,
|
||||
serverId: serverId,
|
||||
mediaTitle: mediaTitle,
|
||||
onSubtitleDownloaded: onSubtitleDownloaded,
|
||||
onTrackChanged: onSubtitleTrackChanged,
|
||||
onSecondaryTrackChanged: onSecondarySubtitleTrackChanged,
|
||||
supportsSecondary: supportsSecondary,
|
||||
showHeader: showHeader,
|
||||
subtitleSearchSupported: subtitleSearchSupported,
|
||||
);
|
||||
}
|
||||
|
||||
if (showAudio && showSubtitles) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: FocusTraversalGroup(child: audioColumnFor(selection, true))),
|
||||
VerticalDivider(width: 1, color: Theme.of(context).dividerColor),
|
||||
Expanded(
|
||||
child: FocusTraversalGroup(
|
||||
child: _SubtitleColumn(
|
||||
tracks: subtitleTracks,
|
||||
selection: selection,
|
||||
player: player,
|
||||
ratingKey: ratingKey,
|
||||
serverId: serverId,
|
||||
mediaTitle: mediaTitle,
|
||||
onSubtitleDownloaded: onSubtitleDownloaded,
|
||||
onTrackChanged: onSubtitleTrackChanged,
|
||||
onSecondaryTrackChanged: onSecondarySubtitleTrackChanged,
|
||||
supportsSecondary: supportsSecondary,
|
||||
showHeader: true,
|
||||
subtitleSearchSupported: subtitleSearchSupported,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: FocusTraversalGroup(child: subtitleColumnFor(selection, true))),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -149,20 +169,7 @@ class TrackSheet extends StatelessWidget {
|
||||
return audioColumnFor(selection, false);
|
||||
}
|
||||
|
||||
return _SubtitleColumn(
|
||||
tracks: subtitleTracks,
|
||||
selection: selection,
|
||||
player: player,
|
||||
ratingKey: ratingKey,
|
||||
serverId: serverId,
|
||||
mediaTitle: mediaTitle,
|
||||
onSubtitleDownloaded: onSubtitleDownloaded,
|
||||
onTrackChanged: onSubtitleTrackChanged,
|
||||
onSecondaryTrackChanged: onSecondarySubtitleTrackChanged,
|
||||
supportsSecondary: supportsSecondary,
|
||||
showHeader: false,
|
||||
subtitleSearchSupported: subtitleSearchSupported,
|
||||
);
|
||||
return subtitleColumnFor(selection, false);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -240,6 +247,112 @@ class _SourceAudioColumnState extends State<_SourceAudioColumn> {
|
||||
}
|
||||
}
|
||||
|
||||
class _SourceSubtitleColumn extends StatefulWidget {
|
||||
final List<MediaSubtitleTrack> tracks;
|
||||
final int? selectedStreamId;
|
||||
final ValueChanged<int> onSelected;
|
||||
final String ratingKey;
|
||||
final String serverId;
|
||||
final String? mediaTitle;
|
||||
final Future<void> Function()? onSubtitleDownloaded;
|
||||
final bool showHeader;
|
||||
final bool subtitleSearchSupported;
|
||||
|
||||
const _SourceSubtitleColumn({
|
||||
required this.tracks,
|
||||
required this.selectedStreamId,
|
||||
required this.onSelected,
|
||||
this.ratingKey = '',
|
||||
this.serverId = '',
|
||||
this.mediaTitle,
|
||||
this.onSubtitleDownloaded,
|
||||
required this.showHeader,
|
||||
this.subtitleSearchSupported = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SourceSubtitleColumn> createState() => _SourceSubtitleColumnState();
|
||||
}
|
||||
|
||||
class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> {
|
||||
final _initialScroll = InitialItemScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_initialScroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedId = _effectiveSelectedStreamId();
|
||||
final selectedIndex = selectedId == 0 ? 0 : widget.tracks.indexWhere((t) => t.id == selectedId) + 1;
|
||||
_initialScroll.maybeScrollTo(selectedIndex);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.showHeader) SheetColumnHeader(label: t.videoControls.subtitlesLabel),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _initialScroll.controller,
|
||||
itemCount: widget.tracks.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return TrackSelectionHelper.buildOffTile<SubtitleTrack>(
|
||||
context: context,
|
||||
key: _initialScroll.firstItemKey,
|
||||
isSelected: selectedId == 0,
|
||||
onTap: () {
|
||||
OverlaySheetController.of(context).close();
|
||||
widget.onSelected(0);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final track = widget.tracks[index - 1];
|
||||
return TrackSelectionHelper.buildTrackTile<SubtitleTrack>(
|
||||
context: context,
|
||||
label: track.labelForIndex(index - 1),
|
||||
isSelected: track.id == selectedId,
|
||||
onTap: () {
|
||||
OverlaySheetController.of(context).close();
|
||||
widget.onSelected(track.id);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (widget.ratingKey.isNotEmpty && widget.subtitleSearchSupported) ...[
|
||||
Divider(height: 1, color: Theme.of(context).dividerColor),
|
||||
FocusableListTile(
|
||||
leading: const AppIcon(Symbols.search_rounded),
|
||||
title: Text(t.videoControls.searchSubtitles),
|
||||
onTap: () {
|
||||
OverlaySheetController.of(context).push(
|
||||
builder: (_) => SubtitleSearchSheet(
|
||||
ratingKey: widget.ratingKey,
|
||||
serverId: widget.serverId,
|
||||
mediaTitle: widget.mediaTitle,
|
||||
onSubtitleDownloaded: widget.onSubtitleDownloaded,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
int _effectiveSelectedStreamId() {
|
||||
final explicit = widget.selectedStreamId;
|
||||
if (explicit != null && (explicit == 0 || widget.tracks.any((track) => track.id == explicit))) return explicit;
|
||||
for (final track in widget.tracks) {
|
||||
if (track.selected) return track.id;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
class _AudioColumn extends StatefulWidget {
|
||||
final List<AudioTrack> tracks;
|
||||
final TrackSelection selection;
|
||||
|
||||
@@ -91,6 +91,9 @@ Widget plexVideoControlsBuilder(
|
||||
bool isOfflinePlayback = false,
|
||||
List<MediaAudioTrack> sourceAudioTracks = const [],
|
||||
int? selectedAudioStreamId,
|
||||
List<MediaSubtitleTrack> sourceSubtitleTracks = const [],
|
||||
int? selectedSubtitleStreamId,
|
||||
int? sourcePartId,
|
||||
VoidCallback? onTogglePIPMode,
|
||||
int boxFitMode = 0,
|
||||
VoidCallback? onCycleBoxFitMode,
|
||||
@@ -99,6 +102,7 @@ Widget plexVideoControlsBuilder(
|
||||
Function(AudioTrack)? onAudioTrackChanged,
|
||||
Function(SubtitleTrack)? onSubtitleTrackChanged,
|
||||
Function(SubtitleTrack)? onSecondarySubtitleTrackChanged,
|
||||
Future<void> Function(Duration position)? onSeekRequested,
|
||||
Function(Duration position)? onSeekCompleted,
|
||||
VoidCallback? onBack,
|
||||
void Function({required bool skipAutoPlayCountdown})? onReachedEnd,
|
||||
@@ -136,6 +140,9 @@ Widget plexVideoControlsBuilder(
|
||||
isOfflinePlayback: isOfflinePlayback,
|
||||
sourceAudioTracks: sourceAudioTracks,
|
||||
selectedAudioStreamId: selectedAudioStreamId,
|
||||
sourceSubtitleTracks: sourceSubtitleTracks,
|
||||
selectedSubtitleStreamId: selectedSubtitleStreamId,
|
||||
sourcePartId: sourcePartId,
|
||||
boxFitMode: boxFitMode,
|
||||
onTogglePIPMode: onTogglePIPMode,
|
||||
onCycleBoxFitMode: onCycleBoxFitMode,
|
||||
@@ -144,6 +151,7 @@ Widget plexVideoControlsBuilder(
|
||||
onAudioTrackChanged: onAudioTrackChanged,
|
||||
onSubtitleTrackChanged: onSubtitleTrackChanged,
|
||||
onSecondarySubtitleTrackChanged: onSecondarySubtitleTrackChanged,
|
||||
onSeekRequested: onSeekRequested,
|
||||
onSeekCompleted: onSeekCompleted,
|
||||
onBack: onBack,
|
||||
onReachedEnd: onReachedEnd,
|
||||
@@ -174,6 +182,8 @@ Widget plexVideoControlsBuilder(
|
||||
bool isTranscoding,
|
||||
List<MediaAudioTrack> sourceAudioTracks,
|
||||
int? selectedAudioStreamId,
|
||||
List<MediaSubtitleTrack> sourceSubtitleTracks,
|
||||
int? selectedSubtitleStreamId,
|
||||
bool canSwitch,
|
||||
})
|
||||
effectiveVersionQualityControls({
|
||||
@@ -183,6 +193,8 @@ effectiveVersionQualityControls({
|
||||
required bool isTranscoding,
|
||||
required List<MediaAudioTrack> sourceAudioTracks,
|
||||
required int? selectedAudioStreamId,
|
||||
required List<MediaSubtitleTrack> sourceSubtitleTracks,
|
||||
required int? selectedSubtitleStreamId,
|
||||
}) {
|
||||
if (isOfflinePlayback) {
|
||||
return (
|
||||
@@ -191,6 +203,8 @@ effectiveVersionQualityControls({
|
||||
isTranscoding: false,
|
||||
sourceAudioTracks: const <MediaAudioTrack>[],
|
||||
selectedAudioStreamId: null,
|
||||
sourceSubtitleTracks: const <MediaSubtitleTrack>[],
|
||||
selectedSubtitleStreamId: null,
|
||||
canSwitch: false,
|
||||
);
|
||||
}
|
||||
@@ -200,6 +214,8 @@ effectiveVersionQualityControls({
|
||||
isTranscoding: isTranscoding,
|
||||
sourceAudioTracks: sourceAudioTracks,
|
||||
selectedAudioStreamId: selectedAudioStreamId,
|
||||
sourceSubtitleTracks: sourceSubtitleTracks,
|
||||
selectedSubtitleStreamId: selectedSubtitleStreamId,
|
||||
canSwitch: true,
|
||||
);
|
||||
}
|
||||
@@ -218,6 +234,9 @@ class PlexVideoControls extends StatefulWidget {
|
||||
final bool isOfflinePlayback;
|
||||
final List<MediaAudioTrack> sourceAudioTracks;
|
||||
final int? selectedAudioStreamId;
|
||||
final List<MediaSubtitleTrack> sourceSubtitleTracks;
|
||||
final int? selectedSubtitleStreamId;
|
||||
final int? sourcePartId;
|
||||
final int boxFitMode;
|
||||
final VoidCallback? onTogglePIPMode;
|
||||
final VoidCallback? onCycleBoxFitMode;
|
||||
@@ -227,6 +246,10 @@ class PlexVideoControls extends StatefulWidget {
|
||||
final Function(SubtitleTrack)? onSubtitleTrackChanged;
|
||||
final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged;
|
||||
|
||||
/// Called for app-level seek requests. Plex transcodes use this to restart
|
||||
/// the server-side transcode session at the requested absolute timestamp.
|
||||
final Future<void> Function(Duration position)? onSeekRequested;
|
||||
|
||||
/// Called when a seek operation completes (for Watch Together sync)
|
||||
final Function(Duration position)? onSeekCompleted;
|
||||
|
||||
@@ -308,6 +331,9 @@ class PlexVideoControls extends StatefulWidget {
|
||||
this.isOfflinePlayback = false,
|
||||
this.sourceAudioTracks = const [],
|
||||
this.selectedAudioStreamId,
|
||||
this.sourceSubtitleTracks = const [],
|
||||
this.selectedSubtitleStreamId,
|
||||
this.sourcePartId,
|
||||
this.boxFitMode = 0,
|
||||
this.onTogglePIPMode,
|
||||
this.onCycleBoxFitMode,
|
||||
@@ -316,6 +342,7 @@ class PlexVideoControls extends StatefulWidget {
|
||||
this.onAudioTrackChanged,
|
||||
this.onSubtitleTrackChanged,
|
||||
this.onSecondarySubtitleTrackChanged,
|
||||
this.onSeekRequested,
|
||||
this.onSeekCompleted,
|
||||
this.onBack,
|
||||
this.onReachedEnd,
|
||||
@@ -710,6 +737,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
),
|
||||
onSeek: _throttledSeek,
|
||||
onSeekEnd: _finalizeSeek,
|
||||
onSeekRequested: widget.onSeekRequested,
|
||||
onSeekCompleted: widget.onSeekCompleted,
|
||||
// ignore: no-empty-block - play/pause handled by parent VideoControlsState
|
||||
onPlayPause: () {},
|
||||
|
||||
@@ -30,6 +30,7 @@ class ContentStrip extends StatefulWidget {
|
||||
final String? serverId;
|
||||
final bool showQueueTab;
|
||||
final Function(MediaItem)? onQueueItemSelected;
|
||||
final Future<void> Function(Duration position)? onSeekRequested;
|
||||
final Function(Duration position)? onSeekCompleted;
|
||||
|
||||
/// Whether to use dpad/focus-based navigation (TV mode).
|
||||
@@ -50,6 +51,7 @@ class ContentStrip extends StatefulWidget {
|
||||
this.serverId,
|
||||
this.showQueueTab = false,
|
||||
this.onQueueItemSelected,
|
||||
this.onSeekRequested,
|
||||
this.onSeekCompleted,
|
||||
this.useFocusNavigation = false,
|
||||
this.onNavigateUp,
|
||||
@@ -128,7 +130,7 @@ class ContentStripState extends State<ContentStrip> {
|
||||
|
||||
Future<void> _handleChapterTap(Duration position) async {
|
||||
final clamped = clampSeekPosition(widget.player, position);
|
||||
await widget.player.seek(clamped);
|
||||
await (widget.onSeekRequested ?? widget.player.seek)(clamped);
|
||||
if (mounted) {
|
||||
widget.onSeekCompleted?.call(clamped);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ class TrackChapterControls extends StatelessWidget {
|
||||
final List<MediaChapter> chapters;
|
||||
final bool chaptersLoaded;
|
||||
final TrackControlsState trackControlsState;
|
||||
final Future<void> Function(Duration position)? onSeekRequested;
|
||||
final Function(Duration position)? onSeekCompleted;
|
||||
|
||||
/// List of FocusNodes for the buttons (passed from parent for navigation)
|
||||
@@ -53,6 +54,7 @@ class TrackChapterControls extends StatelessWidget {
|
||||
required this.chapters,
|
||||
required this.chaptersLoaded,
|
||||
required this.trackControlsState,
|
||||
this.onSeekRequested,
|
||||
this.onSeekCompleted,
|
||||
this.focusNodes,
|
||||
this.onFocusChange,
|
||||
@@ -243,7 +245,9 @@ class TrackChapterControls extends StatelessWidget {
|
||||
// Combined audio & subtitles button
|
||||
{
|
||||
final currentIndex = buttonIndex;
|
||||
final hasSubs = _hasSubtitles(tracks);
|
||||
final hasSourceSubs =
|
||||
trackControlsState.sourceSubtitleTracks.isNotEmpty && trackControlsState.onSwitchSubtitleStreamId != null;
|
||||
final hasSubs = _hasSubtitles(tracks) || hasSourceSubs;
|
||||
final selectedSub = player.state.track.subtitle;
|
||||
final hasActiveSubtitle = selectedSub != null && selectedSub.id != 'no';
|
||||
final isHidden = hasSubs && hasActiveSubtitle && !subtitlesVisible;
|
||||
@@ -275,6 +279,9 @@ class TrackChapterControls extends StatelessWidget {
|
||||
sourceAudioTracks: trackControlsState.sourceAudioTracks,
|
||||
selectedAudioStreamId: trackControlsState.selectedAudioStreamId,
|
||||
onSwitchAudioStreamId: trackControlsState.onSwitchAudioStreamId,
|
||||
sourceSubtitleTracks: trackControlsState.sourceSubtitleTracks,
|
||||
selectedSubtitleStreamId: trackControlsState.selectedSubtitleStreamId,
|
||||
onSwitchSubtitleStreamId: trackControlsState.onSwitchSubtitleStreamId,
|
||||
subtitleSearchSupported: trackControlsState.subtitleSearchSupported,
|
||||
),
|
||||
)
|
||||
@@ -305,6 +312,7 @@ class TrackChapterControls extends StatelessWidget {
|
||||
chapters: chapters,
|
||||
chaptersLoaded: chaptersLoaded,
|
||||
serverId: serverId,
|
||||
onSeekRequested: onSeekRequested,
|
||||
onSeekCompleted: onSeekCompleted,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_source_info.dart';
|
||||
|
||||
void main() {
|
||||
group('MediaSubtitleTrack label', () {
|
||||
test('prefers explicit source title over generated display title', () {
|
||||
final track = MediaSubtitleTrack(
|
||||
id: 401,
|
||||
index: 0,
|
||||
codec: 'srt',
|
||||
languageCode: 'eng',
|
||||
title: 'Forced',
|
||||
displayTitle: 'English (SRT)',
|
||||
selected: false,
|
||||
forced: true,
|
||||
);
|
||||
|
||||
expect(track.labelForIndex(0), 'Forced · ENG · SRT');
|
||||
expect(track.label, 'Forced · ENG · SRT');
|
||||
});
|
||||
|
||||
test('falls back to display title when source title is empty', () {
|
||||
final track = MediaSubtitleTrack(
|
||||
id: 402,
|
||||
index: 1,
|
||||
codec: 'ass',
|
||||
languageCode: 'jpn',
|
||||
title: ' ',
|
||||
displayTitle: 'Japanese Signs/Songs',
|
||||
selected: false,
|
||||
forced: false,
|
||||
);
|
||||
|
||||
expect(track.labelForIndex(1), 'Japanese Signs/Songs · JPN · ASS');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -104,6 +104,49 @@ void main() {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('MPV maps server-offset streams to absolute timeline positions', () async {
|
||||
final calls = <MethodCall>[];
|
||||
|
||||
await _withMockChannels(
|
||||
methodChannelName: 'com.plezy/mpv_player',
|
||||
eventChannelName: 'com.plezy/mpv_player/events',
|
||||
methodHandler: (call) {
|
||||
calls.add(call);
|
||||
switch (call.method) {
|
||||
case 'initialize':
|
||||
return Future.value(true);
|
||||
default:
|
||||
return Future.value(null);
|
||||
}
|
||||
},
|
||||
testBody: () async {
|
||||
final player = PlayerNative();
|
||||
try {
|
||||
await player.open(
|
||||
Media('https://example.test/transcode.mkv'),
|
||||
timelineOffset: const Duration(seconds: 10),
|
||||
timelineDuration: const Duration(seconds: 100),
|
||||
);
|
||||
|
||||
expect(player.state.position, const Duration(seconds: 10));
|
||||
expect(player.state.duration, const Duration(seconds: 100));
|
||||
|
||||
player.handlePropertyChange('duration', 90.0);
|
||||
expect(player.state.duration, const Duration(seconds: 100));
|
||||
|
||||
await player.seek(const Duration(seconds: 25));
|
||||
|
||||
final seekCall = calls.lastWhere((call) => call.method == 'command');
|
||||
final args = Map<Object?, Object?>.from(seekCall.arguments as Map)['args'] as List;
|
||||
expect(args, ['seek', '15.0', 'absolute']);
|
||||
expect(player.state.position, const Duration(seconds: 25));
|
||||
} finally {
|
||||
await player.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/media/media_source_info.dart';
|
||||
import 'package:plezy/mpv/mpv.dart';
|
||||
import 'package:plezy/models/plex/plex_config.dart';
|
||||
import 'package:plezy/models/transcode_quality_preset.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/services/plex_client.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
|
||||
setUp(() {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
PlexApiCache.initialize(db);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) {
|
||||
return PlexClient.forTesting(
|
||||
config: PlexConfig(
|
||||
baseUrl: 'https://plex.example.com',
|
||||
token: 'token',
|
||||
clientIdentifier: 'client-id',
|
||||
product: 'Plezy',
|
||||
version: '1',
|
||||
),
|
||||
serverId: 'server-id',
|
||||
httpClient: MockClient(handler),
|
||||
);
|
||||
}
|
||||
|
||||
MediaSourceInfo mediaInfoWithSubtitles(List<MediaSubtitleTrack> subtitleTracks) {
|
||||
return MediaSourceInfo(
|
||||
videoUrl: 'https://plex.example.com/video.mkv',
|
||||
audioTracks: const [],
|
||||
subtitleTracks: subtitleTracks,
|
||||
chapters: const [],
|
||||
);
|
||||
}
|
||||
|
||||
List<SubtitleTrack> buildTranscodeSubtitles(PlexClient client, List<MediaSubtitleTrack> subtitleTracks) {
|
||||
return client.buildTranscodeSidecarSubtitlesForTesting(mediaInfoWithSubtitles(subtitleTracks));
|
||||
}
|
||||
|
||||
test('playback metadata request includes streams for transcode sidecar subtitles', () async {
|
||||
final requests = <Uri>[];
|
||||
final client = makeClient((request) async {
|
||||
requests.add(request.url);
|
||||
if (request.url.path != '/library/metadata/42') {
|
||||
return http.Response('not found', 404);
|
||||
}
|
||||
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'Metadata': [
|
||||
{
|
||||
'ratingKey': '42',
|
||||
'type': 'movie',
|
||||
'title': 'Movie',
|
||||
'Media': [
|
||||
{
|
||||
'id': 7,
|
||||
'container': 'mkv',
|
||||
'Part': [
|
||||
{
|
||||
'id': 99,
|
||||
'key': '/library/parts/99/file.mkv',
|
||||
'Stream': [
|
||||
{'streamType': 1, 'id': 300, 'codec': 'h264'},
|
||||
{'streamType': 2, 'id': 301, 'index': 0, 'languageCode': 'jpn', 'selected': true},
|
||||
{
|
||||
'streamType': 3,
|
||||
'id': 401,
|
||||
'index': 1,
|
||||
'codec': 'ass',
|
||||
'language': 'English',
|
||||
'languageCode': 'eng',
|
||||
'title': 'Signs/Songs',
|
||||
'selected': true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final data = await client.getVideoPlaybackData('42');
|
||||
|
||||
expect(requests, hasLength(1));
|
||||
expect(requests.single.queryParameters['includeStreams'], '1');
|
||||
expect(data.mediaInfo?.subtitleTracks, hasLength(1));
|
||||
expect(data.mediaInfo?.subtitleTracks.single.id, 401);
|
||||
expect(data.mediaInfo?.subtitleTracks.single.selected, isTrue);
|
||||
});
|
||||
|
||||
test('transcode subtitle sidecars only use real Plex stream keys', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
|
||||
final subtitles = buildTranscodeSubtitles(client, [
|
||||
MediaSubtitleTrack(id: 401, codec: 'srt', languageCode: 'eng', selected: false, forced: false),
|
||||
MediaSubtitleTrack(
|
||||
id: 402,
|
||||
codec: 'srt',
|
||||
languageCode: 'eng',
|
||||
selected: false,
|
||||
forced: false,
|
||||
key: '/library/streams/402',
|
||||
external: true,
|
||||
),
|
||||
]);
|
||||
|
||||
expect(subtitles, hasLength(1));
|
||||
expect(subtitles.single.uri, 'https://plex.example.com/library/streams/402.srt?encoding=utf-8&X-Plex-Token=token');
|
||||
});
|
||||
|
||||
test('selected internal text subtitles are not attached as external sidecars', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
|
||||
final subtitles = buildTranscodeSubtitles(client, [
|
||||
MediaSubtitleTrack(
|
||||
id: 401,
|
||||
codec: 'ass',
|
||||
language: 'English',
|
||||
languageCode: 'eng',
|
||||
title: 'Signs/Songs',
|
||||
selected: true,
|
||||
forced: false,
|
||||
),
|
||||
]);
|
||||
|
||||
expect(subtitles, isEmpty);
|
||||
});
|
||||
|
||||
test('selected internal text subtitles are embedded in HTTP MKV transcode', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
|
||||
final params = client.buildTranscodeParamsForTesting(
|
||||
ratingKey: '42',
|
||||
mediaIndex: 0,
|
||||
preset: TranscodeQualityPreset.p720_3mbps,
|
||||
sessionIdentifier: 'session-id',
|
||||
transcodeSessionId: 'transcode-id',
|
||||
selectedSubtitleTrack: MediaSubtitleTrack(
|
||||
id: 401,
|
||||
codec: 'ass',
|
||||
languageCode: 'eng',
|
||||
selected: true,
|
||||
forced: false,
|
||||
),
|
||||
);
|
||||
|
||||
expect(params['protocol'], 'http');
|
||||
expect(params['subtitles'], 'embedded');
|
||||
expect(params['subtitleStreamID'], '401');
|
||||
expect(params['advancedSubtitles'], 'text');
|
||||
expect(params['X-Plex-Chunked'], '1');
|
||||
expect(params.containsKey('X-Plex-Incomplete-Segments'), isFalse);
|
||||
expect(params['X-Plex-Client-Profile-Extra'], contains('add-settings(DirectPlayStreamSelection=true)'));
|
||||
expect(
|
||||
params['X-Plex-Client-Profile-Extra'],
|
||||
contains(
|
||||
'add-transcode-target(type=videoProfile&context=streaming'
|
||||
'&protocol=http&container=mkv&videoCodec=h264%2Chevc%2C*'
|
||||
'&audioCodec=opus%2Cvorbis%2Cflac%2C*&subtitleCodec=ass%2Cpgs%2Cvobsub%2C*)',
|
||||
),
|
||||
);
|
||||
expect(params['X-Plex-Client-Profile-Extra'], isNot(contains('protocol=hls')));
|
||||
expect(params['X-Plex-Client-Profile-Extra'], isNot(contains('type=subtitleProfile')));
|
||||
});
|
||||
|
||||
test('transcode start path uses HTTP start endpoint without token', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
|
||||
final params = client.buildTranscodeParamsForTesting(
|
||||
ratingKey: '42',
|
||||
mediaIndex: 0,
|
||||
preset: TranscodeQualityPreset.p720_3mbps,
|
||||
sessionIdentifier: 'session-id',
|
||||
transcodeSessionId: 'transcode-id',
|
||||
offsetMs: 90500,
|
||||
);
|
||||
|
||||
final startPath = client.buildTranscodeStartPathFromParamsForTesting(params);
|
||||
|
||||
expect(params['offset'], '90');
|
||||
expect(startPath, startsWith('/video/:/transcode/universal/start?'));
|
||||
expect(startPath, isNot(contains('start.m3u8')));
|
||||
expect(startPath, contains('protocol=http'));
|
||||
expect(startPath, contains('offset=90'));
|
||||
expect(startPath, isNot(contains('X-Plex-Token')));
|
||||
});
|
||||
|
||||
test('unsupported embedded subtitles keep main transcode subtitles disabled', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
|
||||
final params = client.buildTranscodeParamsForTesting(
|
||||
ratingKey: '42',
|
||||
mediaIndex: 0,
|
||||
preset: TranscodeQualityPreset.p720_3mbps,
|
||||
sessionIdentifier: 'session-id',
|
||||
transcodeSessionId: 'transcode-id',
|
||||
selectedSubtitleTrack: MediaSubtitleTrack(
|
||||
id: 401,
|
||||
codec: 'pgs',
|
||||
languageCode: 'eng',
|
||||
selected: true,
|
||||
forced: false,
|
||||
),
|
||||
);
|
||||
|
||||
expect(params['subtitles'], 'none');
|
||||
expect(params['protocol'], 'http');
|
||||
expect(params.containsKey('subtitleStreamID'), isFalse);
|
||||
expect(params.containsKey('advancedSubtitles'), isFalse);
|
||||
expect(params['X-Plex-Client-Profile-Extra'], isNot(contains('type=subtitleProfile')));
|
||||
});
|
||||
|
||||
test('bitmap embedded subtitles are skipped during transcode instead of burned', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
|
||||
final subtitles = buildTranscodeSubtitles(client, [
|
||||
MediaSubtitleTrack(id: 401, codec: 'pgs', languageCode: 'eng', selected: true, forced: false),
|
||||
MediaSubtitleTrack(id: 402, codec: 'dvd_subtitle', languageCode: 'eng', selected: true, forced: false),
|
||||
]);
|
||||
|
||||
expect(subtitles, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -225,6 +225,20 @@ void main() {
|
||||
expect(player.addSubtitleCalls.single.uri, 'https://example/c.srt');
|
||||
});
|
||||
|
||||
test('selects subtitle sidecars marked as default', () async {
|
||||
final player = _FakePlayer();
|
||||
final mgr = _make(player: player);
|
||||
addTearDown(mgr.dispose);
|
||||
|
||||
const subs = [
|
||||
SubtitleTrack(id: 'selected', uri: 'https://example/selected.srt', isExternal: true, isDefault: true),
|
||||
];
|
||||
await mgr.addExternalSubtitles(subs);
|
||||
|
||||
expect(player.addSubtitleCalls, hasLength(1));
|
||||
expect(player.addSubtitleCalls.single.select, isTrue);
|
||||
});
|
||||
|
||||
test('a player error on one entry does not prevent others from succeeding', () async {
|
||||
final player = _FakePlayer()..failAddSubtitleTimes = 1;
|
||||
final mgr = _make(player: player);
|
||||
|
||||
@@ -13,6 +13,7 @@ void main() {
|
||||
test('clears switchable version and quality state during offline playback', () {
|
||||
final version = MediaVersion(id: 'v1', videoResolution: '1080');
|
||||
final audio = MediaAudioTrack(id: 1, languageCode: 'eng', selected: false);
|
||||
final subtitle = MediaSubtitleTrack(id: 2, languageCode: 'eng', selected: false, forced: false);
|
||||
|
||||
final result = effectiveVersionQualityControls(
|
||||
isOfflinePlayback: true,
|
||||
@@ -21,6 +22,8 @@ void main() {
|
||||
isTranscoding: true,
|
||||
sourceAudioTracks: [audio],
|
||||
selectedAudioStreamId: 1,
|
||||
sourceSubtitleTracks: [subtitle],
|
||||
selectedSubtitleStreamId: 2,
|
||||
);
|
||||
|
||||
expect(result.canSwitch, isFalse);
|
||||
@@ -29,11 +32,14 @@ void main() {
|
||||
expect(result.isTranscoding, isFalse);
|
||||
expect(result.sourceAudioTracks, isEmpty);
|
||||
expect(result.selectedAudioStreamId, isNull);
|
||||
expect(result.sourceSubtitleTracks, isEmpty);
|
||||
expect(result.selectedSubtitleStreamId, isNull);
|
||||
});
|
||||
|
||||
test('keeps switchable state during online playback', () {
|
||||
final version = MediaVersion(id: 'v1', videoResolution: '1080');
|
||||
final audio = MediaAudioTrack(id: 1, languageCode: 'eng', selected: false);
|
||||
final subtitle = MediaSubtitleTrack(id: 2, languageCode: 'eng', selected: false, forced: false);
|
||||
|
||||
final result = effectiveVersionQualityControls(
|
||||
isOfflinePlayback: false,
|
||||
@@ -42,6 +48,8 @@ void main() {
|
||||
isTranscoding: true,
|
||||
sourceAudioTracks: [audio],
|
||||
selectedAudioStreamId: 1,
|
||||
sourceSubtitleTracks: [subtitle],
|
||||
selectedSubtitleStreamId: 2,
|
||||
);
|
||||
|
||||
expect(result.canSwitch, isTrue);
|
||||
@@ -50,6 +58,8 @@ void main() {
|
||||
expect(result.isTranscoding, isTrue);
|
||||
expect(result.sourceAudioTracks, [audio]);
|
||||
expect(result.selectedAudioStreamId, 1);
|
||||
expect(result.sourceSubtitleTracks, [subtitle]);
|
||||
expect(result.selectedSubtitleStreamId, 2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user