Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
Hosted Weblate 2025-01-12 23:13:39 +01:00
commit 334327dc0b
No known key found for this signature in database
GPG key ID: A3FAAA06E6569B4C
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 ### 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

View file

@ -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 {

View file

@ -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);

View file

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