This commit is contained in:
Forrest 2026-07-29 22:41:38 -04:00
parent 981b4836f9
commit ed910748cb
34 changed files with 2054 additions and 153 deletions

View file

@ -1,19 +1,43 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/widgets.dart' show Color;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
/// User-tunable audio quality. Streaming and offline downloads have independent
/// knobs, both driven by Subsonic's `stream` transcode params (`maxBitRate` +
/// `format`; a rate of 0 means original / no transcode). These are global (not
/// per-server) and persisted to a single atomic JSON file, following the
/// write pattern in `library/library_index.dart`.
/// Top-level Browse mode selector. Lives here (not in `state/providers.dart`)
/// so [AppSettings] can persist the user's default; `providers.dart` re-exports
/// it, so existing importers are unaffected.
enum BrowseMode { artists, albums, tracks }
/// Search matching strategy. `discovery` returns the server's full `search3`
/// result (broad — e.g. songs matched via their artist name). `standard`
/// post-filters to items whose *own* name/title matches the query.
enum SearchMode { discovery, standard }
/// A selectable static accent color offered by the theme override (TODO #1).
class AccentChoice {
const AccentChoice(this.name, this.color);
final String name;
final Color color;
}
/// Application-wide preferences: audio quality, theme accent, default browse
/// view, and search mode. Streaming and offline downloads have independent
/// quality knobs, both driven by Subsonic's `stream` transcode params
/// (`maxBitRate` + `format`; a rate of 0 means original / no transcode). These
/// are global (not per-server) and persisted to a single atomic JSON file,
/// following the write pattern in `library/library_index.dart`.
class AppSettings {
const AppSettings({
this.streamMaxBitRate = 0,
this.downloadMaxBitRate = 0,
this.downloadFormat,
this.useStaticAccent = false,
this.staticAccentColor = _defaultStaticAccent,
this.defaultBrowseMode = BrowseMode.artists,
this.searchMode = SearchMode.discovery,
this.nowPlayingCassette = false,
});
/// Cap for live streaming, in kbps. 0 = original / no transcode.
@ -26,20 +50,67 @@ class AppSettings {
/// the original file / let the server decide.
final String? downloadFormat;
/// When true, ignore album-art-derived accent and use [staticAccentColor]
/// as the sole accent color. When false (default), the accent tracks the
/// currently-playing album art.
final bool useStaticAccent;
/// The fixed accent used while [useStaticAccent] is on. One of
/// [accentChoices]; defaults to Orange.
final Color staticAccentColor;
/// Which sub-view the Browse tab opens on.
final BrowseMode defaultBrowseMode;
/// Which search matching strategy to apply globally.
final SearchMode searchMode;
/// When true, the Now Playing "art" view shows the animated cassette (cover
/// art on the label, reels driven by playback) instead of the plain square
/// album cover.
final bool nowPlayingCassette;
/// Offered bitrate choices (kbps); 0 renders as "Original".
static const List<int> bitrateChoices = [0, 96, 128, 192, 256, 320];
/// Offered download containers; null renders as "Original".
static const List<String?> formatChoices = [null, 'mp3', 'opus', 'aac'];
/// Static accent palette offered when the override is enabled (TODO #1).
static const List<AccentChoice> accentChoices = [
AccentChoice('Red', Color(0xFFDF3535)),
AccentChoice('Blue', Color(0xFF7177EA)),
AccentChoice('Green', Color(0xFF10996B)),
AccentChoice('Orange', Color(0xFFDF7E35)),
AccentChoice('Yellow', Color(0xFFDFC535)),
AccentChoice('Purple', Color(0xFF926CE9)),
AccentChoice('Cream', Color(0xFFFFDB9E)),
];
static const Color _defaultStaticAccent = Color(0xFFDF7E35); // Orange
static String bitrateLabel(int rate) => rate == 0 ? 'Original' : '$rate kbps';
static String formatLabel(String? f) => f ?? 'Original';
static String browseModeLabel(BrowseMode m) => switch (m) {
BrowseMode.artists => 'Artists',
BrowseMode.albums => 'Albums',
BrowseMode.tracks => 'Tracks',
};
static String searchModeLabel(SearchMode m) => switch (m) {
SearchMode.standard => 'Standard',
SearchMode.discovery => 'Discovery',
};
AppSettings copyWith({
int? streamMaxBitRate,
int? downloadMaxBitRate,
// Sentinel so an explicit null (→ original) is distinguishable from "unset".
Object? downloadFormat = _unset,
bool? useStaticAccent,
Color? staticAccentColor,
BrowseMode? defaultBrowseMode,
SearchMode? searchMode,
bool? nowPlayingCassette,
}) =>
AppSettings(
streamMaxBitRate: streamMaxBitRate ?? this.streamMaxBitRate,
@ -47,6 +118,11 @@ class AppSettings {
downloadFormat: identical(downloadFormat, _unset)
? this.downloadFormat
: downloadFormat as String?,
useStaticAccent: useStaticAccent ?? this.useStaticAccent,
staticAccentColor: staticAccentColor ?? this.staticAccentColor,
defaultBrowseMode: defaultBrowseMode ?? this.defaultBrowseMode,
searchMode: searchMode ?? this.searchMode,
nowPlayingCassette: nowPlayingCassette ?? this.nowPlayingCassette,
);
static const Object _unset = Object();
@ -55,13 +131,49 @@ class AppSettings {
'streamMaxBitRate': streamMaxBitRate,
'downloadMaxBitRate': downloadMaxBitRate,
if (downloadFormat != null) 'downloadFormat': downloadFormat,
'useStaticAccent': useStaticAccent,
'staticAccentColor': _hexOf(staticAccentColor),
'defaultBrowseMode': defaultBrowseMode.name,
'searchMode': searchMode.name,
'nowPlayingCassette': nowPlayingCassette,
};
factory AppSettings.fromJson(Map<String, dynamic> j) => AppSettings(
streamMaxBitRate: (j['streamMaxBitRate'] as num?)?.toInt() ?? 0,
downloadMaxBitRate: (j['downloadMaxBitRate'] as num?)?.toInt() ?? 0,
downloadFormat: j['downloadFormat'] as String?,
useStaticAccent: j['useStaticAccent'] as bool? ?? false,
staticAccentColor:
_colorOf(j['staticAccentColor'] as String?) ?? _defaultStaticAccent,
defaultBrowseMode:
_enumByName(BrowseMode.values, j['defaultBrowseMode'] as String?) ??
BrowseMode.artists,
searchMode:
_enumByName(SearchMode.values, j['searchMode'] as String?) ??
SearchMode.discovery,
nowPlayingCassette: j['nowPlayingCassette'] as bool? ?? false,
);
/// Serialize a color to a `#RRGGBB` hex string.
static String _hexOf(Color c) =>
'#${(c.toARGB32() & 0xFFFFFF).toRadixString(16).padLeft(6, '0').toUpperCase()}';
/// Parse a `#RRGGBB` (or `RRGGBB`) hex string, or null if unparseable.
static Color? _colorOf(String? hex) {
if (hex == null) return null;
final h = hex.startsWith('#') ? hex.substring(1) : hex;
final v = int.tryParse(h, radix: 16);
if (v == null || h.length != 6) return null;
return Color(0xFF000000 | v);
}
static T? _enumByName<T extends Enum>(List<T> values, String? name) {
if (name == null) return null;
for (final v in values) {
if (v.name == name) return v;
}
return null;
}
}
/// Loads settings on launch and persists every change (atomic temp+rename).
@ -102,6 +214,31 @@ class SettingsController extends StateNotifier<AppSettings> {
_persist();
}
void setUseStaticAccent(bool value) {
state = state.copyWith(useStaticAccent: value);
_persist();
}
void setStaticAccentColor(Color color) {
state = state.copyWith(staticAccentColor: color);
_persist();
}
void setDefaultBrowseMode(BrowseMode mode) {
state = state.copyWith(defaultBrowseMode: mode);
_persist();
}
void setSearchMode(SearchMode mode) {
state = state.copyWith(searchMode: mode);
_persist();
}
void setNowPlayingCassette(bool value) {
state = state.copyWith(nowPlayingCassette: value);
_persist();
}
Future<void> _persist() async {
try {
final file = _file;