feat: better pre-video loading indicator

This commit is contained in:
edde746
2025-12-22 20:12:26 +01:00
parent 16087cf640
commit e252532988
6 changed files with 176 additions and 35 deletions
+8
View File
@@ -45,6 +45,7 @@ class PlayerNative implements Player {
final _errorController = StreamController<String>.broadcast(); final _errorController = StreamController<String>.broadcast();
final _audioDeviceController = StreamController<AudioDevice>.broadcast(); final _audioDeviceController = StreamController<AudioDevice>.broadcast();
final _audioDevicesController = StreamController<List<AudioDevice>>.broadcast(); final _audioDevicesController = StreamController<List<AudioDevice>>.broadcast();
final _playbackRestartController = StreamController<void>.broadcast();
StreamSubscription? _eventSubscription; StreamSubscription? _eventSubscription;
bool _disposed = false; bool _disposed = false;
@@ -66,6 +67,7 @@ class PlayerNative implements Player {
error: _errorController.stream, error: _errorController.stream,
audioDevice: _audioDeviceController.stream, audioDevice: _audioDeviceController.stream,
audioDevices: _audioDevicesController.stream, audioDevices: _audioDevicesController.stream,
playbackRestart: _playbackRestartController.stream,
); );
_setupEventListener(); _setupEventListener();
@@ -240,6 +242,11 @@ class PlayerNative implements Player {
_completedController.add(false); _completedController.add(false);
break; break;
case 'playback-restart':
// Playback started/restarted - first frame is ready
_playbackRestartController.add(null);
break;
case 'log-message': case 'log-message':
final prefix = data?['prefix'] as String? ?? ''; final prefix = data?['prefix'] as String? ?? '';
final levelStr = data?['level'] as String? ?? 'info'; final levelStr = data?['level'] as String? ?? 'info';
@@ -590,5 +597,6 @@ class PlayerNative implements Player {
await _errorController.close(); await _errorController.close();
await _audioDeviceController.close(); await _audioDeviceController.close();
await _audioDevicesController.close(); await _audioDevicesController.close();
await _playbackRestartController.close();
} }
} }
+4
View File
@@ -47,6 +47,9 @@ class PlayerStreams {
/// Stream of available audio devices. /// Stream of available audio devices.
final Stream<List<AudioDevice>> audioDevices; final Stream<List<AudioDevice>> audioDevices;
/// Stream that emits when playback restarts (first frame ready after load/seek).
final Stream<void> playbackRestart;
const PlayerStreams({ const PlayerStreams({
required this.playing, required this.playing,
required this.completed, required this.completed,
@@ -62,5 +65,6 @@ class PlayerStreams {
required this.error, required this.error,
required this.audioDevice, required this.audioDevice,
required this.audioDevices, required this.audioDevices,
required this.playbackRestart,
}); });
} }
+49 -10
View File
@@ -87,6 +87,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
StreamSubscription<bool>? _bufferingSubscription; StreamSubscription<bool>? _bufferingSubscription;
StreamSubscription<Tracks>? _trackLoadingSubscription; StreamSubscription<Tracks>? _trackLoadingSubscription;
StreamSubscription<Duration>? _positionSubscription; StreamSubscription<Duration>? _positionSubscription;
StreamSubscription<void>? _playbackRestartSubscription;
bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation
bool _isDisposingForNavigation = false; bool _isDisposingForNavigation = false;
@@ -113,6 +114,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
} }
final ValueNotifier<bool> _isBuffering = ValueNotifier<bool>(false); // Track if video is currently buffering final ValueNotifier<bool> _isBuffering = ValueNotifier<bool>(false); // Track if video is currently buffering
final ValueNotifier<bool> _hasFirstFrame = ValueNotifier<bool>(false); // Track if first video frame has rendered
final ValueNotifier<bool> _isExiting = ValueNotifier<bool>(false); // Track if navigating away (for black overlay)
@override @override
void initState() { void initState() {
@@ -386,6 +389,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_isBuffering.value = isBuffering; _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) // Listen to position for completion detection (fallback for unreliable MPV events)
_positionSubscription = player!.streams.position.listen((position) { _positionSubscription = player!.streams.position.listen((position) {
final duration = player!.state.duration; final duration = player!.state.duration;
@@ -668,6 +678,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Open video through Player // Open video through Player
if (result.videoUrl != null) { if (result.videoUrl != null) {
// Reset first frame flag for new video
_hasFirstFrame.value = false;
// Pass resume position if available // Pass resume position if available
final resumePosition = widget.metadata.viewOffset != null final resumePosition = widget.metadata.viewOffset != null
? Duration(milliseconds: widget.metadata.viewOffset!) ? Duration(milliseconds: widget.metadata.viewOffset!)
@@ -885,12 +898,16 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (confirmed == true && mounted) { if (confirmed == true && mounted) {
await _watchTogetherProvider!.leaveSession(); await _watchTogetherProvider!.leaveSession();
if (mounted) Navigator.of(context).pop(true); if (mounted) {
_isExiting.value = true;
Navigator.of(context).pop(true);
}
} }
return; return;
} }
// Default behavior for hosts or non-session users // Default behavior for hosts or non-session users
_isExiting.value = true;
Navigator.of(context).pop(true); Navigator.of(context).pop(true);
} }
@@ -914,6 +931,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Dispose value notifiers // Dispose value notifiers
_isBuffering.dispose(); _isBuffering.dispose();
_hasFirstFrame.dispose();
_isExiting.dispose();
// Stop progress tracking and send final state // Stop progress tracking and send final state
_progressTracker?.sendProgress('stopped'); _progressTracker?.sendProgress('stopped');
@@ -932,6 +951,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_bufferingSubscription?.cancel(); _bufferingSubscription?.cancel();
_trackLoadingSubscription?.cancel(); _trackLoadingSubscription?.cancel();
_positionSubscription?.cancel(); _positionSubscription?.cancel();
_playbackRestartSubscription?.cancel();
// Cancel auto-play timer // Cancel auto-play timer
_autoPlayTimer?.cancel(); _autoPlayTimer?.cancel();
@@ -1374,6 +1394,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
Future<void> disposePlayerForNavigation() async { Future<void> disposePlayerForNavigation() async {
if (_isDisposingForNavigation) return; if (_isDisposingForNavigation) return;
_isDisposingForNavigation = true; _isDisposingForNavigation = true;
_isExiting.value = true; // Show black overlay during transition
try { try {
_detachFromWatchTogetherSession(); _detachFromWatchTogetherSession();
@@ -1491,6 +1512,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}, },
onBack: _handleBackButton, onBack: _handleBackButton,
canControl: canControl, canControl: canControl,
hasFirstFrame: _hasFirstFrame,
), ),
); );
}, },
@@ -1611,19 +1633,36 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
), ),
), ),
), ),
// Buffering indicator // Buffering indicator (also shows during initial load, but not when exiting)
ValueListenableBuilder<bool>( ValueListenableBuilder<bool>(
valueListenable: _isBuffering, valueListenable: _isBuffering,
builder: (context, isBuffering, child) { builder: (context, isBuffering, child) {
if (!isBuffering) return const SizedBox.shrink(); return ValueListenableBuilder<bool>(
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<bool>(
valueListenable: _isExiting,
builder: (context, isExiting, child) {
if (!isExiting) return const SizedBox.shrink();
return Positioned.fill( return Positioned.fill(
child: Center( child: Container(color: Colors.black),
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),
),
),
); );
}, },
), ),
@@ -67,6 +67,9 @@ class DesktopVideoControls extends StatefulWidget {
/// Whether the user can control playback (false in host-only mode for non-host). /// Whether the user can control playback (false in host-only mode for non-host).
final bool canControl; final bool canControl;
/// Notifier for whether first video frame has rendered (shows loading state when false).
final ValueNotifier<bool>? hasFirstFrame;
const DesktopVideoControls({ const DesktopVideoControls({
super.key, super.key,
required this.player, required this.player,
@@ -104,6 +107,7 @@ class DesktopVideoControls extends StatefulWidget {
this.serverId = '', this.serverId = '',
this.onBack, this.onBack,
this.canControl = true, this.canControl = true,
this.hasFirstFrame,
}); });
@override @override
@@ -341,14 +345,24 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return ValueListenableBuilder<bool>(
children: [ valueListenable: widget.hasFirstFrame ?? ValueNotifier(true),
// Top bar with back button and title builder: (context, hasFrame, child) {
_buildTopBar(context), return Column(
const Spacer(), children: [
// Bottom controls // Top bar with back button and title (always visible)
_buildBottomControls(context), _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<DesktopVideoControls> {
return DesktopAppBarHelper.wrapWithGestureDetector(topBar, opaque: true); return DesktopAppBarHelper.wrapWithGestureDetector(topBar, opaque: true);
} }
Widget _buildBottomControls(BuildContext context) { Widget _buildBottomControlsContent(BuildContext context, {required bool hasFrame}) {
final canInteract = widget.canControl && hasFrame;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Column( child: Column(
@@ -398,7 +413,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
focusNode: _timelineFocusNode, focusNode: _timelineFocusNode,
onKeyEvent: _handleTimelineKeyEvent, onKeyEvent: _handleTimelineKeyEvent,
onFocusChange: _onFocusChange, onFocusChange: _onFocusChange,
enabled: widget.canControl, enabled: canInteract,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
// Row 2: Playback controls and options // Row 2: Playback controls and options
@@ -38,6 +38,9 @@ class MobileVideoControls extends StatelessWidget {
/// Whether the user can control playback (false in host-only mode for non-host). /// Whether the user can control playback (false in host-only mode for non-host).
final bool canControl; final bool canControl;
/// Notifier for whether first video frame has rendered (shows loading state when false).
final ValueNotifier<bool>? hasFirstFrame;
const MobileVideoControls({ const MobileVideoControls({
super.key, super.key,
required this.player, required this.player,
@@ -56,6 +59,7 @@ class MobileVideoControls extends StatelessWidget {
this.onNext, this.onNext,
this.onPrevious, this.onPrevious,
this.canControl = true, this.canControl = true,
this.hasFirstFrame,
}); });
@override @override
@@ -98,6 +102,24 @@ class MobileVideoControls extends StatelessWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
// Check if we're still loading first frame
final hasFirstFrameNotifier = hasFirstFrame;
if (hasFirstFrameNotifier != null) {
return ValueListenableBuilder<bool>(
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<bool>( return StreamBuilder<bool>(
stream: player.streams.playing, stream: player.streams.playing,
initialData: player.state.playing, initialData: player.state.playing,
@@ -167,6 +189,24 @@ class MobileVideoControls extends StatelessWidget {
} }
Widget _buildBottomBar(BuildContext context) { Widget _buildBottomBar(BuildContext context) {
// Check if we're still loading first frame
final hasFirstFrameNotifier = hasFirstFrame;
if (hasFirstFrameNotifier != null) {
return ValueListenableBuilder<bool>(
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( return _conditionalSafeArea(
context: context, context: context,
top: false, // Only respect bottom safe area when in portrait top: false, // Only respect bottom safe area when in portrait
+50 -15
View File
@@ -58,6 +58,7 @@ Widget plexVideoControlsBuilder(
Function(Duration position)? onSeekCompleted, Function(Duration position)? onSeekCompleted,
VoidCallback? onBack, VoidCallback? onBack,
bool canControl = true, bool canControl = true,
ValueNotifier<bool>? hasFirstFrame,
}) { }) {
return PlexVideoControls( return PlexVideoControls(
player: player, player: player,
@@ -73,6 +74,7 @@ Widget plexVideoControlsBuilder(
onSeekCompleted: onSeekCompleted, onSeekCompleted: onSeekCompleted,
onBack: onBack, onBack: onBack,
canControl: canControl, 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). /// Whether the user can control playback (false in host-only mode for non-host).
final bool canControl; final bool canControl;
/// Notifier for whether first video frame has rendered (shows loading state when false).
final ValueNotifier<bool>? hasFirstFrame;
const PlexVideoControls({ const PlexVideoControls({
super.key, super.key,
required this.player, required this.player,
@@ -112,6 +117,7 @@ class PlexVideoControls extends StatefulWidget {
this.onSeekCompleted, this.onSeekCompleted,
this.onBack, this.onBack,
this.canControl = true, this.canControl = true,
this.hasFirstFrame,
}); });
@override @override
@@ -200,6 +206,15 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
}); });
// Register global key handler for focus-independent shortcuts (desktop only) // Register global key handler for focus-independent shortcuts (desktop only)
HardwareKeyboard.instance.addHandler(_handleGlobalKeyEvent); 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) /// Focus play/pause button if we're in keyboard navigation mode (desktop only)
@@ -402,6 +417,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
@override @override
void dispose() { void dispose() {
HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent); HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent);
widget.hasFirstFrame?.removeListener(_onFirstFrameReady);
_hideTimer?.cancel(); _hideTimer?.cancel();
_feedbackTimer?.cancel(); _feedbackTimer?.cancel();
_resizeDebounceTimer?.cancel(); _resizeDebounceTimer?.cancel();
@@ -487,10 +503,17 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
void _startHideTimer() { void _startHideTimer() {
_hideTimer?.cancel(); _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 // Only auto-hide if playing
if (widget.player.state.playing) { if (widget.player.state.playing) {
_hideTimer = Timer(const Duration(seconds: 3), () { _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(() { setState(() {
_showControls = false; _showControls = false;
}); });
@@ -1193,20 +1216,30 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
child: GestureDetector( child: GestureDetector(
onTap: _toggleControls, onTap: _toggleControls,
behavior: HitTestBehavior.deferToChild, behavior: HitTestBehavior.deferToChild,
child: Container( child: ValueListenableBuilder<bool>(
decoration: BoxDecoration( valueListenable: widget.hasFirstFrame ?? ValueNotifier(true),
gradient: LinearGradient( builder: (context, hasFrame, child) {
begin: Alignment.topCenter, return Container(
end: Alignment.bottomCenter, decoration: BoxDecoration(
colors: [ // Use solid black when loading, gradient when loaded
Colors.black.withValues(alpha: 0.7), color: hasFrame ? null : Colors.black,
Colors.transparent, gradient: hasFrame
Colors.transparent, ? LinearGradient(
Colors.black.withValues(alpha: 0.7), begin: Alignment.topCenter,
], end: Alignment.bottomCenter,
stops: const [0.0, 0.2, 0.8, 1.0], 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 child: isMobile
? Listener( ? Listener(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
@@ -1228,6 +1261,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
onNext: widget.onNext, onNext: widget.onNext,
onPrevious: widget.onPrevious, onPrevious: widget.onPrevious,
canControl: widget.canControl, canControl: widget.canControl,
hasFirstFrame: widget.hasFirstFrame,
), ),
) )
: Listener( : Listener(
@@ -1274,6 +1308,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
serverId: widget.metadata.serverId ?? '', serverId: widget.metadata.serverId ?? '',
onBack: widget.onBack, onBack: widget.onBack,
canControl: widget.canControl, canControl: widget.canControl,
hasFirstFrame: widget.hasFirstFrame,
), ),
), ),
), ),