#180 video: brightness/volume swipe gestures
This commit is contained in:
parent
b39eaba3eb
commit
cbfba1c156
28 changed files with 641 additions and 248 deletions
|
@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
## <a id="unreleased"></a>[Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Video: optional gestures to adjust brightness/volume
|
||||
|
||||
## <a id="v1.7.9"></a>[v1.7.9] - 2023-01-15
|
||||
|
||||
### Added
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -41,7 +41,6 @@ class Settings extends ChangeNotifier {
|
|||
static const Set<String> _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:
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<Dependency> _googleMobileServices = [
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -680,9 +680,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
|
|||
}
|
||||
|
||||
Future<void> _onLeave() async {
|
||||
if (settings.viewerMaxBrightness) {
|
||||
await ScreenBrightness().resetScreenBrightness();
|
||||
}
|
||||
await ScreenBrightness().resetScreenBrightness();
|
||||
if (settings.keepScreenOn == KeepScreenOn.viewerOnly) {
|
||||
await windowService.keepScreenOn(false);
|
||||
}
|
||||
|
|
|
@ -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<EntryPageView> with SingleTickerProvider
|
|||
late ValueNotifier<ViewState> _viewStateNotifier;
|
||||
late AvesMagnifierController _magnifierController;
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
ImageStream? _videoCoverStream;
|
||||
late ImageStreamListener _videoCoverStreamListener;
|
||||
final ValueNotifier<ImageInfo?> _videoCoverInfoNotifier = ValueNotifier(null);
|
||||
final ValueNotifier<Widget?> _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<EntryPageView> 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<EntryPageView> 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<EntryPageView> 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<EntryPageView> with SingleTickerProvider
|
|||
builder: (context, sar, child) {
|
||||
final videoDisplaySize = entry.videoDisplaySize(sar);
|
||||
|
||||
return Selector<Settings, Tuple2<bool, bool>>(
|
||||
selector: (context, s) => Tuple2(s.videoGestureDoubleTapTogglePlay, s.videoGestureSideDoubleTapSeek),
|
||||
return Selector<Settings, Tuple3<bool, bool, bool>>(
|
||||
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<double?>(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<Widget?>(
|
||||
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<Widget?>(
|
||||
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<VideoStatus> _buildVideoCover({
|
||||
required AvesVideoController videoController,
|
||||
required Size videoDisplaySize,
|
||||
required MagnifierDoubleTapCallback? onDoubleTap,
|
||||
}) {
|
||||
// fade out image to ease transition with the player
|
||||
return StreamBuilder<VideoStatus>(
|
||||
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<ImageInfo?>(
|
||||
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<EntryPageView> 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<EntryPageView> 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<EntryPageView> with SingleTickerProvider
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
typedef MagnifierTapCallback = void Function(Offset childPosition);
|
||||
|
|
160
lib/widgets/viewer/visual/video/cover.dart
Normal file
160
lib/widgets/viewer/visual/video/cover.dart
Normal file
|
@ -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<VideoCover> createState() => _VideoCoverState();
|
||||
}
|
||||
|
||||
class _VideoCoverState extends State<VideoCover> {
|
||||
ImageStream? _videoCoverStream;
|
||||
late ImageStreamListener _videoCoverStreamListener;
|
||||
final ValueNotifier<ImageInfo?> _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<VideoStatus>(
|
||||
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<ImageInfo?>(
|
||||
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();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
@ -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';
|
|
@ -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';
|
|
@ -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';
|
138
lib/widgets/viewer/visual/video/swipe_action.dart
Normal file
138
lib/widgets/viewer/visual/video/swipe_action.dart
Normal file
|
@ -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<double> get() {
|
||||
switch (this) {
|
||||
case SwipeAction.brightness:
|
||||
return ScreenBrightness().current;
|
||||
case SwipeAction.volume:
|
||||
return VolumeController().getVolume();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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<double?> 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<double?>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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<MagnifierCore> 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<MagnifierCore> 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<MagnifierCore> 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<MagnifierCore> 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<MagnifierCore> 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<MagnifierCore> 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,
|
||||
);
|
||||
},
|
||||
|
|
|
@ -60,8 +60,7 @@ class _MagnifierGestureDetectorState extends State<MagnifierGestureDetector> {
|
|||
() => MagnifierGestureRecognizer(
|
||||
debugOwner: this,
|
||||
hitDetector: widget.hitDetector,
|
||||
validateAxis: scope.axis,
|
||||
touchSlopFactor: scope.touchSlopFactor,
|
||||
scope: scope,
|
||||
doubleTapDetails: doubleTapDetails,
|
||||
),
|
||||
(instance) {
|
||||
|
|
|
@ -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<Axis> validateAxis;
|
||||
final double touchSlopFactor;
|
||||
final MagnifierGestureDetectorScope scope;
|
||||
final ValueNotifier<TapDownDetails?> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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<MagnifierGestureDetectorScope>();
|
||||
return scope;
|
||||
}
|
||||
|
||||
final List<Axis> 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>();
|
||||
}
|
||||
|
||||
MagnifierGestureDetectorScope copyWith({
|
||||
List<Axis>? 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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -95,6 +95,7 @@ dependencies:
|
|||
transparent_image:
|
||||
tuple:
|
||||
url_launcher:
|
||||
volume_controller:
|
||||
xml:
|
||||
|
||||
dev_dependencies:
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
|
|
Loading…
Reference in a new issue