feat(player): add AAudio hi-res, WavPack DSD and DAC volume

This commit is contained in:
zarzet committed 2026-09-27 15:26:11 +07:00
1 parent 6decce0f8b
commit d92bcf7716
32 files changed
+2118 -55

No files matched your search

+42 -2
View File
@@ -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)),
+114 -7
View File
@@ -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,
),
],
],
),