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'; /// 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 } /// Ordering applied to the Albums browse grid (filtered client-side). enum AlbumSort { nameAsc, artistAsc, yearDesc, yearAsc, recentlyAdded } /// Ordering applied to the Tracks browse list (filtered client-side). enum TrackSort { titleAsc, artistAsc, albumAsc, yearDesc, recentlyAdded, ratingDesc } /// Selectable app-wide color theme. [standard] is the original dark palette /// (with its static-vs-dynamic accent toggle); [lavender] is a light theme /// whose accent is locked to its lavender primary. See `theme/tokens.dart`. enum AppTheme { standard, lavender } /// 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.maxConcurrentDownloads = defaultConcurrentDownloads, this.useStaticAccent = false, this.staticAccentColor = _defaultStaticAccent, this.defaultBrowseMode = BrowseMode.artists, this.searchMode = SearchMode.discovery, this.albumSort = AlbumSort.nameAsc, this.trackSort = TrackSort.titleAsc, this.nowPlayingCassette = false, this.appTheme = AppTheme.standard, }); /// Cap for live streaming, in kbps. 0 = original / no transcode. final int streamMaxBitRate; /// Cap for downloaded files, in kbps. 0 = original (best offline quality). final int downloadMaxBitRate; /// Transcode container for downloads (`mp3`, `opus`, `aac`), or null to keep /// the original file / let the server decide. final String? downloadFormat; /// How many tracks may download at once. Re-read live by the download pump /// (see `downloads/download_manager.dart`) so changes apply mid-session. /// Clamped to [[minConcurrentDownloads], [maxConcurrentDownloadsCap]]. final int maxConcurrentDownloads; /// 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; /// Persisted sort order for the Albums / Tracks browse views. (Filters — /// genre/year — are session-only and live in `state/providers.dart`.) final AlbumSort albumSort; final TrackSort trackSort; /// 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; /// The selected app-wide color theme. final AppTheme appTheme; /// Whether the accent is pinned (never overridden by album-art extraction): /// either the user enabled the static accent, or a theme that locks its /// accent (e.g. [AppTheme.lavender]) is active. bool get accentIsFixed => useStaticAccent || appTheme == AppTheme.lavender; /// Offered bitrate choices (kbps); 0 renders as "Original". static const List bitrateChoices = [0, 96, 128, 192, 256, 320]; /// Offered download containers; null renders as "Original". static const List formatChoices = [null, 'mp3', 'opus', 'aac']; /// Bounds and default for [maxConcurrentDownloads]. static const int minConcurrentDownloads = 1; static const int maxConcurrentDownloadsCap = 10; static const int defaultConcurrentDownloads = 3; /// Clamp any value into the allowed concurrent-download range. static int clampConcurrentDownloads(int v) => v < minConcurrentDownloads ? minConcurrentDownloads : (v > maxConcurrentDownloadsCap ? maxConcurrentDownloadsCap : v); /// Static accent palette offered when the override is enabled (TODO #1). static const List 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 (native)' : '$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', }; static String albumSortLabel(AlbumSort s) => switch (s) { AlbumSort.nameAsc => 'Name', AlbumSort.artistAsc => 'Artist', AlbumSort.yearDesc => 'Year (newest)', AlbumSort.yearAsc => 'Year (oldest)', AlbumSort.recentlyAdded => 'Recently added', }; static String trackSortLabel(TrackSort s) => switch (s) { TrackSort.titleAsc => 'Title', TrackSort.artistAsc => 'Artist', TrackSort.albumAsc => 'Album', TrackSort.yearDesc => 'Year (newest)', TrackSort.recentlyAdded => 'Recently added', TrackSort.ratingDesc => 'Rating (highest first)', }; static String themeLabel(AppTheme t) => switch (t) { AppTheme.standard => 'Default', AppTheme.lavender => 'Lavender', }; AppSettings copyWith({ int? streamMaxBitRate, int? downloadMaxBitRate, // Sentinel so an explicit null (→ original) is distinguishable from "unset". Object? downloadFormat = _unset, int? maxConcurrentDownloads, bool? useStaticAccent, Color? staticAccentColor, BrowseMode? defaultBrowseMode, SearchMode? searchMode, AlbumSort? albumSort, TrackSort? trackSort, bool? nowPlayingCassette, AppTheme? appTheme, }) => AppSettings( streamMaxBitRate: streamMaxBitRate ?? this.streamMaxBitRate, downloadMaxBitRate: downloadMaxBitRate ?? this.downloadMaxBitRate, downloadFormat: identical(downloadFormat, _unset) ? this.downloadFormat : downloadFormat as String?, maxConcurrentDownloads: maxConcurrentDownloads ?? this.maxConcurrentDownloads, useStaticAccent: useStaticAccent ?? this.useStaticAccent, staticAccentColor: staticAccentColor ?? this.staticAccentColor, defaultBrowseMode: defaultBrowseMode ?? this.defaultBrowseMode, searchMode: searchMode ?? this.searchMode, albumSort: albumSort ?? this.albumSort, trackSort: trackSort ?? this.trackSort, nowPlayingCassette: nowPlayingCassette ?? this.nowPlayingCassette, appTheme: appTheme ?? this.appTheme, ); static const Object _unset = Object(); Map toJson() => { 'streamMaxBitRate': streamMaxBitRate, 'downloadMaxBitRate': downloadMaxBitRate, if (downloadFormat != null) 'downloadFormat': downloadFormat, 'maxConcurrentDownloads': maxConcurrentDownloads, 'useStaticAccent': useStaticAccent, 'staticAccentColor': _hexOf(staticAccentColor), 'defaultBrowseMode': defaultBrowseMode.name, 'searchMode': searchMode.name, 'albumSort': albumSort.name, 'trackSort': trackSort.name, 'nowPlayingCassette': nowPlayingCassette, 'appTheme': appTheme.name, }; factory AppSettings.fromJson(Map j) => AppSettings( streamMaxBitRate: (j['streamMaxBitRate'] as num?)?.toInt() ?? 0, downloadMaxBitRate: (j['downloadMaxBitRate'] as num?)?.toInt() ?? 0, downloadFormat: j['downloadFormat'] as String?, maxConcurrentDownloads: clampConcurrentDownloads( (j['maxConcurrentDownloads'] as num?)?.toInt() ?? defaultConcurrentDownloads), 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, albumSort: _enumByName(AlbumSort.values, j['albumSort'] as String?) ?? AlbumSort.nameAsc, trackSort: _enumByName(TrackSort.values, j['trackSort'] as String?) ?? TrackSort.titleAsc, nowPlayingCassette: j['nowPlayingCassette'] as bool? ?? false, appTheme: _enumByName(AppTheme.values, j['appTheme'] as String?) ?? AppTheme.standard, ); /// 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(List 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). class SettingsController extends StateNotifier { SettingsController() : super(const AppSettings()) { _load(); } File? _file; Future _load() async { try { final dir = await getApplicationSupportDirectory(); _file = File('${dir.path}/settings.json'); if (await _file!.exists()) { final raw = jsonDecode(await _file!.readAsString()); if (raw is Map) { state = AppSettings.fromJson(raw.cast()); } } } catch (_) { // Missing/corrupt settings are non-fatal — keep the defaults. } } void setStreamMaxBitRate(int rate) { state = state.copyWith(streamMaxBitRate: rate); _persist(); } void setDownloadMaxBitRate(int rate) { state = state.copyWith(downloadMaxBitRate: rate); _persist(); } void setDownloadFormat(String? format) { state = state.copyWith(downloadFormat: format); _persist(); } void setMaxConcurrentDownloads(int count) { state = state.copyWith( maxConcurrentDownloads: AppSettings.clampConcurrentDownloads(count)); _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 setAlbumSort(AlbumSort sort) { state = state.copyWith(albumSort: sort); _persist(); } void setTrackSort(TrackSort sort) { state = state.copyWith(trackSort: sort); _persist(); } void setNowPlayingCassette(bool value) { state = state.copyWith(nowPlayingCassette: value); _persist(); } void setAppTheme(AppTheme theme) { state = state.copyWith(appTheme: theme); _persist(); } Future _persist() async { try { final file = _file; if (file == null) return; final tmp = File('${file.path}.tmp'); await tmp.writeAsString(jsonEncode(state.toJson())); await tmp.rename(file.path); } catch (_) {} } } final settingsProvider = StateNotifierProvider( (ref) => SettingsController(), );