diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index a74c025cd..1f976fe10 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -439,8 +439,6 @@ "settingsVideoEnableAutoPlay": "Automatische Wiedergabe", "settingsVideoLoopModeTile": "Schleifen-Modus", "settingsVideoLoopModeTitle": "Schleifen-Modus", - "settingsVideoQuickActionsTile": "Schnelle Aktionen für Videos", - "settingsVideoQuickActionEditorTitle": "Schnelle Aktionen", "settingsSubtitleThemeTile": "Untertitel", "settingsSubtitleThemeTitle": "Untertitel", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 35df162c7..d965c2b14 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -155,7 +155,7 @@ "videoControlsNone": "None", "videoControlsPlay": "Play", "videoControlsPlaySeek": "Play & seek", - "videoControlsPlayOutside": "Play outside", + "videoControlsPlayOutside": "Open with other player", "mapStyleGoogleNormal": "Google Maps", "mapStyleGoogleHybrid": "Google Maps (Hybrid)", @@ -628,8 +628,6 @@ "settingsVideoEnableAutoPlay": "Auto play", "settingsVideoLoopModeTile": "Loop mode", "settingsVideoLoopModeTitle": "Loop Mode", - "settingsVideoQuickActionsTile": "Quick actions for videos", - "settingsVideoQuickActionEditorTitle": "Quick Actions", "settingsSubtitleThemeTile": "Subtitles", "settingsSubtitleThemeTitle": "Subtitles", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 39ac08f98..4a554fc0d 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -442,8 +442,6 @@ "settingsVideoEnableAutoPlay": "Reproducción automática", "settingsVideoLoopModeTile": "Modo bucle", "settingsVideoLoopModeTitle": "Modo bucle", - "settingsVideoQuickActionsTile": "Acciones rápidas para videos", - "settingsVideoQuickActionEditorTitle": "Acciones rápidas", "settingsSubtitleThemeTile": "Subtítulos", "settingsSubtitleThemeTitle": "Subtítulos", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 65759fb4e..47da9ee1e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -440,8 +440,6 @@ "settingsVideoEnableAutoPlay": "Lecture automatique", "settingsVideoLoopModeTile": "Lecture répétée", "settingsVideoLoopModeTitle": "Lecture répétée", - "settingsVideoQuickActionsTile": "Actions rapides pour les vidéos", - "settingsVideoQuickActionEditorTitle": "Actions rapides", "settingsSubtitleThemeTile": "Sous-titres", "settingsSubtitleThemeTitle": "Sous-titres", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index e14674683..133e2b96b 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -439,8 +439,6 @@ "settingsVideoEnableAutoPlay": "Putar otomatis", "settingsVideoLoopModeTile": "Putar ulang", "settingsVideoLoopModeTitle": "Putar Ulang", - "settingsVideoQuickActionsTile": "Aksi cepat untuk video", - "settingsVideoQuickActionEditorTitle": "Aksi Cepat", "settingsSubtitleThemeTile": "Subtitle", "settingsSubtitleThemeTitle": "Subtitle", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index abe37bbcc..8cf6068ca 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -440,8 +440,6 @@ "settingsVideoEnableAutoPlay": "자동 재생", "settingsVideoLoopModeTile": "반복 모드", "settingsVideoLoopModeTitle": "반복 모드", - "settingsVideoQuickActionsTile": "동영상의 빠른 작업", - "settingsVideoQuickActionEditorTitle": "빠른 작업", "settingsSubtitleThemeTile": "자막", "settingsSubtitleThemeTitle": "자막", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 948e20c12..2167f6dee 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -440,8 +440,6 @@ "settingsVideoEnableAutoPlay": "Reprodução automática", "settingsVideoLoopModeTile": "Modo de loop", "settingsVideoLoopModeTitle": "Modo de loop", - "settingsVideoQuickActionsTile": "Ações rápidas para vídeos", - "settingsVideoQuickActionEditorTitle": "Ações rápidas", "settingsSubtitleThemeTile": "Legendas", "settingsSubtitleThemeTitle": "Legendas", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 86b70ebe5..d023f2ba7 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -439,8 +439,6 @@ "settingsVideoEnableAutoPlay": "Автозапуск воспроизведения", "settingsVideoLoopModeTile": "Циклический режим", "settingsVideoLoopModeTitle": "Цикличный режим", - "settingsVideoQuickActionsTile": "Быстрые действия для видео", - "settingsVideoQuickActionEditorTitle": "Быстрые действия", "settingsSubtitleThemeTile": "Субтитры", "settingsSubtitleThemeTitle": "Субтитры", diff --git a/lib/model/actions/entry_actions.dart b/lib/model/actions/entry_actions.dart index 2528b62d7..54ce983a9 100644 --- a/lib/model/actions/entry_actions.dart +++ b/lib/model/actions/entry_actions.dart @@ -21,6 +21,14 @@ enum EntryAction { flip, // vector viewSource, + // video + videoCaptureFrame, + videoSelectStreams, + videoSetSpeed, + videoSettings, + videoTogglePlay, + videoReplay10, + videoSkip10, // external edit, open, @@ -41,8 +49,8 @@ class EntryActions { EntryAction.copy, EntryAction.move, EntryAction.toggleFavourite, - EntryAction.viewSource, EntryAction.rotateScreen, + EntryAction.viewSource, ]; static const export = [ @@ -62,6 +70,13 @@ class EntryActions { ]; static const pageActions = [ + EntryAction.videoCaptureFrame, + EntryAction.videoSelectStreams, + EntryAction.videoSetSpeed, + EntryAction.videoSettings, + EntryAction.videoTogglePlay, + EntryAction.videoReplay10, + EntryAction.videoSkip10, EntryAction.rotateCCW, EntryAction.rotateCW, EntryAction.flip, @@ -72,6 +87,13 @@ class EntryActions { EntryAction.restore, EntryAction.debug, ]; + + static const video = [ + EntryAction.videoCaptureFrame, + EntryAction.videoSetSpeed, + EntryAction.videoSelectStreams, + EntryAction.videoSettings, + ]; } extension ExtraEntryAction on EntryAction { @@ -110,6 +132,22 @@ extension ExtraEntryAction on EntryAction { // vector case EntryAction.viewSource: return context.l10n.entryActionViewSource; + // video + case EntryAction.videoCaptureFrame: + return context.l10n.videoActionCaptureFrame; + case EntryAction.videoSelectStreams: + return context.l10n.videoActionSelectStreams; + case EntryAction.videoSetSpeed: + return context.l10n.videoActionSetSpeed; + case EntryAction.videoSettings: + return context.l10n.videoActionSettings; + case EntryAction.videoTogglePlay: + // different data depending on toggle state + return context.l10n.videoActionPlay; + case EntryAction.videoReplay10: + return context.l10n.videoActionReplay10; + case EntryAction.videoSkip10: + return context.l10n.videoActionSkip10; // external case EntryAction.edit: return context.l10n.entryActionEdit; @@ -176,6 +214,22 @@ extension ExtraEntryAction on EntryAction { // vector case EntryAction.viewSource: return AIcons.vector; + // video + case EntryAction.videoCaptureFrame: + return AIcons.captureFrame; + case EntryAction.videoSelectStreams: + return AIcons.streams; + case EntryAction.videoSetSpeed: + return AIcons.speed; + case EntryAction.videoSettings: + return AIcons.videoSettings; + case EntryAction.videoTogglePlay: + // different data depending on toggle state + return AIcons.play; + case EntryAction.videoReplay10: + return AIcons.replay10; + case EntryAction.videoSkip10: + return AIcons.skip10; // external case EntryAction.edit: return AIcons.edit; diff --git a/lib/model/actions/video_actions.dart b/lib/model/actions/video_actions.dart deleted file mode 100644 index f0eff3676..000000000 --- a/lib/model/actions/video_actions.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:aves/theme/icons.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:flutter/widgets.dart'; - -enum VideoAction { - // controls - playOutside, - replay10, - skip10, - togglePlay, - // menu - captureFrame, - selectStreams, - setSpeed, - settings, - // TODO TLAD [video] toggle mute -} - -class VideoActions { - static const menu = [ - VideoAction.captureFrame, - VideoAction.setSpeed, - VideoAction.selectStreams, - VideoAction.settings, - ]; -} - -extension ExtraVideoAction on VideoAction { - String getText(BuildContext context) { - switch (this) { - case VideoAction.captureFrame: - return context.l10n.videoActionCaptureFrame; - case VideoAction.playOutside: - return context.l10n.entryActionOpen; - case VideoAction.replay10: - return context.l10n.videoActionReplay10; - case VideoAction.skip10: - return context.l10n.videoActionSkip10; - case VideoAction.selectStreams: - return context.l10n.videoActionSelectStreams; - case VideoAction.setSpeed: - return context.l10n.videoActionSetSpeed; - case VideoAction.settings: - return context.l10n.videoActionSettings; - case VideoAction.togglePlay: - // different data depending on toggle state - return context.l10n.videoActionPlay; - } - } - - Widget getIcon() => Icon(getIconData()); - - IconData getIconData() { - switch (this) { - case VideoAction.captureFrame: - return AIcons.captureFrame; - case VideoAction.playOutside: - return AIcons.openOutside; - case VideoAction.replay10: - return AIcons.replay10; - case VideoAction.skip10: - return AIcons.skip10; - case VideoAction.selectStreams: - return AIcons.streams; - case VideoAction.setSpeed: - return AIcons.speed; - case VideoAction.settings: - return AIcons.videoSettings; - case VideoAction.togglePlay: - // different data depending on toggle state - return AIcons.play; - } - } -} diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index bbee26066..afa1f219f 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -1,6 +1,5 @@ import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/entry_set_actions.dart'; -import 'package:aves/model/actions/video_actions.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/settings/enums/enums.dart'; @@ -74,10 +73,6 @@ class SettingsDefaults { static const enableMotionPhotoAutoPlay = false; // video - static const videoQuickActions = [ - VideoAction.replay10, - VideoAction.togglePlay, - ]; static const enableVideoHardwareAcceleration = true; static const enableVideoAutoPlay = false; static const videoLoopMode = VideoLoopMode.shortOnly; diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 3774b3441..67b8f7e0f 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -4,7 +4,6 @@ import 'dart:math'; import 'package:aves/l10n/l10n.dart'; import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/entry_set_actions.dart'; -import 'package:aves/model/actions/video_actions.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/settings/defaults.dart'; import 'package:aves/model/settings/enums/enums.dart'; @@ -90,7 +89,6 @@ class Settings extends ChangeNotifier { static const imageBackgroundKey = 'image_background'; // video - static const videoQuickActionsKey = 'video_quick_actions'; static const enableVideoHardwareAccelerationKey = 'video_hwaccel_mediacodec'; static const enableVideoAutoPlayKey = 'video_auto_play'; static const videoLoopModeKey = 'video_loop'; @@ -419,10 +417,6 @@ class Settings extends ChangeNotifier { // video - List get videoQuickActions => getEnumListOrDefault(videoQuickActionsKey, SettingsDefaults.videoQuickActions, VideoAction.values); - - set videoQuickActions(List newValue) => setAndNotify(videoQuickActionsKey, newValue.map((v) => v.toString()).toList()); - bool get enableVideoHardwareAcceleration => getBoolOrDefault(enableVideoHardwareAccelerationKey, SettingsDefaults.enableVideoHardwareAcceleration); set enableVideoHardwareAcceleration(bool newValue) => setAndNotify(enableVideoHardwareAccelerationKey, newValue); @@ -713,7 +707,6 @@ class Settings extends ChangeNotifier { case collectionBrowsingQuickActionsKey: case collectionSelectionQuickActionsKey: case viewerQuickActionsKey: - case videoQuickActionsKey: if (newValue is List) { settingsStore.setStringList(key, newValue.cast()); } else { diff --git a/lib/widgets/debug/settings.dart b/lib/widgets/debug/settings.dart index 81bded031..cb2c286a3 100644 --- a/lib/widgets/debug/settings.dart +++ b/lib/widgets/debug/settings.dart @@ -59,7 +59,6 @@ class DebugSettingsSection extends StatelessWidget { 'infoMapZoom': '${settings.infoMapZoom}', 'collectionSelectionQuickActions': '${settings.collectionSelectionQuickActions}', 'viewerQuickActions': '${settings.viewerQuickActions}', - 'videoQuickActions': '${settings.videoQuickActions}', 'drawerTypeBookmarks': toMultiline(settings.drawerTypeBookmarks), 'drawerAlbumBookmarks': toMultiline(settings.drawerAlbumBookmarks), 'drawerPageBookmarks': toMultiline(settings.drawerPageBookmarks), diff --git a/lib/widgets/settings/video/video.dart b/lib/widgets/settings/video/video.dart index ffebd5bdf..9162582e3 100644 --- a/lib/widgets/settings/video/video.dart +++ b/lib/widgets/settings/video/video.dart @@ -11,7 +11,6 @@ import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/settings/common/tile_leading.dart'; import 'package:aves/widgets/settings/video/controls.dart'; import 'package:aves/widgets/settings/video/subtitle_theme.dart'; -import 'package:aves/widgets/settings/video/video_actions_editor.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -37,7 +36,6 @@ class VideoSection extends StatelessWidget { title: Text(context.l10n.settingsVideoShowVideos), ), ), - const VideoActionsTile(), Selector( selector: (context, s) => s.enableVideoHardwareAcceleration, builder: (context, current, child) => SwitchListTile( diff --git a/lib/widgets/settings/video/video_actions_editor.dart b/lib/widgets/settings/video/video_actions_editor.dart deleted file mode 100644 index 4dce80402..000000000 --- a/lib/widgets/settings/video/video_actions_editor.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:aves/model/actions/video_actions.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/settings/common/quick_actions/editor_page.dart'; -import 'package:flutter/material.dart'; - -class VideoActionsTile extends StatelessWidget { - const VideoActionsTile({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return ListTile( - title: Text(context.l10n.settingsVideoQuickActionsTile), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - settings: const RouteSettings(name: VideoActionEditorPage.routeName), - builder: (context) => const VideoActionEditorPage(), - ), - ); - }, - ); - } -} - -class VideoActionEditorPage extends StatelessWidget { - static const routeName = '/settings/video_actions'; - - const VideoActionEditorPage({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return QuickActionEditorPage( - title: context.l10n.settingsVideoQuickActionEditorTitle, - bannerText: context.l10n.settingsViewerQuickActionEditorBanner, - allAvailableActions: VideoActions.menu, - actionIcon: (action) => action.getIcon(), - actionText: (context, action) => action.getText(context), - load: () => settings.videoQuickActions, - save: (actions) => settings.videoQuickActions = actions, - ); - } -} diff --git a/lib/widgets/settings/viewer/overlay.dart b/lib/widgets/settings/viewer/overlay.dart index 8bd8238ce..d85832c83 100644 --- a/lib/widgets/settings/viewer/overlay.dart +++ b/lib/widgets/settings/viewer/overlay.dart @@ -46,14 +46,6 @@ class ViewerOverlayPage extends StatelessWidget { title: Text(context.l10n.settingsViewerShowOverlayOnOpening), ), ), - Selector( - selector: (context, s) => s.showOverlayMinimap, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.showOverlayMinimap = v, - title: Text(context.l10n.settingsViewerShowMinimap), - ), - ), Selector( selector: (context, s) => s.showOverlayInfo, builder: (context, current, child) => SwitchListTile( @@ -75,6 +67,14 @@ class ViewerOverlayPage extends StatelessWidget { ); }, ), + Selector( + selector: (context, s) => s.showOverlayMinimap, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.showOverlayMinimap = v, + title: Text(context.l10n.settingsViewerShowMinimap), + ), + ), Selector( selector: (context, s) => s.showOverlayThumbnailPreview, builder: (context, current, child) => SwitchListTile( diff --git a/lib/widgets/settings/viewer/viewer_actions_editor.dart b/lib/widgets/settings/viewer/viewer_actions_editor.dart index 88b1859d6..f61014417 100644 --- a/lib/widgets/settings/viewer/viewer_actions_editor.dart +++ b/lib/widgets/settings/viewer/viewer_actions_editor.dart @@ -30,7 +30,18 @@ class ViewerActionEditorPage extends StatelessWidget { const ViewerActionEditorPage({Key? key}) : super(key: key); static const allAvailableActions = [ - ...EntryActions.topLevel, + EntryAction.share, + EntryAction.edit, + EntryAction.rename, + EntryAction.delete, + EntryAction.copy, + EntryAction.move, + EntryAction.toggleFavourite, + EntryAction.rotateScreen, + EntryAction.videoCaptureFrame, + EntryAction.videoSetSpeed, + EntryAction.videoSelectStreams, + EntryAction.viewSource, EntryAction.rotateCCW, EntryAction.rotateCW, EntryAction.flip, diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index 2ede09ed9..1c7b931d0 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -34,7 +34,9 @@ import 'package:aves/widgets/viewer/action/printer.dart'; import 'package:aves/widgets/viewer/action/single_entry_editor.dart'; import 'package:aves/widgets/viewer/debug/debug_page.dart'; import 'package:aves/widgets/viewer/info/notifications.dart'; +import 'package:aves/widgets/viewer/overlay/notifications.dart'; import 'package:aves/widgets/viewer/source_viewer_page.dart'; +import 'package:aves/widgets/viewer/video/conductor.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -99,7 +101,19 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix case EntryAction.viewSource: _goToSourceViewer(context); break; - // external + // video + case EntryAction.videoCaptureFrame: + case EntryAction.videoSelectStreams: + case EntryAction.videoSetSpeed: + case EntryAction.videoSettings: + case EntryAction.videoTogglePlay: + case EntryAction.videoReplay10: + case EntryAction.videoSkip10: + final controller = context.read().getController(entry); + if (controller != null) { + VideoActionNotification(controller: controller, action: action).dispatch(context); + } + break; case EntryAction.edit: androidAppService.edit(entry.uri, entry.mimeType).then((success) { if (!success) showNoMatchingAppDialog(context); diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 9f6f88385..3307af7b7 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -14,17 +14,16 @@ import 'package:aves/utils/change_notifier.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/basic/insets.dart'; -import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart'; import 'package:aves/widgets/viewer/entry_vertical_pager.dart'; import 'package:aves/widgets/viewer/hero.dart'; import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; import 'package:aves/widgets/viewer/multipage/controller.dart'; -import 'package:aves/widgets/viewer/overlay/bottom/common.dart'; -import 'package:aves/widgets/viewer/overlay/bottom/panorama.dart'; -import 'package:aves/widgets/viewer/overlay/bottom/video/video.dart'; +import 'package:aves/widgets/viewer/overlay/bottom.dart'; import 'package:aves/widgets/viewer/overlay/notifications.dart'; +import 'package:aves/widgets/viewer/overlay/panorama.dart'; import 'package:aves/widgets/viewer/overlay/top.dart'; +import 'package:aves/widgets/viewer/overlay/video/video.dart'; import 'package:aves/widgets/viewer/page_entry_builder.dart'; import 'package:aves/widgets/viewer/video/conductor.dart'; import 'package:aves/widgets/viewer/video/controller.dart'; @@ -60,8 +59,8 @@ class _EntryViewerStackState extends State with FeedbackMixin, final AChangeNotifier _verticalScrollNotifier = AChangeNotifier(); final ValueNotifier _overlayVisible = ValueNotifier(true); late AnimationController _overlayAnimationController; - late Animation _topOverlayScale, _bottomOverlayScale; - late Animation _bottomOverlayOffset; + late Animation _overlayButtonScale, _overlayVideoControlScale; + late Animation _overlayTopOffset; EdgeInsets? _frozenViewInsets, _frozenViewPadding; late VideoActionDelegate _videoActionDelegate; final Map Function()> _multiPageControllerPageListeners = {}; @@ -110,17 +109,17 @@ class _EntryViewerStackState extends State with FeedbackMixin, duration: context.read().viewerOverlayAnimation, vsync: this, ); - _topOverlayScale = CurvedAnimation( + _overlayButtonScale = CurvedAnimation( parent: _overlayAnimationController, // a little bounce at the top curve: Curves.easeOutBack, ); - _bottomOverlayScale = CurvedAnimation( + _overlayVideoControlScale = CurvedAnimation( parent: _overlayAnimationController, // no bounce at the bottom, to avoid video controller displacement curve: Curves.easeOutQuad, ); - _bottomOverlayOffset = Tween(begin: const Offset(0, 1), end: const Offset(0, 0)).animate(CurvedAnimation( + _overlayTopOffset = Tween(begin: const Offset(0, -1), end: const Offset(0, 0)).animate(CurvedAnimation( parent: _overlayAnimationController, curve: Curves.easeOutQuad, )); @@ -209,7 +208,7 @@ class _EntryViewerStackState extends State with FeedbackMixin, if (_currentHorizontalPage != index) { _horizontalPager.jumpToPage(index); } - } else if (notification is VideoGestureNotification) { + } else if (notification is VideoActionNotification) { final controller = notification.controller; final action = notification.action; _videoActionDelegate.onActionSelected(context, controller, action); @@ -245,27 +244,20 @@ class _EntryViewerStackState extends State with FeedbackMixin, Widget child = ValueListenableBuilder( valueListenable: _entryNotifier, builder: (context, mainEntry, child) { - if (mainEntry == null) return const SizedBox.shrink(); + if (mainEntry == null) return const SizedBox(); - Widget _buildContent({AvesEntry? pageEntry}) { - return EmbeddedDataOpener( - entry: mainEntry, - child: ViewerTopOverlay( - mainEntry: mainEntry, - scale: _topOverlayScale, - canToggleFavourite: hasCollection, - viewInsets: _frozenViewInsets, - viewPadding: _frozenViewPadding, - ), - ); - } - - return mainEntry.isMultiPage - ? PageEntryBuilder( - multiPageController: context.read().getController(mainEntry), - builder: (pageEntry) => _buildContent(pageEntry: pageEntry), - ) - : _buildContent(); + return SlideTransition( + position: _overlayTopOffset, + child: ViewerTopOverlay( + entries: entries, + index: _currentHorizontalPage, + hasCollection: hasCollection, + mainEntry: mainEntry, + scale: _overlayButtonScale, + viewInsets: _frozenViewInsets, + viewPadding: _frozenViewPadding, + ), + ); }, ); @@ -298,7 +290,8 @@ class _EntryViewerStackState extends State with FeedbackMixin, Widget child = ValueListenableBuilder( valueListenable: _entryNotifier, builder: (context, mainEntry, child) { - if (mainEntry == null) return const SizedBox.shrink(); + if (mainEntry == null) return const SizedBox(); + final multiPageController = mainEntry.isMultiPage ? context.read().getController(mainEntry) : null; Widget? _buildExtraBottomOverlay({AvesEntry? pageEntry}) { @@ -311,7 +304,7 @@ class _EntryViewerStackState extends State with FeedbackMixin, builder: (context, videoController, child) => VideoControlOverlay( entry: targetEntry, controller: videoController, - scale: _bottomOverlayScale, + scale: _overlayVideoControlScale, onActionSelected: (action) { if (videoController != null) { _videoActionDelegate.onActionSelected(context, videoController, action); @@ -329,7 +322,7 @@ class _EntryViewerStackState extends State with FeedbackMixin, } else if (targetEntry.is360) { child = PanoramaOverlay( entry: targetEntry, - scale: _bottomOverlayScale, + scale: _overlayButtonScale, ); } return child != null @@ -348,21 +341,24 @@ class _EntryViewerStackState extends State with FeedbackMixin, ) : _buildExtraBottomOverlay(); - return Column( - children: [ - if (extraBottomOverlay != null) extraBottomOverlay, - SlideTransition( - position: _bottomOverlayOffset, - child: ViewerBottomOverlay( + return TooltipTheme( + data: TooltipTheme.of(context).copyWith( + preferBelow: false, + ), + child: Column( + children: [ + if (extraBottomOverlay != null) extraBottomOverlay, + ViewerBottomOverlay( entries: entries, index: _currentHorizontalPage, - showPosition: hasCollection, + hasCollection: hasCollection, + animationController: _overlayAnimationController, viewInsets: _frozenViewInsets, viewPadding: _frozenViewPadding, multiPageController: multiPageController, ), - ), - ], + ], + ), ); }, ); diff --git a/lib/widgets/viewer/overlay/bottom.dart b/lib/widgets/viewer/overlay/bottom.dart new file mode 100644 index 000000000..f16bd9c23 --- /dev/null +++ b/lib/widgets/viewer/overlay/bottom.dart @@ -0,0 +1,210 @@ +import 'dart:math'; + +import 'package:aves/model/entry.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/widgets/common/extensions/media_query.dart'; +import 'package:aves/widgets/viewer/multipage/controller.dart'; +import 'package:aves/widgets/viewer/overlay/multipage.dart'; +import 'package:aves/widgets/viewer/overlay/thumbnail_preview.dart'; +import 'package:aves/widgets/viewer/overlay/viewer_button_row.dart'; +import 'package:aves/widgets/viewer/page_entry_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tuple/tuple.dart'; + +class ViewerBottomOverlay extends StatefulWidget { + final List entries; + final int index; + final bool hasCollection; + final AnimationController animationController; + final EdgeInsets? viewInsets, viewPadding; + final MultiPageController? multiPageController; + + const ViewerBottomOverlay({ + Key? key, + required this.entries, + required this.index, + required this.hasCollection, + required this.animationController, + this.viewInsets, + this.viewPadding, + required this.multiPageController, + }) : super(key: key); + + @override + State createState() => _ViewerBottomOverlayState(); +} + +class _ViewerBottomOverlayState extends State { + List get entries => widget.entries; + + AvesEntry? get entry { + final index = widget.index; + return index < entries.length ? entries[index] : null; + } + + MultiPageController? get multiPageController => widget.multiPageController; + + @override + Widget build(BuildContext context) { + final mainEntry = entry; + if (mainEntry == null) return const SizedBox(); + + Widget _buildContent({AvesEntry? pageEntry}) => _BottomOverlayContent( + entries: entries, + index: widget.index, + mainEntry: mainEntry, + pageEntry: pageEntry ?? mainEntry, + hasCollection: widget.hasCollection, + multiPageController: multiPageController, + animationController: widget.animationController, + ); + + Widget child = multiPageController != null + ? PageEntryBuilder( + multiPageController: multiPageController!, + builder: (pageEntry) => _buildContent(pageEntry: pageEntry), + ) + : _buildContent(); + + return Selector( + selector: (context, mq) => max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom), + builder: (context, mqPaddingBottom, child) { + return Padding( + padding: EdgeInsets.only(bottom: mqPaddingBottom), + child: child, + ); + }, + child: child, + ); + } +} + +class _BottomOverlayContent extends StatefulWidget { + final List entries; + final int index; + final AvesEntry mainEntry, pageEntry; + final bool hasCollection; + final MultiPageController? multiPageController; + final AnimationController animationController; + + const _BottomOverlayContent({ + Key? key, + required this.entries, + required this.index, + required this.mainEntry, + required this.pageEntry, + required this.hasCollection, + required this.multiPageController, + required this.animationController, + }) : super(key: key); + + @override + State<_BottomOverlayContent> createState() => _BottomOverlayContentState(); +} + +class _BottomOverlayContentState extends State<_BottomOverlayContent> { + late Animation _buttonScale, _thumbnailOpacity; + + @override + void initState() { + super.initState(); + final animationController = widget.animationController; + _buttonScale = CurvedAnimation( + parent: animationController, + // a little bounce at the top + curve: Curves.easeOutBack, + ); + _thumbnailOpacity = CurvedAnimation( + parent: animationController, + curve: Curves.easeOutQuad, + ); + } + + @override + Widget build(BuildContext context) { + final mainEntry = widget.mainEntry; + final pageEntry = widget.pageEntry; + final multiPageController = widget.multiPageController; + + return AnimatedBuilder( + animation: Listenable.merge([ + mainEntry.metadataChangeNotifier, + pageEntry.metadataChangeNotifier, + ]), + builder: (context, child) { + return Selector( + selector: (context, mq) => mq.size.width, + builder: (context, mqWidth, child) { + return SizedBox( + width: mqWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (mainEntry.isMultiPage && multiPageController != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: FadeTransition( + opacity: _thumbnailOpacity, + child: MultiPageOverlay( + controller: multiPageController, + availableWidth: mqWidth, + ), + ), + ), + ViewerButtonRow( + mainEntry: mainEntry, + pageEntry: pageEntry, + scale: _buttonScale, + canToggleFavourite: widget.hasCollection, + ), + if (settings.showOverlayThumbnailPreview) + FadeTransition( + opacity: _thumbnailOpacity, + child: ViewerThumbnailPreview( + availableWidth: mqWidth, + displayedIndex: widget.index, + entries: widget.entries, + ), + ), + ], + ), + ); + }, + ); + }); + } +} + +class ExtraBottomOverlay extends StatelessWidget { + final EdgeInsets? viewInsets, viewPadding; + final Widget child; + + const ExtraBottomOverlay({ + Key? key, + this.viewInsets, + this.viewPadding, + required this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final mq = context.select>((mq) => Tuple3(mq.size.width, mq.viewInsets, mq.viewPadding)); + final mqWidth = mq.item1; + final mqViewInsets = mq.item2; + final mqViewPadding = mq.item3; + + final viewInsets = this.viewInsets ?? mqViewInsets; + final viewPadding = this.viewPadding ?? mqViewPadding; + final safePadding = (viewInsets + viewPadding).copyWith(bottom: 8) + const EdgeInsets.symmetric(horizontal: 8.0); + + return Padding( + padding: safePadding, + child: SizedBox( + width: mqWidth - safePadding.horizontal, + child: child, + ), + ); + } +} diff --git a/lib/widgets/viewer/overlay/bottom/common.dart b/lib/widgets/viewer/overlay/bottom/common.dart deleted file mode 100644 index 0751281aa..000000000 --- a/lib/widgets/viewer/overlay/bottom/common.dart +++ /dev/null @@ -1,232 +0,0 @@ -import 'dart:math'; - -import 'package:aves/model/entry.dart'; -import 'package:aves/model/metadata/overlay.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/services/common/services.dart'; -import 'package:aves/utils/constants.dart'; -import 'package:aves/widgets/common/extensions/media_query.dart'; -import 'package:aves/widgets/common/fx/blurred.dart'; -import 'package:aves/widgets/viewer/multipage/controller.dart'; -import 'package:aves/widgets/viewer/overlay/bottom/details.dart'; -import 'package:aves/widgets/viewer/overlay/bottom/multipage.dart'; -import 'package:aves/widgets/viewer/overlay/bottom/thumbnail_preview.dart'; -import 'package:aves/widgets/viewer/overlay/common.dart'; -import 'package:aves/widgets/viewer/page_entry_builder.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:tuple/tuple.dart'; - -class ViewerBottomOverlay extends StatefulWidget { - final List entries; - final int index; - final bool showPosition; - final EdgeInsets? viewInsets, viewPadding; - final MultiPageController? multiPageController; - - const ViewerBottomOverlay({ - Key? key, - required this.entries, - required this.index, - required this.showPosition, - this.viewInsets, - this.viewPadding, - required this.multiPageController, - }) : super(key: key); - - @override - State createState() => _ViewerBottomOverlayState(); -} - -class _ViewerBottomOverlayState extends State { - late Future _detailLoader; - AvesEntry? _lastEntry; - OverlayMetadata? _lastDetails; - - List get entries => widget.entries; - - AvesEntry? get entry { - final index = widget.index; - return index < entries.length ? entries[index] : null; - } - - MultiPageController? get multiPageController => widget.multiPageController; - - @override - void initState() { - super.initState(); - _initDetailLoader(); - } - - @override - void didUpdateWidget(covariant ViewerBottomOverlay oldWidget) { - super.didUpdateWidget(oldWidget); - if (entry != _lastEntry) { - _initDetailLoader(); - } - } - - void _initDetailLoader() { - final requestEntry = entry; - _detailLoader = requestEntry != null ? metadataFetchService.getOverlayMetadata(requestEntry) : SynchronousFuture(null); - } - - @override - Widget build(BuildContext context) { - final hasEdgeContent = settings.showOverlayThumbnailPreview || settings.showOverlayInfo || multiPageController != null; - final blurred = settings.enableOverlayBlurEffect; - return BlurredRect( - enabled: hasEdgeContent && blurred, - child: Selector( - selector: (context, mq) => max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom), - builder: (context, mqPaddingBottom, child) { - return Container( - color: hasEdgeContent ? overlayBackgroundColor(blurred: blurred) : Colors.transparent, - padding: EdgeInsets.only(bottom: mqPaddingBottom), - child: child, - ); - }, - child: FutureBuilder( - future: _detailLoader, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) { - _lastDetails = snapshot.data; - _lastEntry = entry; - } - if (_lastEntry == null) return const SizedBox(); - final mainEntry = _lastEntry!; - - Widget _buildContent({AvesEntry? pageEntry}) => _BottomOverlayContent( - entries: entries, - index: widget.index, - mainEntry: mainEntry, - pageEntry: pageEntry ?? mainEntry, - details: _lastDetails, - showPosition: widget.showPosition, - multiPageController: multiPageController, - ); - - return multiPageController != null - ? PageEntryBuilder( - multiPageController: multiPageController!, - builder: (pageEntry) => _buildContent(pageEntry: pageEntry), - ) - : _buildContent(); - }, - ), - ), - ); - } -} - -class _BottomOverlayContent extends AnimatedWidget { - final List entries; - final int index; - final AvesEntry mainEntry, pageEntry; - final OverlayMetadata? details; - final bool showPosition; - final MultiPageController? multiPageController; - - _BottomOverlayContent({ - Key? key, - required this.entries, - required this.index, - required this.mainEntry, - required this.pageEntry, - required this.details, - required this.showPosition, - required this.multiPageController, - }) : super( - key: key, - listenable: Listenable.merge([ - mainEntry.metadataChangeNotifier, - pageEntry.metadataChangeNotifier, - ]), - ); - - @override - Widget build(BuildContext context) { - return DefaultTextStyle( - style: Theme.of(context).textTheme.bodyText2!.copyWith( - shadows: Constants.embossShadows, - ), - softWrap: false, - overflow: TextOverflow.fade, - maxLines: 1, - child: Selector( - selector: (context, mq) => mq.size.width, - builder: (context, mqWidth, child) { - return SizedBox( - width: mqWidth, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (mainEntry.isMultiPage && multiPageController != null) - MultiPageOverlay( - controller: multiPageController!, - availableWidth: mqWidth, - ), - if (settings.showOverlayInfo) - SafeArea( - top: false, - bottom: false, - child: LayoutBuilder( - builder: (context, constraints) { - return ViewerDetailOverlay( - pageEntry: pageEntry, - details: details, - position: showPosition ? '${index + 1}/${entries.length}' : null, - availableWidth: constraints.maxWidth, - multiPageController: multiPageController, - ); - }, - ), - ), - if (settings.showOverlayThumbnailPreview) - ViewerThumbnailPreview( - availableWidth: mqWidth, - displayedIndex: index, - entries: entries, - ), - ], - ), - ); - }, - ), - ); - } -} - -class ExtraBottomOverlay extends StatelessWidget { - final EdgeInsets? viewInsets, viewPadding; - final Widget child; - - const ExtraBottomOverlay({ - Key? key, - this.viewInsets, - this.viewPadding, - required this.child, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final mq = context.select>((mq) => Tuple3(mq.size.width, mq.viewInsets, mq.viewPadding)); - final mqWidth = mq.item1; - final mqViewInsets = mq.item2; - final mqViewPadding = mq.item3; - - final viewInsets = this.viewInsets ?? mqViewInsets; - final viewPadding = this.viewPadding ?? mqViewPadding; - final safePadding = (viewInsets + viewPadding).copyWith(bottom: 8) + const EdgeInsets.symmetric(horizontal: 8.0); - - return Padding( - padding: safePadding, - child: SizedBox( - width: mqWidth - safePadding.horizontal, - child: child, - ), - ); - } -} diff --git a/lib/widgets/viewer/overlay/bottom/video/video.dart b/lib/widgets/viewer/overlay/bottom/video/video.dart deleted file mode 100644 index f7d5187fd..000000000 --- a/lib/widgets/viewer/overlay/bottom/video/video.dart +++ /dev/null @@ -1,281 +0,0 @@ -import 'dart:async'; - -import 'package:aves/model/actions/video_actions.dart'; -import 'package:aves/model/entry.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/theme/durations.dart'; -import 'package:aves/widgets/common/basic/menu.dart'; -import 'package:aves/widgets/common/basic/popup_menu_button.dart'; -import 'package:aves/widgets/viewer/overlay/bottom/video/controls.dart'; -import 'package:aves/widgets/viewer/overlay/bottom/video/progress_bar.dart'; -import 'package:aves/widgets/viewer/overlay/common.dart'; -import 'package:aves/widgets/viewer/video/controller.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:provider/provider.dart'; - -class VideoControlOverlay extends StatefulWidget { - final AvesEntry entry; - final AvesVideoController? controller; - final Animation scale; - final Function(VideoAction value) onActionSelected; - final VoidCallback onActionMenuOpened; - - const VideoControlOverlay({ - Key? key, - required this.entry, - required this.controller, - required this.scale, - required this.onActionSelected, - required this.onActionMenuOpened, - }) : super(key: key); - - @override - State createState() => _VideoControlOverlayState(); -} - -class _VideoControlOverlayState extends State with SingleTickerProviderStateMixin { - AvesEntry get entry => widget.entry; - - Animation get scale => widget.scale; - - AvesVideoController? get controller => widget.controller; - - Stream get statusStream => controller?.statusStream ?? Stream.value(VideoStatus.idle); - - Stream get positionStream => controller?.positionStream ?? Stream.value(0); - - bool get isPlaying => controller?.isPlaying ?? false; - - static const double outerPadding = 8; - static const double innerPadding = 8; - - @override - Widget build(BuildContext context) { - return StreamBuilder( - stream: statusStream, - builder: (context, snapshot) { - // do not use stream snapshot because it is obsolete when switching between videos - final status = controller?.status ?? VideoStatus.idle; - Widget child; - if (status == VideoStatus.error) { - const action = VideoAction.playOutside; - child = Align( - alignment: AlignmentDirectional.centerEnd, - child: OverlayButton( - scale: scale, - child: IconButton( - icon: action.getIcon(), - onPressed: entry.trashed ? null : () => widget.onActionSelected(action), - tooltip: action.getText(context), - ), - ), - ); - } else { - child = Selector( - selector: (context, mq) => mq.size.width - mq.padding.horizontal, - builder: (context, mqWidth, child) { - final buttonWidth = OverlayButton.getSize(context); - final availableCount = ((mqWidth - outerPadding * 2) / (buttonWidth + innerPadding)).floor(); - return Selector>( - selector: (context, s) => s.videoQuickActions, - builder: (context, videoQuickActions, child) { - final quickActions = videoQuickActions.take(availableCount - 1).toList(); - final menuActions = VideoActions.menu.where((action) => !quickActions.contains(action)).toList(); - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - _ButtonRow( - quickActions: quickActions, - menuActions: menuActions, - scale: scale, - controller: controller, - onActionSelected: widget.onActionSelected, - onActionMenuOpened: widget.onActionMenuOpened, - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: VideoProgressBar( - controller: controller, - scale: scale, - ), - ), - VideoControlRow( - controller: controller, - scale: scale, - onActionSelected: widget.onActionSelected, - ), - ], - ), - ], - ); - }, - ); - }, - ); - } - - return TooltipTheme( - data: TooltipTheme.of(context).copyWith( - preferBelow: false, - ), - child: child, - ); - }); - } -} - -class _ButtonRow extends StatelessWidget { - final List quickActions, menuActions; - final Animation scale; - final AvesVideoController? controller; - final Function(VideoAction value) onActionSelected; - final VoidCallback onActionMenuOpened; - - const _ButtonRow({ - Key? key, - required this.quickActions, - required this.menuActions, - required this.scale, - required this.controller, - required this.onActionSelected, - required this.onActionMenuOpened, - }) : super(key: key); - - static const double padding = 8; - - bool get isPlaying => controller?.isPlaying ?? false; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - ...quickActions.map((action) => _buildOverlayButton(context, action)), - if (menuActions.isNotEmpty) - Padding( - padding: const EdgeInsetsDirectional.only(start: padding), - child: OverlayButton( - scale: scale, - child: MenuIconTheme( - child: AvesPopupMenuButton( - itemBuilder: (context) => menuActions.map((action) => _buildPopupMenuItem(context, action)).toList(), - onSelected: (action) async { - // wait for the popup menu to hide before proceeding with the action - await Future.delayed(Durations.popupMenuAnimation * timeDilation); - onActionSelected(action); - }, - onMenuOpened: onActionMenuOpened, - ), - ), - ), - ), - ], - ); - } - - Widget _buildOverlayButton(BuildContext context, VideoAction action) { - late Widget child; - void onPressed() => onActionSelected(action); - - ValueListenableBuilder _buildFromListenable(ValueListenable? enabledNotifier) { - return ValueListenableBuilder( - valueListenable: enabledNotifier ?? ValueNotifier(false), - builder: (context, canDo, child) => IconButton( - icon: child!, - onPressed: canDo ? onPressed : null, - tooltip: action.getText(context), - ), - child: action.getIcon(), - ); - } - - switch (action) { - case VideoAction.captureFrame: - child = _buildFromListenable(controller?.canCaptureFrameNotifier); - break; - case VideoAction.selectStreams: - child = _buildFromListenable(controller?.canSelectStreamNotifier); - break; - case VideoAction.setSpeed: - child = _buildFromListenable(controller?.canSetSpeedNotifier); - break; - case VideoAction.togglePlay: - child = PlayToggler( - controller: controller, - onPressed: onPressed, - ); - break; - case VideoAction.playOutside: - case VideoAction.replay10: - case VideoAction.skip10: - case VideoAction.settings: - child = IconButton( - icon: action.getIcon(), - onPressed: onPressed, - tooltip: action.getText(context), - ); - break; - } - return Padding( - padding: const EdgeInsetsDirectional.only(start: padding), - child: OverlayButton( - scale: scale, - child: child, - ), - ); - } - - PopupMenuEntry _buildPopupMenuItem(BuildContext context, VideoAction action) { - late final bool enabled; - switch (action) { - case VideoAction.captureFrame: - enabled = controller?.canCaptureFrameNotifier.value ?? false; - break; - case VideoAction.selectStreams: - enabled = controller?.canSelectStreamNotifier.value ?? false; - break; - case VideoAction.setSpeed: - enabled = controller?.canSetSpeedNotifier.value ?? false; - break; - case VideoAction.replay10: - case VideoAction.skip10: - case VideoAction.settings: - case VideoAction.togglePlay: - enabled = true; - break; - case VideoAction.playOutside: - enabled = !(controller?.entry.trashed ?? true); - break; - } - - Widget? child; - switch (action) { - case VideoAction.togglePlay: - child = PlayToggler( - controller: controller, - isMenuItem: true, - ); - break; - case VideoAction.captureFrame: - case VideoAction.playOutside: - case VideoAction.replay10: - case VideoAction.skip10: - case VideoAction.selectStreams: - case VideoAction.setSpeed: - case VideoAction.settings: - child = MenuRow(text: action.getText(context), icon: action.getIcon()); - break; - } - - return PopupMenuItem( - value: action, - enabled: enabled, - child: child, - ); - } -} diff --git a/lib/widgets/viewer/overlay/bottom/details.dart b/lib/widgets/viewer/overlay/details.dart similarity index 63% rename from lib/widgets/viewer/overlay/bottom/details.dart rename to lib/widgets/viewer/overlay/details.dart index ec8235588..e0c8bb44b 100644 --- a/lib/widgets/viewer/overlay/bottom/details.dart +++ b/lib/widgets/viewer/overlay/details.dart @@ -5,13 +5,16 @@ import 'package:aves/model/metadata/overlay.dart'; import 'package:aves/model/multipage.dart'; import 'package:aves/model/settings/enums/coordinate_format.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/format.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/viewer/multipage/controller.dart'; +import 'package:aves/widgets/viewer/page_entry_builder.dart'; import 'package:decorated_icon/decorated_icon.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; @@ -21,7 +24,98 @@ const double _iconSize = 16.0; const double _interRowPadding = 2.0; const double _subRowMinWidth = 300.0; -class ViewerDetailOverlay extends StatelessWidget { +class ViewerDetailOverlay extends StatefulWidget { + final List entries; + final int index; + final bool hasCollection; + final MultiPageController? multiPageController; + + const ViewerDetailOverlay({ + Key? key, + required this.entries, + required this.index, + required this.hasCollection, + required this.multiPageController, + }) : super(key: key); + + @override + State createState() => _ViewerDetailOverlayState(); +} + +class _ViewerDetailOverlayState extends State { + List get entries => widget.entries; + + AvesEntry? get entry { + final index = widget.index; + return index < entries.length ? entries[index] : null; + } + + late Future _detailLoader; + AvesEntry? _lastEntry; + OverlayMetadata? _lastDetails; + + @override + void initState() { + super.initState(); + _initDetailLoader(); + } + + @override + void didUpdateWidget(covariant ViewerDetailOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (entry != _lastEntry) { + _initDetailLoader(); + } + } + + void _initDetailLoader() { + final requestEntry = entry; + _detailLoader = requestEntry != null ? metadataFetchService.getOverlayMetadata(requestEntry) : SynchronousFuture(null); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + bottom: false, + child: LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth; + + return FutureBuilder( + future: _detailLoader, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) { + _lastDetails = snapshot.data; + _lastEntry = entry; + } + if (_lastEntry == null) return const SizedBox(); + final mainEntry = _lastEntry!; + + final multiPageController = widget.multiPageController; + Widget _buildContent({AvesEntry? pageEntry}) => ViewerDetailOverlayContent( + pageEntry: pageEntry ?? mainEntry, + details: _lastDetails, + position: widget.hasCollection ? '${widget.index + 1}/${entries.length}' : null, + availableWidth: availableWidth, + multiPageController: multiPageController, + ); + + return multiPageController != null + ? PageEntryBuilder( + multiPageController: multiPageController, + builder: (pageEntry) => _buildContent(pageEntry: pageEntry), + ) + : _buildContent(); + }, + ); + }, + ), + ); + } +} + +class ViewerDetailOverlayContent extends StatelessWidget { final AvesEntry pageEntry; final OverlayMetadata? details; final String? position; @@ -30,7 +124,7 @@ class ViewerDetailOverlay extends StatelessWidget { static const padding = EdgeInsets.symmetric(vertical: 4, horizontal: 8); - const ViewerDetailOverlay({ + const ViewerDetailOverlayContent({ Key? key, required this.pageEntry, required this.details, @@ -46,49 +140,57 @@ class ViewerDetailOverlay extends StatelessWidget { final hasShootingDetails = details != null && !details!.isEmpty && settings.showOverlayShootingDetails; final animationDuration = context.select((v) => v.viewerOverlayChangeAnimation); - return Padding( - padding: padding, - child: Selector( - selector: (context, mq) => mq.orientation, - builder: (context, orientation, child) { - final twoColumns = orientation == Orientation.landscape && infoMaxWidth / 2 > _subRowMinWidth; - final subRowWidth = twoColumns ? min(_subRowMinWidth, infoMaxWidth / 2) : infoMaxWidth; + return DefaultTextStyle( + style: Theme.of(context).textTheme.bodyText2!.copyWith( + shadows: Constants.embossShadows, + ), + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + child: Padding( + padding: padding, + child: Selector( + selector: (context, mq) => mq.orientation, + builder: (context, orientation, child) { + final twoColumns = orientation == Orientation.landscape && infoMaxWidth / 2 > _subRowMinWidth; + final subRowWidth = twoColumns ? min(_subRowMinWidth, infoMaxWidth / 2) : infoMaxWidth; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (positionTitle.isNotEmpty) positionTitle, - _buildSoloLocationRow(animationDuration), - if (twoColumns) - Padding( - padding: const EdgeInsets.only(top: _interRowPadding), - child: Row( - children: [ - SizedBox( - width: subRowWidth, - child: _DateRow( - entry: pageEntry, - multiPageController: multiPageController, - )), - _buildDuoShootingRow(subRowWidth, hasShootingDetails, animationDuration), - ], + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (positionTitle.isNotEmpty) positionTitle, + if (twoColumns) + Padding( + padding: const EdgeInsets.only(top: _interRowPadding), + child: Row( + children: [ + SizedBox( + width: subRowWidth, + child: _DateRow( + entry: pageEntry, + multiPageController: multiPageController, + )), + _buildDuoShootingRow(subRowWidth, hasShootingDetails, animationDuration), + ], + ), + ) + else ...[ + Container( + padding: const EdgeInsets.only(top: _interRowPadding), + width: subRowWidth, + child: _DateRow( + entry: pageEntry, + multiPageController: multiPageController, + ), ), - ) - else ...[ - Container( - padding: const EdgeInsets.only(top: _interRowPadding), - width: subRowWidth, - child: _DateRow( - entry: pageEntry, - multiPageController: multiPageController, - ), - ), - _buildSoloShootingRow(subRowWidth, hasShootingDetails, animationDuration), + _buildSoloShootingRow(subRowWidth, hasShootingDetails, animationDuration), + ], + _buildSoloLocationRow(animationDuration), ], - ], - ); - }, + ); + }, + ), ), ); } diff --git a/lib/widgets/viewer/overlay/bottom/multipage.dart b/lib/widgets/viewer/overlay/multipage.dart similarity index 100% rename from lib/widgets/viewer/overlay/bottom/multipage.dart rename to lib/widgets/viewer/overlay/multipage.dart diff --git a/lib/widgets/viewer/overlay/notifications.dart b/lib/widgets/viewer/overlay/notifications.dart index 8809d111f..f4dc5893d 100644 --- a/lib/widgets/viewer/overlay/notifications.dart +++ b/lib/widgets/viewer/overlay/notifications.dart @@ -1,4 +1,4 @@ -import 'package:aves/model/actions/video_actions.dart'; +import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/widgets/viewer/video/controller.dart'; import 'package:flutter/material.dart'; @@ -17,11 +17,11 @@ class ViewEntryNotification extends Notification { } @immutable -class VideoGestureNotification extends Notification { +class VideoActionNotification extends Notification { final AvesVideoController controller; - final VideoAction action; + final EntryAction action; - const VideoGestureNotification({ + const VideoActionNotification({ required this.controller, required this.action, }); diff --git a/lib/widgets/viewer/overlay/bottom/panorama.dart b/lib/widgets/viewer/overlay/panorama.dart similarity index 100% rename from lib/widgets/viewer/overlay/bottom/panorama.dart rename to lib/widgets/viewer/overlay/panorama.dart diff --git a/lib/widgets/viewer/overlay/bottom/thumbnail_preview.dart b/lib/widgets/viewer/overlay/thumbnail_preview.dart similarity index 100% rename from lib/widgets/viewer/overlay/bottom/thumbnail_preview.dart rename to lib/widgets/viewer/overlay/thumbnail_preview.dart diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index b1ce4e443..fdebc1f4b 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -1,329 +1,106 @@ -import 'package:aves/model/actions/entry_actions.dart'; -import 'package:aves/model/device.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/settings.dart'; -import 'package:aves/theme/durations.dart'; -import 'package:aves/theme/icons.dart'; -import 'package:aves/widgets/common/basic/menu.dart'; -import 'package:aves/widgets/common/basic/popup_menu_button.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/favourite_toggler.dart'; -import 'package:aves/widgets/viewer/action/entry_action_delegate.dart'; +import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; +import 'package:aves/widgets/viewer/multipage/controller.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; +import 'package:aves/widgets/viewer/overlay/details.dart'; import 'package:aves/widgets/viewer/overlay/minimap.dart'; -import 'package:aves/widgets/viewer/overlay/notifications.dart'; import 'package:aves/widgets/viewer/page_entry_builder.dart'; import 'package:aves/widgets/viewer/visual/conductor.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; class ViewerTopOverlay extends StatelessWidget { + final List entries; + final int index; final AvesEntry mainEntry; final Animation scale; final EdgeInsets? viewInsets, viewPadding; - final bool canToggleFavourite; - - static const double outerPadding = 8; - static const double innerPadding = 8; + final bool hasCollection; const ViewerTopOverlay({ Key? key, + required this.entries, + required this.index, required this.mainEntry, required this.scale, - required this.canToggleFavourite, + required this.hasCollection, required this.viewInsets, required this.viewPadding, }) : super(key: key); @override Widget build(BuildContext context) { - return SafeArea( - minimum: (viewInsets ?? EdgeInsets.zero) + (viewPadding ?? EdgeInsets.zero), - child: Padding( - padding: const EdgeInsets.all(outerPadding), - child: Selector( - selector: (context, mq) => mq.size.width - mq.padding.horizontal, - builder: (context, mqWidth, child) { - final buttonWidth = OverlayButton.getSize(context); - final availableCount = ((mqWidth - outerPadding * 2 - buttonWidth) / (buttonWidth + innerPadding)).floor(); + late Widget child; - return mainEntry.isMultiPage - ? PageEntryBuilder( - multiPageController: context.read().getController(mainEntry), - builder: (pageEntry) => _buildOverlay(context, availableCount, mainEntry, pageEntry: pageEntry), - ) - : _buildOverlay(context, availableCount, mainEntry); - }, + if (mainEntry.isMultiPage) { + final multiPageController = context.read().getController(mainEntry); + child = PageEntryBuilder( + multiPageController: multiPageController, + builder: (pageEntry) => _buildOverlay( + context, + mainEntry, + pageEntry: pageEntry, + multiPageController: multiPageController, ), - ), - ); - } - - Widget _buildOverlay(BuildContext context, int availableCount, AvesEntry mainEntry, {AvesEntry? pageEntry}) { - pageEntry ??= mainEntry; - final trashed = mainEntry.trashed; - - bool _isVisible(EntryAction action) { - if (trashed) { - switch (action) { - case EntryAction.delete: - case EntryAction.restore: - return true; - case EntryAction.debug: - return kDebugMode; - default: - return false; - } - } else { - final targetEntry = EntryActions.pageActions.contains(action) ? pageEntry! : mainEntry; - switch (action) { - case EntryAction.toggleFavourite: - return canToggleFavourite; - case EntryAction.delete: - case EntryAction.rename: - case EntryAction.copy: - case EntryAction.move: - return targetEntry.canEdit; - case EntryAction.rotateCCW: - case EntryAction.rotateCW: - case EntryAction.flip: - return targetEntry.canRotateAndFlip; - case EntryAction.convert: - case EntryAction.print: - return !targetEntry.isVideo && device.canPrint; - case EntryAction.openMap: - return targetEntry.hasGps; - case EntryAction.viewSource: - return targetEntry.isSvg; - case EntryAction.rotateScreen: - return settings.isRotationLocked; - case EntryAction.addShortcut: - return device.canPinShortcut; - case EntryAction.copyToClipboard: - case EntryAction.edit: - case EntryAction.open: - case EntryAction.setAs: - case EntryAction.share: - return true; - case EntryAction.restore: - return false; - case EntryAction.debug: - return kDebugMode; - } - } - } - - final buttonRow = Selector( - selector: (context, s) => s.isRotationLocked, - builder: (context, s, child) { - final quickActions = (trashed ? EntryActions.trashed : settings.viewerQuickActions).where(_isVisible).take(availableCount - 1).toList(); - final topLevelActions = EntryActions.topLevel.where((action) => !quickActions.contains(action)).where(_isVisible).toList(); - final exportActions = EntryActions.export.where((action) => !quickActions.contains(action)).where(_isVisible).toList(); - return _TopOverlayRow( - quickActions: quickActions, - topLevelActions: topLevelActions, - exportActions: exportActions, - scale: scale, - mainEntry: mainEntry, - pageEntry: pageEntry!, - ); - }, - ); - - if (settings.showOverlayMinimap) { - final viewStateConductor = context.read(); - final viewStateNotifier = viewStateConductor.getOrCreateController(pageEntry); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - buttonRow, - const SizedBox(height: 8), - FadeTransition( - opacity: scale, - child: Minimap( - viewStateNotifier: viewStateNotifier, - ), - ) - ], ); + } else { + child = _buildOverlay(context, mainEntry); } - return buttonRow; + + return child; } -} -class _TopOverlayRow extends StatelessWidget { - final List quickActions, topLevelActions, exportActions; - final Animation scale; - final AvesEntry mainEntry, pageEntry; + Widget _buildOverlay( + BuildContext context, + AvesEntry mainEntry, { + AvesEntry? pageEntry, + MultiPageController? multiPageController, + }) { + pageEntry ??= mainEntry; - AvesEntry get favouriteTargetEntry => mainEntry.isBurst ? pageEntry : mainEntry; + final showInfo = settings.showOverlayInfo; - const _TopOverlayRow({ - Key? key, - required this.quickActions, - required this.topLevelActions, - required this.exportActions, - required this.scale, - required this.mainEntry, - required this.pageEntry, - }) : super(key: key); + final viewStateConductor = context.read(); + final viewStateNotifier = viewStateConductor.getOrCreateController(pageEntry); - @override - Widget build(BuildContext context) { - final hasOverflowMenu = pageEntry.canRotateAndFlip || topLevelActions.isNotEmpty || exportActions.isNotEmpty; - return Row( + final blurred = settings.enableOverlayBlurEffect; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - OverlayButton( - scale: scale, - child: Navigator.canPop(context) ? const BackButton() : const CloseButton(), - ), - const Spacer(), - ...quickActions.map((action) => _buildOverlayButton(context, action)), - if (hasOverflowMenu) - OverlayButton( - scale: scale, - child: MenuIconTheme( - child: AvesPopupMenuButton( - key: const Key('entry-menu-button'), - itemBuilder: (context) { - final exportInternalActions = exportActions.whereNot(EntryActions.exportExternal.contains).toList(); - final exportExternalActions = exportActions.where(EntryActions.exportExternal.contains).toList(); - return [ - if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context), - ...topLevelActions.map((action) => _buildPopupMenuItem(context, action)), - if (exportActions.isNotEmpty) - PopupMenuItem( - padding: EdgeInsets.zero, - child: PopupMenuItemExpansionPanel( - icon: AIcons.export, - title: context.l10n.entryActionExport, - items: [ - ...exportInternalActions.map((action) => _buildPopupMenuItem(context, action)).toList(), - if (exportInternalActions.isNotEmpty && exportExternalActions.isNotEmpty) const PopupMenuDivider(height: 0), - ...exportExternalActions.map((action) => _buildPopupMenuItem(context, action)).toList(), - ], - ), - ), - if (!kReleaseMode) ...[ - const PopupMenuDivider(), - _buildPopupMenuItem(context, EntryAction.debug), - ] - ]; - }, - onSelected: (action) { - // wait for the popup menu to hide before proceeding with the action - Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(context, action)); - }, - onMenuOpened: () { - // if the menu is opened while overlay is hiding, - // the popup menu button is disposed and menu items are ineffective, - // so we make sure overlay stays visible - const ToggleOverlayNotification(visible: true).dispatch(context); - }, + if (showInfo) + BlurredRect( + enabled: blurred, + child: Container( + color: overlayBackgroundColor(blurred: blurred), + child: SafeArea( + minimum: EdgeInsets.only(top: (viewInsets?.top ?? 0) + (viewPadding?.top ?? 0)), + bottom: false, + child: ViewerDetailOverlay( + index: index, + entries: entries, + hasCollection: hasCollection, + multiPageController: multiPageController, + ), ), ), ), + if (settings.showOverlayMinimap) + SafeArea( + top: !showInfo, + child: Padding( + padding: const EdgeInsets.all(8), + child: FadeTransition( + opacity: scale, + child: Minimap( + viewStateNotifier: viewStateNotifier, + ), + ), + ), + ) ], ); } - - Widget _buildOverlayButton(BuildContext context, EntryAction action) { - Widget? child; - void onPressed() => _onActionSelected(context, action); - switch (action) { - case EntryAction.toggleFavourite: - child = FavouriteToggler( - entries: {favouriteTargetEntry}, - onPressed: onPressed, - ); - break; - default: - child = IconButton( - icon: action.getIcon(), - onPressed: onPressed, - tooltip: action.getText(context), - ); - break; - } - return Padding( - padding: const EdgeInsetsDirectional.only(end: ViewerTopOverlay.innerPadding), - child: OverlayButton( - scale: scale, - child: child, - ), - ); - } - - PopupMenuItem _buildPopupMenuItem(BuildContext context, EntryAction action) { - Widget? child; - switch (action) { - // in app actions - case EntryAction.toggleFavourite: - child = FavouriteToggler( - entries: {favouriteTargetEntry}, - isMenuItem: true, - ); - break; - default: - child = MenuRow(text: action.getText(context), icon: action.getIcon()); - break; - } - return PopupMenuItem( - value: action, - child: child, - ); - } - - PopupMenuItem _buildRotateAndFlipMenuItems(BuildContext context) { - Widget buildDivider() => const SizedBox( - height: 16, - child: VerticalDivider( - width: 1, - thickness: 1, - ), - ); - - Widget buildItem(EntryAction action) => Expanded( - child: PopupMenuItem( - value: action, - child: Tooltip( - message: action.getText(context), - child: Center(child: action.getIcon()), - ), - ), - ); - - return PopupMenuItem( - child: Row( - children: [ - buildDivider(), - buildItem(EntryAction.rotateCCW), - buildDivider(), - buildItem(EntryAction.rotateCW), - buildDivider(), - buildItem(EntryAction.flip), - buildDivider(), - ], - ), - ); - } - - void _onActionSelected(BuildContext context, EntryAction action) { - var targetEntry = mainEntry; - if (mainEntry.isMultiPage && (mainEntry.isBurst || EntryActions.pageActions.contains(action))) { - final multiPageController = context.read().getController(mainEntry); - if (multiPageController != null) { - final multiPageInfo = multiPageController.info; - final pageEntry = multiPageInfo?.getPageEntryByIndex(multiPageController.page); - if (pageEntry != null) { - targetEntry = pageEntry; - } - } - } - EntryActionDelegate(targetEntry).onActionSelected(context, action); - } } diff --git a/lib/widgets/viewer/overlay/bottom/video/controls.dart b/lib/widgets/viewer/overlay/video/controls.dart similarity index 89% rename from lib/widgets/viewer/overlay/bottom/video/controls.dart rename to lib/widgets/viewer/overlay/video/controls.dart index 941d5e437..a2e49a528 100644 --- a/lib/widgets/viewer/overlay/bottom/video/controls.dart +++ b/lib/widgets/viewer/overlay/video/controls.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:aves/model/actions/video_actions.dart'; +import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; @@ -15,7 +15,7 @@ import 'package:provider/provider.dart'; class VideoControlRow extends StatelessWidget { final AvesVideoController? controller; final Animation scale; - final Function(VideoAction value) onActionSelected; + final Function(EntryAction value) onActionSelected; static const double padding = 8; static const Radius radius = Radius.circular(123); @@ -41,7 +41,7 @@ class VideoControlRow extends StatelessWidget { child: _buildOverlayButton( child: PlayToggler( controller: controller, - onPressed: () => onActionSelected(VideoAction.togglePlay), + onPressed: () => onActionSelected(EntryAction.videoTogglePlay), ), ), ); @@ -52,30 +52,28 @@ class VideoControlRow extends StatelessWidget { const SizedBox(width: padding), _buildIconButton( context, - VideoAction.replay10, + EntryAction.videoReplay10, borderRadius: const BorderRadius.only(topLeft: radius, bottomLeft: radius), ), _buildOverlayButton( child: PlayToggler( controller: controller, - onPressed: () => onActionSelected(VideoAction.togglePlay), + onPressed: () => onActionSelected(EntryAction.videoTogglePlay), ), borderRadius: const BorderRadius.all(Radius.zero), ), _buildIconButton( context, - VideoAction.skip10, + EntryAction.videoSkip10, borderRadius: const BorderRadius.only(topRight: radius, bottomRight: radius), ), ], ); case VideoControls.playOutside: + final trashed = controller?.entry.trashed ?? false; return Padding( padding: const EdgeInsetsDirectional.only(start: padding), - child: _buildIconButton( - context, - VideoAction.playOutside, - ), + child: _buildIconButton(context, EntryAction.open, enabled: !trashed), ); } }, @@ -94,14 +92,15 @@ class VideoControlRow extends StatelessWidget { Widget _buildIconButton( BuildContext context, - VideoAction action, { + EntryAction action, { + bool enabled = true, BorderRadius? borderRadius, }) => _buildOverlayButton( borderRadius: borderRadius, child: IconButton( icon: action.getIcon(), - onPressed: () => onActionSelected(action), + onPressed: enabled ? () => onActionSelected(action) : null, tooltip: action.getText(context), ), ); diff --git a/lib/widgets/viewer/overlay/bottom/video/progress_bar.dart b/lib/widgets/viewer/overlay/video/progress_bar.dart similarity index 100% rename from lib/widgets/viewer/overlay/bottom/video/progress_bar.dart rename to lib/widgets/viewer/overlay/video/progress_bar.dart diff --git a/lib/widgets/viewer/overlay/video/video.dart b/lib/widgets/viewer/overlay/video/video.dart new file mode 100644 index 000000000..4243cb05c --- /dev/null +++ b/lib/widgets/viewer/overlay/video/video.dart @@ -0,0 +1,81 @@ +import 'dart:async'; + +import 'package:aves/model/actions/entry_actions.dart'; +import 'package:aves/model/entry.dart'; +import 'package:aves/widgets/viewer/overlay/common.dart'; +import 'package:aves/widgets/viewer/overlay/video/controls.dart'; +import 'package:aves/widgets/viewer/overlay/video/progress_bar.dart'; +import 'package:aves/widgets/viewer/video/controller.dart'; +import 'package:flutter/material.dart'; + +class VideoControlOverlay extends StatefulWidget { + final AvesEntry entry; + final AvesVideoController? controller; + final Animation scale; + final Function(EntryAction value) onActionSelected; + final VoidCallback onActionMenuOpened; + + const VideoControlOverlay({ + Key? key, + required this.entry, + required this.controller, + required this.scale, + required this.onActionSelected, + required this.onActionMenuOpened, + }) : super(key: key); + + @override + State createState() => _VideoControlOverlayState(); +} + +class _VideoControlOverlayState extends State with SingleTickerProviderStateMixin { + AvesEntry get entry => widget.entry; + + Animation get scale => widget.scale; + + AvesVideoController? get controller => widget.controller; + + Stream get statusStream => controller?.statusStream ?? Stream.value(VideoStatus.idle); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: statusStream, + builder: (context, snapshot) { + // do not use stream snapshot because it is obsolete when switching between videos + final status = controller?.status ?? VideoStatus.idle; + + if (status == VideoStatus.error) { + const action = EntryAction.open; + return Align( + alignment: AlignmentDirectional.centerEnd, + child: OverlayButton( + scale: scale, + child: IconButton( + icon: action.getIcon(), + onPressed: entry.trashed ? null : () => widget.onActionSelected(action), + tooltip: action.getText(context), + ), + ), + ); + } + + return Row( + children: [ + Expanded( + child: VideoProgressBar( + controller: controller, + scale: scale, + ), + ), + VideoControlRow( + controller: controller, + scale: scale, + onActionSelected: widget.onActionSelected, + ), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/viewer/overlay/viewer_button_row.dart b/lib/widgets/viewer/overlay/viewer_button_row.dart new file mode 100644 index 000000000..02d2cabc8 --- /dev/null +++ b/lib/widgets/viewer/overlay/viewer_button_row.dart @@ -0,0 +1,377 @@ +import 'package:aves/model/actions/entry_actions.dart'; +import 'package:aves/model/device.dart'; +import 'package:aves/model/entry.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/basic/menu.dart'; +import 'package:aves/widgets/common/basic/popup_menu_button.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/favourite_toggler.dart'; +import 'package:aves/widgets/viewer/action/entry_action_delegate.dart'; +import 'package:aves/widgets/viewer/multipage/conductor.dart'; +import 'package:aves/widgets/viewer/overlay/common.dart'; +import 'package:aves/widgets/viewer/overlay/notifications.dart'; +import 'package:aves/widgets/viewer/overlay/video/controls.dart'; +import 'package:aves/widgets/viewer/video/conductor.dart'; +import 'package:aves/widgets/viewer/video/controller.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; + +class ViewerButtonRow extends StatelessWidget { + final AvesEntry mainEntry; + final AvesEntry pageEntry; + final Animation scale; + final bool canToggleFavourite; + + static const double outerPadding = 8; + static const double innerPadding = 8; + + const ViewerButtonRow({ + Key? key, + required this.mainEntry, + required this.pageEntry, + required this.scale, + required this.canToggleFavourite, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final trashed = mainEntry.trashed; + + bool _isVisible(EntryAction action) { + if (trashed) { + switch (action) { + case EntryAction.delete: + case EntryAction.restore: + return true; + case EntryAction.debug: + return kDebugMode; + default: + return false; + } + } else { + final targetEntry = EntryActions.pageActions.contains(action) ? pageEntry : mainEntry; + switch (action) { + case EntryAction.toggleFavourite: + return canToggleFavourite; + case EntryAction.delete: + case EntryAction.rename: + case EntryAction.copy: + case EntryAction.move: + return targetEntry.canEdit; + case EntryAction.rotateCCW: + case EntryAction.rotateCW: + case EntryAction.flip: + return targetEntry.canRotateAndFlip; + case EntryAction.convert: + case EntryAction.print: + return !targetEntry.isVideo && device.canPrint; + case EntryAction.openMap: + return targetEntry.hasGps; + case EntryAction.viewSource: + return targetEntry.isSvg; + case EntryAction.videoCaptureFrame: + case EntryAction.videoSelectStreams: + case EntryAction.videoSetSpeed: + case EntryAction.videoSettings: + case EntryAction.videoTogglePlay: + case EntryAction.videoReplay10: + case EntryAction.videoSkip10: + return targetEntry.isVideo; + case EntryAction.rotateScreen: + return settings.isRotationLocked; + case EntryAction.addShortcut: + return device.canPinShortcut; + case EntryAction.copyToClipboard: + case EntryAction.edit: + case EntryAction.open: + case EntryAction.setAs: + case EntryAction.share: + return true; + case EntryAction.restore: + return false; + case EntryAction.debug: + return kDebugMode; + } + } + } + + return SafeArea( + top: false, + bottom: false, + child: Selector( + selector: (context, mq) => mq.size.width - mq.padding.horizontal, + builder: (context, mqWidth, child) { + final buttonWidth = OverlayButton.getSize(context); + final availableCount = ((mqWidth - outerPadding * 2) / (buttonWidth + innerPadding)).floor(); + return Selector( + selector: (context, s) => s.isRotationLocked, + builder: (context, s, child) { + final quickActions = (trashed ? EntryActions.trashed : settings.viewerQuickActions).where(_isVisible).take(availableCount - 1).toList(); + final topLevelActions = EntryActions.topLevel.where((action) => !quickActions.contains(action)).where(_isVisible).toList(); + final exportActions = EntryActions.export.where((action) => !quickActions.contains(action)).where(_isVisible).toList(); + final videoActions = EntryActions.video.where((action) => !quickActions.contains(action)).where(_isVisible).toList(); + return ViewerButtonRowContent( + quickActions: quickActions, + topLevelActions: topLevelActions, + exportActions: exportActions, + videoActions: videoActions, + scale: scale, + mainEntry: mainEntry, + pageEntry: pageEntry, + ); + }, + ); + }, + ), + ); + } +} + +class ViewerButtonRowContent extends StatelessWidget { + final List quickActions, topLevelActions, exportActions, videoActions; + final Animation scale; + final AvesEntry mainEntry, pageEntry; + + AvesEntry get favouriteTargetEntry => mainEntry.isBurst ? pageEntry : mainEntry; + + static const double padding = 8; + + const ViewerButtonRowContent({ + Key? key, + required this.quickActions, + required this.topLevelActions, + required this.exportActions, + required this.videoActions, + required this.scale, + required this.mainEntry, + required this.pageEntry, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final hasOverflowMenu = pageEntry.canRotateAndFlip || topLevelActions.isNotEmpty || exportActions.isNotEmpty || videoActions.isNotEmpty; + return Selector( + selector: (context, vc) => vc.getController(pageEntry), + builder: (context, videoController, child) { + return Padding( + padding: const EdgeInsets.only(left: padding / 2, right: padding / 2, bottom: padding), + child: Row( + children: [ + const Spacer(), + ...quickActions.map((action) => _buildOverlayButton(context, action, videoController)), + if (hasOverflowMenu) + Padding( + padding: const EdgeInsets.symmetric(horizontal: padding / 2), + child: OverlayButton( + scale: scale, + child: MenuIconTheme( + child: AvesPopupMenuButton( + key: const Key('entry-menu-button'), + itemBuilder: (context) { + final exportInternalActions = exportActions.whereNot(EntryActions.exportExternal.contains).toList(); + final exportExternalActions = exportActions.where(EntryActions.exportExternal.contains).toList(); + return [ + if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context), + ...topLevelActions.map((action) => _buildPopupMenuItem(context, action, videoController)), + if (exportActions.isNotEmpty) + PopupMenuItem( + padding: EdgeInsets.zero, + child: PopupMenuItemExpansionPanel( + icon: AIcons.export, + title: context.l10n.entryActionExport, + items: [ + ...exportInternalActions.map((action) => _buildPopupMenuItem(context, action, videoController)).toList(), + if (exportInternalActions.isNotEmpty && exportExternalActions.isNotEmpty) const PopupMenuDivider(height: 0), + ...exportExternalActions.map((action) => _buildPopupMenuItem(context, action, videoController)).toList(), + ], + ), + ), + if (videoActions.isNotEmpty) + PopupMenuItem( + padding: EdgeInsets.zero, + child: PopupMenuItemExpansionPanel( + icon: AIcons.video, + title: context.l10n.settingsSectionVideo, + items: [ + ...videoActions.map((action) => _buildPopupMenuItem(context, action, videoController)).toList(), + ], + ), + ), + if (!kReleaseMode) ...[ + const PopupMenuDivider(), + _buildPopupMenuItem(context, EntryAction.debug, videoController), + ] + ]; + }, + onSelected: (action) { + // wait for the popup menu to hide before proceeding with the action + Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(context, action)); + }, + onMenuOpened: () { + // if the menu is opened while overlay is hiding, + // the popup menu button is disposed and menu items are ineffective, + // so we make sure overlay stays visible + const ToggleOverlayNotification(visible: true).dispatch(context); + }, + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildOverlayButton(BuildContext context, EntryAction action, AvesVideoController? videoController) { + Widget? child; + void onPressed() => _onActionSelected(context, action); + + ValueListenableBuilder _buildFromListenable(ValueListenable? enabledNotifier) { + return ValueListenableBuilder( + valueListenable: enabledNotifier ?? ValueNotifier(false), + builder: (context, canDo, child) => IconButton( + icon: child!, + onPressed: canDo ? onPressed : null, + tooltip: action.getText(context), + ), + child: action.getIcon(), + ); + } + + switch (action) { + case EntryAction.toggleFavourite: + child = FavouriteToggler( + entries: {favouriteTargetEntry}, + onPressed: onPressed, + ); + break; + case EntryAction.videoTogglePlay: + child = PlayToggler( + controller: videoController, + onPressed: onPressed, + ); + break; + case EntryAction.videoCaptureFrame: + child = _buildFromListenable(videoController?.canCaptureFrameNotifier); + break; + case EntryAction.videoSelectStreams: + child = _buildFromListenable(videoController?.canSelectStreamNotifier); + break; + case EntryAction.videoSetSpeed: + child = _buildFromListenable(videoController?.canSetSpeedNotifier); + break; + default: + child = IconButton( + icon: action.getIcon(), + onPressed: onPressed, + tooltip: action.getText(context), + ); + break; + } + return Padding( + padding: const EdgeInsets.symmetric(horizontal: padding / 2), + child: OverlayButton( + scale: scale, + child: child, + ), + ); + } + + PopupMenuItem _buildPopupMenuItem(BuildContext context, EntryAction action, AvesVideoController? videoController) { + late final bool enabled; + switch (action) { + case EntryAction.videoCaptureFrame: + enabled = videoController?.canCaptureFrameNotifier.value ?? false; + break; + case EntryAction.videoSelectStreams: + enabled = videoController?.canSelectStreamNotifier.value ?? false; + break; + case EntryAction.videoSetSpeed: + enabled = videoController?.canSetSpeedNotifier.value ?? false; + break; + default: + enabled = true; + break; + } + + Widget? child; + switch (action) { + case EntryAction.toggleFavourite: + child = FavouriteToggler( + entries: {favouriteTargetEntry}, + isMenuItem: true, + ); + break; + case EntryAction.videoTogglePlay: + child = PlayToggler( + controller: videoController, + isMenuItem: true, + ); + break; + default: + child = MenuRow(text: action.getText(context), icon: action.getIcon()); + break; + } + return PopupMenuItem( + value: action, + enabled: enabled, + child: child, + ); + } + + PopupMenuItem _buildRotateAndFlipMenuItems(BuildContext context) { + Widget buildDivider() => const SizedBox( + height: 16, + child: VerticalDivider( + width: 1, + thickness: 1, + ), + ); + + Widget buildItem(EntryAction action) => Expanded( + child: PopupMenuItem( + value: action, + child: Tooltip( + message: action.getText(context), + child: Center(child: action.getIcon()), + ), + ), + ); + + return PopupMenuItem( + child: Row( + children: [ + buildDivider(), + buildItem(EntryAction.rotateCCW), + buildDivider(), + buildItem(EntryAction.rotateCW), + buildDivider(), + buildItem(EntryAction.flip), + buildDivider(), + ], + ), + ); + } + + void _onActionSelected(BuildContext context, EntryAction action) { + var targetEntry = mainEntry; + if (mainEntry.isMultiPage && (mainEntry.isBurst || EntryActions.pageActions.contains(action))) { + final multiPageController = context.read().getController(mainEntry); + if (multiPageController != null) { + final multiPageInfo = multiPageController.info; + final pageEntry = multiPageInfo?.getPageEntryByIndex(multiPageController.page); + if (pageEntry != null) { + targetEntry = pageEntry; + } + } + } + EntryActionDelegate(targetEntry).onActionSelected(context, action); + } +} diff --git a/lib/widgets/viewer/video_action_delegate.dart b/lib/widgets/viewer/video_action_delegate.dart index e9ab31024..d413821e7 100644 --- a/lib/widgets/viewer/video_action_delegate.dart +++ b/lib/widgets/viewer/video_action_delegate.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:aves/model/actions/video_actions.dart'; +import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/source/collection_lens.dart'; @@ -13,6 +13,7 @@ import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/video_speed_dialog.dart'; import 'package:aves/widgets/dialogs/video_stream_selection_dialog.dart'; import 'package:aves/widgets/settings/video/video.dart'; @@ -34,37 +35,41 @@ class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix stopOverlayHidingTimer(); } - void onActionSelected(BuildContext context, AvesVideoController controller, VideoAction action) { + void onActionSelected(BuildContext context, AvesVideoController controller, EntryAction action) { // make sure overlay is not disappearing when selecting an action stopOverlayHidingTimer(); const ToggleOverlayNotification(visible: true).dispatch(context); switch (action) { - case VideoAction.captureFrame: + case EntryAction.videoCaptureFrame: _captureFrame(context, controller); break; - case VideoAction.playOutside: - final entry = controller.entry; - androidAppService.open(entry.uri, entry.mimeTypeAnySubtype); - break; - case VideoAction.replay10: - if (controller.isReady) controller.seekTo(controller.currentPosition - 10000); - break; - case VideoAction.skip10: - if (controller.isReady) controller.seekTo(controller.currentPosition + 10000); - break; - case VideoAction.selectStreams: + case EntryAction.videoSelectStreams: _showStreamSelectionDialog(context, controller); break; - case VideoAction.setSpeed: + case EntryAction.videoSetSpeed: _showSpeedDialog(context, controller); break; - case VideoAction.settings: + case EntryAction.videoSettings: _showSettings(context, controller); break; - case VideoAction.togglePlay: + case EntryAction.videoTogglePlay: _togglePlayPause(context, controller); break; + case EntryAction.videoReplay10: + if (controller.isReady) controller.seekTo(controller.currentPosition - 10000); + break; + case EntryAction.videoSkip10: + if (controller.isReady) controller.seekTo(controller.currentPosition + 10000); + break; + case EntryAction.open: + final entry = controller.entry; + androidAppService.open(entry.uri, entry.mimeTypeAnySubtype).then((success) { + if (!success) showNoMatchingAppDialog(context); + }); + break; + default: + throw UnsupportedError('$action is not a video action'); } } diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index 36fc98ac3..cf4dc87a9 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:aves/model/actions/video_actions.dart'; +import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_images.dart'; import 'package:aves/model/settings/enums/accessibility_animations.dart'; @@ -194,7 +194,7 @@ class _EntryPageViewState extends State { if (videoController == null) return const SizedBox(); Positioned _buildDoubleTapDetector( - VideoAction action, { + EntryAction action, { double widthFactor = 1, AlignmentGeometry alignment = Alignment.center, IconData? Function()? icon, @@ -215,7 +215,7 @@ class _EntryPageViewState extends State { ) ], ); - VideoGestureNotification( + VideoActionNotification( controller: videoController, action: action, ).dispatch(context); @@ -256,12 +256,12 @@ class _EntryPageViewState extends State { ), if (playGesture) _buildDoubleTapDetector( - VideoAction.togglePlay, + EntryAction.videoTogglePlay, icon: () => videoController.isPlaying ? AIcons.pause : AIcons.play, ), if (seekGesture) ...[ - _buildDoubleTapDetector(VideoAction.replay10, widthFactor: .25, alignment: Alignment.centerLeft), - _buildDoubleTapDetector(VideoAction.skip10, widthFactor: .25, alignment: Alignment.centerRight), + _buildDoubleTapDetector(EntryAction.videoReplay10, widthFactor: .25, alignment: Alignment.centerLeft), + _buildDoubleTapDetector(EntryAction.videoSkip10, widthFactor: .25, alignment: Alignment.centerRight), ], if (useActionGesture) ValueListenableBuilder(