video: control buttons

This commit is contained in:
Thibault Deckers 2022-03-02 11:51:14 +09:00
parent 6b62806ddb
commit 054910f7b3
26 changed files with 709 additions and 464 deletions

View file

@ -152,6 +152,11 @@
"videoLoopModeShortOnly": "Short videos only",
"videoLoopModeAlways": "Always",
"videoControlsNone": "None",
"videoControlsPlay": "Play",
"videoControlsPlaySeek": "Play & seek",
"videoControlsPlayOutside": "Play outside",
"mapStyleGoogleNormal": "Google Maps",
"mapStyleGoogleHybrid": "Google Maps (Hybrid)",
"mapStyleGoogleTerrain": "Google Maps (Terrain)",
@ -641,8 +646,10 @@
"settingsSubtitleThemeTextAlignmentCenter": "Center",
"settingsSubtitleThemeTextAlignmentRight": "Right",
"settingsGesturesTile": "Gestures",
"settingsGesturesTitle": "Gestures",
"settingsVideoControlsTile": "Controls",
"settingsVideoControlsTitle": "Controls",
"settingsVideoButtonsTile": "Buttons",
"settingsVideoButtonsTitle": "Buttons",
"settingsVideoGestureDoubleTapTogglePlay": "Double tap to play/pause",
"settingsVideoGestureSideDoubleTapSeek": "Double tap on screen edges to seek backward/forward",

View file

