feat(settings): give playback its own settings page

This commit is contained in:
zarzet
2026-09-27 15:28:03 +07:00
parent e0f5a96564
commit 25f9d16e3d
7 changed files with 322 additions and 227 deletions
+4
View File
@@ -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",
+4
View File
@@ -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",
@@ -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<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,
@@ -870,199 +847,6 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
),
),
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<UsbAudioStatus>(
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<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,
),
],
],
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
@@ -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<PlaybackSettingsPage> createState() =>
_PlaybackSettingsPageState();
}
class _PlaybackSettingsPageState extends ConsumerState<PlaybackSettingsPage> {
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;
}
@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<UsbAudioStatus>(
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<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: 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)),
],
),
);
}
}
@@ -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<SettingsSearchEntry> extensions;
final List<SettingsSearchEntry> appearance;
final List<SettingsSearchEntry> library;
final List<SettingsSearchEntry> playback;
final List<SettingsSearchEntry> metadata;
final List<SettingsSearchEntry> lyrics;
final List<SettingsSearchEntry> download;
+17 -8
View File
@@ -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<SettingsTab> {
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<SettingsTab> {
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<SettingsTab> {
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(