feat(player): start Plex transcodes at the resume position (#1817)

A Plex transcode session always starts producing at zero: the decision
request never sent offset=, so any non-zero open - resuming a
transcoded title, or switching from Direct Play to a transcoded
quality mid-playback - opened a session whose produced window begins
at the start of the file and seeked it. mpv immediately requests a
segment the transcoder has not produced, PMS answers 404 for it and
every subsequent segment, and playback buffers forever.

Send offset=<seconds> (6dp) with the decision and start request - the
view offset on initial open, the resolved resume position on every
in-place reload - so the session begins producing at the position the
player consumes first. The playlist timeline is unchanged: an offset
session's media playlist still covers the full title from segment
zero, so the player keeps opening with start: at the resume position
and in-stream seeks work as before.

Before a native player opens an offset playlist, waitForTranscodeReady
walks the master playlist, the media playlist, and the segment
containing the offset, because PMS can publish a manifest before that
segment is fetchable and mpv treats the 404 as an HLS error. The probe
is best-effort: it never fails an open, hands off immediately on HTTP
500 (on the response and exception paths alike) so the server-limit
dialog stays prompt, stops on cancellation, skips itself when the
playlist durations never reach the offset, and stays out of the
endpoint-failover cascade. In-place reloads resolve the replacement
source only after the old stop report has gone out, so Plex cannot use
that stop to terminate the replacement transcode.

close #1840
This commit is contained in:
toluLikesToCode
2026-08-09 06:28:59 +02:00
committed by edde746
parent bb3762ed63
commit 8740a19f36
10 changed files with 694 additions and 13 deletions
@@ -632,11 +632,22 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
}
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
// Overlap the old item's stop report with the resolve round-trip; it
// is awaited again right before the open below.
// Local resume lookup can overlap the old stop, but source resolution
// below must not: Plex can use that stop to terminate any new
// transcode sharing this playback session identifier.
final stoppedProgressFuture = _sendStoppedProgressOnce();
var openResumePosition = await _resolveOpenResumePosition(
metadata: metadata,
isOffline: _offlineLibraryMode,
offlineWatchService: offlineWatchService,
requested: resumePosition,
);
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
final playbackResolver = PlaybackSourceResolver(serverManager: serverManager, database: database);
await stoppedProgressFuture;
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
final playbackContext = await playbackResolver.resolve(
PlaybackInitializationOptions(
metadata: metadata,
@@ -649,6 +660,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
preferredSubtitleTrack: initializationSubtitleTrack,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
transcodeOffset: openResumePosition,
),
offlineLibraryMode: _offlineLibraryMode,
);
@@ -661,7 +673,17 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
if (result.videoUrl == null) {
throw PlaybackException('No video URL available');
}
if (result.isOffline && !_offlineLibraryMode) {
// The pre-resolve lookup assumed an online source; a download won
// instead, so consult locally tracked progress after all.
openResumePosition = await _resolveOpenResumePosition(
metadata: metadata,
isOffline: true,
offlineWatchService: offlineWatchService,
requested: resumePosition,
);
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
}
var subtitleSelection = await _resolveSubtitleSelectionForOpen(
metadata: metadata,
result: result,
@@ -688,14 +710,6 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback);
}
final openResumePosition = await _resolveOpenResumePosition(
metadata: metadata,
isOffline: _offlineLibraryMode || result.isOffline,
offlineWatchService: offlineWatchService,
requested: resumePosition,
);
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
final displayCriteria = result.mediaInfo?.displayCriteria;
final settingsService = await SettingsService.getInstance();
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
@@ -731,7 +745,6 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
resumePosition: openResumePosition,
durationMs: metadata.durationMs,
);
await stoppedProgressFuture;
_progressTracker?.stopTracking();
_progressTracker?.dispose();
_progressTracker = null;
@@ -753,6 +766,12 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
externalSubtitles: subtitleSelection.sidecarsAtOpen,
);
var effectiveExternalSubtitlePlan = externalSubtitlePlan;
await _awaitTranscodeReadiness(
client: mediaClient,
isTranscoding: result.isTranscoding,
videoUrl: result.videoUrl!,
);
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
final openResult = await _openMediaOnPlayer(
player: currentPlayer,
settingsService: settingsService,
@@ -602,6 +602,24 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
await player.setProperty('stream-buffer-size', '${ringBytes ?? mpvDefaultStreamBufferBytes}');
}
/// Best-effort wait for an offset transcode session's segment at the
/// resume point, run immediately before the player opens the URL so the
/// wait hides behind the other pre-open work and the guarantee is fresh
/// when the player attaches. A not-ready session still opens — mpv
/// classifies whatever the server actually returns — and no-offset URLs
/// return immediately. Starting a new probe aborts the previous one so a
/// superseded open never leaves it polling out its window.
Future<void> _awaitTranscodeReadiness({
required MediaServerClient? client,
required bool isTranscoding,
required String videoUrl,
}) async {
if (!isTranscoding || client is! PlexClient) return;
_transcodeReadinessAbort?.abort();
final abort = _transcodeReadinessAbort = AbortController();
await client.waitForTranscodeReady(videoUrl, abort: abort);
}
/// Open [videoUrl] on [player]: stream tuning → open → native subtitle style.
///
/// [shouldContinue] is re-checked between the awaits so stale generations
@@ -259,6 +259,12 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
resumePosition: resumePosition,
durationMs: _currentMetadata.durationMs,
);
await _awaitTranscodeReadiness(
client: playbackContext.reportingClient,
isTranscoding: result.isTranscoding,
videoUrl: result.videoUrl!,
);
if (!attempt.isCurrent) return;
final openResult = await _openMediaOnPlayer(
player: currentPlayer,
settingsService: settingsService,
+13
View File
@@ -75,6 +75,7 @@ import '../providers/shader_provider.dart';
import '../providers/user_profile_provider.dart';
import '../utils/app_logger.dart';
import '../utils/dialogs.dart';
import '../utils/media_server_http_client.dart' show AbortController;
import '../utils/log_redaction_manager.dart';
import '../utils/live_tv_player_navigation.dart';
import '../utils/player_utils.dart';
@@ -431,6 +432,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_PlaybackTransitionLease? _playbackTransitionLease;
Completer<void>? _playbackTransitionIdleCompleter;
bool _playbackIntentShouldPlay = true;
/// In-flight transcode readiness probe, aborted by the next probe or by
/// dispose so a superseded open never leaves it polling out its window.
AbortController? _transcodeReadinessAbort;
int _pendingSubtitleCycleCount = 0;
bool _subtitleCycleDrainActive = false;
@@ -1268,6 +1273,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
preferredSubtitleTrack: _preferredSubtitleTrack,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
// The initial resume position is the server view offset (the
// online open resolves the same value later), so a resumed
// transcode starts producing at the resume point instead of
// seeking a stream that begins at zero.
transcodeOffset: _currentMetadata.viewOffsetMs != null
? Duration(milliseconds: _currentMetadata.viewOffsetMs!)
: null,
),
offlineLibraryMode: false,
);
@@ -1688,6 +1700,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
@override
void dispose() {
unawaited(AndroidExitDiagnostics.markUiState(AndroidUiState.mainScreen));
_transcodeReadinessAbort?.abort();
_playerInitializationGeneration++;
_frameRate.dispose();
WidgetsBinding.instance.removeObserver(this);