@ -3,26 +3,24 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
enum VideoAction {
captureFrame,
// controls
playOutside,
replay10,
skip10,
togglePlay,
// menu
captureFrame,
selectStreams,
setSpeed,
settings,
togglePlay,
// TODO TLAD [video] toggle mute
}
class VideoActions {
static const all = [
VideoAction.togglePlay,
static const menu = [
VideoAction.captureFrame,
VideoAction.setSpeed,
VideoAction.selectStreams,
VideoAction.replay10,
VideoAction.skip10,
VideoAction.playOutside,
VideoAction.settings,
];
}

View file

@ -82,6 +82,7 @@ class SettingsDefaults {
static const enableVideoAutoPlay = false;
static const videoLoopMode = VideoLoopMode.shortOnly;
static const videoShowRawTimedText = false;
static const videoControls = VideoControls.play;
static const videoGestureDoubleTapTogglePlay = false;
static const videoGestureSideDoubleTapSeek = true;

View file

@ -18,3 +18,5 @@ enum KeepScreenOn { never, viewerOnly, always }
enum UnitSystem { metric, imperial }
enum VideoLoopMode { never, shortOnly, always }
enum VideoControls { none, play, playSeek, playOutside }

View file

@ -0,0 +1,19 @@
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
import 'enums.dart';
extension ExtraVideoControls on VideoControls {
String getName(BuildContext context) {
switch (this) {
case VideoControls.none:
return context.l10n.videoControlsNone;
case VideoControls.play:
return context.l10n.videoControlsPlay;
case VideoControls.playSeek:
return context.l10n.videoControlsPlaySeek;
case VideoControls.playOutside:
return context.l10n.videoControlsPlayOutside;
}
}
}

View file

@ -95,6 +95,7 @@ class Settings extends ChangeNotifier {
static const enableVideoAutoPlayKey = 'video_auto_play';
static const videoLoopModeKey = 'video_loop';
static const videoShowRawTimedTextKey = 'video_show_raw_timed_text';
static const videoControlsKey = 'video_controls';
static const videoGestureDoubleTapTogglePlayKey = 'video_gesture_double_tap_toggle_play';
static const videoGestureSideDoubleTapSeekKey = 'video_gesture_side_double_tap_skip';
@ -438,6 +439,10 @@ class Settings extends ChangeNotifier {
set videoShowRawTimedText(bool newValue) => setAndNotify(videoShowRawTimedTextKey, newValue);
VideoControls get videoControls => getEnumOrDefault(videoControlsKey, SettingsDefaults.videoControls, VideoControls.values);
set videoControls(VideoControls newValue) => setAndNotify(videoControlsKey, newValue.toString());
bool get videoGestureDoubleTapTogglePlay => getBoolOrDefault(videoGestureDoubleTapTogglePlayKey, SettingsDefaults.videoGestureDoubleTapTogglePlay);
set videoGestureDoubleTapTogglePlay(bool newValue) => setAndNotify(videoGestureDoubleTapTogglePlayKey, newValue);
@ -686,6 +691,7 @@ class Settings extends ChangeNotifier {
case tagSortFactorKey:
case imageBackgroundKey:
case videoLoopModeKey:
case videoControlsKey:
case subtitleTextAlignmentKey:
case infoMapStyleKey:
case coordinateFormatKey:

View file

@ -96,14 +96,12 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
if (uniqueNames.length < names.length) {
final value = await showDialog<NameConflictStrategy>(
context: context,
builder: (context) {
return AvesSelectionDialog<NameConflictStrategy>(
builder: (context) => AvesSelectionDialog<NameConflictStrategy>(
initialValue: nameConflictStrategy,
options: Map.fromEntries(NameConflictStrategy.values.map((v) => MapEntry(v, v.getName(context)))),
message: originAlbums.length == 1 ? l10n.nameConflictDialogSingleSourceMessage : l10n.nameConflictDialogMultipleSourceMessage,
confirmationButtonLabel: l10n.continueButtonLabel,
);
},
),
);
if (value == null) return;
nameConflictStrategy = value;

View file

@ -29,7 +29,7 @@ class BlurredRect extends StatelessWidget {
class BlurredRRect extends StatelessWidget {
final bool enabled;
final double borderRadius;
final BorderRadius? borderRadius;
final Widget child;
const BlurredRRect({
@ -39,17 +39,31 @@ class BlurredRRect extends StatelessWidget {
required this.child,
}) : super(key: key);
factory BlurredRRect.all({
Key? key,
bool enabled = true,
required double borderRadius,
required Widget child,
}) {
return BlurredRRect(
key: key,
enabled: enabled,
borderRadius: BorderRadius.all(Radius.circular(borderRadius)),
child: child,
);
}
@override
Widget build(BuildContext context) {
return enabled
? ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(borderRadius)),
child: BackdropFilter(
return ClipRRect(
borderRadius: borderRadius,
child: enabled
? BackdropFilter(
filter: _filter,
child: child,
),
)
: child;
: child,
);
}
}

View file

@ -17,7 +17,6 @@ import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/viewer/info/notifications.dart';
import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
@ -139,22 +138,16 @@ class MapButtonPanel extends StatelessWidget {
final availableStyles = EntryMapStyle.values.where((style) => !style.isGoogleMaps || canUseGoogleMaps);
final preferredStyle = settings.infoMapStyle;
final initialStyle = availableStyles.contains(preferredStyle) ? preferredStyle : availableStyles.first;
final style = await showDialog<EntryMapStyle>(
await showSelectionDialog<EntryMapStyle>(
context: context,
builder: (context) {
return AvesSelectionDialog<EntryMapStyle>(
builder: (context) => AvesSelectionDialog<EntryMapStyle>(
initialValue: initialStyle,
options: Map.fromEntries(availableStyles.map((v) => MapEntry(v, v.getName(context)))),
title: context.l10n.mapStyleTitle,
),
onSelection: (v) => settings.infoMapStyle = v,
);
},
);
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (style != null && style != settings.infoMapStyle) {
settings.infoMapStyle = style;
}
},
tooltip: context.l10n.mapStyleTooltip,
),
),
@ -313,7 +306,7 @@ class _OverlayCoordinateFilterChipState extends State<_OverlayCoordinateFilterCh
);
return Padding(
padding: EdgeInsets.all(widget.padding),
child: BlurredRRect(
child: BlurredRRect.all(
enabled: blurred,
borderRadius: AvesFilterChip.defaultRadius,
child: AvesFilterChip(

View file

@ -1,8 +1,26 @@
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/basic/reselectable_radio_list_tile.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'aves_dialog.dart';
Future<void> showSelectionDialog<T>({
required BuildContext context,
required WidgetBuilder builder,
required void Function(T value) onSelection,
}) async {
final value = await showDialog<T>(
context: context,
builder: builder,
);
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (value != null) {
onSelection(value);
}
}
typedef TextBuilder<T> = String Function(T value);
class AvesSelectionDialog<T> extends StatefulWidget {

View file

@ -1,11 +1,9 @@
import 'package:aves/model/settings/enums/accessibility_animations.dart';
import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
class RemoveAnimationsTile extends StatelessWidget {
@ -18,21 +16,15 @@ class RemoveAnimationsTile extends StatelessWidget {
return ListTile(
title: Text(context.l10n.settingsRemoveAnimationsTile),
subtitle: Text(currentAnimations.getName(context)),
onTap: () async {
final value = await showDialog<AccessibilityAnimations>(
onTap: () => showSelectionDialog<AccessibilityAnimations>(
context: context,
builder: (context) => AvesSelectionDialog<AccessibilityAnimations>(
initialValue: currentAnimations,
options: Map.fromEntries(AccessibilityAnimations.values.map((v) => MapEntry(v, v.getName(context)))),
title: context.l10n.settingsRemoveAnimationsTitle,
),
);
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (value != null) {
settings.accessibilityAnimations = value;
}
},
onSelection: (v) => settings.accessibilityAnimations = v,
),
);
}
}

View file

@ -36,19 +36,15 @@ class _TimeToTakeActionTileState extends State<TimeToTakeActionTile> {
return ListTile(
title: Text(context.l10n.settingsTimeToTakeActionTile),
subtitle: Text(currentTimeToTakeAction.getName(context)),
onTap: () async {
final value = await showDialog<AccessibilityTimeout>(
onTap: () => showSelectionDialog<AccessibilityTimeout>(
context: context,
builder: (context) => AvesSelectionDialog<AccessibilityTimeout>(
initialValue: currentTimeToTakeAction,
options: Map.fromEntries(optionValues.map((v) => MapEntry(v, v.getName(context)))),
title: context.l10n.settingsTimeToTakeActionTitle,
),
);
if (value != null) {
settings.timeToTakeAction = value;
}
},
onSelection: (v) => settings.timeToTakeAction = v,
),
);
},
);

View file

@ -45,8 +45,7 @@ class LanguageSection extends StatelessWidget {
ListTile(
title: Text(l10n.settingsCoordinateFormatTile),
subtitle: Text(currentCoordinateFormat.getName(context)),
onTap: () async {
final value = await showDialog<CoordinateFormat>(
onTap: () => showSelectionDialog<CoordinateFormat>(
context: context,
builder: (context) => AvesSelectionDialog<CoordinateFormat>(
initialValue: currentCoordinateFormat,
@ -54,28 +53,21 @@ class LanguageSection extends StatelessWidget {
optionSubtitleBuilder: (value) => value.format(l10n, Constants.pointNemo),
title: l10n.settingsCoordinateFormatTitle,
),
);
if (value != null) {
settings.coordinateFormat = value;
}
},
onSelection: (v) => settings.coordinateFormat = v,
),
),
ListTile(
title: Text(l10n.settingsUnitSystemTile),
subtitle: Text(currentUnitSystem.getName(context)),
onTap: () async {
final value = await showDialog<UnitSystem>(
onTap: () => showSelectionDialog<UnitSystem>(
context: context,
builder: (context) => AvesSelectionDialog<UnitSystem>(
initialValue: currentUnitSystem,
options: Map.fromEntries(UnitSystem.values.map((v) => MapEntry(v, v.getName(context)))),
title: l10n.settingsUnitSystemTitle,
),
);
if (value != null) {
settings.unitSystem = value;
}
},
onSelection: (v) => settings.unitSystem = v,
),
),
],
);

View file

@ -2,13 +2,11 @@ import 'dart:collection';
import 'package:aves/l10n/l10n.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/settings/language/locales.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
class LocaleTile extends StatelessWidget {
@ -28,21 +26,15 @@ class LocaleTile extends StatelessWidget {
return Text(locale == null ? context.l10n.settingsSystemDefault : _getLocaleName(locale));
},
),
onTap: () async {
final value = await showDialog<Locale>(
onTap: () => showSelectionDialog<Locale>(
context: context,
builder: (context) => AvesSelectionDialog<Locale>(
initialValue: settings.locale ?? _systemLocaleOption,
options: _getLocaleOptions(context),
title: context.l10n.settingsLanguage,
),
);
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (value != null) {
settings.locale = value == _systemLocaleOption ? null : value;
}
},
onSelection: (v) => settings.locale = v == _systemLocaleOption ? null : v,
),
);
}

View file

@ -39,38 +39,30 @@ class NavigationSection extends StatelessWidget {
ListTile(
title: Text(context.l10n.settingsHome),
subtitle: Text(currentHomePage.getName(context)),
onTap: () async {
final value = await showDialog<HomePageSetting>(
onTap: () => showSelectionDialog<HomePageSetting>(
context: context,
builder: (context) => AvesSelectionDialog<HomePageSetting>(
initialValue: currentHomePage,
options: Map.fromEntries(HomePageSetting.values.map((v) => MapEntry(v, v.getName(context)))),
title: context.l10n.settingsHome,
),
);
if (value != null) {
settings.homePage = value;
}
},
onSelection: (v) => settings.homePage = v,
),
),
const NavigationDrawerTile(),
const ConfirmationDialogTile(),
ListTile(
title: Text(context.l10n.settingsKeepScreenOnTile),
subtitle: Text(currentKeepScreenOn.getName(context)),
onTap: () async {
final value = await showDialog<KeepScreenOn>(
onTap: () => showSelectionDialog<KeepScreenOn>(
context: context,
builder: (context) => AvesSelectionDialog<KeepScreenOn>(
initialValue: currentKeepScreenOn,
options: Map.fromEntries(KeepScreenOn.values.map((v) => MapEntry(v, v.getName(context)))),
title: context.l10n.settingsKeepScreenOnTitle,
),
);
if (value != null) {
settings.keepScreenOn = value;
}
},
onSelection: (v) => settings.keepScreenOn = v,
),
),
SwitchListTile(
value: currentMustBackTwiceToExit,

View file

@ -0,0 +1,80 @@
import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/enums/video_controls.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class VideoControlsTile extends StatelessWidget {
const VideoControlsTile({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(context.l10n.settingsVideoControlsTile),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: VideoControlsPage.routeName),
builder: (context) => const VideoControlsPage(),
),
);
},
);
}
}
class VideoControlsPage extends StatelessWidget {
static const routeName = '/settings/video/controls';
const VideoControlsPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.settingsVideoControlsTitle),
),
body: SafeArea(
child: ListView(
children: [
Selector<Settings, VideoControls>(
selector: (context, s) => s.videoControls,
builder: (context, current, child) => ListTile(
title: Text(context.l10n.settingsVideoButtonsTile),
subtitle: Text(current.getName(context)),
onTap: () => showSelectionDialog<VideoControls>(
context: context,
builder: (context) => AvesSelectionDialog<VideoControls>(
initialValue: current,
options: Map.fromEntries(VideoControls.values.map((v) => MapEntry(v, v.getName(context)))),
title: context.l10n.settingsVideoButtonsTitle,
),
onSelection: (v) => settings.videoControls = v,
),
),
),
Selector<Settings, bool>(
selector: (context, s) => s.videoGestureDoubleTapTogglePlay,
builder: (context, current, child) => SwitchListTile(
value: current,
onChanged: (v) => settings.videoGestureDoubleTapTogglePlay = v,
title: Text(context.l10n.settingsVideoGestureDoubleTapTogglePlay),
),
),
Selector<Settings, bool>(
selector: (context, s) => s.videoGestureSideDoubleTapSeek,
builder: (context, current, child) => SwitchListTile(
value: current,
onChanged: (v) => settings.videoGestureSideDoubleTapSeek = v,
title: Text(context.l10n.settingsVideoGestureSideDoubleTapSeek),
),
),
],
),
),
);
}
}

View file

@ -1,61 +0,0 @@
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class VideoGesturesTile extends StatelessWidget {
const VideoGesturesTile({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(context.l10n.settingsGesturesTile),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: VideoGesturesPage.routeName),
builder: (context) => const VideoGesturesPage(),
),
);
},
);
}
}
class VideoGesturesPage extends StatelessWidget {
static const routeName = '/settings/video/gestures';
const VideoGesturesPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.settingsGesturesTitle),
),
body: SafeArea(
child: ListView(
children: [
Selector<Settings, bool>(
selector: (context, s) => s.videoGestureDoubleTapTogglePlay,
builder: (context, current, child) => SwitchListTile(
value: current,
onChanged: (v) => settings.videoGestureDoubleTapTogglePlay = v,
title: Text(context.l10n.settingsVideoGestureDoubleTapTogglePlay),
),
),
Selector<Settings, bool>(
selector: (context, s) => s.videoGestureSideDoubleTapSeek,
builder: (context, current, child) => SwitchListTile(
value: current,
onChanged: (v) => settings.videoGestureSideDoubleTapSeek = v,
title: Text(context.l10n.settingsVideoGestureSideDoubleTapSeek),
),
),
],
),
),
);
}
}

