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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user