diff --git a/CHANGELOG.md b/CHANGELOG.md index 41b92ca7a..d637dfe8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/lib/src/cupertino/cupertino_controls.dart b/lib/src/cupertino/cupertino_controls.dart index 251700799..722723232 100644 --- a/lib/src/cupertino/cupertino_controls.dart +++ b/lib/src/cupertino/cupertino_controls.dart @@ -145,6 +145,7 @@ class _CupertinoControlsState extends State final oldController = _chewieController; _chewieController = ChewieController.of(context); controller = chewieController.videoPlayerController; + _latestValue = controller.value; if (oldController != chewieController) { _dispose(); @@ -746,6 +747,20 @@ class _CupertinoControlsState extends State _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; diff --git a/lib/src/material/material_controls.dart b/lib/src/material/material_controls.dart index 3c5de69c8..2eb2c4d3a 100644 --- a/lib/src/material/material_controls.dart +++ b/lib/src/material/material_controls.dart @@ -125,6 +125,7 @@ class _MaterialControlsState extends State final oldController = _chewieController; _chewieController = ChewieController.of(context); controller = chewieController.videoPlayerController; + _latestValue = controller.value; if (oldController != chewieController) { _dispose(); @@ -622,6 +623,20 @@ class _MaterialControlsState extends State _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; diff --git a/lib/src/material/material_desktop_controls.dart b/lib/src/material/material_desktop_controls.dart index 42a0c8218..b0459a25d 100644 --- a/lib/src/material/material_desktop_controls.dart +++ b/lib/src/material/material_desktop_controls.dart @@ -154,6 +154,7 @@ class _MaterialDesktopControlsState extends State final oldController = _chewieController; _chewieController = ChewieController.of(context); controller = chewieController.videoPlayerController; + _latestValue = controller.value; if (oldController != chewieController) { _dispose(); @@ -561,6 +562,20 @@ class _MaterialDesktopControlsState extends State _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; diff --git a/test/external_playpause_test.dart b/test/external_playpause_test.dart new file mode 100644 index 000000000..5d7b45cc4 --- /dev/null +++ b/test/external_playpause_test.dart @@ -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 = { + '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 _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( + tester.element(find.byType(CenterPlayButton)), + listen: false, + ); + return !notifier.hideStuff; +} + +void _playPauseFromControls(WidgetTester tester) { + tester.widget(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); + }); + }); + }); +}