diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 5874789c..66ad2051 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1,4 +1,8 @@ { + "settingsPlaybackSubtitle": "Player, volume, AutoMix, and audio output", + "sectionPlayer": "Player", + "sectionVolumeAndTransitions": "Volume and transitions", + "sectionAudioOutput": "Audio output", "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", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 07c27326..b348b8b7 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -1,4 +1,8 @@ { + "settingsPlaybackSubtitle": "Pemutar, volume, AutoMix, dan keluaran audio", + "sectionPlayer": "Pemutar", + "sectionVolumeAndTransitions": "Volume dan transisi", + "sectionAudioOutput": "Keluaran audio", "usbBitPerfect": "Audio hi-res dan bit-perfect", "usbHardwareVolume": "Volume hardware DAC", "usbVolumeWarningTitle": "Periksa volume sebelum memutar", diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 36bf6952..817be414 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -11,7 +11,6 @@ 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'; @@ -19,7 +18,6 @@ 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}); @@ -206,27 +204,6 @@ class _LibrarySettingsPageState extends ConsumerState { await ref.read(localLibraryProvider.notifier).cancelScan(); } - Future _confirmAudioVolume(String message) async { - return await showAppDialog( - 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 _clearLibrary() async { final confirmed = await showAppDialog( context: context, @@ -870,199 +847,6 @@ class _LibrarySettingsPageState extends ConsumerState { ), ), - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.libraryPlayback), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - SettingsItem( - icon: Icons.open_in_new, - title: context.l10n.libraryExternalPlayer, - subtitle: context.l10n.libraryExternalPlayerSubtitle, - trailing: settings.playerMode == 'external' - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () => ref - .read(settingsProvider.notifier) - .setPlayerMode('external'), - ), - SettingsItem( - icon: Icons.play_circle_outline, - title: context.l10n.libraryBuiltInPreviewPlayer, - subtitle: context.l10n.libraryBuiltInPreviewPlayerSubtitle, - trailing: settings.playerMode == 'internal' - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () => ref - .read(settingsProvider.notifier) - .setPlayerMode('internal'), - ), - SettingsSwitchItem( - icon: Icons.graphic_eq, - title: context.l10n.libraryPlaybackNormalization, - subtitle: context.l10n.libraryPlaybackNormalizationSubtitle, - value: settings.playbackNormalization, - enabled: !settings.usbBitPerfect, - onChanged: (v) => ref - .read(settingsProvider.notifier) - .setPlaybackNormalization(v), - ), - SettingsSwitchItem( - icon: Icons.compare_arrows, - title: 'AutoMix', - subtitle: context.l10n.autoMixDescription, - value: settings.autoMix, - enabled: !settings.usbBitPerfect, - onChanged: (value) => - ref.read(settingsProvider.notifier).setAutoMix(value), - showDivider: Platform.isAndroid, - ), - if (Platform.isAndroid) - ValueListenableBuilder( - 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.transport == 'pcm' - ? '${status.bitDepth}-bit PCM' - : status.transport == 'dop' - ? '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, - 'route_changed' => - context.l10n.usbBitPerfectRouteChanged, - 'android_version' || - 'unsupported' || - 'format' => context.l10n.usbBitPerfectFallback, - _ => context.l10n.usbBitPerfectPending, - }, - ].join('\n\n'), - value: settings.usbBitPerfect, - onChanged: (value) async { - if (value && - !await _confirmAudioVolume( - context.l10n.usbVolumeWarning, - )) { - return; - } - if (mounted) { - ref - .read(settingsProvider.notifier) - .setUsbBitPerfect(value); - } - }, - 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) async { - if (value && - !await _confirmAudioVolume( - context.l10n.usbVolumeWarning, - )) { - return; - } - if (mounted) { - ref.read(settingsProvider.notifier).setUsbDirect(value); - } - }, - showDivider: true, - ), - 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: 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( - 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, - ), - ], - ], - ), - ), const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ), diff --git a/lib/screens/settings/playback_settings_page.dart b/lib/screens/settings/playback_settings_page.dart new file mode 100644 index 00000000..4f11c620 --- /dev/null +++ b/lib/screens/settings/playback_settings_page.dart @@ -0,0 +1,270 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/music_playback_deck.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_sliver_header.dart'; +import 'package:spotiflac_android/widgets/mornye_volume_control.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +class PlaybackSettingsPage extends ConsumerStatefulWidget { + const PlaybackSettingsPage({super.key}); + + @override + ConsumerState createState() => + _PlaybackSettingsPageState(); +} + +class _PlaybackSettingsPageState extends ConsumerState { + Future _confirmAudioVolume(String message) async { + return await showAppDialog( + 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; + } + + @override + Widget build(BuildContext context) { + final settings = ref.watch(settingsProvider); + final colorScheme = Theme.of(context).colorScheme; + return Scaffold( + body: CustomScrollView( + slivers: [ + AppSliverHeader.page(title: context.l10n.libraryPlayback), + SliverToBoxAdapter( + child: SettingsSectionHeader(title: context.l10n.sectionPlayer), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsItem( + icon: Icons.open_in_new, + title: context.l10n.libraryExternalPlayer, + subtitle: context.l10n.libraryExternalPlayerSubtitle, + trailing: settings.playerMode == 'external' + ? Icon(Icons.check, color: colorScheme.primary) + : null, + onTap: () => ref + .read(settingsProvider.notifier) + .setPlayerMode('external'), + ), + SettingsItem( + icon: Icons.play_circle_outline, + title: context.l10n.libraryBuiltInPreviewPlayer, + subtitle: context.l10n.libraryBuiltInPreviewPlayerSubtitle, + trailing: settings.playerMode == 'internal' + ? Icon(Icons.check, color: colorScheme.primary) + : null, + onTap: () => ref + .read(settingsProvider.notifier) + .setPlayerMode('internal'), + showDivider: false, + ), + ], + ), + ), + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.sectionVolumeAndTransitions, + ), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsSwitchItem( + icon: Icons.graphic_eq, + title: context.l10n.libraryPlaybackNormalization, + subtitle: context.l10n.libraryPlaybackNormalizationSubtitle, + value: settings.playbackNormalization, + enabled: !settings.usbBitPerfect, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setPlaybackNormalization(value), + ), + SettingsSwitchItem( + icon: Icons.compare_arrows, + title: 'AutoMix', + subtitle: context.l10n.autoMixDescription, + value: settings.autoMix, + enabled: !settings.usbBitPerfect, + onChanged: (value) => + ref.read(settingsProvider.notifier).setAutoMix(value), + showDivider: false, + ), + ], + ), + ), + if (Platform.isAndroid) + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.sectionAudioOutput, + ), + ), + if (Platform.isAndroid) + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + ValueListenableBuilder( + 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.transport == 'pcm' + ? '${status.bitDepth}-bit PCM' + : status.transport == 'dop' + ? '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, + 'route_changed' => + context.l10n.usbBitPerfectRouteChanged, + 'android_version' || + 'unsupported' || + 'format' => context.l10n.usbBitPerfectFallback, + _ => context.l10n.usbBitPerfectPending, + }, + ].join('\n\n'), + value: settings.usbBitPerfect, + onChanged: (value) async { + if (value && + !await _confirmAudioVolume( + context.l10n.usbVolumeWarning, + )) { + return; + } + if (mounted) { + ref + .read(settingsProvider.notifier) + .setUsbBitPerfect(value); + } + }, + showDivider: settings.usbBitPerfect, + ), + ), + if (settings.usbBitPerfect) ...[ + SettingsSwitchItem( + icon: Icons.usb, + title: context.l10n.usbDirect, + subtitle: context.l10n.usbDirectDescription, + value: settings.usbDirect, + onChanged: (value) async { + if (value && + !await _confirmAudioVolume( + context.l10n.usbVolumeWarning, + )) { + return; + } + if (mounted) { + ref + .read(settingsProvider.notifier) + .setUsbDirect(value); + } + }, + ), + 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), + ), + 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( + 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: 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, + ), + ], + ], + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ); + } +} diff --git a/lib/screens/settings/settings_search_catalog.dart b/lib/screens/settings/settings_search_catalog.dart index bcb3a443..660c6885 100644 --- a/lib/screens/settings/settings_search_catalog.dart +++ b/lib/screens/settings/settings_search_catalog.dart @@ -50,7 +50,7 @@ class SettingsSearchQuery { /// Localized index of controls found inside every first-level settings page. class SettingsSearchCatalog { - SettingsSearchCatalog(AppLocalizations l10n) + SettingsSearchCatalog(AppLocalizations l10n, {bool androidAudio = false}) : extensions = [ SettingsSearchEntry( icon: Icons.low_priority, @@ -224,6 +224,8 @@ class SettingsSearchCatalog { subtitle: l10n.libraryClearSubtitle, keywords: const ['reset local library'], ), + ], + playback = [ SettingsSearchEntry( icon: Icons.open_in_new, title: l10n.libraryExternalPlayer, @@ -242,6 +244,27 @@ class SettingsSearchCatalog { subtitle: l10n.libraryPlaybackNormalizationSubtitle, keywords: const ['replaygain', 'volume'], ), + SettingsSearchEntry( + icon: Icons.compare_arrows, + title: 'AutoMix', + subtitle: l10n.autoMixDescription, + keywords: const ['crossfade', 'beat matching'], + ), + if (androidAudio) ...[ + SettingsSearchEntry( + icon: Icons.usb, + title: l10n.usbBitPerfect, + subtitle: l10n.usbBitPerfectDescription, + keywords: const ['usb', 'dac', 'dsd', 'dop', 'hardware volume'], + ), + SettingsSearchEntry( + icon: Icons.headphones, + title: l10n.dapExclusive, + subtitle: l10n.dapExclusiveDescription, + keywords: const ['oboe', 'aaudio', 'exclusive', 'hi-res'], + targetLabel: l10n.usbBitPerfect, + ), + ], ], metadata = [ SettingsSearchEntry( @@ -694,6 +717,7 @@ class SettingsSearchCatalog { final List extensions; final List appearance; final List library; + final List playback; final List metadata; final List lyrics; final List download; diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index ca8a8cc3..2956d05e 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/constants/app_info.dart'; @@ -17,6 +19,7 @@ import 'package:spotiflac_android/screens/settings/library_settings_page.dart'; import 'package:spotiflac_android/screens/settings/log_screen.dart'; import 'package:spotiflac_android/screens/settings/lyrics_settings_page.dart'; import 'package:spotiflac_android/screens/settings/metadata_settings_page.dart'; +import 'package:spotiflac_android/screens/settings/playback_settings_page.dart'; import 'package:spotiflac_android/screens/settings/settings_search_catalog.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; @@ -101,7 +104,10 @@ class _SettingsTabState extends ConsumerState { return _cachedGroups!; } - final searchCatalog = SettingsSearchCatalog(l10n); + final searchCatalog = SettingsSearchCatalog( + l10n, + androidAudio: Platform.isAndroid, + ); final groups = [ _Group( destinations: [ @@ -142,13 +148,7 @@ class _SettingsTabState extends ConsumerState { iconColor: Colors.blue, title: l10n.settingsLocalLibrary, subtitle: l10n.settingsLocalLibrarySubtitle, - keywords: const [ - 'scan', - 'local', - 'player', - 'playback', - 'duplicate', - ], + keywords: const ['scan', 'local', 'duplicate'], searchEntries: searchCatalog.library, pageBuilder: () => const LibrarySettingsPage(), ), @@ -170,6 +170,15 @@ class _SettingsTabState extends ConsumerState { searchEntries: searchCatalog.lyrics, pageBuilder: () => const LyricsSettingsPage(), ), + _Destination( + icon: Icons.play_circle_outline, + iconColor: Colors.indigo, + title: l10n.libraryPlayback, + subtitle: l10n.settingsPlaybackSubtitle, + keywords: const ['player', 'audio', 'playback', 'pemutaran'], + searchEntries: searchCatalog.playback, + pageBuilder: () => const PlaybackSettingsPage(), + ), ], ), _Group( diff --git a/rust_backend/crates/mobile/src/usb_audio/README.md b/rust_backend/crates/mobile/src/usb_audio/README.md index 2a83e95c..b76eaea7 100644 --- a/rust_backend/crates/mobile/src/usb_audio/README.md +++ b/rust_backend/crates/mobile/src/usb_audio/README.md @@ -3,7 +3,7 @@ Android USB host transport for local FLAC/WAV PCM, uncompressed DSF/DSDIFF and lossless WavPack DSD. The existing Android 14 preferred-mixer path remains available. Direct USB is -opt-in under Settings > Library > Playback > Hi-res and bit-perfect audio. +opt-in under Settings > Playback > Hi-res and bit-perfect audio. ## Ownership and playback @@ -39,7 +39,7 @@ opt-in under Settings > Library > Playback > Hi-res and bit-perfect audio. on the same attached device, respecting a hardware knob lowered externally. Remove remembered levels on detach. This is attenuation, not a guarantee of safe acoustic output for every amplifier/headphone combination. -- The Mornye player slider and Library playback settings control verified DAC +- The Mornye player slider and Playback settings control verified DAC volume. They never modify DSD/DoP samples or apply software gain. Phone volume buttons may not affect direct USB output. - Warn before enabling the mode. A DAC without verified hardware volume stays