From 7d35132509483b39999ac0fc03f7db6050afd487 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 3 Apr 2026 23:14:11 +0200 Subject: [PATCH] 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. --- lib/theme/gapped_track_shape.dart | 89 +++++++++++++++++++ lib/theme/mono_theme.dart | 4 +- .../painters/buffer_range_painter.dart | 76 +++++++++++----- .../painters/chapter_marker_painter.dart | 9 +- .../widgets/live_timeline_bar.dart | 28 ++++-- .../widgets/timeline_slider.dart | 72 ++++++--------- .../widgets/volume_control.dart | 12 +-- 7 files changed, 199 insertions(+), 91 deletions(-) create mode 100644 lib/theme/gapped_track_shape.dart diff --git a/lib/theme/gapped_track_shape.dart b/lib/theme/gapped_track_shape.dart new file mode 100644 index 00000000..35d74ae5 --- /dev/null +++ b/lib/theme/gapped_track_shape.dart @@ -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 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(); + } +} diff --git a/lib/theme/mono_theme.dart b/lib/theme/mono_theme.dart index 7e8e22c0..74819779 100644 --- a/lib/theme/mono_theme.dart +++ b/lib/theme/mono_theme.dart @@ -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( diff --git a/lib/widgets/video_controls/painters/buffer_range_painter.dart b/lib/widgets/video_controls/painters/buffer_range_painter.dart index fc1559f0..aad97197 100644 --- a/lib/widgets/video_controls/painters/buffer_range_painter.dart +++ b/lib/widgets/video_controls/painters/buffer_range_painter.dart @@ -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 ranges; final Duration duration; + final List 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 = []; + 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 = [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; } } diff --git a/lib/widgets/video_controls/painters/chapter_marker_painter.dart b/lib/widgets/video_controls/painters/chapter_marker_painter.dart index 5ab514df..f47159dc 100644 --- a/lib/widgets/video_controls/painters/chapter_marker_painter.dart +++ b/lib/widgets/video_controls/painters/chapter_marker_painter.dart @@ -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); } } diff --git a/lib/widgets/video_controls/widgets/live_timeline_bar.dart b/lib/widgets/video_controls/widgets/live_timeline_bar.dart index 02570f5c..0ed8f1c4 100644 --- a/lib/widgets/video_controls/widgets/live_timeline_bar.dart +++ b/lib/widgets/video_controls/widgets/live_timeline_bar.dart @@ -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 diff --git a/lib/widgets/video_controls/widgets/timeline_slider.dart b/lib/widgets/video_controls/widgets/timeline_slider.dart index 8c6015aa..401852d8 100644 --- a/lib/widgets/video_controls/widgets/timeline_slider.dart +++ b/lib/widgets/video_controls/widgets/timeline_slider.dart @@ -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 { 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 { 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( - 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 { 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 { ), ), ), - // 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 { 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, ); diff --git a/lib/widgets/video_controls/widgets/volume_control.dart b/lib/widgets/video_controls/widgets/volume_control.dart index 56e8f44f..f3b0b041 100644 --- a/lib/widgets/video_controls/widgets/volume_control.dart +++ b/lib/widgets/video_controls/widgets/volume_control.dart @@ -205,8 +205,6 @@ class _VolumeControlState extends State { } 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 { ), // 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,