feat(player): add verified USB bit-perfect PCM output

This commit is contained in:
zarzet
2026-09-27 12:22:42 +07:00
parent 5f0463a7a4
commit 4e464b5b6b
17 changed files with 1262 additions and 13 deletions
+6
View File
@@ -1,4 +1,10 @@
{
"usbBitPerfect": "USB bit-perfect audio",
"usbBitPerfectDescription": "Preserve the original PCM audio on compatible USB DACs with Android 14 or later. Applies from the next track, bypassing AutoMix and normalization. Control volume on your DAC; system volume may not apply.",
"usbBitPerfectPending": "Waiting for the next track and a compatible USB output.",
"usbBitPerfectNoDevice": "No USB DAC connected. Using normal audio output.",
"usbBitPerfectFallback": "This device or audio format does not support bit-perfect output. Using normal playback.",
"usbBitPerfectRouteChanged": "The USB audio route changed. Playback paused; select a track to reconnect.",
"upgradeIntroWelcome": "Welcome to\nSpotiFLAC-Mobile 5.0",
"upgradeIntroThemeTitle": "Make it\nyour own.",
"upgradeIntroThemeBody": "Choose a look. Your choice applies immediately, and you can change it later in Appearance.",
+6
View File
@@ -1,4 +1,10 @@
{
"usbBitPerfect": "Audio USB bit-perfect",
"usbBitPerfectDescription": "Pertahankan PCM asli pada DAC USB yang kompatibel dengan Android 14 atau lebih baru. Berlaku mulai lagu berikutnya, tanpa AutoMix dan normalisasi. Atur volume melalui DAC; volume sistem mungkin tidak berlaku.",
"usbBitPerfectPending": "Menunggu lagu berikutnya dan keluaran USB yang kompatibel.",
"usbBitPerfectNoDevice": "DAC USB tidak terhubung. Menggunakan keluaran audio biasa.",
"usbBitPerfectFallback": "Perangkat atau format audio ini tidak mendukung keluaran bit-perfect. Menggunakan pemutaran biasa.",
"usbBitPerfectRouteChanged": "Keluaran audio USB berubah. Pemutaran dijeda; pilih lagu untuk menyambungkan kembali.",
"upgradeIntroWelcome": "Selamat datang di\nSpotiFLAC-Mobile 5.0",
"upgradeIntroThemeTitle": "Pilih\ntampilanmu.",
"upgradeIntroThemeBody": "Pilih tema yang kamu suka. Pilihan langsung diterapkan dan bisa diubah lagi lewat Tampilan.",
+4
View File
@@ -32,6 +32,7 @@ class AppSettings {
// Apply ReplayGain/R128 tags as volume normalization in the built-in player.
final bool playbackNormalization;
final bool autoMix;
final bool usbBitPerfect;
final bool autoplay;
final bool isFirstLaunch;
final bool checkForUpdates;
@@ -145,6 +146,7 @@ class AppSettings {
this.embedReplayGain = false,
this.playbackNormalization = false,
this.autoMix = false,
this.usbBitPerfect = false,
this.autoplay = false,
this.isFirstLaunch = true,
this.checkForUpdates = true,
@@ -233,6 +235,7 @@ class AppSettings {
bool? embedReplayGain,
bool? playbackNormalization,
bool? autoMix,
bool? usbBitPerfect,
bool? autoplay,
bool? isFirstLaunch,
bool? checkForUpdates,
@@ -317,6 +320,7 @@ class AppSettings {
playbackNormalization:
playbackNormalization ?? this.playbackNormalization,
autoMix: autoMix ?? this.autoMix,
usbBitPerfect: usbBitPerfect ?? this.usbBitPerfect,
autoplay: autoplay ?? this.autoplay,
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
+2
View File
@@ -23,6 +23,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
embedReplayGain: json['embedReplayGain'] as bool? ?? false,
playbackNormalization: json['playbackNormalization'] as bool? ?? false,
autoMix: json['autoMix'] as bool? ?? false,
usbBitPerfect: json['usbBitPerfect'] as bool? ?? false,
autoplay: json['autoplay'] as bool? ?? false,
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
@@ -123,6 +124,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'embedReplayGain': instance.embedReplayGain,
'playbackNormalization': instance.playbackNormalization,
'autoMix': instance.autoMix,
'usbBitPerfect': instance.usbBitPerfect,
'autoplay': instance.autoplay,
'isFirstLaunch': instance.isFirstLaunch,
'checkForUpdates': instance.checkForUpdates,
+5
View File
@@ -561,6 +561,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setUsbBitPerfect(bool enabled) {
state = state.copyWith(usbBitPerfect: enabled);
_saveSettings();
}
void setEmbedMetadata(bool enabled) {
state = state.copyWith(embedMetadata: enabled);
_saveSettings();
+4
View File
@@ -92,6 +92,7 @@ class _MainShellState extends ConsumerState<MainShell>
ref.read(settingsProvider).playbackNormalization,
);
setAutoMixEnabled(ref.read(settingsProvider).autoMix);
setUsbBitPerfectEnabled(ref.read(settingsProvider).usbBitPerfect);
setAutoplayEnabled(
ref.read(settingsProvider).autoplay,
includeLocal: ref.read(settingsProvider).localLibraryEnabled,
@@ -742,6 +743,9 @@ class _MainShellState extends ConsumerState<MainShell>
ref.listen(settingsProvider.select((s) => s.autoMix), (_, enabled) {
setAutoMixEnabled(enabled);
});
ref.listen(settingsProvider.select((s) => s.usbBitPerfect), (_, enabled) {
setUsbBitPerfectEnabled(enabled);
});
ref.listen(
settingsProvider.select((s) => (s.autoplay, s.localLibraryEnabled)),
(_, value) => setAutoplayEnabled(value.$1, includeLocal: value.$2),
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/music_playback_deck.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/widgets/duplicate_review_sheet.dart';
@@ -880,6 +881,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
title: context.l10n.libraryPlaybackNormalization,
subtitle: context.l10n.libraryPlaybackNormalizationSubtitle,
value: settings.playbackNormalization,
enabled: !settings.usbBitPerfect,
onChanged: (v) => ref
.read(settingsProvider.notifier)
.setPlaybackNormalization(v),
@@ -889,10 +891,39 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
title: 'AutoMix',
subtitle: context.l10n.autoMixDescription,
value: settings.autoMix,
enabled: !settings.usbBitPerfect,
onChanged: (value) =>
ref.read(settingsProvider.notifier).setAutoMix(value),
showDivider: false,
showDivider: Platform.isAndroid,
),
if (Platform.isAndroid)
ValueListenableBuilder<UsbAudioStatus>(
valueListenable: usbAudioStatus,
builder: (context, status, _) => SettingsSwitchItem(
icon: Icons.usb,
title: context.l10n.usbBitPerfect,
subtitle: [
context.l10n.usbBitPerfectDescription,
if (settings.usbBitPerfect)
switch (status.reason) {
'active' =>
'${status.device} · ${status.bitDepth}-bit · ${(status.sampleRate! / 1000).toStringAsFixed(1)} kHz',
'no_usb' => context.l10n.usbBitPerfectNoDevice,
'route_changed' =>
context.l10n.usbBitPerfectRouteChanged,
'android_version' ||
'unsupported' ||
'format' => context.l10n.usbBitPerfectFallback,
_ => context.l10n.usbBitPerfectPending,
},
].join('\n\n'),
value: settings.usbBitPerfect,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setUsbBitPerfect(value),
showDivider: false,
),
),
],
),
),
+277
View File
@@ -0,0 +1,277 @@
import 'dart:async';
import 'dart:io';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// Only verified native playback reports active. File metadata alone never does.
final usbAudioStatus = ValueNotifier<UsbAudioStatus>(const UsbAudioStatus());
class UsbAudioStatus {
const UsbAudioStatus({
this.reason = 'idle',
this.device,
this.sampleRate,
this.bitDepth,
});
final String reason;
final String? device;
final int? sampleRate;
final int? bitDepth;
}
/// Keeps the existing transport and AutoMix API while adding a local USB path.
/// Each prepare owns a token so delayed native events cannot affect a new song.
class MusicPlaybackDeck {
MusicPlaybackDeck({required String playerId, bool? nativeAvailable})
: _ordinary = AudioPlayer(playerId: playerId),
_nativeAvailable = nativeAvailable ?? Platform.isAndroid {
_subscriptions.addAll([
_ordinary.onPlayerStateChanged.listen((value) {
if (!_direct) _states.add(value);
}),
_ordinary.onDurationChanged.listen((value) {
if (!_direct) _durations.add(value);
}),
_ordinary.onPlayerComplete.listen((_) {
if (!_direct) _completions.add(null);
}),
]);
}
static const _channel = MethodChannel('com.zarz.spotiflac/usb_pcm');
static const _events = EventChannel('com.zarz.spotiflac/usb_pcm/events');
static Stream<dynamic>? _nativeEvents;
static int _nextToken = 0;
final AudioPlayer _ordinary;
final bool _nativeAvailable;
final _states = StreamController<PlayerState>.broadcast();
final _durations = StreamController<Duration>.broadcast();
final _positions = StreamController<Duration>.broadcast();
final _completions = StreamController<void>.broadcast();
final _subscriptions = <StreamSubscription<dynamic>>[];
StreamSubscription<dynamic>? _nativeSubscription;
StreamSubscription<Duration>? _positionSubscription;
Timer? _timer;
bool _polling = false;
bool _direct = false;
bool _disposed = false;
int _token = 0;
Duration? _duration;
DeviceFileSource? _source;
bool _routeLost = false;
bool _nativeStarted = false;
PlayerState _directState = PlayerState.stopped;
UsbAudioStatus _preparedStatus = const UsbAudioStatus();
bool get isDirect => _direct;
bool get needsSourceReload => _direct && _routeLost;
PlayerState get state => _direct ? _directState : _ordinary.state;
Stream<PlayerState> get onPlayerStateChanged => _states.stream;
Stream<Duration> get onDurationChanged => _durations.stream;
Stream<Duration> get onPositionChanged => _positions.stream;
Stream<void> get onPlayerComplete => _completions.stream;
set positionUpdater(PositionUpdater updater) {
_ordinary.positionUpdater = updater;
unawaited(_positionSubscription?.cancel());
_positionSubscription = _ordinary.onPositionChanged.listen((value) {
if (!_direct) _positions.add(value);
});
}
Future<void> setReleaseMode(ReleaseMode mode) =>
_ordinary.setReleaseMode(mode);
Future<void> setAudioContext(AudioContext context) =>
_ordinary.setAudioContext(context);
Future<void> setVolume(double volume) async {
if (!_direct) await _ordinary.setVolume(volume);
}
Future<void> setPlaybackRate(double rate) async {
if (!_direct) await _ordinary.setPlaybackRate(rate);
}
Future<void> setSource(
DeviceFileSource source, {
bool preferBitPerfect = false,
}) async {
await stop();
_source = source;
_routeLost = false;
_nativeStarted = false;
if (preferBitPerfect && _nativeAvailable) {
_token = ++_nextToken;
_nativeSubscription ??=
(_nativeEvents ??= _events.receiveBroadcastStream()).listen(
_onNativeEvent,
onError: (Object error) {
if (_direct && !_disposed) {
_routeLost = true;
unawaited(pause().catchError((Object _) {}));
usbAudioStatus.value = const UsbAudioStatus(
reason: 'route_changed',
);
}
},
);
try {
final response = await _channel.invokeMapMethod<String, dynamic>(
'prepare',
{'path': source.path, 'token': _token},
);
if (response?['ready'] == true) {
_direct = true;
_directState = PlayerState.stopped;
_duration = Duration(
milliseconds: (response?['duration'] as num?)?.toInt() ?? 0,
);
_preparedStatus = UsbAudioStatus(
reason: 'active',
device: response?['device'] as String?,
sampleRate: (response?['sampleRate'] as num?)?.toInt(),
bitDepth: (response?['bitDepth'] as num?)?.toInt(),
);
usbAudioStatus.value = const UsbAudioStatus(reason: 'ready');
_durations.add(_duration!);
return;
}
usbAudioStatus.value = UsbAudioStatus(
reason: response?['reason'] as String? ?? 'unsupported',
);
} on PlatformException {
usbAudioStatus.value = const UsbAudioStatus(reason: 'unsupported');
} on MissingPluginException {
usbAudioStatus.value = const UsbAudioStatus(reason: 'unsupported');
}
}
await _ordinary.setSource(source);
}
void _onNativeEvent(dynamic event) {
if (_disposed || !_direct || event is! Map || event['token'] != _token) {
return;
}
switch (event['event']) {
case 'active':
usbAudioStatus.value = _preparedStatus;
case 'paused':
_routeLost = true;
_timer?.cancel();
_setState(PlayerState.paused);
usbAudioStatus.value = const UsbAudioStatus(reason: 'route_changed');
case 'complete':
_timer?.cancel();
_setState(PlayerState.completed);
_completions.add(null);
}
}
void _setState(PlayerState value) {
_directState = value;
if (!_disposed) _states.add(value);
}
Future<void> resume() async {
if (!_direct) return _ordinary.resume();
final token = _token;
// Before first playback the SAF lease is still open. After playback starts,
// reconnect through the handler to obtain a fresh lease for the same song.
try {
if (_routeLost) throw PlatformException(code: 'route_changed');
await _channel.invokeMethod<void>('resume');
} on PlatformException {
if (token != _token || !_direct || _disposed) return;
if (_nativeStarted) {
_routeLost = true;
_setState(PlayerState.paused);
usbAudioStatus.value = const UsbAudioStatus(reason: 'route_changed');
rethrow;
}
final source = _source;
final position = await getCurrentPosition() ?? Duration.zero;
await stop();
usbAudioStatus.value = const UsbAudioStatus(reason: 'unsupported');
if (source == null || _disposed) return;
await _ordinary.setVolume(1);
await _ordinary.setSource(source);
if (position > Duration.zero) await _ordinary.seek(position);
return _ordinary.resume();
}
if (_disposed || token != _token || !_direct) return;
_nativeStarted = true;
_setState(PlayerState.playing);
usbAudioStatus.value = _preparedStatus;
_timer?.cancel();
_timer = Timer.periodic(const Duration(milliseconds: 200), (_) async {
if (_polling || _disposed) return;
_polling = true;
final token = _token;
try {
final position = await getCurrentPosition();
if (!_disposed && _direct && token == _token && position != null) {
_positions.add(position);
}
} on PlatformException {
// Route failures arrive through the native event stream.
} finally {
_polling = false;
}
});
}
Future<void> pause() async {
if (!_direct) return _ordinary.pause();
final token = _token;
_timer?.cancel();
await _channel.invokeMethod<void>('pause');
if (token == _token && _direct) _setState(PlayerState.paused);
}
Future<void> seek(Duration position) async {
if (!_direct) return _ordinary.seek(position);
final token = _token;
await _channel.invokeMethod<void>('seek', {
'position': position.inMilliseconds,
});
if (!_disposed && token == _token && _direct) _positions.add(position);
}
Future<Duration?> getCurrentPosition() async {
if (!_direct) return _ordinary.getCurrentPosition();
final milliseconds = await _channel.invokeMethod<int>('position');
return milliseconds == null ? null : Duration(milliseconds: milliseconds);
}
Future<Duration?> getDuration() =>
_direct ? Future.value(_duration) : _ordinary.getDuration();
Future<void> stop() async {
_timer?.cancel();
if (_direct) {
_token = ++_nextToken;
await _channel.invokeMethod<void>('stop');
_setState(PlayerState.stopped);
_direct = false;
usbAudioStatus.value = const UsbAudioStatus();
}
await _ordinary.stop();
}
Future<void> dispose() async {
await stop();
_disposed = true;
await _nativeSubscription?.cancel();
await _positionSubscription?.cancel();
for (final subscription in _subscriptions) {
await subscription.cancel();
}
await _ordinary.dispose();
await _states.close();
await _durations.close();
await _positions.close();
await _completions.close();
}
}
+6 -4
View File
@@ -9,8 +9,8 @@ class _MusicAutoMix {
final AutoMixAnalyzer analyzer;
final _preparationPins = <int, Set<String>>{};
final _mixPins = <String>{};
AudioPlayer? _prepared;
AudioPlayer? _outgoing;
MusicPlaybackDeck? _prepared;
MusicPlaybackDeck? _outgoing;
AutoMixPlan? _plan;
PlayableMedia? _next;
String? _nextPath;
@@ -37,6 +37,8 @@ class _MusicAutoMix {
bool get _canMix =>
_autoMixEnabled &&
!_usbBitPerfectEnabled &&
!handler._player.isDirect &&
!handler._disposed &&
handler._sourceReady &&
!handler._userPaused &&
@@ -91,7 +93,7 @@ class _MusicAutoMix {
: -1;
final pins = <String>{};
_preparationPins[generation] = pins;
AudioPlayer? deck;
MusicPlaybackDeck? deck;
try {
if (nextIndex < 0 || nextIndex == index || index < 0) return;
final current = handler._media[index];
@@ -102,7 +104,7 @@ class _MusicAutoMix {
final nextPath = await handler._resolveSource(next);
if (nextPath == null || !_current(generation)) return;
pins.add(nextPath);
deck = AudioPlayer(
deck = MusicPlaybackDeck(
playerId:
'music-mix-$generation-${DateTime.now().microsecondsSinceEpoch}',
);
+44 -8
View File
@@ -16,6 +16,7 @@ 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/music_playback_deck.dart';
import 'package:spotiflac_android/services/automix_analysis.dart';
import 'package:spotiflac_android/services/automix_analyzer.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
@@ -42,6 +43,7 @@ void updateMusicPlayerStrings({
bool _playbackNormalizationEnabled = false;
bool _autoMixEnabled = false;
bool _usbBitPerfectEnabled = false;
MusicPlayerHandler? _activeMusicPlayerHandler;
PlaybackNotification _notificationPresentation = const PlaybackNotification();
Future<void> Function(MediaItem)? _toggleNotificationFavorite;
@@ -99,6 +101,14 @@ void setAutoMixEnabled(bool enabled) {
if (handler != null) unawaited(handler._autoMix.cancel());
}
void setUsbBitPerfectEnabled(bool enabled) {
if (_usbBitPerfectEnabled == enabled) return;
_usbBitPerfectEnabled = enabled;
final handler = _activeMusicPlayerHandler;
if (handler != null) unawaited(handler._autoMix.cancel());
// Apply at the next source boundary; never raise a playing track's volume.
}
/// Refreshes gain tags after a successful file update, including SAF copies.
void refreshPlaybackNormalization(String source) {
final handler = _activeMusicPlayerHandler;
@@ -352,13 +362,13 @@ Duration normalizedPlaybackResumePosition(
class MusicPlayerHandler extends BaseAudioHandler
with QueueHandler, SeekHandler {
AudioPlayer _player = AudioPlayer(playerId: 'music-player');
MusicPlaybackDeck _player = MusicPlaybackDeck(playerId: 'music-player');
late final _MusicAutoMix _autoMix;
late final _MusicAutoplay _autoplay;
bool _completing = false;
double _normalizationVolume = 1;
final _playerSubscriptions =
<AudioPlayer, List<StreamSubscription<dynamic>>>{};
<MusicPlaybackDeck, List<StreamSubscription<dynamic>>>{};
AudioSession? _audioSession;
final List<PlayableMedia> _media = [];
final List<MediaItem> _queueItems = [];
@@ -433,7 +443,7 @@ class MusicPlayerHandler extends BaseAudioHandler
_listenToPlayer(_player);
}
void _listenToPlayer(AudioPlayer player) {
void _listenToPlayer(MusicPlaybackDeck player) {
// Position updates must continue with the display asleep. UI progress is
// interpolated between updates; querying native playback every frame is
// unnecessary and would stop preparing AutoMix when there are no frames.
@@ -444,6 +454,10 @@ class MusicPlayerHandler extends BaseAudioHandler
_playerSubscriptions[player] = [
player.onPlayerStateChanged.listen((state) {
if (!identical(player, _player)) return;
if (state == PlayerState.paused && player.needsSourceReload) {
_userPaused = true;
_pausedByInterruption = false;
}
if (_switchingGeneration != 0 &&
(state == PlayerState.stopped ||
state == PlayerState.completed ||
@@ -474,7 +488,7 @@ class MusicPlayerHandler extends BaseAudioHandler
];
}
Future<void> _disposeDeck(AudioPlayer player) async {
Future<void> _disposeDeck(MusicPlaybackDeck player) async {
final subscriptions = _playerSubscriptions.remove(player);
if (subscriptions == null) return;
for (final subscription in subscriptions) {
@@ -764,7 +778,11 @@ class MusicPlayerHandler extends BaseAudioHandler
String? cacheKey,
String? displayName,
}) async {
if (!_playbackNormalizationEnabled) return 1.0;
if (!_playbackNormalizationEnabled ||
_usbBitPerfectEnabled ||
_player.isDirect) {
return 1.0;
}
return _normalizationCache.volumeFor(
path,
cacheKey: cacheKey,
@@ -1347,7 +1365,10 @@ class MusicPlayerHandler extends BaseAudioHandler
// AudioPlayer.play combines prepare/seek/resume without a cancellation
// boundary. A late prepare from an old request could resume the wrong
// source. Serialize preparation and recheck before making it audible.
await _player.setSource(DeviceFileSource(path));
await _player.setSource(
DeviceFileSource(path),
preferBitPerfect: _usbBitPerfectEnabled,
);
if (!_isCurrentPlayRequest(generation, media)) return;
if (position != null) {
await _player.seek(position);
@@ -1494,7 +1515,7 @@ class MusicPlayerHandler extends BaseAudioHandler
mediaItem.add(media.toMediaItem(resolvedSource: resolved));
}
_broadcastPosition(effectiveStartPosition, force: true);
_broadcastState(playerState: PlayerState.playing);
_broadcastState();
_lastPeriodicPersistAt = DateTime.now();
unawaited(_persistSession(position: effectiveStartPosition));
// Some files do not emit onDurationChanged reliably (stuck at 0:00);
@@ -1613,6 +1634,15 @@ class MusicPlayerHandler extends BaseAudioHandler
_pausedByInterruption = false;
_interruptionActive = false;
_userPaused = false;
if (_player.needsSourceReload) {
final generation = _playRequestGeneration;
final index = _index;
final position =
await _player.getCurrentPosition() ?? playbackState.value.position;
if (generation != _playRequestGeneration || _disposed) return;
await _playIndex(index, recordHistory: false, startPosition: position);
return;
}
if ((!_sourceReady ||
_player.state == PlayerState.stopped ||
_player.state == PlayerState.completed) &&
@@ -1627,7 +1657,13 @@ class MusicPlayerHandler extends BaseAudioHandler
}
await _activateAudioSession();
await _claimHardwareMediaButtons();
await _player.resume();
try {
await _player.resume();
} on PlatformException {
if (!_player.needsSourceReload) rethrow;
_broadcastState(playerState: PlayerState.paused);
return;
}
_broadcastState(playerState: PlayerState.playing);
}