#194 viewer: overlay review

This commit is contained in:
Thibault Deckers 2022-03-04 11:35:16 +09:00
parent ff9c652636
commit fe7c2d61f9
34 changed files with 1048 additions and 1084 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -440,8 +440,6 @@
"settingsVideoEnableAutoPlay": "자동 재생",
"settingsVideoLoopModeTile": "반복 모드",
"settingsVideoLoopModeTitle": "반복 모드",
"settingsVideoQuickActionsTile": "동영상의 빠른 작업",
"settingsVideoQuickActionEditorTitle": "빠른 작업",
"settingsSubtitleThemeTile": "자막",
"settingsSubtitleThemeTitle": "자막",

View file

@ -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",

View file

@ -439,8 +439,6 @@
"settingsVideoEnableAutoPlay": "Автозапуск воспроизведения",
"settingsVideoLoopModeTile": "Циклический режим",
"settingsVideoLoopModeTitle": "Цикличный режим",
"settingsVideoQuickActionsTile": "Быстрые действия для видео",
"settingsVideoQuickActionEditorTitle": "Быстрые действия",
"settingsSubtitleThemeTile": "Субтитры",
"settingsSubtitleThemeTitle": "Субтитры",

View file

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

View file

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

View file

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

View file

@ -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<VideoAction> get videoQuickActions => getEnumListOrDefault(videoQuickActionsKey, SettingsDefaults.videoQuickActions, VideoAction.values);
set videoQuickActions(List<VideoAction> 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<String>());
} else {

View file

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

View file

@ -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<Settings, bool>(
selector: (context, s) => s.enableVideoHardwareAcceleration,
builder: (context, current, child) => SwitchListTile(

View file

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

View file

@ -46,14 +46,6 @@ class ViewerOverlayPage extends StatelessWidget {
title: Text(context.l10n.settingsViewerShowOverlayOnOpening),
),
),
Selector<Settings, bool>(
selector: (context, s) => s.showOverlayMinimap,
builder: (context, current, child) => SwitchListTile(
value: current,
onChanged: (v) => settings.showOverlayMinimap = v,
title: Text(context.l10n.settingsViewerShowMinimap),
),
),
Selector<Settings, bool>(
selector: (context, s) => s.showOverlayInfo,
builder: (context, current, child) => SwitchListTile(
@ -75,6 +67,14 @@ class ViewerOverlayPage extends StatelessWidget {
);
},
),
Selector<Settings, bool>(
selector: (context, s) => s.showOverlayMinimap,
builder: (context, current, child) => SwitchListTile(
value: current,
onChanged: (v) => settings.showOverlayMinimap = v,
title: Text(context.l10n.settingsViewerShowMinimap),
),
),
Selector<Settings, bool>(
selector: (context, s) => s.showOverlayThumbnailPreview,
builder: (context, current, child) => SwitchListTile(

View file

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

View file

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

View file

@ -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<EntryViewerStack> with FeedbackMixin,
final AChangeNotifier _verticalScrollNotifier = AChangeNotifier();
final ValueNotifier<bool> _overlayVisible = ValueNotifier(true);
late AnimationController _overlayAnimationController;
late Animation<double> _topOverlayScale, _bottomOverlayScale;
late Animation<Offset> _bottomOverlayOffset;
late Animation<double> _overlayButtonScale, _overlayVideoControlScale;
late Animation<Offset> _overlayTopOffset;
EdgeInsets? _frozenViewInsets, _frozenViewPadding;
late VideoActionDelegate _videoActionDelegate;
final Map<MultiPageController, Future<void> Function()> _multiPageControllerPageListeners = {};
@ -110,17 +109,17 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
duration: context.read<DurationsData>().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<EntryViewerStack> 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<EntryViewerStack> with FeedbackMixin,
Widget child = ValueListenableBuilder<AvesEntry?>(
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<MultiPageConductor>().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<EntryViewerStack> with FeedbackMixin,
Widget child = ValueListenableBuilder<AvesEntry?>(
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<MultiPageConductor>().getController(mainEntry) : null;
Widget? _buildExtraBottomOverlay({AvesEntry? pageEntry}) {
@ -311,7 +304,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> 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<EntryViewerStack> 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<EntryViewerStack> 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,
),
),
],
],
),
);
},
);

View file

@ -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<AvesEntry> 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<StatefulWidget> createState() => _ViewerBottomOverlayState();
}
class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
List<AvesEntry> 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<MediaQueryData, double>(
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<AvesEntry> 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<double> _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<MediaQueryData, double>(
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<MediaQueryData, Tuple3<double, EdgeInsets, EdgeInsets>>((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,
),
);
}
}

