fix(player): preserve volume when toggling mute (#1532)
* fix(player): preserve volume when toggling mute Muting now keeps the last volume level memorized. Unmuting restores that level, including amplified values allowed by the configured maximum. * fix(player): preserve volume for companion remote mute Route the companion remote through the shared mute transition so it no longer overwrites the persisted volume or restores to 100%. Also correct the Dartdoc formatting for the transition record fields.
This commit is contained in:
@@ -39,9 +39,9 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState {
|
|||||||
receiver.onVolumeMute = () async {
|
receiver.onVolumeMute = () async {
|
||||||
if (player == null) return;
|
if (player == null) return;
|
||||||
final settings = await SettingsService.getInstance();
|
final settings = await SettingsService.getInstance();
|
||||||
final newVolume = player!.state.volume > 0 ? 0.0 : 100.0;
|
final transition = settings.resolveMuteToggle(player!.state.volume);
|
||||||
unawaited(player!.setVolume(newVolume));
|
unawaited(player!.setVolume(transition.playerVolume));
|
||||||
unawaited(settings.write(SettingsService.volume, newVolume));
|
unawaited(settings.write(SettingsService.volume, transition.persistedVolume));
|
||||||
};
|
};
|
||||||
receiver.onSubtitles = _cycleSubtitleTrack;
|
receiver.onSubtitles = _cycleSubtitleTrack;
|
||||||
receiver.onAudioTracks = _cycleAudioTrack;
|
receiver.onAudioTracks = _cycleAudioTrack;
|
||||||
|
|||||||
@@ -344,9 +344,9 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
|||||||
onToggleFullscreen?.call();
|
onToggleFullscreen?.call();
|
||||||
break;
|
break;
|
||||||
case 'mute_toggle':
|
case 'mute_toggle':
|
||||||
final newVolume = player.state.volume > 0 ? 0.0 : 100.0;
|
final transition = _settingsService.resolveMuteToggle(player.state.volume);
|
||||||
player.setVolume(newVolume);
|
player.setVolume(transition.playerVolume);
|
||||||
_settingsService.write(SettingsService.volume, newVolume);
|
_settingsService.write(SettingsService.volume, transition.persistedVolume);
|
||||||
break;
|
break;
|
||||||
case 'subtitle_toggle':
|
case 'subtitle_toggle':
|
||||||
onToggleSubtitles?.call();
|
onToggleSubtitles?.call();
|
||||||
|
|||||||
@@ -614,6 +614,21 @@ class SettingsService extends BaseSharedPreferencesService {
|
|||||||
_cachedInstance = null;
|
_cachedInstance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolves a video mute toggle without replacing the saved volume with 0.
|
||||||
|
///
|
||||||
|
/// `persistedVolume` is the non-zero value callers should keep in [volume],
|
||||||
|
/// while `playerVolume` is the value to apply to the active player.
|
||||||
|
({double playerVolume, double persistedVolume}) resolveMuteToggle(double currentVolume) {
|
||||||
|
if (currentVolume.isFinite && currentVolume > 0) {
|
||||||
|
return (playerVolume: 0, persistedVolume: currentVolume);
|
||||||
|
}
|
||||||
|
|
||||||
|
final previousVolume = read(volume);
|
||||||
|
final candidate = previousVolume.isFinite && previousVolume > 0 ? previousVolume : volume.defaultValue;
|
||||||
|
final restoredVolume = candidate.clamp(0.0, read(maxVolume).toDouble()).toDouble();
|
||||||
|
return (playerVolume: restoredVolume, persistedVolume: restoredVolume);
|
||||||
|
}
|
||||||
|
|
||||||
static Map<String, String> defaultKeyboardShortcuts() => _defaultKeyboardShortcuts();
|
static Map<String, String> defaultKeyboardShortcuts() => _defaultKeyboardShortcuts();
|
||||||
static Map<String, HotKey> defaultKeyboardHotkeys() => _defaultKeyboardHotkeys();
|
static Map<String, HotKey> defaultKeyboardHotkeys() => _defaultKeyboardHotkeys();
|
||||||
|
|
||||||
|
|||||||
@@ -148,9 +148,9 @@ class _VolumeControlState extends State<VolumeControl> {
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final newVolume = isMuted ? 100.0 : 0.0;
|
final transition = _settings.resolveMuteToggle(widget.player.state.volume);
|
||||||
await widget.player.setVolume(newVolume);
|
await widget.player.setVolume(transition.playerVolume);
|
||||||
await _settings.write(SettingsService.volume, newVolume);
|
await _settings.write(SettingsService.volume, transition.persistedVolume);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -279,6 +279,34 @@ void main() {
|
|||||||
expect(commandCommaResult, KeyEventResult.ignored);
|
expect(commandCommaResult, KeyEventResult.ignored);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('mute shortcut matches the button restoration behavior', (tester) async {
|
||||||
|
final service = await KeyboardShortcutsService.getInstance();
|
||||||
|
addTearDown(service.dispose);
|
||||||
|
final settings = SettingsService.instance;
|
||||||
|
await settings.write(SettingsService.volume, 37.0);
|
||||||
|
final player = _FakePlayer(volume: 37);
|
||||||
|
const muteKey = KeyDownEvent(
|
||||||
|
physicalKey: PhysicalKeyboardKey.keyM,
|
||||||
|
logicalKey: LogicalKeyboardKey.keyM,
|
||||||
|
timeStamp: Duration.zero,
|
||||||
|
);
|
||||||
|
|
||||||
|
final muteResult = service.handleVideoPlayerKeyEvent(muteKey, player, null, null, null, null, null, null);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(muteResult, KeyEventResult.handled);
|
||||||
|
expect(player.volume, 0);
|
||||||
|
expect(settings.read(SettingsService.volume), 37);
|
||||||
|
|
||||||
|
final unmuteResult = service.handleVideoPlayerKeyEvent(muteKey, player, null, null, null, null, null, null);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(unmuteResult, KeyEventResult.handled);
|
||||||
|
expect(player.volume, 37);
|
||||||
|
expect(settings.read(SettingsService.volume), 37);
|
||||||
|
expect(player.volumeChanges, [0, 37]);
|
||||||
|
});
|
||||||
|
|
||||||
test('video zoom scale maps to mpv logarithmic property', () {
|
test('video zoom scale maps to mpv logarithmic property', () {
|
||||||
expect(VideoFilterManager.videoZoomPropertyForScale(1.0), closeTo(0.0, 0.0001));
|
expect(VideoFilterManager.videoZoomPropertyForScale(1.0), closeTo(0.0, 0.0001));
|
||||||
expect(VideoFilterManager.videoZoomPropertyForScale(2.0), closeTo(1.0, 0.0001));
|
expect(VideoFilterManager.videoZoomPropertyForScale(2.0), closeTo(1.0, 0.0001));
|
||||||
@@ -287,7 +315,11 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _FakePlayer implements Player {
|
class _FakePlayer implements Player {
|
||||||
|
_FakePlayer({this.volume = 100});
|
||||||
|
|
||||||
final commands = <List<String>>[];
|
final commands = <List<String>>[];
|
||||||
|
final volumeChanges = <double>[];
|
||||||
|
double volume;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> command(List<String> args) async {
|
Future<void> command(List<String> args) async {
|
||||||
@@ -295,7 +327,13 @@ class _FakePlayer implements Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
PlayerState get state => PlayerState();
|
PlayerState get state => PlayerState(volume: volume);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setVolume(double volume) async {
|
||||||
|
this.volume = volume;
|
||||||
|
volumeChanges.add(volume);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
|||||||
@@ -79,6 +79,50 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('SettingsService mute volume restoration', () {
|
||||||
|
test('keeps 37 persisted across mute and restores it on unmute', () async {
|
||||||
|
final settings = await SettingsService.getInstance();
|
||||||
|
await settings.write(SettingsService.volume, 37.0);
|
||||||
|
|
||||||
|
final mute = settings.resolveMuteToggle(37);
|
||||||
|
await settings.write(SettingsService.volume, mute.persistedVolume);
|
||||||
|
|
||||||
|
expect(mute.playerVolume, 0);
|
||||||
|
expect(settings.read(SettingsService.volume), 37);
|
||||||
|
|
||||||
|
final unmute = settings.resolveMuteToggle(mute.playerVolume);
|
||||||
|
|
||||||
|
expect(unmute.playerVolume, 37);
|
||||||
|
expect(unmute.persistedVolume, 37);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restores amplified volumes when the configured maximum permits them', () async {
|
||||||
|
final settings = await SettingsService.getInstance();
|
||||||
|
await settings.write(SettingsService.maxVolume, 250);
|
||||||
|
await settings.write(SettingsService.volume, 175.0);
|
||||||
|
|
||||||
|
final mute = settings.resolveMuteToggle(175);
|
||||||
|
await settings.write(SettingsService.volume, mute.persistedVolume);
|
||||||
|
final unmute = settings.resolveMuteToggle(mute.playerVolume);
|
||||||
|
|
||||||
|
expect(mute.playerVolume, 0);
|
||||||
|
expect(mute.persistedVolume, 175);
|
||||||
|
expect(unmute.playerVolume, 175);
|
||||||
|
expect(unmute.persistedVolume, 175);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to 100 when no previous non-zero volume exists', () async {
|
||||||
|
final settings = await SettingsService.getInstance();
|
||||||
|
await settings.write(SettingsService.maxVolume, 200);
|
||||||
|
await settings.write(SettingsService.volume, 0.0);
|
||||||
|
|
||||||
|
final unmute = settings.resolveMuteToggle(0);
|
||||||
|
|
||||||
|
expect(unmute.playerVolume, 100);
|
||||||
|
expect(unmute.persistedVolume, 100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('SettingsService TV card defaults', () {
|
group('SettingsService TV card defaults', () {
|
||||||
test('full card layout starts disabled', () async {
|
test('full card layout starts disabled', () async {
|
||||||
final settings = await SettingsService.getInstance();
|
final settings = await SettingsService.getInstance();
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:plezy/i18n/strings.g.dart';
|
||||||
|
import 'package:plezy/mpv/mpv.dart';
|
||||||
|
import 'package:plezy/services/settings_service.dart';
|
||||||
|
import 'package:plezy/widgets/video_controls/widgets/volume_control.dart';
|
||||||
|
|
||||||
|
import '../test_helpers/prefs.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() async {
|
||||||
|
resetSharedPreferencesForTest();
|
||||||
|
SettingsService.resetForTesting();
|
||||||
|
await SettingsService.getInstance();
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('mute button keeps and restores the exact non-zero volume', (tester) async {
|
||||||
|
final settings = SettingsService.instance;
|
||||||
|
await settings.write(SettingsService.volume, 37.0);
|
||||||
|
final player = _VolumePlayer(37);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(body: VolumeControl(player: player)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byType(IconButton));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(player.volume, 0);
|
||||||
|
expect(settings.read(SettingsService.volume), 37);
|
||||||
|
|
||||||
|
await tester.tap(find.byType(IconButton));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(player.volume, 37);
|
||||||
|
expect(settings.read(SettingsService.volume), 37);
|
||||||
|
expect(player.volumeChanges, [0, 37]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VolumePlayer implements Player {
|
||||||
|
_VolumePlayer(this.volume)
|
||||||
|
: _streams = PlayerStreams(
|
||||||
|
playing: const Stream<bool>.empty(),
|
||||||
|
completed: const Stream<bool>.empty(),
|
||||||
|
buffering: const Stream<bool>.empty(),
|
||||||
|
position: const Stream<Duration>.empty(),
|
||||||
|
duration: const Stream<Duration>.empty(),
|
||||||
|
seekable: const Stream<bool>.empty(),
|
||||||
|
buffer: const Stream<Duration>.empty(),
|
||||||
|
volume: const Stream<double>.empty(),
|
||||||
|
rate: const Stream<double>.empty(),
|
||||||
|
tracks: const Stream<Tracks>.empty(),
|
||||||
|
track: const Stream<TrackSelection>.empty(),
|
||||||
|
log: const Stream<PlayerLog>.empty(),
|
||||||
|
error: const Stream<PlayerError>.empty(),
|
||||||
|
audioDevice: const Stream<AudioDevice>.empty(),
|
||||||
|
audioDevices: const Stream<List<AudioDevice>>.empty(),
|
||||||
|
bufferRanges: const Stream<List<BufferRange>>.empty(),
|
||||||
|
playbackRestart: const Stream<void>.empty(),
|
||||||
|
backendSwitched: const Stream<void>.empty(),
|
||||||
|
);
|
||||||
|
|
||||||
|
double volume;
|
||||||
|
final List<double> volumeChanges = [];
|
||||||
|
final PlayerStreams _streams;
|
||||||
|
|
||||||
|
@override
|
||||||
|
PlayerState get state => PlayerState(volume: volume);
|
||||||
|
|
||||||
|
@override
|
||||||
|
PlayerStreams get streams => _streams;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setVolume(double volume) async {
|
||||||
|
this.volume = volume;
|
||||||
|
volumeChanges.add(volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user