From d38f7cf78f4d9316d85fb0e4dabea7f926af4986 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 9 May 2026 09:38:25 +0200 Subject: [PATCH] perf(player): reduce playback UI churn --- lib/screens/video_player/parts/build.dart | 59 +++++++--- lib/screens/video_player_screen.dart | 4 + .../video_controls/parts/visibility.dart | 5 + .../video_controls/video_controls.dart | 7 +- .../widgets/timeline_slider.dart | 109 ++++++++++++++---- macos/Runner/MpvPlayer/MpvPlayerCore.swift | 4 - 6 files changed, 149 insertions(+), 39 deletions(-) diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index bbe73104..26599617 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -1,6 +1,51 @@ part of '../../video_player_screen.dart'; extension _VideoPlayerBuildMethods on VideoPlayerScreenState { + static const double _videoLayoutSizeTolerance = 0.1; + + bool _isSameVideoLayoutSize(Size a, Size b) { + return (a.width - b.width).abs() <= _videoLayoutSizeTolerance && + (a.height - b.height).abs() <= _videoLayoutSizeTolerance; + } + + void _scheduleVideoLayoutUpdate(Size newSize) { + final currentPlayer = player; + if (currentPlayer == null) return; + + final lastSize = _lastVideoLayoutSize; + if (_lastVideoLayoutPlayer == currentPlayer && lastSize != null && _isSameVideoLayoutSize(lastSize, newSize)) { + return; + } + + _pendingVideoLayoutSize = newSize; + if (_videoLayoutUpdateScheduled) return; + _videoLayoutUpdateScheduled = true; + + WidgetsBinding.instance.addPostFrameCallback((_) { + _videoLayoutUpdateScheduled = false; + if (!mounted) return; + + final pendingSize = _pendingVideoLayoutSize; + final currentPlayer = player; + _pendingVideoLayoutSize = null; + if (pendingSize == null || currentPlayer == null) return; + + final lastSize = _lastVideoLayoutSize; + if (_lastVideoLayoutPlayer == currentPlayer && + lastSize != null && + _isSameVideoLayoutSize(lastSize, pendingSize)) { + return; + } + + _lastVideoLayoutSize = pendingSize; + _lastVideoLayoutPlayer = currentPlayer; + _videoFilterManager?.updatePlayerSize(pendingSize); + _videoPIPManager?.updatePlayerSize(pendingSize); + _updateAmbientLightingOnResize(pendingSize); + unawaited(currentPlayer.updateFrame()); + }); + } + Widget _buildLoadingSpinner() { return const Scaffold( backgroundColor: Colors.black, @@ -128,20 +173,8 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { Center( child: LayoutBuilder( builder: (context, constraints) { - // Update player size when layout changes final newSize = Size(constraints.maxWidth, constraints.maxHeight); - - // Update player size in video filter manager, PiP manager, and native layer - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && player != null) { - _videoFilterManager?.updatePlayerSize(newSize); - _videoPIPManager?.updatePlayerSize(newSize); - // Update ambient lighting shader if active (output aspect changed) - _updateAmbientLightingOnResize(newSize); - // Update Metal layer frame on iOS/macOS for rotation - player!.updateFrame(); - } - }); + _scheduleVideoLayoutUpdate(newSize); // Compute canControl from Watch Together provider (reactive) bool canControl = true; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 42ff80d8..6e2d5979 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -363,6 +363,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin VideoPIPManager? _videoPIPManager; ShaderService? _shaderService; AmbientLightingService? _ambientLightingService; + Size? _lastVideoLayoutSize; + Size? _pendingVideoLayoutSize; + Player? _lastVideoLayoutPlayer; + bool _videoLayoutUpdateScheduled = false; final EpisodeNavigationService _episodeNavigation = EpisodeNavigationService(); WatchTogetherProvider? _watchTogetherProvider; diff --git a/lib/widgets/video_controls/parts/visibility.dart b/lib/widgets/video_controls/parts/visibility.dart index 48b5553e..a4dbd0e0 100644 --- a/lib/widgets/video_controls/parts/visibility.dart +++ b/lib/widgets/video_controls/parts/visibility.dart @@ -163,6 +163,11 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { /// Show controls in response to pointer activity (mouse/trackpad movement). void _showControlsFromPointerActivity() { + final nowMs = _pointerActivityStopwatch.elapsedMilliseconds; + final shouldThrottle = _showControls && nowMs - _lastPointerActivityMs < 120; + if (shouldThrottle) return; + _lastPointerActivityMs = nowMs; + if (!_showControls) { _setControlsState(() { _showControls = true; diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 02cff92f..511def06 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -411,6 +411,9 @@ class _PlexVideoControlsState extends State with WindowListen bool _subtitlesVisible = true; // Skip marker button focus node (for TV D-pad navigation) late final FocusNode _skipMarkerFocusNode; + final ValueNotifier _fallbackHasFirstFrame = ValueNotifier(true); + final Stopwatch _pointerActivityStopwatch = Stopwatch()..start(); + int _lastPointerActivityMs = -1000; double? _rateBeforeLongPress; bool _showSpeedIndicator = false; StreamSubscription? _rateSubscription; @@ -514,6 +517,7 @@ class _PlexVideoControlsState extends State with WindowListen _rateSubscription?.cancel(); _focusNode.dispose(); _skipMarkerFocusNode.dispose(); + _fallbackHasFirstFrame.dispose(); // Restore original rate if long-press was active when disposed if (_isLongPressing && _rateBeforeLongPress != null) { widget.player.setRate(_rateBeforeLongPress!); @@ -590,7 +594,6 @@ class _PlexVideoControlsState extends State with WindowListen onKeyEvent: (node, event) => _handleControlsKeyEvent(event, isMobile), child: Listener( behavior: HitTestBehavior.translucent, - onPointerHover: (_) => _showControlsFromPointerActivity(), onPointerSignal: _handlePointerSignal, child: MouseRegion( cursor: (_showControls || _forceShowControls) ? SystemMouseCursors.basic : SystemMouseCursors.none, @@ -641,7 +644,7 @@ class _PlexVideoControlsState extends State with WindowListen onLongPressCancel: _handleLongPressCancel, behavior: HitTestBehavior.deferToChild, child: ValueListenableBuilder( - valueListenable: widget.hasFirstFrame ?? ValueNotifier(true), + valueListenable: widget.hasFirstFrame ?? _fallbackHasFirstFrame, builder: (context, hasFrame, child) { return Container( decoration: BoxDecoration( diff --git a/lib/widgets/video_controls/widgets/timeline_slider.dart b/lib/widgets/video_controls/widgets/timeline_slider.dart index 99af8c7b..9a82aad5 100644 --- a/lib/widgets/video_controls/widgets/timeline_slider.dart +++ b/lib/widgets/video_controls/widgets/timeline_slider.dart @@ -69,6 +69,11 @@ class TimelineSlider extends StatefulWidget { class _TimelineSliderState extends State { double? _mousePosition; double? _dragValue; + int? _hoverTimeMs; + int? _hoverLabelSecond; + int? _hoverPixelBucket; + ScrubFrame? _hoverFrame; + Object? _hoverFrameKey; bool _isFocused = false; // Must match the slider track inset: max(overlayRadius, thumbRadius) @@ -76,12 +81,63 @@ class _TimelineSliderState extends State { static const _thumbWidth = 160.0; - Widget _buildTooltip(double sliderWidth, double pixelX, Duration time) { - final frame = widget.thumbnailDataBuilder?.call(time); - final hasThumbnail = frame != null; + Object? _scrubFrameKey(ScrubFrame? frame) { + return switch (frame) { + null => null, + BytesScrubFrame(:final bytes) => bytes, + SheetScrubFrame(:final sheet, :final tileColumn, :final tileRow, :final sheetColumns, :final sheetRows) => + Object.hash(sheet, tileColumn, tileRow, sheetColumns, sheetRows), + }; + } + + void _clearHoverPosition() { + if (_mousePosition == null && _hoverTimeMs == null && _hoverFrame == null) return; + setState(() { + _mousePosition = null; + _hoverTimeMs = null; + _hoverLabelSecond = null; + _hoverPixelBucket = null; + _hoverFrame = null; + _hoverFrameKey = null; + }); + } + + void _updateHoverPosition(double pixelX, double trackWidth, int durationMs) { + if (durationMs <= 0 || trackWidth <= 0) { + _clearHoverPosition(); + return; + } + + final fraction = ((pixelX - _sliderPadding) / trackWidth).clamp(0.0, 1.0); + final timeMs = (fraction * durationMs).round(); + final frame = widget.thumbnailDataBuilder?.call(Duration(milliseconds: timeMs)); + final frameKey = _scrubFrameKey(frame); + final labelSecond = timeMs ~/ 1000; + final pixelBucket = (pixelX / 4).round(); + + if (_mousePosition != null && + _hoverLabelSecond == labelSecond && + _hoverPixelBucket == pixelBucket && + _hoverFrameKey == frameKey) { + return; + } + + setState(() { + _mousePosition = pixelX; + _hoverTimeMs = timeMs; + _hoverLabelSecond = labelSecond; + _hoverPixelBucket = pixelBucket; + _hoverFrame = frame; + _hoverFrameKey = frameKey; + }); + } + + Widget _buildTooltip(double sliderWidth, double pixelX, Duration time, {ScrubFrame? frame}) { + final resolvedFrame = frame ?? widget.thumbnailDataBuilder?.call(time); + final hasThumbnail = resolvedFrame != null; final tooltipWidth = hasThumbnail ? _thumbWidth : 64.0; - final tooltipHeight = hasThumbnail ? _thumbWidth / frame.aspectRatio : 26.0; + final tooltipHeight = hasThumbnail ? _thumbWidth / resolvedFrame.aspectRatio : 26.0; final tooltipTop = -(tooltipHeight + 2.0); // Center tooltip on cursor, clamped so it stays within the slider bounds @@ -121,7 +177,7 @@ class _TimelineSliderState extends State { child: Stack( fit: StackFit.expand, children: [ - _ScrubFrameView(frame: frame), + _ScrubFrameView(frame: resolvedFrame), Positioned(bottom: 4, left: 0, right: 0, child: Center(child: timeLabel)), ], ), @@ -155,12 +211,8 @@ class _TimelineSliderState extends State { final px = _sliderPadding + fraction * trackWidth; tooltip = _buildTooltip(sliderWidth, px, displayPosition); } else if (_mousePosition != null) { - // Convert mouse pixel position to a 0..1 fraction of the track - // (subtract padding to get position relative to track start), - // then map that fraction to a time in milliseconds - final fraction = ((_mousePosition! - _sliderPadding) / trackWidth).clamp(0.0, 1.0); - final time = Duration(milliseconds: (fraction * durationMs).round()); - tooltip = _buildTooltip(sliderWidth, _mousePosition!, time); + final time = Duration(milliseconds: _hoverTimeMs ?? 0); + tooltip = _buildTooltip(sliderWidth, _mousePosition!, time, frame: _hoverFrame); } else if (widget.showKeyRepeatThumbnail && widget.thumbnailDataBuilder != null) { // Preview thumbnail at the current playback position while the // user holds a dpad/keyboard direction. The decoder lags behind @@ -251,9 +303,8 @@ class _TimelineSliderState extends State { } return MouseRegion( - // Handle mouse hover events - onHover: (event) => setState(() => _mousePosition = event.localPosition.dx), - onExit: (_) => setState(() => _mousePosition = null), + onHover: (event) => _updateHoverPosition(event.localPosition.dx, trackWidth, durationMs), + onExit: (_) => _clearHoverPosition(), child: slider, ); }, @@ -265,16 +316,28 @@ class _ScrubFrameView extends StatelessWidget { final ScrubFrame frame; const _ScrubFrameView({required this.frame}); + int? _cacheDimension(double logicalSize, double devicePixelRatio) { + if (!logicalSize.isFinite || logicalSize <= 0) return null; + return (logicalSize * devicePixelRatio).round().clamp(1, 8192).toInt(); + } + @override Widget build(BuildContext context) { final f = frame; switch (f) { case BytesScrubFrame(): - return Image.memory( - f.bytes, - fit: BoxFit.cover, - gaplessPlayback: true, - errorBuilder: (_, _, _) => const SizedBox.shrink(), + return LayoutBuilder( + builder: (context, constraints) { + final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); + return Image.memory( + f.bytes, + fit: BoxFit.cover, + gaplessPlayback: true, + cacheWidth: _cacheDimension(constraints.maxWidth, devicePixelRatio), + cacheHeight: _cacheDimension(constraints.maxHeight, devicePixelRatio), + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ); + }, ); case SheetScrubFrame(): // The parent tooltip box matches the source tile aspect (see @@ -286,6 +349,12 @@ class _ScrubFrameView extends StatelessWidget { final tileH = constraints.maxHeight; final sheetW = tileW * f.sheetColumns; final sheetH = tileH * f.sheetRows; + final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); + final sheet = ResizeImage.resizeIfNeeded( + _cacheDimension(sheetW, devicePixelRatio), + _cacheDimension(sheetH, devicePixelRatio), + f.sheet, + ); return ClipRect( child: OverflowBox( maxWidth: sheetW, @@ -294,7 +363,7 @@ class _ScrubFrameView extends StatelessWidget { child: Transform.translate( offset: Offset(-f.tileColumn * tileW, -f.tileRow * tileH), child: Image( - image: f.sheet, + image: sheet, width: sheetW, height: sheetH, fit: BoxFit.fill, diff --git a/macos/Runner/MpvPlayer/MpvPlayerCore.swift b/macos/Runner/MpvPlayer/MpvPlayerCore.swift index c5227569..7ec46ce5 100644 --- a/macos/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/macos/Runner/MpvPlayer/MpvPlayerCore.swift @@ -144,7 +144,6 @@ class MpvPlayerCore: MpvPlayerCoreBase { } updateEDRMode(sigPeak: lastSigPeak) - print("[MpvPlayerCore] updateFrame: \(videoLayer.frame)") } override func updateEDRMode(sigPeak: Double) { @@ -172,9 +171,6 @@ class MpvPlayerCore: MpvPlayerCoreBase { videoLayer.wantsExtendedDynamicRangeContent = shouldEnableEDR } - print( - "[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), currentHeadroom: \(currentHeadroom), potentialHeadroom: \(potentialHeadroom), contentHeadroom: \(shouldEnableEDR ? contentHeadroom : 0))" - ) } func dispose() {