From 4436a8655d18e74e076a3fd841f34b5244d3f935 Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Wed, 4 Feb 2026 22:27:18 -0500 Subject: [PATCH 1/2] Add tooltip showing target time when hovering or dragging the playback slider --- .../widgets/timeline_slider.dart | 227 ++++++++++++------ 1 file changed, 152 insertions(+), 75 deletions(-) diff --git a/lib/widgets/video_controls/widgets/timeline_slider.dart b/lib/widgets/video_controls/widgets/timeline_slider.dart index 68bc097a..64cb5cc4 100644 --- a/lib/widgets/video_controls/widgets/timeline_slider.dart +++ b/lib/widgets/video_controls/widgets/timeline_slider.dart @@ -2,13 +2,14 @@ import 'package:flutter/material.dart'; import '../../../models/plex_media_info.dart'; import '../../../i18n/strings.g.dart'; import '../../../focus/focusable_wrapper.dart'; +import '../../../utils/formatters.dart'; import '../painters/chapter_marker_painter.dart'; /// Timeline slider with chapter markers for video playback /// /// Displays a horizontal slider showing playback position and duration, /// with optional chapter markers overlaid at their respective positions. -class TimelineSlider extends StatelessWidget { +class TimelineSlider extends StatefulWidget { final Duration position; final Duration duration; final List chapters; @@ -43,86 +44,162 @@ class TimelineSlider extends StatelessWidget { }); @override - Widget build(BuildContext context) { - Widget slider = Stack( - alignment: Alignment.center, - children: [ - // Chapter markers layer - if (chaptersLoaded && chapters.isNotEmpty && duration.inMilliseconds > 0) - Positioned.fill( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Row( - children: - chapters.map((chapter) { - final chapterPosition = (chapter.startTimeOffset ?? 0) / duration.inMilliseconds; - return Expanded(flex: (chapterPosition * 1000).toInt(), child: const SizedBox()); - }).toList()..add( - Expanded( - flex: - 1000 - - chapters.fold( - 0, - (sum, chapter) => - sum + ((chapter.startTimeOffset ?? 0) / duration.inMilliseconds * 1000).toInt(), - ), - child: const SizedBox(), - ), - ), - ), - ), - ), - // Slider - use IgnorePointer to block interaction while preserving visual style - IgnorePointer( - ignoring: !enabled, - child: Semantics( - label: t.videoControls.timelineSlider, - slider: true, - child: Slider( - value: duration.inMilliseconds > 0 ? position.inMilliseconds.toDouble() : 0.0, - min: 0.0, - max: duration.inMilliseconds.toDouble(), - onChanged: (value) { - onSeek(Duration(milliseconds: value.toInt())); - }, - onChangeEnd: (value) { - onSeekEnd(Duration(milliseconds: value.toInt())); - }, - activeColor: Colors.white, - inactiveColor: Colors.white.withValues(alpha: 0.3), + State createState() => _TimelineSliderState(); +} + +class _TimelineSliderState extends State { + double? _mousePosition; + double? _dragValue; + + static const _sliderPadding = 24.0; + + Widget _buildTooltip(double sliderWidth, double pixelX, Duration time) { + final tooltipWidth = 64.0; + + // Center tooltip on cursor, clamped so it stays within the slider bounds + final left = (pixelX - tooltipWidth / 2).clamp(0.0, sliderWidth - tooltipWidth); + return Positioned( + left: left, + top: -26, + child: IgnorePointer( + child: Container( + width: tooltipWidth, + height: 26, + alignment: Alignment.center, + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(4)), + child: Text( + formatDurationTimestamp(time), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + height: 1.0, + fontFeatures: [FontFeature.tabularFigures()], ), ), ), - // Chapter marker indicators - if (chaptersLoaded && chapters.isNotEmpty && duration.inMilliseconds > 0) - Positioned.fill( - child: IgnorePointer( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: CustomPaint( - painter: ChapterMarkerPainter(chapters: chapters, duration: duration), + ), + ); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final sliderWidth = constraints.maxWidth; + // Calculate the actual track width by subtracting the thumb padding on each side + final trackWidth = sliderWidth - 2 * _sliderPadding; + final durationMs = widget.duration.inMilliseconds; + + // Resolve tooltip position (drag takes priority over hover) + Widget? tooltip; + if (durationMs > 0) { + if (_dragValue != null) { + // Convert drag value (ms) to a 0..1 fraction, then map to pixel + // position on the track (offset by padding to align with the slider) + final fraction = (_dragValue! / durationMs).clamp(0.0, 1.0); + final px = _sliderPadding + fraction * trackWidth; + tooltip = _buildTooltip(sliderWidth, px, Duration(milliseconds: _dragValue!.toInt())); + } else if (_mousePosition != null) { + // Convert mouse pixel position to a 0..1 fraction of the track + // (subtract padding to get position relative to track start), + // then map that fraction to a time in milliseconds + final fraction = ((_mousePosition! - _sliderPadding) / trackWidth).clamp(0.0, 1.0); + final time = Duration(milliseconds: (fraction * durationMs).round()); + tooltip = _buildTooltip(sliderWidth, _mousePosition!, time); + } + } + + Widget slider = Stack( + 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: 24), + 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(), + ), + ), + ), + ), + ), + // Slider - use IgnorePointer to block interaction while preserving visual style + IgnorePointer( + ignoring: !widget.enabled, + child: Semantics( + label: t.videoControls.timelineSlider, + slider: true, + child: Slider( + value: widget.duration.inMilliseconds > 0 ? widget.position.inMilliseconds.toDouble() : 0.0, + min: 0.0, + max: widget.duration.inMilliseconds.toDouble(), + onChanged: (value) { + setState(() => _dragValue = value); + widget.onSeek(Duration(milliseconds: value.toInt())); + }, + onChangeEnd: (value) { + setState(() => _dragValue = null); + widget.onSeekEnd(Duration(milliseconds: value.toInt())); + }, + activeColor: Colors.white, + inactiveColor: Colors.white.withValues(alpha: 0.3), ), ), ), - ), - ], + // Chapter marker indicators + if (widget.chaptersLoaded && widget.chapters.isNotEmpty && widget.duration.inMilliseconds > 0) + Positioned.fill( + child: IgnorePointer( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: CustomPaint( + painter: ChapterMarkerPainter(chapters: widget.chapters, duration: widget.duration), + ), + ), + ), + ), + if (tooltip != null) tooltip, + ], + ); + + // Wrap with FocusableWrapper when focusNode is provided + if (widget.focusNode != null) { + slider = FocusableWrapper( + focusNode: widget.focusNode, + onKeyEvent: widget.enabled ? widget.onKeyEvent : null, + onFocusChange: widget.onFocusChange, + borderRadius: 8, + autoScroll: false, + useBackgroundFocus: true, + disableScale: true, + semanticLabel: t.videoControls.timelineSlider, + child: slider, + ); + } + + return MouseRegion( + // Handle mouse hover events + onHover: (event) => setState(() => _mousePosition = event.localPosition.dx), + onExit: (_) => setState(() => _mousePosition = null), + child: slider, + ); + }, ); - - // Wrap with FocusableWrapper when focusNode is provided - if (focusNode != null) { - slider = FocusableWrapper( - focusNode: focusNode, - onKeyEvent: enabled ? onKeyEvent : null, - onFocusChange: onFocusChange, - borderRadius: 8, - autoScroll: false, - useBackgroundFocus: true, - disableScale: true, - semanticLabel: t.videoControls.timelineSlider, - child: slider, - ); - } - - return slider; } } From b542433ca5a19edd918d8ca28a1e64893a853c82 Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Thu, 5 Feb 2026 15:21:17 -0500 Subject: [PATCH 2/2] Put tooltip closer to timeline --- lib/widgets/video_controls/widgets/timeline_slider.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/video_controls/widgets/timeline_slider.dart b/lib/widgets/video_controls/widgets/timeline_slider.dart index 64cb5cc4..56ba69e6 100644 --- a/lib/widgets/video_controls/widgets/timeline_slider.dart +++ b/lib/widgets/video_controls/widgets/timeline_slider.dart @@ -60,7 +60,7 @@ class _TimelineSliderState extends State { final left = (pixelX - tooltipWidth / 2).clamp(0.0, sliderWidth - tooltipWidth); return Positioned( left: left, - top: -26, + top: -16, child: IgnorePointer( child: Container( width: tooltipWidth,