256 lines
8.9 KiB
Dart
256 lines
8.9 KiB
Dart
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<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,
|
|
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<String, dynamic> 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<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).
|
|
class SettingsController extends StateNotifier<AppSettings> {
|
|
SettingsController() : super(const AppSettings()) {
|
|
_load();
|
|
}
|
|
|
|
File? _file;
|
|
|
|
Future<void> _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<String, dynamic>());
|
|
}
|
|
}
|
|
} 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<void> _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<SettingsController, AppSettings>(
|
|
(ref) => SettingsController(),
|
|
);
|