aves/lib/widgets/viewer/visual/controller_mixin.dart
Thibault Deckers b8e9786f4d minor fixes
2025-03-16 18:05:24 +01:00

272 lines
9.5 KiB
Dart

import 'dart:async';
import 'dart:math';
import 'package:aves/app_mode.dart';
import 'package:aves/model/device.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';
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
mixin EntryViewControllerMixin<T extends StatefulWidget> on State<T> {
final Map<AvesEntry, VoidCallback> _metadataChangeListeners = {};
final Map<MultiPageController, Future<void> Function()> _multiPageControllerPageListeners = {};
bool? videoMutedOverride;
bool get isViewingImage;
ValueNotifier<AvesEntry?> get entryNotifier;
Future<void> initEntryControllers(AvesEntry? entry) async {
if (!mounted || entry == null) return;
if (entry.isVideo) {
await _initVideoController(entry);
}
if (entry.isMultiPage) {
await _initMultiPageController(entry);
}
void listener() => _onMetadataChanged(entry);
_metadataChangeListeners[entry] = listener;
entry.metadataChangeNotifier.addListener(listener);
}
void cleanEntryControllers(AvesEntry? entry) {
if (entry == null) return;
final listener = _metadataChangeListeners.remove(entry);
if (listener != null) {
entry.metadataChangeNotifier.removeListener(listener);
}
if (entry.isMultiPage) {
_cleanMultiPageController(entry);
}
}
void _onMetadataChanged(AvesEntry entry) {
debugPrint('reinitialize controllers for entry=$entry because metadata changed');
cleanEntryControllers(entry);
initEntryControllers(entry);
}
SlideshowVideoPlayback? get videoPlaybackOverride {
if (!mounted) return null;
final appMode = context.read<ValueNotifier<AppMode>>().value;
switch (appMode) {
case AppMode.screenSaver:
return settings.screenSaverVideoPlayback;
case AppMode.slideshow:
return settings.slideshowVideoPlayback;
default:
return null;
}
}
bool get videoAutoPlayEnabled {
if (!isViewingImage) return false;
switch (videoPlaybackOverride) {
case SlideshowVideoPlayback.skip:
return false;
case SlideshowVideoPlayback.playMuted:
case SlideshowVideoPlayback.playWithSound:
return true;
case null:
break;
}
switch (settings.videoAutoPlayMode) {
case VideoAutoPlayMode.disabled:
return false;
case VideoAutoPlayMode.playMuted:
case VideoAutoPlayMode.playWithSound:
return true;
}
}
bool get shouldAutoPlayVideoMuted {
if (videoMutedOverride != null) {
return videoMutedOverride!;
}
switch (videoPlaybackOverride) {
case SlideshowVideoPlayback.skip:
case SlideshowVideoPlayback.playWithSound:
return false;
case SlideshowVideoPlayback.playMuted:
return true;
case null:
break;
}
switch (settings.videoAutoPlayMode) {
case VideoAutoPlayMode.disabled:
case VideoAutoPlayMode.playWithSound:
return false;
case VideoAutoPlayMode.playMuted:
return true;
}
}
bool get shouldAutoPlayMotionPhoto {
if (!isViewingImage) return false;
return settings.enableMotionPhotoAutoPlay;
}
Future<void> _initVideoController(AvesEntry entry) async {
final controller = context.read<VideoConductor>().getOrCreateController(entry);
setState(() {});
if (videoAutoPlayEnabled || entry.isAnimated) {
final resumeTimeMillis = await controller.getResumeTime(context);
await _autoPlayVideo(controller, () => entry == entryNotifier.value, resumeTimeMillis: resumeTimeMillis);
}
}
Future<void> _initMultiPageController(AvesEntry entry) async {
if (!mounted) return;
final multiPageController = context.read<MultiPageConductor>().getOrCreateController(entry);
setState(() {});
final multiPageInfo = multiPageController.info ?? await multiPageController.infoStream.first;
assert(multiPageInfo != null);
if (multiPageInfo == null) return;
if (entry.isMotionPhoto) {
await multiPageInfo.extractMotionPhotoVideo();
}
final videoPageEntries = multiPageInfo.videoPageEntries;
if (videoPageEntries.isNotEmpty) {
// init video controllers for all pages that could need it
final videoConductor = context.read<VideoConductor>();
videoPageEntries.forEach((entry) => videoConductor.getOrCreateController(entry, maxControllerCount: videoPageEntries.length));
// auto play/pause when changing page
Future<void> _onPageChanged() async {
await pauseVideoControllers();
if (videoAutoPlayEnabled || (entry.isMotionPhoto && shouldAutoPlayMotionPhoto)) {
final page = multiPageController.page;
final pageInfo = multiPageInfo.getByIndex(page)!;
if (pageInfo.isVideo) {
final pageEntry = multiPageInfo.getPageEntryByIndex(page);
final pageVideoController = videoConductor.getController(pageEntry);
assert(pageVideoController != null);
if (pageVideoController != null) {
await _autoPlayVideo(pageVideoController, () => entry == entryNotifier.value && page == multiPageController.page);
}
}
}
}
_multiPageControllerPageListeners[multiPageController] = _onPageChanged;
multiPageController.pageNotifier.addListener(_onPageChanged);
await _onPageChanged();
if (entry.isMotionPhoto && shouldAutoPlayMotionPhoto) {
await Future.delayed(ADurations.motionPhotoAutoPlayDelay);
if (entry == entryNotifier.value) {
multiPageController.page = 1;
}
}
}
}
Future<void> _cleanMultiPageController(AvesEntry entry) async {
final multiPageController = _multiPageControllerPageListeners.keys.firstWhereOrNull((v) => v.entry == entry);
if (multiPageController != null) {
final _onPageChange = _multiPageControllerPageListeners.remove(multiPageController);
if (_onPageChange != null) {
multiPageController.pageNotifier.removeListener(_onPageChange);
}
}
}
Future<void> _autoPlayVideo(AvesVideoController videoController, bool Function() isCurrent, {int? resumeTimeMillis}) async {
// video decoding may fail or have initial artifacts when the player initializes
// during this widget initialization (because of the page transition and hero animation?)
// so we play after a delay for increased stability
await Future.delayed(const Duration(milliseconds: 300) * timeDilation);
if (!videoController.isMuted && (videoController.entry.isAnimated || shouldAutoPlayVideoMuted)) {
await videoController.mute(true);
}
if (resumeTimeMillis != null) {
await videoController.seekTo(resumeTimeMillis);
}
await videoController.play();
// playing controllers are paused when the entry changes,
// but the controller may still be preparing (not yet playing) when this happens
// so we make sure the current entry is still the same to keep playing
if (!isCurrent()) {
await videoController.pause();
}
}
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 (!device.supportPictureInPicture) return;
if (context.mounted && 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();
}
}