From 37e4d21277a2467dbfc0c78e0d44bf62aa87bc13 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sat, 12 Jun 2021 19:09:05 +0900 Subject: [PATCH] video: set speed --- lib/l10n/app_en.arb | 14 +- lib/l10n/app_ko.arb | 8 +- lib/model/actions/video_actions.dart | 37 ++ lib/model/filters/mime.dart | 9 +- lib/model/filters/type.dart | 2 +- lib/model/settings/settings.dart | 11 +- lib/theme/icons.dart | 6 +- lib/widgets/common/identity/aves_icons.dart | 2 +- lib/widgets/dialogs/video_speed_dialog.dart | 70 ++++ lib/widgets/viewer/overlay/bottom/video.dart | 344 ++++++++++++++----- lib/widgets/viewer/video/controller.dart | 8 + lib/widgets/viewer/video/fijkplayer.dart | 77 ++++- 12 files changed, 466 insertions(+), 122 deletions(-) create mode 100644 lib/model/actions/video_actions.dart create mode 100644 lib/widgets/dialogs/video_speed_dialog.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b97799115..0cd864571 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -93,6 +93,13 @@ "entryActionRemoveFavourite": "Remove from favourites", "@entryActionRemoveFavourite": {}, + "videoActionPause": "Pause", + "@videoActionPause": {}, + "videoActionPlay": "Play", + "@videoActionPlay": {}, + "videoActionSetSpeed": "Playback speed", + "@videoActionSetSpeed": {}, + "filterFavouriteLabel": "Favourite", "@filterFavouriteLabel": {}, "filterLocationEmptyLabel": "Unlocated", @@ -263,6 +270,9 @@ "renameEntryDialogLabel": "New name", "@renameEntryDialogLabel": {}, + "videoSpeedDialogLabel": "Playback speed", + "@videoSpeedDialogLabel": {}, + "genericSuccessFeedback": "Done!", "@genericSuccessFeedback": {}, "genericFailureFeedback": "Failed", @@ -629,10 +639,6 @@ "@viewerOpenPanoramaButtonLabel": {}, "viewerOpenTooltip": "Open", "@viewerOpenTooltip": {}, - "viewerPauseTooltip": "Pause", - "@viewerPauseTooltip": {}, - "viewerPlayTooltip": "Play", - "@viewerPlayTooltip": {}, "viewerErrorUnknown": "Oops!", "@viewerErrorUnknown": {}, "viewerErrorDoesNotExist": "The file no longer exists.", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 4a5cb44dc..cad834a1d 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -47,6 +47,10 @@ "entryActionAddFavourite": "즐겨찾기에 추가", "entryActionRemoveFavourite": "즐겨찾기에서 삭제", + "videoActionPause": "일시정지", + "videoActionPlay": "재생", + "videoActionSetSpeed": "재생 배속", + "filterFavouriteLabel": "즐겨찾기", "filterLocationEmptyLabel": "장소 없음", "filterTagEmptyLabel": "태그 없음", @@ -118,6 +122,8 @@ "renameEntryDialogLabel": "이름", + "videoSpeedDialogLabel": "재생 배속", + "genericSuccessFeedback": "정상 처리됐습니다", "genericFailureFeedback": "오류가 발생했습니다", @@ -293,8 +299,6 @@ "viewerOpenPanoramaButtonLabel": "파노라마 열기", "viewerOpenTooltip": "열기", - "viewerPauseTooltip": "일시정지", - "viewerPlayTooltip": "재생", "viewerErrorUnknown": "아이구!", "viewerErrorDoesNotExist": "파일이 존재하지 않습니다.", diff --git a/lib/model/actions/video_actions.dart b/lib/model/actions/video_actions.dart new file mode 100644 index 000000000..2902e15a7 --- /dev/null +++ b/lib/model/actions/video_actions.dart @@ -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; + } + } +} diff --git a/lib/model/filters/mime.dart b/lib/model/filters/mime.dart index 0d447a62e..750cdf0a4 100644 --- a/lib/model/filters/mime.dart +++ b/lib/model/filters/mime.dart @@ -12,27 +12,28 @@ class MimeFilter extends CollectionFilter { final String mime; late EntryFilter _test; late String _label; - IconData? /*late*/ _icon; + late IconData _icon; static final image = MimeFilter(MimeTypes.anyImage); static final video = MimeFilter(MimeTypes.anyVideo); MimeFilter(this.mime) { + IconData? icon; var lowMime = mime.toLowerCase(); if (lowMime.endsWith('/*')) { lowMime = lowMime.substring(0, lowMime.length - 2); _test = (entry) => entry.mimeType.startsWith(lowMime); _label = lowMime.toUpperCase(); if (mime == MimeTypes.anyImage) { - _icon = AIcons.image; + icon = AIcons.image; } else if (mime == MimeTypes.anyVideo) { - _icon = AIcons.video; + icon = AIcons.video; } } else { _test = (entry) => entry.mimeType == lowMime; _label = MimeUtils.displayType(lowMime); } - _icon ??= AIcons.vector; + _icon = icon ?? AIcons.vector; } MimeFilter.fromMap(Map json) diff --git a/lib/model/filters/type.dart b/lib/model/filters/type.dart index a808971c4..ec0f51aeb 100644 --- a/lib/model/filters/type.dart +++ b/lib/model/filters/type.dart @@ -15,7 +15,7 @@ class TypeFilter extends CollectionFilter { final String itemType; late EntryFilter _test; - IconData? /*late*/ _icon; + late IconData _icon; static final animated = TypeFilter._private(_animated); static final geotiff = TypeFilter._private(_geotiff); diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index cb68e2ff6..ad708c318 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -1,4 +1,5 @@ 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/settings/screen_on.dart'; import 'package:collection/collection.dart'; @@ -15,7 +16,7 @@ import 'enums.dart'; final Settings settings = Settings._private(); class Settings extends ChangeNotifier { - static SharedPreferences? /*late final*/ _prefs; + static SharedPreferences? _prefs; Settings._private(); @@ -49,6 +50,7 @@ class Settings extends ChangeNotifier { static const showOverlayInfoKey = 'show_overlay_info'; static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details'; static const viewerQuickActionsKey = 'viewer_quick_actions'; + static const videoQuickActionsKey = 'video_quick_actions'; // video static const enableVideoHardwareAccelerationKey = 'video_hwaccel_mediacodec'; @@ -76,6 +78,9 @@ class Settings extends ChangeNotifier { EntryAction.toggleFavourite, EntryAction.share, ]; + static const videoQuickActionsDefault = [ + VideoAction.togglePlay, + ]; Future init() async { _prefs = await SharedPreferences.getInstance(); @@ -229,6 +234,10 @@ class Settings extends ChangeNotifier { set viewerQuickActions(List newValue) => setAndNotify(viewerQuickActionsKey, newValue.map((v) => v.toString()).toList()); + List get videoQuickActions => getEnumListOrDefault(videoQuickActionsKey, videoQuickActionsDefault, VideoAction.values); + + set videoQuickActions(List newValue) => setAndNotify(videoQuickActionsKey, newValue.map((v) => v.toString()).toList()); + // video set enableVideoHardwareAcceleration(bool newValue) => setAndNotify(enableVideoHardwareAccelerationKey, newValue); diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index d6ad028d0..73fd19fd0 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; + // ignore: import_of_legacy_library_into_null_safe 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 openOutside = Icons.open_in_new_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 rename = Icons.title_outlined; static const IconData rotateLeft = Icons.rotate_left_outlined; @@ -56,6 +59,7 @@ class AIcons { static const IconData setCover = MdiIcons.imageEditOutline; static const IconData share = Icons.share_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 zoomIn = Icons.add_outlined; static const IconData zoomOut = Icons.remove_outlined; @@ -75,7 +79,7 @@ class AIcons { static const IconData geo = Icons.language_outlined; static const IconData motionPhoto = Icons.motion_photos_on_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 selected = Icons.check_circle_outline; static const IconData unselected = Icons.radio_button_unchecked; diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index 433764ab3..735ea25e3 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -23,7 +23,7 @@ class VideoIcon extends StatelessWidget { final thumbnailTheme = context.watch(); final showDuration = thumbnailTheme.showVideoDuration; Widget child = OverlayIcon( - icon: entry.is360 ? AIcons.threeSixty : AIcons.play, + icon: entry.is360 ? AIcons.threeSixty : AIcons.videoThumb, size: thumbnailTheme.iconSize, text: showDuration ? entry.durationText : null, iconScale: entry.is360 && showDuration ? .9 : 1, diff --git a/lib/widgets/dialogs/video_speed_dialog.dart b/lib/widgets/dialogs/video_speed_dialog.dart new file mode 100644 index 000000000..280234aa5 --- /dev/null +++ b/lib/widgets/dialogs/video_speed_dialog.dart @@ -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 { + 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); +} diff --git a/lib/widgets/viewer/overlay/bottom/video.dart b/lib/widgets/viewer/overlay/bottom/video.dart index 7ba521522..358fa935a 100644 --- a/lib/widgets/viewer/overlay/bottom/video.dart +++ b/lib/widgets/viewer/overlay/bottom/video.dart @@ -1,17 +1,22 @@ import 'dart:async'; +import 'package:aves/model/actions/video_actions.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/theme/durations.dart'; import 'package:aves/theme/icons.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/fx/blurred.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/notifications.dart'; import 'package:aves/widgets/viewer/video/controller.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; class VideoControlOverlay extends StatefulWidget { final AvesEntry entry; @@ -32,8 +37,6 @@ class VideoControlOverlay extends StatefulWidget { class _VideoControlOverlayState extends State with SingleTickerProviderStateMixin { final GlobalKey _progressBarKey = GlobalKey(debugLabel: 'video-progress-bar'); bool _playingOnDragStart = false; - late AnimationController _playPauseAnimation; - final List _subscriptions = []; AvesEntry get entry => widget.entry; @@ -47,44 +50,6 @@ class _VideoControlOverlayState extends State with SingleTi 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 Widget build(BuildContext context) { return StreamBuilder( @@ -92,40 +57,42 @@ class _VideoControlOverlayState extends State with SingleTi builder: (context, snapshot) { // do not use stream snapshot because it is obsolete when switching between videos final status = controller?.status ?? VideoStatus.idle; + List 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( data: TooltipTheme.of(context).copyWith( preferBelow: false, ), child: Row( mainAxisAlignment: MainAxisAlignment.end, - children: status == VideoStatus.error - ? [ - 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, - ), - ), - ], + children: children, ), ); }); @@ -196,27 +163,6 @@ class _VideoControlOverlayState extends State 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 _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 { if (controller == null) return; final keyContext = _progressBarKey.currentContext!; @@ -225,3 +171,223 @@ class _VideoControlOverlayState extends State with SingleTi await controller!.seekToProgress(localPosition.dx / box.size.width); } } + +class _ButtonRow extends StatelessWidget { + final List quickActions, menuActions; + final Animation 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( + 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 _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 _showSpeedDialog(BuildContext context) async { + final _controller = controller; + if (_controller == null) return; + + final newSpeed = await showDialog( + context: context, + builder: (context) => VideoSpeedDialog( + current: _controller.speed, + min: _controller.minSpeed, + max: _controller.maxSpeed, + ), + ); + if (newSpeed == null) return; + + _controller.speed = newSpeed; + } + + Future _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 _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(); + } + } +} diff --git a/lib/widgets/viewer/video/controller.dart b/lib/widgets/viewer/video/controller.dart index bdbfef65d..4093fb00d 100644 --- a/lib/widgets/viewer/video/controller.dart +++ b/lib/widgets/viewer/video/controller.dart @@ -39,6 +39,14 @@ abstract class AvesVideoController { ValueNotifier get sarNotifier; + double get speed; + + double get minSpeed; + + double get maxSpeed; + + set speed(double speed); + Widget buildPlayerWidget(BuildContext context); } diff --git a/lib/widgets/viewer/video/fijkplayer.dart b/lib/widgets/viewer/video/fijkplayer.dart index 41ff90954..5a190a46e 100644 --- a/lib/widgets/viewer/video/fijkplayer.dart +++ b/lib/widgets/viewer/video/fijkplayer.dart @@ -26,6 +26,16 @@ class IjkPlayerAvesVideoController extends AvesVideoController { final ValueNotifier _selectedAudioStream = ValueNotifier(null); final ValueNotifier _selectedTextStream = ValueNotifier(null); 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 final ValueNotifier 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 // so we introduce a small delay after the player is declared `prepared`, before playing await _instance.setDataSourceUntilPrepared(entry.uri); + if (speed != 1) { + _applySpeed(); + } _initialPlayTimer = Timer(initialPlayDelay, play); } @@ -83,7 +96,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController { const accurateSeekEnabled = false; // 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; // 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 options.setFormatOption('fflags', 'fastseek'); - // `enable-accurate-seek`: enable accurate seek, default: 0, in [0, 1] - options.setPlayerOption('enable-accurate-seek', accurateSeekEnabled ? 1 : 0); - - // `accurate-seek-timeout`: accurate seek timeout, default: 5000 ms, in [0, 5000] + // `accurate-seek-timeout`: accurate seek timeout + // default: 5000 ms, in [0, 5000] options.setPlayerOption('accurate-seek-timeout', 1000); - // `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); - - // `cover-after-prepared`: show cover provided to `FijkView` when player is `prepared` without auto play, default: 0, in [0, 1] + // `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); + // `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 - // `subtitle`: decode subtitle stream, default: 0, in [0, 1] + // `subtitle`: decode subtitle stream + // default: 0, in [0, 1] // option.setPlayerOption('subtitle', 1); _instance.applyOptions(options); @@ -232,6 +258,19 @@ class IjkPlayerAvesVideoController extends AvesVideoController { @override Stream 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 Widget buildPlayerWidget(BuildContext context) { return ValueListenableBuilder(