#180 video: double tap seek gesture

This commit is contained in:
Thibault Deckers 2022-03-01 15:50:41 +09:00
parent 064f20bb3a
commit 7e54b91aa4
17 changed files with 231 additions and 30 deletions

View file

@ -92,8 +92,8 @@
"entryActionSetAs": "Set as", "entryActionSetAs": "Set as",
"entryActionOpenMap": "Show in map app", "entryActionOpenMap": "Show in map app",
"entryActionRotateScreen": "Rotate screen", "entryActionRotateScreen": "Rotate screen",
"entryActionAddFavourite": "Add to favourites", "entryActionAddFavourite": "Add to favorites",
"entryActionRemoveFavourite": "Remove from favourites", "entryActionRemoveFavourite": "Remove from favorites",
"videoActionCaptureFrame": "Capture frame", "videoActionCaptureFrame": "Capture frame",
"videoActionPause": "Pause", "videoActionPause": "Pause",
@ -111,7 +111,7 @@
"entryInfoActionRemoveMetadata": "Remove metadata", "entryInfoActionRemoveMetadata": "Remove metadata",
"filterBinLabel": "Recycle bin", "filterBinLabel": "Recycle bin",
"filterFavouriteLabel": "Favourite", "filterFavouriteLabel": "Favorite",
"filterLocationEmptyLabel": "Unlocated", "filterLocationEmptyLabel": "Unlocated",
"filterTagEmptyLabel": "Untagged", "filterTagEmptyLabel": "Untagged",
"filterRatingUnratedLabel": "Unrated", "filterRatingUnratedLabel": "Unrated",
@ -490,7 +490,7 @@
} }
}, },
"collectionEmptyFavourites": "No favourites", "collectionEmptyFavourites": "No favorites",
"collectionEmptyVideos": "No videos", "collectionEmptyVideos": "No videos",
"collectionEmptyImages": "No images", "collectionEmptyImages": "No images",
@ -498,7 +498,7 @@
"collectionDeselectSectionTooltip": "Deselect section", "collectionDeselectSectionTooltip": "Deselect section",
"drawerCollectionAll": "All collection", "drawerCollectionAll": "All collection",
"drawerCollectionFavourites": "Favourites", "drawerCollectionFavourites": "Favorites",
"drawerCollectionImages": "Images", "drawerCollectionImages": "Images",
"drawerCollectionVideos": "Videos", "drawerCollectionVideos": "Videos",
"drawerCollectionAnimated": "Animated", "drawerCollectionAnimated": "Animated",
@ -556,7 +556,7 @@
"settingsActionImport": "Import", "settingsActionImport": "Import",
"appExportCovers": "Covers", "appExportCovers": "Covers",
"appExportFavourites": "Favourites", "appExportFavourites": "Favorites",
"appExportSettings": "Settings", "appExportSettings": "Settings",
"settingsSectionNavigation": "Navigation", "settingsSectionNavigation": "Navigation",
@ -579,7 +579,7 @@
"settingsNavigationDrawerAddAlbum": "Add album", "settingsNavigationDrawerAddAlbum": "Add album",
"settingsSectionThumbnails": "Thumbnails", "settingsSectionThumbnails": "Thumbnails",
"settingsThumbnailShowFavouriteIcon": "Show favourite icon", "settingsThumbnailShowFavouriteIcon": "Show favorite icon",
"settingsThumbnailShowLocationIcon": "Show location icon", "settingsThumbnailShowLocationIcon": "Show location icon",
"settingsThumbnailShowMotionPhotoIcon": "Show motion photo icon", "settingsThumbnailShowMotionPhotoIcon": "Show motion photo icon",
"settingsThumbnailShowRating": "Show rating", "settingsThumbnailShowRating": "Show rating",
@ -641,6 +641,10 @@
"settingsSubtitleThemeTextAlignmentCenter": "Center", "settingsSubtitleThemeTextAlignmentCenter": "Center",
"settingsSubtitleThemeTextAlignmentRight": "Right", "settingsSubtitleThemeTextAlignmentRight": "Right",
"settingsGesturesTile": "Gestures",
"settingsGesturesTitle": "Gestures",
"settingsVideoGestureSideDoubleTapSeek": "Double tap on screen edges to seek backward/forward",
"settingsSectionPrivacy": "Privacy", "settingsSectionPrivacy": "Privacy",
"settingsAllowInstalledAppAccess": "Allow access to app inventory", "settingsAllowInstalledAppAccess": "Allow access to app inventory",
"settingsAllowInstalledAppAccessSubtitle": "Used to improve album display", "settingsAllowInstalledAppAccessSubtitle": "Used to improve album display",

