mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 14:37:33 +02:00
feat(playback): automate mute and headphone playback controls
This commit is contained in:
1 parent
ad3c9c0279
commit
1abc45e2bf
16 files changed
+1028
-36
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -24,6 +24,9 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> 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<String, dynamic> _$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]!,
|
||||
|
||||
@@ -568,6 +568,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_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();
|
||||
|
||||
@@ -109,6 +109,13 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
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<MainShell>
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -123,6 +123,40 @@ class _PlaybackSettingsPageState extends ConsumerState<PlaybackSettingsPage> {
|
||||
),
|
||||
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),
|
||||
),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 =
|
||||
<MusicPlaybackDeck, List<StreamSubscription<dynamic>>>{};
|
||||
AudioSession? _audioSession;
|
||||
late final PlaybackAutomation _playbackAutomation;
|
||||
StreamSubscription<double>? _automationVolumeSubscription;
|
||||
StreamSubscription<Set<AudioDevice>>? _automationDevicesSubscription;
|
||||
bool _headphoneMonitoringRequested = false;
|
||||
int _headphoneMonitoringRevision = 0;
|
||||
final List<PlayableMedia> _media = [];
|
||||
final List<MediaItem> _queueItems = [];
|
||||
final Map<String, String> _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<void> _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<void> _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<void> 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<void> pause() async {
|
||||
_playbackAutomation.cancelPendingActions();
|
||||
_pausedByInterruption = false;
|
||||
await _pausePlayback();
|
||||
}
|
||||
|
||||
Future<void> _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<void> 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) {
|
||||
|
||||
@@ -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<void> Function() pause,
|
||||
required Future<void> 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<void> Function() _pause;
|
||||
final Future<void> 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<String>? _headphones;
|
||||
Future<void> _tail = Future<void>.value();
|
||||
|
||||
bool get blocksPlayback => _pauseOnMute && _muted != false;
|
||||
|
||||
Future<void> 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<void> volumeChanged(double volume) {
|
||||
if (!volume.isFinite) return _tail;
|
||||
_muted = volume <= 0;
|
||||
return _enqueue(_applyVolume);
|
||||
}
|
||||
|
||||
Future<void> playbackStarted() => _enqueue(_applyVolume);
|
||||
|
||||
Future<void> _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<void> devicesChanged(Set<AudioDevice> 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<void> _enqueue(Future<void> 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<void> dispose() async {
|
||||
_disposed = true;
|
||||
cancelPendingActions();
|
||||
await _tail;
|
||||
}
|
||||
}
|
||||
@@ -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<double>? source}) : _source = source;
|
||||
|
||||
static final SystemVolumeService instance = SystemVolumeService();
|
||||
|
||||
final Stream<double>? _source;
|
||||
final Set<MultiStreamController<double>> _listeners = {};
|
||||
StreamSubscription<double>? _subscription;
|
||||
double? _latest;
|
||||
|
||||
Stream<double> get changes => Stream<double>.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!);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<UsbVolumeState?>((ref) {
|
||||
final events = StreamController<UsbVolumeState?>();
|
||||
@@ -23,20 +24,9 @@ final usbVolumeProvider = StreamProvider.autoDispose<UsbVolumeState?>((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<double>((ref) {
|
||||
final events = StreamController<double>();
|
||||
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<double>(
|
||||
(ref) => SystemVolumeService.instance.changes,
|
||||
);
|
||||
|
||||
final systemVolumeWriterProvider = Provider<Future<void> Function(double)>((
|
||||
ref,
|
||||
|
||||
@@ -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 = <RenderedMixTail>[];
|
||||
@@ -67,6 +68,7 @@ class _AudioNative {
|
||||
final sources = <String, String>{};
|
||||
final sourceGates = <String, Completer<void>>{};
|
||||
final resumedSources = <String>[];
|
||||
Completer<void>? 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<String> volumeCalls;
|
||||
|
||||
Future<void> 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<void>.delayed(Duration.zero);
|
||||
native.messenger.setMockMethodCallHandler(events, null);
|
||||
});
|
||||
|
||||
Future<void> 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<void>.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<void>();
|
||||
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<void>();
|
||||
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<void>.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<void>.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');
|
||||
|
||||
@@ -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<String, dynamic>,
|
||||
);
|
||||
expect(saved.pauseOnMute, isTrue);
|
||||
expect(saved.playOnHeadphonesConnected, isTrue);
|
||||
|
||||
container.read(settingsProvider.notifier).setPlayerMode('external');
|
||||
await tester.pump();
|
||||
final automationControls = tester.widgetList<SettingsSwitchItem>(
|
||||
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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<void>? pauseGate;
|
||||
bool failPause = false;
|
||||
final List<Object> 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<void> 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<void>();
|
||||
await transport.enable();
|
||||
final muted = transport.automation.volumeChanged(0);
|
||||
await Future<void>.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<void>();
|
||||
await transport.enable();
|
||||
final muted = transport.automation.volumeChanged(0);
|
||||
await Future<void>.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);
|
||||
});
|
||||
}
|
||||
@@ -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<double>.broadcast(
|
||||
onListen: () => starts++,
|
||||
onCancel: () => cancels++,
|
||||
);
|
||||
final volume = SystemVolumeService(source: source.stream);
|
||||
final background = <double>[];
|
||||
final slider = <double>[];
|
||||
final backgroundSubscription = volume.changes.listen(background.add);
|
||||
source.add(0.5);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final sliderSubscription = volume.changes.listen(slider.add);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(slider, [0.5]);
|
||||
expect(starts, 1);
|
||||
await sliderSubscription.cancel();
|
||||
expect(cancels, 0);
|
||||
source.add(0);
|
||||
await Future<void>.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<double>.broadcast();
|
||||
final volume = SystemVolumeService(source: source.stream);
|
||||
final first = volume.changes.listen((_) {});
|
||||
source.add(0);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await first.cancel();
|
||||
final values = <double>[];
|
||||
final second = volume.changes.listen(values.add);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(values, isEmpty);
|
||||
source.add(0.7);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(values, [0.7]);
|
||||
await second.cancel();
|
||||
expect(source.hasListener, isFalse);
|
||||
await source.close();
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user