diff --git a/CHANGELOG.md b/CHANGELOG.md index 41b92ca7a..f3c1ea1d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [Unreleased] +* 👇 [#930](https://github.com/fluttercommunity/chewie/pull/930): Add `ChewieController.swipeToExitFullscreen` (default `true`) and `swipeThreshold` (default `300` px/s) to exit fullscreen with a downward swipe. 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..5d529bc19 100644 --- a/lib/src/chewie_player.dart +++ b/lib/src/chewie_player.dart @@ -120,11 +120,26 @@ class ChewieState extends State { ) { return Scaffold( resizeToAvoidBottomInset: false, - body: Container( - alignment: Alignment.center, - color: Colors.black, - child: controllerProvider, - ), + body: widget.controller.swipeToExitFullscreen + ? GestureDetector( + onVerticalDragEnd: (DragEndDetails details) { + // A positive dy indicates a downward swipe. Use a threshold to avoid accidental triggers. + final double dy = details.primaryVelocity ?? 0; + if (dy > widget.controller.swipeThreshold) { + widget.controller.exitFullScreen(); + } + }, + child: Container( + alignment: Alignment.center, + color: Colors.black, + child: controllerProvider, + ), + ) + : Container( + alignment: Alignment.center, + color: Colors.black, + child: controllerProvider, + ), ); } @@ -364,6 +379,8 @@ class ChewieController extends ChangeNotifier { this.hideControlsTimer = defaultHideControlsTimer, this.controlsSafeAreaMinimum = EdgeInsets.zero, this.pauseOnBackgroundTap = false, + this.swipeToExitFullscreen = true, + this.swipeThreshold = 300, }) : assert( playbackSpeeds.every((speed) => speed > 0), 'The playbackSpeeds values must all be greater than 0', @@ -425,6 +442,8 @@ class ChewieController extends ChangeNotifier { )? routePageBuilder, bool? pauseOnBackgroundTap, + bool? swipeToExitFullscreen, + double? swipeThreshold, }) { return ChewieController( draggableProgressBar: draggableProgressBar ?? this.draggableProgressBar, @@ -492,6 +511,9 @@ class ChewieController extends ChangeNotifier { progressIndicatorDelay: progressIndicatorDelay ?? this.progressIndicatorDelay, pauseOnBackgroundTap: pauseOnBackgroundTap ?? this.pauseOnBackgroundTap, + swipeToExitFullscreen: + swipeToExitFullscreen ?? this.swipeToExitFullscreen, + swipeThreshold: swipeThreshold ?? this.swipeThreshold, ); } @@ -685,6 +707,13 @@ class ChewieController extends ChangeNotifier { /// Defines if the player should pause when the background is tapped final bool pauseOnBackgroundTap; + /// Defines if the player allows swipe to exit fullscreen + final bool swipeToExitFullscreen; + + /// Defines the minimum velocity threshold for swipe to exit fullscreen gesture + /// The velocity is measured in pixels per second + final double swipeThreshold; + static ChewieController of(BuildContext context) { final chewieControllerProvider = context .dependOnInheritedWidgetOfExactType()!; diff --git a/test/swipe_to_exit_fullscreen_test.dart b/test/swipe_to_exit_fullscreen_test.dart new file mode 100644 index 000000000..58d3dd8e1 --- /dev/null +++ b/test/swipe_to_exit_fullscreen_test.dart @@ -0,0 +1,185 @@ +import 'package:chewie/chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:video_player/video_player.dart'; + +ChewieController buildController({ + bool? swipeToExitFullscreen, + double? swipeThreshold, +}) { + return ChewieController( + videoPlayerController: VideoPlayerController.networkUrl( + Uri.parse('https://example.com/video.mp4'), + ), + autoPlay: false, + looping: false, + swipeToExitFullscreen: swipeToExitFullscreen ?? true, + swipeThreshold: swipeThreshold ?? 300, + ); +} + +/// The [GestureDetector] the fullscreen route wraps the video in when +/// [ChewieController.swipeToExitFullscreen] is enabled. +final Finder swipeArea = find.byWidgetPredicate( + (widget) => widget is GestureDetector && widget.onVerticalDragEnd != null, +); + +/// The black backdrop the fullscreen route always paints behind the video, +/// with or without the swipe gesture. The player's own route is offstage while +/// the fullscreen route is up, so this only ever matches the fullscreen one. +final Finder fullScreenBackground = find.byWidgetPredicate( + (widget) => widget is Container && widget.color == Colors.black, +); + +extension on WidgetTester { + Future pumpFullScreen(ChewieController controller) async { + await pumpWidget( + MaterialApp( + home: Scaffold(body: Chewie(controller: controller)), + ), + ); + await pump(); + + controller.enterFullScreen(); + // Let the fullscreen route finish its push transition. + await pump(); + await pump(const Duration(seconds: 1)); + } + + /// Flings vertically over the fullscreen video with the given velocity in + /// pixels per second. A positive [velocity] is a downward swipe. + Future swipeVertically(double velocity) async { + // A long enough travel for the velocity tracker to settle on [velocity], + // while staying inside the 800x600 test surface. + const double distance = 250; + await fling( + swipeArea, + Offset(0, velocity.isNegative ? -distance : distance), + velocity.abs(), + ); + await pump(); + await pump(const Duration(seconds: 1)); + } +} + +void main() { + group('swipe to exit fullscreen', () { + testWidgets('a downward swipe above the threshold exits fullscreen', ( + tester, + ) async { + final controller = buildController(); + addTearDown(controller.dispose); + + await tester.pumpFullScreen(controller); + expect(controller.isFullScreen, isTrue); + expect(swipeArea, findsOneWidget); + + await tester.swipeVertically(1000); + + expect(controller.isFullScreen, isFalse); + expect(swipeArea, findsNothing); + }); + + testWidgets('a downward swipe below the threshold stays in fullscreen', ( + tester, + ) async { + final controller = buildController(); + addTearDown(controller.dispose); + + await tester.pumpFullScreen(controller); + + await tester.swipeVertically(200); + + expect(controller.isFullScreen, isTrue); + expect(swipeArea, findsOneWidget); + }); + + testWidgets('an upward swipe never exits fullscreen', (tester) async { + final controller = buildController(); + addTearDown(controller.dispose); + + await tester.pumpFullScreen(controller); + + await tester.swipeVertically(-1000); + + expect(controller.isFullScreen, isTrue); + expect(swipeArea, findsOneWidget); + }); + + testWidgets('swipeThreshold defines the velocity that exits fullscreen', ( + tester, + ) async { + final controller = buildController(swipeThreshold: 2000); + addTearDown(controller.dispose); + + await tester.pumpFullScreen(controller); + + // Fast enough for the default threshold, too slow for this one. + await tester.swipeVertically(1000); + expect(controller.isFullScreen, isTrue); + + await tester.swipeVertically(3000); + expect(controller.isFullScreen, isFalse); + }); + + testWidgets('swipeToExitFullscreen: false leaves the gesture unwired', ( + tester, + ) async { + final controller = buildController(swipeToExitFullscreen: false); + addTearDown(controller.dispose); + + await tester.pumpFullScreen(controller); + expect(controller.isFullScreen, isTrue); + expect(swipeArea, findsNothing); + + // Same downward fling as the passing case, over the fullscreen video. + await tester.fling(fullScreenBackground, const Offset(0, 400), 1000); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(controller.isFullScreen, isTrue); + }); + }); + + group('ChewieController swipe options', () { + test('default to an enabled gesture at 300 px/s', () { + final controller = ChewieController( + videoPlayerController: VideoPlayerController.networkUrl( + Uri.parse('https://example.com/video.mp4'), + ), + ); + addTearDown(controller.dispose); + + expect(controller.swipeToExitFullscreen, isTrue); + expect(controller.swipeThreshold, 300); + }); + + test('copyWith overrides both options', () { + final controller = buildController(); + addTearDown(controller.dispose); + + final copy = controller.copyWith( + swipeToExitFullscreen: false, + swipeThreshold: 750, + ); + addTearDown(copy.dispose); + + expect(copy.swipeToExitFullscreen, isFalse); + expect(copy.swipeThreshold, 750); + }); + + test('copyWith preserves both options when they are omitted', () { + final controller = buildController( + swipeToExitFullscreen: false, + swipeThreshold: 750, + ); + addTearDown(controller.dispose); + + final copy = controller.copyWith(); + addTearDown(copy.dispose); + + expect(copy.swipeToExitFullscreen, isFalse); + expect(copy.swipeThreshold, 750); + }); + }); +}