View file

@ -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<AvesEntry> 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<StatefulWidget> createState() => _ViewerBottomOverlayState();
}
class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
late Future<OverlayMetadata?> _detailLoader;
AvesEntry? _lastEntry;
OverlayMetadata? _lastDetails;
List<AvesEntry> 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<MediaQueryData, double>(
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<OverlayMetadata?>(
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<AvesEntry> 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<MediaQueryData, double>(
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<MediaQueryData, Tuple3<double, EdgeInsets, EdgeInsets>>((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,
),
);
}
}

View file

@ -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<double> 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<StatefulWidget> createState() => _VideoControlOverlayState();
}
class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTickerProviderStateMixin {
AvesEntry get entry => widget.entry;
Animation<double> get scale => widget.scale;
AvesVideoController? get controller => widget.controller;
Stream<VideoStatus> get statusStream => controller?.statusStream ?? Stream.value(VideoStatus.idle);
Stream<int> 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<VideoStatus>(
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<MediaQueryData, double>(
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<Settings, List<VideoAction>>(
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<VideoAction> quickActions, menuActions;
final Animation<double> 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<VideoAction>(
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<bool> _buildFromListenable(ValueListenable<bool>? enabledNotifier) {
return ValueListenableBuilder<bool>(
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<VideoAction> _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,
);
}
}

View file

@ -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<AvesEntry> 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<ViewerDetailOverlay> createState() => _ViewerDetailOverlayState();
}
class _ViewerDetailOverlayState extends State<ViewerDetailOverlay> {
List<AvesEntry> get entries => widget.entries;
AvesEntry? get entry {
final index = widget.index;
return index < entries.length ? entries[index] : null;
}
late Future<OverlayMetadata?> _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<OverlayMetadata?>(
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<DurationsData, Duration>((v) => v.viewerOverlayChangeAnimation);
return Padding(
padding: padding,
child: Selector<MediaQueryData, Orientation>(
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<MediaQueryData, Orientation>(
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),
],
],
);
},
);
},
),
),
);
}

View file

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

View file

@ -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<AvesEntry> entries;
final int index;
final AvesEntry mainEntry;
final Animation<double> 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<MediaQueryData, double>(
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<MultiPageConductor>().getController(mainEntry),
builder: (pageEntry) => _buildOverlay(context, availableCount, mainEntry, pageEntry: pageEntry),
)
: _buildOverlay(context, availableCount, mainEntry);
},
if (mainEntry.isMultiPage) {
final multiPageController = context.read<MultiPageConductor>().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<Settings, bool>(
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<ViewStateConductor>();
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<EntryAction> quickActions, topLevelActions, exportActions;
final Animation<double> 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<ViewStateConductor>();
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<EntryAction>(
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<EntryAction>(
padding: EdgeInsets.zero,
child: PopupMenuItemExpansionPanel<EntryAction>(
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<EntryAction> _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<EntryAction> _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<MultiPageConductor>().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);
}
}

View file

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

View file

@ -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<double> 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<StatefulWidget> createState() => _VideoControlOverlayState();
}
class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTickerProviderStateMixin {
AvesEntry get entry => widget.entry;
Animation<double> get scale => widget.scale;
AvesVideoController? get controller => widget.controller;
Stream<VideoStatus> get statusStream => controller?.statusStream ?? Stream.value(VideoStatus.idle);
@override
Widget build(BuildContext context) {
return StreamBuilder<VideoStatus>(
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,
),
],
);
},
);
}
}

View file

@ -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<double> 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<MediaQueryData, double>(
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<Settings, bool>(
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<EntryAction> quickActions, topLevelActions, exportActions, videoActions;
final Animation<double> 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<VideoConductor, AvesVideoController?>(
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<EntryAction>(
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<EntryAction>(
padding: EdgeInsets.zero,
child: PopupMenuItemExpansionPanel<EntryAction>(
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<EntryAction>(
padding: EdgeInsets.zero,
child: PopupMenuItemExpansionPanel<EntryAction>(
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<bool> _buildFromListenable(ValueListenable<bool>? enabledNotifier) {
return ValueListenableBuilder<bool>(
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<EntryAction> _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<EntryAction> _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<MultiPageConductor>().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);
}
}

View file

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

View file

@ -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<EntryPageView> {
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<EntryPageView> {
)
],
);
VideoGestureNotification(
VideoActionNotification(
controller: videoController,
action: action,
).dispatch(context);
@ -256,12 +256,12 @@ class _EntryPageViewState extends State<EntryPageView> {
),
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<Widget?>(