PiP: fixed trigger state, aspect ratio clamp
This commit is contained in:
parent
bb5bbcc069
commit
4d50f258e4
4 changed files with 77 additions and 61 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue