From 655d2518905264af930a024304d47f497782cd0a Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 5 Mar 2023 23:05:58 +0100 Subject: [PATCH] #478 picture-in-picture --- CHANGELOG.md | 2 + android/app/src/main/AndroidManifest.xml | 1 + .../aves/channel/calls/MediaSessionHandler.kt | 12 +- .../streams/MediaCommandStreamHandler.kt | 12 ++ lib/l10n/app_en.arb | 1 + lib/model/device.dart | 9 +- lib/model/settings/defaults.dart | 1 + lib/model/settings/settings.dart | 10 ++ lib/services/media/media_session_service.dart | 22 +++- lib/utils/dependencies.dart | 5 + lib/widgets/aves_app.dart | 1 - .../common/tile_extent_controller.dart | 4 +- lib/widgets/settings/video/video.dart | 14 +++ .../viewer/entry_horizontal_pager.dart | 110 ++++++++++++------ lib/widgets/viewer/entry_viewer_page.dart | 12 +- lib/widgets/viewer/entry_viewer_stack.dart | 105 +++++++++++++---- lib/widgets/viewer/notifications.dart | 6 + lib/widgets/viewer/video/conductor.dart | 26 ++++- lib/widgets/viewer/video/controller.dart | 5 - .../viewer/visual/entry_page_view.dart | 6 + .../lib/aves_report_platform.dart | 5 +- pubspec.lock | 9 ++ pubspec.yaml | 4 + untranslated.json | 49 ++++++-- 24 files changed, 341 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd566af0a..401ba61df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ All notable changes to this project will be documented in this file. ### Added - Vaults: custom pattern lock +- Video: picture-in-picture +- Video: handle skip next/previous media buttons ### Changed diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ef519cae2..f7b14d5d6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -96,6 +96,7 @@ This change eventually prevents building the app with Flutter v3.3.3. android:exported="true" android:hardwareAccelerated="true" android:launchMode="singleTop" + android:supportsPictureInPicture="true" android:theme="@style/NormalTheme" android:windowSoftInputMode="adjustResize"> diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaSessionHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaSessionHandler.kt index 779213671..f78099e0c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaSessionHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaSessionHandler.kt @@ -65,11 +65,13 @@ class MediaSessionHandler(private val context: Context, private val mediaCommand val stateString = call.argument("state") val positionMillis = call.argument("positionMillis")?.toLong() val playbackSpeed = call.argument("playbackSpeed")?.toFloat() + val canSkipToNext = call.argument("canSkipToNext") + val canSkipToPrevious = call.argument("canSkipToPrevious") - if (uri == null || title == null || durationMillis == null || stateString == null || positionMillis == null || playbackSpeed == null) { + if (uri == null || title == null || durationMillis == null || stateString == null || positionMillis == null || playbackSpeed == null || canSkipToNext == null || canSkipToPrevious == null) { result.error( "updateSession-args", "missing arguments: uri=$uri, title=$title, durationMillis=$durationMillis" + - ", stateString=$stateString, positionMillis=$positionMillis, playbackSpeed=$playbackSpeed", null + ", stateString=$stateString, positionMillis=$positionMillis, playbackSpeed=$playbackSpeed, canSkipToNext=$canSkipToNext, canSkipToPrevious=$canSkipToPrevious", null ) return } @@ -90,6 +92,12 @@ class MediaSessionHandler(private val context: Context, private val mediaCommand } else { actions or PlaybackStateCompat.ACTION_PLAY } + if (canSkipToNext) { + actions = actions or PlaybackStateCompat.ACTION_SKIP_TO_NEXT + } + if (canSkipToPrevious) { + actions = actions or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS + } val playbackState = PlaybackStateCompat.Builder() .setState( diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaCommandStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaCommandStreamHandler.kt index b81815622..49273098f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaCommandStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaCommandStreamHandler.kt @@ -46,6 +46,16 @@ class MediaCommandStreamHandler : EventChannel.StreamHandler, MediaSessionCompat success(hashMapOf(KEY_COMMAND to COMMAND_PAUSE)) } + override fun onSkipToNext() { + super.onSkipToNext() + success(hashMapOf(KEY_COMMAND to COMMAND_SKIP_TO_NEXT)) + } + + override fun onSkipToPrevious() { + super.onSkipToPrevious() + success(hashMapOf(KEY_COMMAND to COMMAND_SKIP_TO_PREVIOUS)) + } + override fun onStop() { super.onStop() success(hashMapOf(KEY_COMMAND to COMMAND_STOP)) @@ -70,6 +80,8 @@ class MediaCommandStreamHandler : EventChannel.StreamHandler, MediaSessionCompat const val COMMAND_PLAY = "play" const val COMMAND_PAUSE = "pause" + const val COMMAND_SKIP_TO_NEXT = "skip_to_next" + const val COMMAND_SKIP_TO_PREVIOUS = "skip_to_previous" const val COMMAND_STOP = "stop" const val COMMAND_SEEK = "seek" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 63827cd32..f11aeb838 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -793,6 +793,7 @@ "settingsVideoSectionTitle": "Video", "settingsVideoShowVideos": "Show videos", "settingsVideoEnableHardwareAcceleration": "Hardware acceleration", + "settingsVideoEnablePip": "Picture-in-picture", "settingsVideoAutoPlay": "Auto play", "settingsVideoLoopModeTile": "Loop mode", "settingsVideoLoopModeDialogTitle": "Loop Mode", diff --git a/lib/model/device.dart b/lib/model/device.dart index e49802885..1601324ab 100644 --- a/lib/model/device.dart +++ b/lib/model/device.dart @@ -1,5 +1,6 @@ import 'package:aves/services/common/services.dart'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:floating/floating.dart'; import 'package:local_auth/local_auth.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -9,7 +10,7 @@ class Device { late final String _userAgent; late final bool _canAuthenticateUser, _canGrantDirectoryAccess, _canPinShortcut, _canPrint; late final bool _canRenderFlagEmojis, _canRequestManageMedia, _canSetLockScreenWallpaper, _canUseCrypto; - late final bool _hasGeocoder, _isDynamicColorAvailable, _isTelevision, _showPinShortcutFeedback, _supportEdgeToEdgeUIMode; + late final bool _hasGeocoder, _isDynamicColorAvailable, _isTelevision, _showPinShortcutFeedback, _supportEdgeToEdgeUIMode, _supportPictureInPicture; String get userAgent => _userAgent; @@ -41,6 +42,8 @@ class Device { bool get supportEdgeToEdgeUIMode => _supportEdgeToEdgeUIMode; + bool get supportPictureInPicture => _supportPictureInPicture; + Device._private(); Future init() async { @@ -53,6 +56,10 @@ class Device { final auth = LocalAuthentication(); _canAuthenticateUser = await auth.canCheckBiometrics || await auth.isDeviceSupported(); + final floating = Floating(); + _supportPictureInPicture = await floating.isPipAvailable; + floating.dispose(); + final capabilities = await deviceService.getCapabilities(); _canGrantDirectoryAccess = capabilities['canGrantDirectoryAccess'] ?? false; _canPinShortcut = capabilities['canPinShortcut'] ?? false; diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index 554faccd8..a1908aab2 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -90,6 +90,7 @@ class SettingsDefaults { // video static const enableVideoHardwareAcceleration = true; + static const enableVideoPip = false; static const videoAutoPlayMode = VideoAutoPlayMode.disabled; static const videoLoopMode = VideoLoopMode.shortOnly; static const videoShowRawTimedText = false; diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index e89a34383..9d19757ab 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -133,6 +133,7 @@ class Settings extends ChangeNotifier { // video static const enableVideoHardwareAccelerationKey = 'video_hwaccel_mediacodec'; + static const enableVideoPipKey = 'video_pip'; static const videoAutoPlayModeKey = 'video_auto_play_mode'; static const videoLoopModeKey = 'video_loop'; static const videoControlsKey = 'video_controls'; @@ -284,6 +285,7 @@ class Settings extends ChangeNotifier { viewerGestureSideTapNext = false; viewerUseCutout = true; viewerMaxBrightness = false; + enableVideoPip = false; videoControls = VideoControls.none; videoGestureDoubleTapTogglePlay = false; videoGestureSideDoubleTapSeek = false; @@ -298,6 +300,9 @@ class Settings extends ChangeNotifier { if (viewerUseCutout != SettingsDefaults.viewerUseCutout && !await windowService.isCutoutAware()) { _set(viewerUseCutoutKey, null); } + if (enableVideoPip && !device.supportPictureInPicture) { + _set(enableVideoPipKey, null); + } } // app @@ -655,6 +660,10 @@ class Settings extends ChangeNotifier { set enableVideoHardwareAcceleration(bool newValue) => _set(enableVideoHardwareAccelerationKey, newValue); + bool get enableVideoPip => getBool(enableVideoPipKey) ?? SettingsDefaults.enableVideoPip; + + set enableVideoPip(bool newValue) => _set(enableVideoPipKey, newValue); + VideoAutoPlayMode get videoAutoPlayMode => getEnumOrDefault(videoAutoPlayModeKey, SettingsDefaults.videoAutoPlayMode, VideoAutoPlayMode.values); set videoAutoPlayMode(VideoAutoPlayMode newValue) => _set(videoAutoPlayModeKey, newValue.toString()); @@ -1078,6 +1087,7 @@ class Settings extends ChangeNotifier { case viewerUseCutoutKey: case viewerMaxBrightnessKey: case enableMotionPhotoAutoPlayKey: + case enableVideoPipKey: case enableVideoHardwareAccelerationKey: case videoGestureDoubleTapTogglePlayKey: case videoGestureSideDoubleTapSeekKey: diff --git a/lib/services/media/media_session_service.dart b/lib/services/media/media_session_service.dart index b453716e0..643c19297 100644 --- a/lib/services/media/media_session_service.dart +++ b/lib/services/media/media_session_service.dart @@ -11,7 +11,11 @@ import 'package:get_it/get_it.dart'; abstract class MediaSessionService { Stream get mediaCommands; - Future update(AvesVideoController controller); + Future update({ + required AvesVideoController controller, + required bool canSkipToNext, + required bool canSkipToPrevious, + }); Future release(); } @@ -38,7 +42,11 @@ class PlatformMediaSessionService implements MediaSessionService, Disposable { Stream get mediaCommands => _streamController.stream.where((event) => event is MediaCommandEvent).cast(); @override - Future update(AvesVideoController controller) async { + Future update({ + required AvesVideoController controller, + required bool canSkipToNext, + required bool canSkipToPrevious, + }) async { final entry = controller.entry; try { await _platformObject.invokeMethod('update', { @@ -48,6 +56,8 @@ class PlatformMediaSessionService implements MediaSessionService, Disposable { 'state': _toPlatformState(controller.status), 'positionMillis': controller.currentPosition, 'playbackSpeed': controller.speed, + 'canSkipToNext': canSkipToNext, + 'canSkipToPrevious': canSkipToPrevious, }); } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); @@ -88,6 +98,12 @@ class PlatformMediaSessionService implements MediaSessionService, Disposable { case 'pause': event = const MediaCommandEvent(MediaCommand.pause); break; + case 'skip_to_next': + event = const MediaCommandEvent(MediaCommand.skipToNext); + break; + case 'skip_to_previous': + event = const MediaCommandEvent(MediaCommand.skipToPrevious); + break; case 'stop': event = const MediaCommandEvent(MediaCommand.stop); break; @@ -104,7 +120,7 @@ class PlatformMediaSessionService implements MediaSessionService, Disposable { } } -enum MediaCommand { play, pause, stop, seek } +enum MediaCommand { play, pause, skipToNext, skipToPrevious, stop, seek } @immutable class MediaCommandEvent extends Equatable { diff --git a/lib/utils/dependencies.dart b/lib/utils/dependencies.dart index 564d43c57..0b33a9f47 100644 --- a/lib/utils/dependencies.dart +++ b/lib/utils/dependencies.dart @@ -75,6 +75,11 @@ class Dependencies { license: mit, sourceUrl: 'https://github.com/deckerst/fijkplayer', ), + Dependency( + name: 'Floating', + license: mit, + sourceUrl: 'https://github.com/wrbl606/floating', + ), Dependency( name: 'Flutter Display Mode', license: mit, diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index 6e1b3dfdf..a2ad8684a 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -362,7 +362,6 @@ class _AvesAppState extends State with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { - debugPrint('$runtimeType lifecycle ${state.name}'); reportService.log('Lifecycle ${state.name}'); switch (state) { case AppLifecycleState.inactive: diff --git a/lib/widgets/common/tile_extent_controller.dart b/lib/widgets/common/tile_extent_controller.dart index e32d8662d..924ecbdf3 100644 --- a/lib/widgets/common/tile_extent_controller.dart +++ b/lib/widgets/common/tile_extent_controller.dart @@ -87,14 +87,14 @@ class TileExtentController { int _effectiveColumnCountForExtent(double extent) { if (extent > 0) { final columnCount = _columnCountForExtent(extent); - final countMin = _effectiveColumnCountMin(); final countMax = _effectiveColumnCountMax(); + final countMin = min(_effectiveColumnCountMin(), countMax); return columnCount.round().clamp(countMin, countMax); } return columnCountDefault; } - double get effectiveExtentMin => _extentForColumnCount(_effectiveColumnCountMax()); + double get effectiveExtentMin => min(_extentForColumnCount(_effectiveColumnCountMax()), effectiveExtentMax); double get effectiveExtentMax => _extentForColumnCount(_effectiveColumnCountMin()); diff --git a/lib/widgets/settings/video/video.dart b/lib/widgets/settings/video/video.dart index d7c3b978e..3fa9a5760 100644 --- a/lib/widgets/settings/video/video.dart +++ b/lib/widgets/settings/video/video.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:aves/model/device.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/enums/video_auto_play_mode.dart'; @@ -40,6 +41,7 @@ class VideoSection extends SettingsSection { return [ if (!standalonePage) SettingsTileVideoShowVideos(), SettingsTileVideoEnableHardwareAcceleration(), + if (!settings.useTvLayout && device.supportPictureInPicture) SettingsTileVideoEnablePip(), SettingsTileVideoEnableAutoPlay(), SettingsTileVideoLoopMode(), if (!settings.useTvLayout) SettingsTileVideoControls(), @@ -72,6 +74,18 @@ class SettingsTileVideoEnableHardwareAcceleration extends SettingsTile { ); } +class SettingsTileVideoEnablePip extends SettingsTile { + @override + String title(BuildContext context) => context.l10n.settingsVideoEnablePip; + + @override + Widget build(BuildContext context) => SettingsSwitchListTile( + selector: (context, s) => s.enableVideoPip, + onChanged: (v) => settings.enableVideoPip = v, + title: title(context), + ); +} + class SettingsTileVideoEnableAutoPlay extends SettingsTile { @override String title(BuildContext context) => context.l10n.settingsVideoAutoPlay; diff --git a/lib/widgets/viewer/entry_horizontal_pager.dart b/lib/widgets/viewer/entry_horizontal_pager.dart index fcf6e6017..099ec2f92 100644 --- a/lib/widgets/viewer/entry_horizontal_pager.dart +++ b/lib/widgets/viewer/entry_horizontal_pager.dart @@ -3,11 +3,13 @@ import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/enums/viewer_transition.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; -import 'package:aves_magnifier/aves_magnifier.dart'; import 'package:aves/widgets/viewer/controller.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; +import 'package:aves/widgets/viewer/notifications.dart'; import 'package:aves/widgets/viewer/page_entry_builder.dart'; import 'package:aves/widgets/viewer/visual/entry_page_view.dart'; +import 'package:aves_magnifier/aves_magnifier.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -45,40 +47,43 @@ class _MultiEntryScrollerState extends State with AutomaticK return MagnifierGestureDetectorScope( axis: const [Axis.horizontal, Axis.vertical], - child: PageView.builder( - // key is expected by test driver - key: const Key('horizontal-pageview'), - scrollDirection: Axis.horizontal, - controller: pageController, - physics: MagnifierScrollerPhysics( - gestureSettings: context.select((mq) => mq.gestureSettings), - parent: const BouncingScrollPhysics(), + child: NotificationListener( + onNotification: _handleNotification, + child: PageView.builder( + // key is expected by test driver + key: const Key('horizontal-pageview'), + scrollDirection: Axis.horizontal, + controller: pageController, + physics: MagnifierScrollerPhysics( + gestureSettings: context.select((mq) => mq.gestureSettings), + parent: const BouncingScrollPhysics(), + ), + onPageChanged: widget.onPageChanged, + itemBuilder: (context, index) { + final mainEntry = entries[index % entries.length]; + + final child = mainEntry.isMultiPage + ? PageEntryBuilder( + multiPageController: context.read().getController(mainEntry), + builder: (pageEntry) => _buildViewer(mainEntry, pageEntry: pageEntry), + ) + : _buildViewer(mainEntry); + + return Selector( + selector: (context, s) => s.accessibilityAnimations.animate, + builder: (context, animate, child) { + if (!animate) return child!; + return AnimatedBuilder( + animation: pageController, + builder: viewerController.transition.builder(pageController, index), + child: child, + ); + }, + child: child, + ); + }, + itemCount: viewerController.repeat ? null : entries.length, ), - onPageChanged: widget.onPageChanged, - itemBuilder: (context, index) { - final mainEntry = entries[index % entries.length]; - - final child = mainEntry.isMultiPage - ? PageEntryBuilder( - multiPageController: context.read().getController(mainEntry), - builder: (pageEntry) => _buildViewer(mainEntry, pageEntry: pageEntry), - ) - : _buildViewer(mainEntry); - - return Selector( - selector: (context, s) => s.accessibilityAnimations.animate, - builder: (context, animate, child) { - if (!animate) return child!; - return AnimatedBuilder( - animation: pageController, - builder: viewerController.transition.builder(pageController, index), - child: child, - ); - }, - child: child, - ); - }, - itemCount: viewerController.repeat ? null : entries.length, ), ); } @@ -94,6 +99,43 @@ class _MultiEntryScrollerState extends State with AutomaticK ); } + bool _handleNotification(dynamic notification) { + if (notification is ShowPreviousVideoNotification) { + _showPreviousVideo(); + } else if (notification is ShowNextVideoNotification) { + _showNextVideo(); + } else { + return false; + } + return true; + } + + void _showPreviousVideo() { + final currentIndex = pageController.page?.round(); + if (currentIndex != null) { + final previousVideoEntry = entries.take(currentIndex).lastWhereOrNull((entry) => entry.isVideo); + if (previousVideoEntry != null) { + final previousIndex = entries.indexOf(previousVideoEntry); + if (previousIndex != -1) { + ShowEntryNotification(animate: false, index: previousIndex).dispatch(context); + } + } + } + } + + void _showNextVideo() { + final currentIndex = pageController.page?.round(); + if (currentIndex != null) { + final nextVideoEntry = entries.skip(currentIndex + 1).firstWhereOrNull((entry) => entry.isVideo); + if (nextVideoEntry != null) { + final nextIndex = entries.indexOf(nextVideoEntry); + if (nextIndex != -1) { + ShowEntryNotification(animate: false, index: nextIndex).dispatch(context); + } + } + } + } + @override bool get wantKeepAlive => true; } diff --git a/lib/widgets/viewer/entry_viewer_page.dart b/lib/widgets/viewer/entry_viewer_page.dart index fc860189d..89fc4dc6e 100644 --- a/lib/widgets/viewer/entry_viewer_page.dart +++ b/lib/widgets/viewer/entry_viewer_page.dart @@ -41,12 +41,14 @@ class _EntryViewerPageState extends State { @override Widget build(BuildContext context) { + final collection = widget.collection; return AvesScaffold( body: ViewStateConductorProvider( child: VideoConductorProvider( + collection: collection, child: MultiPageConductorProvider( child: EntryViewerStack( - collection: widget.collection, + collection: collection, initialEntry: widget.initialEntry, viewerController: _viewerController, ), @@ -86,17 +88,19 @@ class ViewStateConductorProvider extends StatelessWidget { } class VideoConductorProvider extends StatelessWidget { + final CollectionLens? collection; final Widget? child; const VideoConductorProvider({ super.key, - this.child, + this.collection, + required this.child, }); @override Widget build(BuildContext context) { return Provider( - create: (context) => VideoConductor(), + create: (context) => VideoConductor(collection: collection), dispose: (context, value) => value.dispose(), child: child, ); @@ -108,7 +112,7 @@ class MultiPageConductorProvider extends StatelessWidget { const MultiPageConductorProvider({ super.key, - this.child, + required this.child, }); @override diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index b805798fa..b00ef23d8 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -34,6 +34,7 @@ import 'package:aves/widgets/viewer/video_action_delegate.dart'; import 'package:aves/widgets/viewer/visual/conductor.dart'; import 'package:aves/widgets/viewer/visual/controller_mixin.dart'; import 'package:collection/collection.dart'; +import 'package:floating/floating.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -58,6 +59,7 @@ class EntryViewerStack extends StatefulWidget { } class _EntryViewerStackState extends State with EntryViewControllerMixin, FeedbackMixin, SingleTickerProviderStateMixin, WidgetsBindingObserver { + final Floating _floating = Floating(); late int _currentEntryIndex; late ValueNotifier _currentVerticalPage; late PageController _horizontalPager, _verticalPager; @@ -159,6 +161,7 @@ class _EntryViewerStackState extends State with EntryViewContr @override void dispose() { + _floating.dispose(); cleanEntryControllers(entryNotifier.value); _videoActionDelegate.dispose(); _overlayAnimationController.dispose(); @@ -183,9 +186,10 @@ class _EntryViewerStackState extends State with EntryViewContr void didChangeAppLifecycleState(AppLifecycleState state) { switch (state) { case AppLifecycleState.inactive: + _onAppInactive(); + break; case AppLifecycleState.paused: case AppLifecycleState.detached: - viewerController.autopilot = false; pauseVideoControllers(); break; case AppLifecycleState.resumed: @@ -194,6 +198,17 @@ class _EntryViewerStackState extends State with EntryViewContr } } + Future _onAppInactive() async { + viewerController.autopilot = false; + bool enabledPip = false; + if (settings.enableVideoPip) { + enabledPip |= await _enablePictureInPicture(); + } + if (!enabledPip) { + await pauseVideoControllers(); + } + } + @override Widget build(BuildContext context) { final viewStateConductor = context.read(); @@ -209,29 +224,38 @@ class _EntryViewerStackState extends State with EntryViewContr child: LayoutBuilder( builder: (context, constraints) { final availableSize = Size(constraints.maxWidth, constraints.maxHeight); - return Stack( - children: [ - ViewerVerticalPageView( - collection: collection, - entryNotifier: entryNotifier, - viewerController: viewerController, - overlayOpacity: _overlayInitialized - ? _overlayOpacity - : settings.showOverlayOnOpening - ? kAlwaysCompleteAnimation - : kAlwaysDismissedAnimation, - verticalPager: _verticalPager, - horizontalPager: _horizontalPager, - onVerticalPageChanged: _onVerticalPageChanged, - onHorizontalPageChanged: _onHorizontalPageChanged, - onImagePageRequested: () => _goToVerticalPage(imagePage), - onViewDisposed: (mainEntry, pageEntry) => viewStateConductor.reset(pageEntry ?? mainEntry), - ), - ..._buildOverlays(availableSize).map(_decorateOverlay), - const TopGestureAreaProtector(), - const SideGestureAreaProtector(), - const BottomGestureAreaProtector(), - ], + final viewer = ViewerVerticalPageView( + collection: collection, + entryNotifier: entryNotifier, + viewerController: viewerController, + overlayOpacity: _overlayInitialized + ? _overlayOpacity + : settings.showOverlayOnOpening + ? kAlwaysCompleteAnimation + : kAlwaysDismissedAnimation, + verticalPager: _verticalPager, + horizontalPager: _horizontalPager, + onVerticalPageChanged: _onVerticalPageChanged, + onHorizontalPageChanged: _onHorizontalPageChanged, + onImagePageRequested: () => _goToVerticalPage(imagePage), + onViewDisposed: (mainEntry, pageEntry) => viewStateConductor.reset(pageEntry ?? mainEntry), + ); + return StreamBuilder( + stream: _floating.pipStatus$, + builder: (context, snapshot) { + var pipEnabled = snapshot.data == PiPStatus.enabled; + return Stack( + children: [ + viewer, + if (!pipEnabled) ...[ + ..._buildOverlays(availableSize).map(_decorateOverlay), + const TopGestureAreaProtector(), + const SideGestureAreaProtector(), + const BottomGestureAreaProtector(), + ], + ], + ); + }, ); }, ), @@ -708,6 +732,39 @@ class _EntryViewerStackState extends State with EntryViewContr } } + Future _enablePictureInPicture() async { + final videoController = context.read().getPlayingController(); + if (videoController != null) { + final targetEntry = videoController.entry; + final entrySize = targetEntry.displaySize; + final aspectRatio = Rational(entrySize.width.round(), entrySize.height.round()); + + final mq = context.read(); + final viewSize = mq.size * mq.devicePixelRatio; + final fittedSize = applyBoxFit(BoxFit.contain, entrySize, viewSize).destination; + final sourceRectHint = Rectangle( + ((viewSize.width - fittedSize.width) / 2).round(), + ((viewSize.height - fittedSize.height) / 2).round(), + fittedSize.width.round(), + fittedSize.height.round(), + ); + + try { + final status = await _floating.enable( + aspectRatio: aspectRatio, + sourceRectHint: sourceRectHint, + ); + await reportService.log('Enabled picture-in-picture with status=$status'); + return status == PiPStatus.enabled; + } on PlatformException catch (e, stack) { + if (e.message != 'Activity must be resumed to enter picture-in-picture') { + await reportService.recordError(e, stack); + } + } + } + return false; + } + // overlay Future _initOverlay() async { diff --git a/lib/widgets/viewer/notifications.dart b/lib/widgets/viewer/notifications.dart index a8d0736ed..74850f6dd 100644 --- a/lib/widgets/viewer/notifications.dart +++ b/lib/widgets/viewer/notifications.dart @@ -40,6 +40,12 @@ class ShowEntryNotification extends Notification { }); } +@immutable +class ShowPreviousVideoNotification extends Notification {} + +@immutable +class ShowNextVideoNotification extends Notification {} + @immutable class ToggleOverlayNotification extends Notification { final bool? visible; diff --git a/lib/widgets/viewer/video/conductor.dart b/lib/widgets/viewer/video/conductor.dart index 08389e802..82abe32c3 100644 --- a/lib/widgets/viewer/video/conductor.dart +++ b/lib/widgets/viewer/video/conductor.dart @@ -3,18 +3,20 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/viewer/video/controller.dart'; import 'package:aves/widgets/viewer/video/fijkplayer.dart'; import 'package:collection/collection.dart'; class VideoConductor { + final CollectionLens? _collection; final List _controllers = []; final List _subscriptions = []; static const _defaultMaxControllerCount = 3; - VideoConductor(); + VideoConductor({CollectionLens? collection}) : _collection = collection; Future dispose() async { await disposeAll(); @@ -33,7 +35,7 @@ class VideoConductor { _controllers.remove(controller); } else { controller = IjkPlayerAvesVideoController(entry, persistPlayback: true); - _subscriptions.add(controller.statusStream.listen(_onControllerStatusChanged)); + _subscriptions.add(controller.statusStream.listen((event) => _onControllerStatusChanged(controller!, event))); } _controllers.insert(0, controller); while (_controllers.length > (maxControllerCount ?? _defaultMaxControllerCount)) { @@ -42,11 +44,29 @@ class VideoConductor { return controller; } + AvesVideoController? getPlayingController() => _controllers.firstWhereOrNull((c) => c.isPlaying); + AvesVideoController? getController(AvesEntry entry) { return _controllers.firstWhereOrNull((c) => c.entry.uri == entry.uri && c.entry.pageId == entry.pageId); } - Future _onControllerStatusChanged(VideoStatus status) async { + Future _onControllerStatusChanged(AvesVideoController controller, VideoStatus status) async { + bool canSkipToNext = false, canSkipToPrevious = false; + final entries = _collection?.sortedEntries; + if (entries != null) { + final currentIndex = entries.indexOf(controller.entry); + if (currentIndex != -1) { + bool isVideo(AvesEntry entry) => entry.isVideo; + canSkipToPrevious = entries.take(currentIndex).lastWhereOrNull(isVideo) != null; + canSkipToNext = entries.skip(currentIndex + 1).firstWhereOrNull(isVideo) != null; + } + } + + await mediaSessionService.update( + controller: controller, + canSkipToNext: canSkipToNext, + canSkipToPrevious: canSkipToPrevious, + ); if (settings.keepScreenOn == KeepScreenOn.videoPlayback) { await windowService.keepScreenOn(status == VideoStatus.playing); } diff --git a/lib/widgets/viewer/video/controller.dart b/lib/widgets/viewer/video/controller.dart index f450388e2..a41fb6d01 100644 --- a/lib/widgets/viewer/video/controller.dart +++ b/lib/widgets/viewer/video/controller.dart @@ -10,7 +10,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; abstract class AvesVideoController { - final List _subscriptions = []; final AvesEntry _entry; final bool persistPlayback; @@ -22,14 +21,10 @@ abstract class AvesVideoController { AvesVideoController(AvesEntry entry, {required this.persistPlayback}) : _entry = entry { entry.visualChangeNotifier.addListener(onVisualChanged); - _subscriptions.add(statusStream.listen((event) => mediaSessionService.update(this))); } @mustCallSuper Future dispose() async { - _subscriptions - ..forEach((sub) => sub.cancel()) - ..clear(); entry.visualChangeNotifier.removeListener(onVisualChanged); await _savePlaybackState(); } diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index e5712ed24..2198bce5c 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -450,6 +450,12 @@ class _EntryPageViewState extends State with SingleTickerProvider case MediaCommand.pause: videoController.pause(); break; + case MediaCommand.skipToNext: + ShowNextVideoNotification().dispatch(context); + break; + case MediaCommand.skipToPrevious: + ShowPreviousVideoNotification().dispatch(context); + break; case MediaCommand.stop: videoController.pause(); break; diff --git a/plugins/aves_report_crashlytics/lib/aves_report_platform.dart b/plugins/aves_report_crashlytics/lib/aves_report_platform.dart index 51c739ff0..cc501c381 100644 --- a/plugins/aves_report_crashlytics/lib/aves_report_platform.dart +++ b/plugins/aves_report_crashlytics/lib/aves_report_platform.dart @@ -55,7 +55,10 @@ class PlatformReportService extends ReportService { } @override - Future log(String message) async => _instance?.log(message); + Future log(String message) async { + debugPrint('Report log=$message'); + await _instance?.log(message); + } @override Future setCustomKey(String key, Object value) async => _instance?.setCustomKey(key, value); diff --git a/pubspec.lock b/pubspec.lock index 4583f1972..8b108b7a2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -373,6 +373,15 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + floating: + dependency: "direct main" + description: + path: "." + ref: source-rect-hint + resolved-ref: "84b2f7997da4393dedf3f2e09dd990e5d5492161" + url: "https://github.com/deckerst/floating.git" + source: git + version: "1.1.3" fluster: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index e4c188594..7b37b2d71 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,6 +60,10 @@ dependencies: url: https://github.com/deckerst/fijkplayer.git ref: aves flex_color_picker: + floating: + git: + url: https://github.com/deckerst/floating.git + ref: source-rect-hint fluster: flutter_displaymode: flutter_highlight: diff --git a/untranslated.json b/untranslated.json index ea94407bc..c3206618f 100644 --- a/untranslated.json +++ b/untranslated.json @@ -479,6 +479,7 @@ "settingsVideoSectionTitle", "settingsVideoShowVideos", "settingsVideoEnableHardwareAcceleration", + "settingsVideoEnablePip", "settingsVideoAutoPlay", "settingsVideoLoopModeTile", "settingsVideoLoopModeDialogTitle", @@ -1034,6 +1035,7 @@ "settingsVideoSectionTitle", "settingsVideoShowVideos", "settingsVideoEnableHardwareAcceleration", + "settingsVideoEnablePip", "settingsVideoAutoPlay", "settingsVideoLoopModeTile", "settingsVideoLoopModeDialogTitle", @@ -1198,6 +1200,7 @@ "placePageTitle", "placeEmpty", "settingsConfirmationVaultDataLoss", + "settingsVideoEnablePip", "settingsDisablingBinWarningDialogMessage" ], @@ -1231,6 +1234,7 @@ "placePageTitle", "placeEmpty", "settingsConfirmationVaultDataLoss", + "settingsVideoEnablePip", "settingsDisablingBinWarningDialogMessage" ], @@ -1242,19 +1246,22 @@ "exportEntryDialogWriteMetadata", "drawerPlacePage", "placePageTitle", - "placeEmpty" + "placeEmpty", + "settingsVideoEnablePip" ], "es": [ "vaultLockTypePattern", "patternDialogEnter", - "patternDialogConfirm" + "patternDialogConfirm", + "settingsVideoEnablePip" ], "eu": [ "vaultLockTypePattern", "patternDialogEnter", - "patternDialogConfirm" + "patternDialogConfirm", + "settingsVideoEnablePip" ], "fa": [ @@ -1596,6 +1603,7 @@ "settingsVideoSectionTitle", "settingsVideoShowVideos", "settingsVideoEnableHardwareAcceleration", + "settingsVideoEnablePip", "settingsVideoAutoPlay", "settingsVideoLoopModeTile", "settingsVideoLoopModeDialogTitle", @@ -1731,7 +1739,8 @@ "fr": [ "vaultLockTypePattern", "patternDialogEnter", - "patternDialogConfirm" + "patternDialogConfirm", + "settingsVideoEnablePip" ], "gl": [ @@ -2103,6 +2112,7 @@ "settingsVideoSectionTitle", "settingsVideoShowVideos", "settingsVideoEnableHardwareAcceleration", + "settingsVideoEnablePip", "settingsVideoAutoPlay", "settingsVideoLoopModeTile", "settingsVideoLoopModeDialogTitle", @@ -2735,6 +2745,7 @@ "settingsVideoSectionTitle", "settingsVideoShowVideos", "settingsVideoEnableHardwareAcceleration", + "settingsVideoEnablePip", "settingsVideoAutoPlay", "settingsVideoLoopModeTile", "settingsVideoLoopModeDialogTitle", @@ -2872,7 +2883,8 @@ "id": [ "vaultLockTypePattern", "patternDialogEnter", - "patternDialogConfirm" + "patternDialogConfirm", + "settingsVideoEnablePip" ], "it": [ @@ -2885,7 +2897,8 @@ "exportEntryDialogWriteMetadata", "drawerPlacePage", "placePageTitle", - "placeEmpty" + "placeEmpty", + "settingsVideoEnablePip" ], "ja": [ @@ -2931,6 +2944,7 @@ "settingsModificationWarningDialogMessage", "settingsConfirmationVaultDataLoss", "settingsViewerShowDescription", + "settingsVideoEnablePip", "settingsVideoGestureVerticalDragBrightnessVolume", "settingsDisablingBinWarningDialogMessage", "settingsAccessibilityShowPinchGestureAlternatives", @@ -2941,7 +2955,8 @@ "ko": [ "vaultLockTypePattern", "patternDialogEnter", - "patternDialogConfirm" + "patternDialogConfirm", + "settingsVideoEnablePip" ], "lt": [ @@ -2981,6 +2996,7 @@ "settingsModificationWarningDialogMessage", "settingsConfirmationVaultDataLoss", "settingsViewerShowDescription", + "settingsVideoEnablePip", "settingsVideoGestureVerticalDragBrightnessVolume", "settingsDisablingBinWarningDialogMessage", "settingsAccessibilityShowPinchGestureAlternatives", @@ -3017,6 +3033,7 @@ "placePageTitle", "placeEmpty", "settingsConfirmationVaultDataLoss", + "settingsVideoEnablePip", "settingsDisablingBinWarningDialogMessage" ], @@ -3069,6 +3086,7 @@ "settingsConfirmationVaultDataLoss", "settingsViewerShowRatingTags", "settingsViewerShowDescription", + "settingsVideoEnablePip", "settingsSubtitleThemeTextPositionTile", "settingsSubtitleThemeTextPositionDialogTitle", "settingsVideoGestureVerticalDragBrightnessVolume", @@ -3325,6 +3343,7 @@ "settingsVideoSectionTitle", "settingsVideoShowVideos", "settingsVideoEnableHardwareAcceleration", + "settingsVideoEnablePip", "settingsVideoAutoPlay", "settingsVideoLoopModeTile", "settingsVideoLoopModeDialogTitle", @@ -3398,7 +3417,8 @@ "pl": [ "vaultLockTypePattern", "patternDialogEnter", - "patternDialogConfirm" + "patternDialogConfirm", + "settingsVideoEnablePip" ], "pt": [ @@ -3424,6 +3444,7 @@ "placePageTitle", "placeEmpty", "settingsConfirmationVaultDataLoss", + "settingsVideoEnablePip", "settingsVideoGestureVerticalDragBrightnessVolume", "settingsDisablingBinWarningDialogMessage" ], @@ -3438,7 +3459,8 @@ "exportEntryDialogWriteMetadata", "drawerPlacePage", "placePageTitle", - "placeEmpty" + "placeEmpty", + "settingsVideoEnablePip" ], "ru": [ @@ -3459,6 +3481,7 @@ "placeEmpty", "settingsModificationWarningDialogMessage", "settingsConfirmationVaultDataLoss", + "settingsVideoEnablePip", "settingsVideoGestureVerticalDragBrightnessVolume", "settingsDisablingBinWarningDialogMessage" ], @@ -3749,6 +3772,7 @@ "settingsVideoSectionTitle", "settingsVideoShowVideos", "settingsVideoEnableHardwareAcceleration", + "settingsVideoEnablePip", "settingsVideoAutoPlay", "settingsVideoLoopModeTile", "settingsVideoLoopModeDialogTitle", @@ -4104,6 +4128,7 @@ "settingsVideoSectionTitle", "settingsVideoShowVideos", "settingsVideoEnableHardwareAcceleration", + "settingsVideoEnablePip", "settingsVideoAutoPlay", "settingsVideoLoopModeTile", "settingsVideoLoopModeDialogTitle", @@ -4268,13 +4293,15 @@ "placePageTitle", "placeEmpty", "settingsConfirmationVaultDataLoss", + "settingsVideoEnablePip", "settingsDisablingBinWarningDialogMessage" ], "uk": [ "vaultLockTypePattern", "patternDialogEnter", - "patternDialogConfirm" + "patternDialogConfirm", + "settingsVideoEnablePip" ], "zh": [ @@ -4312,6 +4339,7 @@ "settingsModificationWarningDialogMessage", "settingsConfirmationVaultDataLoss", "settingsViewerShowDescription", + "settingsVideoEnablePip", "settingsVideoGestureVerticalDragBrightnessVolume", "settingsDisablingBinWarningDialogMessage", "settingsAccessibilityShowPinchGestureAlternatives", @@ -4354,6 +4382,7 @@ "settingsModificationWarningDialogMessage", "settingsConfirmationVaultDataLoss", "settingsViewerShowDescription", + "settingsVideoEnablePip", "settingsVideoGestureVerticalDragBrightnessVolume", "settingsDisablingBinWarningDialogMessage", "settingsAccessibilityShowPinchGestureAlternatives",