Files
plezy/lib/widgets/video_controls/video_controls.dart
T

868 lines
37 KiB
Dart

import 'dart:async' show StreamSubscription, Timer, unawaited;
import 'dart:io' show Platform;
import 'package:flutter/gestures.dart' show PointerSignalEvent, PointerScrollEvent;
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:rate_limiter/rate_limiter.dart';
import 'package:flutter/services.dart'
show
SystemChrome,
DeviceOrientation,
LogicalKeyboardKey,
PhysicalKeyboardKey,
KeyEvent,
KeyDownEvent,
KeyUpEvent,
HardwareKeyboard;
import '../../services/fullscreen_state_manager.dart';
import '../../services/macos_window_service.dart';
import '../../services/pip_service.dart';
import 'package:window_manager/window_manager.dart';
import '../../mixins/settings_effect_mixin.dart';
import '../../mixins/mounted_set_state_mixin.dart';
import '../../mpv/mpv.dart';
import '../overlay_sheet.dart';
import '../../focus/dpad_navigator.dart';
import '../../database/app_database.dart';
import '../../media/media_backend.dart';
import '../../media/media_item.dart';
import '../../models/livetv_capture_buffer.dart';
import '../../providers/multi_server_provider.dart';
import '../../media/media_source_info.dart';
import '../../models/transcode_quality_preset.dart';
import '../../media/media_version.dart';
import '../../screens/video_player_screen.dart';
import '../../focus/key_event_utils.dart';
import '../../services/keyboard_shortcuts_service.dart';
import '../../services/scrub_preview_source.dart';
import '../../services/settings_service.dart';
import '../../utils/formatters.dart';
import '../../utils/platform_detector.dart';
import '../../utils/player_utils.dart';
import '../../theme/mono_tokens.dart';
import '../../utils/provider_extensions.dart';
import '../../utils/snackbar_helper.dart';
import 'icons.dart';
import 'playback_extras_loader.dart';
import 'widgets/player_toast_indicator.dart';
import '../../utils/app_logger.dart';
import '../../i18n/strings.g.dart';
import '../../focus/input_mode_tracker.dart';
import 'models/track_controls_state.dart';
import 'widgets/double_tap_feedback.dart';
import 'widgets/linux_keep_alive.dart';
import 'widgets/mobile_skip_zones.dart';
import 'widgets/skip_marker_button.dart';
import 'widgets/track_chapter_controls.dart';
import 'widgets/performance_overlay/performance_overlay.dart';
import 'mobile_video_controls.dart';
import 'desktop_video_controls.dart';
import 'package:provider/provider.dart';
import '../../models/shader_preset.dart';
import '../../providers/playback_state_provider.dart';
import '../../providers/shader_provider.dart';
import '../../services/shader_service.dart';
part 'parts/key_events.dart';
part 'parts/markers.dart';
part 'parts/navigation.dart';
part 'parts/playback_extras.dart';
part 'parts/playback_input.dart';
part 'parts/track_controls.dart';
part 'parts/visibility.dart';
/// Custom video controls builder for Plex with chapter, audio, and subtitle support
Widget plexVideoControlsBuilder(
Player player,
MediaItem metadata, {
VoidCallback? onNext,
VoidCallback? onPrevious,
List<MediaVersion>? availableVersions,
int? selectedMediaIndex,
TranscodeQualityPreset selectedQualityPreset = TranscodeQualityPreset.original,
bool serverSupportsTranscoding = false,
bool isTranscoding = false,
bool isOfflinePlayback = false,
List<MediaAudioTrack> sourceAudioTracks = const [],
int? selectedAudioStreamId,
VoidCallback? onTogglePIPMode,
int boxFitMode = 0,
VoidCallback? onCycleBoxFitMode,
VoidCallback? onCycleAudioTrack,
VoidCallback? onCycleSubtitleTrack,
Function(AudioTrack)? onAudioTrackChanged,
Function(SubtitleTrack)? onSubtitleTrackChanged,
Function(SubtitleTrack)? onSecondarySubtitleTrackChanged,
Function(Duration position)? onSeekCompleted,
VoidCallback? onBack,
void Function({required bool skipAutoPlayCountdown})? onReachedEnd,
bool canControl = true,
ValueNotifier<bool>? hasFirstFrame,
FocusNode? playNextFocusNode,
ValueNotifier<bool>? controlsVisible,
ShaderService? shaderService,
VoidCallback? onShaderChanged,
ScrubFrame? Function(Duration time)? thumbnailDataBuilder,
bool isLive = false,
String? liveChannelName,
CaptureBuffer? captureBuffer,
bool isAtLiveEdge = true,
double streamStartEpoch = 0,
int? currentPositionEpoch,
ValueChanged<int>? onLiveSeek,
VoidCallback? onJumpToLive,
bool isAmbientLightingEnabled = false,
VoidCallback? onToggleAmbientLighting,
required PlayerToastController toastController,
}) {
return PlexVideoControls(
player: player,
metadata: metadata,
toastController: toastController,
onNext: onNext,
onPrevious: onPrevious,
availableVersions: availableVersions ?? [],
selectedMediaIndex: selectedMediaIndex ?? 0,
selectedQualityPreset: selectedQualityPreset,
serverSupportsTranscoding: serverSupportsTranscoding,
isTranscoding: isTranscoding,
isOfflinePlayback: isOfflinePlayback,
sourceAudioTracks: sourceAudioTracks,
selectedAudioStreamId: selectedAudioStreamId,
boxFitMode: boxFitMode,
onTogglePIPMode: onTogglePIPMode,
onCycleBoxFitMode: onCycleBoxFitMode,
onCycleAudioTrack: onCycleAudioTrack,
onCycleSubtitleTrack: onCycleSubtitleTrack,
onAudioTrackChanged: onAudioTrackChanged,
onSubtitleTrackChanged: onSubtitleTrackChanged,
onSecondarySubtitleTrackChanged: onSecondarySubtitleTrackChanged,
onSeekCompleted: onSeekCompleted,
onBack: onBack,
onReachedEnd: onReachedEnd,
canControl: canControl,
hasFirstFrame: hasFirstFrame,
playNextFocusNode: playNextFocusNode,
controlsVisible: controlsVisible,
shaderService: shaderService,
onShaderChanged: onShaderChanged,
thumbnailDataBuilder: thumbnailDataBuilder,
isLive: isLive,
liveChannelName: liveChannelName,
captureBuffer: captureBuffer,
isAtLiveEdge: isAtLiveEdge,
streamStartEpoch: streamStartEpoch,
currentPositionEpoch: currentPositionEpoch,
onLiveSeek: onLiveSeek,
onJumpToLive: onJumpToLive,
isAmbientLightingEnabled: isAmbientLightingEnabled,
onToggleAmbientLighting: onToggleAmbientLighting,
);
}
@visibleForTesting
({
List<MediaVersion> availableVersions,
bool serverSupportsTranscoding,
bool isTranscoding,
List<MediaAudioTrack> sourceAudioTracks,
int? selectedAudioStreamId,
bool canSwitch,
})
effectiveVersionQualityControls({
required bool isOfflinePlayback,
required List<MediaVersion> availableVersions,
required bool serverSupportsTranscoding,
required bool isTranscoding,
required List<MediaAudioTrack> sourceAudioTracks,
required int? selectedAudioStreamId,
}) {
if (isOfflinePlayback) {
return (
availableVersions: const <MediaVersion>[],
serverSupportsTranscoding: false,
isTranscoding: false,
sourceAudioTracks: const <MediaAudioTrack>[],
selectedAudioStreamId: null,
canSwitch: false,
);
}
return (
availableVersions: availableVersions,
serverSupportsTranscoding: serverSupportsTranscoding,
isTranscoding: isTranscoding,
sourceAudioTracks: sourceAudioTracks,
selectedAudioStreamId: selectedAudioStreamId,
canSwitch: true,
);
}
class PlexVideoControls extends StatefulWidget {
final Player player;
final MediaItem metadata;
final VoidCallback? onNext;
final VoidCallback? onPrevious;
final List<MediaVersion> availableVersions;
final int selectedMediaIndex;
final TranscodeQualityPreset selectedQualityPreset;
final bool serverSupportsTranscoding;
final bool isTranscoding;
final bool isOfflinePlayback;
final List<MediaAudioTrack> sourceAudioTracks;
final int? selectedAudioStreamId;
final int boxFitMode;
final VoidCallback? onTogglePIPMode;
final VoidCallback? onCycleBoxFitMode;
final VoidCallback? onCycleAudioTrack;
final VoidCallback? onCycleSubtitleTrack;
final Function(AudioTrack)? onAudioTrackChanged;
final Function(SubtitleTrack)? onSubtitleTrackChanged;
final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged;
/// Called when a seek operation completes (for Watch Together sync)
final Function(Duration position)? onSeekCompleted;
/// Called when back button is pressed (for Watch Together session leave confirmation)
final VoidCallback? onBack;
/// Called when the video has effectively reached the end (e.g. credits extend
/// to EOF and can't be seeked past). Parent should route this into its normal
/// completion flow so the auto-play-next setting is honored.
final void Function({required bool skipAutoPlayCountdown})? onReachedEnd;
/// Whether the user can control playback (false in host-only mode for non-host).
final bool canControl;
/// Notifier for whether first video frame has rendered (shows loading state when false).
final ValueNotifier<bool>? hasFirstFrame;
/// Optional focus node for Play Next dialog button (for TV navigation from timeline)
final FocusNode? playNextFocusNode;
/// Notifier to report controls visibility to parent (for popup positioning)
final ValueNotifier<bool>? controlsVisible;
/// Optional shader service for MPV shader control
final ShaderService? shaderService;
/// Called when shader preset changes
final VoidCallback? onShaderChanged;
/// Optional callback that returns thumbnail image bytes for a given timestamp.
final ScrubFrame? Function(Duration time)? thumbnailDataBuilder;
/// Whether this is a live TV stream (disables seek, progress, etc.)
final bool isLive;
/// Channel name for live TV display
final String? liveChannelName;
/// Capture buffer for live TV time-shift (null = no time-shift support)
final CaptureBuffer? captureBuffer;
/// Whether playback is at the live edge
final bool isAtLiveEdge;
/// Epoch seconds corresponding to player position 0 (for live TV)
final double streamStartEpoch;
/// Current playback position as absolute epoch seconds (for live TV)
final int? currentPositionEpoch;
/// Seek callback for live TV time-shift (epoch seconds)
final ValueChanged<int>? onLiveSeek;
/// Jump to live edge callback
final VoidCallback? onJumpToLive;
/// Whether ambient lighting is enabled (passed to settings sheet)
final bool isAmbientLightingEnabled;
/// Called to toggle ambient lighting (passed to settings sheet)
final VoidCallback? onToggleAmbientLighting;
/// Toast controller for VLC-style in-player notifications (rate changes, backend switch).
final PlayerToastController toastController;
const PlexVideoControls({
super.key,
required this.player,
required this.metadata,
required this.toastController,
this.onNext,
this.onPrevious,
this.availableVersions = const [],
this.selectedMediaIndex = 0,
this.selectedQualityPreset = TranscodeQualityPreset.original,
this.serverSupportsTranscoding = false,
this.isTranscoding = false,
this.isOfflinePlayback = false,
this.sourceAudioTracks = const [],
this.selectedAudioStreamId,
this.boxFitMode = 0,
this.onTogglePIPMode,
this.onCycleBoxFitMode,
this.onCycleAudioTrack,
this.onCycleSubtitleTrack,
this.onAudioTrackChanged,
this.onSubtitleTrackChanged,
this.onSecondarySubtitleTrackChanged,
this.onSeekCompleted,
this.onBack,
this.onReachedEnd,
this.canControl = true,
this.hasFirstFrame,
this.playNextFocusNode,
this.controlsVisible,
this.shaderService,
this.onShaderChanged,
this.thumbnailDataBuilder,
this.isLive = false,
this.liveChannelName,
this.captureBuffer,
this.isAtLiveEdge = true,
this.streamStartEpoch = 0,
this.currentPositionEpoch,
this.onLiveSeek,
this.onJumpToLive,
this.isAmbientLightingEnabled = false,
this.onToggleAmbientLighting,
});
@override
State<PlexVideoControls> createState() => _PlexVideoControlsState();
}
class _PlexVideoControlsState extends State<PlexVideoControls>
with WindowListener, SettingsEffectMixin, MountedSetStateMixin {
bool _showControls = true;
bool _forceShowControls = false;
bool _isLoadingExtras = false;
List<MediaChapter> _chapters = [];
bool _chaptersLoaded = false;
Timer? _hideTimer;
bool _isFullscreen = false;
bool _isAlwaysOnTop = false;
late final FocusNode _focusNode;
KeyboardShortcutsService? _keyboardService;
// Live settings — read through the service so a change anywhere in the app
// reflects here without a manual reload. UI rebuilds are wired via
// [bindRebuild] in [initState]; side effects (rotation, sync) via [bindEffect].
SettingsService get _settings => SettingsService.instanceOrNull!;
int get _seekTimeSmall => _settings.read(SettingsService.seekTimeSmall);
int get _rewindOnResume => _settings.read(SettingsService.rewindOnResume);
int get _audioSyncOffset => _settings.read(SettingsService.audioSyncOffset);
int get _subtitleSyncOffset => _settings.read(SettingsService.subtitleSyncOffset);
bool get _isRotationLocked => _settings.read(SettingsService.rotationLocked);
bool _isScreenLocked = false; // Touch lock during playback
bool _showLockIcon = false; // Whether to show the lock overlay icon
Timer? _lockIconTimer;
bool get _clickVideoTogglesPlayback => _settings.read(SettingsService.clickVideoTogglesPlayback);
bool get _showChapterMarkersOnTimeline => _settings.read(SettingsService.showChapterMarkersOnTimeline);
bool _isContentStripVisible = false; // Whether the swipe-up content strip is showing
int _trafficLightVisibilityGeneration = 0;
// GlobalKey to access DesktopVideoControls state for focus management
final GlobalKey<DesktopVideoControlsState> _desktopControlsKey = GlobalKey<DesktopVideoControlsState>();
// Double-tap feedback state
bool _showDoubleTapFeedback = false;
double _doubleTapFeedbackOpacity = 0.0;
bool _lastDoubleTapWasForward = true;
Timer? _feedbackTimer;
int _accumulatedSkipSeconds = 0; // Stacking skip: total skip during active feedback
// Custom tap detection state (more reliable than Flutter's onDoubleTap)
DateTime? _lastSkipTapTime;
bool _lastSkipTapWasForward = true;
DateTime? _lastSkipActionTime; // Debounce: prevents double-tap counting as 2 skips
Timer? _singleTapTimer; // Timer for delayed single-tap action (toggle controls)
// Seek throttle
late final Throttle _seekThrottle;
// Current marker state
MediaMarker? _currentMarker;
List<MediaMarker> _markers = [];
bool _markersLoaded = false;
// Playback state subscription for auto-hide timer
StreamSubscription<bool>? _playingSubscription;
// Completed subscription to show controls when video ends
StreamSubscription<bool>? _completedSubscription;
// Position subscription for marker tracking
StreamSubscription<Duration>? _positionSubscription;
// Auto-skip state
bool get _autoSkipIntro => _settings.read(SettingsService.autoSkipIntro);
bool get _autoSkipCredits => _settings.read(SettingsService.autoSkipCredits);
int get _autoSkipDelay => _settings.read(SettingsService.autoSkipDelay);
Timer? _autoSkipTimer;
double _autoSkipProgress = 0.0;
// Skip button dismiss state
bool _skipButtonDismissed = false;
Timer? _skipButtonDismissTimer;
// Video player navigation (use arrow keys to navigate controls)
bool get _videoPlayerNavigationEnabled => _settings.read(SettingsService.videoPlayerNavigationEnabled);
// Performance overlay
bool get _showPerformanceOverlay => _settings.read(SettingsService.showPerformanceOverlay);
bool get _autoHidePerformanceOverlay => _settings.read(SettingsService.autoHidePerformanceOverlay);
// Long-press 2x speed state
bool _isLongPressing = false;
// Subtitle visibility toggle state
bool _subtitlesVisible = true;
// Skip marker button focus node (for TV D-pad navigation)
late final FocusNode _skipMarkerFocusNode;
final ValueNotifier<bool> _fallbackHasFirstFrame = ValueNotifier<bool>(true);
final Stopwatch _pointerActivityStopwatch = Stopwatch()..start();
int _lastPointerActivityMs = -1000;
double? _rateBeforeLongPress;
bool _showSpeedIndicator = false;
StreamSubscription<double>? _rateSubscription;
double? _lastReportedRate;
// Suppression window used when long-press ends so the rate-restore emission
// doesn't flash a second pill as the rate snaps back.
DateTime? _suppressRateToastUntil;
// PiP support
bool _isPipSupported = false;
final PipService _pipService = PipService();
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_skipMarkerFocusNode = FocusNode(debugLabel: 'SkipMarkerButton');
_seekThrottle = throttle(
(Duration pos) {
unawaited(_seekToPosition(pos, notifyCompletion: false));
},
const Duration(milliseconds: 200),
leading: true,
trailing: true,
);
// Side effects: rotation lock + focus on nav-enable. Both fire immediately
// so init wiring (orientation, focus) lives in one place.
bindEffect<bool>(SettingsService.rotationLocked, _applyRotationLock);
bindEffect<bool>(SettingsService.videoPlayerNavigationEnabled, (enabled) {
if (enabled && _showControls) _focusPlayPauseIfKeyboardMode();
}, fireImmediately: false);
// Rebuild on any setting that affects build output (seek labels, skip
// logic, perf overlay visibility, click-toggles, etc.).
bindRebuild([
SettingsService.seekTimeSmall,
SettingsService.rewindOnResume,
SettingsService.audioSyncOffset,
SettingsService.subtitleSyncOffset,
SettingsService.rotationLocked,
SettingsService.autoSkipIntro,
SettingsService.autoSkipCredits,
SettingsService.autoSkipDelay,
SettingsService.videoPlayerNavigationEnabled,
SettingsService.showPerformanceOverlay,
SettingsService.autoHidePerformanceOverlay,
SettingsService.clickVideoTogglesPlayback,
SettingsService.showChapterMarkersOnTimeline,
]);
_startHideTimer();
_initKeyboardService();
_listenToPosition();
_listenToPlayingState();
_listenToCompleted();
_checkPipSupport();
// Add window listener for tracking fullscreen state (for button icon)
if (PlatformDetector.isDesktopOS()) {
if (Platform.isMacOS) {
_isFullscreen = FullscreenStateManager().isFullscreen;
FullscreenStateManager().addListener(_onFullscreenStateChanged);
}
windowManager.addListener(this);
_initAlwaysOnTopState();
}
// Register global key handler for focus-independent shortcuts (desktop only)
HardwareKeyboard.instance.addHandler(_handleGlobalKeyEvent);
// Listen for first frame to start auto-hide timer
widget.hasFirstFrame?.addListener(_onFirstFrameReady);
// Listen for external requests to show controls (e.g. screen-level focus recovery)
widget.controlsVisible?.addListener(_onControlsVisibleExternal);
// On macOS, show controls and disable auto-hide when PiP activates
if (Platform.isMacOS) {
_pipService.isPipActive.addListener(_onMacPipChanged);
}
// Defer context-dependent initialization to after first build
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
// Subscribe to rate stream *after* first frame so the initial
// setRate(defaultSpeed) emission during player startup is missed.
_lastReportedRate = widget.player.state.rate;
_rateSubscription = widget.player.streams.rate.listen(_onRateChanged);
_loadPlaybackExtras();
_focusPlayPauseIfKeyboardMode();
});
}
void _setControlsState(VoidCallback fn) => setStateIfMounted(fn);
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent);
widget.controlsVisible?.removeListener(_onControlsVisibleExternal);
widget.hasFirstFrame?.removeListener(_onFirstFrameReady);
_hideTimer?.cancel();
_feedbackTimer?.cancel();
_lockIconTimer?.cancel();
_autoSkipTimer?.cancel();
_skipButtonDismissTimer?.cancel();
_singleTapTimer?.cancel();
_seekThrottle.cancel();
_playingSubscription?.cancel();
_completedSubscription?.cancel();
_positionSubscription?.cancel();
_rateSubscription?.cancel();
_focusNode.dispose();
_skipMarkerFocusNode.dispose();
_fallbackHasFirstFrame.dispose();
// Restore original rate if long-press was active when disposed
if (_isLongPressing && _rateBeforeLongPress != null) {
widget.player.setRate(_rateBeforeLongPress!);
}
// Remove window listener and reset always-on-top if it was enabled
if (PlatformDetector.isDesktopOS()) {
windowManager.removeListener(this);
if (_isAlwaysOnTop) {
windowManager.setAlwaysOnTop(false);
}
}
if (Platform.isMacOS) {
FullscreenStateManager().removeListener(_onFullscreenStateChanged);
_pipService.isPipActive.removeListener(_onMacPipChanged);
_trafficLightVisibilityGeneration++;
unawaited(MacOSWindowService.setTrafficLightsVisible(true));
}
super.dispose();
}
void _onFullscreenStateChanged() {
final isFullscreen = FullscreenStateManager().isFullscreen;
if (!mounted || _isFullscreen == isFullscreen) return;
setState(() {
_isFullscreen = isFullscreen;
});
_updateTrafficLightVisibility();
}
@override
void onWindowEnterFullScreen() {
if (mounted) {
setState(() {
_isFullscreen = true;
});
}
}
@override
void onWindowLeaveFullScreen() {
if (mounted) {
setState(() {
_isFullscreen = false;
});
}
}
@override
void onWindowMaximize() {
// On macOS, maximize is the same as fullscreen (green button)
if (mounted && Platform.isMacOS) {
setState(() {
_isFullscreen = true;
});
}
}
@override
void onWindowUnmaximize() {
// On macOS, unmaximize means exiting fullscreen
if (mounted && Platform.isMacOS) {
setState(() {
_isFullscreen = false;
});
}
}
@override
// ignore: no-empty-block - required by WindowListener interface
void onWindowResize() {}
@override
Widget build(BuildContext context) {
final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV();
// Hide ALL controls when in PiP mode (except macOS where main window stays visible)
return ValueListenableBuilder<bool>(
valueListenable: _pipService.isPipActive,
builder: (context, isInPip, _) {
if (isInPip && !Platform.isMacOS) return const SizedBox.shrink();
return Focus(
focusNode: _focusNode,
autofocus: true,
onKeyEvent: (node, event) => _handleControlsKeyEvent(event, isMobile),
child: Listener(
behavior: HitTestBehavior.translucent,
onPointerSignal: _handlePointerSignal,
child: MouseRegion(
cursor: (_showControls || _forceShowControls) ? SystemMouseCursors.basic : SystemMouseCursors.none,
onHover: (_) => _showControlsFromPointerActivity(),
onExit: (_) => _hideControlsFromPointerExit(),
child: Stack(
children: [
// Keep-alive: 1px widget that continuously repaints to prevent
// Flutter animations from freezing when the frame clock goes idle
if (Platform.isLinux || Platform.isWindows)
const Positioned(top: 0, left: 0, child: LinuxKeepAlive()),
// Also handles long-press for 2x speed.
Positioned.fill(
child: GestureDetector(
onTap: _handleOuterTap,
onLongPressStart: (_) => _handleLongPressStart(),
onLongPressEnd: (_) => _handleLongPressEnd(),
onLongPressCancel: _handleLongPressCancel,
behavior: HitTestBehavior.opaque,
child: const ColoredBox(color: Colors.transparent),
),
),
// Mobile double-tap zones for skip forward/backward
if (isMobile)
MobileSkipZones(
onTapInSkipZone: (isForward) => _handleTapInSkipZone(isForward: isForward),
onLongPressStart: (_) => _handleLongPressStart(),
onLongPressEnd: (_) => _handleLongPressEnd(),
onLongPressCancel: _handleLongPressCancel,
),
// Custom controls overlay
// Positioned AFTER double-tap zones so controls receive taps first
Positioned.fill(
child: IgnorePointer(
ignoring: !_showControls,
child: FocusScope(
// Prevent focus from entering controls when hidden
canRequestFocus: _showControls || _forceShowControls,
child: AnimatedOpacity(
opacity: (_showControls || _forceShowControls) ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: Builder(
builder: (context) {
return GestureDetector(
onTapUp: (details) => _handleControlsOverlayTap(details, _sizeOf(context)),
onLongPressStart: (_) => _handleLongPressStart(),
onLongPressEnd: (_) => _handleLongPressEnd(),
onLongPressCancel: _handleLongPressCancel,
behavior: HitTestBehavior.deferToChild,
child: ValueListenableBuilder<bool>(
valueListenable: widget.hasFirstFrame ?? _fallbackHasFirstFrame,
builder: (context, hasFrame, child) {
return Container(
decoration: BoxDecoration(
// Use solid black when loading, gradient when loaded
color: hasFrame ? null : Colors.black,
gradient: hasFrame
? LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.7),
Colors.transparent,
Colors.transparent,
Colors.black.withValues(alpha: 0.7),
],
stops: const [0.0, 0.2, 0.8, 1.0],
)
: null,
),
child: child,
);
},
child: isMobile
? Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (_) {
if (!_isContentStripVisible) _restartHideTimerIfPlaying();
},
child: Builder(
builder: (context) {
final playbackState = context.watch<PlaybackStateProvider>();
final hasStripContent =
_chapters.isNotEmpty || playbackState.isQueueActive;
return MobileVideoControls(
player: widget.player,
metadata: widget.metadata,
chapters: _chapters,
chaptersLoaded: _chaptersLoaded,
showChapterMarkersOnTimeline: _showChapterMarkersOnTimeline,
seekTimeSmall: _seekTimeSmall,
trackChapterControls: _buildTrackChapterControlsWidget(
hideChaptersAndQueue: hasStripContent,
),
onSeek: _throttledSeek,
onSeekEnd: _finalizeSeek,
onSeekCompleted: widget.onSeekCompleted,
// ignore: no-empty-block - play/pause handled by parent VideoControlsState
onPlayPause: () {},
onCancelAutoHide: () => _hideTimer?.cancel(),
onStartAutoHide: _startHideTimer,
onBack: widget.onBack,
onNext: widget.onNext,
onPrevious: widget.onPrevious,
canControl: widget.canControl,
hasFirstFrame: widget.hasFirstFrame,
thumbnailDataBuilder: widget.thumbnailDataBuilder,
isLive: widget.isLive,
liveChannelName: widget.liveChannelName,
captureBuffer: widget.captureBuffer,
isAtLiveEdge: widget.isAtLiveEdge,
streamStartEpoch: widget.streamStartEpoch,
onLiveSeek: widget.onLiveSeek,
serverId: widget.metadata.serverId,
showQueueTab: playbackState.isQueueActive,
onQueueItemSelected: playbackState.isQueueActive
? _onQueueItemSelected
: null,
controlsVisible: widget.controlsVisible,
onStripVisibilityChanged: (visible) {
setState(() => _isContentStripVisible = visible);
if (visible) {
_hideTimer?.cancel();
} else {
_restartHideTimerIfPlaying();
}
},
);
},
),
)
: _buildDesktopControlsListener(),
),
);
},
),
),
),
),
),
// Visual feedback overlay for double-tap
if (isMobile && _showDoubleTapFeedback)
Positioned.fill(
child: IgnorePointer(
child: AnimatedOpacity(
opacity: _doubleTapFeedbackOpacity,
duration: tokens(context).slow,
child: DoubleTapFeedback(
isForward: _lastDoubleTapWasForward,
seconds: _accumulatedSkipSeconds,
),
),
),
),
// Speed indicator overlay for long-press 2x
if (_showSpeedIndicator) Positioned.fill(child: IgnorePointer(child: _buildSpeedIndicator())),
// Stream-driven VLC-style pill (rate changes, backend-switch notifications)
Positioned.fill(
child: IgnorePointer(
child: ListenableBuilder(
listenable: widget.toastController,
builder: (context, _) {
final toast = widget.toastController.current;
if (toast == null) return const SizedBox.shrink();
return AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: PlayerToastIndicator(
key: ValueKey('${toast.icon.codePoint}:${toast.text}'),
icon: toast.icon,
text: toast.text,
),
);
},
),
),
),
// Skip intro/credits button (auto-dismisses after 7s, then only shows with controls)
if (_currentMarker != null &&
widget.playNextFocusNode == null &&
(!_skipButtonDismissed || _showControls))
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
right: 24,
bottom: () {
if (!_showControls) return 24.0;
if (_isContentStripVisible) return 180.0;
return isMobile ? 80.0 : 115.0;
}(),
child: AnimatedOpacity(
opacity: 1.0,
duration: tokens(context).slow,
child: _buildSkipMarkerButton(),
),
),
if (_showPerformanceOverlay)
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
top: _showControls && isMobile ? 80.0 : 16.0,
left: 16,
child: AnimatedOpacity(
opacity: (!_autoHidePerformanceOverlay || _showControls || _forceShowControls) ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: IgnorePointer(child: PlayerPerformanceOverlay(player: widget.player)),
),
),
if (_isScreenLocked)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
setState(() => _showLockIcon = true);
_startLockIconHideTimer();
},
onLongPress: _unlockScreen,
child: AnimatedOpacity(
opacity: _showLockIcon ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: const BorderRadius.all(Radius.circular(28)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const AppIcon(Symbols.lock_rounded, fill: 1, color: Colors.white, size: 20),
const SizedBox(width: 8),
Text(
t.videoControls.longPressToUnlock,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
),
),
],
),
),
),
);
},
);
}
}