mobile-music/lib/screens/settings_screen.dart
2026-08-04 16:08:51 -04:00

575 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../settings/settings_store.dart';
import '../state/providers.dart';
import '../subsonic/credentials.dart';
import '../theme/tokens.dart';
import '../widgets/hairline_panel.dart';
import 'connect_sheet.dart';
/// Audio-quality settings: independent streaming and download knobs, both
/// driven by Subsonic's `stream` transcode params (see `settings/settings_store.dart`).
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final controller = ref.read(settingsProvider.notifier);
final conn = ref.watch(connectionProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Settings',
style: TextStyle(fontWeight: FontWeight.w700)),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(TimbreSpacing.lg),
children: [
HairlinePanel(
title: 'Streaming',
active: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Caption(
'Quality used when playing tracks that are not downloaded.'),
const SizedBox(height: TimbreSpacing.md),
_ChoiceChips<int>(
label: 'Max bitrate',
values: AppSettings.bitrateChoices,
selected: settings.streamMaxBitRate,
labelFor: AppSettings.bitrateLabel,
onSelect: controller.setStreamMaxBitRate,
),
],
),
),
const SizedBox(height: TimbreSpacing.xl),
HairlinePanel(
title: 'Downloads',
active: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Caption(
'Quality used when saving tracks for offline playback. '
'"Original" keeps the source file (best quality, largest).'),
const SizedBox(height: TimbreSpacing.md),
_ChoiceChips<int>(
label: 'Max bitrate',
values: AppSettings.bitrateChoices,
selected: settings.downloadMaxBitRate,
labelFor: AppSettings.bitrateLabel,
onSelect: controller.setDownloadMaxBitRate,
),
const SizedBox(height: TimbreSpacing.lg),
_ChoiceChips<String?>(
label: 'Format',
values: AppSettings.formatChoices,
selected: settings.downloadFormat,
labelFor: AppSettings.formatLabel,
onSelect: controller.setDownloadFormat,
),
const SizedBox(height: TimbreSpacing.lg),
const _Caption(
'How many tracks download at once. Higher is faster on '
'a strong connection; lower is gentler on the server.'),
const SizedBox(height: TimbreSpacing.md),
_Stepper(
label: 'Simultaneous downloads',
value: settings.maxConcurrentDownloads,
min: AppSettings.minConcurrentDownloads,
max: AppSettings.maxConcurrentDownloadsCap,
onChanged: controller.setMaxConcurrentDownloads,
),
],
),
),
const SizedBox(height: TimbreSpacing.xl),
HairlinePanel(
title: 'Appearance',
active: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Caption(
'By default the accent color is drawn from the playing '
'album art. Turn on a static accent to pin one color.'),
const SizedBox(height: TimbreSpacing.md),
_ChoiceChips<bool>(
label: 'Static accent',
values: const [false, true],
selected: settings.useStaticAccent,
labelFor: (v) => v ? 'On' : 'Off',
onSelect: controller.setUseStaticAccent,
),
if (settings.useStaticAccent) ...[
const SizedBox(height: TimbreSpacing.lg),
_ColorChips(
label: 'Accent color',
values: AppSettings.accentChoices,
selected: settings.staticAccentColor,
onSelect: controller.setStaticAccentColor,
),
],
const SizedBox(height: TimbreSpacing.lg),
const _Caption(
'Show Now Playing as an animated cassette — cover art on '
'the label, reels that spin and wind with the track.'),
const SizedBox(height: TimbreSpacing.md),
_ChoiceChips<bool>(
label: 'Now Playing art',
values: const [false, true],
selected: settings.nowPlayingCassette,
labelFor: (v) => v ? 'Cassette' : 'Album cover',
onSelect: controller.setNowPlayingCassette,
),
],
),
),
const SizedBox(height: TimbreSpacing.xl),
HairlinePanel(
title: 'Browse',
active: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Caption('Which view the Browse tab opens on.'),
const SizedBox(height: TimbreSpacing.md),
_ChoiceChips<BrowseMode>(
label: 'Default view',
values: BrowseMode.values,
selected: settings.defaultBrowseMode,
labelFor: AppSettings.browseModeLabel,
onSelect: controller.setDefaultBrowseMode,
),
],
),
),
const SizedBox(height: TimbreSpacing.xl),
HairlinePanel(
title: 'Search',
active: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Caption(
'"Standard" matches only names/titles that contain your '
'query. "Discovery" uses the server\'s broader matching.'),
const SizedBox(height: TimbreSpacing.md),
_ChoiceChips<SearchMode>(
label: 'Search mode',
values: const [SearchMode.standard, SearchMode.discovery],
selected: settings.searchMode,
labelFor: AppSettings.searchModeLabel,
onSelect: controller.setSearchMode,
),
],
),
),
const SizedBox(height: TimbreSpacing.xl),
HairlinePanel(
title: 'Servers',
active: true,
trailing:
conn.servers.isNotEmpty ? '(${conn.servers.length})' : null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Caption(
'Save multiple servers and switch the active one. Only '
'one is connected at a time.'),
const SizedBox(height: TimbreSpacing.sm),
if (conn.servers.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: TimbreSpacing.sm),
child: Text('No servers saved yet.',
style: TextStyle(color: TimbreColors.dimmed)),
)
else
for (final s in conn.servers)
_ServerManageRow(
creds: s,
active: s.id == conn.activeId,
onActivate: () => ref
.read(connectionProvider.notifier)
.switchTo(s.id),
onEdit: () =>
showConnectSheet(context, initial: s),
onDelete: () => ref
.read(connectionProvider.notifier)
.removeServer(s.id),
),
const SizedBox(height: TimbreSpacing.sm),
InkWell(
onTap: () => showConnectSheet(context),
child: const Padding(
padding:
EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add,
size: 16, color: TimbreColors.foreground),
SizedBox(width: TimbreSpacing.sm),
Text('Add server',
style:
TextStyle(color: TimbreColors.foreground)),
],
),
),
),
],
),
),
],
),
),
);
}
}
/// A saved-server row in Settings: activate (tap the label), edit, or delete.
class _ServerManageRow extends StatelessWidget {
const _ServerManageRow({
required this.creds,
required this.active,
required this.onActivate,
required this.onEdit,
required this.onDelete,
});
final SubsonicCredentials creds;
final bool active;
final VoidCallback onActivate;
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
return Row(
children: [
Expanded(
child: InkWell(
onTap: onActivate,
child: Container(
constraints: const BoxConstraints(
minHeight: TimbreSpacing.minTouchTarget),
alignment: Alignment.centerLeft,
child: Row(
children: [
Text(active ? '● ' : '○ ',
style: TextStyle(
color: active ? accent : TimbreColors.dimmed)),
Expanded(
child: Text(
creds.display,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: TimbreColors.foreground,
fontWeight:
active ? FontWeight.w700 : FontWeight.w400,
),
),
),
],
),
),
),
),
IconButton(
tooltip: 'Edit',
onPressed: onEdit,
icon: const Icon(Icons.edit, size: 16, color: TimbreColors.dimmed),
),
IconButton(
tooltip: 'Delete',
onPressed: onDelete,
icon:
const Icon(Icons.delete_outline, size: 18, color: TimbreColors.dimmed),
),
],
);
}
}
class _Caption extends StatelessWidget {
const _Caption(this.text);
final String text;
@override
Widget build(BuildContext context) => Text(
text,
style: const TextStyle(color: TimbreColors.dimmed, fontSize: 12),
);
}
/// A labelled row of selectable value chips — the app's underline-accent
/// language applied to compact bordered chips.
class _ChoiceChips<T> extends StatelessWidget {
const _ChoiceChips({
required this.label,
required this.values,
required this.selected,
required this.labelFor,
required this.onSelect,
});
final String label;
final List<T> values;
final T selected;
final String Function(T) labelFor;
final ValueChanged<T> onSelect;
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(color: TimbreColors.foreground)),
const SizedBox(height: TimbreSpacing.sm),
Wrap(
spacing: TimbreSpacing.sm,
runSpacing: TimbreSpacing.sm,
children: [
for (final v in values)
_Chip(
text: labelFor(v),
active: v == selected,
accent: accent,
onTap: () => onSelect(v),
),
],
),
],
);
}
}
class _Chip extends StatelessWidget {
const _Chip({
required this.text,
required this.active,
required this.accent,
required this.onTap,
});
final String text;
final bool active;
final Color accent;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(
horizontal: TimbreSpacing.lg,
vertical: TimbreSpacing.md,
),
decoration: BoxDecoration(
border: Border.all(
color: active ? accent : TimbreColors.border,
),
color: active ? accent.withValues(alpha: 0.12) : TimbreColors.surface,
),
child: Text(
text,
style: TextStyle(
color: active ? accent : TimbreColors.foreground,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
),
),
),
);
}
}
/// A labelled −/value/+ stepper bounded to [[min], [max]], styled to match the
/// bordered-chip language of [_ChoiceChips].
class _Stepper extends StatelessWidget {
const _Stepper({
required this.label,
required this.value,
required this.min,
required this.max,
required this.onChanged,
});
final String label;
final int value;
final int min;
final int max;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(color: TimbreColors.foreground)),
const SizedBox(height: TimbreSpacing.sm),
Row(
children: [
_StepButton(
icon: Icons.remove,
accent: accent,
enabled: value > min,
onTap: () => onChanged(value - 1),
),
Container(
constraints: const BoxConstraints(
minWidth: TimbreSpacing.minTouchTarget,
minHeight: TimbreSpacing.minTouchTarget),
alignment: Alignment.center,
child: Text(
'$value',
style: const TextStyle(
color: TimbreColors.foreground,
fontWeight: FontWeight.w700,
),
),
),
_StepButton(
icon: Icons.add,
accent: accent,
enabled: value < max,
onTap: () => onChanged(value + 1),
),
],
),
],
);
}
}
class _StepButton extends StatelessWidget {
const _StepButton({
required this.icon,
required this.accent,
required this.enabled,
required this.onTap,
});
final IconData icon;
final Color accent;
final bool enabled;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: enabled ? onTap : null,
child: Container(
constraints: const BoxConstraints(
minWidth: TimbreSpacing.minTouchTarget,
minHeight: TimbreSpacing.minTouchTarget),
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border.all(color: TimbreColors.border),
color: TimbreColors.surface,
),
child: Icon(
icon,
size: 18,
color: enabled ? accent : TimbreColors.dimmed,
),
),
);
}
}
/// A row of accent-color chips — like [_ChoiceChips] but each chip shows a
/// color swatch and the color's own hue drives the active border/fill.
class _ColorChips extends StatelessWidget {
const _ColorChips({
required this.label,
required this.values,
required this.selected,
required this.onSelect,
});
final String label;
final List<AccentChoice> values;
final Color selected;
final ValueChanged<Color> onSelect;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(color: TimbreColors.foreground)),
const SizedBox(height: TimbreSpacing.sm),
Wrap(
spacing: TimbreSpacing.sm,
runSpacing: TimbreSpacing.sm,
children: [
for (final c in values)
_ColorChip(
choice: c,
active: c.color == selected,
onTap: () => onSelect(c.color),
),
],
),
],
);
}
}
class _ColorChip extends StatelessWidget {
const _ColorChip({
required this.choice,
required this.active,
required this.onTap,
});
final AccentChoice choice;
final bool active;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(
horizontal: TimbreSpacing.lg,
vertical: TimbreSpacing.md,
),
decoration: BoxDecoration(
border: Border.all(
color: active ? choice.color : TimbreColors.border,
),
color: active
? choice.color.withValues(alpha: 0.12)
: TimbreColors.surface,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 12, height: 12, color: choice.color),
const SizedBox(width: TimbreSpacing.sm),
Text(
choice.name,
style: TextStyle(
color: active ? choice.color : TimbreColors.foreground,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
),
),
],
),
),
);
}
}