feat(playback): automate mute and headphone playback controls

This commit is contained in:
zarzet committed 2026-10-05 01:55:08 +07:00
1 parent ad3c9c0279
commit 1abc45e2bf
16 files changed
+1028 -36

No files matched your search

+5
View File
@@ -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"
+6 -1
View File
@@ -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."
}
+9
View File
@@ -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,
+5
View File
@@ -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]!,
+10
View File
@@ -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();
+17
View File
@@ -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',
+150 -21
View File
@@ -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) {
+138
View File
@@ -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;
}
}
+50
View File
@@ -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!);
}
}
}
+4 -14
View File
@@ -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,
+146
View File
@@ -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');
+106
View File
@@ -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,
);
});
}
}
+281
View File
@@ -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);
});
}
+55
View File
@@ -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();
},
);
}