PiP: fixed trigger state, aspect ratio clamp

This commit is contained in:
Thibault Deckers 2025-01-12 23:13:36 +01:00
parent bb5bbcc069
commit 4d50f258e4
4 changed files with 77 additions and 61 deletions

View file

@ -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
## <a id="v1.12.1"></a>[v1.12.1] - 2025-01-05

View file

@ -78,7 +78,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
late VideoActionDelegate _videoActionDelegate;
final ValueNotifier<EntryHeroInfo?> _heroInfoNotifier = ValueNotifier(null);
bool _isEntryTracked = true;
Timer? _overlayHidingTimer, _appInactiveReactionTimer;
Timer? _overlayHidingTimer;
late ValueNotifier<AvesVideoController?> _playingVideoControllerNotifier;
@override
bool get isViewingImage => _currentVerticalPage.value == imagePage;
@ -168,6 +169,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
_videoActionDelegate = VideoActionDelegate(
collection: collection,
);
_playingVideoControllerNotifier = context.read<VideoConductor>().playingVideoControllerNotifier;
_playingVideoControllerNotifier.addListener(_onPlayingVideoControllerChanged);
initEntryControllers(entry);
_registerWidget(widget);
AvesApp.lifecycleStateNotifier.addListener(_onAppLifecycleStateChanged);
@ -185,6 +188,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> 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<EntryViewerStack> with EntryViewContr
_verticalScrollNotifier.dispose();
_heroInfoNotifier.dispose();
_stopOverlayHidingTimer();
_stopAppInactiveTimer();
AvesApp.lifecycleStateNotifier.removeListener(_onAppLifecycleStateChanged);
_unregisterWidget(widget);
super.dispose();
@ -253,7 +257,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> 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<bool>(
valueListenable: _viewLocked,
builder: (context, locked, child) {
@ -328,47 +332,31 @@ class _EntryViewerStackState extends State<EntryViewerStack> 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<void> _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<VideoConductor>().getPlayingController();
_appInactiveReactionTimer = Timer(ADurations.appInactiveReactionDelay, () => _onAppInactive(playingController));
}
void _stopAppInactiveTimer() => _appInactiveReactionTimer?.cancel();
void _onPlayingVideoControllerChanged() => updatePictureInPicture(context);
Widget _decorateOverlay(Widget overlay) {
return ValueListenableBuilder<double>(
@ -939,36 +927,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
await Future.delayed(const Duration(milliseconds: 50));
}
Future<bool> _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<int>(
((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<void> _initOverlay() async {

View file

@ -16,7 +16,9 @@ class VideoConductor {
final CollectionLens? _collection;
final List<AvesVideoController> _controllers = [];
final List<StreamSubscription> _subscriptions = [];
final PlaybackStateHandler playbackStateHandler = DatabasePlaybackStateHandler();
final PlaybackStateHandler _playbackStateHandler = DatabasePlaybackStateHandler();
final ValueNotifier<AvesVideoController?> 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<void> _applyToAll(FutureOr Function(AvesVideoController controller) action) => Future.forEach<AvesVideoController>(_controllers, action);

View file

@ -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<T extends StatefulWidget> on State<T> {
}
Future<void> pauseVideoControllers() => context.read<VideoConductor>().pauseAll();
static const _pipRatioMax = Rational(43, 18);
static const _pipRatioMin = Rational(18, 43);
Future<void> updatePictureInPicture(BuildContext context) async {
if (context.mounted) {
if (settings.videoBackgroundMode == VideoBackgroundMode.pip) {
final playingController = context.read<VideoConductor>().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<int>(
((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();
}
}