This commit is contained in:
Forrest 2026-07-29 14:14:18 -04:00
commit d205277cdd
182 changed files with 22978 additions and 0 deletions

View file

@ -0,0 +1,365 @@
// Callback fields are assigned from named required params, which can't be
// private initializing formals.
// ignore_for_file: prefer_initializing_formals
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import '../settings/settings_store.dart';
import '../subsonic/models.dart';
import '../subsonic/subsonic_client.dart';
/// Lifecycle of a single track download.
enum DownloadStatus { queued, downloading, done, failed }
/// Per-song download record. Only [DownloadStatus.done] entries are persisted
/// to the manifest; queued/downloading/failed entries are in-memory only.
class DownloadInfo {
const DownloadInfo({
required this.song,
required this.status,
this.path,
this.bitRate,
this.format,
this.sizeBytes,
this.progress = 0,
this.error,
});
final Song song;
final DownloadStatus status;
/// Absolute path to the downloaded file (set once [status] is done).
final String? path;
final int? bitRate;
final String? format;
final int? sizeBytes;
/// 0.0–1.0 while downloading (0 if the total size is unknown mid-flight).
final double progress;
final String? error;
bool get isDone => status == DownloadStatus.done;
bool get isActive =>
status == DownloadStatus.queued || status == DownloadStatus.downloading;
DownloadInfo copyWith({
DownloadStatus? status,
String? path,
int? bitRate,
String? format,
int? sizeBytes,
double? progress,
String? error,
}) =>
DownloadInfo(
song: song,
status: status ?? this.status,
path: path ?? this.path,
bitRate: bitRate ?? this.bitRate,
format: format ?? this.format,
sizeBytes: sizeBytes ?? this.sizeBytes,
progress: progress ?? this.progress,
error: error,
);
Map<String, dynamic> toJson() => {
'song': song.toJson(),
'path': path,
if (bitRate != null) 'bitRate': bitRate,
if (format != null) 'format': format,
if (sizeBytes != null) 'sizeBytes': sizeBytes,
};
/// Rebuild a completed record from the manifest.
factory DownloadInfo.fromJson(Map<String, dynamic> j) => DownloadInfo(
song: Song.fromJson((j['song'] as Map).cast<String, dynamic>()),
status: DownloadStatus.done,
path: j['path'] as String?,
bitRate: (j['bitRate'] as num?)?.toInt(),
format: j['format'] as String?,
sizeBytes: (j['sizeBytes'] as num?)?.toInt(),
progress: 1,
);
}
/// Snapshot of all known downloads for the active server, keyed by song id.
class DownloadState {
const DownloadState({this.byId = const {}});
final Map<String, DownloadInfo> byId;
DownloadInfo? operator [](String id) => byId[id];
bool isDownloaded(String id) => byId[id]?.isDone ?? false;
List<DownloadInfo> get completed =>
byId.values.where((d) => d.isDone).toList();
int get totalBytes => completed.fold(0, (sum, d) => sum + (d.sizeBytes ?? 0));
DownloadState copyWith({Map<String, DownloadInfo>? byId}) =>
DownloadState(byId: byId ?? this.byId);
}
/// Downloads tracks to disk for offline playback. Files live under
/// `<appSupport>/downloads/<serverKey>/` and a `downloads_<serverKey>.json`
/// manifest survives restarts. Modeled on `library/library_index.dart`
/// (per-server keying, atomic temp+rename writes, a generation guard so a
/// server switch mid-download can't clobber the new server's manifest).
class DownloadController extends StateNotifier<DownloadState> {
DownloadController({
required SubsonicClient? Function() clientGetter,
required AppSettings Function() settingsGetter,
required String? Function() serverKeyGetter,
}) : _clientGetter = clientGetter,
_settingsGetter = settingsGetter,
_serverKeyGetter = serverKeyGetter,
super(const DownloadState()) {
reloadForServer();
}
final SubsonicClient? Function() _clientGetter;
final AppSettings Function() _settingsGetter;
final String? Function() _serverKeyGetter;
final Dio _dio = Dio(BaseOptions(
receiveTimeout: const Duration(minutes: 5),
headers: {'User-Agent': 'ratune-mobile'},
));
static const int _maxConcurrent = 3;
int _generation = 0;
String? _loadedKey;
final List<String> _queue = [];
int _active = 0;
/// Path to a downloaded file if (and only if) it is fully downloaded — read
/// synchronously by the playback stream-URI resolver.
String? localPathFor(String id) {
final info = state.byId[id];
return info != null && info.isDone ? info.path : null;
}
bool isDownloaded(String id) => state.isDownloaded(id);
// ---- Server switching / manifest load ----------------------------------
Future<Directory> _downloadsDir(String key) async {
final dir = await getApplicationSupportDirectory();
final d = Directory('${dir.path}/downloads/$key');
if (!await d.exists()) await d.create(recursive: true);
return d;
}
Future<File> _manifestFile(String key) async {
final dir = await getApplicationSupportDirectory();
return File('${dir.path}/downloads_$key.json');
}
/// Load (or clear) the manifest when the active server changes. Verifies each
/// file still exists on disk and drops stale entries.
Future<void> reloadForServer() async {
final key = _serverKeyGetter();
if (key == _loadedKey) return;
_generation++;
final gen = _generation;
_loadedKey = key;
_queue.clear();
if (key == null) {
state = const DownloadState();
return;
}
try {
final file = await _manifestFile(key);
if (!await file.exists()) {
if (gen == _generation) state = const DownloadState();
return;
}
final raw = jsonDecode(await file.readAsString());
final byId = <String, DownloadInfo>{};
if (raw is List) {
for (final e in raw.whereType<Map>()) {
final info = DownloadInfo.fromJson(e.cast<String, dynamic>());
final path = info.path;
if (path != null && await File(path).exists()) {
byId[info.song.id] = info;
}
}
}
if (gen == _generation) state = DownloadState(byId: byId);
} catch (_) {
if (gen == _generation) state = const DownloadState();
}
}
// ---- Enqueue / download -------------------------------------------------
/// Queue [song] for download (no-op if already downloaded or in flight).
void download(Song song) {
if (song.id.isEmpty) return;
final existing = state.byId[song.id];
if (existing != null && (existing.isDone || existing.isActive)) return;
if (_clientGetter() == null) return; // offline — nothing to fetch.
_put(DownloadInfo(song: song, status: DownloadStatus.queued));
_queue.add(song.id);
_pump();
}
/// Queue every track (album / playlist "download all").
void downloadAll(List<Song> songs) {
for (final s in songs) {
download(s);
}
}
void _pump() {
while (_active < _maxConcurrent && _queue.isNotEmpty) {
final id = _queue.removeAt(0);
final info = state.byId[id];
if (info == null || info.status != DownloadStatus.queued) continue;
_active++;
_run(info.song).whenComplete(() {
_active--;
_pump();
});
}
}
Future<void> _run(Song song) async {
final gen = _generation;
final client = _clientGetter();
final key = _serverKeyGetter();
if (client == null || key == null) {
_put(state.byId[song.id]!
.copyWith(status: DownloadStatus.failed, error: 'Not connected'));
return;
}
final settings = _settingsGetter();
final rate = settings.downloadMaxBitRate;
final format = settings.downloadFormat;
final uri = client.streamUri(song.id, maxBitRate: rate, format: format);
// Choose a sensible extension; the stored path is authoritative regardless.
final ext = format ?? (rate > 0 ? 'mp3' : (song.suffix ?? 'mp3'));
try {
_put(state.byId[song.id]!
.copyWith(status: DownloadStatus.downloading, progress: 0));
final dir = await _downloadsDir(key);
final finalPath = '${dir.path}/${song.id}.$ext';
final tmpPath = '$finalPath.part';
await _dio.downloadUri(
uri,
tmpPath,
onReceiveProgress: (received, total) {
if (gen != _generation) return;
if (total > 0) {
final cur = state.byId[song.id];
if (cur != null && cur.status == DownloadStatus.downloading) {
_put(cur.copyWith(progress: received / total));
}
}
},
);
if (gen != _generation) {
// Server switched mid-download — discard the partial file.
await File(tmpPath).delete().catchError((_) => File(tmpPath));
return;
}
final tmp = File(tmpPath);
await tmp.rename(finalPath);
final size = await File(finalPath).length();
_put(DownloadInfo(
song: song,
status: DownloadStatus.done,
path: finalPath,
bitRate: rate == 0 ? null : rate,
format: format,
sizeBytes: size,
progress: 1,
));
await _persist();
} catch (e) {
if (gen != _generation) return;
final cur = state.byId[song.id];
if (cur != null) {
_put(cur.copyWith(status: DownloadStatus.failed, error: e.toString()));
}
}
}
// ---- Remove -------------------------------------------------------------
/// Delete a single download (file + manifest entry).
Future<void> remove(String songId) async {
final info = state.byId[songId];
if (info == null) return;
_queue.remove(songId);
if (info.path != null) {
try {
final f = File(info.path!);
if (await f.exists()) await f.delete();
} catch (_) {}
}
final next = Map<String, DownloadInfo>.from(state.byId)..remove(songId);
state = state.copyWith(byId: next);
await _persist();
}
/// Delete every download for the active server.
Future<void> clearAll() async {
_queue.clear();
final key = _serverKeyGetter();
for (final info in state.byId.values) {
if (info.path != null) {
try {
final f = File(info.path!);
if (await f.exists()) await f.delete();
} catch (_) {}
}
}
state = const DownloadState();
if (key != null) {
try {
final file = await _manifestFile(key);
if (await file.exists()) await file.delete();
} catch (_) {}
}
}
// ---- Internals ----------------------------------------------------------
void _put(DownloadInfo info) {
state = state.copyWith(
byId: {...state.byId, info.song.id: info},
);
}
/// Atomic write of the completed-downloads manifest (temp + rename).
Future<void> _persist() async {
final key = _serverKeyGetter();
if (key == null) return;
try {
final file = await _manifestFile(key);
final tmp = File('${file.path}.tmp');
await tmp.writeAsString(
jsonEncode(state.completed.map((d) => d.toJson()).toList()),
);
await tmp.rename(file.path);
} catch (_) {}
}
}