diff --git a/lib/src/chewie_player.dart b/lib/src/chewie_player.dart index 7ffa295b2..066e1df18 100644 --- a/lib/src/chewie_player.dart +++ b/lib/src/chewie_player.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:chewie/src/chewie_progress_colors.dart'; +import 'package:chewie/src/models/chewie_chapter.dart'; import 'package:chewie/src/models/option_item.dart'; import 'package:chewie/src/models/options_translation.dart'; import 'package:chewie/src/models/subtitle_model.dart'; @@ -335,6 +336,7 @@ class ChewieController extends ChangeNotifier { this.hideControlsTimer = defaultHideControlsTimer, this.controlsSafeAreaMinimum = EdgeInsets.zero, this.pauseOnBackgroundTap = false, + this.chapters = const [], }) : assert( playbackSpeeds.every((speed) => speed > 0), 'The playbackSpeeds values must all be greater than 0', @@ -394,6 +396,7 @@ class ChewieController extends ChangeNotifier { )? routePageBuilder, bool? pauseOnBackgroundTap, + List? chapters, }) { return ChewieController( draggableProgressBar: draggableProgressBar ?? this.draggableProgressBar, @@ -458,6 +461,7 @@ class ChewieController extends ChangeNotifier { progressIndicatorDelay: progressIndicatorDelay ?? this.progressIndicatorDelay, pauseOnBackgroundTap: pauseOnBackgroundTap ?? this.pauseOnBackgroundTap, + chapters: chapters ?? this.chapters, ); } @@ -630,6 +634,11 @@ class ChewieController extends ChangeNotifier { /// Defines if the player should pause when the background is tapped final bool pauseOnBackgroundTap; + /// Chapters of the video, sorted by ascending start time. + /// When non-empty, the progress bar is split into chapter segments and the + /// hovered/scrubbed chapter title is displayed above the bar. + final List chapters; + static ChewieController of(BuildContext context) { final chewieControllerProvider = context .dependOnInheritedWidgetOfExactType()!; diff --git a/lib/src/cupertino/cupertino_controls.dart b/lib/src/cupertino/cupertino_controls.dart index 201f236f0..962d23b5d 100644 --- a/lib/src/cupertino/cupertino_controls.dart +++ b/lib/src/cupertino/cupertino_controls.dart @@ -639,6 +639,7 @@ class _CupertinoControlsState extends State padding: const EdgeInsets.only(right: 12.0), child: CupertinoVideoProgressBar( controller, + chapters: chewieController.chapters, onDragStart: () { setState(() { _dragging = true; diff --git a/lib/src/cupertino/cupertino_progress_bar.dart b/lib/src/cupertino/cupertino_progress_bar.dart index f24fc0f80..5ed1e4ef6 100644 --- a/lib/src/cupertino/cupertino_progress_bar.dart +++ b/lib/src/cupertino/cupertino_progress_bar.dart @@ -1,4 +1,5 @@ import 'package:chewie/src/chewie_progress_colors.dart'; +import 'package:chewie/src/models/chewie_chapter.dart'; import 'package:chewie/src/progress_bar.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; @@ -13,8 +14,10 @@ class CupertinoVideoProgressBar extends StatelessWidget { this.onDragUpdate, super.key, this.draggableProgressBar = true, + this.chapters = const [], }) : colors = colors ?? ChewieProgressColors(); + final List chapters; final VideoPlayerController controller; final ChewieProgressColors colors; final Function()? onDragStart; @@ -34,6 +37,7 @@ class CupertinoVideoProgressBar extends StatelessWidget { onDragStart: onDragStart, onDragUpdate: onDragUpdate, draggableProgressBar: draggableProgressBar, + chapters: chapters, ); } } diff --git a/lib/src/material/material_controls.dart b/lib/src/material/material_controls.dart index 3d43a1ab8..3b62fcb85 100644 --- a/lib/src/material/material_controls.dart +++ b/lib/src/material/material_controls.dart @@ -639,6 +639,7 @@ class _MaterialControlsState extends State return Expanded( child: MaterialVideoProgressBar( controller, + chapters: chewieController.chapters, onDragStart: () { setState(() { _dragging = true; diff --git a/lib/src/material/material_desktop_controls.dart b/lib/src/material/material_desktop_controls.dart index acdd4a8b0..dcc0cc42c 100644 --- a/lib/src/material/material_desktop_controls.dart +++ b/lib/src/material/material_desktop_controls.dart @@ -600,6 +600,7 @@ class _MaterialDesktopControlsState extends State return Expanded( child: MaterialVideoProgressBar( controller, + chapters: chewieController.chapters, onDragStart: () { setState(() { _dragging = true; diff --git a/lib/src/material/material_progress_bar.dart b/lib/src/material/material_progress_bar.dart index ba58a37d6..c72a469cc 100644 --- a/lib/src/material/material_progress_bar.dart +++ b/lib/src/material/material_progress_bar.dart @@ -1,4 +1,5 @@ import 'package:chewie/src/chewie_progress_colors.dart'; +import 'package:chewie/src/models/chewie_chapter.dart'; import 'package:chewie/src/progress_bar.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; @@ -15,8 +16,10 @@ class MaterialVideoProgressBar extends StatelessWidget { this.onDragUpdate, super.key, this.draggableProgressBar = true, + this.chapters = const [], }) : colors = colors ?? ChewieProgressColors(); + final List chapters; final double height; final double barHeight; final double handleHeight; @@ -39,6 +42,7 @@ class MaterialVideoProgressBar extends StatelessWidget { onDragStart: onDragStart, onDragUpdate: onDragUpdate, draggableProgressBar: draggableProgressBar, + chapters: chapters, ); } } diff --git a/lib/src/models/chewie_chapter.dart b/lib/src/models/chewie_chapter.dart new file mode 100644 index 000000000..bee572dc6 --- /dev/null +++ b/lib/src/models/chewie_chapter.dart @@ -0,0 +1,6 @@ +class ChewieChapter { + const ChewieChapter({required this.title, required this.start}); + + final String title; + final Duration start; +} diff --git a/lib/src/models/index.dart b/lib/src/models/index.dart index a308c33db..14471221b 100644 --- a/lib/src/models/index.dart +++ b/lib/src/models/index.dart @@ -1,3 +1,4 @@ +export 'chewie_chapter.dart'; export 'option_item.dart'; export 'options_translation.dart'; export 'subtitle_model.dart'; diff --git a/lib/src/progress_bar.dart b/lib/src/progress_bar.dart index 4b7eea470..e6f7a93d4 100644 --- a/lib/src/progress_bar.dart +++ b/lib/src/progress_bar.dart @@ -1,4 +1,5 @@ import 'package:chewie/chewie.dart'; +import 'package:chewie/src/helpers/utils.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; @@ -14,6 +15,7 @@ class VideoProgressBar extends StatefulWidget { required this.barHeight, required this.handleHeight, required this.drawShadow, + this.chapters = const [], }) : colors = colors ?? ChewieProgressColors(); final VideoPlayerController controller; @@ -26,6 +28,7 @@ class VideoProgressBar extends StatefulWidget { final double handleHeight; final bool drawShadow; final bool draggableProgressBar; + final List chapters; @override // ignore: library_private_types_in_public_api @@ -44,6 +47,8 @@ class _VideoProgressBarState extends State { Offset? _latestDraggableOffset; + Offset? _hoverPosition; + VideoPlayerController get controller => widget.controller; @override @@ -74,10 +79,11 @@ class _VideoProgressBarState extends State { handleHeight: widget.handleHeight, drawShadow: widget.drawShadow, latestDraggableOffset: _latestDraggableOffset, + chapters: widget.chapters, ), ); - return widget.draggableProgressBar + final interactive = widget.draggableProgressBar ? GestureDetector( onHorizontalDragStart: (DragStartDetails details) { if (!controller.value.isInitialized) { @@ -120,6 +126,89 @@ class _VideoProgressBarState extends State { child: child, ) : child; + + if (widget.chapters.isEmpty) { + return widget.draggableProgressBar + ? MouseRegion(cursor: SystemMouseCursors.click, child: interactive) + : interactive; + } + + return MouseRegion( + cursor: widget.draggableProgressBar + ? SystemMouseCursors.click + : MouseCursor.defer, + onHover: (event) => setState(() => _hoverPosition = event.localPosition), + onExit: (_) => setState(() => _hoverPosition = null), + child: LayoutBuilder( + builder: (context, constraints) { + return Stack( + clipBehavior: Clip.none, + children: [interactive, ?_buildChapterLabel(constraints)], + ); + }, + ), + ); + } + + Widget? _buildChapterLabel(BoxConstraints constraints) { + final value = controller.value; + if (!value.isInitialized || value.duration <= Duration.zero) { + return null; + } + + double? pointedDx; + if (_latestDraggableOffset != null) { + final box = context.findRenderObject() as RenderBox?; + if (box != null && box.hasSize) { + pointedDx = box.globalToLocal(_latestDraggableOffset!).dx; + } + } else if (_hoverPosition != null) { + pointedDx = _hoverPosition!.dx; + } + if (pointedDx == null || + !constraints.maxWidth.isFinite || + constraints.maxWidth <= 0) { + return null; + } + + final clampedDx = pointedDx.clamp(0.0, constraints.maxWidth); + final pointedPosition = value.duration * (clampedDx / constraints.maxWidth); + + ChewieChapter? pointedChapter; + for (final chapter in widget.chapters) { + if (chapter.start > pointedPosition) break; + pointedChapter = chapter; + } + if (pointedChapter == null) { + return null; + } + + final stackHeight = constraints.maxHeight.isFinite + ? constraints.maxHeight + : widget.barHeight * 2; + + return Positioned( + left: clampedDx, + bottom: stackHeight / 2 + widget.barHeight / 2 + widget.handleHeight + 4, + child: FractionalTranslation( + translation: const Offset(-0.5, 0), + child: IgnorePointer( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${pointedChapter.title} ยท ${formatDuration(pointedPosition)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ), + ), + ), + ); } } @@ -132,6 +221,7 @@ class StaticProgressBar extends StatelessWidget { required this.handleHeight, required this.drawShadow, this.latestDraggableOffset, + this.chapters = const [], }); final Offset? latestDraggableOffset; @@ -141,6 +231,7 @@ class StaticProgressBar extends StatelessWidget { final double barHeight; final double handleHeight; final bool drawShadow; + final List chapters; @override Widget build(BuildContext context) { @@ -161,6 +252,7 @@ class StaticProgressBar extends StatelessWidget { barHeight: barHeight, handleHeight: handleHeight, drawShadow: drawShadow, + chapters: chapters, ), ), ); @@ -175,6 +267,7 @@ class _ProgressBarPainter extends CustomPainter { required this.handleHeight, required this.drawShadow, required this.draggableValue, + this.chapters = const [], }); VideoPlayerValue value; @@ -183,29 +276,86 @@ class _ProgressBarPainter extends CustomPainter { final double barHeight; final double handleHeight; final bool drawShadow; + final List chapters; /// The value of the draggable progress bar. /// If null, the progress bar is not being dragged. final Duration? draggableValue; + static const double _chapterGapWidth = 2.0; + @override bool shouldRepaint(CustomPainter painter) { return true; } + List _chapterBoundaries(Size size) { + final durationMs = value.duration.inMilliseconds; + if (durationMs <= 0 || chapters.isEmpty) { + return const []; + } + final boundaries = []; + for (final chapter in chapters) { + final startMs = chapter.start.inMilliseconds; + if (startMs <= 0 || startMs >= durationMs) continue; + boundaries.add(startMs / durationMs * size.width); + } + return boundaries; + } + + void _drawBar( + Canvas canvas, + double fromX, + double toX, + double baseOffset, + Paint paint, + List boundaries, + ) { + if (toX <= fromX) return; + var segmentStart = fromX; + for (final boundary in boundaries) { + if (boundary <= fromX || boundary >= toX) continue; + final segmentEnd = boundary - _chapterGapWidth / 2; + if (segmentEnd > segmentStart) { + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromPoints( + Offset(segmentStart, baseOffset), + Offset(segmentEnd, baseOffset + barHeight), + ), + const Radius.circular(4.0), + ), + paint, + ); + } + segmentStart = boundary + _chapterGapWidth / 2; + } + if (toX > segmentStart) { + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromPoints( + Offset(segmentStart, baseOffset), + Offset(toX, baseOffset + barHeight), + ), + const Radius.circular(4.0), + ), + paint, + ); + } + } + @override void paint(Canvas canvas, Size size) { final baseOffset = size.height / 2 - barHeight / 2; + final boundaries = _chapterBoundaries(size); - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromPoints( - Offset(0.0, baseOffset), - Offset(size.width, baseOffset + barHeight), - ), - const Radius.circular(4.0), - ), + _drawBar( + canvas, + 0.0, + size.width, + baseOffset, colors.backgroundPaint, + boundaries, ); if (!value.isInitialized) { return; @@ -221,26 +371,22 @@ class _ProgressBarPainter extends CustomPainter { for (final DurationRange range in value.buffered) { final double start = range.startFraction(value.duration) * size.width; final double end = range.endFraction(value.duration) * size.width; - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromPoints( - Offset(start, baseOffset), - Offset(end, baseOffset + barHeight), - ), - const Radius.circular(4.0), - ), + _drawBar( + canvas, + start, + end, + baseOffset, colors.bufferedPaint, + boundaries, ); } - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromPoints( - Offset(0.0, baseOffset), - Offset(playedPart, baseOffset + barHeight), - ), - const Radius.circular(4.0), - ), + _drawBar( + canvas, + 0.0, + playedPart, + baseOffset, colors.playedPaint, + boundaries, ); if (drawShadow) { diff --git a/test/progress_bar_chapters_test.dart b/test/progress_bar_chapters_test.dart new file mode 100644 index 000000000..b86f27b7c --- /dev/null +++ b/test/progress_bar_chapters_test.dart @@ -0,0 +1,312 @@ +import 'package:chewie/chewie.dart'; +import 'package:chewie/src/progress_bar.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:video_player/video_player.dart'; + +const _chapters = [ + ChewieChapter(title: 'Intro', start: Duration.zero), + ChewieChapter(title: 'Second Chapter', start: Duration(minutes: 1)), + ChewieChapter(title: 'Finale', start: Duration(minutes: 3)), +]; + +class _FakeVideoPlayerController extends VideoPlayerController { + _FakeVideoPlayerController() + : super.networkUrl(Uri.parse('https://example.com/video.m3u8')) { + value = VideoPlayerValue( + duration: const Duration(minutes: 4), + isInitialized: true, + position: const Duration(minutes: 2), + buffered: [DurationRange(Duration.zero, const Duration(minutes: 3))], + ); + } + + Duration? lastSeek; + int playCalls = 0; + int pauseCalls = 0; + + @override + Future seekTo(Duration position) async { + lastSeek = position; + } + + @override + Future play() async { + playCalls++; + } + + @override + Future pause() async { + pauseCalls++; + } + + @override + Future setLooping(bool looping) async {} + + @override + Future setVolume(double volume) async {} + + @override + Future initialize() async {} +} + +Widget _wrapBar(VideoProgressBar bar) { + return MaterialApp( + home: Scaffold( + body: Center(child: SizedBox(width: 400, height: 48, child: bar)), + ), + ); +} + +Future _hoverAt(WidgetTester tester, Offset location) async { + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await gesture.moveTo(location); + await tester.pump(); + return gesture; +} + +void main() { + testWidgets('progress bar with chapters paints segments and handles hover', ( + tester, + ) async { + final controller = _FakeVideoPlayerController(); + await tester.pumpWidget( + _wrapBar( + VideoProgressBar( + controller, + barHeight: 10, + handleHeight: 6, + drawShadow: true, + chapters: _chapters, + ), + ), + ); + + expect(find.byType(VideoProgressBar), findsOneWidget); + expect(tester.takeException(), isNull); + + final barCenter = tester.getCenter(find.byType(VideoProgressBar)); + final gesture = await _hoverAt(tester, barCenter); + await tester.pump(); + + expect(find.textContaining('Second Chapter'), findsOneWidget); + + await gesture.moveTo(barCenter - const Offset(150, 0)); + await tester.pump(); + expect(find.textContaining('Intro'), findsOneWidget); + + await gesture.moveTo(barCenter + const Offset(180, 0)); + await tester.pump(); + expect(find.textContaining('Finale'), findsOneWidget); + + await gesture.moveTo(const Offset(1, 1)); + await tester.pump(); + expect(find.textContaining('Finale'), findsNothing); + }); + + testWidgets('scrubbing shows the pointed chapter and seeks on release', ( + tester, + ) async { + final controller = _FakeVideoPlayerController(); + await tester.pumpWidget( + _wrapBar( + VideoProgressBar( + controller, + barHeight: 10, + handleHeight: 6, + drawShadow: true, + chapters: _chapters, + ), + ), + ); + + final barCenter = tester.getCenter(find.byType(VideoProgressBar)); + final drag = await tester.startGesture(barCenter); + await drag.moveBy(const Offset(60, 0)); + await tester.pump(); + await drag.moveBy(const Offset(60, 0)); + await tester.pump(); + + expect(find.textContaining('Finale'), findsOneWidget); + + await drag.up(); + await tester.pump(); + + expect(controller.lastSeek, isNotNull); + expect(controller.lastSeek!, greaterThan(const Duration(minutes: 3))); + }); + + testWidgets('tap seeks to the tapped position', (tester) async { + final controller = _FakeVideoPlayerController(); + await tester.pumpWidget( + _wrapBar( + VideoProgressBar( + controller, + barHeight: 10, + handleHeight: 6, + drawShadow: true, + chapters: _chapters, + ), + ), + ); + + await tester.tapAt(tester.getCenter(find.byType(VideoProgressBar))); + await tester.pump(); + + expect(controller.lastSeek, const Duration(minutes: 2)); + }); + + testWidgets('out-of-range chapter starts are ignored when painting', ( + tester, + ) async { + final controller = _FakeVideoPlayerController(); + await tester.pumpWidget( + _wrapBar( + VideoProgressBar( + controller, + barHeight: 10, + handleHeight: 6, + drawShadow: true, + chapters: const [ + ChewieChapter(title: 'Intro', start: Duration.zero), + ChewieChapter(title: 'Beyond', start: Duration(minutes: 10)), + ], + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('no chapter label is shown without chapters', (tester) async { + final controller = _FakeVideoPlayerController(); + await tester.pumpWidget( + _wrapBar( + VideoProgressBar( + controller, + barHeight: 10, + handleHeight: 6, + drawShadow: true, + ), + ), + ); + + final region = tester.widget( + find + .descendant( + of: find.byType(VideoProgressBar), + matching: find.byType(MouseRegion), + ) + .first, + ); + expect(region.cursor, SystemMouseCursors.click); + + await _hoverAt(tester, tester.getCenter(find.byType(VideoProgressBar))); + await tester.pump(); + expect(find.byType(Text), findsNothing); + }); + + testWidgets('non-draggable bar with chapters never seeks', (tester) async { + final controller = _FakeVideoPlayerController(); + await tester.pumpWidget( + _wrapBar( + VideoProgressBar( + controller, + barHeight: 10, + handleHeight: 6, + drawShadow: true, + draggableProgressBar: false, + chapters: _chapters, + ), + ), + ); + + await tester.tapAt(tester.getCenter(find.byType(VideoProgressBar))); + await tester.pump(); + + expect(controller.lastSeek, isNull); + expect(tester.takeException(), isNull); + }); + + testWidgets('uninitialized controller paints the plain background bar', ( + tester, + ) async { + final controller = VideoPlayerController.networkUrl( + Uri.parse('https://example.com/video.m3u8'), + ); + await tester.pumpWidget( + _wrapBar( + VideoProgressBar( + controller, + barHeight: 10, + handleHeight: 6, + drawShadow: true, + chapters: _chapters, + ), + ), + ); + + await _hoverAt(tester, tester.getCenter(find.byType(VideoProgressBar))); + await tester.pump(); + + expect(find.byType(Text), findsNothing); + expect(tester.takeException(), isNull); + }); + + test('ChewieChapter holds its title and start time', () { + const chapter = ChewieChapter(title: 'Intro', start: Duration(seconds: 5)); + expect(chapter.title, 'Intro'); + expect(chapter.start, const Duration(seconds: 5)); + }); + + test('ChewieController exposes chapters and copyWith carries them over', () { + final controller = ChewieController( + videoPlayerController: _FakeVideoPlayerController(), + chapters: _chapters, + ); + expect(controller.chapters, _chapters); + + final unchanged = controller.copyWith(); + expect(unchanged.chapters, _chapters); + + const replacement = [ChewieChapter(title: 'Only', start: Duration.zero)]; + final replaced = controller.copyWith(chapters: replacement); + expect(replaced.chapters, replacement); + }); + + for (final (String name, Widget controls) in [ + ('MaterialControls', const MaterialControls()), + ('MaterialDesktopControls', const MaterialDesktopControls()), + ( + 'CupertinoControls', + const CupertinoControls( + backgroundColor: Colors.black, + iconColor: Colors.white, + ), + ), + ]) { + testWidgets('$name forwards chapters to its progress bar', (tester) async { + final chewieController = ChewieController( + videoPlayerController: _FakeVideoPlayerController(), + chapters: _chapters, + customControls: controls, + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: Chewie(controller: chewieController)), + ), + ); + await tester.pump(); + + final bar = tester.widget( + find.byType(VideoProgressBar), + ); + expect(bar.chapters, _chapters); + + await tester.pumpWidget(const SizedBox()); + }); + } +}