video: set speed
This commit is contained in:
parent
f56b3dc0e4
commit
37e4d21277
12 changed files with 466 additions and 122 deletions
|
@ -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.",
|
||||||
|
|
|
@ -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": "파일이 존재하지 않습니다.",
|
||||||
|
|
||||||
|
|
37
lib/model/actions/video_actions.dart
Normal file
37
lib/model/actions/video_actions.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
70
lib/widgets/dialogs/video_speed_dialog.dart
Normal file
70
lib/widgets/dialogs/video_speed_dialog.dart
Normal 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);
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>(
|
||||||
|
|
Loading…
Reference in a new issue