Files
plezy/lib/screens/video_player/parts/build.dart
T
edde746 b97a22c213 fix(player): report every AudioTrack release so a failed one can recover
An episode that opens but never plays, forever, with no error and no way out
except force-quitting the app. The reporter's log has the whole shape: media
opens at 85206ms, the first video frame renders, `AudioTrack init failed 0
Config(48000, 252, 5, 40000)` is logged exactly once, and the position never
moves again. Force-quitting fixes it for a while, which is the tell — the state
that breaks recovery is process-wide and static.

`DefaultAudioSink` releases its `AudioOutput` on every flush — every seek, every
renderer disable, every reconfigure — and increments a private static
`pendingReleaseCount` as it does. It decrements only from `Listener::onReleased`.
`RawPositionAudioOutput.release` never called `delegate.release()` for a
cacheable output, and it forwarded `addListener` straight through, so the sink's
listener sat on the real output while the wrapper was parked and the increment
was never balanced. media3's own delivery is lossy too: it posts `onReleased` to
the playback looper, which `ExoPlayer.release()` has already quit by the time the
20ms-delayed release runs, so even a real release drops its decrement at
teardown.

A counter that never returns to zero silently disables media3's escalation of
both init and write failures: `PendingExceptionHolder` arms its throw deadline
only when nothing is pending, and short-circuits every retry while something is.
So the `InitializationException` is never thrown, the audio renderer never
becomes ready, and the player is pinned in `STATE_BUFFERING`. No
`PlaybackException` means `retryAfterAudioTrackError` never runs, which is why
the same failure recovered onto decoded PCM earlier in the same log and hung
outright later.

The wrapper now owns the listener set and answers every flush exactly once: at
once when it parks the track, because a parked track is never going to release;
on the delegate's confirmation for a real release; and from the provider at
teardown, where nothing else ever will. Bitstream outputs are not parked at all —
a direct route is often single-instance and a parked one would block its own
successor.

An eviction therefore builds its replacement while the old AudioTrack is still
going away, as upstream does. Holding the count open across the park to buy
media3 patience for that window was tried and is worse: it pins the counter above
zero for the whole live track after the first seek, which is the hang above.
Refusing to allocate until the release confirms is worse too — the refusal
reaches media3 as an init failure with no pending release to excuse it, so the
200ms deadline starts immediately and a slow TV teardown turns an ordinary config
change into a playback error. If the overlapping allocation does fail, media3
escalates into the audio recovery ladder and the watchdog below backs it up.

Because no amount of accounting hygiene guarantees media3 will raise the next
failure, add the watchdog that was missing. Nothing covered "buffering, holding
data, not moving": the frame watchdog wants `STATE_READY` and zero frames, the
decoder-hang check is cancelled by the first frame, `ResumeStallPolicy` treats a
frozen clock as explicitly not its business, `EndOfStreamPolicy` wants the
position past the duration, and media3's stuck-buffering detector wants an empty
buffer. `BufferingStallPolicy` covers exactly that hole and escalates through the
existing audio ladder — now shared with the exception path — then to the mpv
backend rather than leaving a spinner up.

The watchdog only indicts a player that could have started. `DefaultLoadControl`
is configured to hold playback until 5s is buffered after a rebuffer, so the
stall threshold is derived from that same constant rather than guessing at one,
and a buffer below it reads as starved — the loader's business, not the
renderer's. Starvation also restarts the stall clock, so a minute of network
rebuffering cannot bank the timeout and have the first poll after recovery
report a stall that never happened.

Also raise the passthrough buffer to a second. media3 defaults it to 250ms, which
the AC3 factor doubles to the 40000 bytes that failed here, and 1.10.1's only
retry is to keep halving; upstream adopted the same 1s floor in #3207.

Recovery now resumes from the furthest position reached rather than `lastPosition`,
which the poller writes down as freely as up — a dead clock reporting 0 is how an
audio recovery restarted a resumed episode from the top. On the Dart side the
episode loading flags are cleared on every exit of the in-place reload, not just
the success and rollback paths; a flag stranded by a superseded reload made the
Next button a no-op for the rest of the session.

close #1790
2026-08-05 12:03:05 +02:00

391 lines
17 KiB
Dart

