mobile-music/lib/settings/settings_store.dart
2026-07-29 14:14:18 -04:00

119 lines
3.8 KiB
Dart

import 'dart:convert';
import 'dart:io';
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`.
class AppSettings {
const AppSettings({
this.streamMaxBitRate = 0,
this.downloadMaxBitRate = 0,
this.downloadFormat,
});
/// 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;
/// 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 String bitrateLabel(int rate) => rate == 0 ? 'Original' : '$rate kbps';
static String formatLabel(String? f) => f ?? 'Original';
AppSettings copyWith({
int? streamMaxBitRate,
int? downloadMaxBitRate,
// Sentinel so an explicit null (→ original) is distinguishable from "unset".
Object? downloadFormat = _unset,
}) =>
AppSettings(
streamMaxBitRate: streamMaxBitRate ?? this.streamMaxBitRate,
downloadMaxBitRate: downloadMaxBitRate ?? this.downloadMaxBitRate,
downloadFormat: identical(downloadFormat, _unset)
? this.downloadFormat
: downloadFormat as String?,
);
static const Object _unset = Object();
Map<String, dynamic> toJson() => {
'streamMaxBitRate': streamMaxBitRate,
'downloadMaxBitRate': downloadMaxBitRate,
if (downloadFormat != null) 'downloadFormat': downloadFormat,
};
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?,
);
}
/// 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();
}
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(),
);