diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ccb5852b2..eef9da963 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -92,8 +92,8 @@ "entryActionSetAs": "Set as", "entryActionOpenMap": "Show in map app", "entryActionRotateScreen": "Rotate screen", - "entryActionAddFavourite": "Add to favourites", - "entryActionRemoveFavourite": "Remove from favourites", + "entryActionAddFavourite": "Add to favorites", + "entryActionRemoveFavourite": "Remove from favorites", "videoActionCaptureFrame": "Capture frame", "videoActionPause": "Pause", @@ -111,7 +111,7 @@ "entryInfoActionRemoveMetadata": "Remove metadata", "filterBinLabel": "Recycle bin", - "filterFavouriteLabel": "Favourite", + "filterFavouriteLabel": "Favorite", "filterLocationEmptyLabel": "Unlocated", "filterTagEmptyLabel": "Untagged", "filterRatingUnratedLabel": "Unrated", @@ -490,7 +490,7 @@ } }, - "collectionEmptyFavourites": "No favourites", + "collectionEmptyFavourites": "No favorites", "collectionEmptyVideos": "No videos", "collectionEmptyImages": "No images", @@ -498,7 +498,7 @@ "collectionDeselectSectionTooltip": "Deselect section", "drawerCollectionAll": "All collection", - "drawerCollectionFavourites": "Favourites", + "drawerCollectionFavourites": "Favorites", "drawerCollectionImages": "Images", "drawerCollectionVideos": "Videos", "drawerCollectionAnimated": "Animated", @@ -556,7 +556,7 @@ "settingsActionImport": "Import", "appExportCovers": "Covers", - "appExportFavourites": "Favourites", + "appExportFavourites": "Favorites", "appExportSettings": "Settings", "settingsSectionNavigation": "Navigation", @@ -579,7 +579,7 @@ "settingsNavigationDrawerAddAlbum": "Add album", "settingsSectionThumbnails": "Thumbnails", - "settingsThumbnailShowFavouriteIcon": "Show favourite icon", + "settingsThumbnailShowFavouriteIcon": "Show favorite icon", "settingsThumbnailShowLocationIcon": "Show location icon", "settingsThumbnailShowMotionPhotoIcon": "Show motion photo icon", "settingsThumbnailShowRating": "Show rating", @@ -641,6 +641,10 @@ "settingsSubtitleThemeTextAlignmentCenter": "Center", "settingsSubtitleThemeTextAlignmentRight": "Right", + "settingsGesturesTile": "Gestures", + "settingsGesturesTitle": "Gestures", + "settingsVideoGestureSideDoubleTapSeek": "Double tap on screen edges to seek backward/forward", + "settingsSectionPrivacy": "Privacy", "settingsAllowInstalledAppAccess": "Allow access to app inventory", "settingsAllowInstalledAppAccessSubtitle": "Used to improve album display", diff --git a/lib/model/actions/chip_actions.dart b/lib/model/actions/chip_actions.dart index 046850c81..efae273d7 100644 --- a/lib/model/actions/chip_actions.dart +++ b/lib/model/actions/chip_actions.dart @@ -23,9 +23,7 @@ extension ExtraChipAction on ChipAction { } } - Widget getIcon() { - return Icon(_getIconData()); - } + Widget getIcon() => Icon(_getIconData()); IconData _getIconData() { switch (this) { diff --git a/lib/model/actions/chip_set_actions.dart b/lib/model/actions/chip_set_actions.dart index 7a73a8186..65ce55e5a 100644 --- a/lib/model/actions/chip_set_actions.dart +++ b/lib/model/actions/chip_set_actions.dart @@ -90,9 +90,7 @@ extension ExtraChipSetAction on ChipSetAction { } } - Widget getIcon() { - return Icon(_getIconData()); - } + Widget getIcon() => Icon(_getIconData()); IconData _getIconData() { switch (this) { diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart index 8df63b991..7446ff8e4 100644 --- a/lib/model/actions/entry_set_actions.dart +++ b/lib/model/actions/entry_set_actions.dart @@ -159,9 +159,7 @@ extension ExtraEntrySetAction on EntrySetAction { } } - Widget getIcon() { - return Icon(_getIconData()); - } + Widget getIcon() => Icon(_getIconData()); IconData _getIconData() { switch (this) { diff --git a/lib/model/actions/video_actions.dart b/lib/model/actions/video_actions.dart index 627640bb5..411d974ce 100644 --- a/lib/model/actions/video_actions.dart +++ b/lib/model/actions/video_actions.dart @@ -50,11 +50,9 @@ extension ExtraVideoAction on VideoAction { } } - Widget getIcon() { - return Icon(_getIconData()); - } + Widget getIcon() => Icon(getIconData()); - IconData _getIconData() { + IconData getIconData() { switch (this) { case VideoAction.captureFrame: return AIcons.captureFrame; diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index 4e0255916..06d0d0b38 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -82,6 +82,7 @@ class SettingsDefaults { static const enableVideoAutoPlay = false; static const videoLoopMode = VideoLoopMode.shortOnly; static const videoShowRawTimedText = false; + static const videoGestureSideDoubleTapSeek = true; // subtitles static const subtitleFontSize = 20.0; diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index f8b0dbd59..760999c95 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -95,6 +95,7 @@ class Settings extends ChangeNotifier { static const enableVideoAutoPlayKey = 'video_auto_play'; static const videoLoopModeKey = 'video_loop'; static const videoShowRawTimedTextKey = 'video_show_raw_timed_text'; + static const videoGestureSideDoubleTapSeekKey = 'video_gesture_side_double_tap_skip'; // subtitles static const subtitleFontSizeKey = 'subtitle_font_size'; @@ -436,6 +437,10 @@ class Settings extends ChangeNotifier { set videoShowRawTimedText(bool newValue) => setAndNotify(videoShowRawTimedTextKey, newValue); + bool get videoGestureSideDoubleTapSeek => getBoolOrDefault(videoGestureSideDoubleTapSeekKey, SettingsDefaults.videoGestureSideDoubleTapSeek); + + set videoGestureSideDoubleTapSeek(bool newValue) => setAndNotify(videoGestureSideDoubleTapSeekKey, newValue); + // subtitles double get subtitleFontSize => getDouble(subtitleFontSizeKey) ?? SettingsDefaults.subtitleFontSize; @@ -654,6 +659,7 @@ class Settings extends ChangeNotifier { case enableMotionPhotoAutoPlayKey: case enableVideoHardwareAccelerationKey: case enableVideoAutoPlayKey: + case videoGestureSideDoubleTapSeekKey: case subtitleShowOutlineKey: case saveSearchHistoryKey: case filePickerShowHiddenFilesKey: diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 6e9b05903..96fdca5c8 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -39,6 +39,7 @@ class Durations { static const thumbnailScrollerScrollAnimation = Duration(milliseconds: 200); static const thumbnailScrollerShadeAnimation = Duration(milliseconds: 150); static const viewerVideoPlayerTransition = Duration(milliseconds: 500); + static const viewerActionFeedbackAnimation = Duration(milliseconds: 800); // info animations static const mapStyleSwitchAnimation = Duration(milliseconds: 300); diff --git a/lib/widgets/common/action_mixins/feedback.dart b/lib/widgets/common/action_mixins/feedback.dart index 6a95d0499..e05c77a82 100644 --- a/lib/widgets/common/action_mixins/feedback.dart +++ b/lib/widgets/common/action_mixins/feedback.dart @@ -294,3 +294,70 @@ class _FeedbackMessageState extends State<_FeedbackMessage> { ); } } + +class ActionFeedback extends StatefulWidget { + final Widget? child; + + const ActionFeedback({ + Key? key, + required this.child, + }) : super(key: key); + + @override + _ActionFeedbackState createState() => _ActionFeedbackState(); +} + +class _ActionFeedbackState extends State with SingleTickerProviderStateMixin { + late final AnimationController _animationController; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: Durations.viewerActionFeedbackAnimation, + vsync: this, + ); + } + + @override + void didUpdateWidget(covariant ActionFeedback oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.child != widget.child) { + _animationController.reset(); + if (widget.child != null) { + _animationController.forward(); + } + } + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Center( + child: AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + final t = _animationController.value; + final opacity = Curves.easeOutQuad.transform(t > .5 ? (1 - t) * 2 : t * 2); + final scale = Curves.slowMiddle.transform(t) * 2; + return Opacity( + opacity: opacity, + child: Transform.scale( + scale: scale, + child: child, + ), + ); + }, + child: widget.child, + ), + ), + ); + } +} diff --git a/lib/widgets/settings/video/gestures.dart b/lib/widgets/settings/video/gestures.dart new file mode 100644 index 000000000..80bb864a7 --- /dev/null +++ b/lib/widgets/settings/video/gestures.dart @@ -0,0 +1,53 @@ +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class VideoGesturesTile extends StatelessWidget { + const VideoGesturesTile({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(context.l10n.settingsGesturesTile), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: VideoGesturesPage.routeName), + builder: (context) => const VideoGesturesPage(), + ), + ); + }, + ); + } +} + +class VideoGesturesPage extends StatelessWidget { + static const routeName = '/settings/video/gestures'; + + const VideoGesturesPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.settingsGesturesTitle), + ), + body: SafeArea( + child: ListView( + children: [ + Selector( + selector: (context, s) => s.videoGestureSideDoubleTapSeek, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.videoGestureSideDoubleTapSeek = v, + title: Text(context.l10n.settingsVideoGestureSideDoubleTapSeek), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/settings/video/subtitle_theme.dart b/lib/widgets/settings/video/subtitle_theme.dart index 072e14447..fbf5bec73 100644 --- a/lib/widgets/settings/video/subtitle_theme.dart +++ b/lib/widgets/settings/video/subtitle_theme.dart @@ -28,7 +28,7 @@ class SubtitleThemeTile extends StatelessWidget { } class SubtitleThemePage extends StatelessWidget { - static const routeName = '/settings/subtitle_theme'; + static const routeName = '/settings/video/subtitle_theme'; static const textAlignOptions = [TextAlign.left, TextAlign.center, TextAlign.right]; diff --git a/lib/widgets/settings/video/video.dart b/lib/widgets/settings/video/video.dart index 5417112fa..f51bff418 100644 --- a/lib/widgets/settings/video/video.dart +++ b/lib/widgets/settings/video/video.dart @@ -9,6 +9,7 @@ import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/settings/common/tile_leading.dart'; +import 'package:aves/widgets/settings/video/gestures.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'; @@ -73,6 +74,7 @@ class VideoSection extends StatelessWidget { }, ), ), + const VideoGesturesTile(), const SubtitleThemeTile(), ]; diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 22ddaf3bf..67acd3bd7 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -209,6 +209,10 @@ class _EntryViewerStackState extends State with FeedbackMixin, if (_currentHorizontalPage != index) { _horizontalPager.jumpToPage(index); } + } else if (notification is VideoGestureNotification) { + final controller = notification.controller; + final action = notification.action; + _videoActionDelegate.onActionSelected(context, controller, action); } else { return false; } diff --git a/lib/widgets/viewer/overlay/notifications.dart b/lib/widgets/viewer/overlay/notifications.dart index 8444b9a61..8809d111f 100644 --- a/lib/widgets/viewer/overlay/notifications.dart +++ b/lib/widgets/viewer/overlay/notifications.dart @@ -1,3 +1,5 @@ +import 'package:aves/model/actions/video_actions.dart'; +import 'package:aves/widgets/viewer/video/controller.dart'; import 'package:flutter/material.dart'; @immutable @@ -13,3 +15,14 @@ class ViewEntryNotification extends Notification { const ViewEntryNotification({required this.index}); } + +@immutable +class VideoGestureNotification extends Notification { + final AvesVideoController controller; + final VideoAction action; + + const VideoGestureNotification({ + required this.controller, + required this.action, + }); +} diff --git a/lib/widgets/viewer/video/fijkplayer.dart b/lib/widgets/viewer/video/fijkplayer.dart index e4fa3c6a6..539b594bc 100644 --- a/lib/widgets/viewer/video/fijkplayer.dart +++ b/lib/widgets/viewer/video/fijkplayer.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:math'; import 'dart:typed_data'; import 'package:aves/model/entry.dart'; @@ -301,7 +300,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController { @override Future seekTo(int targetMillis) async { - targetMillis = max(0, targetMillis); + targetMillis = targetMillis.clamp(0, duration); if (isReady) { await _instance.seekTo(targetMillis); } else { diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index eb35be268..02cb6c0f3 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -1,10 +1,13 @@ import 'dart:async'; +import 'package:aves/model/actions/video_actions.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_images.dart'; import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/magnifier/controller/controller.dart'; import 'package:aves/widgets/common/magnifier/controller/state.dart'; import 'package:aves/widgets/common/magnifier/magnifier.dart'; @@ -24,6 +27,7 @@ import 'package:aves/widgets/viewer/visual/subtitle/subtitle.dart'; import 'package:aves/widgets/viewer/visual/vector.dart'; import 'package:aves/widgets/viewer/visual/video.dart'; import 'package:collection/collection.dart'; +import 'package:decorated_icon/decorated_icon.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -51,6 +55,7 @@ class _EntryPageViewState extends State { ImageStream? _videoCoverStream; late ImageStreamListener _videoCoverStreamListener; final ValueNotifier _videoCoverInfoNotifier = ValueNotifier(null); + final ValueNotifier _actionFeedbackChildNotifier = ValueNotifier(null); MagnifierController? _dismissedCoverMagnifierController; @@ -187,6 +192,29 @@ class _EntryPageViewState extends State { Widget _buildVideoView() { final videoController = context.read().getController(entry); if (videoController == null) return const SizedBox(); + + Positioned _buildDoubleTapDetector(AlignmentGeometry alignment, VideoAction action) { + return Positioned.fill( + child: FractionallySizedBox( + alignment: alignment, + widthFactor: .25, + child: GestureDetector( + onDoubleTap: () { + _actionFeedbackChildNotifier.value = DecoratedIcon( + action.getIconData(), + shadows: Constants.embossShadows, + size: 48, + ); + VideoGestureNotification( + controller: videoController, + action: action, + ).dispatch(context); + }, + ), + ), + ); + } + return ValueListenableBuilder( valueListenable: videoController.sarNotifier, builder: (context, sar, child) { @@ -213,6 +241,16 @@ class _EntryPageViewState extends State { viewStateNotifier: _viewStateNotifier, debugMode: true, ), + if (settings.videoGestureSideDoubleTapSeek) ...[ + _buildDoubleTapDetector(Alignment.centerLeft, VideoAction.replay10), + _buildDoubleTapDetector(Alignment.centerRight, VideoAction.skip10), + ValueListenableBuilder( + valueListenable: _actionFeedbackChildNotifier, + builder: (context, feedbackChild, child) => ActionFeedback( + child: feedbackChild, + ), + ), + ], ], ), _buildVideoCover(videoController, videoDisplaySize), diff --git a/untranslated.json b/untranslated.json index 254855227..518979511 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,31 +1,52 @@ { "de": [ "entryActionConvert", - "settingsViewerShowOverlayThumbnails" + "settingsViewerShowOverlayThumbnails", + "settingsGesturesTile", + "settingsGesturesTitle", + "settingsVideoGestureSideDoubleTapSeek" ], "es": [ - "settingsViewerShowOverlayThumbnails" + "settingsViewerShowOverlayThumbnails", + "settingsGesturesTile", + "settingsGesturesTitle", + "settingsVideoGestureSideDoubleTapSeek" ], "fr": [ - "settingsViewerShowOverlayThumbnails" + "settingsViewerShowOverlayThumbnails", + "settingsGesturesTile", + "settingsGesturesTitle", + "settingsVideoGestureSideDoubleTapSeek" ], "id": [ - "settingsViewerShowOverlayThumbnails" + "settingsViewerShowOverlayThumbnails", + "settingsGesturesTile", + "settingsGesturesTitle", + "settingsVideoGestureSideDoubleTapSeek" ], "ko": [ - "settingsViewerShowOverlayThumbnails" + "settingsViewerShowOverlayThumbnails", + "settingsGesturesTile", + "settingsGesturesTitle", + "settingsVideoGestureSideDoubleTapSeek" ], "pt": [ - "settingsViewerShowOverlayThumbnails" + "settingsViewerShowOverlayThumbnails", + "settingsGesturesTile", + "settingsGesturesTitle", + "settingsVideoGestureSideDoubleTapSeek" ], "ru": [ "entryActionConvert", - "settingsViewerShowOverlayThumbnails" + "settingsViewerShowOverlayThumbnails", + "settingsGesturesTile", + "settingsGesturesTitle", + "settingsVideoGestureSideDoubleTapSeek" ] }