diff --git a/CHANGELOG.md b/CHANGELOG.md index 277eaa677..d2b5d14f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ All notable changes to this project will be documented in this file. ### Added - Viewer: optional thumbnail preview -- Viewer: optional video gestures to play/seek +- Video: optional gestures to play/seek +- Video: mute action ### Changed diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d965c2b14..46f340203 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -96,6 +96,8 @@ "entryActionRemoveFavourite": "Remove from favorites", "videoActionCaptureFrame": "Capture frame", + "videoActionMute": "Mute", + "videoActionUnmute": "Unmute", "videoActionPause": "Pause", "videoActionPlay": "Play", "videoActionReplay10": "Seek backward 10 seconds", diff --git a/lib/model/actions/entry_actions.dart b/lib/model/actions/entry_actions.dart index 54ce983a9..b9282b429 100644 --- a/lib/model/actions/entry_actions.dart +++ b/lib/model/actions/entry_actions.dart @@ -25,6 +25,7 @@ enum EntryAction { videoCaptureFrame, videoSelectStreams, videoSetSpeed, + videoToggleMute, videoSettings, videoTogglePlay, videoReplay10, @@ -73,6 +74,7 @@ class EntryActions { EntryAction.videoCaptureFrame, EntryAction.videoSelectStreams, EntryAction.videoSetSpeed, + EntryAction.videoToggleMute, EntryAction.videoSettings, EntryAction.videoTogglePlay, EntryAction.videoReplay10, @@ -90,6 +92,7 @@ class EntryActions { static const video = [ EntryAction.videoCaptureFrame, + EntryAction.videoToggleMute, EntryAction.videoSetSpeed, EntryAction.videoSelectStreams, EntryAction.videoSettings, @@ -135,6 +138,9 @@ extension ExtraEntryAction on EntryAction { // video case EntryAction.videoCaptureFrame: return context.l10n.videoActionCaptureFrame; + case EntryAction.videoToggleMute: + // different data depending on toggle state + return context.l10n.videoActionMute; case EntryAction.videoSelectStreams: return context.l10n.videoActionSelectStreams; case EntryAction.videoSetSpeed: @@ -217,6 +223,9 @@ extension ExtraEntryAction on EntryAction { // video case EntryAction.videoCaptureFrame: return AIcons.captureFrame; + case EntryAction.videoToggleMute: + // different data depending on toggle state + return AIcons.mute; case EntryAction.videoSelectStreams: return AIcons.streams; case EntryAction.videoSetSpeed: diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index c147d43d9..8c02d5913 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -72,6 +72,8 @@ class AIcons { static const IconData layers = Icons.layers_outlined; static const IconData map = Icons.map_outlined; static const IconData move = MdiIcons.fileMoveOutline; + static const IconData mute = Icons.volume_off_outlined; + static const IconData unmute = Icons.volume_up_outlined; static const IconData newTier = Icons.fiber_new_outlined; static const IconData openOutside = Icons.open_in_new_outlined; static const IconData pin = Icons.push_pin_outlined; diff --git a/lib/widgets/settings/viewer/viewer_actions_editor.dart b/lib/widgets/settings/viewer/viewer_actions_editor.dart index f61014417..453a58f96 100644 --- a/lib/widgets/settings/viewer/viewer_actions_editor.dart +++ b/lib/widgets/settings/viewer/viewer_actions_editor.dart @@ -39,6 +39,7 @@ class ViewerActionEditorPage extends StatelessWidget { EntryAction.toggleFavourite, EntryAction.rotateScreen, EntryAction.videoCaptureFrame, + EntryAction.videoToggleMute, EntryAction.videoSetSpeed, EntryAction.videoSelectStreams, EntryAction.viewSource, diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index 1c7b931d0..8f093ba5b 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -103,6 +103,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix break; // video case EntryAction.videoCaptureFrame: + case EntryAction.videoToggleMute: case EntryAction.videoSelectStreams: case EntryAction.videoSetSpeed: case EntryAction.videoSettings: diff --git a/lib/widgets/viewer/overlay/video/controls.dart b/lib/widgets/viewer/overlay/video/controls.dart index a2e49a528..7ecb9b309 100644 --- a/lib/widgets/viewer/overlay/video/controls.dart +++ b/lib/widgets/viewer/overlay/video/controls.dart @@ -1,13 +1,8 @@ -import 'dart:async'; - import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/settings.dart'; -import 'package:aves/theme/durations.dart'; -import 'package:aves/theme/icons.dart'; -import 'package:aves/widgets/common/basic/menu.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; +import 'package:aves/widgets/viewer/overlay/video/play_toggler.dart'; import 'package:aves/widgets/viewer/video/controller.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -105,98 +100,3 @@ class VideoControlRow extends StatelessWidget { ), ); } - -class PlayToggler extends StatefulWidget { - final AvesVideoController? controller; - final bool isMenuItem; - final VoidCallback? onPressed; - - const PlayToggler({ - Key? key, - required this.controller, - this.isMenuItem = false, - this.onPressed, - }) : super(key: key); - - @override - State createState() => _PlayTogglerState(); -} - -class _PlayTogglerState extends State 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: context.read().iconAnimation, - vsync: this, - ); - _registerWidget(widget); - } - - @override - void didUpdateWidget(covariant PlayToggler oldWidget) { - super.didUpdateWidget(oldWidget); - _unregisterWidget(oldWidget); - _registerWidget(widget); - } - - @override - void dispose() { - _unregisterWidget(widget); - _playPauseAnimation.dispose(); - super.dispose(); - } - - void _registerWidget(PlayToggler widget) { - final controller = widget.controller; - if (controller != null) { - _subscriptions.add(controller.statusStream.listen(_onStatusChange)); - _onStatusChange(controller.status); - } - } - - void _unregisterWidget(PlayToggler widget) { - _subscriptions - ..forEach((sub) => sub.cancel()) - ..clear(); - } - - @override - Widget build(BuildContext context) { - if (widget.isMenuItem) { - return isPlaying - ? MenuRow( - text: context.l10n.videoActionPause, - icon: const Icon(AIcons.pause), - ) - : MenuRow( - text: context.l10n.videoActionPlay, - icon: const Icon(AIcons.play), - ); - } - return IconButton( - icon: AnimatedIcon( - icon: AnimatedIcons.play_pause, - progress: _playPauseAnimation, - ), - onPressed: widget.onPressed, - tooltip: isPlaying ? context.l10n.videoActionPause : context.l10n.videoActionPlay, - ); - } - - void _onStatusChange(VideoStatus status) { - final status = _playPauseAnimation.status; - if (isPlaying && status != AnimationStatus.forward && status != AnimationStatus.completed) { - _playPauseAnimation.forward(); - } else if (!isPlaying && status != AnimationStatus.reverse && status != AnimationStatus.dismissed) { - _playPauseAnimation.reverse(); - } - } -} diff --git a/lib/widgets/viewer/overlay/video/mute_toggler.dart b/lib/widgets/viewer/overlay/video/mute_toggler.dart new file mode 100644 index 000000000..8ab3089c7 --- /dev/null +++ b/lib/widgets/viewer/overlay/video/mute_toggler.dart @@ -0,0 +1,49 @@ +import 'dart:async'; + +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/basic/menu.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/viewer/video/controller.dart'; +import 'package:flutter/material.dart'; + +class MuteToggler extends StatelessWidget { + final AvesVideoController? controller; + final bool isMenuItem; + final VoidCallback? onPressed; + + const MuteToggler({ + Key? key, + required this.controller, + this.isMenuItem = false, + this.onPressed, + }) : super(key: key); + + bool get isMuted => controller?.isMuted ?? false; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller?.canMuteNotifier ?? ValueNotifier(false), + builder: (context, canDo, child) { + return StreamBuilder( + stream: controller?.volumeStream ?? Stream.value(1.0), + builder: (context, snapshot) { + final icon = Icon(isMuted ? AIcons.unmute : AIcons.mute); + final text = isMuted ? context.l10n.videoActionUnmute : context.l10n.videoActionMute; + + return isMenuItem + ? MenuRow( + text: text, + icon: icon, + ) + : IconButton( + icon: icon, + onPressed: canDo ? onPressed : null, + tooltip: text, + ); + }, + ); + }, + ); + } +} diff --git a/lib/widgets/viewer/overlay/video/play_toggler.dart b/lib/widgets/viewer/overlay/video/play_toggler.dart new file mode 100644 index 000000000..b7e781b29 --- /dev/null +++ b/lib/widgets/viewer/overlay/video/play_toggler.dart @@ -0,0 +1,100 @@ +import 'dart:async'; + +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/basic/menu.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/viewer/video/controller.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class PlayToggler extends StatefulWidget { + final AvesVideoController? controller; + final bool isMenuItem; + final VoidCallback? onPressed; + + const PlayToggler({ + Key? key, + required this.controller, + this.isMenuItem = false, + this.onPressed, + }) : super(key: key); + + @override + State createState() => _PlayTogglerState(); +} + +class _PlayTogglerState extends State 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: context.read().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) { + final text = isPlaying ? context.l10n.videoActionPause : context.l10n.videoActionPlay; + + return widget.isMenuItem + ? MenuRow( + text: text, + icon: Icon(isPlaying ? AIcons.pause : AIcons.play), + ) + : IconButton( + icon: AnimatedIcon( + icon: AnimatedIcons.play_pause, + progress: _playPauseAnimation, + ), + onPressed: widget.onPressed, + tooltip: text, + ); + } + + 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/overlay/viewer_button_row.dart b/lib/widgets/viewer/overlay/viewer_button_row.dart index 02d2cabc8..da484af26 100644 --- a/lib/widgets/viewer/overlay/viewer_button_row.dart +++ b/lib/widgets/viewer/overlay/viewer_button_row.dart @@ -12,7 +12,8 @@ import 'package:aves/widgets/viewer/action/entry_action_delegate.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/overlay/notifications.dart'; -import 'package:aves/widgets/viewer/overlay/video/controls.dart'; +import 'package:aves/widgets/viewer/overlay/video/mute_toggler.dart'; +import 'package:aves/widgets/viewer/overlay/video/play_toggler.dart'; import 'package:aves/widgets/viewer/video/conductor.dart'; import 'package:aves/widgets/viewer/video/controller.dart'; import 'package:collection/collection.dart'; @@ -75,6 +76,7 @@ class ViewerButtonRow extends StatelessWidget { case EntryAction.viewSource: return targetEntry.isSvg; case EntryAction.videoCaptureFrame: + case EntryAction.videoToggleMute: case EntryAction.videoSelectStreams: case EntryAction.videoSetSpeed: case EntryAction.videoSettings: @@ -252,6 +254,12 @@ class ViewerButtonRowContent extends StatelessWidget { onPressed: onPressed, ); break; + case EntryAction.videoToggleMute: + child = MuteToggler( + controller: videoController, + onPressed: onPressed, + ); + break; case EntryAction.videoTogglePlay: child = PlayToggler( controller: videoController, @@ -290,6 +298,9 @@ class ViewerButtonRowContent extends StatelessWidget { case EntryAction.videoCaptureFrame: enabled = videoController?.canCaptureFrameNotifier.value ?? false; break; + case EntryAction.videoToggleMute: + enabled = videoController?.canMuteNotifier.value ?? false; + break; case EntryAction.videoSelectStreams: enabled = videoController?.canSelectStreamNotifier.value ?? false; break; @@ -309,6 +320,12 @@ class ViewerButtonRowContent extends StatelessWidget { isMenuItem: true, ); break; + case EntryAction.videoToggleMute: + child = MuteToggler( + controller: videoController, + isMenuItem: true, + ); + break; case EntryAction.videoTogglePlay: child = PlayToggler( controller: videoController, diff --git a/lib/widgets/viewer/video/controller.dart b/lib/widgets/viewer/video/controller.dart index 58048fefa..6507d5e58 100644 --- a/lib/widgets/viewer/video/controller.dart +++ b/lib/widgets/viewer/video/controller.dart @@ -92,6 +92,8 @@ abstract class AvesVideoController { Stream get statusStream; + Stream get volumeStream; + bool get isReady; bool get isPlaying => status == VideoStatus.playing; @@ -111,12 +113,16 @@ abstract class AvesVideoController { ValueNotifier get canCaptureFrameNotifier; + ValueNotifier get canMuteNotifier; + ValueNotifier get canSetSpeedNotifier; ValueNotifier get canSelectStreamNotifier; ValueNotifier get sarNotifier; + bool get isMuted; + double get speed; double get minSpeed; @@ -133,6 +139,8 @@ abstract class AvesVideoController { Future captureFrame(); + Future toggleMute(); + Widget buildPlayerWidget(BuildContext context); } diff --git a/lib/widgets/viewer/video/fijkplayer.dart b/lib/widgets/viewer/video/fijkplayer.dart index 539b594bc..253a946b8 100644 --- a/lib/widgets/viewer/video/fijkplayer.dart +++ b/lib/widgets/viewer/video/fijkplayer.dart @@ -17,11 +17,13 @@ class IjkPlayerAvesVideoController extends AvesVideoController { final List _subscriptions = []; final StreamController _valueStreamController = StreamController.broadcast(); final StreamController _timedTextStreamController = StreamController.broadcast(); + final StreamController _volumeStreamController = StreamController.broadcast(); final AChangeNotifier _completedNotifier = AChangeNotifier(); Offset _macroBlockCrop = Offset.zero; final List _streams = []; Timer? _initialPlayTimer; double _speed = 1; + double _volume = 1; // audio/video get out of sync with speed < .5 // the video stream plays at .5 but the audio is slowed as requested @@ -36,6 +38,9 @@ class IjkPlayerAvesVideoController extends AvesVideoController { @override final ValueNotifier canCaptureFrameNotifier = ValueNotifier(false); + @override + final ValueNotifier canMuteNotifier = ValueNotifier(false); + @override final ValueNotifier canSetSpeedNotifier = ValueNotifier(false); @@ -61,13 +66,18 @@ class IjkPlayerAvesVideoController extends AvesVideoController { ) { _instance = FijkPlayer(); _valueStream.map((value) => value.videoRenderStart).firstWhere((v) => v, orElse: () => false).then( - (started) => canCaptureFrameNotifier.value = captureFrameEnabled && started, - onError: (error) {}, - ); + (started) { + canCaptureFrameNotifier.value = captureFrameEnabled && started; + }, + onError: (error) {}, + ); _valueStream.map((value) => value.audioRenderStart).firstWhere((v) => v, orElse: () => false).then( - (started) => canSetSpeedNotifier.value = started, - onError: (error) {}, - ); + (started) { + canMuteNotifier.value = started; + canSetSpeedNotifier.value = started; + }, + onError: (error) {}, + ); _startListening(); } @@ -115,8 +125,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); + await _applyVolume(); if (speed != 1) { - _applySpeed(); + await _applySpeed(); } _initialPlayTimer = Timer(initialPlayDelay, play); } @@ -319,6 +330,9 @@ class IjkPlayerAvesVideoController extends AvesVideoController { @override Stream get statusStream => _valueStream.map((value) => value.state.toAves); + @override + Stream get volumeStream => _volumeStreamController.stream; + @override bool get isReady => _instance.isPlayable(); @@ -338,6 +352,18 @@ class IjkPlayerAvesVideoController extends AvesVideoController { @override Stream get timedTextStream => _timedTextStreamController.stream; + @override + bool get isMuted => _volume == 0; + + @override + Future toggleMute() async { + _volume = isMuted ? 1 : 0; + _volumeStreamController.add(_volume); + await _applyVolume(); + } + + Future _applyVolume() => _instance.setVolume(_volume); + @override double get speed => _speed; @@ -357,7 +383,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController { bool _needSoundTouch(double speed) => speed > 1; // TODO TLAD [video] bug: setting speed fails when there is no audio stream or audio is disabled - void _applySpeed() => _instance.setSpeed(speed); + Future _applySpeed() => _instance.setSpeed(speed); // When a stream is selected, the video accelerates to catch up with it. // The duration of this acceleration phase depends on the player `min-frames` parameter. diff --git a/lib/widgets/viewer/video_action_delegate.dart b/lib/widgets/viewer/video_action_delegate.dart index 4cc7b46c5..da920b545 100644 --- a/lib/widgets/viewer/video_action_delegate.dart +++ b/lib/widgets/viewer/video_action_delegate.dart @@ -44,6 +44,9 @@ class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix case EntryAction.videoCaptureFrame: _captureFrame(context, controller); break; + case EntryAction.videoToggleMute: + controller.toggleMute(); + break; case EntryAction.videoSelectStreams: _showStreamSelectionDialog(context, controller); break; diff --git a/untranslated.json b/untranslated.json index cbd776a7b..5fd972ea9 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,6 +1,8 @@ { "de": [ "entryActionConvert", + "videoActionMute", + "videoActionUnmute", "videoControlsNone", "videoControlsPlay", "videoControlsPlaySeek", @@ -15,6 +17,8 @@ ], "es": [ + "videoActionMute", + "videoActionUnmute", "videoControlsNone", "videoControlsPlay", "videoControlsPlaySeek", @@ -29,6 +33,8 @@ ], "fr": [ + "videoActionMute", + "videoActionUnmute", "videoControlsNone", "videoControlsPlay", "videoControlsPlaySeek", @@ -43,6 +49,8 @@ ], "id": [ + "videoActionMute", + "videoActionUnmute", "videoControlsNone", "videoControlsPlay", "videoControlsPlaySeek", @@ -57,6 +65,8 @@ ], "ko": [ + "videoActionMute", + "videoActionUnmute", "videoControlsNone", "videoControlsPlay", "videoControlsPlaySeek", @@ -71,6 +81,8 @@ ], "pt": [ + "videoActionMute", + "videoActionUnmute", "videoControlsNone", "videoControlsPlay", "videoControlsPlaySeek", @@ -86,6 +98,8 @@ "ru": [ "entryActionConvert", + "videoActionMute", + "videoActionUnmute", "videoControlsNone", "videoControlsPlay", "videoControlsPlaySeek",