mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 14:07:26 +02:00
feat(player): warn when muted and allow manual resume
This commit is contained in:
1 parent
d89048409f
commit
a68ab4ee97
10 files changed
+286
-6
No files matched your search
+4
-1
@@ -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(
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user