From fd1d187edebb70771f615eb5d4ccae0c893869cb Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:22:27 +0200 Subject: [PATCH] feat: timeline thumbnail on dpad key-repeat close #805 --- .../desktop_video_controls.dart | 27 +++++++++++++++++++ .../widgets/timeline_slider.dart | 13 +++++++++ .../widgets/video_timeline_bar.dart | 6 +++++ 3 files changed, 46 insertions(+) diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index d5078057..041e57aa 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io' show Platform; import 'package:flutter/material.dart'; @@ -171,6 +172,11 @@ class DesktopVideoControlsState extends State { LogicalKeyboardKey? _seekDirection; // Current direction being held int _seekRepeatCount = 0; // Consecutive key repeats for acceleration + // Preview thumbnail during sustained dpad/keyboard seeking + bool _showKeyRepeatThumbnail = false; + Timer? _keyRepeatThumbnailTimer; + static const _keyRepeatThumbnailTimeout = Duration(milliseconds: 400); + // Content strip state bool _contentStripVisible = false; final GlobalKey _contentStripKey = GlobalKey(); @@ -214,6 +220,7 @@ class DesktopVideoControlsState extends State { @override void dispose() { + _keyRepeatThumbnailTimer?.cancel(); _prevItemFocusNode.dispose(); _prevChapterFocusNode.dispose(); _skipBackFocusNode.dispose(); @@ -429,6 +436,24 @@ class DesktopVideoControlsState extends State { void _resetSeekState() { _seekDirection = null; _seekRepeatCount = 0; + _keyRepeatThumbnailTimer?.cancel(); + _keyRepeatThumbnailTimer = null; + if (_showKeyRepeatThumbnail) { + setState(() => _showKeyRepeatThumbnail = false); + } + } + + /// Show the timeline preview thumbnail during sustained key-repeat seeking. + /// Arms a short timer that hides the thumbnail once repeats stop. + void _triggerKeyRepeatThumbnail() { + if (!_showKeyRepeatThumbnail) { + setState(() => _showKeyRepeatThumbnail = true); + } + _keyRepeatThumbnailTimer?.cancel(); + _keyRepeatThumbnailTimer = Timer(_keyRepeatThumbnailTimeout, () { + if (!mounted) return; + setState(() => _showKeyRepeatThumbnail = false); + }); } /// Calculate seek multiplier based on repeat count (stepped tiers) @@ -490,6 +515,7 @@ class DesktopVideoControlsState extends State { } if (event is KeyRepeatEvent) { _seekRepeatCount++; + _triggerKeyRepeatThumbnail(); } final isForward = key == LogicalKeyboardKey.arrowRight; @@ -680,6 +706,7 @@ class DesktopVideoControlsState extends State { onFocusChange: _onFocusChange, enabled: canInteract, thumbnailDataBuilder: widget.thumbnailDataBuilder, + showKeyRepeatThumbnail: _showKeyRepeatThumbnail, ), ], // Row 2: Playback controls and options diff --git a/lib/widgets/video_controls/widgets/timeline_slider.dart b/lib/widgets/video_controls/widgets/timeline_slider.dart index 72cb59b7..d337d80c 100644 --- a/lib/widgets/video_controls/widgets/timeline_slider.dart +++ b/lib/widgets/video_controls/widgets/timeline_slider.dart @@ -37,6 +37,11 @@ class TimelineSlider extends StatefulWidget { /// Optional callback that returns thumbnail image bytes for a given timestamp. final Uint8List? Function(Duration time)? thumbnailDataBuilder; + /// When true, show the preview thumbnail at the current playback position. + /// Intended for sustained dpad/keyboard seeking where the decoder cannot + /// keep up with accumulated seeks. Single presses should leave this false. + final bool showKeyRepeatThumbnail; + const TimelineSlider({ super.key, required this.position, @@ -51,6 +56,7 @@ class TimelineSlider extends StatefulWidget { this.onFocusChange, this.enabled = true, this.thumbnailDataBuilder, + this.showKeyRepeatThumbnail = false, }); @override @@ -153,6 +159,13 @@ class _TimelineSliderState extends State { final fraction = ((_mousePosition! - _sliderPadding) / trackWidth).clamp(0.0, 1.0); final time = Duration(milliseconds: (fraction * durationMs).round()); tooltip = _buildTooltip(sliderWidth, _mousePosition!, time); + } else if (widget.showKeyRepeatThumbnail && widget.thumbnailDataBuilder != null) { + // Preview thumbnail at the current playback position while the + // user holds a dpad/keyboard direction. The decoder lags behind + // rapid seeks, so the BIF thumbnail is the only live feedback. + final fraction = (widget.position.inMilliseconds / durationMs).clamp(0.0, 1.0); + final px = _sliderPadding + fraction * trackWidth; + tooltip = _buildTooltip(sliderWidth, px, widget.position); } } diff --git a/lib/widgets/video_controls/widgets/video_timeline_bar.dart b/lib/widgets/video_controls/widgets/video_timeline_bar.dart index 28cbd385..0b511029 100644 --- a/lib/widgets/video_controls/widgets/video_timeline_bar.dart +++ b/lib/widgets/video_controls/widgets/video_timeline_bar.dart @@ -41,6 +41,10 @@ class VideoTimelineBar extends StatelessWidget { /// Optional callback that returns thumbnail image bytes for a given timestamp. final Uint8List? Function(Duration time)? thumbnailDataBuilder; + /// When true, show the preview thumbnail at the current playback position + /// (used during sustained dpad/keyboard key-repeat seeking). + final bool showKeyRepeatThumbnail; + const VideoTimelineBar({ super.key, required this.player, @@ -55,6 +59,7 @@ class VideoTimelineBar extends StatelessWidget { this.enabled = true, this.showFinishTime = false, this.thumbnailDataBuilder, + this.showKeyRepeatThumbnail = false, }); @override @@ -164,6 +169,7 @@ class VideoTimelineBar extends StatelessWidget { onFocusChange: onFocusChange, enabled: enabled, thumbnailDataBuilder: thumbnailDataBuilder, + showKeyRepeatThumbnail: showKeyRepeatThumbnail, ); } }