Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`); the per-press seek amount is configurable via `ChewieController.keyboardSeekDuration` (default 10 seconds). Thanks [Ortes](https://github.com/Ortes).

## [1.14.1]
* 🛠️ [#945](https://github.com/fluttercommunity/chewie/pull/945): Flutter 3.38 downgrade. Thanks [diegotori](https://github.com/diegotori).
* Library now supports Flutter and Dart versions `3.38.0` and `3.10` or higher respectively, restoring previous compatibility.
Expand Down
15 changes: 15 additions & 0 deletions lib/src/chewie_player.dart
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,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',
Expand Down Expand Up @@ -394,6 +396,8 @@ class ChewieController extends ChangeNotifier {
)?
routePageBuilder,
bool? pauseOnBackgroundTap,
bool? showSeekIndicator,
Duration? keyboardSeekDuration,
}) {
return ChewieController(
draggableProgressBar: draggableProgressBar ?? this.draggableProgressBar,
Expand Down Expand Up @@ -458,6 +462,8 @@ class ChewieController extends ChangeNotifier {
progressIndicatorDelay:
progressIndicatorDelay ?? this.progressIndicatorDelay,
pauseOnBackgroundTap: pauseOnBackgroundTap ?? this.pauseOnBackgroundTap,
showSeekIndicator: showSeekIndicator ?? this.showSeekIndicator,
keyboardSeekDuration: keyboardSeekDuration ?? this.keyboardSeekDuration,
);
}

Expand Down Expand Up @@ -630,6 +636,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<ChewieControllerProvider>()!;
Expand Down
44 changes: 42 additions & 2 deletions lib/src/material/material_desktop_controls.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
Expand Down Expand Up @@ -42,6 +43,12 @@ class _MaterialDesktopControlsState extends State<MaterialDesktopControls>
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;

Expand Down Expand Up @@ -126,6 +133,12 @@ class _MaterialDesktopControlsState extends State<MaterialDesktopControls>
_buildBottomBar(context),
],
),
if (chewieController.showSeekIndicator)
SeekIndicator(
show: _showSeekIndicator,
forward: _seekIndicatorForward,
seconds: _seekIndicatorSeconds,
),
],
),
),
Expand All @@ -146,6 +159,7 @@ class _MaterialDesktopControlsState extends State<MaterialDesktopControls>
_hideTimer?.cancel();
_initTimer?.cancel();
_showAfterExpandCollapseTimer?.cancel();
_seekIndicatorTimer?.cancel();
}

@override
Expand Down Expand Up @@ -575,11 +589,37 @@ class _MaterialDesktopControlsState extends State<MaterialDesktopControls>
}

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) {
Expand Down
77 changes: 77 additions & 0 deletions lib/src/seek_indicator.dart
Original file line number Diff line number Diff line change
@@ -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,
),
),
],
),
),
),
),
),
);
}
}
133 changes: 133 additions & 0 deletions test/seek_indicator_test.dart
Original file line number Diff line number Diff line change
@@ -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<void> _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<SeekIndicator>(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),
);
});
}
Loading