View file

@ -23,9 +23,7 @@ extension ExtraChipAction on ChipAction {
} }
} }
Widget getIcon() { Widget getIcon() => Icon(_getIconData());
return Icon(_getIconData());
}
IconData _getIconData() { IconData _getIconData() {
switch (this) { switch (this) {

View file

@ -90,9 +90,7 @@ extension ExtraChipSetAction on ChipSetAction {
} }
} }
Widget getIcon() { Widget getIcon() => Icon(_getIconData());
return Icon(_getIconData());
}
IconData _getIconData() { IconData _getIconData() {
switch (this) { switch (this) {

View file

@ -159,9 +159,7 @@ extension ExtraEntrySetAction on EntrySetAction {
} }
} }
Widget getIcon() { Widget getIcon() => Icon(_getIconData());
return Icon(_getIconData());
}
IconData _getIconData() { IconData _getIconData() {
switch (this) { switch (this) {

View file

@ -50,11 +50,9 @@ extension ExtraVideoAction on VideoAction {
} }
} }
Widget getIcon() { Widget getIcon() => Icon(getIconData());
return Icon(_getIconData());
}
IconData _getIconData() { IconData getIconData() {
switch (this) { switch (this) {
case VideoAction.captureFrame: case VideoAction.captureFrame:
return AIcons.captureFrame; return AIcons.captureFrame;

View file

@ -82,6 +82,7 @@ class SettingsDefaults {
static const enableVideoAutoPlay = false; static const enableVideoAutoPlay = false;
static const videoLoopMode = VideoLoopMode.shortOnly; static const videoLoopMode = VideoLoopMode.shortOnly;
static const videoShowRawTimedText = false; static const videoShowRawTimedText = false;
static const videoGestureSideDoubleTapSeek = true;
// subtitles // subtitles
static const subtitleFontSize = 20.0; static const subtitleFontSize = 20.0;

View file

@ -95,6 +95,7 @@ class Settings extends ChangeNotifier {
static const enableVideoAutoPlayKey = 'video_auto_play'; static const enableVideoAutoPlayKey = 'video_auto_play';
static const videoLoopModeKey = 'video_loop'; static const videoLoopModeKey = 'video_loop';
static const videoShowRawTimedTextKey = 'video_show_raw_timed_text'; static const videoShowRawTimedTextKey = 'video_show_raw_timed_text';
static const videoGestureSideDoubleTapSeekKey = 'video_gesture_side_double_tap_skip';
// subtitles // subtitles
static const subtitleFontSizeKey = 'subtitle_font_size'; static const subtitleFontSizeKey = 'subtitle_font_size';
@ -436,6 +437,10 @@ class Settings extends ChangeNotifier {
set videoShowRawTimedText(bool newValue) => setAndNotify(videoShowRawTimedTextKey, newValue); set videoShowRawTimedText(bool newValue) => setAndNotify(videoShowRawTimedTextKey, newValue);
bool get videoGestureSideDoubleTapSeek => getBoolOrDefault(videoGestureSideDoubleTapSeekKey, SettingsDefaults.videoGestureSideDoubleTapSeek);
set videoGestureSideDoubleTapSeek(bool newValue) => setAndNotify(videoGestureSideDoubleTapSeekKey, newValue);
// subtitles // subtitles
double get subtitleFontSize => getDouble(subtitleFontSizeKey) ?? SettingsDefaults.subtitleFontSize; double get subtitleFontSize => getDouble(subtitleFontSizeKey) ?? SettingsDefaults.subtitleFontSize;
@ -654,6 +659,7 @@ class Settings extends ChangeNotifier {
case enableMotionPhotoAutoPlayKey: case enableMotionPhotoAutoPlayKey:
case enableVideoHardwareAccelerationKey: case enableVideoHardwareAccelerationKey:
case enableVideoAutoPlayKey: case enableVideoAutoPlayKey:
case videoGestureSideDoubleTapSeekKey:
case subtitleShowOutlineKey: case subtitleShowOutlineKey:
case saveSearchHistoryKey: case saveSearchHistoryKey:
case filePickerShowHiddenFilesKey: case filePickerShowHiddenFilesKey:

View file

@ -39,6 +39,7 @@ class Durations {
static const thumbnailScrollerScrollAnimation = Duration(milliseconds: 200); static const thumbnailScrollerScrollAnimation = Duration(milliseconds: 200);
static const thumbnailScrollerShadeAnimation = Duration(milliseconds: 150); static const thumbnailScrollerShadeAnimation = Duration(milliseconds: 150);
static const viewerVideoPlayerTransition = Duration(milliseconds: 500); static const viewerVideoPlayerTransition = Duration(milliseconds: 500);
static const viewerActionFeedbackAnimation = Duration(milliseconds: 800);
// info animations // info animations
static const mapStyleSwitchAnimation = Duration(milliseconds: 300); static const mapStyleSwitchAnimation = Duration(milliseconds: 300);

View file

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

View file

@ -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<Settings, bool>(
selector: (context, s) => s.videoGestureSideDoubleTapSeek,
builder: (context, current, child) => SwitchListTile(
value: current,
onChanged: (v) => settings.videoGestureSideDoubleTapSeek = v,
title: Text(context.l10n.settingsVideoGestureSideDoubleTapSeek),
),
),
],
),
),
);
}
}

View file

@ -28,7 +28,7 @@ class SubtitleThemeTile extends StatelessWidget {
} }
class SubtitleThemePage 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]; static const textAlignOptions = [TextAlign.left, TextAlign.center, TextAlign.right];

View file

@ -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/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/settings/common/tile_leading.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/subtitle_theme.dart';
import 'package:aves/widgets/settings/video/video_actions_editor.dart'; import 'package:aves/widgets/settings/video/video_actions_editor.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -73,6 +74,7 @@ class VideoSection extends StatelessWidget {
}, },
), ),
), ),
const VideoGesturesTile(),
const SubtitleThemeTile(), const SubtitleThemeTile(),
]; ];