View file

@ -57,19 +57,15 @@ class SubtitleThemePage extends StatelessWidget {
ListTile(
title: Text(context.l10n.settingsSubtitleThemeTextAlignmentTile),
subtitle: Text(_getTextAlignName(context, settings.subtitleTextAlignment)),
onTap: () async {
final value = await showDialog<TextAlign>(
onTap: () => showSelectionDialog<TextAlign>(
context: context,
builder: (context) => AvesSelectionDialog<TextAlign>(
initialValue: settings.subtitleTextAlignment,
options: Map.fromEntries(textAlignOptions.map((v) => MapEntry(v, _getTextAlignName(context, v)))),
title: context.l10n.settingsSubtitleThemeTextAlignmentTitle,
),
);
if (value != null) {
settings.subtitleTextAlignment = value;
}
},
onSelection: (v) => settings.subtitleTextAlignment = v,
),
),
SliderListTile(
title: context.l10n.settingsSubtitleThemeTextSize,

View file

@ -9,7 +9,7 @@ import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/settings/common/tile_leading.dart';
import 'package:aves/widgets/settings/video/gestures.dart';
import 'package:aves/widgets/settings/video/controls.dart';
import 'package:aves/widgets/settings/video/subtitle_theme.dart';
import 'package:aves/widgets/settings/video/video_actions_editor.dart';
import 'package:flutter/material.dart';
@ -59,22 +59,18 @@ class VideoSection extends StatelessWidget {
builder: (context, current, child) => ListTile(
title: Text(context.l10n.settingsVideoLoopModeTile),
subtitle: Text(current.getName(context)),
onTap: () async {
final value = await showDialog<VideoLoopMode>(
onTap: () => showSelectionDialog<VideoLoopMode>(
context: context,
builder: (context) => AvesSelectionDialog<VideoLoopMode>(
initialValue: current,
options: Map.fromEntries(VideoLoopMode.values.map((v) => MapEntry(v, v.getName(context)))),
title: context.l10n.settingsVideoLoopModeTitle,
),
);
if (value != null) {
settings.videoLoopMode = value;
}
},
onSelection: (v) => settings.videoLoopMode = v,
),
),
const VideoGesturesTile(),
),
const VideoControlsTile(),
const SubtitleThemeTile(),
];

View file

@ -34,7 +34,7 @@ class VideoActionEditorPage extends StatelessWidget {
return QuickActionEditorPage<VideoAction>(
title: context.l10n.settingsVideoQuickActionEditorTitle,
bannerText: context.l10n.settingsViewerQuickActionEditorBanner,
allAvailableActions: VideoActions.all,
allAvailableActions: VideoActions.menu,
actionIcon: (action) => action.getIcon(),
actionText: (context, action) => action.getText(context),
load: () => settings.videoQuickActions,

View file

@ -22,7 +22,7 @@ import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/multipage/controller.dart';
import 'package:aves/widgets/viewer/overlay/bottom/common.dart';
import 'package:aves/widgets/viewer/overlay/bottom/panorama.dart';
import 'package:aves/widgets/viewer/overlay/bottom/video.dart';
import 'package:aves/widgets/viewer/overlay/bottom/video/video.dart';
import 'package:aves/widgets/viewer/overlay/notifications.dart';
import 'package:aves/widgets/viewer/overlay/top.dart';
import 'package:aves/widgets/viewer/page_entry_builder.dart';

View file

@ -0,0 +1,203 @@
import 'dart:async';
import 'package:aves/model/actions/video_actions.dart';
import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class VideoControlRow extends StatelessWidget {
final AvesVideoController? controller;
final Animation<double> scale;
final Function(VideoAction value) onActionSelected;
static const double padding = 8;
static const Radius radius = Radius.circular(123);
const VideoControlRow({
Key? key,
required this.controller,
required this.scale,
required this.onActionSelected,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Selector<Settings, VideoControls>(
selector: (context, s) => s.videoControls,
builder: (context, videoControls, child) {
switch (videoControls) {
case VideoControls.none:
return const SizedBox();
case VideoControls.play:
return Padding(
padding: const EdgeInsetsDirectional.only(start: padding),
child: _buildOverlayButton(
child: PlayToggler(
controller: controller,
onPressed: () => onActionSelected(VideoAction.togglePlay),
),
),
);
case VideoControls.playSeek:
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: padding),
_buildIconButton(
context,
VideoAction.replay10,
borderRadius: const BorderRadius.only(topLeft: radius, bottomLeft: radius),
),
_buildOverlayButton(
child: PlayToggler(
controller: controller,
onPressed: () => onActionSelected(VideoAction.togglePlay),
),
borderRadius: const BorderRadius.all(Radius.zero),
),
_buildIconButton(
context,
VideoAction.skip10,
borderRadius: const BorderRadius.only(topRight: radius, bottomRight: radius),
),
],
);
case VideoControls.playOutside:
return Padding(
padding: const EdgeInsetsDirectional.only(start: padding),
child: _buildIconButton(
context,
VideoAction.playOutside,
),
);
}
},
);
}
Widget _buildOverlayButton({
BorderRadius? borderRadius,
required Widget child,
}) =>
OverlayButton(
scale: scale,
borderRadius: borderRadius,
child: child,
);
Widget _buildIconButton(
BuildContext context,
VideoAction action, {
BorderRadius? borderRadius,
}) =>
_buildOverlayButton(
borderRadius: borderRadius,
child: IconButton(
icon: action.getIcon(),
onPressed: () => onActionSelected(action),
tooltip: action.getText(context),
),
);
}
class PlayToggler extends StatefulWidget {
final AvesVideoController? controller;
final bool isMenuItem;
final VoidCallback? onPressed;
const PlayToggler({
Key? key,
required this.controller,
this.isMenuItem = false,
this.onPressed,
}) : super(key: key);
@override
State<PlayToggler> createState() => _PlayTogglerState();
}
class _PlayTogglerState extends State<PlayToggler> with SingleTickerProviderStateMixin {
final List<StreamSubscription> _subscriptions = [];
late AnimationController _playPauseAnimation;
AvesVideoController? get controller => widget.controller;
bool get isPlaying => controller?.isPlaying ?? false;
@override
void initState() {
super.initState();
_playPauseAnimation = AnimationController(
duration: context.read<DurationsData>().iconAnimation,
vsync: this,
);
_registerWidget(widget);
}
@override
void didUpdateWidget(covariant PlayToggler oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
_unregisterWidget(widget);
_playPauseAnimation.dispose();
super.dispose();
}
void _registerWidget(PlayToggler widget) {
final controller = widget.controller;
if (controller != null) {
_subscriptions.add(controller.statusStream.listen(_onStatusChange));
_onStatusChange(controller.status);
}
}
void _unregisterWidget(PlayToggler widget) {
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
}
@override
Widget build(BuildContext context) {
if (widget.isMenuItem) {
return isPlaying
? MenuRow(
text: context.l10n.videoActionPause,
icon: const Icon(AIcons.pause),
)
: MenuRow(
text: context.l10n.videoActionPlay,
icon: const Icon(AIcons.play),
);
}
return IconButton(
icon: AnimatedIcon(
icon: AnimatedIcons.play_pause,
progress: _playPauseAnimation,
),
onPressed: widget.onPressed,
tooltip: isPlaying ? context.l10n.videoActionPause : context.l10n.videoActionPlay,
);
}
void _onStatusChange(VideoStatus status) {
final status = _playPauseAnimation.status;
if (isPlaying && status != AnimationStatus.forward && status != AnimationStatus.completed) {
_playPauseAnimation.forward();
} else if (!isPlaying && status != AnimationStatus.reverse && status != AnimationStatus.dismissed) {
_playPauseAnimation.reverse();
}
}
}

View file

@ -0,0 +1,135 @@
import 'dart:async';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/format.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/fx/blurred.dart';
import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:flutter/material.dart';
class VideoProgressBar extends StatefulWidget {
final AvesVideoController? controller;
final Animation<double> scale;
const VideoProgressBar({
Key? key,
required this.controller,
required this.scale,
}) : super(key: key);
@override
State<VideoProgressBar> createState() => _VideoProgressBarState();
}
class _VideoProgressBarState extends State<VideoProgressBar> {
final GlobalKey _progressBarKey = GlobalKey(debugLabel: 'video-progress-bar');
bool _playingOnDragStart = false;
static const double radius = 123;
AvesVideoController? get controller => widget.controller;
Stream<int> get positionStream => controller?.positionStream ?? Stream.value(0);
bool get isPlaying => controller?.isPlaying ?? false;
@override
Widget build(BuildContext context) {
final blurred = settings.enableOverlayBlurEffect;
const textStyle = TextStyle(shadows: Constants.embossShadows);
return SizeTransition(
sizeFactor: widget.scale,
child: BlurredRRect.all(
enabled: blurred,
borderRadius: radius,
child: GestureDetector(
onTapDown: (details) {
_seekFromTap(details.globalPosition);
},
onHorizontalDragStart: (details) {
_playingOnDragStart = isPlaying;
if (_playingOnDragStart) controller!.pause();
},
onHorizontalDragUpdate: (details) {
_seekFromTap(details.globalPosition);
},
onHorizontalDragEnd: (details) {
if (_playingOnDragStart) controller!.play();
},
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: kMinInteractiveDimension,
),
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
decoration: BoxDecoration(
color: overlayBackgroundColor(blurred: blurred),
border: AvesBorder.border,
borderRadius: const BorderRadius.all(Radius.circular(radius)),
),
child: Column(
key: _progressBarKey,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
StreamBuilder<int>(
stream: positionStream,
builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos
final position = controller?.currentPosition.floor() ?? 0;
return Text(
formatFriendlyDuration(Duration(milliseconds: position)),
style: textStyle,
);
}),
const Spacer(),
Text(
formatFriendlyDuration(Duration(milliseconds: controller?.duration ?? 0)),
style: textStyle,
),
],
),
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(4)),
child: Directionality(
// force directionality for `LinearProgressIndicator`
textDirection: TextDirection.ltr,
child: StreamBuilder<int>(
stream: positionStream,
builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos
var progress = controller?.progress ?? 0.0;
if (!progress.isFinite) progress = 0.0;
return LinearProgressIndicator(
value: progress,
backgroundColor: Colors.grey.shade700,
);
}),
),
),
const Text(
// fake text below to match the height of the text above and center the whole thing
'',
style: textStyle,
),
],
),
),
),
),
),
);
}
void _seekFromTap(Offset globalPosition) async {
if (controller == null) return;
final keyContext = _progressBarKey.currentContext!;
final box = keyContext.findRenderObject() as RenderBox;
final localPosition = box.globalToLocal(globalPosition);
await controller!.seekToProgress(localPosition.dx / box.size.width);
}
}

