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,