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 } /// 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. 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; /// 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 bitrateChoices = [0, 96, 128, 192, 256, 320]; /// Offered download containers; null renders as "Original". static const List formatChoices = [null, 'mp3', 'opus', 'aac']; /// 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' : '$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, downloadMaxBitRate: downloadMaxBitRate ?? this.downloadMaxBitRate, 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(); Map toJson() => { '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 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(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 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 _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(), );