From 0cb4eccb41f00d3f89e5d1b6888b419c79fc3c2b Mon Sep 17 00:00:00 2001 From: JenteJan Date: Mon, 8 Jun 2026 07:18:09 +0200 Subject: [PATCH] perf(player): cut wasted per-frame rebuilds during playback Profiling steady-state playback on macOS (libmpv path, shared with Windows) showed the Flutter layer doing needless work while watching, even though the video itself is composited cheaply by mpv. - Unmount the overlay controls once their hide animation finishes, so their position-driven Consumers stop rebuilding while the overlay is hidden (the common 'just watching' case). Remount before fading back in. Steady-state UI work drops to ~3 idle frames/sec. - Only build the progress-bar scrub-preview card while it is actually visible; it was building and laying out its image/trickplay subtree on every frame behind opacity 0 (the dominant LAYOUT cost when controls are shown). - Narrow the audio queue / queue-dialog watches to repeatMode via select() instead of watching the whole playback model, so they no longer rebuild and re-map the entire queue on every position tick. No behavioural change; pure rebuild-scope reduction. --- .../components/audio_player_queue_dialog.dart | 5 ++- .../components/video_player_queue.dart | 5 ++- .../components/video_progress_bar.dart | 5 ++- .../video_player/video_player_controls.dart | 38 ++++++++++++++----- 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/lib/screens/video_player/components/audio_player_queue_dialog.dart b/lib/screens/video_player/components/audio_player_queue_dialog.dart index eadb592ec..73bc6148f 100644 --- a/lib/screens/video_player/components/audio_player_queue_dialog.dart +++ b/lib/screens/video_player/components/audio_player_queue_dialog.dart @@ -53,11 +53,12 @@ class AudioQueueDialog extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final playbackInfo = ref.watch(mediaPlaybackProvider); final currentModel = ref.watch(playBackModel); final player = ref.watch(videoPlayerProvider); - final shouldWrap = playbackInfo.repeatMode == AudioRepeatMode.all; + // Only depend on repeatMode here; watching the whole model rebuilt the + // entire dialog (and re-mapped the queue) on every position tick. + final shouldWrap = ref.watch(mediaPlaybackProvider.select((value) => value.repeatMode == AudioRepeatMode.all)); final items = player.audioQueueForDisplay(wrapAround: shouldWrap); final currentItem = currentModel?.item; final tempStart = player.temporaryQueueStartInDisplay(wrapAround: shouldWrap); diff --git a/lib/screens/video_player/components/video_player_queue.dart b/lib/screens/video_player/components/video_player_queue.dart index 4c1b6c4e0..339783a83 100644 --- a/lib/screens/video_player/components/video_player_queue.dart +++ b/lib/screens/video_player/components/video_player_queue.dart @@ -98,10 +98,11 @@ class VideoPlayerQueue extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final playbackInfo = ref.watch(mediaPlaybackProvider); final player = ref.watch(videoPlayerProvider); - final shouldWrap = playbackInfo.repeatMode == AudioRepeatMode.all; + // Only depend on repeatMode here; watching the whole model rebuilt the + // entire queue (and re-mapped it) on every position tick. + final shouldWrap = ref.watch(mediaPlaybackProvider.select((value) => value.repeatMode == AudioRepeatMode.all)); final providerItems = player.audioQueueForDisplay(wrapAround: shouldWrap); final items = providerItems.isNotEmpty ? providerItems : this.items; final tempStart = player.temporaryQueueStartInDisplay(wrapAround: shouldWrap); diff --git a/lib/screens/video_player/components/video_progress_bar.dart b/lib/screens/video_player/components/video_progress_bar.dart index 7c7989fe6..aeccc465c 100644 --- a/lib/screens/video_player/components/video_progress_bar.dart +++ b/lib/screens/video_player/components/video_progress_bar.dart @@ -209,7 +209,10 @@ class _ChapterProgressSliderState extends ConsumerState { ), ), if (!widget.buffering) ...[ - chapterCard(context, position, isVisible), + // Only build the scrub-preview card while it is actually visible + // (hovering/dragging the bar). Otherwise it would build + lay out + // its image/trickplay subtree on every frame behind opacity 0. + if (isVisible) chapterCard(context, position, isVisible), Positioned( left: (constraints.maxWidth / (widget.duration.inMilliseconds / position.inMilliseconds)) .clamp(1, constraints.maxWidth), diff --git a/lib/screens/video_player/video_player_controls.dart b/lib/screens/video_player/video_player_controls.dart index c5ef094f6..796da5148 100644 --- a/lib/screens/video_player/video_player_controls.dart +++ b/lib/screens/video_player/video_player_controls.dart @@ -64,6 +64,11 @@ class _DesktopControlsState extends ConsumerState { final fadeDuration = const Duration(milliseconds: 350); bool showOverlay = true; + + /// Whether the overlay controls are kept in the widget tree. They are + /// unmounted once the hide animation finishes so their position-driven + /// Consumers stop rebuilding while the user is just watching. + bool _overlayMounted = true; bool wasPlaying = false; SystemUiMode? _currentSystemUiMode; @@ -171,13 +176,23 @@ class _DesktopControlsState extends ConsumerState { child: AnimatedOpacity( duration: fadeDuration, opacity: showOverlay ? 1 : 0, - child: Column( - children: [ - topButtons(context), - const Spacer(), - bottomButtons(context), - ], - ), + onEnd: () { + // Once fully faded out, drop the controls from the tree so + // their position-driven Consumers stop rebuilding while + // the overlay is hidden. + if (!showOverlay && _overlayMounted) { + setState(() => _overlayMounted = false); + } + }, + child: _overlayMounted + ? Column( + children: [ + topButtons(context), + const Spacer(), + bottomButtons(context), + ], + ) + : const SizedBox.shrink(), ), ), VideoPlayerSeekIndicator(controller: _seekController), @@ -747,8 +762,13 @@ class _DesktopControlsState extends ConsumerState { } void toggleOverlay({bool? value}) { - if (showOverlay == (value ?? !showOverlay)) return; - setState(() => showOverlay = (value ?? !showOverlay)); + final newValue = value ?? !showOverlay; + if (showOverlay == newValue) return; + setState(() { + showOverlay = newValue; + // Remount the controls before fading them back in. + if (newValue) _overlayMounted = true; + }); resetTimer(); final desiredMode = showOverlay ? SystemUiMode.edgeToEdge : SystemUiMode.immersiveSticky;