Merge pull request #384 from micahmo/feature/slider-time-tooltip
Add tooltip showing target time when hovering or dragging the playback slider
This commit is contained in:
@@ -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<PlexChapter> 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<int>(
|
||||
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<TimelineSlider> createState() => _TimelineSliderState();
|
||||
}
|
||||
|
||||
class _TimelineSliderState extends State<TimelineSlider> {
|
||||
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: -16,
|
||||
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<int>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user