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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user