Skip to content
Open
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## [Unreleased]
* ⏯️ [#957](https://github.com/fluttercommunity/chewie/pull/957): Reveal the controls whenever playback is toggled from outside them — hardware media keys, headset/notification controls, the MediaSession API, or the host app driving the `VideoPlayerController` — and restart the auto-hide timer. 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
15 changes: 15 additions & 0 deletions lib/src/cupertino/cupertino_controls.dart
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ class _CupertinoControlsState extends State<CupertinoControls>
final oldController = _chewieController;
_chewieController = ChewieController.of(context);
controller = chewieController.videoPlayerController;
_latestValue = controller.value;

if (oldController != chewieController) {
_dispose();
Expand Down Expand Up @@ -746,6 +747,20 @@ class _CupertinoControlsState extends State<CupertinoControls>
_displayBufferingIndicator = buffering;
}

// Play/pause can also come from outside these controls (Control Center,
// the lock screen, headset buttons): reveal the controls the same way
// _playPause does, so the state change is visible either way.
if (_latestValue.isPlaying != controller.value.isPlaying) {
if (controller.value.isPlaying) {
_cancelAndRestartTimer();
} else {
setState(() {
notifier.hideStuff = false;
});
_hideTimer?.cancel();
}
}

setState(() {
_latestValue = controller.value;
_subtitlesPosition = controller.value.position;
Expand Down
15 changes: 15 additions & 0 deletions lib/src/material/material_controls.dart
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ class _MaterialControlsState extends State<MaterialControls>
final oldController = _chewieController;
_chewieController = ChewieController.of(context);
controller = chewieController.videoPlayerController;
_latestValue = controller.value;

if (oldController != chewieController) {
_dispose();
Expand Down Expand Up @@ -622,6 +623,20 @@ class _MaterialControlsState extends State<MaterialControls>
_displayBufferingIndicator = buffering;
}

// Play/pause can also come from outside these controls (headset buttons,
// notification controls, MediaSession): reveal the controls the same way
// _playPause does, so the state change is visible either way.
if (_latestValue.isPlaying != controller.value.isPlaying) {
if (controller.value.isPlaying) {
_cancelAndRestartTimer();
} else {
setState(() {
notifier.hideStuff = false;
});
_hideTimer?.cancel();
}
}

setState(() {
_latestValue = controller.value;
_subtitlesPosition = controller.value.position;
Expand Down
15 changes: 15 additions & 0 deletions lib/src/material/material_desktop_controls.dart
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ class _MaterialDesktopControlsState extends State<MaterialDesktopControls>
final oldController = _chewieController;
_chewieController = ChewieController.of(context);
controller = chewieController.videoPlayerController;
_latestValue = controller.value;

if (oldController != chewieController) {
_dispose();
Expand Down Expand Up @@ -561,6 +562,20 @@ class _MaterialDesktopControlsState extends State<MaterialDesktopControls>
_displayBufferingIndicator = buffering;
}

// Play/pause can also come from outside these controls (hardware media
// keys handled by the browser, MediaSession): reveal the controls the same
// way _playPause does, so the state change is visible either way.
if (_latestValue.isPlaying != controller.value.isPlaying) {
if (controller.value.isPlaying) {
_cancelAndRestartTimer();
} else {
setState(() {
notifier.hideStuff = false;
});
_hideTimer?.cancel();
}
}

setState(() {
_latestValue = controller.value;
_subtitlesPosition = controller.value.position;
Expand Down
149 changes: 149 additions & 0 deletions test/external_playpause_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import 'package:chewie/chewie.dart';
import 'package:chewie/src/center_play_button.dart';
import 'package:chewie/src/notifiers/index.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart';

const _src =
'https://assets.mixkit.co/videos/preview/mixkit-spinning-around-the-earth-29351-large.mp4';

// Long enough for the hide timer started by _cancelAndRestartTimer to fire.
final _afterHideControlsTimer =
ChewieController.defaultHideControlsTimer + const Duration(seconds: 1);

const _controlsUnderTest = <String, Widget>{
'MaterialControls': MaterialControls(),
'MaterialDesktopControls': MaterialDesktopControls(),
'CupertinoControls': CupertinoControls(
backgroundColor: Colors.black,
iconColor: Colors.white,
),
};

/// Flips the playing state the way an external actor would (headset buttons,
/// notification and Control Center controls, hardware media keys handled by
/// the browser, the embedder driving the controller): the value changes
/// without going through the controls' own play/pause path.
void _setPlayingExternally(
VideoPlayerController controller, {
required bool playing,
}) {
controller.value = controller.value.copyWith(isPlaying: playing);
}

Future<VideoPlayerController> _pumpPlayer(
WidgetTester tester,
Widget controls,
) async {
final videoController = VideoPlayerController.networkUrl(Uri.parse(_src));
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Chewie(
controller: ChewieController(
videoPlayerController: videoController,
autoPlay: false,
looping: false,
showControlsOnInitialize: false,
customControls: controls,
),
),
),
),
);
await tester.pump();
return videoController;
}

bool _controlsVisible(WidgetTester tester) {
final notifier = Provider.of<PlayerNotifier>(
tester.element(find.byType(CenterPlayButton)),
listen: false,
);
return !notifier.hideStuff;
}

void _playPauseFromControls(WidgetTester tester) {
tester.widget<CenterPlayButton>(find.byType(CenterPlayButton)).onPressed!();
}

void main() {
_controlsUnderTest.forEach((name, controls) {
group(name, () {
testWidgets('reveals the controls and auto-hides on an external play', (
tester,
) async {
final videoController = await _pumpPlayer(tester, controls);
expect(_controlsVisible(tester), isFalse);

_setPlayingExternally(videoController, playing: true);
await tester.pump();
expect(_controlsVisible(tester), isTrue);

await tester.pump(_afterHideControlsTimer);
expect(_controlsVisible(tester), isFalse);
});

testWidgets(
'reveals the controls and keeps them up on an external pause',
(tester) async {
final videoController = await _pumpPlayer(tester, controls);

_setPlayingExternally(videoController, playing: true);
await tester.pump();
await tester.pump(_afterHideControlsTimer);
expect(_controlsVisible(tester), isFalse);

_setPlayingExternally(videoController, playing: false);
await tester.pump();
expect(_controlsVisible(tester), isTrue);

await tester.pump(_afterHideControlsTimer);
expect(
_controlsVisible(tester),
isTrue,
reason:
'a paused video keeps its controls up, as when paused from '
'the controls themselves',
);
},
);

testWidgets('keeps the controls up after a pause from the controls', (
tester,
) async {
final videoController = await _pumpPlayer(tester, controls);

_setPlayingExternally(videoController, playing: true);
await tester.pump();
expect(_controlsVisible(tester), isTrue);

_playPauseFromControls(tester);
await tester.pump();
expect(videoController.value.isPlaying, isFalse);
expect(_controlsVisible(tester), isTrue);

await tester.pump(_afterHideControlsTimer);
expect(_controlsVisible(tester), isTrue);
});

testWidgets('leaves hidden controls hidden when only the position '
'changes', (tester) async {
final videoController = await _pumpPlayer(tester, controls);

_setPlayingExternally(videoController, playing: true);
await tester.pump();
await tester.pump(_afterHideControlsTimer);
expect(_controlsVisible(tester), isFalse);

videoController.value = videoController.value.copyWith(
position: const Duration(seconds: 42),
);
await tester.pump();
expect(_controlsVisible(tester), isFalse);
});
});
});
}