575 lines
19 KiB
Dart
575 lines
19 KiB
Dart
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|