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 _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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user