mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-01 05:39:34 +02:00
feat(player): add direct USB audio and DSD transport
This commit is contained in:
1 parent
4e464b5b6b
commit
608675a255
31 files changed
+2875
-12
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user