From e252532988be80291116ed01d9a632ef4039983a Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 22 Dec 2025 20:10:44 +0100 Subject: [PATCH] feat: better pre-video loading indicator --- lib/mpv/player/player_native.dart | 8 +++ lib/mpv/player/player_streams.dart | 4 ++ lib/screens/video_player_screen.dart | 59 ++++++++++++++--- .../desktop_video_controls.dart | 35 +++++++--- .../video_controls/mobile_video_controls.dart | 40 ++++++++++++ .../video_controls/video_controls.dart | 65 ++++++++++++++----- 6 files changed, 176 insertions(+), 35 deletions(-) diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 4a1898aa..62a117ae 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -45,6 +45,7 @@ class PlayerNative implements Player { final _errorController = StreamController.broadcast(); final _audioDeviceController = StreamController.broadcast(); final _audioDevicesController = StreamController>.broadcast(); + final _playbackRestartController = StreamController.broadcast(); StreamSubscription? _eventSubscription; bool _disposed = false; @@ -66,6 +67,7 @@ class PlayerNative implements Player { error: _errorController.stream, audioDevice: _audioDeviceController.stream, audioDevices: _audioDevicesController.stream, + playbackRestart: _playbackRestartController.stream, ); _setupEventListener(); @@ -240,6 +242,11 @@ class PlayerNative implements Player { _completedController.add(false); break; + case 'playback-restart': + // Playback started/restarted - first frame is ready + _playbackRestartController.add(null); + break; + case 'log-message': final prefix = data?['prefix'] as String? ?? ''; final levelStr = data?['level'] as String? ?? 'info'; @@ -590,5 +597,6 @@ class PlayerNative implements Player { await _errorController.close(); await _audioDeviceController.close(); await _audioDevicesController.close(); + await _playbackRestartController.close(); } } diff --git a/lib/mpv/player/player_streams.dart b/lib/mpv/player/player_streams.dart index b1189040..05f40571 100644 --- a/lib/mpv/player/player_streams.dart +++ b/lib/mpv/player/player_streams.dart @@ -47,6 +47,9 @@ class PlayerStreams { /// Stream of available audio devices. final Stream> audioDevices; + /// Stream that emits when playback restarts (first frame ready after load/seek). + final Stream playbackRestart; + const PlayerStreams({ required this.playing, required this.completed, @@ -62,5 +65,6 @@ class PlayerStreams { required this.error, required this.audioDevice, required this.audioDevices, + required this.playbackRestart, }); } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 86f840a3..b81063c3 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -87,6 +87,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin StreamSubscription? _bufferingSubscription; StreamSubscription? _trackLoadingSubscription; StreamSubscription? _positionSubscription; + StreamSubscription? _playbackRestartSubscription; bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation bool _isDisposingForNavigation = false; @@ -113,6 +114,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin } final ValueNotifier _isBuffering = ValueNotifier(false); // Track if video is currently buffering + final ValueNotifier _hasFirstFrame = ValueNotifier(false); // Track if first video frame has rendered + final ValueNotifier _isExiting = ValueNotifier(false); // Track if navigating away (for black overlay) @override void initState() { @@ -386,6 +389,13 @@ class VideoPlayerScreenState extends State with WidgetsBindin _isBuffering.value = isBuffering; }); + // Listen to playback restart to detect first frame ready + _playbackRestartSubscription = player!.streams.playbackRestart.listen((_) { + if (!_hasFirstFrame.value) { + _hasFirstFrame.value = true; + } + }); + // Listen to position for completion detection (fallback for unreliable MPV events) _positionSubscription = player!.streams.position.listen((position) { final duration = player!.state.duration; @@ -668,6 +678,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Open video through Player if (result.videoUrl != null) { + // Reset first frame flag for new video + _hasFirstFrame.value = false; + // Pass resume position if available final resumePosition = widget.metadata.viewOffset != null ? Duration(milliseconds: widget.metadata.viewOffset!) @@ -885,12 +898,16 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (confirmed == true && mounted) { await _watchTogetherProvider!.leaveSession(); - if (mounted) Navigator.of(context).pop(true); + if (mounted) { + _isExiting.value = true; + Navigator.of(context).pop(true); + } } return; } // Default behavior for hosts or non-session users + _isExiting.value = true; Navigator.of(context).pop(true); } @@ -914,6 +931,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Dispose value notifiers _isBuffering.dispose(); + _hasFirstFrame.dispose(); + _isExiting.dispose(); // Stop progress tracking and send final state _progressTracker?.sendProgress('stopped'); @@ -932,6 +951,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _bufferingSubscription?.cancel(); _trackLoadingSubscription?.cancel(); _positionSubscription?.cancel(); + _playbackRestartSubscription?.cancel(); // Cancel auto-play timer _autoPlayTimer?.cancel(); @@ -1374,6 +1394,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin Future disposePlayerForNavigation() async { if (_isDisposingForNavigation) return; _isDisposingForNavigation = true; + _isExiting.value = true; // Show black overlay during transition try { _detachFromWatchTogetherSession(); @@ -1491,6 +1512,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin }, onBack: _handleBackButton, canControl: canControl, + hasFirstFrame: _hasFirstFrame, ), ); }, @@ -1611,19 +1633,36 @@ class VideoPlayerScreenState extends State with WidgetsBindin ), ), ), - // Buffering indicator + // Buffering indicator (also shows during initial load, but not when exiting) ValueListenableBuilder( valueListenable: _isBuffering, builder: (context, isBuffering, child) { - if (!isBuffering) return const SizedBox.shrink(); + return ValueListenableBuilder( + valueListenable: _hasFirstFrame, + builder: (context, hasFrame, child) { + // Don't show spinner when exiting (just black overlay) + // Show spinner when (buffering OR loading) AND NOT exiting + if ((!isBuffering && hasFrame) || _isExiting.value) return const SizedBox.shrink(); + return Positioned.fill( + child: Center( + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), shape: BoxShape.circle), + child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 3), + ), + ), + ); + }, + ); + }, + ), + // Black overlay during exit (no spinner - just covers transparency) + ValueListenableBuilder( + valueListenable: _isExiting, + builder: (context, isExiting, child) { + if (!isExiting) return const SizedBox.shrink(); return Positioned.fill( - child: Center( - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), shape: BoxShape.circle), - child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 3), - ), - ), + child: Container(color: Colors.black), ); }, ), diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 179af18e..1d4afbb2 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -67,6 +67,9 @@ class DesktopVideoControls extends StatefulWidget { /// 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? hasFirstFrame; + const DesktopVideoControls({ super.key, required this.player, @@ -104,6 +107,7 @@ class DesktopVideoControls extends StatefulWidget { this.serverId = '', this.onBack, this.canControl = true, + this.hasFirstFrame, }); @override @@ -341,14 +345,24 @@ class DesktopVideoControlsState extends State { @override Widget build(BuildContext context) { - return Column( - children: [ - // Top bar with back button and title - _buildTopBar(context), - const Spacer(), - // Bottom controls - _buildBottomControls(context), - ], + return ValueListenableBuilder( + valueListenable: widget.hasFirstFrame ?? ValueNotifier(true), + builder: (context, hasFrame, child) { + return Column( + children: [ + // Top bar with back button and title (always visible) + _buildTopBar(context), + if (!hasFrame) + // Loading: empty space, spinner shown by video_player_screen + const Expanded(child: SizedBox.shrink()) + else ...[ + // Loaded: spacer + bottom controls + const Spacer(), + _buildBottomControlsContent(context, hasFrame: true), + ], + ], + ); + }, ); } @@ -382,7 +396,8 @@ class DesktopVideoControlsState extends State { return DesktopAppBarHelper.wrapWithGestureDetector(topBar, opaque: true); } - Widget _buildBottomControls(BuildContext context) { + Widget _buildBottomControlsContent(BuildContext context, {required bool hasFrame}) { + final canInteract = widget.canControl && hasFrame; return Padding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), child: Column( @@ -398,7 +413,7 @@ class DesktopVideoControlsState extends State { focusNode: _timelineFocusNode, onKeyEvent: _handleTimelineKeyEvent, onFocusChange: _onFocusChange, - enabled: widget.canControl, + enabled: canInteract, ), const SizedBox(height: 4), // Row 2: Playback controls and options diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 8f4b3eb9..1689b631 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -38,6 +38,9 @@ class MobileVideoControls extends StatelessWidget { /// 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? hasFirstFrame; + const MobileVideoControls({ super.key, required this.player, @@ -56,6 +59,7 @@ class MobileVideoControls extends StatelessWidget { this.onNext, this.onPrevious, this.canControl = true, + this.hasFirstFrame, }); @override @@ -98,6 +102,24 @@ class MobileVideoControls extends StatelessWidget { return const SizedBox.shrink(); } + // Check if we're still loading first frame + final hasFirstFrameNotifier = hasFirstFrame; + if (hasFirstFrameNotifier != null) { + return ValueListenableBuilder( + valueListenable: hasFirstFrameNotifier, + builder: (context, hasFrame, child) { + if (!hasFrame) { + // Empty space, spinner shown by video_player_screen + return const SizedBox.shrink(); + } + return _buildPlaybackControlsContent(context); + }, + ); + } + return _buildPlaybackControlsContent(context); + } + + Widget _buildPlaybackControlsContent(BuildContext context) { return StreamBuilder( stream: player.streams.playing, initialData: player.state.playing, @@ -167,6 +189,24 @@ class MobileVideoControls extends StatelessWidget { } Widget _buildBottomBar(BuildContext context) { + // Check if we're still loading first frame + final hasFirstFrameNotifier = hasFirstFrame; + if (hasFirstFrameNotifier != null) { + return ValueListenableBuilder( + valueListenable: hasFirstFrameNotifier, + builder: (context, hasFrame, child) { + if (!hasFrame) { + // Hide timeline while loading + return const SizedBox.shrink(); + } + return _buildBottomBarContent(context); + }, + ); + } + return _buildBottomBarContent(context); + } + + Widget _buildBottomBarContent(BuildContext context) { return _conditionalSafeArea( context: context, top: false, // Only respect bottom safe area when in portrait diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 0a2c7839..0f6eb15f 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -58,6 +58,7 @@ Widget plexVideoControlsBuilder( Function(Duration position)? onSeekCompleted, VoidCallback? onBack, bool canControl = true, + ValueNotifier? hasFirstFrame, }) { return PlexVideoControls( player: player, @@ -73,6 +74,7 @@ Widget plexVideoControlsBuilder( onSeekCompleted: onSeekCompleted, onBack: onBack, canControl: canControl, + hasFirstFrame: hasFirstFrame, ); } @@ -97,6 +99,9 @@ class PlexVideoControls extends StatefulWidget { /// 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? hasFirstFrame; + const PlexVideoControls({ super.key, required this.player, @@ -112,6 +117,7 @@ class PlexVideoControls extends StatefulWidget { this.onSeekCompleted, this.onBack, this.canControl = true, + this.hasFirstFrame, }); @override @@ -200,6 +206,15 @@ class _PlexVideoControlsState extends State with WindowListen }); // 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); + } + + /// Called when hasFirstFrame changes - start auto-hide timer when first frame is ready + void _onFirstFrameReady() { + if (widget.hasFirstFrame?.value == true) { + _startHideTimer(); + } } /// Focus play/pause button if we're in keyboard navigation mode (desktop only) @@ -402,6 +417,7 @@ class _PlexVideoControlsState extends State with WindowListen @override void dispose() { HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent); + widget.hasFirstFrame?.removeListener(_onFirstFrameReady); _hideTimer?.cancel(); _feedbackTimer?.cancel(); _resizeDebounceTimer?.cancel(); @@ -487,10 +503,17 @@ class _PlexVideoControlsState extends State with WindowListen void _startHideTimer() { _hideTimer?.cancel(); + + // Don't auto-hide while loading first frame (user needs to see spinner and back button) + final hasFrame = widget.hasFirstFrame?.value ?? true; + if (!hasFrame) return; + // Only auto-hide if playing if (widget.player.state.playing) { _hideTimer = Timer(const Duration(seconds: 3), () { - if (mounted && widget.player.state.playing) { + // Also check hasFirstFrame in callback (in case it changed) + final stillLoading = !(widget.hasFirstFrame?.value ?? true); + if (mounted && widget.player.state.playing && !stillLoading) { setState(() { _showControls = false; }); @@ -1193,20 +1216,30 @@ class _PlexVideoControlsState extends State with WindowListen child: GestureDetector( onTap: _toggleControls, behavior: HitTestBehavior.deferToChild, - child: Container( - decoration: BoxDecoration( - gradient: 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], - ), - ), + child: ValueListenableBuilder( + valueListenable: widget.hasFirstFrame ?? ValueNotifier(true), + 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, @@ -1228,6 +1261,7 @@ class _PlexVideoControlsState extends State with WindowListen onNext: widget.onNext, onPrevious: widget.onPrevious, canControl: widget.canControl, + hasFirstFrame: widget.hasFirstFrame, ), ) : Listener( @@ -1274,6 +1308,7 @@ class _PlexVideoControlsState extends State with WindowListen serverId: widget.metadata.serverId ?? '', onBack: widget.onBack, canControl: widget.canControl, + hasFirstFrame: widget.hasFirstFrame, ), ), ),