View file

@ -4,14 +4,10 @@ import 'package:aves/model/actions/video_actions.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/format.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/basic/popup_menu_button.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/blurred.dart';
import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/viewer/overlay/bottom/video/controls.dart';
import 'package:aves/widgets/viewer/overlay/bottom/video/progress_bar.dart';
import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:flutter/foundation.dart';
@ -40,9 +36,6 @@ class VideoControlOverlay extends StatefulWidget {
}
class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTickerProviderStateMixin {
final GlobalKey _progressBarKey = GlobalKey(debugLabel: 'video-progress-bar');
bool _playingOnDragStart = false;
AvesEntry get entry => widget.entry;
Animation<double> get scale => widget.scale;
@ -89,7 +82,7 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
selector: (context, s) => s.videoQuickActions,
builder: (context, videoQuickActions, child) {
final quickActions = videoQuickActions.take(availableCount - 1).toList();
final menuActions = VideoActions.all.where((action) => !quickActions.contains(action)).toList();
final menuActions = VideoActions.menu.where((action) => !quickActions.contains(action)).toList();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
@ -103,7 +96,21 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
onActionMenuOpened: widget.onActionMenuOpened,
),
const SizedBox(height: 8),
_buildProgressBar(),
Row(
children: [
Expanded(
child: VideoProgressBar(
controller: controller,
scale: scale,
),
),
VideoControlRow(
controller: controller,
scale: scale,
onActionSelected: widget.onActionSelected,
),
],
),
],
);
},
@ -120,104 +127,6 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
);
});
}
Widget _buildProgressBar() {
const progressBarBorderRadius = 123.0;
final blurred = settings.enableOverlayBlurEffect;
const textStyle = TextStyle(shadows: Constants.embossShadows);
return SizeTransition(
sizeFactor: scale,
child: BlurredRRect(
enabled: blurred,
borderRadius: progressBarBorderRadius,
child: GestureDetector(
onTapDown: (details) {
_seekFromTap(details.globalPosition);
},
onHorizontalDragStart: (details) {
_playingOnDragStart = isPlaying;
if (_playingOnDragStart) controller!.pause();
},
onHorizontalDragUpdate: (details) {
_seekFromTap(details.globalPosition);
},
onHorizontalDragEnd: (details) {
if (_playingOnDragStart) controller!.play();
},
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: kMinInteractiveDimension,
),
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
decoration: BoxDecoration(
color: overlayBackgroundColor(blurred: blurred),
border: AvesBorder.border,
borderRadius: const BorderRadius.all(Radius.circular(progressBarBorderRadius)),
),
child: Column(
key: _progressBarKey,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
StreamBuilder<int>(
stream: positionStream,
builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos
final position = controller?.currentPosition.floor() ?? 0;
return Text(
formatFriendlyDuration(Duration(milliseconds: position)),
style: textStyle,
);
}),
const Spacer(),
Text(
entry.durationText,
style: textStyle,
),
],
),
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(4)),
child: Directionality(
// force directionality for `LinearProgressIndicator`
textDirection: TextDirection.ltr,
child: StreamBuilder<int>(
stream: positionStream,
builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos
var progress = controller?.progress ?? 0.0;
if (!progress.isFinite) progress = 0.0;
return LinearProgressIndicator(
value: progress,
backgroundColor: Colors.grey.shade700,
);
}),
),
),
const Text(
// fake text below to match the height of the text above and center the whole thing
'',
style: textStyle,
),
],
),
),
),
),
),
);
}
void _seekFromTap(Offset globalPosition) async {
if (controller == null) return;
final keyContext = _progressBarKey.currentContext!;
final box = keyContext.findRenderObject() as RenderBox;
final localPosition = box.globalToLocal(globalPosition);
await controller!.seekToProgress(localPosition.dx / box.size.width);
}
}
class _ButtonRow extends StatelessWidget {
@ -296,7 +205,7 @@ class _ButtonRow extends StatelessWidget {
child = _buildFromListenable(controller?.canSetSpeedNotifier);
break;
case VideoAction.togglePlay:
child = _PlayToggler(
child = PlayToggler(
controller: controller,
onPressed: onPressed,
);
@ -347,7 +256,7 @@ class _ButtonRow extends StatelessWidget {
Widget? child;
switch (action) {
case VideoAction.togglePlay:
child = _PlayToggler(
child = PlayToggler(
controller: controller,
isMenuItem: true,
);
@ -370,97 +279,3 @@ class _ButtonRow extends StatelessWidget {
);
}
}
class _PlayToggler extends StatefulWidget {
final AvesVideoController? controller;
final bool isMenuItem;
final VoidCallback? onPressed;
const _PlayToggler({
required this.controller,
this.isMenuItem = false,
this.onPressed,
});
@override
State<_PlayToggler> createState() => _PlayTogglerState();
}
class _PlayTogglerState extends State<_PlayToggler> with SingleTickerProviderStateMixin {
final List<StreamSubscription> _subscriptions = [];
late AnimationController _playPauseAnimation;
AvesVideoController? get controller => widget.controller;
bool get isPlaying => controller?.isPlaying ?? false;
@override
void initState() {
super.initState();
_playPauseAnimation = AnimationController(
duration: context.read<DurationsData>().iconAnimation,
vsync: this,
);
_registerWidget(widget);
}
@override
void didUpdateWidget(covariant _PlayToggler oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
_unregisterWidget(widget);
_playPauseAnimation.dispose();
super.dispose();
}
void _registerWidget(_PlayToggler widget) {
final controller = widget.controller;
if (controller != null) {
_subscriptions.add(controller.statusStream.listen(_onStatusChange));
_onStatusChange(controller.status);
}
}
void _unregisterWidget(_PlayToggler widget) {
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
}
@override
Widget build(BuildContext context) {
if (widget.isMenuItem) {
return isPlaying
? MenuRow(
text: context.l10n.videoActionPause,
icon: const Icon(AIcons.pause),
)
: MenuRow(
text: context.l10n.videoActionPlay,
icon: const Icon(AIcons.play),
);
}
return IconButton(
icon: AnimatedIcon(
icon: AnimatedIcons.play_pause,
progress: _playPauseAnimation,
),
onPressed: widget.onPressed,
tooltip: isPlaying ? context.l10n.videoActionPause : context.l10n.videoActionPlay,
);
}
void _onStatusChange(VideoStatus status) {
final status = _playPauseAnimation.status;
if (isPlaying && status != AnimationStatus.forward && status != AnimationStatus.completed) {
_playPauseAnimation.forward();
} else if (!isPlaying && status != AnimationStatus.reverse && status != AnimationStatus.dismissed) {
_playPauseAnimation.reverse();
}
}
}

View file

@ -7,11 +7,13 @@ Color overlayBackgroundColor({required bool blurred}) => blurred ? Colors.black2
class OverlayButton extends StatelessWidget {
final Animation<double> scale;
final BorderRadius? borderRadius;
final Widget child;
const OverlayButton({
Key? key,
this.scale = kAlwaysCompleteAnimation,
this.borderRadius,
required this.child,
}) : super(key: key);
@ -20,7 +22,24 @@ class OverlayButton extends StatelessWidget {
final blurred = settings.enableOverlayBlurEffect;
return ScaleTransition(
scale: scale,
child: BlurredOval(
child: borderRadius != null
? BlurredRRect(
enabled: blurred,
borderRadius: borderRadius,
child: Material(
type: MaterialType.button,
borderRadius: borderRadius,
color: overlayBackgroundColor(blurred: blurred),
child: Ink(
decoration: BoxDecoration(
border: AvesBorder.border,
borderRadius: borderRadius,
),
child: child,
),
),
)
: BlurredOval(
enabled: blurred,
child: Material(
type: MaterialType.circle,
@ -61,7 +80,7 @@ class OverlayTextButton extends StatelessWidget {
final blurred = settings.enableOverlayBlurEffect;
return SizeTransition(
sizeFactor: scale,
child: BlurredRRect(
child: BlurredRRect.all(
enabled: blurred,
borderRadius: _borderRadius,
child: OutlinedButton(

View file

@ -1,58 +1,100 @@
{
"de": [
"entryActionConvert",
"videoControlsNone",
"videoControlsPlay",
"videoControlsPlaySeek",
"videoControlsPlayOutside",
"settingsViewerShowOverlayThumbnails",
"settingsGesturesTile",
"settingsGesturesTitle",
"settingsVideoControlsTile",
"settingsVideoControlsTitle",
"settingsVideoButtonsTile",
"settingsVideoButtonsTitle",
"settingsVideoGestureDoubleTapTogglePlay",
"settingsVideoGestureSideDoubleTapSeek"
],
"es": [
"videoControlsNone",
"videoControlsPlay",
"videoControlsPlaySeek",
"videoControlsPlayOutside",
"settingsViewerShowOverlayThumbnails",
"settingsGesturesTile",
"settingsGesturesTitle",
"settingsVideoControlsTile",
"settingsVideoControlsTitle",
"settingsVideoButtonsTile",
"settingsVideoButtonsTitle",
"settingsVideoGestureDoubleTapTogglePlay",
"settingsVideoGestureSideDoubleTapSeek"
],
"fr": [
"videoControlsNone",
"videoControlsPlay",
"videoControlsPlaySeek",
"videoControlsPlayOutside",
"settingsViewerShowOverlayThumbnails",
"settingsGesturesTile",
"settingsGesturesTitle",
"settingsVideoControlsTile",
"settingsVideoControlsTitle",
"settingsVideoButtonsTile",
"settingsVideoButtonsTitle",
"settingsVideoGestureDoubleTapTogglePlay",
"settingsVideoGestureSideDoubleTapSeek"
],
"id": [
"videoControlsNone",
"videoControlsPlay",
"videoControlsPlaySeek",
"videoControlsPlayOutside",
"settingsViewerShowOverlayThumbnails",
"settingsGesturesTile",
"settingsGesturesTitle",
"settingsVideoControlsTile",
"settingsVideoControlsTitle",
"settingsVideoButtonsTile",
"settingsVideoButtonsTitle",
"settingsVideoGestureDoubleTapTogglePlay",
"settingsVideoGestureSideDoubleTapSeek"
],
"ko": [
"videoControlsNone",
"videoControlsPlay",
"videoControlsPlaySeek",
"videoControlsPlayOutside",
"settingsViewerShowOverlayThumbnails",
"settingsGesturesTile",
"settingsGesturesTitle",
"settingsVideoControlsTile",
"settingsVideoControlsTitle",
"settingsVideoButtonsTile",
"settingsVideoButtonsTitle",
"settingsVideoGestureDoubleTapTogglePlay",
"settingsVideoGestureSideDoubleTapSeek"
],
"pt": [
"videoControlsNone",
"videoControlsPlay",
"videoControlsPlaySeek",
"videoControlsPlayOutside",
"settingsViewerShowOverlayThumbnails",
"settingsGesturesTile",
"settingsGesturesTitle",
"settingsVideoControlsTile",
"settingsVideoControlsTitle",
"settingsVideoButtonsTile",
"settingsVideoButtonsTitle",
"settingsVideoGestureDoubleTapTogglePlay",
"settingsVideoGestureSideDoubleTapSeek"
],
"ru": [
"entryActionConvert",
"videoControlsNone",
"videoControlsPlay",
"videoControlsPlaySeek",
"videoControlsPlayOutside",
"settingsViewerShowOverlayThumbnails",
"settingsGesturesTile",
"settingsGesturesTitle",
"settingsVideoControlsTile",
"settingsVideoControlsTitle",
"settingsVideoButtonsTile",
"settingsVideoButtonsTitle",
"settingsVideoGestureDoubleTapTogglePlay",
"settingsVideoGestureSideDoubleTapSeek"
]