Files
plezy/lib/screens/video_player/parts/build.dart
T
edde746 bbed260169 fix(player): start the TV player with its chrome down
A television raised the whole OSD and timebar on every playback start. The
chrome controller is born visible, and its auto-hide clock cannot arm until the
first frame lands, so the controls did not merely appear early: they appeared
exactly when the picture did, and then sat over the opening five seconds of
every movie and episode. The timeline is gated behind the first frame, so the
bar materialised on top of the video rather than over the loading spinner,
which is what makes it read as a pop-up rather than as chrome that was already
there.

The route now opens with no chrome on TV. Nothing is lost: the loading spinner
and buffering overlay are their own overlays, the screen focus node owns back,
and the first D-pad press raises the controls the way it already does after
every auto-hide. Pointer and touch platforms keep the chrome, where the
viewer's hand is on the surface and the title and back affordance belong over
the spinner.

Initial presentation now follows initial visibility. They were separate:
seeding only visibility would leave the route claiming its chrome was still
presented, so PlayerNavigationCoordinator would read back as "hide the chrome",
hide() would no-op against chrome that was never up, and the press would be
swallowed instead of leaving the player.

Controls that mount with the chrome already down now claim focus themselves.
Focus normally reaches them through the hide transition, and their own
autofocus cannot win it back because the screen node took it during the loading
phase. Left alone, the screen node kept primary focus and its self-heal raised
the entire OSD on the first D-pad press, which put the chrome straight back
over the picture and bypassed the transient seek and transport indicators.

Both player spinners now carry a label. They were bare progress indicators, so
a screen reader announced nothing at all while the picture was coming up, and
the TV Maestro flows had no way left to tell a loading player from a playing
one once the Pause button stopped appearing on its own.

The two TV flows are repaired to match. They waited on that button, and now
wait for the labelled spinner to clear, which cannot happen before the media is
opened. 05 additionally reaches Search by D-pad rather than a percentage
coordinate, because a tap flips InputModeTracker to pointer mode and collapses
the rail it is aiming at, and it gates on the play-next prompt's own Cancel
action: "Next Episode" is also the credits skip button, so the old assertion
could pass without the prompt ever opening.

close #1765
2026-08-02 11:45:27 +02:00

383 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;
final nextZoomScale = VideoFilterManager.normalizeZoomScale(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 {
onNext = (_nextEpisode != null && 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),
],
),
),
),
);
}
}