View file

@ -209,6 +209,10 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
if (_currentHorizontalPage != index) { if (_currentHorizontalPage != index) {
_horizontalPager.jumpToPage(index); _horizontalPager.jumpToPage(index);
} }
} else if (notification is VideoGestureNotification) {
final controller = notification.controller;
final action = notification.action;
_videoActionDelegate.onActionSelected(context, controller, action);
} else { } else {
return false; return false;
} }

View file

@ -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'; import 'package:flutter/material.dart';
@immutable @immutable
@ -13,3 +15,14 @@ class ViewEntryNotification extends Notification {
const ViewEntryNotification({required this.index}); const ViewEntryNotification({required this.index});
} }
@immutable
class VideoGestureNotification extends Notification {
final AvesVideoController controller;
final VideoAction action;
const VideoGestureNotification({
required this.controller,
required this.action,
});
}

View file

@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
@ -301,7 +300,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
@override @override
Future<void> seekTo(int targetMillis) async { Future<void> seekTo(int targetMillis) async {
targetMillis = max(0, targetMillis); targetMillis = targetMillis.clamp(0, duration);
if (isReady) { if (isReady) {
await _instance.seekTo(targetMillis); await _instance.seekTo(targetMillis);
} else { } else {

View file

@ -1,10 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/actions/video_actions.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_images.dart'; import 'package:aves/model/entry_images.dart';
import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/enums/accessibility_animations.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.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/controller.dart';
import 'package:aves/widgets/common/magnifier/controller/state.dart'; import 'package:aves/widgets/common/magnifier/controller/state.dart';
import 'package:aves/widgets/common/magnifier/magnifier.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/vector.dart';
import 'package:aves/widgets/viewer/visual/video.dart'; import 'package:aves/widgets/viewer/visual/video.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -51,6 +55,7 @@ class _EntryPageViewState extends State<EntryPageView> {
ImageStream? _videoCoverStream; ImageStream? _videoCoverStream;
late ImageStreamListener _videoCoverStreamListener; late ImageStreamListener _videoCoverStreamListener;
final ValueNotifier<ImageInfo?> _videoCoverInfoNotifier = ValueNotifier(null); final ValueNotifier<ImageInfo?> _videoCoverInfoNotifier = ValueNotifier(null);
final ValueNotifier<Widget?> _actionFeedbackChildNotifier = ValueNotifier(null);
MagnifierController? _dismissedCoverMagnifierController; MagnifierController? _dismissedCoverMagnifierController;
@ -187,6 +192,29 @@ class _EntryPageViewState extends State<EntryPageView> {
Widget _buildVideoView() { Widget _buildVideoView() {
final videoController = context.read<VideoConductor>().getController(entry); final videoController = context.read<VideoConductor>().getController(entry);
if (videoController == null) return const SizedBox(); 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<double>( return ValueListenableBuilder<double>(
valueListenable: videoController.sarNotifier, valueListenable: videoController.sarNotifier,
builder: (context, sar, child) { builder: (context, sar, child) {
@ -213,6 +241,16 @@ class _EntryPageViewState extends State<EntryPageView> {
viewStateNotifier: _viewStateNotifier, viewStateNotifier: _viewStateNotifier,
debugMode: true, debugMode: true,
), ),
if (settings.videoGestureSideDoubleTapSeek) ...[
_buildDoubleTapDetector(Alignment.centerLeft, VideoAction.replay10),
_buildDoubleTapDetector(Alignment.centerRight, VideoAction.skip10),
ValueListenableBuilder<Widget?>(
valueListenable: _actionFeedbackChildNotifier,
builder: (context, feedbackChild, child) => ActionFeedback(
child: feedbackChild,
),
),
],
], ],
), ),
_buildVideoCover(videoController, videoDisplaySize), _buildVideoCover(videoController, videoDisplaySize),

View file

@ -1,31 +1,52 @@
{ {
"de": [ "de": [
"entryActionConvert", "entryActionConvert",
"settingsViewerShowOverlayThumbnails" "settingsViewerShowOverlayThumbnails",
"settingsGesturesTile",
"settingsGesturesTitle",
"settingsVideoGestureSideDoubleTapSeek"
], ],
"es": [ "es": [
"settingsViewerShowOverlayThumbnails" "settingsViewerShowOverlayThumbnails",
"settingsGesturesTile",
"settingsGesturesTitle",
"settingsVideoGestureSideDoubleTapSeek"
], ],
"fr": [ "fr": [
"settingsViewerShowOverlayThumbnails" "settingsViewerShowOverlayThumbnails",
"settingsGesturesTile",
"settingsGesturesTitle",
"settingsVideoGestureSideDoubleTapSeek"
], ],
"id": [ "id": [
"settingsViewerShowOverlayThumbnails" "settingsViewerShowOverlayThumbnails",
"settingsGesturesTile",
"settingsGesturesTitle",
"settingsVideoGestureSideDoubleTapSeek"
], ],
"ko": [ "ko": [
"settingsViewerShowOverlayThumbnails" "settingsViewerShowOverlayThumbnails",
"settingsGesturesTile",
"settingsGesturesTitle",
"settingsVideoGestureSideDoubleTapSeek"
], ],
"pt": [ "pt": [
"settingsViewerShowOverlayThumbnails" "settingsViewerShowOverlayThumbnails",
"settingsGesturesTile",
"settingsGesturesTitle",
"settingsVideoGestureSideDoubleTapSeek"
], ],
"ru": [ "ru": [
"entryActionConvert", "entryActionConvert",
"settingsViewerShowOverlayThumbnails" "settingsViewerShowOverlayThumbnails",
"settingsGesturesTile",
"settingsGesturesTitle",
"settingsVideoGestureSideDoubleTapSeek"
] ]
} }