mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
feat(player): add AAudio hi-res, WavPack DSD and DAC volume
This commit is contained in:
1 parent
6decce0f8b
commit
d92bcf7716
32 files changed
+2118
-55
No files matched your search
+15
-4
@@ -1,10 +1,21 @@
|
||||
{
|
||||
"usbBitPerfect": "USB bit-perfect audio",
|
||||
"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.",
|
||||
"usbBitPerfect": "Hi-res and bit-perfect audio",
|
||||
"usbBitPerfectDescription": "Preserve source precision on compatible outputs. Choose direct USB or AAudio exclusive below; otherwise Android USB output requires Android 14 or later. Applies from the next track, without AutoMix or normalization. System volume may not control direct USB.",
|
||||
"usbHardwareVolume": "DAC hardware volume",
|
||||
"usbVolumeWarningTitle": "Check your listening volume",
|
||||
"usbVolumeWarning": "Direct USB audio can be much louder than normal playback, and phone volume buttons may not control it. Lower your DAC or amplifier volume before starting. When supported, SpotiFLAC sets the DAC to at most −40 dB on connection; this does not guarantee a safe listening level on every amplifier or headphone.",
|
||||
"usbVolumeAccept": "I understand",
|
||||
"usbVolumeUnavailable": "Playback is paused because this DAC has no verified hardware volume control. Lower the DAC or amplifier volume, then enable fixed-volume output if needed and select the track again.",
|
||||
"usbFixedVolume": "Allow fixed-volume USB output",
|
||||
"usbFixedVolumeDescription": "For DACs without a volume control accessible to the app. Keep off for USB headsets without their own volume control. Applies from the next track.",
|
||||
"usbFixedVolumeWarning": "The app cannot lower the volume on this output. Audio may start at full level, and the phone volume slider will not help. Only continue after lowering the volume on your DAC or external amplifier. Do not enable this for a headset without its own volume control.",
|
||||
"dapExclusive": "DAP hi-res (AAudio exclusive)",
|
||||
"dapExclusiveDescription": "Use Oboe/AAudio at the track's original rate and precision for FLAC/WAV PCM. Requires Android 8.1 or later and compatible hardware. Exclusive access is checked; unavailable formats use normal playback. DSD requires the direct USB driver. Applies from the next track.",
|
||||
"dapExclusiveUnavailable": "This device cannot open an exact-rate AAudio exclusive stream for this track. Using normal playback.",
|
||||
"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.",
|
||||
"usbDirectDescription": "Use exclusive USB audio for FLAC/WAV, DSF/DFF and WavPack DSD, 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.",
|
||||
"usbDsdOverPcmDescription": "Enable only if your DAC explicitly supports DoP. Sends DSF/DFF and WavPack 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.",
|
||||
|
||||
+15
-4
@@ -1,12 +1,23 @@
|
||||
{
|
||||
"usbBitPerfect": "Audio USB bit-perfect",
|
||||
"usbBitPerfect": "Audio hi-res dan bit-perfect",
|
||||
"usbHardwareVolume": "Volume hardware DAC",
|
||||
"usbVolumeWarningTitle": "Periksa volume sebelum memutar",
|
||||
"usbVolumeWarning": "Audio USB langsung bisa jauh lebih keras daripada pemutaran biasa, dan tombol volume HP mungkin tidak berpengaruh. Turunkan volume DAC atau amplifier sebelum memutar. Jika didukung, SpotiFLAC mengatur volume DAC paling tinggi −40 dB saat tersambung; angka ini tidak menjamin tingkat suara yang aman untuk semua amplifier atau headphone.",
|
||||
"usbVolumeAccept": "Saya mengerti",
|
||||
"usbVolumeUnavailable": "Pemutaran dijeda karena DAC ini tidak memiliki kontrol volume hardware yang terverifikasi. Turunkan volume DAC atau amplifier, lalu aktifkan keluaran volume tetap jika diperlukan dan pilih lagunya kembali.",
|
||||
"usbFixedVolume": "Izinkan keluaran USB volume tetap",
|
||||
"usbFixedVolumeDescription": "Untuk DAC yang kontrol volumenya tidak bisa diakses aplikasi. Biarkan mati pada headset USB tanpa kontrol volume sendiri. Berlaku mulai lagu berikutnya.",
|
||||
"usbFixedVolumeWarning": "Aplikasi tidak bisa menurunkan volume keluaran ini. Suara bisa mulai pada tingkat penuh dan slider volume HP tidak akan membantu. Lanjutkan hanya setelah menurunkan volume pada DAC atau amplifier eksternal. Jangan aktifkan untuk headset tanpa kontrol volume sendiri.",
|
||||
"dapExclusive": "Hi-res DAP (AAudio eksklusif)",
|
||||
"dapExclusiveDescription": "Gunakan Oboe/AAudio pada laju sampel dan presisi asli lagu PCM FLAC/WAV. Memerlukan Android 8.1 atau lebih baru dan perangkat kompatibel. Akses eksklusif diperiksa; format yang tidak didukung memakai pemutaran biasa. DSD memerlukan driver USB langsung. Berlaku mulai lagu berikutnya.",
|
||||
"dapExclusiveUnavailable": "Perangkat ini tidak bisa membuka AAudio eksklusif pada laju sampel asli lagu. Memakai pemutaran biasa.",
|
||||
"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.",
|
||||
"usbDirectDescription": "Gunakan audio USB eksklusif untuk FLAC/WAV, DSF/DFF dan WavPack DSD, 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.",
|
||||
"usbDsdOverPcmDescription": "Aktifkan hanya jika DAC Anda mendukung DoP. Mengirim DSF/DFF dan WavPack 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.",
|
||||
"usbBitPerfectDescription": "Pertahankan presisi audio asli pada keluaran kompatibel. Pilih USB langsung atau AAudio eksklusif di bawah; keluaran USB Android biasa memerlukan Android 14 atau lebih baru. Berlaku mulai lagu berikutnya, tanpa AutoMix atau normalisasi. Volume sistem mungkin tidak mengatur USB langsung.",
|
||||
"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.",
|
||||
|
||||
@@ -53,6 +53,12 @@ void main() {
|
||||
'libusb',
|
||||
'libusb1-sys',
|
||||
], await rootBundle.loadString('assets/licenses/usb.txt'));
|
||||
yield LicenseEntryWithLineBreaks(const [
|
||||
'Oboe',
|
||||
], await rootBundle.loadString('assets/licenses/oboe.txt'));
|
||||
yield LicenseEntryWithLineBreaks(const [
|
||||
'WavPack',
|
||||
], await rootBundle.loadString('assets/licenses/wavpack.txt'));
|
||||
}
|
||||
});
|
||||
_startupBenchmark?.observeFirstFrame();
|
||||
|
||||
@@ -35,6 +35,8 @@ class AppSettings {
|
||||
final bool usbBitPerfect;
|
||||
final bool usbDirect;
|
||||
final bool usbDsdOverPcm;
|
||||
final bool usbAllowFixedVolume;
|
||||
final bool dapExclusive;
|
||||
final bool autoplay;
|
||||
final bool isFirstLaunch;
|
||||
final bool checkForUpdates;
|
||||
@@ -151,6 +153,8 @@ class AppSettings {
|
||||
this.usbBitPerfect = false,
|
||||
this.usbDirect = false,
|
||||
this.usbDsdOverPcm = false,
|
||||
this.usbAllowFixedVolume = false,
|
||||
this.dapExclusive = false,
|
||||
this.autoplay = false,
|
||||
this.isFirstLaunch = true,
|
||||
this.checkForUpdates = true,
|
||||
@@ -242,6 +246,8 @@ class AppSettings {
|
||||
bool? usbBitPerfect,
|
||||
bool? usbDirect,
|
||||
bool? usbDsdOverPcm,
|
||||
bool? usbAllowFixedVolume,
|
||||
bool? dapExclusive,
|
||||
bool? autoplay,
|
||||
bool? isFirstLaunch,
|
||||
bool? checkForUpdates,
|
||||
@@ -329,6 +335,8 @@ class AppSettings {
|
||||
usbBitPerfect: usbBitPerfect ?? this.usbBitPerfect,
|
||||
usbDirect: usbDirect ?? this.usbDirect,
|
||||
usbDsdOverPcm: usbDsdOverPcm ?? this.usbDsdOverPcm,
|
||||
usbAllowFixedVolume: usbAllowFixedVolume ?? this.usbAllowFixedVolume,
|
||||
dapExclusive: dapExclusive ?? this.dapExclusive,
|
||||
autoplay: autoplay ?? this.autoplay,
|
||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
||||
|
||||
@@ -26,6 +26,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
usbBitPerfect: json['usbBitPerfect'] as bool? ?? false,
|
||||
usbDirect: json['usbDirect'] as bool? ?? false,
|
||||
usbDsdOverPcm: json['usbDsdOverPcm'] as bool? ?? false,
|
||||
usbAllowFixedVolume: json['usbAllowFixedVolume'] as bool? ?? false,
|
||||
dapExclusive: json['dapExclusive'] as bool? ?? false,
|
||||
autoplay: json['autoplay'] as bool? ?? false,
|
||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
||||
@@ -129,6 +131,8 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'usbBitPerfect': instance.usbBitPerfect,
|
||||
'usbDirect': instance.usbDirect,
|
||||
'usbDsdOverPcm': instance.usbDsdOverPcm,
|
||||
'usbAllowFixedVolume': instance.usbAllowFixedVolume,
|
||||
'dapExclusive': instance.dapExclusive,
|
||||
'autoplay': instance.autoplay,
|
||||
'isFirstLaunch': instance.isFirstLaunch,
|
||||
'checkForUpdates': instance.checkForUpdates,
|
||||
|
||||
@@ -567,7 +567,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
void setUsbDirect(bool enabled) {
|
||||
state = state.copyWith(usbDirect: enabled);
|
||||
state = state.copyWith(
|
||||
usbDirect: enabled,
|
||||
dapExclusive: enabled ? false : null,
|
||||
);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
@@ -576,6 +579,19 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUsbAllowFixedVolume(bool enabled) {
|
||||
state = state.copyWith(usbAllowFixedVolume: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setDapExclusive(bool enabled) {
|
||||
state = state.copyWith(
|
||||
dapExclusive: enabled,
|
||||
usbDirect: enabled ? false : null,
|
||||
);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setEmbedMetadata(bool enabled) {
|
||||
state = state.copyWith(embedMetadata: enabled);
|
||||
_saveSettings();
|
||||
|
||||
@@ -26,6 +26,7 @@ import 'package:spotiflac_android/screens/settings/settings_tab.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/shell_navigation_service.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
import 'package:spotiflac_android/services/music_playback_deck.dart';
|
||||
import 'package:spotiflac_android/services/music_player_service.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/app_remote_config_service.dart';
|
||||
@@ -63,6 +64,7 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
bool _hasCheckedAppAnnouncement = false;
|
||||
bool _initialSafRepairComplete = false;
|
||||
bool _safRepairDialogVisible = false;
|
||||
bool _usbVolumeWarningShown = false;
|
||||
StreamSubscription<String>? _shareSubscription;
|
||||
DateTime? _lastBackPress;
|
||||
final GlobalKey<NavigatorState> _homeTabNavigatorKey =
|
||||
@@ -96,6 +98,8 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
setUsbOutputOptions(
|
||||
direct: ref.read(settingsProvider).usbDirect,
|
||||
allowDop: ref.read(settingsProvider).usbDsdOverPcm,
|
||||
allowFixedVolume: ref.read(settingsProvider).usbAllowFixedVolume,
|
||||
dapExclusive: ref.read(settingsProvider).dapExclusive,
|
||||
);
|
||||
setAutoplayEnabled(
|
||||
ref.read(settingsProvider).autoplay,
|
||||
@@ -114,6 +118,7 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
usbAudioStatus.addListener(_onUsbAudioStatusChanged);
|
||||
_homePreviewStopObserver = _PreviewStopNavigatorObserver(
|
||||
() => ref.read(previewPlayerProvider.notifier).stop(),
|
||||
);
|
||||
@@ -158,6 +163,28 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
});
|
||||
}
|
||||
|
||||
void _onUsbAudioStatusChanged() {
|
||||
if (usbAudioStatus.value.reason == 'active') {
|
||||
_usbVolumeWarningShown = false;
|
||||
}
|
||||
if (usbAudioStatus.value.reason != 'volume_unavailable' ||
|
||||
_usbVolumeWarningShown) {
|
||||
return;
|
||||
}
|
||||
_usbVolumeWarningShown = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || usbAudioStatus.value.reason != 'volume_unavailable') {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.usbVolumeUnavailable),
|
||||
duration: const Duration(seconds: 8),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed && _initialSafRepairComplete) {
|
||||
@@ -509,6 +536,7 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
usbAudioStatus.removeListener(_onUsbAudioStatusChanged);
|
||||
ShellNavigationService.unregisterTabSelectionHandler(this);
|
||||
_shareSubscription?.cancel();
|
||||
_pageController.dispose();
|
||||
@@ -751,8 +779,20 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
setUsbBitPerfectEnabled(enabled);
|
||||
});
|
||||
ref.listen(
|
||||
settingsProvider.select((s) => (s.usbDirect, s.usbDsdOverPcm)),
|
||||
(_, value) => setUsbOutputOptions(direct: value.$1, allowDop: value.$2),
|
||||
settingsProvider.select(
|
||||
(s) => (
|
||||
s.usbDirect,
|
||||
s.usbDsdOverPcm,
|
||||
s.usbAllowFixedVolume,
|
||||
s.dapExclusive,
|
||||
),
|
||||
),
|
||||
(_, value) => setUsbOutputOptions(
|
||||
direct: value.$1,
|
||||
allowDop: value.$2,
|
||||
allowFixedVolume: value.$3,
|
||||
dapExclusive: value.$4,
|
||||
),
|
||||
);
|
||||
ref.listen(
|
||||
settingsProvider.select((s) => (s.autoplay, s.localLibraryEnabled)),
|
||||
|
||||
@@ -19,6 +19,7 @@ import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
|
||||
import 'package:spotiflac_android/widgets/ios_library_folder_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_volume_control.dart';
|
||||
|
||||
class LibrarySettingsPage extends ConsumerStatefulWidget {
|
||||
const LibrarySettingsPage({super.key});
|
||||
@@ -205,6 +206,27 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
await ref.read(localLibraryProvider.notifier).cancelScan();
|
||||
}
|
||||
|
||||
Future<bool> _confirmAudioVolume(String message) async {
|
||||
return await showAppDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AppAlertDialog(
|
||||
title: Text(context.l10n.usbVolumeWarningTitle),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
AppDialogAction(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
AppDialogAction(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.usbVolumeAccept),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
Future<void> _clearLibrary() async {
|
||||
final confirmed = await showAppDialog<bool>(
|
||||
context: context,
|
||||
@@ -913,6 +935,10 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
? 'DSD (DoP)'
|
||||
: 'DSD (Native)'} · ${(status.sampleRate! / 1000).toStringAsFixed(1)} kHz',
|
||||
'dsd_unsupported' => context.l10n.usbDsdUnsupported,
|
||||
'volume_unavailable' =>
|
||||
context.l10n.usbVolumeUnavailable,
|
||||
'exclusive_unavailable' =>
|
||||
context.l10n.dapExclusiveUnavailable,
|
||||
'permission_denied' =>
|
||||
context.l10n.usbPermissionDenied,
|
||||
'no_usb' => context.l10n.usbBitPerfectNoDevice,
|
||||
@@ -925,9 +951,19 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
},
|
||||
].join('\n\n'),
|
||||
value: settings.usbBitPerfect,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setUsbBitPerfect(value),
|
||||
onChanged: (value) async {
|
||||
if (value &&
|
||||
!await _confirmAudioVolume(
|
||||
context.l10n.usbVolumeWarning,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
if (mounted) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setUsbBitPerfect(value);
|
||||
}
|
||||
},
|
||||
showDivider: settings.usbBitPerfect,
|
||||
),
|
||||
),
|
||||
@@ -937,9 +973,18 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
title: context.l10n.usbDirect,
|
||||
subtitle: context.l10n.usbDirectDescription,
|
||||
value: settings.usbDirect,
|
||||
onChanged: (value) =>
|
||||
ref.read(settingsProvider.notifier).setUsbDirect(value),
|
||||
showDivider: settings.usbDirect,
|
||||
onChanged: (value) async {
|
||||
if (value &&
|
||||
!await _confirmAudioVolume(
|
||||
context.l10n.usbVolumeWarning,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
if (mounted) {
|
||||
ref.read(settingsProvider.notifier).setUsbDirect(value);
|
||||
}
|
||||
},
|
||||
showDivider: true,
|
||||
),
|
||||
if (settings.usbDirect)
|
||||
SettingsSwitchItem(
|
||||
@@ -950,8 +995,70 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setUsbDsdOverPcm(value),
|
||||
showDivider: false,
|
||||
showDivider: true,
|
||||
),
|
||||
if (settings.usbDirect) ...[
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.volume_up_outlined,
|
||||
title: context.l10n.usbFixedVolume,
|
||||
subtitle: context.l10n.usbFixedVolumeDescription,
|
||||
value: settings.usbAllowFixedVolume,
|
||||
onChanged: (value) async {
|
||||
if (value &&
|
||||
!await _confirmAudioVolume(
|
||||
context.l10n.usbFixedVolumeWarning,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
if (mounted) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setUsbAllowFixedVolume(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
ValueListenableBuilder<UsbVolumeState?>(
|
||||
valueListenable: usbHardwareVolume,
|
||||
builder: (context, volume, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.usbHardwareVolume,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
volume == null
|
||||
? context.l10n.usbBitPerfectPending
|
||||
: volume.available
|
||||
? '${volume.currentDb.toStringAsFixed(1)} dB'
|
||||
: context.l10n.usbVolumeUnavailable,
|
||||
),
|
||||
if (volume?.available == true)
|
||||
MornyeVolumeControl(
|
||||
foreground: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.headphones,
|
||||
title: context.l10n.dapExclusive,
|
||||
subtitle: context.l10n.dapExclusiveDescription,
|
||||
value: settings.dapExclusive,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setDapExclusive(value),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -8,10 +8,45 @@ 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 {
|
||||
class UsbOutputUnavailable extends StateError {
|
||||
UsbOutputUnavailable(super.message);
|
||||
}
|
||||
|
||||
class UsbDsdUnavailable extends UsbOutputUnavailable {
|
||||
UsbDsdUnavailable() : super('DSD requires a compatible direct USB output');
|
||||
}
|
||||
|
||||
/// Null outside direct USB; an unavailable control must not change phone volume.
|
||||
final usbHardwareVolume = ValueNotifier<UsbVolumeState?>(null);
|
||||
|
||||
class UsbVolumeState {
|
||||
UsbVolumeState(Map<dynamic, dynamic> data)
|
||||
: available = data['available'] == true,
|
||||
minDb = (data['minDb'] as num?)?.toDouble() ?? -90,
|
||||
maxDb = (data['maxDb'] as num?)?.toDouble() ?? 0,
|
||||
currentDb = (data['currentDb'] as num?)?.toDouble() ?? 0,
|
||||
token = (data['token'] as num?)?.toInt() ?? 0;
|
||||
final bool available;
|
||||
final double minDb, maxDb, currentDb;
|
||||
final int token;
|
||||
double get fraction => available && maxDb > minDb
|
||||
? ((currentDb - minDb) / (maxDb - minDb)).clamp(0, 1)
|
||||
: 0;
|
||||
}
|
||||
|
||||
Future<void> setUsbHardwareVolume(double fraction) async {
|
||||
final state = usbHardwareVolume.value;
|
||||
if (state == null || !state.available) return;
|
||||
final result = await MusicPlaybackDeck._channel
|
||||
.invokeMapMethod<String, dynamic>('volume', {
|
||||
'token': state.token,
|
||||
'db': state.minDb + fraction.clamp(0, 1) * (state.maxDb - state.minDb),
|
||||
});
|
||||
if (result != null && usbHardwareVolume.value?.token == state.token) {
|
||||
usbHardwareVolume.value = UsbVolumeState(result);
|
||||
}
|
||||
}
|
||||
|
||||
class UsbAudioStatus {
|
||||
const UsbAudioStatus({
|
||||
this.reason = 'idle',
|
||||
@@ -72,6 +107,7 @@ class MusicPlaybackDeck {
|
||||
DeviceFileSource? _source;
|
||||
bool _routeLost = false;
|
||||
bool _nativeStarted = false;
|
||||
bool _volumeBlocked = false;
|
||||
PlayerState _directState = PlayerState.stopped;
|
||||
UsbAudioStatus _preparedStatus = const UsbAudioStatus();
|
||||
|
||||
@@ -113,6 +149,8 @@ class MusicPlaybackDeck {
|
||||
bool directUsb = false,
|
||||
bool allowDop = false,
|
||||
bool requiresDsd = false,
|
||||
bool allowFixedVolume = false,
|
||||
bool dapExclusive = false,
|
||||
}) async {
|
||||
await stop();
|
||||
_source = source;
|
||||
@@ -142,9 +180,17 @@ class MusicPlaybackDeck {
|
||||
'token': _token,
|
||||
'direct': directUsb,
|
||||
'allowDop': allowDop,
|
||||
'allowFixedVolume': allowFixedVolume,
|
||||
'dapExclusive': dapExclusive,
|
||||
'requiresDsd': requiresDsd,
|
||||
});
|
||||
if (_disposed || preparingToken != _token) return;
|
||||
if (response?['ready'] == true) {
|
||||
_volumeBlocked = response?['volumeBlocked'] == true;
|
||||
final volume = response?['volume'];
|
||||
usbHardwareVolume.value = volume is Map
|
||||
? UsbVolumeState(volume)
|
||||
: null;
|
||||
_direct = true;
|
||||
_directState = PlayerState.stopped;
|
||||
_duration = Duration(
|
||||
@@ -158,7 +204,9 @@ class MusicPlaybackDeck {
|
||||
transport: response?['transport'] as String? ?? 'pcm',
|
||||
driver: response?['driver'] as String? ?? 'android',
|
||||
);
|
||||
usbAudioStatus.value = const UsbAudioStatus(reason: 'ready');
|
||||
usbAudioStatus.value = UsbAudioStatus(
|
||||
reason: _volumeBlocked ? 'volume_unavailable' : 'ready',
|
||||
);
|
||||
_durations.add(_duration!);
|
||||
return;
|
||||
}
|
||||
@@ -211,6 +259,12 @@ class MusicPlaybackDeck {
|
||||
|
||||
Future<void> resume() async {
|
||||
if (!_direct) return _ordinary.resume();
|
||||
if (_volumeBlocked) {
|
||||
usbAudioStatus.value = const UsbAudioStatus(reason: 'volume_unavailable');
|
||||
throw UsbOutputUnavailable(
|
||||
'DAC hardware volume unavailable; check audio output settings',
|
||||
);
|
||||
}
|
||||
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.
|
||||
@@ -219,6 +273,14 @@ class MusicPlaybackDeck {
|
||||
await _channel.invokeMethod<void>('resume');
|
||||
} on PlatformException {
|
||||
if (token != _token || !_direct || _disposed) return;
|
||||
if (_preparedStatus.driver == 'usb_direct') {
|
||||
_routeLost = true;
|
||||
_setState(PlayerState.paused);
|
||||
usbAudioStatus.value = const UsbAudioStatus(reason: 'route_changed');
|
||||
throw UsbOutputUnavailable(
|
||||
'Direct USB output changed; reconnect before playing',
|
||||
);
|
||||
}
|
||||
if (_nativeStarted) {
|
||||
_routeLost = true;
|
||||
_setState(PlayerState.paused);
|
||||
@@ -297,6 +359,8 @@ class MusicPlaybackDeck {
|
||||
await _channel.invokeMethod<void>('stop');
|
||||
_setState(PlayerState.stopped);
|
||||
_direct = false;
|
||||
_volumeBlocked = false;
|
||||
usbHardwareVolume.value = null;
|
||||
usbAudioStatus.value = const UsbAudioStatus();
|
||||
}
|
||||
await _ordinary.stop();
|
||||
|
||||
@@ -46,6 +46,8 @@ bool _autoMixEnabled = false;
|
||||
bool _usbBitPerfectEnabled = false;
|
||||
bool _usbDirectEnabled = false;
|
||||
bool _usbDopEnabled = false;
|
||||
bool _usbAllowFixedVolume = false;
|
||||
bool _dapExclusiveEnabled = false;
|
||||
MusicPlayerHandler? _activeMusicPlayerHandler;
|
||||
PlaybackNotification _notificationPresentation = const PlaybackNotification();
|
||||
Future<void> Function(MediaItem)? _toggleNotificationFavorite;
|
||||
@@ -111,9 +113,16 @@ 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}) {
|
||||
void setUsbOutputOptions({
|
||||
required bool direct,
|
||||
required bool allowDop,
|
||||
bool allowFixedVolume = false,
|
||||
bool dapExclusive = false,
|
||||
}) {
|
||||
_usbDirectEnabled = direct;
|
||||
_usbDopEnabled = allowDop;
|
||||
_usbAllowFixedVolume = allowFixedVolume;
|
||||
_dapExclusiveEnabled = dapExclusive;
|
||||
}
|
||||
|
||||
/// Refreshes gain tags after a successful file update, including SAF copies.
|
||||
@@ -1377,8 +1386,11 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
DeviceFileSource(path),
|
||||
preferBitPerfect: _usbBitPerfectEnabled,
|
||||
directUsb: _usbDirectEnabled,
|
||||
allowFixedVolume: _usbAllowFixedVolume,
|
||||
dapExclusive: _dapExclusiveEnabled,
|
||||
allowDop: _usbDopEnabled,
|
||||
requiresDsd:
|
||||
media.bitDepth == 1 ||
|
||||
const ['dsf', 'dff'].contains(media.format?.toLowerCase()) ||
|
||||
media.source.toLowerCase().endsWith('.dsf') ||
|
||||
media.source.toLowerCase().endsWith('.dff'),
|
||||
@@ -1489,7 +1501,7 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
playbackLease = null;
|
||||
}
|
||||
} catch (directError) {
|
||||
if (directError is UsbDsdUnavailable ||
|
||||
if (directError is UsbOutputUnavailable ||
|
||||
playbackLease == null ||
|
||||
!_isCurrentPlayRequest(generation, media)) {
|
||||
rethrow;
|
||||
|
||||
@@ -7,6 +7,19 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
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';
|
||||
|
||||
final usbVolumeProvider = StreamProvider.autoDispose<UsbVolumeState?>((ref) {
|
||||
final events = StreamController<UsbVolumeState?>();
|
||||
void changed() => events.add(usbHardwareVolume.value);
|
||||
usbHardwareVolume.addListener(changed);
|
||||
changed();
|
||||
ref.onDispose(() {
|
||||
usbHardwareVolume.removeListener(changed);
|
||||
unawaited(events.close());
|
||||
});
|
||||
return events.stream;
|
||||
});
|
||||
|
||||
/// A single shared subscription for the visible player, including hardware
|
||||
/// volume-button changes. This controls system volume, not ReplayGain gain.
|
||||
@@ -29,6 +42,7 @@ final systemVolumeWriterProvider = Provider<Future<void> Function(double)>((
|
||||
ref,
|
||||
) {
|
||||
return (value) {
|
||||
if (usbHardwareVolume.value != null) return setUsbHardwareVolume(value);
|
||||
final volume = VolumeController.instance;
|
||||
volume.showSystemUI = false;
|
||||
return volume.setVolume(value.clamp(0, 1));
|
||||
@@ -60,10 +74,15 @@ class _MornyeVolumeControlState extends ConsumerState<MornyeVolumeControl> {
|
||||
Future<void> _flushVolume() async {
|
||||
_writing = true;
|
||||
final write = ref.read(systemVolumeWriterProvider);
|
||||
final usbToken = usbHardwareVolume.value?.token;
|
||||
try {
|
||||
// Send changes during the gesture. If the platform is still processing
|
||||
// a write, keep only the newest value instead of queuing stale positions.
|
||||
while (mounted && _pendingVolume != null) {
|
||||
if (usbHardwareVolume.value?.token != usbToken) {
|
||||
_pendingVolume = null;
|
||||
break;
|
||||
}
|
||||
final value = _pendingVolume!;
|
||||
_pendingVolume = null;
|
||||
try {
|
||||
@@ -80,11 +99,18 @@ class _MornyeVolumeControlState extends ConsumerState<MornyeVolumeControl> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final volume = ref.watch(systemVolumeProvider).value;
|
||||
final usb = ref.watch(usbVolumeProvider).value ?? usbHardwareVolume.value;
|
||||
final volume = usb == null
|
||||
? ref.watch(systemVolumeProvider).value
|
||||
: usb.available
|
||||
? usb.fraction
|
||||
: null;
|
||||
final value = (_preview ?? volume ?? 0).clamp(0.0, 1.0);
|
||||
String percentage(double volume) => '${(volume * 100).round()}%';
|
||||
return Semantics(
|
||||
label: context.l10n.nowPlayingVolume,
|
||||
label: usb == null
|
||||
? context.l10n.nowPlayingVolume
|
||||
: context.l10n.usbHardwareVolume,
|
||||
slider: true,
|
||||
enabled: volume != null,
|
||||
excludeSemantics: true,
|
||||
|
||||
Reference in new issue
Block a user