fix(plex): support no-burn transcode subtitles

This commit is contained in:
edde746
2026-05-20 17:21:31 +02:00
parent 2b167a60f5
commit c93a266722
34 changed files with 1103 additions and 192 deletions
+17 -2
View File
@@ -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.
+5 -1
View File
@@ -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
+8 -1
View File
@@ -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();
+29 -10
View File
@@ -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 []);
}
+5 -1
View File
@@ -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
+27 -1
View File
@@ -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;
+138
View File
@@ -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;
}
}
}
+4 -1
View File
@@ -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;
+4 -1
View File
@@ -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
View File
@@ -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,
),
);
+27 -3
View File
@@ -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;
}
}
+8
View File
@@ -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,
),
)
+37
View File
@@ -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');
});
});
}
+43
View File
@@ -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);
});
}
+14
View File
@@ -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);
+10
View File
@@ -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);
});
});