Merge remote-tracking branch 'origin/develop' into develop
This commit is contained in:
commit
334327dc0b
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
|
### Fixed
|
||||||
|
|
||||||
- crash when cataloguing some videos
|
- 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
|
## <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;
|
late VideoActionDelegate _videoActionDelegate;
|
||||||
final ValueNotifier<EntryHeroInfo?> _heroInfoNotifier = ValueNotifier(null);
|
final ValueNotifier<EntryHeroInfo?> _heroInfoNotifier = ValueNotifier(null);
|
||||||
bool _isEntryTracked = true;
|
bool _isEntryTracked = true;
|
||||||
Timer? _overlayHidingTimer, _appInactiveReactionTimer;
|
Timer? _overlayHidingTimer;
|
||||||
|
late ValueNotifier<AvesVideoController?> _playingVideoControllerNotifier;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get isViewingImage => _currentVerticalPage.value == imagePage;
|
bool get isViewingImage => _currentVerticalPage.value == imagePage;
|
||||||
|
@ -168,6 +169,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
|
||||||
_videoActionDelegate = VideoActionDelegate(
|
_videoActionDelegate = VideoActionDelegate(
|
||||||
collection: collection,
|
collection: collection,
|
||||||
);
|
);
|
||||||
|
_playingVideoControllerNotifier = context.read<VideoConductor>().playingVideoControllerNotifier;
|
||||||
|
_playingVideoControllerNotifier.addListener(_onPlayingVideoControllerChanged);
|
||||||
initEntryControllers(entry);
|
initEntryControllers(entry);
|
||||||
_registerWidget(widget);
|
_registerWidget(widget);
|
||||||
AvesApp.lifecycleStateNotifier.addListener(_onAppLifecycleStateChanged);
|
AvesApp.lifecycleStateNotifier.addListener(_onAppLifecycleStateChanged);
|
||||||
|
@ -185,6 +188,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
|
||||||
void dispose() {
|
void dispose() {
|
||||||
AvesApp.pageRouteObserver.unsubscribe(this);
|
AvesApp.pageRouteObserver.unsubscribe(this);
|
||||||
cleanEntryControllers(entryNotifier.value);
|
cleanEntryControllers(entryNotifier.value);
|
||||||
|
_playingVideoControllerNotifier.removeListener(_onPlayingVideoControllerChanged);
|
||||||
|
updatePictureInPicture(context);
|
||||||
_videoActionDelegate.dispose();
|
_videoActionDelegate.dispose();
|
||||||
_verticalPageAnimationController.dispose();
|
_verticalPageAnimationController.dispose();
|
||||||
_overlayButtonScale.dispose();
|
_overlayButtonScale.dispose();
|
||||||
|
@ -201,7 +206,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
|
||||||
_verticalScrollNotifier.dispose();
|
_verticalScrollNotifier.dispose();
|
||||||
_heroInfoNotifier.dispose();
|
_heroInfoNotifier.dispose();
|
||||||
_stopOverlayHidingTimer();
|
_stopOverlayHidingTimer();
|
||||||
_stopAppInactiveTimer();
|
|
||||||
AvesApp.lifecycleStateNotifier.removeListener(_onAppLifecycleStateChanged);
|
AvesApp.lifecycleStateNotifier.removeListener(_onAppLifecycleStateChanged);
|
||||||
_unregisterWidget(widget);
|
_unregisterWidget(widget);
|
||||||
super.dispose();
|
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
|
// so we do not access status stream directly, but check for support first
|
||||||
stream: device.supportPictureInPicture ? Floating().pipStatusStream : Stream.value(PiPStatus.disabled),
|
stream: device.supportPictureInPicture ? Floating().pipStatusStream : Stream.value(PiPStatus.disabled),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
var pipEnabled = snapshot.data == PiPStatus.enabled;
|
final pipEnabled = snapshot.data == PiPStatus.enabled;
|
||||||
return ValueListenableBuilder<bool>(
|
return ValueListenableBuilder<bool>(
|
||||||
valueListenable: _viewLocked,
|
valueListenable: _viewLocked,
|
||||||
builder: (context, locked, child) {
|
builder: (context, locked, child) {
|
||||||
|
@ -328,47 +332,31 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
|
||||||
|
|
||||||
// lifecycle
|
// 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() {
|
void _onAppLifecycleStateChanged() {
|
||||||
switch (AvesApp.lifecycleStateNotifier.value) {
|
switch (AvesApp.lifecycleStateNotifier.value) {
|
||||||
case AppLifecycleState.inactive:
|
case AppLifecycleState.inactive:
|
||||||
// inactive: when losing focus
|
// inactive: when losing focus
|
||||||
// also triggered when app is rotated on Android API >=33
|
// also triggered when app is rotated on Android API >=33
|
||||||
_startAppInactiveTimer();
|
break;
|
||||||
|
case AppLifecycleState.hidden:
|
||||||
case AppLifecycleState.paused:
|
case AppLifecycleState.paused:
|
||||||
case AppLifecycleState.detached:
|
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
|
// detached: when app is without a view
|
||||||
viewerController.autopilot = false;
|
viewerController.autopilot = false;
|
||||||
_stopAppInactiveTimer();
|
|
||||||
pauseVideoControllers();
|
pauseVideoControllers();
|
||||||
case AppLifecycleState.resumed:
|
case AppLifecycleState.resumed:
|
||||||
_stopAppInactiveTimer();
|
|
||||||
case AppLifecycleState.hidden:
|
|
||||||
// hidden: transient state between `inactive` and `paused`
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onAppInactive(AvesVideoController? playingController) async {
|
void _onPlayingVideoControllerChanged() => updatePictureInPicture(context);
|
||||||
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();
|
|
||||||
|
|
||||||
Widget _decorateOverlay(Widget overlay) {
|
Widget _decorateOverlay(Widget overlay) {
|
||||||
return ValueListenableBuilder<double>(
|
return ValueListenableBuilder<double>(
|
||||||
|
@ -939,36 +927,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
|
||||||
await Future.delayed(const Duration(milliseconds: 50));
|
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
|
// overlay
|
||||||
|
|
||||||
Future<void> _initOverlay() async {
|
Future<void> _initOverlay() async {
|
||||||
|
|
|
@ -16,7 +16,9 @@ class VideoConductor {
|
||||||
final CollectionLens? _collection;
|
final CollectionLens? _collection;
|
||||||
final List<AvesVideoController> _controllers = [];
|
final List<AvesVideoController> _controllers = [];
|
||||||
final List<StreamSubscription> _subscriptions = [];
|
final List<StreamSubscription> _subscriptions = [];
|
||||||
final PlaybackStateHandler playbackStateHandler = DatabasePlaybackStateHandler();
|
final PlaybackStateHandler _playbackStateHandler = DatabasePlaybackStateHandler();
|
||||||
|
|
||||||
|
final ValueNotifier<AvesVideoController?> playingVideoControllerNotifier = ValueNotifier(null);
|
||||||
|
|
||||||
static const _defaultMaxControllerCount = 3;
|
static const _defaultMaxControllerCount = 3;
|
||||||
|
|
||||||
|
@ -38,6 +40,7 @@ class VideoConductor {
|
||||||
..forEach((sub) => sub.cancel())
|
..forEach((sub) => sub.cancel())
|
||||||
..clear();
|
..clear();
|
||||||
await _disposeAll();
|
await _disposeAll();
|
||||||
|
playingVideoControllerNotifier.dispose();
|
||||||
_controllers.clear();
|
_controllers.clear();
|
||||||
if (settings.keepScreenOn == KeepScreenOn.videoPlayback) {
|
if (settings.keepScreenOn == KeepScreenOn.videoPlayback) {
|
||||||
await windowService.keepScreenOn(false);
|
await windowService.keepScreenOn(false);
|
||||||
|
@ -51,7 +54,7 @@ class VideoConductor {
|
||||||
} else {
|
} else {
|
||||||
controller = videoControllerFactory.buildController(
|
controller = videoControllerFactory.buildController(
|
||||||
entry,
|
entry,
|
||||||
playbackStateHandler: playbackStateHandler,
|
playbackStateHandler: _playbackStateHandler,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
);
|
);
|
||||||
_subscriptions.add(controller.statusStream.listen((event) => _onControllerStatusChanged(entry, controller!, event)));
|
_subscriptions.add(controller.statusStream.listen((event) => _onControllerStatusChanged(entry, controller!, event)));
|
||||||
|
@ -90,6 +93,8 @@ class VideoConductor {
|
||||||
if (settings.keepScreenOn == KeepScreenOn.videoPlayback) {
|
if (settings.keepScreenOn == KeepScreenOn.videoPlayback) {
|
||||||
await windowService.keepScreenOn(status == VideoStatus.playing);
|
await windowService.keepScreenOn(status == VideoStatus.playing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
playingVideoControllerNotifier.value = getPlayingController();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _applyToAll(FutureOr Function(AvesVideoController controller) action) => Future.forEach<AvesVideoController>(_controllers, action);
|
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/app_mode.dart';
|
||||||
import 'package:aves/model/entry/entry.dart';
|
import 'package:aves/model/entry/entry.dart';
|
||||||
import 'package:aves/model/entry/extensions/multipage.dart';
|
import 'package:aves/model/entry/extensions/multipage.dart';
|
||||||
import 'package:aves/model/entry/extensions/props.dart';
|
import 'package:aves/model/entry/extensions/props.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
|
import 'package:aves/services/common/services.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/widgets/viewer/multipage/conductor.dart';
|
import 'package:aves/widgets/viewer/multipage/conductor.dart';
|
||||||
import 'package:aves/widgets/viewer/multipage/controller.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_model/aves_model.dart';
|
||||||
import 'package:aves_video/aves_video.dart';
|
import 'package:aves_video/aves_video.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:floating/floating.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
// state controllers/monitors
|
// state controllers/monitors
|
||||||
|
@ -216,4 +222,50 @@ mixin EntryViewControllerMixin<T extends StatefulWidget> on State<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> pauseVideoControllers() => context.read<VideoConductor>().pauseAll();
|
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