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(); + } }