feat: M3 2024 slider style for video player timelines

Use GappedTrackShape (no stop indicator) globally.
Timeline/volume sliders get local overrides for thinner
track, zero padding, and no overlay. Chapter markers
rendered as track gaps. Live TV timeline matches style.
Timeline thumb hidden when unfocused in dpad mode.
This commit is contained in:
edde746
2026-04-03 23:14:11 +02:00
parent dbb3e14c50
commit 7d35132509
7 changed files with 199 additions and 91 deletions
+89
View File
@@ -0,0 +1,89 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
/// [GappedSliderTrackShape] without the hardcoded stop indicator dot.
class GappedTrackShape extends SliderTrackShape with BaseSliderTrackShape {
const GappedTrackShape();
@override
void paint(
PaintingContext context,
Offset offset, {
required RenderBox parentBox,
required SliderThemeData sliderTheme,
required Animation<double> enableAnimation,
required TextDirection textDirection,
required Offset thumbCenter,
Offset? secondaryOffset,
bool isDiscrete = false,
bool isEnabled = false,
double additionalActiveTrackHeight = 2,
}) {
if (sliderTheme.trackHeight == null || sliderTheme.trackHeight! <= 0) return;
final activeColor = ColorTween(
begin: sliderTheme.disabledActiveTrackColor,
end: sliderTheme.activeTrackColor,
).evaluate(enableAnimation)!;
final inactiveColor = ColorTween(
begin: sliderTheme.disabledInactiveTrackColor,
end: sliderTheme.inactiveTrackColor,
).evaluate(enableAnimation)!;
final Paint leftPaint, rightPaint;
switch (textDirection) {
case TextDirection.ltr:
leftPaint = Paint()..color = activeColor;
rightPaint = Paint()..color = inactiveColor;
case TextDirection.rtl:
leftPaint = Paint()..color = inactiveColor;
rightPaint = Paint()..color = activeColor;
}
final trackGap = sliderTheme.trackGap ?? 0;
final trackRect = getPreferredRect(
parentBox: parentBox,
offset: offset,
sliderTheme: sliderTheme,
isEnabled: isEnabled,
isDiscrete: isDiscrete,
);
final outerRadius = Radius.circular(trackRect.shortestSide / 2);
const innerRadius = Radius.circular(2.0);
final trackRRect = RRect.fromRectAndRadius(trackRect, outerRadius);
final leftRRect = RRect.fromLTRBAndCorners(
trackRect.left,
trackRect.top,
math.max(trackRect.left, thumbCenter.dx - trackGap),
trackRect.bottom,
topLeft: outerRadius,
bottomLeft: outerRadius,
topRight: innerRadius,
bottomRight: innerRadius,
);
final rightRRect = RRect.fromLTRBAndCorners(
thumbCenter.dx + trackGap,
trackRect.top,
trackRect.right,
trackRect.bottom,
topRight: outerRadius,
bottomRight: outerRadius,
topLeft: innerRadius,
bottomLeft: innerRadius,
);
final canvas = context.canvas..save()..clipRRect(trackRRect);
if (thumbCenter.dx > leftRRect.left + sliderTheme.trackHeight! / 2) {
canvas.drawRRect(leftRRect, leftPaint);
}
if (thumbCenter.dx < rightRRect.right - sliderTheme.trackHeight! / 2) {
canvas.drawRRect(rightRRect, rightPaint);
}
canvas.restore();
}
}
+3 -1
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'gapped_track_shape.dart';
import 'mono_tokens.dart';
ThemeData monoTheme({required bool dark, bool oled = false}) {
@@ -123,8 +124,9 @@ ThemeData monoTheme({required bool dark, bool oled = false}) {
trackGap: 6,
thumbSize: const WidgetStatePropertyAll(Size(4, 20)),
thumbShape: const HandleThumbShape(),
trackShape: const GappedSliderTrackShape(),
trackShape: const GappedTrackShape(),
tickMarkShape: const RoundSliderTickMarkShape(tickMarkRadius: 2),
year2023: false,
),
dividerTheme: DividerThemeData(space: 0, thickness: 1, color: c.outline),
listTileTheme: ListTileThemeData(
@@ -1,52 +1,84 @@
import 'package:flutter/material.dart';
import '../../../models/plex_media_info.dart';
import '../../../mpv/models.dart';
/// Custom painter that draws a background track and buffered range bars
/// on the video timeline slider.
/// 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<PlexChapter> chapters;
BufferRangePainter({required this.ranges, required this.duration});
BufferRangePainter({required this.ranges, required this.duration, this.chapters = const []});
@override
void paint(Canvas canvas, Size size) {
final trackHeight = 4.0;
const trackHeight = 8.0;
const gapWidth = 4.0;
final radius = trackHeight / 2;
final y = (size.height - trackHeight) / 2;
// Background track (full width)
final bgPaint = Paint()
..color = Colors.white.withValues(alpha: 0.3)
..style = PaintingStyle.fill;
canvas.drawRRect(
RRect.fromRectAndRadius(Rect.fromLTWH(0, y, size.width, trackHeight), const Radius.circular(2)),
bgPaint,
);
if (duration.inMilliseconds <= 0) return;
final durationMs = duration.inMilliseconds.toDouble();
// Buffer range bars
// 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));
}
// Draw background segments
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;
// Draw buffer ranges clipped to segments
final bufPaint = Paint()
..color = Colors.white.withValues(alpha: 0.5)
..style = PaintingStyle.fill;
final durationMs = duration.inMilliseconds.toDouble();
for (final range in ranges) {
final startFraction = (range.start.inMilliseconds / durationMs).clamp(0.0, 1.0);
final endFraction = (range.end.inMilliseconds / durationMs).clamp(0.0, 1.0);
if (endFraction <= startFraction) continue;
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;
final left = startFraction * size.width;
final right = endFraction * size.width;
canvas.drawRRect(
RRect.fromRectAndRadius(Rect.fromLTWH(left, y, right - left, trackHeight), const Radius.circular(2)),
bufPaint,
);
// 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 || oldDelegate.ranges != ranges;
return oldDelegate.duration != duration || oldDelegate.ranges != ranges || oldDelegate.chapters != chapters;
}
}
@@ -14,17 +14,14 @@ class ChapterMarkerPainter extends CustomPainter {
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.7)
..strokeWidth = 2
..strokeCap = StrokeCap.round;
..style = PaintingStyle.fill;
for (final chapter in chapters) {
final startMs = chapter.startTimeOffset ?? 0;
if (startMs == 0) continue; // Skip first chapter marker at 0:00
final position = (startMs / duration.inMilliseconds) * size.width;
// Draw short vertical line for chapter marker (centered on slider track)
canvas.drawLine(Offset(position, size.height * 0.45), Offset(position, size.height * 0.55), paint);
final x = (startMs / duration.inMilliseconds) * size.width;
canvas.drawCircle(Offset(x, size.height / 2), 3, paint);
}
}
@@ -192,30 +192,40 @@ class _LiveTimelinePainter extends CustomPainter {
void paint(Canvas canvas, Size size) {
final w = size.width;
final trackY = size.height / 2;
const trackHeight = 4.0;
const thumbRadius = 6.0;
const trackHeight = 8.0;
final trackRadius = Radius.circular(trackHeight / 2);
final posX = positionFraction * w;
// Background track (unplayed)
// Background track
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromCenter(center: Offset(w / 2, trackY), width: w, height: trackHeight),
const Radius.circular(2),
trackRadius,
),
Paint()..color = Colors.white.withValues(alpha: 0.15),
);
// Played region
if (posX > 0) {
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTRB(0, trackY - trackHeight / 2, posX, trackY + trackHeight / 2),
trackRadius,
),
Paint()..color = Colors.red,
);
}
// Handle thumb (pill shape matching HandleThumbShape)
const thumbWidth = 4.0;
const thumbHeight = 20.0;
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTRB(0, trackY - trackHeight / 2, posX, trackY + trackHeight / 2),
const Radius.circular(2),
Rect.fromCenter(center: Offset(posX, trackY), width: thumbWidth, height: thumbHeight),
Radius.circular(thumbWidth / 2),
),
Paint()..color = Colors.red,
);
// Thumb
canvas.drawCircle(Offset(posX, trackY), thumbRadius, Paint()..color = Colors.red);
}
@override
@@ -5,9 +5,9 @@ import '../../../models/plex_media_info.dart';
import '../../../mpv/models.dart';
import '../../../i18n/strings.g.dart';
import '../../../focus/focusable_wrapper.dart';
import '../../../focus/input_mode_tracker.dart';
import '../../../utils/formatters.dart';
import '../painters/buffer_range_painter.dart';
import '../painters/chapter_marker_painter.dart';
/// Timeline slider with chapter markers for video playback
///
@@ -60,9 +60,10 @@ class TimelineSlider extends StatefulWidget {
class _TimelineSliderState extends State<TimelineSlider> {
double? _mousePosition;
double? _dragValue;
bool _isFocused = false;
// Must match the slider track inset: max(overlayRadius, thumbRadius)
static const _sliderPadding = 12.0;
static const _sliderPadding = 0.0;
static const _thumbWidth = 160.0;
static const _thumbHeight = 90.0;
@@ -164,39 +165,17 @@ class _TimelineSliderState extends State<TimelineSlider> {
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
// Chapter markers layer
if (widget.chaptersLoaded && widget.chapters.isNotEmpty && widget.duration.inMilliseconds > 0)
Positioned.fill(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: _sliderPadding),
child: Row(
children:
widget.chapters.map((chapter) {
final chapterPosition = (chapter.startTimeOffset ?? 0) / widget.duration.inMilliseconds;
return Expanded(flex: (chapterPosition * 1000).toInt(), child: const SizedBox());
}).toList()..add(
Expanded(
flex:
1000 -
widget.chapters.fold<int>(
0,
(sum, chapter) =>
sum +
((chapter.startTimeOffset ?? 0) / widget.duration.inMilliseconds * 1000).toInt(),
),
child: const SizedBox(),
),
),
),
),
),
// Buffer range + background track painter
// Buffer range + segmented background track (with chapter gaps)
Positioned.fill(
child: IgnorePointer(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: _sliderPadding),
child: CustomPaint(
painter: BufferRangePainter(ranges: widget.bufferRanges, duration: widget.duration),
painter: BufferRangePainter(
ranges: widget.bufferRanges,
duration: widget.duration,
chapters: widget.chaptersLoaded ? widget.chapters : const [],
),
),
),
),
@@ -205,9 +184,17 @@ class _TimelineSliderState extends State<TimelineSlider> {
IgnorePointer(
ignoring: !widget.enabled,
child: SliderTheme(
data: SliderThemeData(
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
data: SliderTheme.of(context).copyWith(
trackHeight: 8,
trackGap: 0,
padding: EdgeInsets.zero,
overlayShape: const RoundSliderOverlayShape(overlayRadius: 0),
tickMarkShape: SliderTickMarkShape.noTickMark,
thumbSize: WidgetStatePropertyAll(
(!InputModeTracker.isKeyboardMode(context) || _isFocused)
? const Size(4, 20)
: Size.zero,
),
),
child: Semantics(
label: t.videoControls.timelineSlider,
@@ -230,18 +217,6 @@ class _TimelineSliderState extends State<TimelineSlider> {
),
),
),
// Chapter marker indicators
if (widget.chaptersLoaded && widget.chapters.isNotEmpty && widget.duration.inMilliseconds > 0)
Positioned.fill(
child: IgnorePointer(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: _sliderPadding),
child: CustomPaint(
painter: ChapterMarkerPainter(chapters: widget.chapters, duration: widget.duration),
),
),
),
),
?tooltip,
],
);
@@ -251,11 +226,14 @@ class _TimelineSliderState extends State<TimelineSlider> {
slider = FocusableWrapper(
focusNode: widget.focusNode,
onKeyEvent: widget.enabled ? widget.onKeyEvent : null,
onFocusChange: widget.onFocusChange,
onFocusChange: (hasFocus) {
setState(() => _isFocused = hasFocus);
widget.onFocusChange?.call(hasFocus);
},
borderRadius: 8,
autoScroll: false,
useBackgroundFocus: true,
disableScale: true,
focusColor: Colors.transparent,
semanticLabel: t.videoControls.timelineSlider,
child: slider,
);
@@ -205,8 +205,6 @@ class _VolumeControlState extends State<VolumeControl> {
}
Widget _buildVolumeSlider(double volume, bool isKeyboardMode) {
// Show visual indicator when in adjust mode with keyboard
final showAdjustIndicator = _isAdjustMode && isKeyboardMode;
final maxVolumeDouble = _maxVolume.toDouble();
// Calculate 100% marker position as fraction of slider width
@@ -244,10 +242,12 @@ class _VolumeControlState extends State<VolumeControl> {
),
// Volume slider
SliderTheme(
data: SliderThemeData(
trackHeight: showAdjustIndicator ? 4 : 3,
thumbShape: RoundSliderThumbShape(enabledThumbRadius: showAdjustIndicator ? 8 : 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
data: SliderTheme.of(context).copyWith(
trackHeight: 8,
trackGap: 0,
padding: EdgeInsets.zero,
overlayShape: const RoundSliderOverlayShape(overlayRadius: 0),
tickMarkShape: SliderTickMarkShape.noTickMark,
),
child: Semantics(
label: t.videoControls.volumeSlider,