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 _audioDeviceController = StreamController<AudioDevice>.broadcast();
final _audioDevicesController = StreamController<List<AudioDevice>>.broadcast();
final _playbackRestartController = StreamController<void>.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();
}
}
+4
View File
@@ -47,6 +47,9 @@ class PlayerStreams {
/// Stream of available audio devices.
final Stream<List<AudioDevice>> audioDevices;
/// Stream that emits when playback restarts (first frame ready after load/seek).
final Stream<void> 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,
});
}
+49 -10
View File
@@ -87,6 +87,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
StreamSubscription<bool>? _bufferingSubscription;
StreamSubscription<Tracks>? _trackLoadingSubscription;
StreamSubscription<Duration>? _positionSubscription;
StreamSubscription<void>? _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<VideoPlayerScreen> with WidgetsBindin
}
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
void initState() {
@@ -386,6 +389,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin
_bufferingSubscription?.cancel();
_trackLoadingSubscription?.cancel();
_positionSubscription?.cancel();
_playbackRestartSubscription?.cancel();
// Cancel auto-play timer
_autoPlayTimer?.cancel();
@@ -1374,6 +1394,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
Future<void> 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<VideoPlayerScreen> with WidgetsBindin
},
onBack: _handleBackButton,
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>(
valueListenable: _isBuffering,
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(
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),
);
},
),
@@ -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<bool>? 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<DesktopVideoControls> {
@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<bool>(
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<DesktopVideoControls> {
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<DesktopVideoControls> {
focusNode: _timelineFocusNode,
onKeyEvent: _handleTimelineKeyEvent,
onFocusChange: _onFocusChange,
enabled: widget.canControl,
enabled: canInteract,
),
const SizedBox(height: 4),
// 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).
final bool canControl;
/// Notifier for whether first video frame has rendered (shows loading state when false).
final ValueNotifier<bool>? 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<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>(
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<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(
context: context,
top: false, // Only respect bottom safe area when in portrait
+50 -15
View File
@@ -58,6 +58,7 @@ Widget plexVideoControlsBuilder(
Function(Duration position)? onSeekCompleted,
VoidCallback? onBack,
bool canControl = true,
ValueNotifier<bool>? 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<bool>? 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<PlexVideoControls> 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<PlexVideoControls> 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<PlexVideoControls> 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<PlexVideoControls> 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<bool>(
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<PlexVideoControls> with WindowListen
onNext: widget.onNext,
onPrevious: widget.onPrevious,
canControl: widget.canControl,
hasFirstFrame: widget.hasFirstFrame,
),
)
: Listener(
@@ -1274,6 +1308,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
serverId: widget.metadata.serverId ?? '',
onBack: widget.onBack,
canControl: widget.canControl,
hasFirstFrame: widget.hasFirstFrame,
),
),
),