diff --git a/CHANGELOG.md b/CHANGELOG.md index 41b92ca7a..192c58467 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [Unreleased] +* ⏩ [#953](https://github.com/fluttercommunity/chewie/pull/953): Add a YouTube-style seek indicator that flashes the seeked amount when seeking with the keyboard arrows on desktop, accumulating on repeated presses in the same direction (e.g. `10s` → `20s` → `30s`). Configurable via `ChewieController.showSeekIndicator` (default `true`), with the per-press amount set by `ChewieController.keyboardSeekDuration` (default 10 seconds). Thanks [Ortes](https://github.com/Ortes). + ## [1.15.0] * 🌐 [#946](https://github.com/fluttercommunity/chewie/pull/946): Web: enter the browser's native (OS-level) fullscreen via the Fullscreen API instead of only expanding the Flutter view inside the browser window. Pressing Escape to leave browser fullscreen also exits Chewie's fullscreen. Controlled by the new `ChewieController.useNativeFullScreenOnWeb` flag (defaults to `true`; no effect on non-web platforms). Thanks [Ortes](https://github.com/Ortes). * 🖱️ [#950](https://github.com/fluttercommunity/chewie/pull/950): Show click cursor on hover over Material controls and progress bar. Thanks [Ortes](https://github.com/Ortes). diff --git a/lib/src/chewie_player.dart b/lib/src/chewie_player.dart index ead0c6bed..96406e654 100644 --- a/lib/src/chewie_player.dart +++ b/lib/src/chewie_player.dart @@ -364,6 +364,8 @@ class ChewieController extends ChangeNotifier { this.hideControlsTimer = defaultHideControlsTimer, this.controlsSafeAreaMinimum = EdgeInsets.zero, this.pauseOnBackgroundTap = false, + this.showSeekIndicator = true, + this.keyboardSeekDuration = const Duration(seconds: 10), }) : assert( playbackSpeeds.every((speed) => speed > 0), 'The playbackSpeeds values must all be greater than 0', @@ -425,6 +427,8 @@ class ChewieController extends ChangeNotifier { )? routePageBuilder, bool? pauseOnBackgroundTap, + bool? showSeekIndicator, + Duration? keyboardSeekDuration, }) { return ChewieController( draggableProgressBar: draggableProgressBar ?? this.draggableProgressBar, @@ -492,6 +496,8 @@ class ChewieController extends ChangeNotifier { progressIndicatorDelay: progressIndicatorDelay ?? this.progressIndicatorDelay, pauseOnBackgroundTap: pauseOnBackgroundTap ?? this.pauseOnBackgroundTap, + showSeekIndicator: showSeekIndicator ?? this.showSeekIndicator, + keyboardSeekDuration: keyboardSeekDuration ?? this.keyboardSeekDuration, ); } @@ -685,6 +691,15 @@ class ChewieController extends ChangeNotifier { /// Defines if the player should pause when the background is tapped final bool pauseOnBackgroundTap; + /// Whether to flash a YouTube-style indicator showing the seeked amount when + /// seeking with the keyboard arrows on desktop. Repeated presses in the same + /// direction accumulate (e.g. 10s → 20s → 30s). Defaults to `true`. + final bool showSeekIndicator; + + /// How far each left/right arrow-key press seeks on the desktop controls. + /// Also drives the amount shown by the seek indicator. Defaults to 10 seconds. + final Duration keyboardSeekDuration; + static ChewieController of(BuildContext context) { final chewieControllerProvider = context .dependOnInheritedWidgetOfExactType()!; diff --git a/lib/src/material/material_desktop_controls.dart b/lib/src/material/material_desktop_controls.dart index 42a0c8218..4413e6a1b 100644 --- a/lib/src/material/material_desktop_controls.dart +++ b/lib/src/material/material_desktop_controls.dart @@ -11,6 +11,7 @@ import 'package:chewie/src/material/widgets/playback_speed_dialog.dart'; import 'package:chewie/src/models/option_item.dart'; import 'package:chewie/src/models/subtitle_model.dart'; import 'package:chewie/src/notifiers/index.dart'; +import 'package:chewie/src/seek_indicator.dart'; import 'package:chewie/src/subtitle_overlay.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -43,6 +44,12 @@ class _MaterialDesktopControlsState extends State Timer? _bufferingDisplayTimer; bool _displayBufferingIndicator = false; + // YouTube-style keyboard seek indicator state. + Timer? _seekIndicatorTimer; + bool _showSeekIndicator = false; + bool _seekIndicatorForward = true; + int _seekIndicatorSeconds = 0; + final barHeight = 48.0 * 1.5; final marginSize = 5.0; @@ -127,6 +134,12 @@ class _MaterialDesktopControlsState extends State _buildBottomBar(context), ], ), + if (chewieController.showSeekIndicator) + SeekIndicator( + show: _showSeekIndicator, + forward: _seekIndicatorForward, + seconds: _seekIndicatorSeconds, + ), ], ), ), @@ -147,6 +160,7 @@ class _MaterialDesktopControlsState extends State _hideTimer?.cancel(); _initTimer?.cancel(); _showAfterExpandCollapseTimer?.cancel(); + _seekIndicatorTimer?.cancel(); } @override @@ -568,11 +582,37 @@ class _MaterialDesktopControlsState extends State } void _seekBackward() { - _seekRelative(const Duration(seconds: -10)); + _seekRelative(-chewieController.keyboardSeekDuration); + _bumpSeekIndicator(forward: false); } void _seekForward() { - _seekRelative(const Duration(seconds: 10)); + _seekRelative(chewieController.keyboardSeekDuration); + _bumpSeekIndicator(forward: true); + } + + /// Shows the YouTube-style seek indicator and accumulates the seeked amount + /// while the user keeps pressing in the same direction. Pressing the opposite + /// direction (or after it has faded out) resets the counter. + void _bumpSeekIndicator({required bool forward}) { + if (!chewieController.showSeekIndicator) return; + + final step = chewieController.keyboardSeekDuration.inSeconds; + setState(() { + if (_showSeekIndicator && _seekIndicatorForward == forward) { + _seekIndicatorSeconds += step; + } else { + _seekIndicatorForward = forward; + _seekIndicatorSeconds = step; + } + _showSeekIndicator = true; + }); + + _seekIndicatorTimer?.cancel(); + _seekIndicatorTimer = Timer(const Duration(milliseconds: 900), () { + if (!mounted) return; + setState(() => _showSeekIndicator = false); + }); } void _seekRelative(Duration relativeSeek) { diff --git a/lib/src/seek_indicator.dart b/lib/src/seek_indicator.dart new file mode 100644 index 000000000..451da2a4d --- /dev/null +++ b/lib/src/seek_indicator.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +/// A YouTube-style transient indicator that flashes the amount of time seeked +/// when the user seeks with the keyboard. +/// +/// Repeated presses in the same direction keep it visible and accumulate +/// [seconds] (e.g. 10 → 20 → 30). It is purely visual and never intercepts +/// pointer events. +class SeekIndicator extends StatelessWidget { + const SeekIndicator({ + super.key, + required this.show, + required this.forward, + required this.seconds, + this.fadeDuration = const Duration(milliseconds: 300), + }); + + /// Whether the indicator is currently visible. + final bool show; + + /// `true` when seeking forward, `false` when seeking backward. Drives both + /// the icon and which side of the screen the pill sits on. + final bool forward; + + /// The accumulated number of seconds seeked, shown as text. + final int seconds; + + /// How long the fade in/out animation takes. + final Duration fadeDuration; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Align( + alignment: forward + ? const Alignment(0.6, 0.0) + : const Alignment(-0.6, 0.0), + child: AnimatedOpacity( + opacity: show ? 1.0 : 0.0, + duration: fadeDuration, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(24.0), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 10.0, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + forward + ? Icons.fast_forward_rounded + : Icons.fast_rewind_rounded, + color: Colors.white, + size: 22.0, + ), + const SizedBox(width: 6.0), + Text( + '$seconds s', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/test/seek_indicator_test.dart b/test/seek_indicator_test.dart new file mode 100644 index 000000000..646b20b89 --- /dev/null +++ b/test/seek_indicator_test.dart @@ -0,0 +1,133 @@ +import 'package:chewie/chewie.dart'; +import 'package:chewie/src/seek_indicator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:video_player/video_player.dart'; + +const _src = + 'https://assets.mixkit.co/videos/preview/mixkit-spinning-around-the-earth-29351-large.mp4'; + +ChewieController _controller({ + bool showSeekIndicator = true, + Duration keyboardSeekDuration = const Duration(seconds: 10), +}) { + return ChewieController( + videoPlayerController: VideoPlayerController.networkUrl(Uri.parse(_src)), + autoPlay: false, + looping: false, + showSeekIndicator: showSeekIndicator, + keyboardSeekDuration: keyboardSeekDuration, + customControls: const MaterialDesktopControls(), + ); +} + +Future _pumpPlayer( + WidgetTester tester, + ChewieController controller, +) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: Chewie(controller: controller)), + ), + ); + await tester.pump(); +} + +SeekIndicator _indicator(WidgetTester tester) => + tester.widget(find.byType(SeekIndicator)); + +void main() { + testWidgets('accumulates seconds on repeated forward keypresses', ( + tester, + ) async { + await _pumpPlayer(tester, _controller()); + + for (var i = 0; i < 3; i++) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + } + + final indicator = _indicator(tester); + expect(indicator.show, isTrue); + expect(indicator.forward, isTrue); + expect(indicator.seconds, 30); + }); + + testWidgets('resets and flips direction on an opposite keypress', ( + tester, + ) async { + await _pumpPlayer(tester, _controller()); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + + final indicator = _indicator(tester); + expect(indicator.forward, isFalse); + expect(indicator.seconds, 10); + }); + + testWidgets('accumulates the configured keyboardSeekDuration', ( + tester, + ) async { + await _pumpPlayer( + tester, + _controller(keyboardSeekDuration: const Duration(seconds: 5)), + ); + + for (var i = 0; i < 3; i++) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + } + + expect(_indicator(tester).seconds, 15); + }); + + testWidgets('fades out shortly after the last keypress', (tester) async { + await _pumpPlayer(tester, _controller()); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(_indicator(tester).show, isTrue); + + await tester.pump(const Duration(seconds: 1)); + expect(_indicator(tester).show, isFalse); + }); + + testWidgets('is not built when showSeekIndicator is false', (tester) async { + await _pumpPlayer(tester, _controller(showSeekIndicator: false)); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(find.byType(SeekIndicator), findsNothing); + }); + + test('showSeekIndicator defaults to true and survives copyWith', () { + final controller = ChewieController( + videoPlayerController: VideoPlayerController.networkUrl(Uri.parse(_src)), + ); + expect(controller.showSeekIndicator, isTrue); + expect( + controller.copyWith(showSeekIndicator: false).showSeekIndicator, + isFalse, + ); + }); + + test('keyboardSeekDuration defaults to 10s and survives copyWith', () { + final controller = ChewieController( + videoPlayerController: VideoPlayerController.networkUrl(Uri.parse(_src)), + ); + expect(controller.keyboardSeekDuration, const Duration(seconds: 10)); + expect( + controller + .copyWith(keyboardSeekDuration: const Duration(seconds: 30)) + .keyboardSeekDuration, + const Duration(seconds: 30), + ); + }); +}