part of '../../video_player_screen.dart';
extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
static const double _videoLayoutSizeTolerance = 0.1;
static const double _pinchZoomActivationThreshold = 0.06;
static const int _pinchZoomActivationUpdateThreshold = 3;
bool _isSameVideoLayoutSize(Size a, Size b) {
return (a.width - b.width).abs() <= _videoLayoutSizeTolerance &&
(a.height - b.height).abs() <= _videoLayoutSizeTolerance;
}
void _scheduleVideoLayoutUpdate(Size newSize) {
final currentPlayer = player;
if (currentPlayer == null) return;
final lastSize = _lastVideoLayoutSize;
if (_lastVideoLayoutPlayer == currentPlayer && lastSize != null && _isSameVideoLayoutSize(lastSize, newSize)) {
return;
}
_pendingVideoLayoutSize = newSize;
if (_videoLayoutUpdateScheduled) return;
_videoLayoutUpdateScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_videoLayoutUpdateScheduled = false;
if (!mounted) return;
final pendingSize = _pendingVideoLayoutSize;
final currentPlayer = player;
_pendingVideoLayoutSize = null;
if (pendingSize == null || currentPlayer == null) return;
final lastSize = _lastVideoLayoutSize;
if (_lastVideoLayoutPlayer == currentPlayer &&
lastSize != null &&
_isSameVideoLayoutSize(lastSize, pendingSize)) {
return;
}
_lastVideoLayoutSize = pendingSize;
_lastVideoLayoutPlayer = currentPlayer;
_videoFilterManager?.updatePlayerSize(pendingSize);
_updateAmbientLightingOnResize(pendingSize);
unawaited(currentPlayer.updateFrame());
});
}
PlaybackSourceSubtitleChoice? _selectedSourceSubtitleChoiceForControls(List<MediaSubtitleTrack> tracks) {
if (tracks.isEmpty) return null;
final selection = _playbackSession?.subtitleSelection;
if (selection != null) {
if (selection.isOff) return const PlaybackSourceSubtitleChoice.off();
final sourceId = selection.primarySourceStreamId;
if (sourceId != null && tracks.any((track) => track.id == sourceId)) {
return PlaybackSourceSubtitleChoice.source(sourceId);
}
}
for (final track in tracks) {
if (track.selected) return PlaybackSourceSubtitleChoice.source(track.id);
}
return const PlaybackSourceSubtitleChoice.off();
}
List<PlaybackSubtitleSidecar> _sourceSubtitleSidecarsForControls() =>
_playbackSession?.context.result.subtitleSidecars ?? const <PlaybackSubtitleSidecar>[];
List<MediaSubtitleTrack> _sourceSubtitleTracksForControls() {
final sidecarSourceIds = {for (final sidecar in _sourceSubtitleSidecarsForControls()) ?sidecar.sourceStreamId};
return selectableSourceSubtitleTracks(
_currentMediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[],
isTranscoding: _isTranscoding,
sidecarSourceIds: sidecarSourceIds,
supportsEmbeddedTranscodeSelection: _currentMetadata.backend == MediaBackend.plex,
);
}
Widget _buildLoadingSpinner() {
return const Scaffold(
backgroundColor: Colors.black,
body: Center(child: PlayerLoadingIndicator()),
);
}
Widget _buildPlayerInitializationSurface() {
final bootstrapPlayer = _bootstrapPlayer;
if (bootstrapPlayer == null) return _buildLoadingSpinner();
// Linux creates the texture before its EGL/mpv render bootstrap can be
// proven. Mount the provisional surface so Flutter drives one texture
// copy, while retaining the black loading cover until playback itself
// reports its first frame.
return Stack(
fit: StackFit.expand,
children: [
Video(player: bootstrapPlayer, hasFirstFrame: _hasFirstFrame),
const Center(child: PlayerLoadingIndicator()),
],
);
}
Widget _buildInitializationError(String message) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: .min,
children: [
const AppIcon(Symbols.error_rounded, color: Colors.white70, size: 44, fill: 1),
const SizedBox(height: 16),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 16),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: .center,
children: [
FocusableButton(
autofocus: true,
onPressed: _retryPlayerInitialization,
child: FilledButton(onPressed: _retryPlayerInitialization, child: Text(t.common.retry)),
),
const SizedBox(width: 12),
FocusableButton(
onPressed: () => unawaited(_handleBackButton()),
child: OutlinedButton(
onPressed: () => unawaited(_handleBackButton()),
child: Text(t.common.back),
),
),
],
),
],
),
),
),
),
);
}
void _startMobileZoomGesture() {
final filterManager = _videoFilterManager;
if (filterManager == null || _isPinchZooming) return;
_isPinchZooming = true;
_pinchZoomActivationUpdateCount = 0;
_pinchZoomChanged = false;
_pinchStartZoomScale = filterManager.zoomScale;
}
void _clearMobileZoomGesture() {
_isPinchZooming = false;
_pinchZoomActivationUpdateCount = 0;
_pinchZoomChanged = false;
_pinchStartZoomScale = null;
}
Widget _buildVideoPlayer(BuildContext context) {
// Cache platform detection to avoid multiple calls
final isMobile = PlatformDetector.isMobile(context);
final hideChromeOnMouseExit = !(isMobile && !PlatformDetector.isTV());
// Back handling (sheet-close + player exit) is owned by the OverlaySheetHost
// that wraps this widget — see video_player_screen.dart (canPop/onSystemBack).
return Scaffold(
// Use transparent background on macOS when native video layer is active
backgroundColor: Colors.transparent,
body: GestureDetector(
behavior: HitTestBehavior.translucent, // Allow taps to pass through to controls
onScaleStart: (details) {
if (!isMobile) return;
if (details.pointerCount >= 2) _startMobileZoomGesture();
},
onScaleUpdate: (details) {
if (!isMobile) return;
if (details.pointerCount < 2) return;
if (!_isPinchZooming) _startMobileZoomGesture();
final startZoom = _pinchStartZoomScale;
final filterManager = _videoFilterManager;
if (!_isPinchZooming || startZoom == null || filterManager == null) return;
// Snap through 100% so pinching back undoes a zoom exactly, which is
// the touch path to an unzoomed picture (#1505).
final nextZoomScale = VideoFilterManager.normalizeZoomScale(
VideoFilterManager.snapPinchZoomScale(startZoom * details.scale),
);
if (!_pinchZoomChanged) {
if ((details.scale - 1.0).abs() <= _pinchZoomActivationThreshold) {
_pinchZoomActivationUpdateCount = 0;
return;
}
_pinchZoomActivationUpdateCount++;
if (_pinchZoomActivationUpdateCount < _pinchZoomActivationUpdateThreshold) return;
if (nextZoomScale == filterManager.zoomScale) return;
_pinchZoomChanged = true;
_ambientLightingService?.disable();
}
filterManager.setZoomScale(nextZoomScale);
},
onScaleEnd: (details) {
if (!isMobile) return;
if (!_isPinchZooming) return;
if (!_pinchZoomChanged) {
_clearMobileZoomGesture();
return;
}
final zoomScale = _videoFilterManager?.zoomScale ?? 1.0;
_showZoomToast(zoomScale);
_clearMobileZoomGesture();
_setPlayerState(() {});
},
child: PlayerChromeInteractionRegion(
controller: _chromeController,
hideOnExit: hideChromeOnMouseExit,
child: Stack(
children: [
// macOS PiP placeholder — video is in PiP window, show background with icon
// Placed before Video so controls render on top
if (Platform.isMacOS) const VideoPlayerMacPipPlaceholder(),
Center(
child: LayoutBuilder(
builder: (context, constraints) {
final newSize = Size(constraints.maxWidth, constraints.maxHeight);
_scheduleVideoLayoutUpdate(newSize);
var authority = (canControlPlayback: true, canNavigateMediaItems: true);
try {
authority = context
.select<WatchTogetherProvider, ({bool canControlPlayback, bool canNavigateMediaItems})>(
(wt) => (
canControlPlayback: !wt.isInSession || wt.canControl(),
canNavigateMediaItems: !wt.isInSession || wt.isHost,
),
);
} catch (_) {
// Watch Together is optional outside the main app shell.
}
if (_lastMediaControlAuthority != authority) {
_lastMediaControlAuthority = authority;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(_syncMediaControlsAvailability());
});
}
VoidCallback? onNext;
if (widget.isLive) {
onNext = _hasNextChannel ? () => _switchLiveChannel(1) : null;
} else {
// _playNext no-ops while a navigation is in flight; matching that here
// keeps the control from looking live while it does nothing.
onNext = (_nextEpisode != null && !_isLoadingNext && authority.canNavigateMediaItems)
? _playNext
: null;
}
VoidCallback? onPrevious;
if (widget.isLive) {
onPrevious = _hasPreviousChannel ? () => _switchLiveChannel(-1) : null;
} else {
final canRestartOrPrevious = _currentMetadata.isEpisode || _previousEpisode != null;
onPrevious = (canRestartOrPrevious && authority.canNavigateMediaItems)
? _restartOrPlayPrevious
: null;
}
final sourceAudioTracks = _currentMediaInfo?.audioTracks ?? const <MediaAudioTrack>[];
final sourceSubtitleSidecars = _sourceSubtitleSidecarsForControls();
final sourceSubtitleTracks = _sourceSubtitleTracksForControls();
return Video(
player: player!,
hasFirstFrame: _hasFirstFrame,
controls: (context) => PlexVideoControls(
player: player!,
volumeController: _volumeController!,
metadata: _currentMetadata,
onNext: onNext,
onPrevious: onPrevious,
availableVersions: _availableVersions,
selectedMediaIndex: _effectiveSelectedMediaIndex,
selectedQualityPreset: _selectedQualityPreset,
serverSupportsTranscoding: _serverSupportsTranscoding,
isTranscoding: _isTranscoding,
isOfflinePlayback: _isOfflinePlayback,
sourceAudioTracks: sourceAudioTracks,
selectedAudioStreamId: _selectedAudioStreamId,
sourceSubtitleTracks: sourceSubtitleTracks,
selectedSubtitleChoice: _selectedSourceSubtitleChoiceForControls(sourceSubtitleTracks),
selectedSecondarySubtitleStreamId: _playbackSession?.subtitleSelection.secondarySourceStreamId,
sourceSubtitleSidecars: sourceSubtitleSidecars,
sourcePartId: _currentMediaInfo?.partId,
onPlaybackSourceChanged: _switchPlaybackSource,
onTogglePIPMode: _togglePIPMode,
boxFitMode: _videoFilterManager?.boxFitMode ?? 0,
videoZoomScale: _videoFilterManager?.zoomScale ?? 1.0,
onCycleBoxFitMode: _cycleBoxFitMode,
onVideoZoomChanged: _setVideoZoom,
onZoomIn: _zoomVideoIn,
onZoomOut: _zoomVideoOut,
onResetVideoZoom: _resetVideoZoom,
onCycleAudioTrack: _cycleAudioTrack,
onCycleSubtitleTrack: _cycleSubtitleTrack,
onAudioTrackChanged: _onAudioTrackChanged,
onSubtitleTrackChanged: _onSubtitleTrackChanged,
onSecondarySubtitleTrackChanged: _onSecondarySubtitleTrackChanged,
onSeekRequested: _seekPlayback,
onPlayPauseRequested: _handleControlsTransport,
onSeekCompleted: _notifyWatchTogetherSeek,
onBack: _handleBackButton,
onReachedEnd: ({skipAutoPlayCountdown = false}) =>
_onVideoCompleted(true, skipAutoPlayCountdown: skipAutoPlayCountdown),
canControl: authority.canControlPlayback,
canNavigateMediaItems: authority.canNavigateMediaItems,
hasFirstFrame: _hasFirstFrame,
playNextFocusNode: _showPlayNextDialog ? _playNextConfirmFocusNode : null,
chromeController: _chromeController,
shaderService: _shaderService,
// ignore: no-empty-block - state update triggers rebuild to reflect shader change
onShaderChanged: () => _setPlayerState(() {}),
thumbnailDataBuilder: _scrubPreviewSource?.isAvailable == true ? _getThumbnailData : null,
isLive: widget.isLive,
liveChannelName: _live.channelName,
captureBuffer: _live.captureBuffer,
isAtLiveEdge: _live.atLiveEdge,
streamStartEpoch: _live.streamStartEpoch,
currentPositionEpoch: widget.isLive ? _currentPositionEpoch : null,
onLiveSeek: _live.captureBuffer != null ? _seekLiveToEpoch : null,
onLiveSeekBy: _live.captureBuffer != null ? _liveSeek.seekBy : null,
onJumpToLive: _live.captureBuffer != null && !_live.atLiveEdge ? _jumpToLiveEdge : null,
isAmbientLightingEnabled: _ambientLightingService?.isEnabled ?? false,
onToggleAmbientLighting: _ambientLightingService?.isSupported == true
? _toggleAmbientLighting
: null,
toastController: _toastController,
),
);
},
),
),
// Netflix-style auto-play overlay (hidden in PiP mode)
VideoPlayerPlayNextOverlay(
visible: _showPlayNextDialog,
nextEpisode: _nextEpisode,
autoPlayCountdown: _autoPlayCountdown,
cancelFocusNode: _playNextCancelFocusNode,
confirmFocusNode: _playNextConfirmFocusNode,
chromeController: _chromeController,
onCancel: _cancelAutoPlay,
onPlayNext: _playNext,
),
// "Still watching?" overlay (hidden in PiP mode)
VideoPlayerStillWatchingOverlay(
visible: _showStillWatchingPrompt,
countdown: _stillWatchingCountdown,
pauseFocusNode: _stillWatchingPauseFocusNode,
continueFocusNode: _stillWatchingContinueFocusNode,
chromeController: _chromeController,
onPause: _onStillWatchingPause,
onContinue: _onStillWatchingContinue,
),
// Buffering indicator (also shows during initial load, but not when exiting)
// Hidden in PiP mode
VideoPlayerBufferingOverlay(
isBuffering: _isBuffering,
hasFirstFrame: _hasFirstFrame,
isExiting: _isExiting,
),
// Watch Together overlays (isolated from video surface repaints)
const VideoPlayerWatchTogetherOverlays(),
// Black overlay during exit (no spinner - just covers transparency)
VideoPlayerExitOverlay(isExiting: _isExiting),
],
),
),
),
);
}
}