#180 video: brightness/volume swipe gestures

This commit is contained in:
Thibault Deckers 2023-01-17 16:54:50 +01:00
parent b39eaba3eb
commit cbfba1c156
28 changed files with 641 additions and 248 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = [

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -95,6 +95,7 @@ dependencies:
transparent_image:
tuple:
url_launcher:
volume_controller:
xml:
dev_dependencies:

View file

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