diff --git a/CHANGELOG.md b/CHANGELOG.md index 8de3150aa..94b7b0a1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Video: optional gestures to adjust brightness/volume + ## [v1.7.9] - 2023-01-15 ### Added diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 52c9ab346..1141b9bfb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -781,6 +781,7 @@ "settingsVideoButtonsTile": "Buttons", "settingsVideoGestureDoubleTapTogglePlay": "Double tap to play/pause", "settingsVideoGestureSideDoubleTapSeek": "Double tap on screen edges to seek backward/forward", + "settingsVideoGestureVerticalDragBrightnessVolume": "Swipe up or down to adjust brightness/volume", "settingsPrivacySectionTitle": "Privacy", "settingsAllowInstalledAppAccess": "Allow access to app inventory", diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index 1dbcc1a18..fb2273e6d 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -97,6 +97,7 @@ class SettingsDefaults { static const videoControls = VideoControls.play; static const videoGestureDoubleTapTogglePlay = false; static const videoGestureSideDoubleTapSeek = true; + static const videoGestureVerticalDragBrightnessVolume = false; // subtitles static const subtitleFontSize = 20.0; diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 27c944d82..f2595ffae 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -41,7 +41,6 @@ class Settings extends ChangeNotifier { static const Set _internalKeys = { hasAcceptedTermsKey, catalogTimeZoneKey, - videoShowRawTimedTextKey, searchHistoryKey, platformAccelerometerRotationKey, platformTransitionAnimationScaleKey, @@ -131,10 +130,10 @@ class Settings extends ChangeNotifier { static const enableVideoHardwareAccelerationKey = 'video_hwaccel_mediacodec'; static const videoAutoPlayModeKey = 'video_auto_play_mode'; static const videoLoopModeKey = 'video_loop'; - static const videoShowRawTimedTextKey = 'video_show_raw_timed_text'; static const videoControlsKey = 'video_controls'; static const videoGestureDoubleTapTogglePlayKey = 'video_gesture_double_tap_toggle_play'; static const videoGestureSideDoubleTapSeekKey = 'video_gesture_side_double_tap_skip'; + static const videoGestureVerticalDragBrightnessVolumeKey = 'video_gesture_vertical_drag_brightness_volume'; // subtitles static const subtitleFontSizeKey = 'subtitle_font_size'; @@ -637,10 +636,6 @@ class Settings extends ChangeNotifier { set videoLoopMode(VideoLoopMode newValue) => _set(videoLoopModeKey, newValue.toString()); - bool get videoShowRawTimedText => getBool(videoShowRawTimedTextKey) ?? SettingsDefaults.videoShowRawTimedText; - - set videoShowRawTimedText(bool newValue) => _set(videoShowRawTimedTextKey, newValue); - VideoControls get videoControls => getEnumOrDefault(videoControlsKey, SettingsDefaults.videoControls, VideoControls.values); set videoControls(VideoControls newValue) => _set(videoControlsKey, newValue.toString()); @@ -653,6 +648,10 @@ class Settings extends ChangeNotifier { set videoGestureSideDoubleTapSeek(bool newValue) => _set(videoGestureSideDoubleTapSeekKey, newValue); + bool get videoGestureVerticalDragBrightnessVolume => getBool(videoGestureVerticalDragBrightnessVolumeKey) ?? SettingsDefaults.videoGestureVerticalDragBrightnessVolume; + + set videoGestureVerticalDragBrightnessVolume(bool newValue) => _set(videoGestureVerticalDragBrightnessVolumeKey, newValue); + // subtitles double get subtitleFontSize => getDouble(subtitleFontSizeKey) ?? SettingsDefaults.subtitleFontSize; @@ -1039,6 +1038,7 @@ class Settings extends ChangeNotifier { case enableVideoHardwareAccelerationKey: case videoGestureDoubleTapTogglePlayKey: case videoGestureSideDoubleTapSeekKey: + case videoGestureVerticalDragBrightnessVolumeKey: case subtitleShowOutlineKey: case tagEditorCurrentFilterSectionExpandedKey: case saveSearchHistoryKey: diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 50185d2b2..cd05e0c89 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -14,6 +14,8 @@ class AIcons { static const IconData aspectRatio = Icons.aspect_ratio_outlined; static const IconData bin = Icons.delete_outlined; static const IconData broken = Icons.broken_image_outlined; + static const IconData brightnessMin = Icons.brightness_low_outlined; + static const IconData brightnessMax = Icons.brightness_high_outlined; static const IconData checked = Icons.done_outlined; static const IconData count = MdiIcons.counter; static const IconData counter = Icons.plus_one_outlined; @@ -52,6 +54,8 @@ class AIcons { static const IconData text = Icons.format_quote_outlined; static const IconData tag = Icons.local_offer_outlined; static const IconData tagUntagged = MdiIcons.tagOffOutline; + static const IconData volumeMin = Icons.volume_mute_outlined; + static const IconData volumeMax = Icons.volume_up_outlined; // view static const IconData group = Icons.group_work_outlined; diff --git a/lib/utils/dependencies.dart b/lib/utils/dependencies.dart index 9bb4babd0..27a0c94c9 100644 --- a/lib/utils/dependencies.dart +++ b/lib/utils/dependencies.dart @@ -123,6 +123,11 @@ class Dependencies { licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE', sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher', ), + Dependency( + name: 'Volume Controller', + license: mit, + sourceUrl: 'https://github.com/kurenai7968/volume_controller', + ), ]; static const List _googleMobileServices = [ diff --git a/lib/widgets/debug/settings.dart b/lib/widgets/debug/settings.dart index a4bbe7c98..d4fc6dc75 100644 --- a/lib/widgets/debug/settings.dart +++ b/lib/widgets/debug/settings.dart @@ -43,11 +43,6 @@ class DebugSettingsSection extends StatelessWidget { onChanged: (v) => settings.canUseAnalysisService = v, title: const Text('canUseAnalysisService'), ), - SwitchListTile( - value: settings.videoShowRawTimedText, - onChanged: (v) => settings.videoShowRawTimedText = v, - title: const Text('videoShowRawTimedText'), - ), Padding( padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), child: InfoRowGroup( diff --git a/lib/widgets/settings/video/controls.dart b/lib/widgets/settings/video/controls.dart index e57b8ef18..d7743500c 100644 --- a/lib/widgets/settings/video/controls.dart +++ b/lib/widgets/settings/video/controls.dart @@ -36,6 +36,11 @@ class VideoControlsPage extends StatelessWidget { onChanged: (v) => settings.videoGestureSideDoubleTapSeek = v, title: context.l10n.settingsVideoGestureSideDoubleTapSeek, ), + SettingsSwitchListTile( + selector: (context, s) => s.videoGestureVerticalDragBrightnessVolume, + onChanged: (v) => settings.videoGestureVerticalDragBrightnessVolume = v, + title: context.l10n.settingsVideoGestureVerticalDragBrightnessVolume, + ), ], ), ), diff --git a/lib/widgets/settings/video/subtitle_sample.dart b/lib/widgets/settings/video/subtitle_sample.dart index e41ab0848..c8ae248e4 100644 --- a/lib/widgets/settings/video/subtitle_sample.dart +++ b/lib/widgets/settings/video/subtitle_sample.dart @@ -5,7 +5,7 @@ import 'package:aves/widgets/common/basic/text/background_painter.dart'; import 'package:aves/widgets/common/basic/text/outlined.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/borders.dart'; -import 'package:aves/widgets/viewer/visual/subtitle/subtitle.dart'; +import 'package:aves/widgets/viewer/visual/video/subtitle/subtitle.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 5dc2c3d74..871f1175f 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -680,9 +680,7 @@ class _EntryViewerStackState extends State with EntryViewContr } Future _onLeave() async { - if (settings.viewerMaxBrightness) { - await ScreenBrightness().resetScreenBrightness(); - } + await ScreenBrightness().resetScreenBrightness(); if (settings.keepScreenOn == KeepScreenOn.viewerOnly) { await windowService.keepScreenOn(false); } diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index 287a8caea..2257879b9 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -3,30 +3,27 @@ import 'dart:async'; import 'package:aves/app_mode.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'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/media_session_service.dart'; -import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/basic/insets.dart'; -import 'package:aves/widgets/common/thumbnail/image.dart'; import 'package:aves/widgets/viewer/controller.dart'; import 'package:aves/widgets/viewer/hero.dart'; import 'package:aves/widgets/viewer/notifications.dart'; import 'package:aves/widgets/viewer/video/conductor.dart'; -import 'package:aves/widgets/viewer/video/controller.dart'; import 'package:aves/widgets/viewer/visual/conductor.dart'; import 'package:aves/widgets/viewer/visual/error.dart'; import 'package:aves/widgets/viewer/visual/raster.dart'; import 'package:aves/widgets/viewer/visual/state.dart'; -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:aves/widgets/viewer/visual/video/cover.dart'; +import 'package:aves/widgets/viewer/visual/video/subtitle/subtitle.dart'; +import 'package:aves/widgets/viewer/visual/video/swipe_action.dart'; +import 'package:aves/widgets/viewer/visual/video/video_view.dart'; import 'package:aves_magnifier/aves_magnifier.dart'; -import 'package:collection/collection.dart'; import 'package:decorated_icon/decorated_icon.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -55,17 +52,8 @@ class _EntryPageViewState extends State with SingleTickerProvider late ValueNotifier _viewStateNotifier; late AvesMagnifierController _magnifierController; final List _subscriptions = []; - ImageStream? _videoCoverStream; - late ImageStreamListener _videoCoverStreamListener; - final ValueNotifier _videoCoverInfoNotifier = ValueNotifier(null); final ValueNotifier _actionFeedbackChildNotifier = ValueNotifier(null); - - AvesMagnifierController? _dismissedCoverMagnifierController; - - AvesMagnifierController get dismissedCoverMagnifierController { - _dismissedCoverMagnifierController ??= AvesMagnifierController(); - return _dismissedCoverMagnifierController!; - } + OverlayEntry? _actionFeedbackOverlayEntry; AvesEntry get mainEntry => widget.mainEntry; @@ -73,9 +61,6 @@ class _EntryPageViewState extends State with SingleTickerProvider ViewerController get viewerController => widget.viewerController; - // use the high res photo as cover for the video part of a motion photo - ImageProvider get videoCoverUriImage => mainEntry.isMotionPhoto ? mainEntry.uriImage : entry.uriImage; - static const rasterMaxScale = ScaleLevel(factor: 5); static const vectorMaxScale = ScaleLevel(factor: 25); @@ -110,9 +95,6 @@ class _EntryPageViewState extends State with SingleTickerProvider _subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged)); if (entry.isVideo) { _subscriptions.add(mediaSessionService.mediaCommands.listen(_onMediaCommand)); - _videoCoverStreamListener = ImageStreamListener((image, _) => _videoCoverInfoNotifier.value = image); - _videoCoverStream = videoCoverUriImage.resolve(ImageConfiguration.empty); - _videoCoverStream!.addListener(_videoCoverStreamListener); } viewerController.startAutopilotAnimation( vsync: this, @@ -127,9 +109,6 @@ class _EntryPageViewState extends State with SingleTickerProvider void _unregisterWidget(EntryPageView oldWidget) { viewerController.stopAutopilotAnimation(vsync: this); - _videoCoverStream?.removeListener(_videoCoverStreamListener); - _videoCoverStream = null; - _videoCoverInfoNotifier.value = null; _magnifierController.dispose(); _subscriptions ..forEach((sub) => sub.cancel()) @@ -222,169 +201,169 @@ class _EntryPageViewState extends State with SingleTickerProvider builder: (context, sar, child) { final videoDisplaySize = entry.videoDisplaySize(sar); - return Selector>( - selector: (context, s) => Tuple2(s.videoGestureDoubleTapTogglePlay, s.videoGestureSideDoubleTapSeek), + return Selector>( + selector: (context, s) => Tuple3( + s.videoGestureDoubleTapTogglePlay, + s.videoGestureSideDoubleTapSeek, + s.videoGestureVerticalDragBrightnessVolume, + ), builder: (context, s, child) { final playGesture = s.item1; final seekGesture = s.item2; - final useActionGesture = playGesture || seekGesture; + final useVerticalDragGesture = s.item3; + final useTapGesture = playGesture || seekGesture; - void _applyAction(EntryAction action, {IconData? Function()? icon}) { - _actionFeedbackChildNotifier.value = DecoratedIcon( - icon?.call() ?? action.getIconData(), - size: 48, - color: Colors.white, - shadows: const [ - Shadow( - color: Colors.black, - blurRadius: 4, - ) - ], - ); - VideoActionNotification( - controller: videoController, - action: action, - ).dispatch(context); + MagnifierDoubleTapCallback? onDoubleTap; + MagnifierGestureScaleStartCallback? onScaleStart; + MagnifierGestureScaleUpdateCallback? onScaleUpdate; + MagnifierGestureScaleEndCallback? onScaleEnd; + + if (useTapGesture) { + void _applyAction(EntryAction action, {IconData? Function()? icon}) { + _actionFeedbackChildNotifier.value = DecoratedIcon( + icon?.call() ?? action.getIconData(), + size: 48, + color: Colors.white, + shadows: const [ + Shadow( + color: Colors.black, + blurRadius: 4, + ) + ], + ); + VideoActionNotification( + controller: videoController, + action: action, + ).dispatch(context); + } + + onDoubleTap = (alignment) { + final x = alignment.x; + if (seekGesture) { + if (x < sideRatio) { + _applyAction(EntryAction.videoReplay10); + return true; + } else if (x > 1 - sideRatio) { + _applyAction(EntryAction.videoSkip10); + return true; + } + } + if (playGesture) { + _applyAction( + EntryAction.videoTogglePlay, + icon: () => videoController.isPlaying ? AIcons.pause : AIcons.play, + ); + return true; + } + return false; + }; } - MagnifierDoubleTapCallback? _onDoubleTap = useActionGesture - ? (alignment) { - final x = alignment.x; - if (seekGesture) { - if (x < sideRatio) { - _applyAction(EntryAction.videoReplay10); - return true; - } else if (x > 1 - sideRatio) { - _applyAction(EntryAction.videoSkip10); - return true; - } - } - if (playGesture) { - _applyAction( - EntryAction.videoTogglePlay, - icon: () => videoController.isPlaying ? AIcons.pause : AIcons.play, - ); - return true; - } - return false; - } - : null; + if (useVerticalDragGesture) { + SwipeAction? swipeAction; + var move = Offset.zero; + var dropped = false; + double? startValue; + final valueNotifier = ValueNotifier(null); + onScaleStart = (details, doubleTap, boundaries) { + dropped = details.pointerCount > 1 || doubleTap; + if (dropped) return; + + startValue = null; + valueNotifier.value = null; + final alignmentX = details.focalPoint.dx / boundaries.viewportSize.width; + final action = alignmentX > .5 ? SwipeAction.volume : SwipeAction.brightness; + action.get().then((v) => startValue = v); + swipeAction = action; + move = Offset.zero; + _actionFeedbackOverlayEntry = OverlayEntry( + builder: (context) => SwipeActionFeedback( + action: action, + valueNotifier: valueNotifier, + ), + ); + Overlay.of(context)!.insert(_actionFeedbackOverlayEntry!); + }; + onScaleUpdate = (details) { + move += details.focalPointDelta; + dropped |= details.pointerCount > 1; + if (valueNotifier.value == null) { + dropped |= MagnifierGestureRecognizer.isXPan(move); + } + if (dropped) return false; + + final _startValue = startValue; + if (_startValue != null) { + final double value = (_startValue - move.dy / SwipeActionFeedback.height).clamp(0, 1); + valueNotifier.value = value; + swipeAction?.set(value); + } + return true; + }; + onScaleEnd = (details) { + if (_actionFeedbackOverlayEntry != null) { + _actionFeedbackOverlayEntry!.remove(); + _actionFeedbackOverlayEntry = null; + } + }; + } + + Widget videoChild = Stack( + children: [ + _buildMagnifier( + displaySize: videoDisplaySize, + onScaleStart: onScaleStart, + onScaleUpdate: onScaleUpdate, + onScaleEnd: onScaleEnd, + onDoubleTap: onDoubleTap, + child: VideoView( + entry: entry, + controller: videoController, + ), + ), + VideoSubtitles( + controller: videoController, + viewStateNotifier: _viewStateNotifier, + ), + if (useTapGesture) + ValueListenableBuilder( + valueListenable: _actionFeedbackChildNotifier, + builder: (context, feedbackChild, child) => ActionFeedback( + child: feedbackChild, + ), + ), + ], + ); + if (useVerticalDragGesture) { + videoChild = MagnifierGestureDetectorScope.of(context)!.copyWith( + acceptPointerEvent: MagnifierGestureRecognizer.isYPan, + child: videoChild, + ); + } return Stack( fit: StackFit.expand, children: [ - Stack( - children: [ - _buildMagnifier( - displaySize: videoDisplaySize, - onDoubleTap: _onDoubleTap, - child: VideoView( - entry: entry, - controller: videoController, - ), - ), - VideoSubtitles( - controller: videoController, - viewStateNotifier: _viewStateNotifier, - ), - if (settings.videoShowRawTimedText) - VideoSubtitles( - controller: videoController, - viewStateNotifier: _viewStateNotifier, - debugMode: true, - ), - if (useActionGesture) - ValueListenableBuilder( - valueListenable: _actionFeedbackChildNotifier, - builder: (context, feedbackChild, child) => ActionFeedback( - child: feedbackChild, - ), - ), - ], - ), - _buildVideoCover( + videoChild, + VideoCover( + mainEntry: mainEntry, + pageEntry: entry, + magnifierController: _magnifierController, videoController: videoController, videoDisplaySize: videoDisplaySize, - onDoubleTap: _onDoubleTap, - ), - ], - ); - }, - ); - }, - ); - } - - StreamBuilder _buildVideoCover({ - required AvesVideoController videoController, - required Size videoDisplaySize, - required MagnifierDoubleTapCallback? onDoubleTap, - }) { - // fade out image to ease transition with the player - return StreamBuilder( - stream: videoController.statusStream, - builder: (context, snapshot) { - final showCover = !videoController.isReady; - return IgnorePointer( - ignoring: !showCover, - child: AnimatedOpacity( - opacity: showCover ? 1 : 0, - curve: Curves.easeInCirc, - duration: Durations.viewerVideoPlayerTransition, - onEnd: () { - // while cover is fading out, the same controller is used for both the cover and the video, - // and both fire scale boundaries events, so we make sure that in the end - // the scale boundaries from the video are used after the cover is gone - final boundaries = _magnifierController.scaleBoundaries; - if (boundaries != null) { - _magnifierController.setScaleBoundaries( - boundaries.copyWith( - childSize: videoDisplaySize, - ), - ); - } - }, - child: ValueListenableBuilder( - valueListenable: _videoCoverInfoNotifier, - builder: (context, videoCoverInfo, child) { - if (videoCoverInfo != null) { - // full cover image may have a different size and different aspect ratio - final coverSize = Size( - videoCoverInfo.image.width.toDouble(), - videoCoverInfo.image.height.toDouble(), - ); - // when the cover is the same size as the video itself - // (which is often the case when the cover is not embedded but just a frame), - // we can reuse the same magnifier and preserve its state when switching from cover to video - final coverController = showCover || coverSize == videoDisplaySize ? _magnifierController : dismissedCoverMagnifierController; - return _buildMagnifier( + onTap: _onTap, + magnifierBuilder: (coverController, coverSize, videoCoverUriImage) => _buildMagnifier( controller: coverController, displaySize: coverSize, onDoubleTap: onDoubleTap, child: Image( image: videoCoverUriImage, ), - ); - } - - // default to cached thumbnail, if any - final extent = entry.cachedThumbnails.firstOrNull?.key.extent; - if (extent != null && extent > 0) { - return GestureDetector( - onTap: _onTap, - child: ThumbnailImage( - entry: entry, - extent: extent, - fit: BoxFit.contain, - showLoadingBackground: false, - ), - ); - } - - return const SizedBox(); - }, - ), - ), + ), + ), + ], + ); + }, ); }, ); @@ -396,6 +375,9 @@ class _EntryPageViewState extends State with SingleTickerProvider ScaleLevel maxScale = rasterMaxScale, ScaleStateCycle scaleStateCycle = defaultScaleStateCycle, bool applyScale = true, + MagnifierGestureScaleStartCallback? onScaleStart, + MagnifierGestureScaleUpdateCallback? onScaleUpdate, + MagnifierGestureScaleEndCallback? onScaleEnd, MagnifierDoubleTapCallback? onDoubleTap, required Widget child, }) { @@ -413,6 +395,9 @@ class _EntryPageViewState extends State with SingleTickerProvider initialScale: viewerController.initialScale, scaleStateCycle: scaleStateCycle, applyScale: applyScale, + onScaleStart: onScaleStart, + onScaleUpdate: onScaleUpdate, + onScaleEnd: onScaleEnd, onTap: (c, s, a, p) => _onTap(alignment: a), onDoubleTap: onDoubleTap, child: child, @@ -487,5 +472,3 @@ class _EntryPageViewState extends State with SingleTickerProvider } } } - -typedef MagnifierTapCallback = void Function(Offset childPosition); diff --git a/lib/widgets/viewer/visual/video/cover.dart b/lib/widgets/viewer/visual/video/cover.dart new file mode 100644 index 000000000..e9a3a81e1 --- /dev/null +++ b/lib/widgets/viewer/visual/video/cover.dart @@ -0,0 +1,160 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/model/entry_images.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/thumbnail/image.dart'; +import 'package:aves/widgets/viewer/video/controller.dart'; +import 'package:aves_magnifier/aves_magnifier.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +class VideoCover extends StatefulWidget { + final AvesEntry mainEntry, pageEntry; + final AvesMagnifierController magnifierController; + final AvesVideoController videoController; + final Size videoDisplaySize; + final void Function({Alignment? alignment}) onTap; + final Widget Function( + AvesMagnifierController coverController, + Size coverSize, + ImageProvider videoCoverUriImage, + ) magnifierBuilder; + + const VideoCover({ + super.key, + required this.mainEntry, + required this.pageEntry, + required this.magnifierController, + required this.videoController, + required this.videoDisplaySize, + required this.onTap, + required this.magnifierBuilder, + }); + + @override + State createState() => _VideoCoverState(); +} + +class _VideoCoverState extends State { + ImageStream? _videoCoverStream; + late ImageStreamListener _videoCoverStreamListener; + final ValueNotifier _videoCoverInfoNotifier = ValueNotifier(null); + + AvesMagnifierController? _dismissedCoverMagnifierController; + + AvesMagnifierController get dismissedCoverMagnifierController { + _dismissedCoverMagnifierController ??= AvesMagnifierController(); + return _dismissedCoverMagnifierController!; + } + + AvesEntry get mainEntry => widget.mainEntry; + + AvesEntry get entry => widget.pageEntry; + + AvesMagnifierController get magnifierController => widget.magnifierController; + + AvesVideoController get videoController => widget.videoController; + + Size get videoDisplaySize => widget.videoDisplaySize; + + // use the high res photo as cover for the video part of a motion photo + ImageProvider get videoCoverUriImage => mainEntry.isMotionPhoto ? mainEntry.uriImage : entry.uriImage; + + @override + void initState() { + super.initState(); + _registerWidget(widget); + } + + @override + void didUpdateWidget(covariant VideoCover oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.pageEntry != widget.pageEntry) { + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + } + + @override + void dispose() { + _unregisterWidget(widget); + super.dispose(); + } + + void _registerWidget(VideoCover widget) { + _videoCoverStreamListener = ImageStreamListener((image, _) => _videoCoverInfoNotifier.value = image); + _videoCoverStream = videoCoverUriImage.resolve(ImageConfiguration.empty); + _videoCoverStream!.addListener(_videoCoverStreamListener); + } + + void _unregisterWidget(VideoCover oldWidget) { + _videoCoverStream?.removeListener(_videoCoverStreamListener); + _videoCoverStream = null; + _videoCoverInfoNotifier.value = null; + } + + @override + Widget build(BuildContext context) { + // fade out image to ease transition with the player + return StreamBuilder( + stream: videoController.statusStream, + builder: (context, snapshot) { + final showCover = !videoController.isReady; + return IgnorePointer( + ignoring: !showCover, + child: AnimatedOpacity( + opacity: showCover ? 1 : 0, + curve: Curves.easeInCirc, + duration: Durations.viewerVideoPlayerTransition, + onEnd: () { + // while cover is fading out, the same controller is used for both the cover and the video, + // and both fire scale boundaries events, so we make sure that in the end + // the scale boundaries from the video are used after the cover is gone + final boundaries = magnifierController.scaleBoundaries; + if (boundaries != null) { + magnifierController.setScaleBoundaries( + boundaries.copyWith( + childSize: videoDisplaySize, + ), + ); + } + }, + child: ValueListenableBuilder( + valueListenable: _videoCoverInfoNotifier, + builder: (context, videoCoverInfo, child) { + if (videoCoverInfo != null) { + // full cover image may have a different size and different aspect ratio + final coverSize = Size( + videoCoverInfo.image.width.toDouble(), + videoCoverInfo.image.height.toDouble(), + ); + // when the cover is the same size as the video itself + // (which is often the case when the cover is not embedded but just a frame), + // we can reuse the same magnifier and preserve its state when switching from cover to video + final coverController = showCover || coverSize == videoDisplaySize ? magnifierController : dismissedCoverMagnifierController; + return widget.magnifierBuilder(coverController, coverSize, videoCoverUriImage); + } + + // default to cached thumbnail, if any + final extent = entry.cachedThumbnails.firstOrNull?.key.extent; + if (extent != null && extent > 0) { + return GestureDetector( + onTap: widget.onTap, + child: ThumbnailImage( + entry: entry, + extent: extent, + fit: BoxFit.contain, + showLoadingBackground: false, + ), + ); + } + + return const SizedBox(); + }, + ), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/viewer/visual/subtitle/ass_parser.dart b/lib/widgets/viewer/visual/video/subtitle/ass_parser.dart similarity index 98% rename from lib/widgets/viewer/visual/subtitle/ass_parser.dart rename to lib/widgets/viewer/visual/video/subtitle/ass_parser.dart index b15414474..3e9f24cba 100644 --- a/lib/widgets/viewer/visual/subtitle/ass_parser.dart +++ b/lib/widgets/viewer/visual/video/subtitle/ass_parser.dart @@ -1,6 +1,6 @@ -import 'package:aves/widgets/viewer/visual/subtitle/line.dart'; -import 'package:aves/widgets/viewer/visual/subtitle/span.dart'; -import 'package:aves/widgets/viewer/visual/subtitle/style.dart'; +import 'package:aves/widgets/viewer/visual/video/subtitle/line.dart'; +import 'package:aves/widgets/viewer/visual/video/subtitle/span.dart'; +import 'package:aves/widgets/viewer/visual/video/subtitle/style.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/viewer/visual/subtitle/line.dart b/lib/widgets/viewer/visual/video/subtitle/line.dart similarity index 93% rename from lib/widgets/viewer/visual/subtitle/line.dart rename to lib/widgets/viewer/visual/video/subtitle/line.dart index dd44cb49c..f7a213b80 100644 --- a/lib/widgets/viewer/visual/subtitle/line.dart +++ b/lib/widgets/viewer/visual/video/subtitle/line.dart @@ -1,4 +1,4 @@ -import 'package:aves/widgets/viewer/visual/subtitle/span.dart'; +import 'package:aves/widgets/viewer/visual/video/subtitle/span.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; diff --git a/lib/widgets/viewer/visual/subtitle/span.dart b/lib/widgets/viewer/visual/video/subtitle/span.dart similarity index 92% rename from lib/widgets/viewer/visual/subtitle/span.dart rename to lib/widgets/viewer/visual/video/subtitle/span.dart index 8cee296a0..0f5870582 100644 --- a/lib/widgets/viewer/visual/subtitle/span.dart +++ b/lib/widgets/viewer/visual/video/subtitle/span.dart @@ -1,4 +1,4 @@ -import 'package:aves/widgets/viewer/visual/subtitle/style.dart'; +import 'package:aves/widgets/viewer/visual/video/subtitle/style.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; diff --git a/lib/widgets/viewer/visual/subtitle/style.dart b/lib/widgets/viewer/visual/video/subtitle/style.dart similarity index 100% rename from lib/widgets/viewer/visual/subtitle/style.dart rename to lib/widgets/viewer/visual/video/subtitle/style.dart diff --git a/lib/widgets/viewer/visual/subtitle/subtitle.dart b/lib/widgets/viewer/visual/video/subtitle/subtitle.dart similarity index 98% rename from lib/widgets/viewer/visual/subtitle/subtitle.dart rename to lib/widgets/viewer/visual/video/subtitle/subtitle.dart index dbb32113a..c83f9db6f 100644 --- a/lib/widgets/viewer/visual/subtitle/subtitle.dart +++ b/lib/widgets/viewer/visual/video/subtitle/subtitle.dart @@ -4,9 +4,9 @@ import 'package:aves/widgets/common/basic/text/background_painter.dart'; import 'package:aves/widgets/common/basic/text/outlined.dart'; import 'package:aves/widgets/viewer/video/controller.dart'; import 'package:aves/widgets/viewer/visual/state.dart'; -import 'package:aves/widgets/viewer/visual/subtitle/ass_parser.dart'; -import 'package:aves/widgets/viewer/visual/subtitle/span.dart'; -import 'package:aves/widgets/viewer/visual/subtitle/style.dart'; +import 'package:aves/widgets/viewer/visual/video/subtitle/ass_parser.dart'; +import 'package:aves/widgets/viewer/visual/video/subtitle/span.dart'; +import 'package:aves/widgets/viewer/visual/video/subtitle/style.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; diff --git a/lib/widgets/viewer/visual/video/swipe_action.dart b/lib/widgets/viewer/visual/video/swipe_action.dart new file mode 100644 index 000000000..a921cca38 --- /dev/null +++ b/lib/widgets/viewer/visual/video/swipe_action.dart @@ -0,0 +1,138 @@ +import 'dart:async'; + +import 'package:aves/theme/icons.dart'; +import 'package:decorated_icon/decorated_icon.dart'; +import 'package:flutter/material.dart'; +import 'package:screen_brightness/screen_brightness.dart'; +import 'package:volume_controller/volume_controller.dart'; + +enum SwipeAction { brightness, volume } + +extension ExtraSwipeAction on SwipeAction { + Future get() { + switch (this) { + case SwipeAction.brightness: + return ScreenBrightness().current; + case SwipeAction.volume: + return VolumeController().getVolume(); + } + } + + Future set(double value) async { + switch (this) { + case SwipeAction.brightness: + await ScreenBrightness().setScreenBrightness(value); + break; + case SwipeAction.volume: + VolumeController().setVolume(value, showSystemUI: false); + break; + } + } +} + +class SwipeActionFeedback extends StatelessWidget { + final SwipeAction action; + final ValueNotifier valueNotifier; + + const SwipeActionFeedback({ + super.key, + required this.action, + required this.valueNotifier, + }); + + static const double width = 32; + static const double height = 160; + static const Radius radius = Radius.circular(width / 2); + static const double borderWidth = 2; + static const Color borderColor = Colors.white; + static final Color fillColor = Colors.white.withOpacity(.8); + static final Color backgroundColor = Colors.black.withOpacity(.2); + static final Color innerBorderColor = Colors.black.withOpacity(.5); + static const Color iconColor = Colors.white; + static const Color shadowColor = Colors.black; + + @override + Widget build(BuildContext context) { + return Center( + child: ValueListenableBuilder( + valueListenable: valueNotifier, + builder: (context, value, child) { + if (value == null) return const SizedBox(); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildIcon(_getMaxIcon()), + Container( + decoration: BoxDecoration( + color: backgroundColor, + border: Border.all( + width: borderWidth * 2, + color: innerBorderColor, + ), + borderRadius: const BorderRadius.all(radius), + ), + foregroundDecoration: BoxDecoration( + border: Border.all( + color: borderColor, + width: borderWidth, + ), + borderRadius: const BorderRadius.all(radius), + ), + width: width, + height: height, + child: ClipRRect( + borderRadius: const BorderRadius.all(radius), + child: Align( + alignment: Alignment.bottomCenter, + child: Container( + color: fillColor, + width: width, + height: height * value, + ), + ), + ), + ), + _buildIcon(_getMinIcon()), + ], + ); + }, + ), + ); + } + + Widget _buildIcon(IconData icon) { + return Padding( + padding: const EdgeInsets.all(16), + child: DecoratedIcon( + icon, + size: width, + color: iconColor, + shadows: const [ + Shadow( + color: shadowColor, + blurRadius: 4, + ) + ], + ), + ); + } + + IconData _getMinIcon() { + switch (action) { + case SwipeAction.brightness: + return AIcons.brightnessMin; + case SwipeAction.volume: + return AIcons.volumeMin; + } + } + + IconData _getMaxIcon() { + switch (action) { + case SwipeAction.brightness: + return AIcons.brightnessMax; + case SwipeAction.volume: + return AIcons.volumeMax; + } + } +} diff --git a/lib/widgets/viewer/visual/video.dart b/lib/widgets/viewer/visual/video/video_view.dart similarity index 100% rename from lib/widgets/viewer/visual/video.dart rename to lib/widgets/viewer/visual/video/video_view.dart diff --git a/plugins/aves_magnifier/lib/aves_magnifier.dart b/plugins/aves_magnifier/lib/aves_magnifier.dart index bda2e655b..dae50972d 100644 --- a/plugins/aves_magnifier/lib/aves_magnifier.dart +++ b/plugins/aves_magnifier/lib/aves_magnifier.dart @@ -2,6 +2,7 @@ library aves_magnifier; export 'src/controller/controller.dart'; export 'src/controller/state.dart'; +export 'src/core/scale_gesture_recognizer.dart'; export 'src/magnifier.dart'; export 'src/pan/gesture_detector_scope.dart'; export 'src/pan/scroll_physics.dart'; diff --git a/plugins/aves_magnifier/lib/src/core/core.dart b/plugins/aves_magnifier/lib/src/core/core.dart index eb24275ba..0ce553290 100644 --- a/plugins/aves_magnifier/lib/src/core/core.dart +++ b/plugins/aves_magnifier/lib/src/core/core.dart @@ -18,6 +18,9 @@ class MagnifierCore extends StatefulWidget { final ScaleStateCycle scaleStateCycle; final bool applyScale; final double panInertia; + final MagnifierGestureScaleStartCallback? onScaleStart; + final MagnifierGestureScaleUpdateCallback? onScaleUpdate; + final MagnifierGestureScaleEndCallback? onScaleEnd; final MagnifierTapCallback? onTap; final MagnifierDoubleTapCallback? onDoubleTap; final Widget child; @@ -28,6 +31,9 @@ class MagnifierCore extends StatefulWidget { required this.scaleStateCycle, required this.applyScale, this.panInertia = .2, + this.onScaleStart, + this.onScaleUpdate, + this.onScaleEnd, this.onTap, this.onDoubleTap, required this.child, @@ -40,7 +46,7 @@ class MagnifierCore extends StatefulWidget { class _MagnifierCoreState extends State with TickerProviderStateMixin, AvesMagnifierControllerDelegate, CornerHitDetector { Offset? _startFocalPoint, _lastViewportFocalPosition; double? _startScale, _quickScaleLastY, _quickScaleLastDistance; - late bool _doubleTap, _quickScaleMoved; + late bool _dropped, _doubleTap, _quickScaleMoved; DateTime _lastScaleGestureDate = DateTime.now(); late AnimationController _scaleAnimationController; @@ -99,9 +105,15 @@ class _MagnifierCoreState extends State with TickerProviderStateM } void onScaleStart(ScaleStartDetails details, bool doubleTap) { + final boundaries = scaleBoundaries; + if (boundaries == null) return; + + widget.onScaleStart?.call(details, doubleTap, boundaries); + _startScale = scale; _startFocalPoint = details.localFocalPoint; _lastViewportFocalPosition = _startFocalPoint; + _dropped = false; _doubleTap = doubleTap; _quickScaleLastDistance = null; _quickScaleLastY = _startFocalPoint!.dy; @@ -115,6 +127,9 @@ class _MagnifierCoreState extends State with TickerProviderStateM final boundaries = scaleBoundaries; if (boundaries == null) return; + _dropped |= widget.onScaleUpdate?.call(details) ?? false; + if (_dropped) return; + double newScale; if (_doubleTap) { // quick scale, aka one finger zoom @@ -151,6 +166,8 @@ class _MagnifierCoreState extends State with TickerProviderStateM final boundaries = scaleBoundaries; if (boundaries == null) return; + widget.onScaleEnd?.call(details); + final _position = controller.position; final _scale = controller.scale!; final maxScale = boundaries.maxScale; @@ -228,7 +245,7 @@ class _MagnifierCoreState extends State with TickerProviderStateM if (onDoubleTap != null) { final viewportSize = boundaries.viewportSize; final alignment = Alignment(viewportTapPosition.dx / viewportSize.width, viewportTapPosition.dy / viewportSize.height); - if (onDoubleTap.call(alignment) == true) return; + if (onDoubleTap(alignment) == true) return; } final childTapPosition = boundaries.viewportToChildPosition(controller, viewportTapPosition); @@ -307,12 +324,12 @@ class _MagnifierCoreState extends State with TickerProviderStateM ); return MagnifierGestureDetector( - onDoubleTap: onDoubleTap, + hitDetector: this, onScaleStart: onScaleStart, onScaleUpdate: onScaleUpdate, onScaleEnd: onScaleEnd, - hitDetector: this, onTapUp: widget.onTap == null ? null : onTap, + onDoubleTap: onDoubleTap, child: child, ); }, diff --git a/plugins/aves_magnifier/lib/src/core/gesture_detector.dart b/plugins/aves_magnifier/lib/src/core/gesture_detector.dart index 7d27b623b..e9ae6017b 100644 --- a/plugins/aves_magnifier/lib/src/core/gesture_detector.dart +++ b/plugins/aves_magnifier/lib/src/core/gesture_detector.dart @@ -60,8 +60,7 @@ class _MagnifierGestureDetectorState extends State { () => MagnifierGestureRecognizer( debugOwner: this, hitDetector: widget.hitDetector, - validateAxis: scope.axis, - touchSlopFactor: scope.touchSlopFactor, + scope: scope, doubleTapDetails: doubleTapDetails, ), (instance) { diff --git a/plugins/aves_magnifier/lib/src/core/scale_gesture_recognizer.dart b/plugins/aves_magnifier/lib/src/core/scale_gesture_recognizer.dart index 0c35ca666..eb95393b9 100644 --- a/plugins/aves_magnifier/lib/src/core/scale_gesture_recognizer.dart +++ b/plugins/aves_magnifier/lib/src/core/scale_gesture_recognizer.dart @@ -1,20 +1,19 @@ import 'dart:math'; +import 'package:aves_magnifier/aves_magnifier.dart'; import 'package:aves_magnifier/src/pan/corner_hit_detector.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; class MagnifierGestureRecognizer extends ScaleGestureRecognizer { final CornerHitDetector hitDetector; - final List validateAxis; - final double touchSlopFactor; + final MagnifierGestureDetectorScope scope; final ValueNotifier doubleTapDetails; MagnifierGestureRecognizer({ super.debugOwner, required this.hitDetector, - required this.validateAxis, - this.touchSlopFactor = 2, + required this.scope, required this.doubleTapDetails, }); @@ -46,7 +45,7 @@ class MagnifierGestureRecognizer extends ScaleGestureRecognizer { @override void handleEvent(PointerEvent event) { - if (validateAxis.isNotEmpty) { + if (scope.axis.isNotEmpty) { var didChangeConfiguration = false; if (event is PointerMoveEvent) { if (!event.synthesized) { @@ -104,26 +103,27 @@ class MagnifierGestureRecognizer extends ScaleGestureRecognizer { return; } + final validateAxis = scope.axis; final move = _initialFocalPoint! - _currentFocalPoint!; - var shouldMove = false; - if (validateAxis.length == 2) { - // the image is the descendant of gesture detector(s) handling drag in both directions - final shouldMoveX = validateAxis.contains(Axis.horizontal) && hitDetector.shouldMoveX(move); - final shouldMoveY = validateAxis.contains(Axis.vertical) && hitDetector.shouldMoveY(move); - if (shouldMoveX == shouldMoveY) { - // consistently can/cannot pan the image in both direction the same way - shouldMove = shouldMoveX; + bool shouldMove = scope.acceptPointerEvent?.call(move) ?? false; + + if (!shouldMove) { + if (validateAxis.length == 2) { + // the image is the descendant of gesture detector(s) handling drag in both directions + final shouldMoveX = validateAxis.contains(Axis.horizontal) && hitDetector.shouldMoveX(move); + final shouldMoveY = validateAxis.contains(Axis.vertical) && hitDetector.shouldMoveY(move); + if (shouldMoveX == shouldMoveY) { + // consistently can/cannot pan the image in both direction the same way + shouldMove = shouldMoveX; + } else { + // can pan the image in one direction, but should yield to an ascendant gesture detector in the other one + // the gesture direction angle is in ]-pi, pi], cf `Offset` doc for details + shouldMove = (isXPan(move) && shouldMoveX) || (isYPan(move) && shouldMoveY); + } } else { - // can pan the image in one direction, but should yield to an ascendant gesture detector in the other one - final d = move.direction; - // the gesture direction angle is in ]-pi, pi], cf `Offset` doc for details - final xPan = (-pi / 4 < d && d < pi / 4) || (3 / 4 * pi < d && d <= pi) || (-pi < d && d < -3 / 4 * pi); - final yPan = (pi / 4 < d && d < 3 / 4 * pi) || (-3 / 4 * pi < d && d < -pi / 4); - shouldMove = (xPan && shouldMoveX) || (yPan && shouldMoveY); + // the image is the descendant of a gesture detector handling drag in one direction + shouldMove = validateAxis.contains(Axis.vertical) ? hitDetector.shouldMoveY(move) : hitDetector.shouldMoveX(move); } - } else { - // the image is the descendant of a gesture detector handling drag in one direction - shouldMove = validateAxis.contains(Axis.vertical) ? hitDetector.shouldMoveY(move) : hitDetector.shouldMoveX(move); } final doubleTap = doubleTapDetails.value != null; @@ -137,9 +137,19 @@ class MagnifierGestureRecognizer extends ScaleGestureRecognizer { // and the magnifier recognizer may compete with the `HorizontalDragGestureRecognizer` from a containing `PageView` // setting `touchSlopFactor` to 2 restores default `ScaleGestureRecognizer` behaviour as `kPanSlop = kTouchSlop * 2.0` // setting `touchSlopFactor` in [0, 1] will allow this recognizer to accept the gesture before the one from `PageView` - if (spanDelta > computeScaleSlop(pointerDeviceKind) || focalPointDelta > computeHitSlop(pointerDeviceKind, gestureSettings) * touchSlopFactor) { + if (spanDelta > computeScaleSlop(pointerDeviceKind) || focalPointDelta > computeHitSlop(pointerDeviceKind, gestureSettings) * scope.touchSlopFactor) { acceptGesture(event.pointer); } } } + + static bool isXPan(Offset move) { + final d = move.direction; + return (-pi / 4 < d && d < pi / 4) || (3 / 4 * pi < d && d <= pi) || (-pi < d && d < -3 / 4 * pi); + } + + static bool isYPan(Offset move) { + final d = move.direction; + return (pi / 4 < d && d < 3 / 4 * pi) || (-3 / 4 * pi < d && d < -pi / 4); + } } diff --git a/plugins/aves_magnifier/lib/src/magnifier.dart b/plugins/aves_magnifier/lib/src/magnifier.dart index f93a07cad..88c3f7586 100644 --- a/plugins/aves_magnifier/lib/src/magnifier.dart +++ b/plugins/aves_magnifier/lib/src/magnifier.dart @@ -29,6 +29,9 @@ class AvesMagnifier extends StatelessWidget { this.initialScale = const ScaleLevel(ref: ScaleReference.contained), this.scaleStateCycle = defaultScaleStateCycle, this.applyScale = true, + this.onScaleStart, + this.onScaleUpdate, + this.onScaleEnd, this.onTap, this.onDoubleTap, required this.child, @@ -52,6 +55,9 @@ class AvesMagnifier extends StatelessWidget { final ScaleStateCycle scaleStateCycle; final bool applyScale; + final MagnifierGestureScaleStartCallback? onScaleStart; + final MagnifierGestureScaleUpdateCallback? onScaleUpdate; + final MagnifierGestureScaleEndCallback? onScaleEnd; final MagnifierTapCallback? onTap; final MagnifierDoubleTapCallback? onDoubleTap; final Widget child; @@ -73,6 +79,9 @@ class AvesMagnifier extends StatelessWidget { controller: controller, scaleStateCycle: scaleStateCycle, applyScale: applyScale, + onScaleStart: onScaleStart, + onScaleUpdate: onScaleUpdate, + onScaleEnd: onScaleEnd, onTap: onTap, onDoubleTap: onDoubleTap, child: child, @@ -88,7 +97,7 @@ typedef MagnifierTapCallback = Function( Alignment alignment, Offset childTapPosition, ); - -typedef MagnifierDoubleTapCallback = bool Function( - Alignment alignment, -); +typedef MagnifierDoubleTapCallback = bool Function(Alignment alignment); +typedef MagnifierGestureScaleStartCallback = void Function(ScaleStartDetails details, bool doubleTap, ScaleBoundaries boundaries); +typedef MagnifierGestureScaleUpdateCallback = bool Function(ScaleUpdateDetails details); +typedef MagnifierGestureScaleEndCallback = void Function(ScaleEndDetails details); diff --git a/plugins/aves_magnifier/lib/src/pan/gesture_detector_scope.dart b/plugins/aves_magnifier/lib/src/pan/gesture_detector_scope.dart index e7a597baf..a9b52c9fe 100644 --- a/plugins/aves_magnifier/lib/src/pan/gesture_detector_scope.dart +++ b/plugins/aves_magnifier/lib/src/pan/gesture_detector_scope.dart @@ -7,18 +7,6 @@ import 'package:flutter/widgets.dart'; /// Useful when placing Magnifier inside a gesture sensitive context, /// such as [PageView], [Dismissible], [BottomSheet]. class MagnifierGestureDetectorScope extends InheritedWidget { - const MagnifierGestureDetectorScope({ - super.key, - required this.axis, - this.touchSlopFactor = .8, - required Widget child, - }) : super(child: child); - - static MagnifierGestureDetectorScope? of(BuildContext context) { - final scope = context.dependOnInheritedWidgetOfExactType(); - return scope; - } - final List axis; // in [0, 1[ @@ -26,9 +14,36 @@ class MagnifierGestureDetectorScope extends InheritedWidget { // <1: less reactive but gives the most leeway to other recognizers // 1: will not be able to compete with a `HorizontalDragGestureRecognizer` up the widget tree final double touchSlopFactor; + final bool? Function(Offset move)? acceptPointerEvent; + + const MagnifierGestureDetectorScope({ + super.key, + required this.axis, + this.touchSlopFactor = .8, + this.acceptPointerEvent, + required Widget child, + }) : super(child: child); + + static MagnifierGestureDetectorScope? of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + MagnifierGestureDetectorScope copyWith({ + List? axis, + double? touchSlopFactor, + bool? Function(Offset move)? acceptPointerEvent, + required Widget child, + }) { + return MagnifierGestureDetectorScope( + axis: axis ?? this.axis, + touchSlopFactor: touchSlopFactor ?? this.touchSlopFactor, + acceptPointerEvent: acceptPointerEvent ?? this.acceptPointerEvent, + child: child, + ); + } @override bool updateShouldNotify(MagnifierGestureDetectorScope oldWidget) { - return axis != oldWidget.axis && touchSlopFactor != oldWidget.touchSlopFactor; + return axis != oldWidget.axis || touchSlopFactor != oldWidget.touchSlopFactor || acceptPointerEvent != oldWidget.acceptPointerEvent; } } diff --git a/pubspec.lock b/pubspec.lock index af51869ef..1c7170c2e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1196,6 +1196,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "9.0.0" + volume_controller: + dependency: "direct main" + description: + name: volume_controller + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.6" watcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0e42057ba..aa6948a4e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -95,6 +95,7 @@ dependencies: transparent_image: tuple: url_launcher: + volume_controller: xml: dev_dependencies: diff --git a/untranslated.json b/untranslated.json index 4301f7282..2fefa0641 100644 --- a/untranslated.json +++ b/untranslated.json @@ -475,6 +475,7 @@ "settingsVideoButtonsTile", "settingsVideoGestureDoubleTapTogglePlay", "settingsVideoGestureSideDoubleTapSeek", + "settingsVideoGestureVerticalDragBrightnessVolume", "settingsPrivacySectionTitle", "settingsAllowInstalledAppAccess", "settingsAllowInstalledAppAccessSubtitle", @@ -576,6 +577,7 @@ "tooManyItemsErrorDialogMessage", "settingsModificationWarningDialogMessage", "settingsViewerShowDescription", + "settingsVideoGestureVerticalDragBrightnessVolume", "settingsDisplayUseTvInterface" ], @@ -586,16 +588,19 @@ "tooManyItemsErrorDialogMessage", "settingsModificationWarningDialogMessage", "settingsViewerShowDescription", + "settingsVideoGestureVerticalDragBrightnessVolume", "settingsAccessibilityShowPinchGestureAlternatives", "settingsDisplayUseTvInterface" ], "el": [ - "tooManyItemsErrorDialogMessage" + "tooManyItemsErrorDialogMessage", + "settingsVideoGestureVerticalDragBrightnessVolume" ], "es": [ - "tooManyItemsErrorDialogMessage" + "tooManyItemsErrorDialogMessage", + "settingsVideoGestureVerticalDragBrightnessVolume" ], "fa": [ @@ -933,6 +938,7 @@ "settingsVideoButtonsTile", "settingsVideoGestureDoubleTapTogglePlay", "settingsVideoGestureSideDoubleTapSeek", + "settingsVideoGestureVerticalDragBrightnessVolume", "settingsPrivacySectionTitle", "settingsAllowInstalledAppAccess", "settingsAllowInstalledAppAccessSubtitle", @@ -1039,6 +1045,10 @@ "filePickerUseThisFolder" ], + "fr": [ + "settingsVideoGestureVerticalDragBrightnessVolume" + ], + "gl": [ "columnCount", "entryActionShareImageOnly", @@ -1404,6 +1414,7 @@ "settingsVideoButtonsTile", "settingsVideoGestureDoubleTapTogglePlay", "settingsVideoGestureSideDoubleTapSeek", + "settingsVideoGestureVerticalDragBrightnessVolume", "settingsPrivacySectionTitle", "settingsAllowInstalledAppAccess", "settingsAllowInstalledAppAccessSubtitle", @@ -1512,6 +1523,14 @@ "filePickerUseThisFolder" ], + "id": [ + "settingsVideoGestureVerticalDragBrightnessVolume" + ], + + "it": [ + "settingsVideoGestureVerticalDragBrightnessVolume" + ], + "ja": [ "columnCount", "chipActionFilterIn", @@ -1526,11 +1545,16 @@ "tooManyItemsErrorDialogMessage", "settingsModificationWarningDialogMessage", "settingsViewerShowDescription", + "settingsVideoGestureVerticalDragBrightnessVolume", "settingsAccessibilityShowPinchGestureAlternatives", "settingsDisplayUseTvInterface", "settingsWidgetDisplayedItem" ], + "ko": [ + "settingsVideoGestureVerticalDragBrightnessVolume" + ], + "lt": [ "columnCount", "filterLocatedLabel", @@ -1539,6 +1563,7 @@ "tooManyItemsErrorDialogMessage", "settingsModificationWarningDialogMessage", "settingsViewerShowDescription", + "settingsVideoGestureVerticalDragBrightnessVolume", "settingsAccessibilityShowPinchGestureAlternatives", "settingsDisplayUseTvInterface" ], @@ -1554,6 +1579,7 @@ "tooManyItemsErrorDialogMessage", "settingsModificationWarningDialogMessage", "settingsViewerShowDescription", + "settingsVideoGestureVerticalDragBrightnessVolume", "settingsAccessibilityShowPinchGestureAlternatives", "settingsDisplayUseTvInterface" ], @@ -1580,6 +1606,7 @@ "settingsViewerShowDescription", "settingsSubtitleThemeTextPositionTile", "settingsSubtitleThemeTextPositionDialogTitle", + "settingsVideoGestureVerticalDragBrightnessVolume", "settingsAccessibilityShowPinchGestureAlternatives", "settingsDisplayUseTvInterface", "settingsWidgetDisplayedItem" @@ -1828,6 +1855,7 @@ "settingsVideoButtonsTile", "settingsVideoGestureDoubleTapTogglePlay", "settingsVideoGestureSideDoubleTapSeek", + "settingsVideoGestureVerticalDragBrightnessVolume", "settingsPrivacySectionTitle", "settingsAllowInstalledAppAccess", "settingsAllowInstalledAppAccessSubtitle", @@ -1873,16 +1901,19 @@ ], "pl": [ - "tooManyItemsErrorDialogMessage" + "tooManyItemsErrorDialogMessage", + "settingsVideoGestureVerticalDragBrightnessVolume" ], "pt": [ "columnCount", - "tooManyItemsErrorDialogMessage" + "tooManyItemsErrorDialogMessage", + "settingsVideoGestureVerticalDragBrightnessVolume" ], "ro": [ - "tooManyItemsErrorDialogMessage" + "tooManyItemsErrorDialogMessage", + "settingsVideoGestureVerticalDragBrightnessVolume" ], "ru": [ @@ -1890,6 +1921,7 @@ "filterTaggedLabel", "tooManyItemsErrorDialogMessage", "settingsModificationWarningDialogMessage", + "settingsVideoGestureVerticalDragBrightnessVolume", "settingsDisplayUseTvInterface" ], @@ -2133,6 +2165,7 @@ "settingsVideoButtonsTile", "settingsVideoGestureDoubleTapTogglePlay", "settingsVideoGestureSideDoubleTapSeek", + "settingsVideoGestureVerticalDragBrightnessVolume", "settingsPrivacySectionTitle", "settingsAllowInstalledAppAccess", "settingsAllowInstalledAppAccessSubtitle", @@ -2241,8 +2274,13 @@ "filePickerUseThisFolder" ], + "tr": [ + "settingsVideoGestureVerticalDragBrightnessVolume" + ], + "uk": [ - "tooManyItemsErrorDialogMessage" + "tooManyItemsErrorDialogMessage", + "settingsVideoGestureVerticalDragBrightnessVolume" ], "zh": [ @@ -2251,6 +2289,7 @@ "tooManyItemsErrorDialogMessage", "settingsModificationWarningDialogMessage", "settingsViewerShowDescription", + "settingsVideoGestureVerticalDragBrightnessVolume", "settingsAccessibilityShowPinchGestureAlternatives", "settingsDisplayUseTvInterface" ], @@ -2262,6 +2301,7 @@ "tooManyItemsErrorDialogMessage", "settingsModificationWarningDialogMessage", "settingsViewerShowDescription", + "settingsVideoGestureVerticalDragBrightnessVolume", "settingsAccessibilityShowPinchGestureAlternatives", "settingsDisplayUseTvInterface" ]