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,