feat: better pre-video loading indicator
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user