Pressing pause or seeking while the player's on-screen controls were hidden raised the entire OSD, covering the subtitles the viewer was rewinding to read. Transport keys now answer with a transient indicator and leave the chrome down; Select, D-pad Center and a centre tap remain the deliberate way to bring the controls back. Play/pause confirms with an icon-only translucent disc at the centre of the frame, 72px around a 44px glyph, which grows and fades in, holds half a second at rest, then runs the same motion in reverse. Seeking shows the amount plus a single chevron on the same line at the edge it travels toward, with no backdrop at all: anything large enough to read as a surface is large enough to cover picture and subtitles, so legibility comes from shadows instead. Only the chevron moves, and it eases outward across most of its cycle and returns briefly, holding a visible opacity floor rather than blinking out. Type is scaled per platform, since a television is read from across the room. The existing text pill stays for genuine notices - rate changes, chapter titles, zoom, errors - because an earlier centred pill overlapped ASS \an8 subtitle placement, which is the readability complaint this feedback exists to answer. Every relative seek entry point now shares one coalescing primitive. The keyboard shortcuts fell through to KeyboardShortcutsService and previously reported nothing, and both they and the remote's chapter fallback rebased each press off player.state.position, so a burst against a slow backend pinned every request near one step while the indicator climbed to a total that was never committed. A released key commits its pending target immediately and resets the acceleration tier, including on live TV where seeks bypass the accumulator. A chapter seek with nowhere to go, past the last chapter or already at the start, no longer announces a jump it does not perform. Rewind-on-resume follows the resolved intent rather than the current state, so a directed pause on an already-paused video neither resumes nor rewinds. Indicators carry their own liveRegion semantics nodes: their labels previously merged into the full-screen "show playback controls" target, corrupting its accessible name, and they keep announcing "Paused"/"Playing" and the seek amount from icon-only visuals. close #1676
383 lines
17 KiB
Dart
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: CircularProgressIndicator(color: Colors.white)),
|
|
);
|
|
}
|
|
|
|
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: CircularProgressIndicator(color: Colors.white)),
|
|
],
|
|
);
|
|
}
|
|
|
|
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),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|