From a68ab4ee977f2ee652385e4ce88e8db526c34a36 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:20:27 +0700 Subject: [PATCH] feat(player): warn when muted and allow manual resume --- lib/app.dart | 5 +- lib/l10n/arb/app_en.arb | 4 ++ lib/l10n/arb/app_id.arb | 1 + lib/services/music_player_runtime.dart | 9 ++++ lib/services/music_player_service.dart | 3 +- lib/services/playback_automation.dart | 27 ++++++++-- lib/widgets/playback_feedback.dart | 53 +++++++++++++++++++ test/music_player_runtime_test.dart | 58 +++++++++++++++++++++ test/playback_automation_test.dart | 61 ++++++++++++++++++++++ test/playback_feedback_test.dart | 71 ++++++++++++++++++++++++++ 10 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 lib/widgets/playback_feedback.dart create mode 100644 test/playback_feedback_test.dart diff --git a/lib/app.dart b/lib/app.dart index 4041302f..ce22ac9e 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -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( diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 3fe9f028..387c632a 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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.", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 9527d9bc..4b197453 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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." diff --git a/lib/services/music_player_runtime.dart b/lib/services/music_player_runtime.dart index 4ae6fb50..17e07971 100644 --- a/lib/services/music_player_runtime.dart +++ b/lib/services/music_player_runtime.dart @@ -113,6 +113,7 @@ class MusicPlayerRuntime { Future? _initFuture; Future? _restoreFuture; final _handlers = StreamController.broadcast(sync: true); + final _mutedPlaybackEvents = StreamController.broadcast(); bool _initializing = false; bool _disposed = false; Future? _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 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 _events( diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index 7b5c17c3..f5bd47ae 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -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 play() async { - _playbackAutomation.cancelPendingActions(); + _playbackAutomation.manualPlaybackRequested(); final activeOperation = _activePlayOperation; if (activeOperation != null) { await activeOperation; diff --git a/lib/services/playback_automation.dart b/lib/services/playback_automation.dart index ad5f981b..1a4c5558 100644 --- a/lib/services/playback_automation.dart +++ b/lib/services/playback_automation.dart @@ -13,27 +13,32 @@ class PlaybackAutomation { required Future Function() pause, required Future 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 Function() _pause; final Future 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? _headphones; Future _tail = Future.value(); - bool get blocksPlayback => _pauseOnMute && _muted != false; + bool get blocksPlayback => + _pauseOnMute && _muted != false && !_allowMutedPlayback; Future 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 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 _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; diff --git a/lib/widgets/playback_feedback.dart b/lib/widgets/playback_feedback.dart new file mode 100644 index 00000000..53eefcdd --- /dev/null +++ b/lib/widgets/playback_feedback.dart @@ -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 createState() => _PlaybackFeedbackState(); +} + +class _PlaybackFeedbackState extends ConsumerState { + StreamSubscription? _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; +} diff --git a/test/music_player_runtime_test.dart b/test/music_player_runtime_test.dart index 32e6a8ba..874d954a 100644 --- a/test/music_player_runtime_test.dart +++ b/test/music_player_runtime_test.dart @@ -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.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 { diff --git a/test/playback_automation_test.dart b/test/playback_automation_test.dart index a5899303..18d8340c 100644 --- a/test/playback_automation_test.dart +++ b/test/playback_automation_test.dart @@ -22,6 +22,7 @@ class _Transport { bool available = true; int pauses = 0; int plays = 0; + int mutedWarnings = 0; Completer? pauseGate; bool failPause = false; final List errors = []; @@ -40,6 +41,7 @@ class _Transport { playing = true; }, onError: errors.add, + onMutedPause: () => mutedWarnings++, ); Future 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(); + await transport.enable(); + final muted = transport.automation.volumeChanged(0); + await Future.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(); }); diff --git a/test/playback_feedback_test.dart b/test/playback_feedback_test.dart new file mode 100644 index 00000000..088b5ce6 --- /dev/null +++ b/test/playback_feedback_test.dart @@ -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); + }); + } +}