364 lines
13 KiB
Dart
364 lines
13 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 }
|
|
|
|
/// 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<int> bitrateChoices = [0, 96, 128, 192, 256, 320];
|
|
|
|
/// Offered download containers; null renders as "Original".
|
|
static const List<String?> 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<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 (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<String, dynamic> 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<String, dynamic> 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<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 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<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(),
|
|
);
|