video: set speed

This commit is contained in:
Thibault Deckers 2021-06-12 19:09:05 +09:00
parent f56b3dc0e4
commit 37e4d21277
12 changed files with 466 additions and 122 deletions

View file

@ -93,6 +93,13 @@
"entryActionRemoveFavourite": "Remove from favourites", "entryActionRemoveFavourite": "Remove from favourites",
"@entryActionRemoveFavourite": {}, "@entryActionRemoveFavourite": {},
"videoActionPause": "Pause",
"@videoActionPause": {},
"videoActionPlay": "Play",
"@videoActionPlay": {},
"videoActionSetSpeed": "Playback speed",
"@videoActionSetSpeed": {},
"filterFavouriteLabel": "Favourite", "filterFavouriteLabel": "Favourite",
"@filterFavouriteLabel": {}, "@filterFavouriteLabel": {},
"filterLocationEmptyLabel": "Unlocated", "filterLocationEmptyLabel": "Unlocated",
@ -263,6 +270,9 @@
"renameEntryDialogLabel": "New name", "renameEntryDialogLabel": "New name",
"@renameEntryDialogLabel": {}, "@renameEntryDialogLabel": {},
"videoSpeedDialogLabel": "Playback speed",
"@videoSpeedDialogLabel": {},
"genericSuccessFeedback": "Done!", "genericSuccessFeedback": "Done!",
"@genericSuccessFeedback": {}, "@genericSuccessFeedback": {},
"genericFailureFeedback": "Failed", "genericFailureFeedback": "Failed",
@ -629,10 +639,6 @@
"@viewerOpenPanoramaButtonLabel": {}, "@viewerOpenPanoramaButtonLabel": {},
"viewerOpenTooltip": "Open", "viewerOpenTooltip": "Open",
"@viewerOpenTooltip": {}, "@viewerOpenTooltip": {},
"viewerPauseTooltip": "Pause",
"@viewerPauseTooltip": {},
"viewerPlayTooltip": "Play",
"@viewerPlayTooltip": {},
"viewerErrorUnknown": "Oops!", "viewerErrorUnknown": "Oops!",
"@viewerErrorUnknown": {}, "@viewerErrorUnknown": {},
"viewerErrorDoesNotExist": "The file no longer exists.", "viewerErrorDoesNotExist": "The file no longer exists.",

View file

@ -47,6 +47,10 @@
"entryActionAddFavourite": "즐겨찾기에 추가", "entryActionAddFavourite": "즐겨찾기에 추가",
"entryActionRemoveFavourite": "즐겨찾기에서 삭제", "entryActionRemoveFavourite": "즐겨찾기에서 삭제",
"videoActionPause": "일시정지",
"videoActionPlay": "재생",
"videoActionSetSpeed": "재생 배속",
"filterFavouriteLabel": "즐겨찾기", "filterFavouriteLabel": "즐겨찾기",
"filterLocationEmptyLabel": "장소 없음", "filterLocationEmptyLabel": "장소 없음",
"filterTagEmptyLabel": "태그 없음", "filterTagEmptyLabel": "태그 없음",
@ -118,6 +122,8 @@
"renameEntryDialogLabel": "이름", "renameEntryDialogLabel": "이름",
"videoSpeedDialogLabel": "재생 배속",
"genericSuccessFeedback": "정상 처리됐습니다", "genericSuccessFeedback": "정상 처리됐습니다",
"genericFailureFeedback": "오류가 발생했습니다", "genericFailureFeedback": "오류가 발생했습니다",
@ -293,8 +299,6 @@
"viewerOpenPanoramaButtonLabel": "파노라마 열기", "viewerOpenPanoramaButtonLabel": "파노라마 열기",
"viewerOpenTooltip": "열기", "viewerOpenTooltip": "열기",
"viewerPauseTooltip": "일시정지",
"viewerPlayTooltip": "재생",
"viewerErrorUnknown": "아이구!", "viewerErrorUnknown": "아이구!",
"viewerErrorDoesNotExist": "파일이 존재하지 않습니다.", "viewerErrorDoesNotExist": "파일이 존재하지 않습니다.",

View file

