diff --git a/CHANGELOG.md b/CHANGELOG.md
index 602f99961..31d4e2074 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file.
### Fixed
- crash when cataloguing some videos
+- switching to PiP for any inactive app state
## [v1.12.1] - 2025-01-05
diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart
index 456db9b6b..4d84853dd 100644
--- a/lib/widgets/viewer/entry_viewer_stack.dart
+++ b/lib/widgets/viewer/entry_viewer_stack.dart
@@ -78,7 +78,8 @@ class _EntryViewerStackState extends State with EntryViewContr
late VideoActionDelegate _videoActionDelegate;
final ValueNotifier _heroInfoNotifier = ValueNotifier(null);
bool _isEntryTracked = true;
- Timer? _overlayHidingTimer, _appInactiveReactionTimer;
+ Timer? _overlayHidingTimer;
+ late ValueNotifier _playingVideoControllerNotifier;
@override
bool get isViewingImage => _currentVerticalPage.value == imagePage;
@@ -168,6 +169,8 @@ class _EntryViewerStackState extends State with EntryViewContr
_videoActionDelegate = VideoActionDelegate(
collection: collection,
);
+ _playingVideoControllerNotifier = context.read().playingVideoControllerNotifier;
+ _playingVideoControllerNotifier.addListener(_onPlayingVideoControllerChanged);
initEntryControllers(entry);
_registerWidget(widget);
AvesApp.lifecycleStateNotifier.addListener(_onAppLifecycleStateChanged);
@@ -185,6 +188,8 @@ class _EntryViewerStackState extends State with EntryViewContr
void dispose() {
AvesApp.pageRouteObserver.unsubscribe(this);
cleanEntryControllers(entryNotifier.value);
+ _playingVideoControllerNotifier.removeListener(_onPlayingVideoControllerChanged);
+ updatePictureInPicture(context);
_videoActionDelegate.dispose();
_verticalPageAnimationController.dispose();
_overlayButtonScale.dispose();
@@ -201,7 +206,6 @@ class _EntryViewerStackState extends State with EntryViewContr
_verticalScrollNotifier.dispose();
_heroInfoNotifier.dispose();
_stopOverlayHidingTimer();
- _stopAppInactiveTimer();
AvesApp.lifecycleStateNotifier.removeListener(_onAppLifecycleStateChanged);
_unregisterWidget(widget);
super.dispose();
@@ -253,7 +257,7 @@ class _EntryViewerStackState extends State with EntryViewContr
// so we do not access status stream directly, but check for support first
stream: device.supportPictureInPicture ? Floating().pipStatusStream : Stream.value(PiPStatus.disabled),
builder: (context, snapshot) {
- var pipEnabled = snapshot.data == PiPStatus.enabled;
+ final pipEnabled = snapshot.data == PiPStatus.enabled;
return ValueListenableBuilder(
valueListenable: _viewLocked,
builder: (context, locked, child) {
@@ -328,47 +332,31 @@ class _EntryViewerStackState extends State with EntryViewContr
// lifecycle
+ // app lifecycle states:
+ // * rotating screen: resumed -> inactive -> resumed
+ // * going home: resumed -> inactive -> hidden -> paused
+ // * back from home: paused -> hidden -> inactive -> resumed
+ // * app switch / settings / etc: resumed -> inactive
void _onAppLifecycleStateChanged() {
switch (AvesApp.lifecycleStateNotifier.value) {
case AppLifecycleState.inactive:
// inactive: when losing focus
// also triggered when app is rotated on Android API >=33
- _startAppInactiveTimer();
+ break;
+ case AppLifecycleState.hidden:
case AppLifecycleState.paused:
case AppLifecycleState.detached:
- // paused: when switching to another app
+ // hidden: transient state between `inactive` and `paused`
+ // paused: when using another app
// detached: when app is without a view
viewerController.autopilot = false;
- _stopAppInactiveTimer();
pauseVideoControllers();
case AppLifecycleState.resumed:
- _stopAppInactiveTimer();
- case AppLifecycleState.hidden:
- // hidden: transient state between `inactive` and `paused`
break;
}
}
- Future _onAppInactive(AvesVideoController? playingController) async {
- bool enabledPip = false;
- if (settings.videoBackgroundMode == VideoBackgroundMode.pip) {
- enabledPip |= await _enablePictureInPicture(playingController);
- }
- if (enabledPip) {
- // ensure playback, in case lifecycle paused/resumed events happened when switching to PiP
- await playingController?.play();
- } else {
- await pauseVideoControllers();
- }
- }
-
- void _startAppInactiveTimer() {
- _stopAppInactiveTimer();
- final playingController = context.read().getPlayingController();
- _appInactiveReactionTimer = Timer(ADurations.appInactiveReactionDelay, () => _onAppInactive(playingController));
- }
-
- void _stopAppInactiveTimer() => _appInactiveReactionTimer?.cancel();
+ void _onPlayingVideoControllerChanged() => updatePictureInPicture(context);
Widget _decorateOverlay(Widget overlay) {
return ValueListenableBuilder(
@@ -939,36 +927,6 @@ class _EntryViewerStackState extends State with EntryViewContr
await Future.delayed(const Duration(milliseconds: 50));
}
- Future _enablePictureInPicture(AvesVideoController? playingController) async {
- if (playingController != null) {
- final entrySize = playingController.entry.displaySize;
- final aspectRatio = Rational(entrySize.width.round(), entrySize.height.round());
-
- final viewSize = MediaQuery.sizeOf(context) * MediaQuery.devicePixelRatioOf(context);
- 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(ImmediatePiP(
- 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/video/conductor.dart b/lib/widgets/viewer/video/conductor.dart
index 250ded24f..cf409183d 100644
--- a/lib/widgets/viewer/video/conductor.dart
+++ b/lib/widgets/viewer/video/conductor.dart
@@ -16,7 +16,9 @@ class VideoConductor {
final CollectionLens? _collection;
final List _controllers = [];
final List _subscriptions = [];
- final PlaybackStateHandler playbackStateHandler = DatabasePlaybackStateHandler();
+ final PlaybackStateHandler _playbackStateHandler = DatabasePlaybackStateHandler();
+
+ final ValueNotifier playingVideoControllerNotifier = ValueNotifier(null);
static const _defaultMaxControllerCount = 3;
@@ -38,6 +40,7 @@ class VideoConductor {
..forEach((sub) => sub.cancel())
..clear();
await _disposeAll();
+ playingVideoControllerNotifier.dispose();
_controllers.clear();
if (settings.keepScreenOn == KeepScreenOn.videoPlayback) {
await windowService.keepScreenOn(false);
@@ -51,7 +54,7 @@ class VideoConductor {
} else {
controller = videoControllerFactory.buildController(
entry,
- playbackStateHandler: playbackStateHandler,
+ playbackStateHandler: _playbackStateHandler,
settings: settings,
);
_subscriptions.add(controller.statusStream.listen((event) => _onControllerStatusChanged(entry, controller!, event)));
@@ -90,6 +93,8 @@ class VideoConductor {
if (settings.keepScreenOn == KeepScreenOn.videoPlayback) {
await windowService.keepScreenOn(status == VideoStatus.playing);
}
+
+ playingVideoControllerNotifier.value = getPlayingController();
}
Future _applyToAll(FutureOr Function(AvesVideoController controller) action) => Future.forEach(_controllers, action);
diff --git a/lib/widgets/viewer/visual/controller_mixin.dart b/lib/widgets/viewer/visual/controller_mixin.dart
index 90fa9af8b..dc61282e3 100644
--- a/lib/widgets/viewer/visual/controller_mixin.dart
+++ b/lib/widgets/viewer/visual/controller_mixin.dart
@@ -1,8 +1,12 @@
+import 'dart:async';
+import 'dart:math';
+
import 'package:aves/app_mode.dart';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/multipage.dart';
import 'package:aves/model/entry/extensions/props.dart';
import 'package:aves/model/settings/settings.dart';
+import 'package:aves/services/common/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/multipage/controller.dart';
@@ -10,8 +14,10 @@ import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:aves_model/aves_model.dart';
import 'package:aves_video/aves_video.dart';
import 'package:collection/collection.dart';
+import 'package:floating/floating.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
+import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
// state controllers/monitors
@@ -216,4 +222,50 @@ mixin EntryViewControllerMixin on State {
}
Future pauseVideoControllers() => context.read().pauseAll();
+
+ static const _pipRatioMax = Rational(43, 18);
+ static const _pipRatioMin = Rational(18, 43);
+
+ Future updatePictureInPicture(BuildContext context) async {
+ if (context.mounted) {
+ if (settings.videoBackgroundMode == VideoBackgroundMode.pip) {
+ final playingController = context.read().getPlayingController();
+ if (playingController != null) {
+ final entrySize = playingController.entry.displaySize;
+ final entryAspectRatio = entrySize.aspectRatio;
+ final Rational pipAspectRatio;
+ if (entryAspectRatio > _pipRatioMax.aspectRatio) {
+ pipAspectRatio = _pipRatioMax;
+ } else if (entryAspectRatio < _pipRatioMin.aspectRatio) {
+ pipAspectRatio = _pipRatioMin;
+ } else {
+ pipAspectRatio = Rational(entrySize.width.round(), entrySize.height.round());
+ }
+
+ final viewSize = MediaQuery.sizeOf(context) * MediaQuery.devicePixelRatioOf(context);
+ 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(OnLeavePiP(
+ aspectRatio: pipAspectRatio,
+ sourceRectHint: sourceRectHint,
+ ));
+ debugPrint('Enabled picture-in-picture with status=$status');
+ return;
+ } on PlatformException catch (e, stack) {
+ await reportService.recordError(e, stack);
+ }
+ }
+ }
+ }
+
+ debugPrint('Cancelling picture-in-picture');
+ await Floating().cancelOnLeavePiP();
+ }
}