#198 video: mute action

This commit is contained in:
Thibault Deckers 2022-03-04 17:18:00 +09:00
parent e5b9f9abd6
commit 47b95ae402
14 changed files with 244 additions and 111 deletions

View file

@ -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

View file

@ -96,6 +96,8 @@
"entryActionRemoveFavourite": "Remove from favorites",
"videoActionCaptureFrame": "Capture frame",
"videoActionMute": "Mute",
"videoActionUnmute": "Unmute",
"videoActionPause": "Pause",
"videoActionPlay": "Play",
"videoActionReplay10": "Seek backward 10 seconds",

View file

@ -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:

View file

@ -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;

View file

@ -39,6 +39,7 @@ class ViewerActionEditorPage extends StatelessWidget {
EntryAction.toggleFavourite,
EntryAction.rotateScreen,
EntryAction.videoCaptureFrame,
EntryAction.videoToggleMute,
EntryAction.videoSetSpeed,
EntryAction.videoSelectStreams,
EntryAction.viewSource,

View file

@ -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:

View file

@ -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<PlayToggler> createState() => _PlayTogglerState();
}
class _PlayTogglerState extends State<PlayToggler> with SingleTickerProviderStateMixin {
final List<StreamSubscription> _subscriptions = [];
late AnimationController _playPauseAnimation;
AvesVideoController? get controller => widget.controller;
bool get isPlaying => controller?.isPlaying ?? false;
@override
void initState() {
super.initState();
_playPauseAnimation = AnimationController(
duration: context.read<DurationsData>().iconAnimation,
vsync: this,
);
_registerWidget(widget);
}
@override
void didUpdateWidget(covariant PlayToggler oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
_unregisterWidget(widget);
_playPauseAnimation.dispose();
super.dispose();
}
void _registerWidget(PlayToggler widget) {
final controller = widget.controller;
if (controller != null) {
_subscriptions.add(controller.statusStream.listen(_onStatusChange));
_onStatusChange(controller.status);
}
}
void _unregisterWidget(PlayToggler widget) {
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
}
@override
Widget build(BuildContext context) {
if (widget.isMenuItem) {
return isPlaying
? MenuRow(
text: context.l10n.videoActionPause,
icon: const Icon(AIcons.pause),
)
: MenuRow(
text: context.l10n.videoActionPlay,
icon: const Icon(AIcons.play),
);
}
return IconButton(
icon: AnimatedIcon(
icon: AnimatedIcons.play_pause,
progress: _playPauseAnimation,
),
onPressed: widget.onPressed,
tooltip: isPlaying ? context.l10n.videoActionPause : context.l10n.videoActionPlay,
);
}
void _onStatusChange(VideoStatus status) {
final status = _playPauseAnimation.status;
if (isPlaying && status != AnimationStatus.forward && status != AnimationStatus.completed) {
_playPauseAnimation.forward();
} else if (!isPlaying && status != AnimationStatus.reverse && status != AnimationStatus.dismissed) {
_playPauseAnimation.reverse();
}
}
}

View file

@ -0,0 +1,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<bool>(
valueListenable: controller?.canMuteNotifier ?? ValueNotifier(false),
builder: (context, canDo, child) {
return StreamBuilder<double>(
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,
);
},
);
},
);
}
}

View file

@ -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<PlayToggler> createState() => _PlayTogglerState();
}
class _PlayTogglerState extends State<PlayToggler> with SingleTickerProviderStateMixin {
final List<StreamSubscription> _subscriptions = [];
late AnimationController _playPauseAnimation;
AvesVideoController? get controller => widget.controller;
bool get isPlaying => controller?.isPlaying ?? false;
@override
void initState() {
super.initState();
_playPauseAnimation = AnimationController(
duration: context.read<DurationsData>().iconAnimation,
vsync: this,
);
_registerWidget(widget);
}
@override
void didUpdateWidget(covariant PlayToggler oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
_unregisterWidget(widget);
_playPauseAnimation.dispose();
super.dispose();
}
void _registerWidget(PlayToggler widget) {
final controller = widget.controller;
if (controller != null) {
_subscriptions.add(controller.statusStream.listen(_onStatusChange));
_onStatusChange(controller.status);
}
}
void _unregisterWidget(PlayToggler widget) {
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
}
@override
Widget build(BuildContext context) {
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();
}
}
}

View file

@ -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,

View file

@ -92,6 +92,8 @@ abstract class AvesVideoController {
Stream<VideoStatus> get statusStream;
Stream<double> get volumeStream;
bool get isReady;
bool get isPlaying => status == VideoStatus.playing;
@ -111,12 +113,16 @@ abstract class AvesVideoController {
ValueNotifier<bool> get canCaptureFrameNotifier;
ValueNotifier<bool> get canMuteNotifier;
ValueNotifier<bool> get canSetSpeedNotifier;
ValueNotifier<bool> get canSelectStreamNotifier;
ValueNotifier<double> get sarNotifier;
bool get isMuted;
double get speed;
double get minSpeed;
@ -133,6 +139,8 @@ abstract class AvesVideoController {
Future<Uint8List> captureFrame();
Future<void> toggleMute();
Widget buildPlayerWidget(BuildContext context);
}

View file

@ -17,11 +17,13 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
final List<StreamSubscription> _subscriptions = [];
final StreamController<FijkValue> _valueStreamController = StreamController.broadcast();
final StreamController<String?> _timedTextStreamController = StreamController.broadcast();
final StreamController<double> _volumeStreamController = StreamController.broadcast();
final AChangeNotifier _completedNotifier = AChangeNotifier();
Offset _macroBlockCrop = Offset.zero;
final List<StreamSummary> _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<bool> canCaptureFrameNotifier = ValueNotifier(false);
@override
final ValueNotifier<bool> canMuteNotifier = ValueNotifier(false);
@override
final ValueNotifier<bool> 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<VideoStatus> get statusStream => _valueStream.map((value) => value.state.toAves);
@override
Stream<double> get volumeStream => _volumeStreamController.stream;
@override
bool get isReady => _instance.isPlayable();
@ -338,6 +352,18 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
@override
Stream<String?> get timedTextStream => _timedTextStreamController.stream;
@override
bool get isMuted => _volume == 0;
@override
Future<void> toggleMute() async {
_volume = isMuted ? 1 : 0;
_volumeStreamController.add(_volume);
await _applyVolume();
}
Future<void> _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<void> _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.

View file

@ -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;

View file

@ -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",