fixes to scrubbing and offline issues

This commit is contained in:
Forrest 2026-07-31 21:06:19 -04:00
parent 5bf85a5f44
commit d558aba246
31 changed files with 296 additions and 33 deletions

View file

@ -67,19 +67,25 @@ class DownloadInfo {
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(),
'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(
/// Rebuild a completed record from the manifest. [path] is the absolute path
/// resolved by the controller from the stored relative (or legacy absolute)
/// path against the *current* app-support directory.
factory DownloadInfo.fromJson(Map<String, dynamic> j, {String? path}) =>
DownloadInfo(
song: Song.fromJson((j['song'] as Map).cast<String, dynamic>()),
status: DownloadStatus.done,
path: j['path'] as String?,
path: path,
bitRate: (j['bitRate'] as num?)?.toInt(),
format: j['format'] as String?,
sizeBytes: (j['sizeBytes'] as num?)?.toInt(),
@ -139,6 +145,42 @@ class DownloadController extends StateNotifier<DownloadState> {
final List<String> _queue = [];
int _active = 0;
/// Cached app-support directory path. Manifests store paths *relative* to
/// this so downloads survive the app-container path changing across updates;
/// we re-root them against the current directory at load time.
String? _supportDirPath;
Future<String> _supportPath() async =>
_supportDirPath ??= (await getApplicationSupportDirectory()).path;
/// Strip the app-support prefix so the manifest stores a stable relative path
/// (`downloads/<key>/<file>`). Falls back to the `downloads/` segment if the
/// absolute path doesn't sit under the cached base.
String? _relativize(String? absPath) {
if (absPath == null) return null;
final base = _supportDirPath;
if (base != null && absPath.startsWith('$base/')) {
return absPath.substring(base.length + 1);
}
final i = absPath.indexOf('downloads/');
return i >= 0 ? absPath.substring(i) : absPath;
}
/// Resolve a manifest entry's stored path to an absolute path under the
/// *current* app-support directory. Prefers the new relative `relPath`; for a
/// legacy absolute `path` it re-roots the trailing `downloads/...` segment so
/// downloads made before this change (or before an app update) still resolve.
String? _resolveStoredPath(Map<String, dynamic> j) {
final base = _supportDirPath;
final rel = j['relPath'] as String?;
if (rel != null) return base != null ? '$base/$rel' : rel;
final legacy = j['path'] as String?;
if (legacy == null) return null;
final i = legacy.indexOf('downloads/');
if (i >= 0 && base != null) return '$base/${legacy.substring(i)}';
return legacy;
}
/// 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) {
@ -177,6 +219,7 @@ class DownloadController extends StateNotifier<DownloadState> {
return;
}
await _supportPath();
try {
final file = await _manifestFile(key);
if (!await file.exists()) {
@ -185,16 +228,23 @@ class DownloadController extends StateNotifier<DownloadState> {
}
final raw = jsonDecode(await file.readAsString());
final byId = <String, DownloadInfo>{};
var migrated = false;
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;
}
final map = e.cast<String, dynamic>();
final path = _resolveStoredPath(map);
if (path == null || !await File(path).exists()) continue;
final info = DownloadInfo.fromJson(map, path: path);
byId[info.song.id] = info;
// A legacy absolute-path entry re-persists as relative on next save.
if (map['relPath'] == null) migrated = true;
}
}
if (gen == _generation) state = DownloadState(byId: byId);
if (gen == _generation) {
state = DownloadState(byId: byId);
// Self-migrate the manifest to relative paths.
if (migrated) await _persist();
}
} catch (_) {
if (gen == _generation) state = const DownloadState();
}
@ -360,10 +410,16 @@ class DownloadController extends StateNotifier<DownloadState> {
final key = _serverKeyGetter();
if (key == null) return;
try {
await _supportPath();
final file = await _manifestFile(key);
final tmp = File('${file.path}.tmp');
await tmp.writeAsString(
jsonEncode(state.completed.map((d) => d.toJson()).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 (_) {}