feat(player): add direct USB audio and DSD transport

This commit is contained in:
zarzet committed 2026-09-27 15:26:10 +07:00
1 parent 4e464b5b6b
commit 608675a255
31 files changed
+2875 -12

No files matched your search

+7 -1
View File
@@ -1,6 +1,12 @@
{
"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.",
"usbBitPerfectDescription": "Preserve the original audio on compatible USB DACs. Android output requires Android 14 or later; the direct USB driver also works on older Android versions. Applies from the next track, bypassing AutoMix and normalization. Control volume on your DAC; system volume may not apply.",
"usbDirect": "Direct USB driver",
"usbDirectDescription": "Use exclusive USB audio for FLAC/WAV and DSF/DFF, with Android USB permission. Native DSD requires a recognized compatible DAC. Other PCM formats use normal playback. Applies from the next track.",
"usbDsdOverPcm": "DSD over PCM (DoP)",
"usbDsdOverPcmDescription": "Enable only if your DAC explicitly supports DoP. Sends DSF/DFF as DSD inside a PCM carrier when native DSD is unavailable. An incompatible device may play noise; leave off for ordinary USB headsets.",
"usbDsdUnsupported": "This DSD format or connected DAC is not supported. Playback stopped.",
"usbPermissionDenied": "USB permission was not granted. Using normal playback.",
"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.",
+7 -1
View File
@@ -1,6 +1,12 @@
{
"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.",
"usbDirect": "Driver USB langsung",
"usbDirectDescription": "Gunakan audio USB eksklusif untuk FLAC/WAV dan DSF/DFF, dengan izin USB Android. Native DSD memerlukan DAC kompatibel yang dikenali. Format PCM lainnya memakai pemutaran biasa. Berlaku mulai lagu berikutnya.",
"usbDsdOverPcm": "DSD melalui PCM (DoP)",
"usbDsdOverPcmDescription": "Aktifkan hanya jika DAC Anda mendukung DoP. Mengirim DSF/DFF sebagai DSD di dalam pembawa PCM saat native DSD tidak tersedia. Perangkat yang tidak kompatibel dapat menghasilkan suara bising; biarkan mati untuk headset USB biasa.",
"usbDsdUnsupported": "Format DSD atau DAC yang tersambung tidak didukung. Pemutaran dihentikan.",
"usbPermissionDenied": "Izin USB tidak diberikan. Memakai pemutaran biasa.",
"usbBitPerfectDescription": "Pertahankan audio asli pada DAC USB yang kompatibel. Keluaran Android memerlukan Android 14 atau lebih baru; driver USB langsung juga tersedia di Android versi lama. 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.",
+6
View File
@@ -48,6 +48,12 @@ void main() {
yield LicenseEntryWithLineBreaks(const [
'Inter',
], await rootBundle.loadString('assets/fonts/Inter-LICENSE.txt'));
if (Platform.isAndroid) {
yield LicenseEntryWithLineBreaks(const [
'libusb',
'libusb1-sys',
], await rootBundle.loadString('assets/licenses/usb.txt'));
}
});
_startupBenchmark?.observeFirstFrame();
+8
View File
@@ -33,6 +33,8 @@ class AppSettings {
final bool playbackNormalization;
final bool autoMix;
final bool usbBitPerfect;
final bool usbDirect;
final bool usbDsdOverPcm;
final bool autoplay;
final bool isFirstLaunch;
final bool checkForUpdates;
@@ -147,6 +149,8 @@ class AppSettings {
this.playbackNormalization = false,
this.autoMix = false,
this.usbBitPerfect = false,
this.usbDirect = false,
this.usbDsdOverPcm = false,
this.autoplay = false,
this.isFirstLaunch = true,
this.checkForUpdates = true,
@@ -236,6 +240,8 @@ class AppSettings {
bool? playbackNormalization,
bool? autoMix,
bool? usbBitPerfect,
bool? usbDirect,
bool? usbDsdOverPcm,
bool? autoplay,
bool? isFirstLaunch,
bool? checkForUpdates,
@@ -321,6 +327,8 @@ class AppSettings {
playbackNormalization ?? this.playbackNormalization,
autoMix: autoMix ?? this.autoMix,
usbBitPerfect: usbBitPerfect ?? this.usbBitPerfect,
usbDirect: usbDirect ?? this.usbDirect,
usbDsdOverPcm: usbDsdOverPcm ?? this.usbDsdOverPcm,
autoplay: autoplay ?? this.autoplay,
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
+4
View File
@@ -24,6 +24,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
playbackNormalization: json['playbackNormalization'] as bool? ?? false,
autoMix: json['autoMix'] as bool? ?? false,
usbBitPerfect: json['usbBitPerfect'] as bool? ?? false,
usbDirect: json['usbDirect'] as bool? ?? false,
usbDsdOverPcm: json['usbDsdOverPcm'] as bool? ?? false,
autoplay: json['autoplay'] as bool? ?? false,
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
@@ -125,6 +127,8 @@ Map<String, dynamic> _$AppSettingsToJson(
'playbackNormalization': instance.playbackNormalization,
'autoMix': instance.autoMix,
'usbBitPerfect': instance.usbBitPerfect,
'usbDirect': instance.usbDirect,
'usbDsdOverPcm': instance.usbDsdOverPcm,
'autoplay': instance.autoplay,
'isFirstLaunch': instance.isFirstLaunch,
'checkForUpdates': instance.checkForUpdates,
+10
View File
@@ -566,6 +566,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setUsbDirect(bool enabled) {
state = state.copyWith(usbDirect: enabled);
_saveSettings();
}
void setUsbDsdOverPcm(bool enabled) {
state = state.copyWith(usbDsdOverPcm: enabled);
_saveSettings();
}
void setEmbedMetadata(bool enabled) {
state = state.copyWith(embedMetadata: enabled);
_saveSettings();
+8
View File
@@ -93,6 +93,10 @@ class _MainShellState extends ConsumerState<MainShell>
);
setAutoMixEnabled(ref.read(settingsProvider).autoMix);
setUsbBitPerfectEnabled(ref.read(settingsProvider).usbBitPerfect);
setUsbOutputOptions(
direct: ref.read(settingsProvider).usbDirect,
allowDop: ref.read(settingsProvider).usbDsdOverPcm,
);
setAutoplayEnabled(
ref.read(settingsProvider).autoplay,
includeLocal: ref.read(settingsProvider).localLibraryEnabled,
@@ -746,6 +750,10 @@ class _MainShellState extends ConsumerState<MainShell>
ref.listen(settingsProvider.select((s) => s.usbBitPerfect), (_, enabled) {
setUsbBitPerfectEnabled(enabled);
});
ref.listen(
settingsProvider.select((s) => (s.usbDirect, s.usbDsdOverPcm)),
(_, value) => setUsbOutputOptions(direct: value.$1, allowDop: value.$2),
);
ref.listen(
settingsProvider.select((s) => (s.autoplay, s.localLibraryEnabled)),
(_, value) => setAutoplayEnabled(value.$1, includeLocal: value.$2),
@@ -907,7 +907,14 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
if (settings.usbBitPerfect)
switch (status.reason) {
'active' =>
'${status.device} · ${status.bitDepth}-bit · ${(status.sampleRate! / 1000).toStringAsFixed(1)} kHz',
'${status.device} · ${status.transport == 'pcm'
? '${status.bitDepth}-bit PCM'
: status.transport == 'dop'
? 'DSD (DoP)'
: 'DSD (Native)'} · ${(status.sampleRate! / 1000).toStringAsFixed(1)} kHz',
'dsd_unsupported' => context.l10n.usbDsdUnsupported,
'permission_denied' =>
context.l10n.usbPermissionDenied,
'no_usb' => context.l10n.usbBitPerfectNoDevice,
'route_changed' =>
context.l10n.usbBitPerfectRouteChanged,
@@ -921,9 +928,31 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setUsbBitPerfect(value),
showDivider: false,
showDivider: settings.usbBitPerfect,
),
),
if (Platform.isAndroid && settings.usbBitPerfect) ...[
SettingsSwitchItem(
icon: Icons.usb,
title: context.l10n.usbDirect,
subtitle: context.l10n.usbDirectDescription,
value: settings.usbDirect,
onChanged: (value) =>
ref.read(settingsProvider.notifier).setUsbDirect(value),
showDivider: settings.usbDirect,
),
if (settings.usbDirect)
SettingsSwitchItem(
icon: Icons.graphic_eq,
title: context.l10n.usbDsdOverPcm,
subtitle: context.l10n.usbDsdOverPcmDescription,
value: settings.usbDsdOverPcm,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setUsbDsdOverPcm(value),
showDivider: false,
),
],
],
),
),
+47 -5
View File
@@ -8,18 +8,26 @@ import 'package:flutter/services.dart';
/// Only verified native playback reports active. File metadata alone never does.
final usbAudioStatus = ValueNotifier<UsbAudioStatus>(const UsbAudioStatus());
class UsbDsdUnavailable extends StateError {
UsbDsdUnavailable() : super('DSD requires a compatible direct USB output');
}
class UsbAudioStatus {
const UsbAudioStatus({
this.reason = 'idle',
this.device,
this.sampleRate,
this.bitDepth,
this.transport = 'pcm',
this.driver = 'android',
});
final String reason;
final String? device;
final int? sampleRate;
final int? bitDepth;
final String transport;
final String driver;
}
/// Keeps the existing transport and AutoMix API while adding a local USB path.
@@ -57,6 +65,7 @@ class MusicPlaybackDeck {
Timer? _timer;
bool _polling = false;
bool _direct = false;
bool _preparing = false;
bool _disposed = false;
int _token = 0;
Duration? _duration;
@@ -68,6 +77,10 @@ class MusicPlaybackDeck {
bool get isDirect => _direct;
bool get needsSourceReload => _direct && _routeLost;
Future<void> cancelPreparation() async {
if (_preparing) await stop();
}
PlayerState get state => _direct ? _directState : _ordinary.state;
Stream<PlayerState> get onPlayerStateChanged => _states.stream;
Stream<Duration> get onDurationChanged => _durations.stream;
@@ -97,6 +110,9 @@ class MusicPlaybackDeck {
Future<void> setSource(
DeviceFileSource source, {
bool preferBitPerfect = false,
bool directUsb = false,
bool allowDop = false,
bool requiresDsd = false,
}) async {
await stop();
_source = source;
@@ -104,6 +120,8 @@ class MusicPlaybackDeck {
_nativeStarted = false;
if (preferBitPerfect && _nativeAvailable) {
_token = ++_nextToken;
final preparingToken = _token;
_preparing = true;
_nativeSubscription ??=
(_nativeEvents ??= _events.receiveBroadcastStream()).listen(
_onNativeEvent,
@@ -118,10 +136,14 @@ class MusicPlaybackDeck {
},
);
try {
final response = await _channel.invokeMapMethod<String, dynamic>(
'prepare',
{'path': source.path, 'token': _token},
);
final response = await _channel
.invokeMapMethod<String, dynamic>('prepare', {
'path': source.path,
'token': _token,
'direct': directUsb,
'allowDop': allowDop,
});
if (_disposed || preparingToken != _token) return;
if (response?['ready'] == true) {
_direct = true;
_directState = PlayerState.stopped;
@@ -133,6 +155,8 @@ class MusicPlaybackDeck {
device: response?['device'] as String?,
sampleRate: (response?['sampleRate'] as num?)?.toInt(),
bitDepth: (response?['bitDepth'] as num?)?.toInt(),
transport: response?['transport'] as String? ?? 'pcm',
driver: response?['driver'] as String? ?? 'android',
);
usbAudioStatus.value = const UsbAudioStatus(reason: 'ready');
_durations.add(_duration!);
@@ -141,12 +165,23 @@ class MusicPlaybackDeck {
usbAudioStatus.value = UsbAudioStatus(
reason: response?['reason'] as String? ?? 'unsupported',
);
if (response?['fatal'] == true) {
throw UsbDsdUnavailable();
}
} on PlatformException {
if (_disposed || preparingToken != _token) return;
usbAudioStatus.value = const UsbAudioStatus(reason: 'unsupported');
} on MissingPluginException {
if (_disposed || preparingToken != _token) return;
usbAudioStatus.value = const UsbAudioStatus(reason: 'unsupported');
} finally {
if (preparingToken == _token) _preparing = false;
}
}
if (requiresDsd) {
usbAudioStatus.value = const UsbAudioStatus(reason: 'dsd_unsupported');
throw UsbDsdUnavailable();
}
await _ordinary.setSource(source);
}
@@ -190,6 +225,12 @@ class MusicPlaybackDeck {
usbAudioStatus.value = const UsbAudioStatus(reason: 'route_changed');
rethrow;
}
if (_preparedStatus.transport != 'pcm') {
_routeLost = true;
_setState(PlayerState.paused);
usbAudioStatus.value = const UsbAudioStatus(reason: 'dsd_unsupported');
throw UsbDsdUnavailable();
}
final source = _source;
final position = await getCurrentPosition() ?? Duration.zero;
await stop();
@@ -250,8 +291,9 @@ class MusicPlaybackDeck {
Future<void> stop() async {
_timer?.cancel();
if (_direct) {
if (_direct || _preparing) {
_token = ++_nextToken;
_preparing = false;
await _channel.invokeMethod<void>('stop');
_setState(PlayerState.stopped);
_direct = false;
+16 -1
View File
@@ -44,6 +44,8 @@ void updateMusicPlayerStrings({
bool _playbackNormalizationEnabled = false;
bool _autoMixEnabled = false;
bool _usbBitPerfectEnabled = false;
bool _usbDirectEnabled = false;
bool _usbDopEnabled = false;
MusicPlayerHandler? _activeMusicPlayerHandler;
PlaybackNotification _notificationPresentation = const PlaybackNotification();
Future<void> Function(MediaItem)? _toggleNotificationFavorite;
@@ -109,6 +111,11 @@ void setUsbBitPerfectEnabled(bool enabled) {
// Apply at the next source boundary; never raise a playing track's volume.
}
void setUsbOutputOptions({required bool direct, required bool allowDop}) {
_usbDirectEnabled = direct;
_usbDopEnabled = allowDop;
}
/// Refreshes gain tags after a successful file update, including SAF copies.
void refreshPlaybackNormalization(String source) {
final handler = _activeMusicPlayerHandler;
@@ -1329,6 +1336,7 @@ class MusicPlayerHandler extends BaseAudioHandler
unawaited(_autoplay.fill());
final media = _media[index];
_switchingGeneration = generation;
await _player.cancelPreparation();
await _serializeSourceChange(() async {
try {
if (!_isCurrentPlayRequest(generation, media)) return;
@@ -1368,6 +1376,12 @@ class MusicPlayerHandler extends BaseAudioHandler
await _player.setSource(
DeviceFileSource(path),
preferBitPerfect: _usbBitPerfectEnabled,
directUsb: _usbDirectEnabled,
allowDop: _usbDopEnabled,
requiresDsd:
const ['dsf', 'dff'].contains(media.format?.toLowerCase()) ||
media.source.toLowerCase().endsWith('.dsf') ||
media.source.toLowerCase().endsWith('.dff'),
);
if (!_isCurrentPlayRequest(generation, media)) return;
if (position != null) {
@@ -1475,7 +1489,8 @@ class MusicPlayerHandler extends BaseAudioHandler
playbackLease = null;
}
} catch (directError) {
if (playbackLease == null ||
if (directError is UsbDsdUnavailable ||
playbackLease == null ||
!_isCurrentPlayRequest(generation, media)) {
rethrow;
}