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]
* 👇 [#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).
Expand Down
39 changes: 34 additions & 5 deletions lib/src/chewie_player.dart
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,26 @@ class ChewieState extends State<Chewie> {
) {
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,
),
);
}

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -425,6 +442,8 @@ class ChewieController extends ChangeNotifier {
)?
routePageBuilder,
bool? pauseOnBackgroundTap,
bool? swipeToExitFullscreen,
double? swipeThreshold,
}) {
return ChewieController(
draggableProgressBar: draggableProgressBar ?? this.draggableProgressBar,
Expand Down Expand Up @@ -492,6 +511,9 @@ class ChewieController extends ChangeNotifier {
progressIndicatorDelay:
progressIndicatorDelay ?? this.progressIndicatorDelay,
pauseOnBackgroundTap: pauseOnBackgroundTap ?? this.pauseOnBackgroundTap,
swipeToExitFullscreen:
swipeToExitFullscreen ?? this.swipeToExitFullscreen,
swipeThreshold: swipeThreshold ?? this.swipeThreshold,
);
}

Expand Down Expand Up @@ -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<ChewieControllerProvider>()!;
Expand Down
185 changes: 185 additions & 0 deletions test/swipe_to_exit_fullscreen_test.dart
Original file line number Diff line number Diff line change
@@ -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<void> 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<void> 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);
});
});
}