perf(player): reduce playback UI churn

This commit is contained in:
edde746
2026-05-09 09:38:25 +02:00
parent 633ce11c88
commit d38f7cf78f
6 changed files with 149 additions and 39 deletions
+46 -13
View File
@@ -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;
+4
View File
@@ -363,6 +363,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
VideoPIPManager? _videoPIPManager;
ShaderService? _shaderService;
AmbientLightingService? _ambientLightingService;
Size? _lastVideoLayoutSize;
Size? _pendingVideoLayoutSize;
Player? _lastVideoLayoutPlayer;
bool _videoLayoutUpdateScheduled = false;
final EpisodeNavigationService _episodeNavigation = EpisodeNavigationService();
WatchTogetherProvider? _watchTogetherProvider;
@@ -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;
@@ -411,6 +411,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
bool _subtitlesVisible = true;
// Skip marker button focus node (for TV D-pad navigation)
late final FocusNode _skipMarkerFocusNode;
final ValueNotifier<bool> _fallbackHasFirstFrame = ValueNotifier<bool>(true);
final Stopwatch _pointerActivityStopwatch = Stopwatch()..start();
int _lastPointerActivityMs = -1000;
double? _rateBeforeLongPress;
bool _showSpeedIndicator = false;
StreamSubscription<double>? _rateSubscription;
@@ -514,6 +517,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> 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<PlexVideoControls> 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<PlexVideoControls> with WindowListen
onLongPressCancel: _handleLongPressCancel,
behavior: HitTestBehavior.deferToChild,
child: ValueListenableBuilder<bool>(
valueListenable: widget.hasFirstFrame ?? ValueNotifier(true),
valueListenable: widget.hasFirstFrame ?? _fallbackHasFirstFrame,
builder: (context, hasFrame, child) {
return Container(
decoration: BoxDecoration(
@@ -69,6 +69,11 @@ class TimelineSlider extends StatefulWidget {
class _TimelineSliderState extends State<TimelineSlider> {
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<TimelineSlider> {
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<TimelineSlider> {
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<TimelineSlider> {
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<TimelineSlider> {
}
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,
@@ -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() {