offline updates and playhead fix

This commit is contained in:
Forrest 2026-08-16 12:46:30 -04:00
parent 7a199fe4df
commit 6663330260
14 changed files with 1673 additions and 545 deletions

View file

@ -55,28 +55,27 @@ class DownloadInfo {
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,
);
}) => 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,
);
/// Serialize a completed record. The path is written as *relative* to the
/// app-support directory by [DownloadController._persist] (key `relPath`) —
/// absolute paths embed the iOS app-container UUID, which changes across app
/// updates and would orphan every download. See [DownloadController].
Map<String, dynamic> toJson() => {
'song': song.toJson(),
if (bitRate != null) 'bitRate': bitRate,
if (format != null) 'format': format,
if (sizeBytes != null) 'sizeBytes': sizeBytes,
};
'song': song.toJson(),
if (bitRate != null) 'bitRate': bitRate,
if (format != null) 'format': format,
if (sizeBytes != null) 'sizeBytes': sizeBytes,
};
/// Rebuild a completed record from the manifest. [path] is the absolute path
/// resolved by the controller from the stored relative (or legacy absolute)
@ -95,10 +94,16 @@ class DownloadInfo {
/// Snapshot of all known downloads for the active server, keyed by song id.
class DownloadState {
const DownloadState({this.byId = const {}});
const DownloadState({this.byId = const {}, this.artById = const {}});
final Map<String, DownloadInfo> byId;
/// Cover-art id -> absolute path of the cached art file on disk. This is
/// in-memory only: it is *rebuilt* on load by scanning disk (see
/// [DownloadController.reloadForServer]) and is never written to the manifest.
/// Multiple songs sharing a cover-art id map to the same deduped file.
final Map<String, String> artById;
DownloadInfo? operator [](String id) => byId[id];
bool isDownloaded(String id) => byId[id]?.isDone ?? false;
@ -106,10 +111,16 @@ class DownloadState {
List<DownloadInfo> get completed =>
byId.values.where((d) => d.isDone).toList();
/// Total bytes of downloaded *audio* only — cached cover art is intentionally
/// excluded (art is small and shared across tracks; counting it would make
/// per-track/total sizes misleading).
int get totalBytes => completed.fold(0, (sum, d) => sum + (d.sizeBytes ?? 0));
DownloadState copyWith({Map<String, DownloadInfo>? byId}) =>
DownloadState(byId: byId ?? this.byId);
DownloadState copyWith({
Map<String, DownloadInfo>? byId,
Map<String, String>? artById,
}) =>
DownloadState(byId: byId ?? this.byId, artById: artById ?? this.artById);
}
/// Downloads tracks to disk for offline playback. Files live under
@ -122,10 +133,10 @@ class DownloadController extends StateNotifier<DownloadState> {
required SubsonicClient? Function() clientGetter,
required AppSettings Function() settingsGetter,
required String? Function() serverKeyGetter,
}) : _clientGetter = clientGetter,
_settingsGetter = settingsGetter,
_serverKeyGetter = serverKeyGetter,
super(const DownloadState()) {
}) : _clientGetter = clientGetter,
_settingsGetter = settingsGetter,
_serverKeyGetter = serverKeyGetter,
super(const DownloadState()) {
reloadForServer();
}
@ -133,10 +144,12 @@ class DownloadController extends StateNotifier<DownloadState> {
final AppSettings Function() _settingsGetter;
final String? Function() _serverKeyGetter;
final Dio _dio = Dio(BaseOptions(
receiveTimeout: const Duration(minutes: 5),
headers: {'User-Agent': 'timbre'},
));
final Dio _dio = Dio(
BaseOptions(
receiveTimeout: const Duration(minutes: 5),
headers: {'User-Agent': 'timbre'},
),
);
int _generation = 0;
String? _loadedKey;
@ -186,6 +199,15 @@ class DownloadController extends StateNotifier<DownloadState> {
return info != null && info.isDone ? info.path : null;
}
/// Absolute path to the cached cover-art file for [coverArtId], or null if no
/// art has been cached for it. Read synchronously by offline browse / Now
/// Playing to show real artwork without a network round-trip. Parallel to
/// [localPathFor]; the caller passes the song's `coverArt` id directly.
String? localArtPathFor(String? coverArtId) {
if (coverArtId == null) return null;
return state.artById[coverArtId];
}
bool isDownloaded(String id) => state.isDownloaded(id);
// ---- Server switching / manifest load ----------------------------------
@ -197,6 +219,25 @@ class DownloadController extends StateNotifier<DownloadState> {
return d;
}
/// Directory holding cached cover art for [key], created on demand. Sits
/// beside the audio files at `<downloadsDir>/art`. Art filenames are derived
/// from the cover-art id via [_sanitizeArtId] so tracks sharing an id share a
/// single file (dedup).
Future<Directory> _artDir(String key) async {
final base = await _downloadsDir(key);
final d = Directory('${base.path}/art');
if (!await d.exists()) await d.create(recursive: true);
return d;
}
/// Turn a Subsonic cover-art id into a filesystem-safe filename stem. Any
/// character outside `[A-Za-z0-9._-]` becomes `_`. This is a pure function of
/// the id, so the same id always resolves to the same file — that lets us
/// dedup shared art and reverse-derive art paths on load without persisting
/// them (see [reloadForServer]).
String _sanitizeArtId(String coverArtId) =>
coverArtId.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_');
Future<File> _manifestFile(String key) async {
final dir = await getApplicationSupportDirectory();
return File('${dir.path}/downloads_$key.json');
@ -238,8 +279,20 @@ class DownloadController extends StateNotifier<DownloadState> {
if (map['relPath'] == null) migrated = true;
}
}
// Rediscover cached cover art. The manifest never stores art paths, so we
// reverse-derive them from each completed song's `coverArt` id + the
// [_sanitizeArtId] scheme and keep only ids whose file actually exists.
final artById = <String, String>{};
final artDir = await _artDir(key);
for (final info in byId.values) {
final coverArt = info.song.coverArt;
if (coverArt == null || artById.containsKey(coverArt)) continue;
final artPath = '${artDir.path}/${_sanitizeArtId(coverArt)}.jpg';
if (await File(artPath).exists()) artById[coverArt] = artPath;
}
if (gen == _generation) {
state = DownloadState(byId: byId);
state = DownloadState(byId: byId, artById: artById);
// Self-migrate the manifest to relative paths.
if (migrated) await _persist();
}
@ -277,8 +330,9 @@ class DownloadController extends StateNotifier<DownloadState> {
// Re-read the concurrency cap each pump so a settings change takes effect
// mid-session: raising it starts more downloads immediately; lowering it
// stops launching new ones while in-flight downloads drain naturally.
final maxConcurrent =
AppSettings.clampConcurrentDownloads(_settingsGetter().maxConcurrentDownloads);
final maxConcurrent = AppSettings.clampConcurrentDownloads(
_settingsGetter().maxConcurrentDownloads,
);
while (_active < maxConcurrent && _queue.isNotEmpty) {
final id = _queue.removeAt(0);
final info = state.byId[id];
@ -296,8 +350,12 @@ class DownloadController extends StateNotifier<DownloadState> {
final client = _clientGetter();
final key = _serverKeyGetter();
if (client == null || key == null) {
_put(state.byId[song.id]!
.copyWith(status: DownloadStatus.failed, error: 'Not connected'));
_put(
state.byId[song.id]!.copyWith(
status: DownloadStatus.failed,
error: 'Not connected',
),
);
return;
}
final settings = _settingsGetter();
@ -309,8 +367,12 @@ class DownloadController extends StateNotifier<DownloadState> {
final ext = format ?? (rate > 0 ? 'mp3' : (song.suffix ?? 'mp3'));
try {
_put(state.byId[song.id]!
.copyWith(status: DownloadStatus.downloading, progress: 0));
_put(
state.byId[song.id]!.copyWith(
status: DownloadStatus.downloading,
progress: 0,
),
);
final dir = await _downloadsDir(key);
final finalPath = '${dir.path}/${song.id}.$ext';
@ -346,16 +408,24 @@ class DownloadController extends StateNotifier<DownloadState> {
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,
));
_put(
DownloadInfo(
song: song,
status: DownloadStatus.done,
path: finalPath,
bitRate: rate == 0 ? null : rate,
format: format,
sizeBytes: size,
progress: 1,
),
);
await _persist();
// Cache the cover art too, so offline browse / Now Playing can show real
// artwork. This is strictly best-effort and runs *after* the audio is
// already committed: any failure here is swallowed and must never fail or
// regress the (successful) audio download.
await _cacheArt(song, client, key, gen);
} catch (e) {
if (gen != _generation) return;
final cur = state.byId[song.id];
@ -365,6 +435,56 @@ class DownloadController extends StateNotifier<DownloadState> {
}
}
/// Best-effort fetch of [song]'s cover art to `<artDir>/<sanitizedId>.jpg`,
/// mirroring the audio temp+rename pattern. Soft-fails: any error is logged
/// only by being swallowed — the audio download stays `done` regardless.
/// Skips the network entirely if the art file already exists (dedup by
/// cover-art id) and honors the [gen] guard so a server switch mid-fetch
/// discards the partial file instead of surfacing another server's art.
Future<void> _cacheArt(
Song song,
SubsonicClient client,
String key,
int gen,
) async {
final coverArt = song.coverArt;
if (coverArt == null) return;
try {
final artDir = await _artDir(key);
final finalPath = '${artDir.path}/${_sanitizeArtId(coverArt)}.jpg';
if (await File(finalPath).exists()) {
// Already cached (possibly by a sibling track) — just ensure the map
// reflects it and skip the fetch.
if (gen == _generation && state.artById[coverArt] != finalPath) {
state = state.copyWith(
artById: {...state.artById, coverArt: finalPath},
);
}
return;
}
final tmpPath = '$finalPath.part';
await _dio.downloadUri(client.coverArtUri(coverArt, size: 512), tmpPath);
if (gen != _generation) {
// Server switched mid-fetch — discard the partial art file.
await File(tmpPath).delete().catchError((_) => File(tmpPath));
return;
}
await File(tmpPath).rename(finalPath);
// Surface the new art immediately to the UI.
if (gen == _generation) {
state = state.copyWith(
artById: {...state.artById, coverArt: finalPath},
);
}
} catch (_) {
// Art is optional; never fail the audio download because of it.
}
}
// ---- Remove -------------------------------------------------------------
/// Delete a single download (file + manifest entry).
@ -379,7 +499,28 @@ class DownloadController extends StateNotifier<DownloadState> {
} catch (_) {}
}
final next = Map<String, DownloadInfo>.from(state.byId)..remove(songId);
state = state.copyWith(byId: next);
// Drop the cached art too, but only if no *other* remaining completed
// download still references the same cover-art id (art is shared/deduped).
var nextArt = state.artById;
final coverArt = info.song.coverArt;
if (coverArt != null) {
final stillUsed = next.values.any(
(d) => d.isDone && d.song.coverArt == coverArt,
);
if (!stillUsed) {
final artPath = state.artById[coverArt];
if (artPath != null) {
try {
final f = File(artPath);
if (await f.exists()) await f.delete();
} catch (_) {}
}
nextArt = Map<String, String>.from(state.artById)..remove(coverArt);
}
}
state = state.copyWith(byId: next, artById: nextArt);
await _persist();
}
@ -395,6 +536,14 @@ class DownloadController extends StateNotifier<DownloadState> {
} catch (_) {}
}
}
// Wipe the whole art directory in one shot (resets artById implicitly via
// the `const DownloadState()` assignment below).
if (key != null) {
try {
final artDir = await _artDir(key);
if (await artDir.exists()) await artDir.delete(recursive: true);
} catch (_) {}
}
state = const DownloadState();
if (key != null) {
try {
@ -407,9 +556,7 @@ class DownloadController extends StateNotifier<DownloadState> {
// ---- Internals ----------------------------------------------------------
void _put(DownloadInfo info) {
state = state.copyWith(
byId: {...state.byId, info.song.id: info},
);
state = state.copyWith(byId: {...state.byId, info.song.id: info});
}
/// Atomic write of the completed-downloads manifest (temp + rename).
@ -421,12 +568,14 @@ class DownloadController extends StateNotifier<DownloadState> {
final file = await _manifestFile(key);
final tmp = File('${file.path}.tmp');
await tmp.writeAsString(
jsonEncode(state.completed.map((d) {
final j = d.toJson();
final rel = _relativize(d.path);
if (rel != null) j['relPath'] = rel;
return j;
}).toList()),
jsonEncode(
state.completed.map((d) {
final j = d.toJson();
final rel = _relativize(d.path);
if (rel != null) j['relPath'] = rel;
return j;
}).toList(),
),
);
await tmp.rename(file.path);
} catch (_) {}