Nothing sat between the full-screen `RasterizedGradient` scrim and the position-driven leaves, so `markNeedsPaint` from the timeline slider walked past the gradient and re-rasterized the entire chrome -- top bar, button row, timestamps, timeline -- on every 250 ms position tick. That also partly defeated `RasterizedGradient`, which exists because Skia gradients cost ~10 ms per full-screen pass on this GPU class. Around that, four things drove root `setState` calls that only needed to change a leaf: - Held D-pad seek called a root `setState` per key repeat, even when the readout was already visible in the same direction and only the accumulated seconds had changed. The seek amount now flows through a `ValueNotifier` that `DoubleTapFeedback` reads, so a burst updates one `Text`. - `DoubleTapFeedback`'s drift chevron ran `..repeat()` with no stop condition, and kept ticking at 60 Hz for the ~300 ms the readout spends at zero opacity after every burst. The controller is now gated on visibility. - The auto-skip countdown ran a 5 Hz timer that root-`setState`d the controls tree for the whole intro/credits window, to animate one progress ring. - The play-next and still-watching countdowns root-`setState`d the player screen once a second, which re-created `PlexVideoControls` with its ~50 props and rebuilt the entire chrome to change one digit. Prompt open/close deliberately stays on the state path, because `playNextFocusNode` is threaded through those props and D-pad focus depends on it. Two of the four position subscribers existed only to recompute pointer-hover tooltips that a D-pad viewer never sees, rebuilding a button subtree 4x/s and rescanning the chapter list each time; the "ends at" readout reformatted a string 4x/s for a value that changes once a minute. All three now dedupe. Finally, the buffer path did two sequential `copyWith` calls on a 15-field `PlayerState` and handed `BufferRangePainter` a freshly allocated list every tick. `List` has no value equality, so `shouldRepaint` was unconditionally true. Measured on device during playback with the chrome raised: 6 of 344 UI frames over budget, p50 4.39 ms, layout p90 1.09 ms. During a held D-pad seek -- the worst frame-time window on the TV path, because the readout and the decoder flush land in the same frames -- 1 of 354 frames over budget, p50 4.28 ms, zero raster frames over budget.
99 lines
3.4 KiB
Dart
99 lines
3.4 KiB
Dart
import 'package:flutter/foundation.dart' show listEquals;
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../../media/media_source_info.dart';
|
|
import '../../../mpv/models.dart';
|
|
|
|
/// Custom painter that draws a segmented background track (split at chapter
|
|
/// boundaries) and buffered range bars on the video timeline slider.
|
|
class BufferRangePainter extends CustomPainter {
|
|
final List<BufferRange> ranges;
|
|
final Duration duration;
|
|
final List<MediaChapter> chapters;
|
|
|
|
BufferRangePainter({required this.ranges, required this.duration, this.chapters = const []});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
const trackHeight = 8.0;
|
|
const gapWidth = 4.0;
|
|
final radius = trackHeight / 2;
|
|
final y = (size.height - trackHeight) / 2;
|
|
|
|
final bgPaint = Paint()
|
|
..color = Colors.white.withValues(alpha: 0.3)
|
|
..style = PaintingStyle.fill;
|
|
|
|
final durationMs = duration.inMilliseconds.toDouble();
|
|
|
|
// Collect chapter split fractions excluding 0 and 1
|
|
final splits = <double>[];
|
|
if (durationMs > 0) {
|
|
for (final chapter in chapters) {
|
|
final ms = chapter.startTimeOffset ?? 0;
|
|
if (ms <= 0) continue;
|
|
final f = (ms / durationMs).clamp(0.0, 1.0);
|
|
if (f > 0 && f < 1) splits.add(f);
|
|
}
|
|
}
|
|
|
|
// Build segment pixel ranges [left, right] with gaps at chapter boundaries
|
|
final segmentEdges = <double>[0, ...splits, 1];
|
|
final segments = <(double, double)>[];
|
|
for (int i = 0; i < segmentEdges.length - 1; i++) {
|
|
final left = segmentEdges[i] * size.width + (i > 0 ? gapWidth / 2 : 0);
|
|
final right = segmentEdges[i + 1] * size.width - (i < segmentEdges.length - 2 ? gapWidth / 2 : 0);
|
|
if (right > left) segments.add((left, right));
|
|
}
|
|
|
|
for (final (left, right) in segments) {
|
|
canvas.drawRRect(
|
|
RRect.fromRectAndRadius(Rect.fromLTWH(left, y, right - left, trackHeight), Radius.circular(radius)),
|
|
bgPaint,
|
|
);
|
|
}
|
|
|
|
if (durationMs <= 0) return;
|
|
|
|
final bufPaint = Paint()
|
|
..color = Colors.white.withValues(alpha: 0.5)
|
|
..style = PaintingStyle.fill;
|
|
|
|
for (final range in ranges) {
|
|
final bufLeft = (range.start.inMilliseconds / durationMs).clamp(0.0, 1.0) * size.width;
|
|
final bufRight = (range.end.inMilliseconds / durationMs).clamp(0.0, 1.0) * size.width;
|
|
if (bufRight <= bufLeft) continue;
|
|
|
|
// Clip buffer to each segment it overlaps
|
|
for (final (segLeft, segRight) in segments) {
|
|
final clippedLeft = bufLeft.clamp(segLeft, segRight);
|
|
final clippedRight = bufRight.clamp(segLeft, segRight);
|
|
if (clippedRight <= clippedLeft) continue;
|
|
canvas.drawRRect(
|
|
RRect.fromRectAndRadius(
|
|
Rect.fromLTWH(clippedLeft, y, clippedRight - clippedLeft, trackHeight),
|
|
Radius.circular(radius),
|
|
),
|
|
bufPaint,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(BufferRangePainter oldDelegate) {
|
|
return oldDelegate.duration != duration ||
|
|
!listEquals(oldDelegate.ranges, ranges) ||
|
|
!_chapterSplitsEqual(oldDelegate.chapters, chapters);
|
|
}
|
|
|
|
static bool _chapterSplitsEqual(List<MediaChapter> previous, List<MediaChapter> current) {
|
|
if (identical(previous, current)) return true;
|
|
if (previous.length != current.length) return false;
|
|
for (var i = 0; i < previous.length; i++) {
|
|
if (previous[i].startTimeOffset != current[i].startTimeOffset) return false;
|
|
}
|
|
return true;
|
|
}
|
|
}
|