From 1abc45e2bf60493943d7f230e407dc6a6645e7ac Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:55:08 +0700 Subject: [PATCH] feat(playback): automate mute and headphone playback controls --- lib/l10n/arb/app_en.arb | 5 + lib/l10n/arb/app_id.arb | 7 +- lib/models/settings.dart | 9 + lib/models/settings.g.dart | 5 + lib/providers/settings_provider.dart | 10 + lib/screens/main_shell.dart | 17 ++ .../settings/playback_settings_page.dart | 34 +++ .../settings/settings_search_catalog.dart | 12 + lib/services/music_player_service.dart | 171 +++++++++-- lib/services/playback_automation.dart | 138 +++++++++ lib/services/system_volume_service.dart | 50 ++++ lib/widgets/mornye_volume_control.dart | 18 +- test/music_player_automix_test.dart | 146 +++++++++ test/playback_automation_settings_test.dart | 106 +++++++ test/playback_automation_test.dart | 281 ++++++++++++++++++ test/system_volume_service_test.dart | 55 ++++ 16 files changed, 1028 insertions(+), 36 deletions(-) create mode 100644 lib/services/playback_automation.dart create mode 100644 lib/services/system_volume_service.dart create mode 100644 test/playback_automation_settings_test.dart create mode 100644 test/playback_automation_test.dart create mode 100644 test/system_volume_service_test.dart diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 2be40d22..85db7d24 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -7133,6 +7133,11 @@ "autoMixLowPass": "Muffled filter", "autoMixMixing": "Mixing", "autoMixCrossfading": "Crossfade", + "playbackAutomation": "Automatic playback", + "playbackPauseOnMute": "Pause when muted", + "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.", "optionsEmbeddedCoverSizeOriginal": "Original resolution", "@optionsEmbeddedCoverSizeOriginal": { "description": "Option that preserves the provider artwork at its original resolution" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index ef445bef..a1eaad49 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -7048,5 +7048,10 @@ "discordPresenceBuildUnavailable": "SDK Discord tidak tersedia di build ini.", "discordPresenceAndroidHelp": "Pasang Discord dan masuk di ponsel ini. Izinkan berbagi aktivitas di Discord. Pemutar eksternal tidak didukung.", "discordPresenceIosHelp": "Hubungkan akun melalui Discord dan izinkan berbagi aktivitas. Ketuk untuk menghubungkan ulang jika otorisasi gagal.", - "discordPresenceDisconnect": "Putuskan dan lupakan akun" + "discordPresenceDisconnect": "Putuskan dan lupakan akun", + "playbackAutomation": "Pemutaran otomatis", + "playbackPauseOnMute": "Jeda saat volume nol", + "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/models/settings.dart b/lib/models/settings.dart index af429ec3..2157e789 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -34,6 +34,8 @@ class AppSettings { final bool embedReplayGain; // Apply ReplayGain/R128 tags as volume normalization in the built-in player. final bool playbackNormalization; + final bool pauseOnMute; + final bool playOnHeadphonesConnected; final bool autoMix; @JsonKey(fromJson: autoMixDurationFromJson) final int autoMixDuration; @@ -176,6 +178,8 @@ class AppSettings { this.embedLyrics = true, this.embedReplayGain = false, this.playbackNormalization = false, + this.pauseOnMute = true, + this.playOnHeadphonesConnected = false, this.autoMix = false, this.autoMixDuration = 0, this.autoMixEffect = AutoMixEffect.auto, @@ -281,6 +285,8 @@ class AppSettings { bool? embedLyrics, bool? embedReplayGain, bool? playbackNormalization, + bool? pauseOnMute, + bool? playOnHeadphonesConnected, bool? autoMix, int? autoMixDuration, AutoMixEffect? autoMixEffect, @@ -383,6 +389,9 @@ class AppSettings { embedReplayGain: embedReplayGain ?? this.embedReplayGain, playbackNormalization: playbackNormalization ?? this.playbackNormalization, + pauseOnMute: pauseOnMute ?? this.pauseOnMute, + playOnHeadphonesConnected: + playOnHeadphonesConnected ?? this.playOnHeadphonesConnected, autoMix: autoMix ?? this.autoMix, autoMixDuration: autoMixDuration ?? this.autoMixDuration, autoMixEffect: autoMixEffect ?? this.autoMixEffect, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index ab0f4575..0b48c349 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -24,6 +24,9 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( embedLyrics: json['embedLyrics'] as bool? ?? true, embedReplayGain: json['embedReplayGain'] as bool? ?? false, playbackNormalization: json['playbackNormalization'] as bool? ?? false, + pauseOnMute: json['pauseOnMute'] as bool? ?? true, + playOnHeadphonesConnected: + json['playOnHeadphonesConnected'] as bool? ?? false, autoMix: json['autoMix'] as bool? ?? false, autoMixDuration: json['autoMixDuration'] == null ? 0 @@ -155,6 +158,8 @@ Map _$AppSettingsToJson( 'embedLyrics': instance.embedLyrics, 'embedReplayGain': instance.embedReplayGain, 'playbackNormalization': instance.playbackNormalization, + 'pauseOnMute': instance.pauseOnMute, + 'playOnHeadphonesConnected': instance.playOnHeadphonesConnected, 'autoMix': instance.autoMix, 'autoMixDuration': instance.autoMixDuration, 'autoMixEffect': _$AutoMixEffectEnumMap[instance.autoMixEffect]!, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index bea63c70..3deaab54 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -568,6 +568,16 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setPauseOnMute(bool enabled) { + state = state.copyWith(pauseOnMute: enabled); + _saveSettings(); + } + + void setPlayOnHeadphonesConnected(bool enabled) { + state = state.copyWith(playOnHeadphonesConnected: enabled); + _saveSettings(); + } + void setAutoMix(bool enabled) { state = state.copyWith(autoMix: enabled); _saveSettings(); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 2335187a..cd592eb2 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -109,6 +109,13 @@ class _MainShellState extends ConsumerState setPlaybackNormalizationEnabled( ref.read(settingsProvider).playbackNormalization, ); + setPlaybackAutomationOptions( + enabled: ref.read(settingsProvider).playerMode == 'internal', + pauseOnMute: ref.read(settingsProvider).pauseOnMute, + playOnHeadphonesConnected: ref + .read(settingsProvider) + .playOnHeadphonesConnected, + ); setAutoMixEnabled(ref.read(settingsProvider).autoMix); setAutoMixOptions(ref.read(settingsProvider).autoMixOptions); unawaited( @@ -793,6 +800,16 @@ class _MainShellState extends ConsumerState ref.listen(settingsProvider.select((s) => s.autoMix), (_, enabled) { setAutoMixEnabled(enabled); }); + ref.listen( + settingsProvider.select( + (s) => (s.playerMode, s.pauseOnMute, s.playOnHeadphonesConnected), + ), + (_, options) => setPlaybackAutomationOptions( + enabled: options.$1 == 'internal', + pauseOnMute: options.$2, + playOnHeadphonesConnected: options.$3, + ), + ); ref.listen(settingsProvider.select((s) => s.autoMixOptions), (_, options) { setAutoMixOptions(options); }); diff --git a/lib/screens/settings/playback_settings_page.dart b/lib/screens/settings/playback_settings_page.dart index f2d66920..02a4f9fe 100644 --- a/lib/screens/settings/playback_settings_page.dart +++ b/lib/screens/settings/playback_settings_page.dart @@ -123,6 +123,40 @@ class _PlaybackSettingsPageState extends ConsumerState { ), const SliverToBoxAdapter(child: AutoMixSettings()), ], + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.playbackAutomation, + ), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsSwitchItem( + icon: Icons.volume_off_outlined, + title: context.l10n.playbackPauseOnMute, + subtitle: context.l10n.playbackPauseOnMuteDescription, + value: settings.pauseOnMute, + enabled: + settings.playerMode == 'internal' && + !settings.usbBitPerfect, + onChanged: (value) => + ref.read(settingsProvider.notifier).setPauseOnMute(value), + ), + SettingsSwitchItem( + icon: Icons.headphones_outlined, + title: context.l10n.playbackOnHeadphonesConnected, + subtitle: + context.l10n.playbackOnHeadphonesConnectedDescription, + value: settings.playOnHeadphonesConnected, + enabled: settings.playerMode == 'internal', + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setPlayOnHeadphonesConnected(value), + showDivider: false, + ), + ], + ), + ), SliverToBoxAdapter( child: SettingsSectionHeader(title: context.l10n.motionArtwork), ), diff --git a/lib/screens/settings/settings_search_catalog.dart b/lib/screens/settings/settings_search_catalog.dart index 7523aff5..22f2d7c6 100644 --- a/lib/screens/settings/settings_search_catalog.dart +++ b/lib/screens/settings/settings_search_catalog.dart @@ -256,6 +256,18 @@ class SettingsSearchCatalog { subtitle: l10n.libraryPlaybackNormalizationSubtitle, keywords: const ['replaygain', 'volume'], ), + SettingsSearchEntry( + icon: Icons.volume_off_outlined, + title: l10n.playbackPauseOnMute, + subtitle: l10n.playbackPauseOnMuteDescription, + keywords: const ['mute', 'volume', 'pause', 'resume'], + ), + SettingsSearchEntry( + icon: Icons.headphones_outlined, + title: l10n.playbackOnHeadphonesConnected, + subtitle: l10n.playbackOnHeadphonesConnectedDescription, + keywords: const ['earphone', 'headphone', 'bluetooth', 'connect'], + ), SettingsSearchEntry( icon: Icons.compare_arrows, title: 'AutoMix', diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index 28518127..33e80803 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -10,7 +10,11 @@ import 'package:spotiflac_android/services/playback_notification.dart'; import 'package:spotiflac_android/services/player_widget_service.dart'; import 'package:spotiflac_android/services/discord_presence_service.dart'; import 'package:audio_session/audio_session.dart' - show AudioSession, AudioSessionConfiguration, AudioInterruptionType; + show + AudioDevice, + AudioSession, + AudioSessionConfiguration, + AudioInterruptionType; import 'package:audioplayers/audioplayers.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/app_state_database.dart'; @@ -20,6 +24,8 @@ import 'package:spotiflac_android/services/sqlite_helpers.dart' show normalizeLookupText; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/playback_normalization.dart'; +import 'package:spotiflac_android/services/playback_automation.dart'; +import 'package:spotiflac_android/services/system_volume_service.dart'; import 'package:spotiflac_android/services/music_playback_deck.dart'; import 'package:spotiflac_android/services/automix_analysis.dart'; import 'package:spotiflac_android/services/automix_analyzer.dart'; @@ -50,6 +56,9 @@ void updateMusicPlayerStrings({ } bool _playbackNormalizationEnabled = false; +bool _playbackAutomationEnabled = true; +bool _pauseOnMute = false; +bool _playOnHeadphonesConnected = false; bool _autoMixEnabled = false; AutoMixOptions _autoMixOptions = const AutoMixOptions(); bool _usbBitPerfectEnabled = false; @@ -122,6 +131,17 @@ void setPlaybackNormalizationEnabled(bool enabled) { _activeMusicPlayerHandler?.reapplyNormalization(); } +void setPlaybackAutomationOptions({ + required bool enabled, + required bool pauseOnMute, + required bool playOnHeadphonesConnected, +}) { + _playbackAutomationEnabled = enabled; + _pauseOnMute = pauseOnMute; + _playOnHeadphonesConnected = playOnHeadphonesConnected; + _activeMusicPlayerHandler?._refreshPlaybackAutomation(); +} + void setAutoMixEnabled(bool enabled) { if (_autoMixEnabled == enabled) return; _autoMixEnabled = enabled; @@ -142,6 +162,7 @@ void setUsbBitPerfectEnabled(bool enabled) { final handler = _activeMusicPlayerHandler; if (handler != null) unawaited(handler._autoMix.cancel()); // Apply at the next source boundary; never raise a playing track's volume. + handler?._refreshPlaybackAutomation(); } void setUsbOutputOptions({ @@ -429,6 +450,11 @@ class MusicPlayerHandler extends BaseAudioHandler final _playerSubscriptions = >>{}; AudioSession? _audioSession; + late final PlaybackAutomation _playbackAutomation; + StreamSubscription? _automationVolumeSubscription; + StreamSubscription>? _automationDevicesSubscription; + bool _headphoneMonitoringRequested = false; + int _headphoneMonitoringRevision = 0; final List _media = []; final List _queueItems = []; final Map _resolvedPathCache = {}; @@ -485,6 +511,18 @@ class MusicPlayerHandler extends BaseAudioHandler AutoMixEffectRenderer? autoMixEffectRenderer, AutoplayLibraryLoader? autoplayLibraryLoader, }) { + _playbackAutomation = PlaybackAutomation( + isPlaying: () => + _player.state == PlayerState.playing || playbackState.value.playing, + canResume: () => + !_disposed && + !_interruptionActive && + _index >= 0 && + _index < _media.length, + pause: _pausePlayback, + play: play, + onError: (error) => _log.w('Automatic playback failed: $error'), + ); _autoMix = _MusicAutoMix( this, autoMixAnalyzer ?? AutoMixAnalyzer(), @@ -525,12 +563,18 @@ class MusicPlayerHandler extends BaseAudioHandler _subscriptions.add(mediaItem.listen((_) => recordListening())); _subscriptions.add(playbackState.listen((_) => recordListening())); + _subscriptions.add( + playbackState.map((state) => state.playing).distinct().listen((playing) { + if (playing) unawaited(_playbackAutomation.playbackStarted()); + }), + ); _listeningTimer = Timer.periodic(const Duration(seconds: 10), (_) { unawaited(listeningRecorder.flush()); }); _player.setReleaseMode(ReleaseMode.stop); unawaited(_player.setAudioContext(_musicAudioContext)); unawaited(_configureAudioSession()); + _refreshPlaybackAutomation(); _listenToPlayer(_player); } @@ -549,6 +593,7 @@ class MusicPlayerHandler extends BaseAudioHandler if (state == PlayerState.paused && player.needsSourceReload) { _userPaused = true; _pausedByInterruption = false; + _playbackAutomation.cancelPendingActions(); } if (_switchingGeneration != 0 && (state == PlayerState.stopped || @@ -598,6 +643,8 @@ class MusicPlayerHandler extends BaseAudioHandler final session = await AudioSession.instance; _audioSession = session; await session.configure(const AudioSessionConfiguration.music()); + if (_disposed) return; + _refreshPlaybackAutomation(); _subscriptions.add( session.interruptionEventStream.listen((event) { @@ -626,7 +673,8 @@ class MusicPlayerHandler extends BaseAudioHandler // (duck/pause) interruption. _interruptionActive = false; if (_pausedByInterruption && - event.type == AudioInterruptionType.pause) { + event.type == AudioInterruptionType.pause && + !_playbackAutomation.blocksPlayback) { _pausedByInterruption = false; unawaited(play()); } else { @@ -639,6 +687,7 @@ class MusicPlayerHandler extends BaseAudioHandler _subscriptions.add( session.becomingNoisyEventStream.listen((_) { // Headphones unplugged / output route lost. + _pausedByInterruption = false; unawaited(_pauseForFocusLoss(reason: 'becoming noisy')); }), ); @@ -647,23 +696,81 @@ class MusicPlayerHandler extends BaseAudioHandler } } + void _refreshPlaybackAutomation() { + if (_disposed) return; + final pauseOnMute = + _playbackAutomationEnabled && _pauseOnMute && !_usbBitPerfectEnabled; + final playOnHeadphonesConnected = + _playbackAutomationEnabled && _playOnHeadphonesConnected; + unawaited( + _playbackAutomation.configure( + pauseOnMute: pauseOnMute, + playOnHeadphonesConnected: playOnHeadphonesConnected, + ), + ); + if (pauseOnMute && _automationVolumeSubscription == null) { + _automationVolumeSubscription = SystemVolumeService.instance.changes + .listen( + (volume) => unawaited(_playbackAutomation.volumeChanged(volume)), + onError: (Object error) => + _log.w('Could not monitor media volume: $error'), + ); + } else if (!pauseOnMute) { + final subscription = _automationVolumeSubscription; + _automationVolumeSubscription = null; + if (subscription != null) unawaited(subscription.cancel()); + } + final session = _audioSession; + if (playOnHeadphonesConnected && + session != null && + !_headphoneMonitoringRequested) { + _headphoneMonitoringRequested = true; + unawaited( + _startHeadphoneMonitoring(session, ++_headphoneMonitoringRevision), + ); + } else if (!playOnHeadphonesConnected) { + _headphoneMonitoringRequested = false; + _headphoneMonitoringRevision++; + final subscription = _automationDevicesSubscription; + _automationDevicesSubscription = null; + if (subscription != null) unawaited(subscription.cancel()); + } + } + + Future _startHeadphoneMonitoring( + AudioSession session, + int revision, + ) async { + try { + // Query a fresh baseline. devicesStream can replay an old inventory + // after monitoring was disabled, which would look like a new connection. + final devices = await session.getDevices(); + if (_disposed || revision != _headphoneMonitoringRevision) return; + unawaited(_playbackAutomation.devicesChanged(devices)); + _automationDevicesSubscription = session.devicesChangedEventStream + .asyncMap((_) => session.getDevices()) + .listen( + (devices) => unawaited(_playbackAutomation.devicesChanged(devices)), + onError: (Object error) => + _log.w('Could not monitor headphone connections: $error'), + ); + } catch (error) { + _log.w('Could not monitor headphone connections: $error'); + } + } + bool get _shouldIgnoreComplete => _switchingGeneration != 0 || _interruptionActive || _userPaused; Future _pauseForFocusLoss({required String reason}) async { _log.i('Pausing internal player because of $reason'); - _playRequestGeneration++; - _switchingGeneration = 0; - await _autoMix.cancel(); + _playbackAutomation.cancelPendingActions(); try { - await _player.pause(); + await _pausePlayback(); } catch (e) { _log.w('Failed to pause after audio focus loss: $e'); + if (!_disposed) _broadcastState(playerState: PlayerState.paused); } - // Force the UI/notification to reflect the pause even if the engine does - // not emit a state-change event on focus loss. - _broadcastState(playerState: PlayerState.paused); - await _persistSession(position: await _currentPositionForPersist()); } void setSleepTimer(Duration duration) { @@ -1454,6 +1561,7 @@ class MusicPlayerHandler extends BaseAudioHandler Duration startPosition = Duration.zero, }) async { if (_disposed || index < 0 || index >= _media.length) return; + _playbackAutomation.cancelPendingActions(); // A normal track change supersedes a pending restore. A restored start // keeps it until the source is actually ready so a transient failure can // be retried from the same position. @@ -1864,6 +1972,7 @@ class MusicPlayerHandler extends BaseAudioHandler @override Future play() async { + _playbackAutomation.cancelPendingActions(); final activeOperation = _activePlayOperation; if (activeOperation != null) { await activeOperation; @@ -1906,16 +2015,26 @@ class MusicPlayerHandler extends BaseAudioHandler ); return; } - await _activateAudioSession(); - await _claimHardwareMediaButtons(); - try { - await _player.resume(); - } on PlatformException { - if (!_player.needsSourceReload) rethrow; - _broadcastState(playerState: PlayerState.paused); - return; - } - _broadcastState(playerState: PlayerState.playing); + final generation = _playRequestGeneration; + await _serializeSourceChange(() async { + if (generation != _playRequestGeneration || _disposed) return; + await _activateAudioSession(); + if (generation != _playRequestGeneration || _disposed) return; + await _claimHardwareMediaButtons(); + if (generation != _playRequestGeneration || _disposed) return; + try { + await _player.resume(); + } on PlatformException { + if (!_player.needsSourceReload) rethrow; + _broadcastState(playerState: PlayerState.paused); + return; + } + if (generation != _playRequestGeneration || _disposed) { + await _player.pause(); + return; + } + _broadcastState(playerState: PlayerState.playing); + }); } @override @@ -1939,10 +2058,15 @@ class MusicPlayerHandler extends BaseAudioHandler @override Future pause() async { + _playbackAutomation.cancelPendingActions(); + _pausedByInterruption = false; + await _pausePlayback(); + } + + Future _pausePlayback() async { final generation = ++_playRequestGeneration; _switchingGeneration = 0; _userPaused = true; - _pausedByInterruption = false; await _autoMix.cancel(); await _serializeSourceChange(() async { if (generation != _playRequestGeneration || _disposed) return; @@ -2002,6 +2126,7 @@ class MusicPlayerHandler extends BaseAudioHandler @override Future stop() async { + _playbackAutomation.cancelPendingActions(); _autoplay.reset(); cancelSleepTimer(); final generation = ++_playRequestGeneration; @@ -2174,6 +2299,10 @@ class MusicPlayerHandler extends BaseAudioHandler listeningRecorder.update(null, playing: false, ended: true); await listeningRecorder.flush(); _playRequestGeneration++; + _headphoneMonitoringRevision++; + await _automationVolumeSubscription?.cancel(); + await _automationDevicesSubscription?.cancel(); + await _playbackAutomation.dispose(); await _sourceChangeTail; await _autoMix.dispose(); for (final sub in _subscriptions) { diff --git a/lib/services/playback_automation.dart b/lib/services/playback_automation.dart new file mode 100644 index 00000000..ad5f981b --- /dev/null +++ b/lib/services/playback_automation.dart @@ -0,0 +1,138 @@ +// Device types are experimental in audio_session. Keep their interpretation +// here so platform route changes do not leak into transport or settings code. +// ignore_for_file: experimental_member_use + +import 'package:audio_session/audio_session.dart'; + +/// Serializes automatic transport requests and remembers who paused playback. +/// A volume change must never undo an explicit pause or a lost output route. +class PlaybackAutomation { + PlaybackAutomation({ + required bool Function() isPlaying, + required bool Function() canResume, + required Future Function() pause, + required Future Function() play, + required void Function(Object) onError, + }) : _isPlaying = isPlaying, + _canResume = canResume, + _pause = pause, + _play = play, + _onError = onError; + + final bool Function() _isPlaying; + final bool Function() _canResume; + final Future Function() _pause; + final Future Function() _play; + final void Function(Object) _onError; + bool _pauseOnMute = false; + bool _playOnHeadphonesConnected = false; + bool? _muted; + bool _pausedByMute = false; + bool _disposed = false; + int _intentRevision = 0; + Set? _headphones; + Future _tail = Future.value(); + + bool get blocksPlayback => _pauseOnMute && _muted != false; + + Future configure({ + required bool pauseOnMute, + required bool playOnHeadphonesConnected, + }) { + if (_pauseOnMute == pauseOnMute && + _playOnHeadphonesConnected == playOnHeadphonesConnected) { + return _tail; + } + cancelPendingActions(); + if (_pauseOnMute != pauseOnMute) _muted = null; + if (_playOnHeadphonesConnected != playOnHeadphonesConnected) { + _headphones = null; + } + _pauseOnMute = pauseOnMute; + _playOnHeadphonesConnected = playOnHeadphonesConnected; + return _enqueue(_applyVolume); + } + + void cancelPendingActions() { + _intentRevision++; + _pausedByMute = false; + } + + Future volumeChanged(double volume) { + if (!volume.isFinite) return _tail; + _muted = volume <= 0; + return _enqueue(_applyVolume); + } + + Future playbackStarted() => _enqueue(_applyVolume); + + Future _applyVolume() async { + if (!_pauseOnMute) return; + if (_muted == true) { + if (_isPlaying() && _canResume()) { + _pausedByMute = true; + await _pause(); + } + } else if (_muted == false && _pausedByMute) { + _pausedByMute = false; + if (!_isPlaying() && _canResume()) await _play(); + } + } + + Future devicesChanged(Set devices) { + final headphones = devices + .where(_isHeadphoneOutput) + .map((device) => device.id) + .toSet(); + final previous = _headphones; + _headphones = headphones; + // The initial device inventory is not a connection gesture. + if (previous == null || !_playOnHeadphonesConnected) return _tail; + if (previous.isNotEmpty && headphones.isEmpty) { + cancelPendingActions(); + return _enqueue(() async { + if (_isPlaying()) await _pause(); + }); + } + if (headphones.difference(previous).isEmpty) return _tail; + return _enqueue(() async { + if (_playOnHeadphonesConnected && + !blocksPlayback && + !_isPlaying() && + _canResume()) { + await _play(); + } + }); + } + + static bool _isHeadphoneOutput(AudioDevice device) => + device.isOutput && + const { + AudioDeviceType.wiredHeadset, + AudioDeviceType.wiredHeadphones, + AudioDeviceType.usbAudio, + AudioDeviceType.bluetoothA2dp, + AudioDeviceType.bluetoothLe, + AudioDeviceType.hearingAid, + }.contains(device.type); + + Future _enqueue(Future Function() action) { + final revision = _intentRevision; + _tail = _tail.then((_) async { + if (_disposed || revision != _intentRevision) return; + try { + await action(); + } catch (error) { + _pausedByMute = false; + _onError(error); + } + }); + return _tail; + } + + Future dispose() async { + _disposed = true; + cancelPendingActions(); + await _tail; + } +} diff --git a/lib/services/system_volume_service.dart b/lib/services/system_volume_service.dart new file mode 100644 index 00000000..74c76ce5 --- /dev/null +++ b/lib/services/system_volume_service.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'package:volume_controller/volume_controller.dart'; + +/// The volume plugin owns one listener. Share it between background playback +/// and volume sliders so opening or closing a player cannot replace it. +class SystemVolumeService { + SystemVolumeService({Stream? source}) : _source = source; + + static final SystemVolumeService instance = SystemVolumeService(); + + final Stream? _source; + final Set> _listeners = {}; + StreamSubscription? _subscription; + double? _latest; + + Stream get changes => Stream.multi((listener) { + _listeners.add(listener); + if (_latest case final value?) listener.add(value); + if (_subscription == null) { + final source = _source; + _subscription = source == null + ? VolumeController.instance.addListener(_publish) + : source.listen(_publish); + _subscription!.onError((Object error, StackTrace stack) { + for (final listener in _listeners.toList()) { + listener.addError(error, stack); + } + }); + } + listener.onCancel = () { + _listeners.remove(listener); + if (_listeners.isEmpty) { + _latest = null; + // Cancel only our subscription; removeListener could cancel a newer + // plugin subscription while cancellation is still in flight. + if (_subscription != null) unawaited(_subscription!.cancel()); + _subscription = null; + } + }; + }); + + void _publish(double value) { + if (!value.isFinite) return; + _latest = value.clamp(0, 1); + for (final listener in _listeners.toList()) { + listener.add(_latest!); + } + } +} diff --git a/lib/widgets/mornye_volume_control.dart b/lib/widgets/mornye_volume_control.dart index 34fca02d..505e13c0 100644 --- a/lib/widgets/mornye_volume_control.dart +++ b/lib/widgets/mornye_volume_control.dart @@ -8,6 +8,7 @@ import 'package:spotiflac_android/widgets/mornye_player_slider.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:volume_controller/volume_controller.dart'; import 'package:spotiflac_android/services/music_playback_deck.dart'; +import 'package:spotiflac_android/services/system_volume_service.dart'; final usbVolumeProvider = StreamProvider.autoDispose((ref) { final events = StreamController(); @@ -23,20 +24,9 @@ final usbVolumeProvider = StreamProvider.autoDispose((ref) { /// A single shared subscription for the visible player, including hardware /// volume-button changes. This controls system volume, not ReplayGain gain. -final systemVolumeProvider = StreamProvider.autoDispose((ref) { - final events = StreamController(); - final volume = VolumeController.instance; - final subscription = volume.addListener(events.add); - subscription.onError((Object error, StackTrace stack) { - events.addError(error, stack); - }); - ref.onDispose(() { - unawaited(subscription.cancel()); - volume.removeListener(); - unawaited(events.close()); - }); - return events.stream; -}); +final systemVolumeProvider = StreamProvider.autoDispose( + (ref) => SystemVolumeService.instance.changes, +); final systemVolumeWriterProvider = Provider Function(double)>(( ref, diff --git a/test/music_player_automix_test.dart b/test/music_player_automix_test.dart index 75e7208b..3a11c956 100644 --- a/test/music_player_automix_test.dart +++ b/test/music_player_automix_test.dart @@ -15,6 +15,7 @@ import 'package:spotiflac_android/services/playback_notification.dart'; import 'package:spotiflac_android/models/automix_options.dart'; import 'package:spotiflac_android/services/automix_effect_renderer.dart'; import 'package:spotiflac_android/services/automix_status.dart'; +import 'package:spotiflac_android/services/system_volume_service.dart'; class _EffectRenderer extends AutoMixEffectRenderer { final tails = []; @@ -67,6 +68,7 @@ class _AudioNative { final sources = {}; final sourceGates = >{}; final resumedSources = []; + Completer? resumeGate; bool emitsDuration = true; void install() { @@ -103,6 +105,7 @@ class _AudioNative { positions[id] = args['position']! as int; unawaited(event(id, 'audio.onSeekComplete')); case 'resume': + await resumeGate?.future; playing.add(id); resumedSources.add(sources[id]!); case 'pause' || 'stop': @@ -170,6 +173,11 @@ void main() { late AutoplayLibraryLoader autoplayLoader; setUp(() { + setPlaybackAutomationOptions( + enabled: true, + pauseOnMute: false, + playOnHeadphonesConnected: false, + ); setAutoMixEnabled(false); setAutoMixOptions(const AutoMixOptions()); setUsbBitPerfectEnabled(false); @@ -255,6 +263,144 @@ void main() { }, ); + group('automatic playback on system volume', () { + const events = MethodChannel( + 'com.kurenai7968.volume_controller.volume_listener_event', + ); + late List volumeCalls; + + Future volume(double value) => native.messenger.handlePlatformMessage( + events.name, + const StandardMethodCodec().encodeSuccessEnvelope(value), + (_) {}, + ); + + setUp(() { + volumeCalls = []; + native.messenger.setMockMethodCallHandler(events, (call) async { + volumeCalls.add(call.method); + return null; + }); + }); + + tearDown(() async { + setPlaybackAutomationOptions( + enabled: true, + pauseOnMute: false, + playOnHeadphonesConnected: false, + ); + await Future.delayed(Duration.zero); + native.messenger.setMockMethodCallHandler(events, null); + }); + + Future enable() async { + await handler.setQueueAndPlay(_tracks); + setPlaybackAutomationOptions( + enabled: true, + pauseOnMute: true, + playOnHeadphonesConnected: false, + ); + await _until(() => volumeCalls.contains('listen')); + await volume(0.5); + } + + test( + 'mute pauses the real transport and unmute resumes the same song', + () async { + await enable(); + await volume(0); + await _until(() => !handler.playbackState.value.playing); + expect(native.playing, isEmpty); + final current = handler.mediaItem.value!.id; + await volume(0.5); + await _until(() => handler.playbackState.value.playing); + expect(native.playing, {'music-player'}); + expect(handler.mediaItem.value!.id, current); + }, + ); + + test('manual pause cancels automatic resume in the handler', () async { + await enable(); + await volume(0); + await _until(() => !handler.playbackState.value.playing); + await handler.pause(); + await volume(0.5); + await Future.delayed(const Duration(milliseconds: 50)); + expect(handler.playbackState.value.playing, isFalse); + expect(native.playing, isEmpty); + }); + + test('manual pause wins while unmute resume is awaiting native', () async { + await enable(); + await volume(0); + await _until(() => !handler.playbackState.value.playing); + final resumeCount = native.calls + .where((call) => call.$2 == 'resume') + .length; + native.resumeGate = Completer(); + await volume(0.5); + await _until( + () => + native.calls.where((call) => call.$2 == 'resume').length > + resumeCount, + ); + final paused = handler.pause(); + native.resumeGate!.complete(); + await paused; + expect(handler.playbackState.value.playing, isFalse); + expect(native.playing, isEmpty); + }); + + test( + 'changing song during unmute cannot pause the replacement song', + () async { + await enable(); + await volume(0); + await _until(() => !handler.playbackState.value.playing); + final resumeCount = native.calls + .where((call) => call.$2 == 'resume') + .length; + native.resumeGate = Completer(); + await volume(0.5); + await _until( + () => + native.calls.where((call) => call.$2 == 'resume').length > + resumeCount, + ); + final changed = handler.skipToNext(); + native.resumeGate!.complete(); + await changed; + expect(handler.mediaItem.value!.id, 'two'); + expect(handler.playbackState.value.playing, isTrue); + expect(native.playing, {'music-player'}); + }, + ); + + test( + 'opening and closing a volume slider preserves the background listener', + () async { + await enable(); + final slider = SystemVolumeService.instance.changes.listen((_) {}); + await Future.delayed(Duration.zero); + await slider.cancel(); + expect(volumeCalls.where((method) => method == 'listen'), hasLength(1)); + expect(volumeCalls, isNot(contains('cancel'))); + await volume(0); + await _until(() => !handler.playbackState.value.playing); + expect(native.playing, isEmpty); + }, + ); + + test('USB output bypasses system mute automation', () async { + await enable(); + setUsbBitPerfectEnabled(true); + await _until(() => volumeCalls.contains('cancel')); + await volume(0); + await Future.delayed(const Duration(milliseconds: 50)); + expect(handler.playbackState.value.playing, isTrue); + }); + }); + group('USB playback transport', () { const methods = MethodChannel('com.zarz.spotiflac/usb_pcm'); const events = MethodChannel('com.zarz.spotiflac/usb_pcm/events'); diff --git a/test/playback_automation_settings_test.dart b/test/playback_automation_settings_test.dart new file mode 100644 index 00000000..250963ad --- /dev/null +++ b/test/playback_automation_settings_test.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/theme_settings.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/screens/settings/playback_settings_page.dart'; +import 'package:spotiflac_android/screens/settings/settings_search_catalog.dart'; +import 'package:spotiflac_android/theme/app_theme.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +void main() { + for (final mornye in [false, true]) { + testWidgets('automatic controls persist in both themes ($mornye)', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = ProviderContainer( + overrides: [ + initialSettingsProvider.overrideWithValue( + const AppSettings(pauseOnMute: false), + ), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: mornye + ? MornyeTheme.build(Brightness.dark, accent: MornyeAccent.blue) + : AppTheme.light(), + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const PlaybackSettingsPage(), + ), + ), + ); + final muted = find.text('Pause when muted'); + final connected = find.text('Play when headphones connect'); + await tester.scrollUntilVisible( + muted, + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + await tester.tap(muted); + await tester.pumpAndSettle(); + await tester.ensureVisible(connected); + await tester.pumpAndSettle(); + await tester.tap(connected); + await tester.pumpAndSettle(); + expect(container.read(settingsProvider).pauseOnMute, isTrue); + expect( + container.read(settingsProvider).playOnHeadphonesConnected, + isTrue, + ); + final prefs = await SharedPreferences.getInstance(); + final saved = AppSettings.fromJson( + jsonDecode(prefs.getString('app_settings')!) as Map, + ); + expect(saved.pauseOnMute, isTrue); + expect(saved.playOnHeadphonesConnected, isTrue); + + container.read(settingsProvider.notifier).setPlayerMode('external'); + await tester.pump(); + final automationControls = tester.widgetList( + find.byWidgetPredicate( + (widget) => + widget is SettingsSwitchItem && + const [ + 'Pause when muted', + 'Play when headphones connect', + ].contains(widget.title), + ), + ); + expect(automationControls, hasLength(2)); + expect(automationControls.every((widget) => !widget.enabled), isTrue); + expect(tester.takeException(), isNull); + + final l10n = AppLocalizations.of(tester.element(connected)); + final catalog = SettingsSearchCatalog(l10n); + expect( + catalog.playback.any( + (entry) => entry.matches(SettingsSearchQuery('mute')), + ), + isTrue, + ); + expect( + catalog.playback.any( + (entry) => entry.matches(SettingsSearchQuery('earphone')), + ), + isTrue, + ); + }); + } +} diff --git a/test/playback_automation_test.dart b/test/playback_automation_test.dart new file mode 100644 index 00000000..a5899303 --- /dev/null +++ b/test/playback_automation_test.dart @@ -0,0 +1,281 @@ +// Exercises the typed device API used by PlaybackAutomation. +// ignore_for_file: experimental_member_use + +import 'dart:async'; + +import 'package:audio_session/audio_session.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/services/playback_automation.dart'; + +AudioDevice device(String id, AudioDeviceType type, {bool output = true}) => + AudioDevice( + id: id, + name: id, + isInput: !output, + isOutput: output, + type: type, + ); + +class _Transport { + bool playing = true; + bool available = true; + int pauses = 0; + int plays = 0; + Completer? pauseGate; + bool failPause = false; + final List errors = []; + + late final PlaybackAutomation automation = PlaybackAutomation( + isPlaying: () => playing, + canResume: () => available, + pause: () async { + pauses++; + if (failPause) throw StateError('pause failed'); + await pauseGate?.future; + playing = false; + }, + play: () async { + plays++; + playing = true; + }, + onError: errors.add, + ); + + Future enable({bool mute = true, bool headphones = false}) => automation + .configure(pauseOnMute: mute, playOnHeadphonesConnected: headphones); +} + +void main() { + test('mute automation defaults on and saved preferences survive restore', () { + final defaults = AppSettings.fromJson({}); + expect(const AppSettings().pauseOnMute, isTrue); + expect(defaults.pauseOnMute, isTrue); + expect(defaults.playOnHeadphonesConnected, isFalse); + final settings = defaults.copyWith( + pauseOnMute: false, + playOnHeadphonesConnected: true, + ); + final restored = AppSettings.fromJson(settings.toJson()); + expect(restored.pauseOnMute, isFalse); + expect(restored.playOnHeadphonesConnected, isTrue); + expect(restored.copyWith(autoMix: true).pauseOnMute, isFalse); + }); + + test('mute pauses once; unmute resumes only a pause owned by mute', () async { + final transport = _Transport(); + await transport.enable(); + await transport.automation.volumeChanged(0.4); + expect(transport.pauses, 0); + await transport.automation.volumeChanged(0); + await transport.automation.volumeChanged(0); + expect(transport.pauses, 1); + await transport.automation.volumeChanged(0.01); + await transport.automation.volumeChanged(0.4); + expect(transport.plays, 1); + expect(transport.playing, isTrue); + await transport.automation.dispose(); + }); + + test('an already paused song cannot start on unmute', () async { + final transport = _Transport()..playing = false; + await transport.enable(); + await transport.automation.volumeChanged(0); + await transport.automation.volumeChanged(0.5); + expect(transport.pauses, 0); + expect(transport.plays, 0); + 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(); + await transport.enable(); + await transport.automation.volumeChanged(0); + transport.automation.cancelPendingActions(); + await transport.automation.volumeChanged(0.5); + expect(transport.plays, 0); + await transport.automation.dispose(); + }); + } + + test('rapid mute and unmute do not leave a paused song behind', () async { + final transport = _Transport()..pauseGate = Completer(); + await transport.enable(); + final muted = transport.automation.volumeChanged(0); + await Future.delayed(Duration.zero); + expect(transport.pauses, 1); + final unmuted = transport.automation.volumeChanged(0.5); + transport.pauseGate!.complete(); + await Future.wait([muted, unmuted]); + expect(transport.plays, 1); + expect(transport.playing, isTrue); + await transport.automation.dispose(); + }); + + test('explicit pause during an automatic pause cancels resume', () async { + final transport = _Transport()..pauseGate = Completer(); + await transport.enable(); + final muted = transport.automation.volumeChanged(0); + await Future.delayed(Duration.zero); + transport.automation.cancelPendingActions(); + final unmuted = transport.automation.volumeChanged(0.5); + transport.pauseGate!.complete(); + await Future.wait([muted, unmuted]); + expect(transport.plays, 0); + await transport.automation.dispose(); + }); + + test('disabled preferences and unavailable sessions do nothing', () async { + final transport = _Transport(); + await transport.automation.volumeChanged(0); + expect(transport.pauses, 0); + transport.available = false; + await transport.enable(); + expect(transport.pauses, 0); + await transport.automation.dispose(); + }); + + test('turning mute automation off cancels its pending resume', () async { + final transport = _Transport(); + await transport.enable(); + await transport.automation.volumeChanged(0); + await transport.enable(mute: false); + await transport.automation.volumeChanged(0.5); + expect(transport.plays, 0); + await transport.automation.dispose(); + }); + + test('starting a new song at zero volume pauses it', () async { + final transport = _Transport()..playing = false; + await transport.enable(); + await transport.automation.volumeChanged(0); + transport.playing = true; + await transport.automation.playbackStarted(); + expect(transport.pauses, 1); + await transport.automation.volumeChanged(0.5); + expect(transport.plays, 1); + await transport.automation.dispose(); + }); + + test('failed pause is reported and cannot trigger unmute autoplay', () async { + final transport = _Transport()..failPause = true; + await transport.enable(); + await transport.automation.volumeChanged(0); + await transport.automation.volumeChanged(0.5); + expect(transport.errors, hasLength(1)); + expect(transport.plays, 0); + await transport.automation.dispose(); + }); + + test( + 'initial headphone inventory does not start a restored session', + () async { + final transport = _Transport()..playing = false; + await transport.enable(headphones: true); + await transport.automation.devicesChanged({ + device('wired', AudioDeviceType.wiredHeadphones), + }); + expect(transport.plays, 0); + await transport.automation.dispose(); + }, + ); + + for (final type in [ + AudioDeviceType.wiredHeadset, + AudioDeviceType.wiredHeadphones, + AudioDeviceType.usbAudio, + AudioDeviceType.bluetoothA2dp, + AudioDeviceType.bluetoothLe, + ]) { + test('$type connects once and disconnecting pauses', () async { + final transport = _Transport()..playing = false; + await transport.enable(headphones: true); + await transport.automation.volumeChanged(0.5); + await transport.automation.devicesChanged({}); + final headphones = {device('headphones', type)}; + await transport.automation.devicesChanged(headphones); + await transport.automation.devicesChanged(headphones); + expect(transport.plays, 1); + await transport.automation.devicesChanged({}); + expect(transport.pauses, 1); + expect(transport.playing, isFalse); + await transport.automation.dispose(); + }); + } + + test( + 'speakers, microphones and Bluetooth call profiles do not autoplay', + () async { + final transport = _Transport()..playing = false; + await transport.enable(headphones: true); + await transport.automation.devicesChanged({}); + await transport.automation.devicesChanged({ + device('speaker', AudioDeviceType.builtInSpeaker), + device('call', AudioDeviceType.bluetoothSco), + device('mic', AudioDeviceType.wiredHeadset, output: false), + }); + expect(transport.plays, 0); + await transport.automation.dispose(); + }, + ); + + test('headphone connections respect mute and audio interruptions', () async { + final transport = _Transport()..playing = false; + await transport.enable(headphones: true); + await transport.automation.devicesChanged({}); + await transport.automation.volumeChanged(0); + await transport.automation.devicesChanged({ + device('wired', AudioDeviceType.wiredHeadphones), + }); + expect(transport.plays, 0); + await transport.automation.volumeChanged(0.5); + expect(transport.plays, 0); + transport.available = false; + await transport.automation.devicesChanged({ + device('bluetooth', AudioDeviceType.bluetoothA2dp), + }); + expect(transport.plays, 0); + await transport.automation.dispose(); + }); + + test('unplugging cancels an unmute resume', () async { + final transport = _Transport(); + await transport.enable(headphones: true); + await transport.automation.devicesChanged({ + device('wired', AudioDeviceType.wiredHeadphones), + }); + await transport.automation.volumeChanged(0); + await transport.automation.devicesChanged({}); + await transport.automation.volumeChanged(0.5); + expect(transport.plays, 0); + await transport.automation.dispose(); + }); + + test( + 'pending connection playback is cancelled by an explicit pause', + () async { + final transport = _Transport()..playing = false; + await transport.enable(headphones: true); + await transport.automation.devicesChanged({}); + final connected = transport.automation.devicesChanged({ + device('wired', AudioDeviceType.wiredHeadphones), + }); + transport.automation.cancelPendingActions(); + await connected; + expect(transport.plays, 0); + await transport.automation.dispose(); + }, + ); + + test('disposal cancels queued work and stale notifications', () async { + final transport = _Transport(); + await transport.enable(headphones: true); + final muted = transport.automation.volumeChanged(0); + await transport.automation.dispose(); + await muted; + await transport.automation.volumeChanged(0.5); + expect(transport.pauses, 0); + expect(transport.plays, 0); + }); +} diff --git a/test/system_volume_service_test.dart b/test/system_volume_service_test.dart new file mode 100644 index 00000000..5539c84e --- /dev/null +++ b/test/system_volume_service_test.dart @@ -0,0 +1,55 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/system_volume_service.dart'; + +void main() { + test('closing a slider leaves background volume monitoring alive', () async { + var starts = 0; + var cancels = 0; + final source = StreamController.broadcast( + onListen: () => starts++, + onCancel: () => cancels++, + ); + final volume = SystemVolumeService(source: source.stream); + final background = []; + final slider = []; + final backgroundSubscription = volume.changes.listen(background.add); + source.add(0.5); + await Future.delayed(Duration.zero); + final sliderSubscription = volume.changes.listen(slider.add); + await Future.delayed(Duration.zero); + expect(slider, [0.5]); + expect(starts, 1); + await sliderSubscription.cancel(); + expect(cancels, 0); + source.add(0); + await Future.delayed(Duration.zero); + expect(background, [0.5, 0]); + await backgroundSubscription.cancel(); + expect(cancels, 1); + await source.close(); + }); + + test( + 'reattaching waits for fresh volume and releases the last listener', + () async { + final source = StreamController.broadcast(); + final volume = SystemVolumeService(source: source.stream); + final first = volume.changes.listen((_) {}); + source.add(0); + await Future.delayed(Duration.zero); + await first.cancel(); + final values = []; + final second = volume.changes.listen(values.add); + await Future.delayed(Duration.zero); + expect(values, isEmpty); + source.add(0.7); + await Future.delayed(Duration.zero); + expect(values, [0.7]); + await second.cancel(); + expect(source.hasListener, isFalse); + await source.close(); + }, + ); +}