@@ -44,6 +44,8 @@ class DesktopVideoControls extends StatefulWidget {
|
||||
final VoidCallback? onSeekForward;
|
||||
final ValueChanged<Duration> onSeek;
|
||||
final ValueChanged<Duration> onSeekEnd;
|
||||
final VoidCallback? onScrubStart;
|
||||
final VoidCallback? onScrubEnd;
|
||||
final IconData Function(int) getReplayIcon;
|
||||
final IconData Function(int) getForwardIcon;
|
||||
|
||||
@@ -122,6 +124,8 @@ class DesktopVideoControls extends StatefulWidget {
|
||||
this.onSeekForward,
|
||||
required this.onSeek,
|
||||
required this.onSeekEnd,
|
||||
this.onScrubStart,
|
||||
this.onScrubEnd,
|
||||
required this.getReplayIcon,
|
||||
required this.getForwardIcon,
|
||||
this.onFocusActivity,
|
||||
@@ -717,6 +721,8 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
showChapterMarkersOnTimeline: widget.showChapterMarkersOnTimeline,
|
||||
onSeek: widget.onSeek,
|
||||
onSeekEnd: widget.onSeekEnd,
|
||||
onScrubStart: widget.onScrubStart,
|
||||
onScrubEnd: widget.onScrubEnd,
|
||||
horizontalLayout: true,
|
||||
focusNode: _timelineFocusNode,
|
||||
onKeyEvent: _handleTimelineKeyEvent,
|
||||
|
||||
@@ -37,6 +37,8 @@ class MobileVideoControls extends StatefulWidget {
|
||||
final Widget trackChapterControls;
|
||||
final Function(Duration) onSeek;
|
||||
final Function(Duration) onSeekEnd;
|
||||
final VoidCallback? onScrubStart;
|
||||
final VoidCallback? onScrubEnd;
|
||||
final Future<void> Function(Duration position)? onSeekRequested;
|
||||
final Function(Duration)? onSeekCompleted;
|
||||
final VoidCallback onPlayPause;
|
||||
@@ -96,6 +98,8 @@ class MobileVideoControls extends StatefulWidget {
|
||||
required this.trackChapterControls,
|
||||
required this.onSeek,
|
||||
required this.onSeekEnd,
|
||||
this.onScrubStart,
|
||||
this.onScrubEnd,
|
||||
required this.onPlayPause,
|
||||
this.onSeekRequested,
|
||||
this.onSeekCompleted,
|
||||
@@ -453,6 +457,8 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
|
||||
showChapterMarkersOnTimeline: widget.showChapterMarkersOnTimeline,
|
||||
onSeek: widget.onSeek,
|
||||
onSeekEnd: widget.onSeekEnd,
|
||||
onScrubStart: widget.onScrubStart,
|
||||
onScrubEnd: widget.onScrubEnd,
|
||||
horizontalLayout: false,
|
||||
enabled: widget.canControl,
|
||||
showFinishTime: true,
|
||||
|
||||
@@ -28,6 +28,8 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
|
||||
onSeekForward: () => unawaited(_seekByTime(forward: true)),
|
||||
onSeek: _throttledSeek,
|
||||
onSeekEnd: _finalizeSeek,
|
||||
onScrubStart: _holdTimelineScrub,
|
||||
onScrubEnd: _releaseTimelineScrub,
|
||||
onSeekRequested: widget.onSeekRequested,
|
||||
getReplayIcon: getReplayIcon,
|
||||
getForwardIcon: getForwardIcon,
|
||||
|
||||
@@ -89,9 +89,6 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
|
||||
|
||||
/// Throttled seek for timeline slider - executes immediately then throttles to 200ms
|
||||
void _throttledSeek(Duration position) {
|
||||
// Hold before the transcoding early-return so a slow scrub never loses
|
||||
// the controls to auto-hide mid-drag (idempotent while held).
|
||||
widget.chromeController.hold(PlayerChromeHold.scrub);
|
||||
if (widget.isTranscoding) return;
|
||||
_seekThrottle([position]);
|
||||
}
|
||||
@@ -100,6 +97,13 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
|
||||
void _finalizeSeek(Duration position) {
|
||||
_seekThrottle.cancel();
|
||||
unawaited(_seekToPosition(position));
|
||||
}
|
||||
|
||||
void _holdTimelineScrub() {
|
||||
widget.chromeController.hold(PlayerChromeHold.scrub);
|
||||
}
|
||||
|
||||
void _releaseTimelineScrub() {
|
||||
widget.chromeController.release(PlayerChromeHold.scrub);
|
||||
}
|
||||
|
||||
|
||||
@@ -802,6 +802,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
),
|
||||
onSeek: _throttledSeek,
|
||||
onSeekEnd: _finalizeSeek,
|
||||
onScrubStart: _holdTimelineScrub,
|
||||
onScrubEnd: _releaseTimelineScrub,
|
||||
onSeekRequested: widget.onSeekRequested,
|
||||
onSeekCompleted: widget.onSeekCompleted,
|
||||
// ignore: no-empty-block - play/pause handled by parent VideoControlsState
|
||||
|
||||
@@ -23,6 +23,8 @@ class TimelineSlider extends StatefulWidget {
|
||||
final bool showChapterMarkersOnTimeline;
|
||||
final ValueChanged<Duration> onSeek;
|
||||
final ValueChanged<Duration> onSeekEnd;
|
||||
final VoidCallback? onScrubStart;
|
||||
final VoidCallback? onScrubEnd;
|
||||
|
||||
/// Optional FocusNode for D-pad/keyboard navigation.
|
||||
final FocusNode? focusNode;
|
||||
@@ -56,6 +58,8 @@ class TimelineSlider extends StatefulWidget {
|
||||
this.showChapterMarkersOnTimeline = true,
|
||||
required this.onSeek,
|
||||
required this.onSeekEnd,
|
||||
this.onScrubStart,
|
||||
this.onScrubEnd,
|
||||
this.focusNode,
|
||||
this.onKeyEvent,
|
||||
this.onFocusChange,
|
||||
@@ -84,6 +88,12 @@ class _TimelineSliderState extends State<TimelineSlider> {
|
||||
|
||||
static const _thumbWidth = 160.0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_scrubbing) widget.onScrubEnd?.call();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(TimelineSlider oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -100,6 +110,7 @@ class _TimelineSliderState extends State<TimelineSlider> {
|
||||
void _handleScrubStart(DragStartDetails details, BuildContext sliderContext) {
|
||||
if (widget.duration.inMilliseconds <= 0) return;
|
||||
_scrubbing = true;
|
||||
widget.onScrubStart?.call();
|
||||
_applyScrub(details.localPosition.dx, sliderContext);
|
||||
}
|
||||
|
||||
@@ -125,7 +136,11 @@ class _TimelineSliderState extends State<TimelineSlider> {
|
||||
_scrubbing = false;
|
||||
final value = _dragValue;
|
||||
setState(() => _dragValue = null);
|
||||
if (value != null) widget.onSeekEnd(Duration(milliseconds: value.round()));
|
||||
try {
|
||||
if (value != null) widget.onSeekEnd(Duration(milliseconds: value.round()));
|
||||
} finally {
|
||||
widget.onScrubEnd?.call();
|
||||
}
|
||||
}
|
||||
|
||||
/// Discrete a11y step (VoiceOver/TalkBack swipe): a complete seek.
|
||||
|
||||
@@ -18,6 +18,8 @@ class VideoTimelineBar extends StatelessWidget {
|
||||
final bool showChapterMarkersOnTimeline;
|
||||
final ValueChanged<Duration> onSeek;
|
||||
final ValueChanged<Duration> onSeekEnd;
|
||||
final VoidCallback? onScrubStart;
|
||||
final VoidCallback? onScrubEnd;
|
||||
|
||||
/// If true, timestamps are shown in a row beside the slider (desktop layout).
|
||||
/// If false, timestamps are shown in a row below the slider (mobile layout).
|
||||
@@ -53,6 +55,8 @@ class VideoTimelineBar extends StatelessWidget {
|
||||
this.showChapterMarkersOnTimeline = true,
|
||||
required this.onSeek,
|
||||
required this.onSeekEnd,
|
||||
this.onScrubStart,
|
||||
this.onScrubEnd,
|
||||
this.horizontalLayout = true,
|
||||
this.focusNode,
|
||||
this.onKeyEvent,
|
||||
@@ -166,6 +170,8 @@ class VideoTimelineBar extends StatelessWidget {
|
||||
showChapterMarkersOnTimeline: showChapterMarkersOnTimeline,
|
||||
onSeek: onSeek,
|
||||
onSeekEnd: onSeekEnd,
|
||||
onScrubStart: onScrubStart,
|
||||
onScrubEnd: onScrubEnd,
|
||||
focusNode: focusNode,
|
||||
onKeyEvent: onKeyEvent,
|
||||
onFocusChange: onFocusChange,
|
||||
|
||||
@@ -425,6 +425,8 @@ void main() {
|
||||
required List<Duration> seekEnds,
|
||||
Duration duration = const Duration(minutes: 10),
|
||||
bool enabled = true,
|
||||
VoidCallback? onScrubStart,
|
||||
VoidCallback? onScrubEnd,
|
||||
Widget Function(Widget child)? wrap,
|
||||
}) async {
|
||||
Widget slider = SizedBox(
|
||||
@@ -437,6 +439,8 @@ void main() {
|
||||
enabled: enabled,
|
||||
onSeek: seeks.add,
|
||||
onSeekEnd: seekEnds.add,
|
||||
onScrubStart: onScrubStart,
|
||||
onScrubEnd: onScrubEnd,
|
||||
),
|
||||
);
|
||||
if (wrap != null) slider = wrap(slider);
|
||||
@@ -450,7 +454,15 @@ void main() {
|
||||
testWidgets('touch drag survives tooltip appearance and finalizes once', (tester) async {
|
||||
final seeks = <Duration>[];
|
||||
final seekEnds = <Duration>[];
|
||||
await pumpScrubSlider(tester, seeks: seeks, seekEnds: seekEnds);
|
||||
var scrubStarts = 0;
|
||||
var scrubEnds = 0;
|
||||
await pumpScrubSlider(
|
||||
tester,
|
||||
seeks: seeks,
|
||||
seekEnds: seekEnds,
|
||||
onScrubStart: () => scrubStarts++,
|
||||
onScrubEnd: () => scrubEnds++,
|
||||
);
|
||||
|
||||
// Down at the center (200/400 → 5min), drag +100px (→ 7.5min). The
|
||||
// first scrub event makes the tooltip appear; the drag must keep
|
||||
@@ -466,9 +478,73 @@ void main() {
|
||||
|
||||
expect(seeks, isNotEmpty);
|
||||
expect(seekEnds, hasLength(1));
|
||||
expect(scrubStarts, 1);
|
||||
expect(scrubEnds, 1);
|
||||
expect(seekEnds.single.inMilliseconds, closeTo(const Duration(minutes: 7, seconds: 30).inMilliseconds, 2000));
|
||||
});
|
||||
|
||||
testWidgets('keyboard input does not start a scrub lifecycle', (tester) async {
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
var scrubStarts = 0;
|
||||
var scrubEnds = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 400,
|
||||
child: TimelineSlider(
|
||||
position: const Duration(minutes: 1),
|
||||
duration: const Duration(minutes: 10),
|
||||
chapters: const [],
|
||||
chaptersLoaded: true,
|
||||
focusNode: focusNode,
|
||||
onKeyEvent: (_, event) => KeyEventResult.handled,
|
||||
onSeek: (_) {},
|
||||
onSeekEnd: (_) {},
|
||||
onScrubStart: () => scrubStarts++,
|
||||
onScrubEnd: () => scrubEnds++,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
|
||||
expect(scrubStarts, 0);
|
||||
expect(scrubEnds, 0);
|
||||
});
|
||||
|
||||
testWidgets('disposing mid-drag ends the scrub lifecycle', (tester) async {
|
||||
final seeks = <Duration>[];
|
||||
final seekEnds = <Duration>[];
|
||||
var scrubStarts = 0;
|
||||
var scrubEnds = 0;
|
||||
await pumpScrubSlider(
|
||||
tester,
|
||||
seeks: seeks,
|
||||
seekEnds: seekEnds,
|
||||
onScrubStart: () => scrubStarts++,
|
||||
onScrubEnd: () => scrubEnds++,
|
||||
);
|
||||
|
||||
final gesture = await tester.startGesture(tester.getCenter(find.byType(TimelineSlider)));
|
||||
await tester.pump();
|
||||
expect(scrubStarts, 1);
|
||||
expect(scrubEnds, 0);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump();
|
||||
await gesture.cancel();
|
||||
|
||||
expect(scrubEnds, 1);
|
||||
});
|
||||
|
||||
testWidgets('tap seeks to the tapped position', (tester) async {
|
||||
final seeks = <Duration>[];
|
||||
final seekEnds = <Duration>[];
|
||||
|
||||
Reference in New Issue
Block a user