@ -0,0 +1,37 @@
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
enum VideoAction {
togglePlay,
setSpeed,
}
class VideoActions {
static const all = [
VideoAction.togglePlay,
VideoAction.setSpeed,
];
}
extension ExtraVideoAction on VideoAction {
String getText(BuildContext context) {
switch (this) {
case VideoAction.togglePlay:
// different data depending on toggle state
return context.l10n.videoActionPlay;
case VideoAction.setSpeed:
return context.l10n.videoActionSetSpeed;
}
}
IconData? getIcon() {
switch (this) {
case VideoAction.togglePlay:
// different data depending on toggle state
return AIcons.play;
case VideoAction.setSpeed:
return AIcons.speed;
}
}
}

View file

@ -12,27 +12,28 @@ class MimeFilter extends CollectionFilter {
final String mime; final String mime;
late EntryFilter _test; late EntryFilter _test;
late String _label; late String _label;
IconData? /*late*/ _icon; late IconData _icon;
static final image = MimeFilter(MimeTypes.anyImage); static final image = MimeFilter(MimeTypes.anyImage);
static final video = MimeFilter(MimeTypes.anyVideo); static final video = MimeFilter(MimeTypes.anyVideo);
MimeFilter(this.mime) { MimeFilter(this.mime) {
IconData? icon;
var lowMime = mime.toLowerCase(); var lowMime = mime.toLowerCase();
if (lowMime.endsWith('/*')) { if (lowMime.endsWith('/*')) {
lowMime = lowMime.substring(0, lowMime.length - 2); lowMime = lowMime.substring(0, lowMime.length - 2);
_test = (entry) => entry.mimeType.startsWith(lowMime); _test = (entry) => entry.mimeType.startsWith(lowMime);
_label = lowMime.toUpperCase(); _label = lowMime.toUpperCase();
if (mime == MimeTypes.anyImage) { if (mime == MimeTypes.anyImage) {
_icon = AIcons.image; icon = AIcons.image;
} else if (mime == MimeTypes.anyVideo) { } else if (mime == MimeTypes.anyVideo) {
_icon = AIcons.video; icon = AIcons.video;
} }
} else { } else {
_test = (entry) => entry.mimeType == lowMime; _test = (entry) => entry.mimeType == lowMime;
_label = MimeUtils.displayType(lowMime); _label = MimeUtils.displayType(lowMime);
} }
_icon ??= AIcons.vector; _icon = icon ?? AIcons.vector;
} }
MimeFilter.fromMap(Map<String, dynamic> json) MimeFilter.fromMap(Map<String, dynamic> json)

View file

@ -15,7 +15,7 @@ class TypeFilter extends CollectionFilter {
final String itemType; final String itemType;
late EntryFilter _test; late EntryFilter _test;
IconData? /*late*/ _icon; late IconData _icon;
static final animated = TypeFilter._private(_animated); static final animated = TypeFilter._private(_animated);
static final geotiff = TypeFilter._private(_geotiff); static final geotiff = TypeFilter._private(_geotiff);

View file

@ -1,4 +1,5 @@
import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/video_actions.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/screen_on.dart'; import 'package:aves/model/settings/screen_on.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -15,7 +16,7 @@ import 'enums.dart';
final Settings settings = Settings._private(); final Settings settings = Settings._private();
class Settings extends ChangeNotifier { class Settings extends ChangeNotifier {
static SharedPreferences? /*late final*/ _prefs; static SharedPreferences? _prefs;
Settings._private(); Settings._private();
@ -49,6 +50,7 @@ class Settings extends ChangeNotifier {
static const showOverlayInfoKey = 'show_overlay_info'; static const showOverlayInfoKey = 'show_overlay_info';
static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details'; static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details';
static const viewerQuickActionsKey = 'viewer_quick_actions'; static const viewerQuickActionsKey = 'viewer_quick_actions';
static const videoQuickActionsKey = 'video_quick_actions';
// video // video
static const enableVideoHardwareAccelerationKey = 'video_hwaccel_mediacodec'; static const enableVideoHardwareAccelerationKey = 'video_hwaccel_mediacodec';
@ -76,6 +78,9 @@ class Settings extends ChangeNotifier {
EntryAction.toggleFavourite, EntryAction.toggleFavourite,
EntryAction.share, EntryAction.share,
]; ];
static const videoQuickActionsDefault = [
VideoAction.togglePlay,
];
Future<void> init() async { Future<void> init() async {
_prefs = await SharedPreferences.getInstance(); _prefs = await SharedPreferences.getInstance();
@ -229,6 +234,10 @@ class Settings extends ChangeNotifier {
set viewerQuickActions(List<EntryAction> newValue) => setAndNotify(viewerQuickActionsKey, newValue.map((v) => v.toString()).toList()); set viewerQuickActions(List<EntryAction> newValue) => setAndNotify(viewerQuickActionsKey, newValue.map((v) => v.toString()).toList());
List<VideoAction> get videoQuickActions => getEnumListOrDefault(videoQuickActionsKey, videoQuickActionsDefault, VideoAction.values);
set videoQuickActions(List<VideoAction> newValue) => setAndNotify(videoQuickActionsKey, newValue.map((v) => v.toString()).toList());
// video // video
set enableVideoHardwareAcceleration(bool newValue) => setAndNotify(enableVideoHardwareAccelerationKey, newValue); set enableVideoHardwareAcceleration(bool newValue) => setAndNotify(enableVideoHardwareAccelerationKey, newValue);

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
// ignore: import_of_legacy_library_into_null_safe // ignore: import_of_legacy_library_into_null_safe
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
@ -47,6 +48,8 @@ class AIcons {
static const IconData layers = Icons.layers_outlined; static const IconData layers = Icons.layers_outlined;
static const IconData openOutside = Icons.open_in_new_outlined; static const IconData openOutside = Icons.open_in_new_outlined;
static const IconData pin = Icons.push_pin_outlined; static const IconData pin = Icons.push_pin_outlined;
static const IconData play = Icons.play_arrow;
static const IconData pause = Icons.pause;
static const IconData print = Icons.print_outlined; static const IconData print = Icons.print_outlined;
static const IconData rename = Icons.title_outlined; static const IconData rename = Icons.title_outlined;
static const IconData rotateLeft = Icons.rotate_left_outlined; static const IconData rotateLeft = Icons.rotate_left_outlined;
@ -56,6 +59,7 @@ class AIcons {
static const IconData setCover = MdiIcons.imageEditOutline; static const IconData setCover = MdiIcons.imageEditOutline;
static const IconData share = Icons.share_outlined; static const IconData share = Icons.share_outlined;
static const IconData sort = Icons.sort_outlined; static const IconData sort = Icons.sort_outlined;
static const IconData speed = Icons.speed_outlined;
static const IconData stats = Icons.pie_chart_outlined; static const IconData stats = Icons.pie_chart_outlined;
static const IconData zoomIn = Icons.add_outlined; static const IconData zoomIn = Icons.add_outlined;
static const IconData zoomOut = Icons.remove_outlined; static const IconData zoomOut = Icons.remove_outlined;
@ -75,7 +79,7 @@ class AIcons {
static const IconData geo = Icons.language_outlined; static const IconData geo = Icons.language_outlined;
static const IconData motionPhoto = Icons.motion_photos_on_outlined; static const IconData motionPhoto = Icons.motion_photos_on_outlined;
static const IconData multiPage = Icons.burst_mode_outlined; static const IconData multiPage = Icons.burst_mode_outlined;
static const IconData play = Icons.play_circle_outline; static const IconData videoThumb = Icons.play_circle_outline;
static const IconData threeSixty = Icons.threesixty_outlined; static const IconData threeSixty = Icons.threesixty_outlined;
static const IconData selected = Icons.check_circle_outline; static const IconData selected = Icons.check_circle_outline;
static const IconData unselected = Icons.radio_button_unchecked; static const IconData unselected = Icons.radio_button_unchecked;

View file

@ -23,7 +23,7 @@ class VideoIcon extends StatelessWidget {
final thumbnailTheme = context.watch<ThumbnailThemeData>(); final thumbnailTheme = context.watch<ThumbnailThemeData>();
final showDuration = thumbnailTheme.showVideoDuration; final showDuration = thumbnailTheme.showVideoDuration;
Widget child = OverlayIcon( Widget child = OverlayIcon(
icon: entry.is360 ? AIcons.threeSixty : AIcons.play, icon: entry.is360 ? AIcons.threeSixty : AIcons.videoThumb,
size: thumbnailTheme.iconSize, size: thumbnailTheme.iconSize,
text: showDuration ? entry.durationText : null, text: showDuration ? entry.durationText : null,
iconScale: entry.is360 && showDuration ? .9 : 1, iconScale: entry.is360 && showDuration ? .9 : 1,

View file

@ -0,0 +1,70 @@
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart';
import 'aves_dialog.dart';
class VideoSpeedDialog extends StatefulWidget {
final double current, min, max;
const VideoSpeedDialog({
required this.current,
required this.min,
required this.max,
});
@override
_VideoSpeedDialogState createState() => _VideoSpeedDialogState();
}
class _VideoSpeedDialogState extends State<VideoSpeedDialog> {
late double _speed;
static const interval = .25;
@override
void initState() {
super.initState();
_speed = widget.current;
}
@override
Widget build(BuildContext context) {
return AvesDialog(
context: context,
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const SizedBox(width: 24),
Text(context.l10n.videoSpeedDialogLabel),
const SizedBox(width: 16),
Text('x$_speed'),
],
),
const SizedBox(height: 16),
Slider(
value: _speed,
onChanged: (v) => setState(() => _speed = v),
min: widget.min,
max: widget.max,
divisions: ((widget.max - widget.min) / interval).round(),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: () => _submit(context),
child: Text(context.l10n.applyButtonLabel),
),
],
);
}
void _submit(BuildContext context) => Navigator.pop(context, _speed);
}

View file

@ -1,17 +1,22 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/actions/video_actions.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_app_service.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/time_utils.dart'; import 'package:aves/utils/time_utils.dart';
import 'package:aves/widgets/common/basic/menu_row.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/common/fx/blurred.dart';
import 'package:aves/widgets/common/fx/borders.dart'; import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/dialogs/video_speed_dialog.dart';
import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:aves/widgets/viewer/overlay/notifications.dart'; import 'package:aves/widgets/viewer/overlay/notifications.dart';
import 'package:aves/widgets/viewer/video/controller.dart'; import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
class VideoControlOverlay extends StatefulWidget { class VideoControlOverlay extends StatefulWidget {
final AvesEntry entry; final AvesEntry entry;
@ -32,8 +37,6 @@ class VideoControlOverlay extends StatefulWidget {
class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTickerProviderStateMixin { class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTickerProviderStateMixin {
final GlobalKey _progressBarKey = GlobalKey(debugLabel: 'video-progress-bar'); final GlobalKey _progressBarKey = GlobalKey(debugLabel: 'video-progress-bar');
bool _playingOnDragStart = false; bool _playingOnDragStart = false;
late AnimationController _playPauseAnimation;
final List<StreamSubscription> _subscriptions = [];
AvesEntry get entry => widget.entry; AvesEntry get entry => widget.entry;
@ -47,44 +50,6 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
bool get isPlaying => controller?.isPlaying ?? false; bool get isPlaying => controller?.isPlaying ?? false;
@override
void initState() {
super.initState();
_playPauseAnimation = AnimationController(
duration: Durations.iconAnimation,
vsync: this,
);
_registerWidget(widget);
}
@override
void didUpdateWidget(covariant VideoControlOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
_unregisterWidget(widget);
_playPauseAnimation.dispose();
super.dispose();
}
void _registerWidget(VideoControlOverlay widget) {
final controller = widget.controller;
if (controller != null) {
_subscriptions.add(controller.statusStream.listen(_onStatusChange));
_onStatusChange(controller.status);
}
}
void _unregisterWidget(VideoControlOverlay widget) {
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return StreamBuilder<VideoStatus>( return StreamBuilder<VideoStatus>(
@ -92,40 +57,42 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
builder: (context, snapshot) { builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos // do not use stream snapshot because it is obsolete when switching between videos
final status = controller?.status ?? VideoStatus.idle; final status = controller?.status ?? VideoStatus.idle;
List<Widget> children;
if (status == VideoStatus.error) {
children = [
OverlayButton(
scale: scale,
child: IconButton(
icon: const Icon(AIcons.openOutside),
onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype),
tooltip: context.l10n.viewerOpenTooltip,
),
),
];
} else {
final quickActions = settings.videoQuickActions;
final menuActions = VideoActions.all.where((action) => !quickActions.contains(action)).toList();
children = [
Expanded(
child: _buildProgressBar(),
),
const SizedBox(width: 8),
_ButtonRow(
quickActions: quickActions,
menuActions: menuActions,
scale: scale,
controller: controller,
),
];
}
return TooltipTheme( return TooltipTheme(
data: TooltipTheme.of(context).copyWith( data: TooltipTheme.of(context).copyWith(
preferBelow: false, preferBelow: false,
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: status == VideoStatus.error children: children,
? [
OverlayButton(
scale: scale,
child: IconButton(
icon: const Icon(AIcons.openOutside),
onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype),
tooltip: context.l10n.viewerOpenTooltip,
),
),
]
: [
Expanded(
child: _buildProgressBar(),
),
const SizedBox(width: 8),
OverlayButton(
scale: scale,
child: IconButton(
icon: AnimatedIcon(
icon: AnimatedIcons.play_pause,
progress: _playPauseAnimation,
),
onPressed: _togglePlayPause,
tooltip: isPlaying ? context.l10n.viewerPauseTooltip : context.l10n.viewerPlayTooltip,
),
),
],
), ),
); );
}); });
@ -196,27 +163,6 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
); );
} }
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();
}
}
Future<void> _togglePlayPause() async {
if (controller == null) return;
if (isPlaying) {
await controller!.pause();
} else {
await controller!.play();
// hide overlay
await Future.delayed(Durations.iconAnimation);
ToggleOverlayNotification().dispatch(context);
}
}
void _seekFromTap(Offset globalPosition) async { void _seekFromTap(Offset globalPosition) async {
if (controller == null) return; if (controller == null) return;
final keyContext = _progressBarKey.currentContext!; final keyContext = _progressBarKey.currentContext!;
@ -225,3 +171,223 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
await controller!.seekToProgress(localPosition.dx / box.size.width); await controller!.seekToProgress(localPosition.dx / box.size.width);
} }
} }
class _ButtonRow extends StatelessWidget {
final List<VideoAction> quickActions, menuActions;
final Animation<double> scale;
final AvesVideoController? controller;
const _ButtonRow({
Key? key,
required this.quickActions,
required this.menuActions,
required this.scale,
required this.controller,
}) : super(key: key);
static const double padding = 8;
bool get isPlaying => controller?.isPlaying ?? false;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
...quickActions.map((action) => _buildOverlayButton(context, action)),
OverlayButton(
scale: scale,
child: PopupMenuButton<VideoAction>(
itemBuilder: (context) => menuActions.map((action) => _buildPopupMenuItem(context, action)).toList(),
onSelected: (action) {
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(context, action));
},
),
),
],
);
}
Widget _buildOverlayButton(BuildContext context, VideoAction action) {
late Widget child;
void onPressed() => _onActionSelected(context, action);
switch (action) {
case VideoAction.togglePlay:
child = _PlayToggler(
controller: controller,
onPressed: onPressed,
);
break;
case VideoAction.setSpeed:
child = IconButton(
icon: Icon(action.getIcon()),
onPressed: onPressed,
tooltip: action.getText(context),
);
break;
}
return Padding(
padding: const EdgeInsetsDirectional.only(end: padding),
child: OverlayButton(
scale: scale,
child: child,
),
);
}
PopupMenuEntry<VideoAction> _buildPopupMenuItem(BuildContext context, VideoAction action) {
Widget? child;
switch (action) {
case VideoAction.togglePlay:
child = _PlayToggler(
controller: controller,
isMenuItem: true,
);
break;
case VideoAction.setSpeed:
child = MenuRow(text: action.getText(context), icon: action.getIcon());
break;
}
return PopupMenuItem(
value: action,
child: child,
);
}
void _onActionSelected(BuildContext context, VideoAction action) {
switch (action) {
case VideoAction.togglePlay:
_togglePlayPause(context);
break;
case VideoAction.setSpeed:
_showSpeedDialog(context);
break;
}
}
Future<void> _showSpeedDialog(BuildContext context) async {
final _controller = controller;
if (_controller == null) return;
final newSpeed = await showDialog<double>(
context: context,
builder: (context) => VideoSpeedDialog(
current: _controller.speed,
min: _controller.minSpeed,
max: _controller.maxSpeed,
),
);
if (newSpeed == null) return;
_controller.speed = newSpeed;
}
Future<void> _togglePlayPause(BuildContext context) async {
final _controller = controller;
if (_controller == null) return;
if (isPlaying) {
await _controller.pause();
} else {
await _controller.play();
// hide overlay
await Future.delayed(Durations.iconAnimation);
ToggleOverlayNotification().dispatch(context);
}
}
}
class _PlayToggler extends StatefulWidget {
final AvesVideoController? controller;
final bool isMenuItem;
final VoidCallback? onPressed;
const _PlayToggler({
required this.controller,
this.isMenuItem = false,
this.onPressed,
});
@override
_PlayTogglerState 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: Durations.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: AIcons.pause,
)
: MenuRow(
text: context.l10n.videoActionPlay,
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

@ -39,6 +39,14 @@ abstract class AvesVideoController {
ValueNotifier<double> get sarNotifier; ValueNotifier<double> get sarNotifier;
double get speed;
double get minSpeed;
double get maxSpeed;
set speed(double speed);
Widget buildPlayerWidget(BuildContext context); Widget buildPlayerWidget(BuildContext context);
} }

View file

@ -26,6 +26,16 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
final ValueNotifier<StreamSummary?> _selectedAudioStream = ValueNotifier(null); final ValueNotifier<StreamSummary?> _selectedAudioStream = ValueNotifier(null);
final ValueNotifier<StreamSummary?> _selectedTextStream = ValueNotifier(null); final ValueNotifier<StreamSummary?> _selectedTextStream = ValueNotifier(null);
Timer? _initialPlayTimer; Timer? _initialPlayTimer;
double _speed = 1;
// audio/video get out of sync with speed < .5
// the video stream plays at .5 but the audio is slowed as requested
@override
final double minSpeed = .5;
// android.media.AudioTrack fails with speed > 2
@override
final double maxSpeed = 2;
@override @override
final ValueNotifier<double> sarNotifier = ValueNotifier(1); final ValueNotifier<double> sarNotifier = ValueNotifier(1);
@ -67,6 +77,9 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
// calling `setDataSource()` with `autoPlay` starts as soon as possible, but often yields initial artifacts // calling `setDataSource()` with `autoPlay` starts as soon as possible, but often yields initial artifacts
// so we introduce a small delay after the player is declared `prepared`, before playing // so we introduce a small delay after the player is declared `prepared`, before playing
await _instance.setDataSourceUntilPrepared(entry.uri); await _instance.setDataSourceUntilPrepared(entry.uri);
if (speed != 1) {
_applySpeed();
}
_initialPlayTimer = Timer(initialPlayDelay, play); _initialPlayTimer = Timer(initialPlayDelay, play);
} }
@ -83,7 +96,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
const accurateSeekEnabled = false; const accurateSeekEnabled = false;
// playing with HW acceleration seems to skip the last frames of some videos // playing with HW acceleration seems to skip the last frames of some videos
// so HW acceleration is always disabled for gif-like videos where the last frames may be significant // so HW acceleration is always disabled for GIF-like videos where the last frames may be significant
final hwAccelerationEnabled = settings.enableVideoHardwareAcceleration && entry.durationMillis! > gifLikeVideoDurationThreshold.inMilliseconds; final hwAccelerationEnabled = settings.enableVideoHardwareAcceleration && entry.durationMillis! > gifLikeVideoDurationThreshold.inMilliseconds;
// TODO TLAD HW codecs sometimes fail when seek-starting some videos, e.g. MP2TS/h264(HDPR) // TODO TLAD HW codecs sometimes fail when seek-starting some videos, e.g. MP2TS/h264(HDPR)
@ -100,29 +113,42 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
// in practice the flag seems ineffective, but harmless too // in practice the flag seems ineffective, but harmless too
options.setFormatOption('fflags', 'fastseek'); options.setFormatOption('fflags', 'fastseek');
// `enable-accurate-seek`: enable accurate seek, default: 0, in [0, 1] // `accurate-seek-timeout`: accurate seek timeout
options.setPlayerOption('enable-accurate-seek', accurateSeekEnabled ? 1 : 0); // default: 5000 ms, in [0, 5000]
// `accurate-seek-timeout`: accurate seek timeout, default: 5000 ms, in [0, 5000]
options.setPlayerOption('accurate-seek-timeout', 1000); options.setPlayerOption('accurate-seek-timeout', 1000);
// `framedrop`: drop frames when cpu is too slow, default: 0, in [-1, 120] // `cover-after-prepared`: show cover provided to `FijkView` when player is `prepared` without auto play
options.setPlayerOption('framedrop', 5); // default: 0, in [0, 1]
// `loop`: set number of times the playback shall be looped, default: 1, in [INT_MIN, INT_MAX]
options.setPlayerOption('loop', loopEnabled ? -1 : 1);
// `mediacodec-all-videos`: MediaCodec: enable all videos, default: 0, in [0, 1]
options.setPlayerOption('mediacodec-all-videos', hwAccelerationEnabled ? 1 : 0);
// `seek-at-start`: set offset of player should be seeked, default: 0, in [0, INT_MAX]
options.setPlayerOption('seek-at-start', startMillis);
// `cover-after-prepared`: show cover provided to `FijkView` when player is `prepared` without auto play, default: 0, in [0, 1]
options.setPlayerOption('cover-after-prepared', 0); options.setPlayerOption('cover-after-prepared', 0);
// `enable-accurate-seek`: enable accurate seek
// default: 0, in [0, 1]
options.setPlayerOption('enable-accurate-seek', accurateSeekEnabled ? 1 : 0);
// `framedrop`: drop frames when cpu is too slow
// default: 0, in [-1, 120]
options.setPlayerOption('framedrop', 5);
// `loop`: set number of times the playback shall be looped
// default: 1, in [INT_MIN, INT_MAX]
options.setPlayerOption('loop', loopEnabled ? -1 : 1);
// `mediacodec-all-videos`: MediaCodec: enable all videos
// default: 0, in [0, 1]
options.setPlayerOption('mediacodec-all-videos', hwAccelerationEnabled ? 1 : 0);
// `seek-at-start`: set offset of player should be seeked
// default: 0, in [0, INT_MAX]
options.setPlayerOption('seek-at-start', startMillis);
// `soundtouch`: enable SoundTouch
// default: 0, in [0, 1]
// slowed down videos with SoundTouch enabled have a weird wobbly audio
options.setPlayerOption('soundtouch', 0);
// TODO TLAD try subs // TODO TLAD try subs
// `subtitle`: decode subtitle stream, default: 0, in [0, 1] // `subtitle`: decode subtitle stream
// default: 0, in [0, 1]
// option.setPlayerOption('subtitle', 1); // option.setPlayerOption('subtitle', 1);
_instance.applyOptions(options); _instance.applyOptions(options);
@ -232,6 +258,19 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
@override @override
Stream<int> get positionStream => _instance.onCurrentPosUpdate.map((pos) => pos.inMilliseconds); Stream<int> get positionStream => _instance.onCurrentPosUpdate.map((pos) => pos.inMilliseconds);
@override
double get speed => _speed;
@override
set speed(double speed) {
if (speed <= 0 || _speed == speed) return;
_speed = speed;
_applySpeed();
}
// TODO TLAD setting speed fails when there is no audio stream or audio is disabled
void _applySpeed() => _instance.setSpeed(speed);
@override @override
Widget buildPlayerWidget(BuildContext context) { Widget buildPlayerWidget(BuildContext context) {
return ValueListenableBuilder<double>( return ValueListenableBuilder<double>(