feat(player): warn when muted and allow manual resume

This commit is contained in:
zarzet committed 2026-10-07 23:20:27 +07:00
1 parent d89048409f
commit a68ab4ee97
10 files changed
+286 -6

No files matched your search

+4 -1
View File
@@ -16,6 +16,7 @@ import 'package:spotiflac_android/l10n/supported_locales.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/widgets/app_snack_bar.dart';
import 'package:spotiflac_android/widgets/mornye_glass_preparation.dart';
import 'package:spotiflac_android/widgets/playback_feedback.dart';
String initialLocationForAppState({
required bool isFirstLaunch,
@@ -206,7 +207,9 @@ class SpotiFLACApp extends ConsumerWidget {
final mediaQuery = MediaQuery.of(context);
final appContent = _OrientationFade(
child: AppScaffoldMessenger(
child: child ?? const SizedBox.shrink(),
child: PlaybackFeedback(
child: child ?? const SizedBox.shrink(),
),
),
);
return MediaQuery(
+4
View File
@@ -7139,6 +7139,10 @@
"autoMixCrossfading": "Crossfade",
"playbackAutomation": "Automatic playback",
"playbackPauseOnMute": "Pause when muted",
"snackbarPlaybackMuted": "Media volume is zero. Playback paused. Press play again to continue.",
"@snackbarPlaybackMuted": {
"description": "Shown after mute automation pauses playback; another explicit play request can override the pause."
},
"playbackPauseOnMuteDescription": "Pause at zero media volume and resume when raised. Manual pauses stay paused. Standard audio output only.",
"playbackOnHeadphonesConnected": "Play when headphones connect",
"playbackOnHeadphonesConnectedDescription": "Resume the current song when wired or Bluetooth headphones connect. Disconnecting pauses playback. Built-in player only.",
+1
View File
@@ -7052,6 +7052,7 @@
"discordPresenceDisconnect": "Putuskan dan lupakan akun",
"playbackAutomation": "Pemutaran otomatis",
"playbackPauseOnMute": "Jeda saat volume nol",
"snackbarPlaybackMuted": "Volume media nol. Lagu dijeda. Tekan play lagi untuk tetap memutar.",
"playbackPauseOnMuteDescription": "Jeda saat volume media nol dan lanjutkan saat dinaikkan. Jeda manual tetap dihormati. Hanya untuk keluaran audio standar.",
"playbackOnHeadphonesConnected": "Putar saat earphone terhubung",
"playbackOnHeadphonesConnectedDescription": "Lanjutkan lagu saat earphone kabel atau Bluetooth terhubung. Pemutaran dijeda saat dilepas. Hanya untuk pemutar bawaan."
+9
View File
@@ -113,6 +113,7 @@ class MusicPlayerRuntime {
Future<MusicPlayerHandler>? _initFuture;
Future<void>? _restoreFuture;
final _handlers = StreamController<MusicPlayerHandler?>.broadcast(sync: true);
final _mutedPlaybackEvents = StreamController<void>.broadcast();
bool _initializing = false;
bool _disposed = false;
Future<void>? _disposeFuture;
@@ -127,6 +128,13 @@ class MusicPlayerRuntime {
String get unknownArtist => _unknownArtist;
PlaybackNotification get notification => _notification;
// Transient feedback is never replayed when a screen or app shell reopens.
Stream<void> get mutedPlaybackEvents => _mutedPlaybackEvents.stream;
void notifyMutedPlayback() {
if (!_disposed) _mutedPlaybackEvents.add(null);
}
void configure(AppSettings settings) {
final previous = _settings;
_settings = settings;
@@ -266,6 +274,7 @@ class MusicPlayerRuntime {
// Initialization may have failed because its pending handler was closed.
}
await _handlers.close();
await _mutedPlaybackEvents.close();
}
Stream<T> _events<T>(
+2 -1
View File
@@ -430,6 +430,7 @@ class MusicPlayerHandler extends BaseAudioHandler
pause: _pausePlayback,
play: play,
onError: (error) => _log.w('Automatic playback failed: $error'),
onMutedPause: _runtime.notifyMutedPlayback,
);
_autoMix = _MusicAutoMix(
this,
@@ -1744,7 +1745,7 @@ class MusicPlayerHandler extends BaseAudioHandler
@override
Future<void> play() async {
_playbackAutomation.cancelPendingActions();
_playbackAutomation.manualPlaybackRequested();
final activeOperation = _activePlayOperation;
if (activeOperation != null) {
await activeOperation;
+23 -4
View File
@@ -13,27 +13,32 @@ class PlaybackAutomation {
required Future<void> Function() pause,
required Future<void> Function() play,
required void Function(Object) onError,
void Function()? onMutedPause,
}) : _isPlaying = isPlaying,
_canResume = canResume,
_pause = pause,
_play = play,
_onError = onError;
_onError = onError,
_onMutedPause = onMutedPause;
final bool Function() _isPlaying;
final bool Function() _canResume;
final Future<void> Function() _pause;
final Future<void> Function() _play;
final void Function(Object) _onError;
final void Function()? _onMutedPause;
bool _pauseOnMute = false;
bool _playOnHeadphonesConnected = false;
bool? _muted;
bool _pausedByMute = false;
bool _allowMutedPlayback = false;
bool _disposed = false;
int _intentRevision = 0;
Set<String>? _headphones;
Future<void> _tail = Future<void>.value();
bool get blocksPlayback => _pauseOnMute && _muted != false;
bool get blocksPlayback =>
_pauseOnMute && _muted != false && !_allowMutedPlayback;
Future<void> configure({
required bool pauseOnMute,
@@ -44,7 +49,10 @@ class PlaybackAutomation {
return _tail;
}
cancelPendingActions();
if (_pauseOnMute != pauseOnMute) _muted = null;
if (_pauseOnMute != pauseOnMute) {
_muted = null;
_allowMutedPlayback = false;
}
if (_playOnHeadphonesConnected != playOnHeadphonesConnected) {
_headphones = null;
}
@@ -58,9 +66,17 @@ class PlaybackAutomation {
_pausedByMute = false;
}
/// A second, explicit Play accepts silent playback until volume is raised.
/// Keep this choice across source reloads and duplicate zero-volume events.
void manualPlaybackRequested() {
if (_pausedByMute && _muted == true) _allowMutedPlayback = true;
cancelPendingActions();
}
Future<void> volumeChanged(double volume) {
if (!volume.isFinite) return _tail;
_muted = volume <= 0;
if (!_muted!) _allowMutedPlayback = false;
return _enqueue(_applyVolume);
}
@@ -68,10 +84,13 @@ class PlaybackAutomation {
Future<void> _applyVolume() async {
if (!_pauseOnMute) return;
if (_muted == true) {
if (_muted == true && !_allowMutedPlayback) {
if (_isPlaying() && _canResume()) {
_pausedByMute = true;
await _pause();
if (_pausedByMute && _muted == true && !_allowMutedPlayback) {
_onMutedPause?.call();
}
}
} else if (_muted == false && _pausedByMute) {
_pausedByMute = false;
+53
View File
@@ -0,0 +1,53 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/widgets/app_snack_bar.dart';
/// Receives transient playback feedback across all foreground app routes.
class PlaybackFeedback extends ConsumerStatefulWidget {
const PlaybackFeedback({super.key, required this.child});
final Widget child;
@override
ConsumerState<PlaybackFeedback> createState() => _PlaybackFeedbackState();
}
class _PlaybackFeedbackState extends ConsumerState<PlaybackFeedback> {
StreamSubscription<void>? _subscription;
int _subscriptionRevision = 0;
@override
void initState() {
super.initState();
ref.listenManual(musicPlayerRuntimeProvider, (_, runtime) {
final revision = ++_subscriptionRevision;
unawaited(_subscription?.cancel());
_subscription = runtime.mutedPlaybackEvents.listen((_) {
if (!mounted ||
revision != _subscriptionRevision ||
WidgetsBinding.instance.lifecycleState !=
AppLifecycleState.resumed) {
return;
}
showAppSnackBar(
context,
content: Text(context.l10n.snackbarPlaybackMuted),
);
});
}, fireImmediately: true);
}
@override
void dispose() {
_subscriptionRevision++;
unawaited(_subscription?.cancel());
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}
+58
View File
@@ -213,6 +213,64 @@ const _track = PlayableMedia(
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'muted start pauses and warns; explicit resume survives source reload',
() async {
final directory = await Directory.systemTemp.createTemp(
'muted-playback-',
);
addTearDown(() => directory.delete(recursive: true));
final file = await File(
'${directory.path}/song.flac',
).writeAsString('audio');
final volume = StreamController<double>.broadcast();
addTearDown(volume.close);
final runtime = _runtime(
settings: const AppSettings(playerMode: 'internal', pauseOnMute: true),
volumeChanges: volume.stream,
);
var warnings = 0;
final notices = runtime.mutedPlaybackEvents.listen((_) => warnings++);
addTearDown(notices.cancel);
final handler = await runtime.initialize();
volume.add(0);
await _settle();
await handler.setQueueAndPlay([
PlayableMedia(
id: 'muted',
source: file.path,
title: 'Muted',
artist: '',
),
]);
await _settle();
await _settle();
expect(handler.mediaItem.value?.id, 'muted');
expect(handler.playbackState.value.playing, isFalse);
expect(warnings, 1);
await handler.play();
await _settle();
expect(handler.playbackState.value.playing, isTrue);
volume.add(0);
await _settle();
expect(handler.playbackState.value.playing, isTrue);
expect(warnings, 1);
volume.add(0.5);
await _settle();
volume.add(0);
await _settle();
await _settle();
expect(handler.playbackState.value.playing, isFalse);
expect(warnings, 2);
await handler.pause();
volume.add(0.5);
await _settle();
expect(handler.playbackState.value.playing, isFalse);
},
);
test(
'position ticks skip listening updates while flushes retain time',
() async {
+61
View File
@@ -22,6 +22,7 @@ class _Transport {
bool available = true;
int pauses = 0;
int plays = 0;
int mutedWarnings = 0;
Completer<void>? pauseGate;
bool failPause = false;
final List<Object> errors = [];
@@ -40,6 +41,7 @@ class _Transport {
playing = true;
},
onError: errors.add,
onMutedPause: () => mutedWarnings++,
);
Future<void> enable({bool mute = true, bool headphones = false}) => automation
@@ -87,6 +89,64 @@ void main() {
await transport.automation.dispose();
});
test('manual play accepts mute until volume is raised again', () async {
final transport = _Transport();
await transport.enable();
await transport.automation.volumeChanged(0);
expect(transport.playing, isFalse);
expect(transport.mutedWarnings, 1);
expect(transport.automation.blocksPlayback, isTrue);
transport.automation.manualPlaybackRequested();
transport.playing = true;
await transport.automation.playbackStarted();
await transport.automation.volumeChanged(0);
await transport.automation.volumeChanged(0);
expect(transport.playing, isTrue);
expect(transport.pauses, 1);
expect(transport.mutedWarnings, 1);
expect(transport.automation.blocksPlayback, isFalse);
// Source reloads/queue changes must preserve the explicit choice.
transport.automation.cancelPendingActions();
await transport.automation.playbackStarted();
expect(transport.pauses, 1);
await transport.automation.volumeChanged(0.5);
await transport.automation.volumeChanged(0);
expect(transport.playing, isFalse);
expect(transport.pauses, 2);
expect(transport.mutedWarnings, 2);
await transport.automation.dispose();
});
test('first manual play of a paused muted song still warns', () async {
final transport = _Transport()..playing = false;
await transport.enable();
await transport.automation.volumeChanged(0);
transport.automation.manualPlaybackRequested();
transport.playing = true;
await transport.automation.playbackStarted();
expect(transport.playing, isFalse);
expect(transport.mutedWarnings, 1);
await transport.automation.dispose();
});
test('a superseded automatic pause does not issue a stale warning', () async {
final transport = _Transport()..pauseGate = Completer<void>();
await transport.enable();
final muted = transport.automation.volumeChanged(0);
await Future<void>.delayed(Duration.zero);
transport.automation.manualPlaybackRequested();
transport.pauseGate!.complete();
await muted;
transport.playing = true;
await transport.automation.playbackStarted();
expect(transport.mutedWarnings, 0);
expect(transport.playing, isTrue);
await transport.automation.dispose();
});
for (final reason in ['manual pause', 'sleep timer', 'focus loss', 'stop']) {
test('$reason cancels an unmute resume', () async {
final transport = _Transport();
@@ -164,6 +224,7 @@ void main() {
await transport.automation.volumeChanged(0);
await transport.automation.volumeChanged(0.5);
expect(transport.errors, hasLength(1));
expect(transport.mutedWarnings, 0);
expect(transport.plays, 0);
await transport.automation.dispose();
});
+71
View File
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/services/music_player_runtime.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/app_snack_bar.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
import 'package:spotiflac_android/widgets/playback_feedback.dart';
void main() {
for (final mornye in [false, true]) {
testWidgets('muted feedback is transient and themed (Mornye: $mornye)', (
tester,
) async {
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
addTearDown(
() => tester.binding.handleAppLifecycleStateChanged(
AppLifecycleState.resumed,
),
);
final runtime = MusicPlayerRuntime();
addTearDown(runtime.dispose);
// Notices emitted before the shell mounts must not be replayed.
runtime.notifyMutedPlayback();
final message = mornye
? 'Volume media nol. Lagu dijeda. Tekan play lagi untuk tetap memutar.'
: 'Media volume is zero. Playback paused. Press play again to continue.';
await tester.pumpWidget(
ProviderScope(
overrides: [musicPlayerRuntimeProvider.overrideWithValue(runtime)],
child: MaterialApp(
locale: Locale(mornye ? 'id' : 'en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(),
builder: (_, child) =>
AppScaffoldMessenger(child: PlaybackFeedback(child: child!)),
home: const Scaffold(body: SizedBox.expand()),
),
),
);
await tester.pumpAndSettle();
expect(find.text(message), findsNothing);
runtime.notifyMutedPlayback();
await tester.pumpAndSettle();
expect(find.text(message), findsOneWidget);
expect(
find.byType(MornyeGlassPanel),
mornye ? findsOneWidget : findsNothing,
);
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
expect(find.text(message), findsNothing);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
runtime.notifyMutedPlayback();
await tester.pumpAndSettle();
expect(find.text(message), findsNothing);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pumpAndSettle();
expect(find.text(message), findsNothing);
// A later mute episode may emit the exact same warning again.
runtime.notifyMutedPlayback();
await tester.pumpAndSettle();
expect(find.text(message), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
}