diff --git a/lib/debug/log_store.dart b/lib/debug/log_store.dart deleted file mode 100644 index 5b48412..0000000 --- a/lib/debug/log_store.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:flutter/foundation.dart'; - -/// Severity of a captured log line, used only to tint it in the Debug tab. -enum LogLevel { info, error } - -/// A single captured console line, stamped with the wall-clock time it arrived. -@immutable -class LogEntry { - const LogEntry(this.time, this.text, this.level); - - final DateTime time; - final String text; - final LogLevel level; - - /// `HH:MM:SS.mmm` — enough resolution to correlate bursts while streaming. - String get timeLabel { - String two(int n) => n.toString().padLeft(2, '0'); - return '${two(time.hour)}:${two(time.minute)}:${two(time.second)}' - '.${time.millisecond.toString().padLeft(3, '0')}'; - } -} - -/// In-memory ring buffer of console/system output, surfaced in the Debug tab so -/// the app's logs can be read (and copied) on-device during beta testing — -/// there's no attached debugger on a TestFlight/sideloaded build. -/// -/// A process-wide singleton because the capture hooks (the zone `print` -/// override and `FlutterError.onError`) are installed in `main()`, outside the -/// widget/provider tree. The UI listens via [ListenableBuilder]. -class LogStore extends ChangeNotifier { - LogStore._(); - static final LogStore instance = LogStore._(); - - /// Keep the tail bounded so a chatty session can't grow memory without limit. - static const int _maxEntries = 3000; - - final List _entries = []; - - /// Newest-last, read-only view for the UI. - List get entries => List.unmodifiable(_entries); - - int get length => _entries.length; - - void add(String text, {LogLevel level = LogLevel.info}) { - // A single print can carry embedded newlines; split so each shows as its - // own row (and the timestamp lines up per line). - final now = DateTime.now(); - for (final line in text.split('\n')) { - _entries.add(LogEntry(now, line, level)); - } - final overflow = _entries.length - _maxEntries; - if (overflow > 0) _entries.removeRange(0, overflow); - notifyListeners(); - } - - void clear() { - _entries.clear(); - notifyListeners(); - } - - /// The whole buffer as plain text, for copy-to-clipboard / sharing. - String asText() => - _entries.map((e) => '${e.timeLabel} ${e.text}').join('\n'); -} diff --git a/lib/downloads/download_manager.dart b/lib/downloads/download_manager.dart index ad27921..6111b07 100644 --- a/lib/downloads/download_manager.dart +++ b/lib/downloads/download_manager.dart @@ -55,27 +55,28 @@ 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 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) @@ -94,16 +95,10 @@ class DownloadInfo { /// Snapshot of all known downloads for the active server, keyed by song id. class DownloadState { - const DownloadState({this.byId = const {}, this.artById = const {}}); + const DownloadState({this.byId = const {}}); final Map 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 artById; - DownloadInfo? operator [](String id) => byId[id]; bool isDownloaded(String id) => byId[id]?.isDone ?? false; @@ -111,16 +106,10 @@ class DownloadState { List 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? byId, - Map? artById, - }) => - DownloadState(byId: byId ?? this.byId, artById: artById ?? this.artById); + DownloadState copyWith({Map? byId}) => + DownloadState(byId: byId ?? this.byId); } /// Downloads tracks to disk for offline playback. Files live under @@ -133,10 +122,10 @@ class DownloadController extends StateNotifier { 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(); } @@ -144,12 +133,10 @@ class DownloadController extends StateNotifier { 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; @@ -199,15 +186,6 @@ class DownloadController extends StateNotifier { 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 ---------------------------------- @@ -219,25 +197,6 @@ class DownloadController extends StateNotifier { return d; } - /// Directory holding cached cover art for [key], created on demand. Sits - /// beside the audio files at `/art`. Art filenames are derived - /// from the cover-art id via [_sanitizeArtId] so tracks sharing an id share a - /// single file (dedup). - Future _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 _manifestFile(String key) async { final dir = await getApplicationSupportDirectory(); return File('${dir.path}/downloads_$key.json'); @@ -279,20 +238,8 @@ class DownloadController extends StateNotifier { 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 = {}; - 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, artById: artById); + state = DownloadState(byId: byId); // Self-migrate the manifest to relative paths. if (migrated) await _persist(); } @@ -330,9 +277,8 @@ class DownloadController extends StateNotifier { // 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]; @@ -350,12 +296,8 @@ class DownloadController extends StateNotifier { 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(); @@ -367,12 +309,8 @@ class DownloadController extends StateNotifier { 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'; @@ -408,24 +346,16 @@ class DownloadController extends StateNotifier { 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]; @@ -435,56 +365,6 @@ class DownloadController extends StateNotifier { } } - /// Best-effort fetch of [song]'s cover art to `/.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 _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). @@ -499,28 +379,7 @@ class DownloadController extends StateNotifier { } catch (_) {} } final next = Map.from(state.byId)..remove(songId); - - // 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.from(state.artById)..remove(coverArt); - } - } - - state = state.copyWith(byId: next, artById: nextArt); + state = state.copyWith(byId: next); await _persist(); } @@ -536,14 +395,6 @@ class DownloadController extends StateNotifier { } 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 { @@ -556,7 +407,9 @@ class DownloadController extends StateNotifier { // ---- 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). @@ -568,14 +421,12 @@ class DownloadController extends StateNotifier { 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 (_) {} diff --git a/lib/library/offline_library.dart b/lib/library/offline_library.dart deleted file mode 100644 index f5e4b35..0000000 --- a/lib/library/offline_library.dart +++ /dev/null @@ -1,153 +0,0 @@ -import '../subsonic/models.dart'; - -/// Reconstructs [Album]s and [Artist]s from a flat list of downloaded [Song]s. -/// The offline library only ever has tracks (that's all that's cached on disk), -/// so album/artist entities are synthesized on demand. Pure — no I/O. - -/// Compare two strings case-insensitively, treating null as empty (sorts first). -int _byString(String? a, String? b) => - (a ?? '').toLowerCase().compareTo((b ?? '').toLowerCase()); - -/// Compare where a null [a]/[b] always sorts *last*. Takes bare [Comparable] so -/// `int` (`Comparable`) works for disc/track ordering. -int _nullsLast(Comparable? a, Comparable? b) { - if (a == null && b == null) return 0; - if (a == null) return 1; - if (b == null) return -1; - return a.compareTo(b); -} - -/// True for a present, non-blank id/name. -bool _has(String? v) => v != null && v.trim().isNotEmpty; - -/// First non-blank value in [values], or null if none. -String? _firstNonNull(Iterable values) { - for (final v in values) { - if (_has(v)) return v; - } - return null; -} - -/// First non-null value in [values], or null if none. For nullable ints. -int? _firstNonNullInt(Iterable values) { - for (final v in values) { - if (v != null) return v; - } - return null; -} - -/// Stable album key: prefer [Song.albumId], else fall back to the album NAME. -/// The synthesized [Album.id] mirrors this exactly (see [albumsFromSongs]), so -/// Phase 2 providers can look an album up by the same key it was built under. -String? _albumKey(Song s) => _has(s.albumId) ? s.albumId : s.album; - -/// Stable artist key: prefer [Song.artistId], else fall back to the artist NAME. -String? _artistKey(Song s) => _has(s.artistId) ? s.artistId : s.artist; - -/// Synthesize [Album]s from downloaded [Song]s, grouped by [_albumKey]. -/// -/// Songs with neither an albumId nor an album name are skipped — with no album -/// identity they can't form a meaningful album. The synthesized [Album.id] is -/// the albumId when present, else the album *name* itself (the same string used -/// as the grouping key), so lookups by id stay stable across rebuilds. -/// -/// Returned albums are sorted by name (case-insensitive ascending) for a stable -/// default order. -List albumsFromSongs(List songs) { - // Preserve first-seen insertion order within groups; output order is imposed - // by the final sort, so the map's own ordering only needs to be deterministic. - final groups = >{}; - for (final s in songs) { - final key = _albumKey(s); - if (key == null) continue; // no album identity → skip - groups.putIfAbsent(key, () => []).add(s); - } - - final out = []; - groups.forEach((key, group) { - // id: albumId if any song carries one, else the name-derived key. - final albumId = _firstNonNull(group.map((s) => s.albumId)); - final id = albumId ?? key; - - final sorted = [...group] - ..sort((a, b) { - final d = _nullsLast(a.discNumber, b.discNumber); - if (d != 0) return d; - final t = _nullsLast(a.track, b.track); - return t != 0 ? t : _byString(a.title, b.title); - }); - - out.add( - Album( - id: id, - name: _firstNonNull(group.map((s) => s.album)), - artist: _firstNonNull(group.map((s) => s.artist)), - artistId: _firstNonNull(group.map((s) => s.artistId)), - coverArt: _firstNonNull(group.map((s) => s.coverArt)), - year: _firstNonNullInt(group.map((s) => s.year)), - genre: _firstNonNull(group.map((s) => s.genre)), - songCount: group.length, - songs: sorted, - ), - ); - }); - - out.sort((a, b) => _byString(a.name, b.name)); - return out; -} - -/// Synthesize [Artist]s from downloaded [Song]s, grouped by [_artistKey]. -/// -/// Songs with neither an artistId nor an artist name are skipped. Each artist's -/// [Artist.albums] is built by running [albumsFromSongs] over that artist's own -/// songs, and its [Artist.id] follows the same id/name fallback as albums. -/// -/// Returned artists are sorted by name (case-insensitive ascending). -List artistsFromSongs(List songs) { - final groups = >{}; - for (final s in songs) { - final key = _artistKey(s); - if (key == null) continue; // no artist identity → skip - groups.putIfAbsent(key, () => []).add(s); - } - - final out = []; - groups.forEach((key, group) { - // id: artistId if any song carries one, else the name-derived key. - final artistId = _firstNonNull(group.map((s) => s.artistId)); - final id = artistId ?? key; - - final albums = albumsFromSongs(group); - - out.add( - Artist( - id: id, - name: _firstNonNull(group.map((s) => s.artist)), - coverArt: _firstNonNull(albums.map((a) => a.coverArt)), - albums: albums, - albumCount: albums.length, - ), - ); - }); - - out.sort((a, b) => _byString(a.name, b.name)); - return out; -} - -/// The synthesized album whose id == [id], or null. Keyed lookup for the album -/// detail provider. -Album? albumFromSongs(List songs, String id) { - for (final a in albumsFromSongs(songs)) { - if (a.id == id) return a; - } - return null; -} - -/// The synthesized artist whose id == [id], or null. Keyed lookup for the artist -/// detail provider. -Artist? artistFromSongs(List songs, String id) { - for (final a in artistsFromSongs(songs)) { - if (a.id == id) return a; - } - return null; -} diff --git a/lib/playback/playback_engine.dart b/lib/playback/playback_engine.dart index 128ce9b..a9d88e5 100644 --- a/lib/playback/playback_engine.dart +++ b/lib/playback/playback_engine.dart @@ -32,7 +32,6 @@ class PlaybackState { this.queue = const [], this.currentIndex, this.playing = false, - this.buffering = false, this.position = Duration.zero, this.duration = Duration.zero, this.shuffle = false, @@ -44,13 +43,6 @@ class PlaybackState { final List queue; final int? currentIndex; final bool playing; - - /// True while the player is loading/buffering a source (not yet `ready`). A - /// streamed source that is buffering legitimately reports position 0; this - /// lets the UI show a spinner instead of a frozen 0:00 bar. Transient — never - /// persisted. - final bool buffering; - final Duration position; final Duration duration; final bool shuffle; @@ -91,7 +83,6 @@ class PlaybackState { List? queue, int? currentIndex, bool? playing, - bool? buffering, Duration? position, Duration? duration, bool? shuffle, @@ -104,7 +95,6 @@ class PlaybackState { queue: queue ?? this.queue, currentIndex: currentIndex ?? this.currentIndex, playing: playing ?? this.playing, - buffering: buffering ?? this.buffering, position: position ?? this.position, duration: duration ?? this.duration, shuffle: shuffle ?? this.shuffle, @@ -193,24 +183,6 @@ class PlaybackController extends StateNotifier /// one disk write per window. Timer? _saveTimer; - // ---- Position interpolation ----------------------------------------- - // - // just_audio's `positionStream`/`position` getter clamps the playing position - // to the reported duration; on iOS an unknown-length stream reports - // `duration == Duration.zero` (not null), so the clamp pins the playhead to - // 0:00 while playing (paused reads the raw value — hence "0:00 playing, - // correct when paused"). We sidestep the clamp entirely by anchoring on the - // raw, unclamped `updatePosition` from `playbackEventStream` and advancing it - // ourselves against the wall clock while actually playing. - - /// Last unclamped position reported by the platform, and the wall-clock time - /// it was sampled (`PlaybackEvent.updateTime`). - Duration _posAnchor = Duration.zero; - DateTime _posAnchorAt = DateTime.fromMillisecondsSinceEpoch(0); - - /// Ticks the interpolated position forward while playing. - Timer? _positionTicker; - /// Server key whose queue we've already restored (or adopted). Gates saves so /// the empty launch state can't clobber a snapshot before restore runs. String? _restoredKey; @@ -243,68 +215,22 @@ class PlaybackController extends StateNotifier _maybeSlideWindow(); }); player.playerStateStream.listen((s) { - final ps = s.processingState; - state = state.copyWith( - playing: s.playing, - buffering: ps == ProcessingState.loading || - ps == ProcessingState.buffering, - ); + state = state.copyWith(playing: s.playing); + }); + player.positionStream.listen((p) { + state = state.copyWith(position: p); }); player.durationStream.listen((d) { if (d != null) state = state.copyWith(duration: d); }); - // The event stream carries the raw, unclamped `updatePosition`; anchor on it - // (and re-anchor on every seek / pause / track change) and reflect it - // immediately so paused/seeked positions are exact. Steady-state advancing - // is done by the ticker below. We also handle load/decode failures here: - // without a handler the platform player runs its own recovery — restarting - // the item at 0 or auto-advancing — the reported "scrub back / skip" bug. + // just_audio surfaces load/decode failures (e.g. an unreachable remote + // source after the network drops) as errors on the event stream. Without a + // handler the platform player runs its own recovery — restarting the item + // at 0 or auto-advancing — which is the reported "scrub back / skip" bug. player.playbackEventStream.listen( - (event) { - _posAnchor = event.updatePosition; - _posAnchorAt = event.updateTime; - // `updatePosition` is sampled at `updateTime`, i.e. slightly in the - // past. While playing, the ticker has already advanced the displayed - // position to ~now; writing the raw sample here would snap it backward - // every time an event fires (they fire periodically), then the ticker - // re-advances it — a visible flicker. So reflect the *interpolated* - // value (continuous with the ticker) while playing, and the raw value - // only when paused/buffering, where it's exact and nothing is ticking. - final pos = (state.playing && !state.buffering) - ? _interpolatedPosition() - : event.updatePosition; - state = state.copyWith(position: pos); - }, + (_) {}, onError: (Object e, StackTrace st) => _onPlayerError(e), ); - _positionTicker?.cancel(); - _positionTicker = Timer.periodic( - const Duration(milliseconds: 200), - (_) => _tickPosition(), - ); - } - - /// Advances the displayed position off [_posAnchor] against the wall clock, - /// bypassing just_audio's duration-zero clamp. Only runs while genuinely - /// playing (not buffering/stalled) so the playhead never drifts ahead of the - /// audio; clamped to [PlaybackState.effectiveDuration] (which falls back to - /// the Subsonic metadata length) so it can't run past the end. - void _tickPosition() { - if (!state.playing || state.buffering) return; - state = state.copyWith(position: _interpolatedPosition()); - } - - /// The current position interpolated off [_posAnchor] against the wall clock, - /// clamped to [PlaybackState.effectiveDuration] (falls back to the Subsonic - /// metadata length) so it can't run past the end. Shared by the ticker and - /// the event listener so both agree — a mismatch between them is what causes - /// the playhead to visibly jump. - Duration _interpolatedPosition() { - final elapsed = DateTime.now().difference(_posAnchorAt); - var pos = elapsed.isNegative ? _posAnchor : _posAnchor + elapsed; - final total = state.effectiveDuration; - if (total > Duration.zero && pos > total) pos = total; - return pos; } /// Id of the song we last ran play side effects for. Queue edits shift @@ -380,28 +306,20 @@ class PlaybackController extends StateNotifier AudioSource _sourceFor(Song song) { final uri = _streamUriFor(song)!; - final isRemote = !uri.isScheme('file'); - if (isRemote) _remoteSourceIds.add(song.id); + if (!uri.isScheme('file')) _remoteSourceIds.add(song.id); final art = _coverArtUriFor(song); - final tag = MediaItem( - id: '${song.id}#${_tagSeq++}', - title: song.title ?? 'Unknown', - album: song.album, - artist: song.artist, - duration: - song.duration != null ? Duration(seconds: song.duration!) : null, - artUri: art, + return AudioSource.uri( + uri, + tag: MediaItem( + id: '${song.id}#${_tagSeq++}', + title: song.title ?? 'Unknown', + album: song.album, + artist: song.artist, + duration: + song.duration != null ? Duration(seconds: song.duration!) : null, + artUri: art, + ), ); - // Remote streams go straight to the native player (AVPlayer / ExoPlayer), - // which fetches the origin directly via its own networking stack. We - // deliberately do NOT wrap in LockCachingAudioSource nor set a player - // userAgent: both route the fetch through just_audio's localhost proxy, - // whose bare dart:io HttpClient sends a default User-Agent and demands an - // exact HTTP 200 — off-LAN edges (reverse proxy / WAF) reject or redirect - // that, breaking streaming while dio downloads still work. The proxy also - // hides the stream's Content-Length, which leaves the native duration - // indefinite and freezes the playhead (see the _wireStreams ticker). - return AudioSource.uri(uri, tag: tag); } /// Replace the queue with [songs] and start at [startIndex]. @@ -949,15 +867,9 @@ class PlaybackController extends StateNotifier void resyncFromPlayer() { final player = _player; if (player == null) return; - final playing = player.playing; - // `player.position` is clamped to the reported duration, which is - // `Duration.zero` for unknown-length streams and would snap the playhead to - // 0 while playing. Use our unclamped anchor when playing; the raw getter is - // correct when paused. - final pos = playing - ? _posAnchor + DateTime.now().difference(_posAnchorAt) - : player.position; + final pos = player.position; final dur = player.duration ?? state.duration; + final playing = player.playing; // player.currentIndex is a window-relative index; map it back to logical. final idx = player.currentIndex != null ? _windowStart + player.currentIndex! @@ -1123,7 +1035,6 @@ class PlaybackController extends StateNotifier @override void dispose() { _saveTimer?.cancel(); - _positionTicker?.cancel(); _player?.dispose(); super.dispose(); } diff --git a/lib/screens/browser_screen.dart b/lib/screens/browser_screen.dart index 79042e2..27e14d4 100644 --- a/lib/screens/browser_screen.dart +++ b/lib/screens/browser_screen.dart @@ -5,7 +5,6 @@ import '../downloads/download_manager.dart'; import '../state/providers.dart'; import '../subsonic/models.dart'; import '../theme/tokens.dart'; -import '../widgets/art_image.dart'; import '../widgets/hairline_panel.dart'; import '../widgets/toast.dart'; import 'add_tag_sheet.dart'; @@ -25,9 +24,8 @@ class BrowserScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final client = ref.watch(subsonicClientProvider); final mode = ref.watch(browseModeProvider); - final offline = ref.watch(subsonicClientProvider) == null; - final hasDownloads = ref.watch(downloadedSongsProvider).isNotEmpty; return Padding( padding: const EdgeInsets.fromLTRB( @@ -46,9 +44,9 @@ class BrowserScreen extends ConsumerWidget { _Action( icon: Icons.search, label: 'Search', - onTap: () => Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => const SearchScreen())), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SearchScreen()), + ), ), _Action( icon: Icons.favorite_border, @@ -67,9 +65,9 @@ class BrowserScreen extends ConsumerWidget { _Action( icon: Icons.label_outline, label: 'Tags', - onTap: () => Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => const TagsScreen())), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const TagsScreen()), + ), ), _Action( icon: Icons.download, @@ -84,7 +82,7 @@ class BrowserScreen extends ConsumerWidget { _ModeSelector(mode: mode), const SizedBox(height: TimbreSpacing.lg), Expanded( - child: offline && !hasDownloads + child: client == null ? const HairlinePanel( title: 'Browse', active: true, @@ -117,9 +115,8 @@ class _ModeSelector extends ConsumerWidget { return InkWell( onTap: () => ref.read(browseModeProvider.notifier).state = m, child: Container( - constraints: const BoxConstraints( - minHeight: TimbreSpacing.minTouchTarget, - ), + constraints: + const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget), padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.md), alignment: Alignment.center, child: Text( @@ -127,9 +124,8 @@ class _ModeSelector extends ConsumerWidget { style: TextStyle( color: active ? TimbreColors.foreground : TimbreColors.dimmed, fontWeight: active ? FontWeight.w700 : FontWeight.w400, - decoration: active - ? TextDecoration.underline - : TextDecoration.none, + decoration: + active ? TextDecoration.underline : TextDecoration.none, decorationColor: accent, decorationThickness: 2, ), @@ -195,6 +191,7 @@ class _AlbumsPanel extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final albums = ref.watch(visibleAlbumsProvider); final filter = ref.watch(albumFilterProvider); + final client = ref.watch(subsonicClientProvider); return HairlinePanel( title: 'Albums', active: true, @@ -218,20 +215,18 @@ class _AlbumsPanel extends ConsumerWidget { Expanded( child: list.isEmpty ? _Centered( - child: _ErrorText( - filter.isActive - ? 'No albums match these filters.' - : 'No albums on this server.', - ), + child: _ErrorText(filter.isActive + ? 'No albums match these filters.' + : 'No albums on this server.'), ) : LayoutBuilder( builder: (context, constraints) { - final cols = (constraints.maxWidth / 180) - .floor() - .clamp(2, 6); + final cols = + (constraints.maxWidth / 180).floor().clamp(2, 6); return GridView.builder( padding: EdgeInsets.zero, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: cols, mainAxisSpacing: TimbreSpacing.md, crossAxisSpacing: TimbreSpacing.md, @@ -242,11 +237,13 @@ class _AlbumsPanel extends ConsumerWidget { itemCount: list.length, itemBuilder: (context, i) => _AlbumTile( album: list[i], - artUri: resolveArtUriW( - ref, - coverArt: list[i].coverArt, - size: 300, - )?.toString(), + artUri: + (client != null && list[i].coverArt != null) + ? client + .coverArtUri(list[i].coverArt!, + size: 300) + .toString() + : null, ), ); }, @@ -269,18 +266,25 @@ class _AlbumTile extends StatelessWidget { @override Widget build(BuildContext context) { return InkWell( - onTap: () => Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: album.id))), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => AlbumScreen(id: album.id)), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ AspectRatio( aspectRatio: 1, - child: ArtImage( - artUri, - fit: BoxFit.cover, - placeholder: const _AlbumArtFallback(), + child: ColoredBox( + color: TimbreColors.surface, + child: artUri != null + ? Image.network( + artUri!, + key: ValueKey(artUri), + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => const _AlbumArtFallback(), + ) + : const _AlbumArtFallback(), ), ), const SizedBox(height: TimbreSpacing.xs), @@ -295,7 +299,8 @@ class _AlbumTile extends StatelessWidget { album.artist!, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: TimbreColors.dimmed, fontSize: 12), + style: TextStyle( + color: TimbreColors.dimmed, fontSize: 12), ), ], ), @@ -307,8 +312,9 @@ class _AlbumArtFallback extends StatelessWidget { const _AlbumArtFallback(); @override Widget build(BuildContext context) => Center( - child: Icon(Icons.album_outlined, color: TimbreColors.dimmed, size: 32), - ); + child: Icon(Icons.album_outlined, + color: TimbreColors.dimmed, size: 32), + ); } /// Flat alphabetical list of every song, backed by the crawled+cached library @@ -325,11 +331,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { - // Offline the tracks come from the provider fallback (downloaded songs); - // only crawl the live library when we actually have a server connection. - if (ref.read(subsonicClientProvider) != null) { - ref.read(libraryIndexProvider.notifier).ensureBuilt(); - } + ref.read(libraryIndexProvider.notifier).ensureBuilt(); }); } @@ -338,12 +340,10 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { final index = ref.watch(libraryIndexProvider); final visible = ref.watch(visibleTracksProvider); final playback = ref.read(playbackCommandsProvider); - final offline = ref.watch(subsonicClientProvider) == null; + final client = ref.watch(subsonicClientProvider); final Widget body; - // Offline the crawled index is empty; `visible` is backed by the downloaded - // songs instead, so skip the online-only indexing / empty-index branches. - if (!offline && index.building) { + if (index.building) { final total = index.total; final label = total > 0 ? 'Indexing ${index.done}/$total albums…' @@ -358,13 +358,11 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { ], ), ); - } else if (!offline && index.songs.isEmpty) { + } else if (index.songs.isEmpty) { body = _Centered( - child: _ErrorText( - index.error != null - ? 'Could not build the track index.' - : 'No tracks indexed yet.', - ), + child: _ErrorText(index.error != null + ? 'Could not build the track index.' + : 'No tracks indexed yet.'), ); } else { final downloads = ref.watch(downloadManagerProvider); @@ -375,24 +373,24 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { Expanded( child: visible.isEmpty ? const _Centered( - child: _ErrorText('No tracks match these filters.'), - ) + child: _ErrorText('No tracks match these filters.')) : ListView.builder( padding: EdgeInsets.zero, itemCount: visible.length, itemBuilder: (context, i) { final song = visible[i]; - final artUri = resolveArtUriW( - ref, - coverArt: song.coverArt, - size: 128, - )?.toString(); + final artUri = (client != null && song.coverArt != null) + ? client + .coverArtUri(song.coverArt!, size: 128) + .toString() + : null; return BrowseRow( title: song.title ?? 'Untitled', subtitle: song.artist, artUri: artUri, downloadStatus: downloads.byId[song.id]?.status, - onTap: () => playback.playSongs(visible, startIndex: i), + onTap: () => + playback.playSongs(visible, startIndex: i), onPlayNext: () => playback.playNext(song), onAddToQueue: () => playback.addToQueue(song), onAddToPlaylist: () => @@ -416,9 +414,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { return HairlinePanel( title: 'Tracks', active: true, - trailing: index.songs.isNotEmpty || visible.isNotEmpty - ? '(${visible.length})' - : null, + trailing: index.songs.isNotEmpty ? '(${visible.length})' : null, padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md), action: Row( mainAxisSize: MainAxisSize.min, @@ -429,10 +425,8 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { : () => ref.read(libraryIndexProvider.notifier).refresh(), child: Padding( padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xs), - child: Text( - '↻ refresh', - style: TextStyle(color: TimbreColors.dimmed, fontSize: 12), - ), + child: Text('↻ refresh', + style: TextStyle(color: TimbreColors.dimmed, fontSize: 12)), ), ), if (visible.isNotEmpty) @@ -451,27 +445,19 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.download, - size: 16, - color: TimbreColors.foreground, - ), + Icon(Icons.download, + size: 16, color: TimbreColors.foreground), SizedBox(width: TimbreSpacing.sm), - Text( - 'Download all', - style: TextStyle(color: TimbreColors.foreground), - ), + Text('Download all', + style: TextStyle(color: TimbreColors.foreground)), ], ), ), ], child: Padding( padding: const EdgeInsets.all(TimbreSpacing.xs), - child: Icon( - Icons.more_vert, - size: 18, - color: TimbreColors.dimmed, - ), + child: Icon(Icons.more_vert, + size: 18, color: TimbreColors.dimmed), ), ), ], @@ -484,27 +470,22 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { /// library, so it's gated behind a dialog unlike per-album download-all. /// [songs] is the currently-visible (filtered/sorted) set. Future _confirmDownloadAll( - BuildContext context, - List songs, - ) async { + BuildContext context, List songs) async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: TimbreColors.surface, title: const Text('Download these tracks?'), content: Text( - 'This queues all ${songs.length} listed tracks for offline ' - 'download. It may use significant storage and data.', - ), + 'This queues all ${songs.length} listed tracks for offline ' + 'download. It may use significant storage and data.'), actions: [ TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel'), - ), + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel')), TextButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Download all'), - ), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Download all')), ], ), ); @@ -562,7 +543,9 @@ class ArtistScreen extends ConsumerWidget { title: album.name ?? 'Unknown album', trailing: album.year?.toString(), onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => AlbumScreen(id: album.id)), + MaterialPageRoute( + builder: (_) => AlbumScreen(id: album.id), + ), ), ); }, @@ -591,13 +574,16 @@ class AlbumScreen extends ConsumerWidget { : [ IconButton( tooltip: 'Add to playlist', - onPressed: () => showAddToPlaylistSheet(context, songs: songs), + onPressed: () => + showAddToPlaylistSheet(context, songs: songs), icon: const Icon(Icons.playlist_add), ), IconButton( tooltip: 'Download album', onPressed: () { - ref.read(downloadManagerProvider.notifier).downloadAll(songs); + ref + .read(downloadManagerProvider.notifier) + .downloadAll(songs); showToast(context, 'Downloading album…'); }, icon: const Icon(Icons.download), @@ -692,36 +678,39 @@ class BrowseRow extends StatelessWidget { Widget build(BuildContext context) { final accent = Theme.of(context).colorScheme.primary; final isDone = downloadStatus == DownloadStatus.done; - final isActive = - downloadStatus == DownloadStatus.queued || + final isActive = downloadStatus == DownloadStatus.queued || downloadStatus == DownloadStatus.downloading; return InkWell( onTap: onTap, child: Container( - constraints: const BoxConstraints( - minHeight: TimbreSpacing.minTouchTarget, - ), + constraints: + const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget), padding: const EdgeInsets.only(left: TimbreSpacing.lg), child: Row( children: [ if (artUri != null) ...[ - ArtImage( - artUri, + SizedBox( width: 40, height: 40, - fit: BoxFit.cover, - placeholder: const _AlbumArtFallback(), + child: ColoredBox( + color: TimbreColors.surface, + child: Image.network( + artUri!, + key: ValueKey(artUri), + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => const _AlbumArtFallback(), + ), + ), ), const SizedBox(width: TimbreSpacing.md), ], if (leading != null) SizedBox( width: 28, - child: Text( - leading!, - style: TextStyle(color: TimbreColors.dimmed), - ), + child: Text(leading!, + style: TextStyle(color: TimbreColors.dimmed)), ), Expanded( child: Column( @@ -740,9 +729,7 @@ class BrowseRow extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - color: TimbreColors.dimmed, - fontSize: 12, - ), + color: TimbreColors.dimmed, fontSize: 12), ), ], ), @@ -750,7 +737,8 @@ class BrowseRow extends StatelessWidget { if (isDone) Padding( padding: const EdgeInsets.only(left: TimbreSpacing.sm), - child: Icon(Icons.download_done, size: 14, color: accent), + child: + Icon(Icons.download_done, size: 14, color: accent), ) else if (isActive) const Padding( @@ -763,7 +751,8 @@ class BrowseRow extends StatelessWidget { ), if (trailing != null) ...[ const SizedBox(width: TimbreSpacing.md), - Text(trailing!, style: TextStyle(color: TimbreColors.dimmed)), + Text(trailing!, + style: TextStyle(color: TimbreColors.dimmed)), ], if (onPlayNext != null) _RowIcon( @@ -823,7 +812,8 @@ class _RowMenu extends StatelessWidget { icon: Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed), color: TimbreColors.surface, padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: TimbreSpacing.minTouchTarget), + constraints: + const BoxConstraints(minWidth: TimbreSpacing.minTouchTarget), onSelected: (v) { switch (v) { case 'playlist': @@ -839,22 +829,17 @@ class _RowMenu extends StatelessWidget { itemBuilder: (_) => [ if (onAddToPlaylist != null) const PopupMenuItem( - value: 'playlist', - child: Text('Add to playlist'), - ), + value: 'playlist', child: Text('Add to playlist')), if (onAddToTag != null) const PopupMenuItem(value: 'tag', child: Text('Add tag…')), if (isDownloaded && onRemoveDownload != null) const PopupMenuItem( - value: 'remove_download', - child: Text('Remove download'), - ) + value: 'remove_download', child: Text('Remove download')) else if (onDownload != null) PopupMenuItem( - value: 'download', - enabled: !isDownloading, - child: Text(isDownloading ? 'Downloading…' : 'Download'), - ), + value: 'download', + enabled: !isDownloading, + child: Text(isDownloading ? 'Downloading…' : 'Download')), ], ); } @@ -896,12 +881,10 @@ class _DetailScaffold extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.w700), - ), + title: Text(title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w700)), actions: actions, ), body: SafeArea(child: child), @@ -918,16 +901,12 @@ class _NotConnected extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text( - "You're offline.", - style: TextStyle(color: TimbreColors.foreground), - ), + Text('Not connected.', + style: TextStyle(color: TimbreColors.foreground)), SizedBox(height: TimbreSpacing.sm), - Text( - 'Download music to browse it here.', - textAlign: TextAlign.center, - style: TextStyle(color: TimbreColors.dimmed), - ), + Text('Tap the status bar to add a Subsonic server.', + textAlign: TextAlign.center, + style: TextStyle(color: TimbreColors.dimmed)), ], ), ); @@ -939,19 +918,19 @@ class _Centered extends StatelessWidget { final Widget child; @override Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.all(TimbreSpacing.xl), - child: Center(child: child), - ); + padding: const EdgeInsets.all(TimbreSpacing.xl), + child: Center(child: child), + ); } class _Loading extends StatelessWidget { const _Loading(); @override Widget build(BuildContext context) => const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ); + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ); } class _ErrorText extends StatelessWidget { @@ -959,10 +938,10 @@ class _ErrorText extends StatelessWidget { final String message; @override Widget build(BuildContext context) => Text( - message, - textAlign: TextAlign.center, - style: TextStyle(color: TimbreColors.dimmed), - ); + message, + textAlign: TextAlign.center, + style: TextStyle(color: TimbreColors.dimmed), + ); } String? _fmtDuration(int? seconds) { diff --git a/lib/screens/debug_screen.dart b/lib/screens/debug_screen.dart deleted file mode 100644 index 4f19c94..0000000 --- a/lib/screens/debug_screen.dart +++ /dev/null @@ -1,190 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../debug/log_store.dart'; -import '../theme/tokens.dart'; - -/// Beta-testing console: shows the app's captured `print`/`debugPrint` output -/// and uncaught errors (see [LogStore], wired up in `main`). Read-only view -/// with copy-all / clear / follow controls — no debugger needed on-device. -class DebugScreen extends StatefulWidget { - const DebugScreen({super.key}); - - @override - State createState() => _DebugScreenState(); -} - -class _DebugScreenState extends State { - static const _errorColor = Color(0xFFE06C75); - - final _controller = ScrollController(); - - /// When true, new lines keep the view pinned to the bottom (tail -f style). - /// Flipped off automatically when the user scrolls up to read history. - bool _follow = true; - - @override - void initState() { - super.initState(); - _controller.addListener(_onScroll); - LogStore.instance.addListener(_onLog); - } - - @override - void dispose() { - LogStore.instance.removeListener(_onLog); - _controller.removeListener(_onScroll); - _controller.dispose(); - super.dispose(); - } - - void _onScroll() { - if (!_controller.hasClients) return; - final atBottom = - _controller.offset >= _controller.position.maxScrollExtent - 24; - if (atBottom != _follow) setState(() => _follow = atBottom); - } - - void _onLog() { - if (!mounted) return; - setState(() {}); - if (_follow) { - WidgetsBinding.instance.addPostFrameCallback((_) => _jumpToBottom()); - } - } - - void _jumpToBottom() { - if (!_controller.hasClients) return; - _controller.jumpTo(_controller.position.maxScrollExtent); - } - - Future _copyAll() async { - await Clipboard.setData(ClipboardData(text: LogStore.instance.asText())); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Log copied to clipboard')), - ); - } - - @override - Widget build(BuildContext context) { - return Container( - color: TimbreColors.background, - child: Column( - children: [ - _header(), - Expanded( - child: ListenableBuilder( - listenable: LogStore.instance, - builder: (context, _) { - final entries = LogStore.instance.entries; - if (entries.isEmpty) { - return Center( - child: Text( - 'No output captured yet.', - style: TextStyle(color: TimbreColors.dimmed), - ), - ); - } - return Scrollbar( - controller: _controller, - child: ListView.builder( - controller: _controller, - padding: const EdgeInsets.symmetric( - horizontal: TimbreSpacing.lg, - vertical: TimbreSpacing.sm, - ), - itemCount: entries.length, - itemBuilder: (context, i) => _line(entries[i]), - ), - ); - }, - ), - ), - ], - ), - ); - } - - Widget _header() { - return Container( - decoration: BoxDecoration( - color: TimbreColors.surface, - border: Border(bottom: BorderSide(color: TimbreColors.border)), - ), - padding: const EdgeInsets.symmetric( - horizontal: TimbreSpacing.lg, - vertical: TimbreSpacing.sm, - ), - child: Row( - children: [ - Expanded( - child: ListenableBuilder( - listenable: LogStore.instance, - builder: (context, _) => Text( - 'console · ${LogStore.instance.length} lines', - style: TextStyle(color: TimbreColors.dimmed, fontSize: 12), - ), - ), - ), - _action( - _follow ? Icons.vertical_align_bottom : Icons.pause, - _follow ? 'follow' : 'paused', - () { - setState(() => _follow = !_follow); - if (_follow) _jumpToBottom(); - }, - active: _follow, - ), - _action(Icons.copy, 'copy', _copyAll), - _action(Icons.delete_outline, 'clear', LogStore.instance.clear), - ], - ), - ); - } - - Widget _action(IconData icon, String label, VoidCallback onTap, - {bool active = false}) { - final accent = Theme.of(context).colorScheme.primary; - final color = active ? accent : TimbreColors.dimmed; - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.md), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14, color: color), - const SizedBox(width: TimbreSpacing.xs), - Text(label, style: TextStyle(color: color, fontSize: 12)), - ], - ), - ), - ); - } - - Widget _line(LogEntry e) { - final isError = e.level == LogLevel.error; - return Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text.rich( - TextSpan( - children: [ - TextSpan( - text: '${e.timeLabel} ', - style: TextStyle(color: TimbreColors.dimmed, fontSize: 11), - ), - TextSpan( - text: e.text, - style: TextStyle( - color: isError ? _errorColor : TimbreColors.foreground, - fontSize: 12, - height: 1.35, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/screens/downloads_screen.dart b/lib/screens/downloads_screen.dart index 4cc762d..7b2a81d 100644 --- a/lib/screens/downloads_screen.dart +++ b/lib/screens/downloads_screen.dart @@ -4,9 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../downloads/download_manager.dart'; import '../state/providers.dart'; import '../theme/tokens.dart'; -import '../widgets/art_image.dart'; import '../widgets/hairline_panel.dart'; -import '../widgets/toast.dart'; /// Manage offline downloads: what's saved, how much space it uses, and any /// in-flight transfers. Tapping a completed track plays it. @@ -21,33 +19,25 @@ class DownloadsScreen extends ConsumerWidget { final active = downloads.byId.values.where((d) => d.isActive).toList(); final completed = downloads.completed; - // Ordered play list — index i here matches the i-th rendered saved row. - final savedSongs = completed.map((d) => d.song).toList(); return Scaffold( appBar: AppBar( - title: const Text( - 'Downloads', - style: TextStyle(fontWeight: FontWeight.w700), - ), + title: const Text('Downloads', + style: TextStyle(fontWeight: FontWeight.w700)), actions: [ if (completed.isNotEmpty) TextButton( onPressed: () => _confirmClear(context, controller), - child: Text( - 'Clear all', - style: TextStyle(color: TimbreColors.dimmed), - ), + child: Text('Clear all', + style: TextStyle(color: TimbreColors.dimmed)), ), ], ), body: SafeArea( child: (active.isEmpty && completed.isEmpty) ? Center( - child: Text( - 'No downloads yet.', - style: TextStyle(color: TimbreColors.dimmed), - ), + child: Text('No downloads yet.', + style: TextStyle(color: TimbreColors.dimmed)), ) : ListView( padding: const EdgeInsets.all(TimbreSpacing.lg), @@ -57,10 +47,11 @@ class DownloadsScreen extends ConsumerWidget { title: 'Downloading', trailing: '(${active.length})', padding: const EdgeInsets.symmetric( - vertical: TimbreSpacing.md, - ), + vertical: TimbreSpacing.md), child: Column( - children: [for (final d in active) _ActiveRow(info: d)], + children: [ + for (final d in active) _ActiveRow(info: d), + ], ), ), const SizedBox(height: TimbreSpacing.xl), @@ -71,68 +62,21 @@ class DownloadsScreen extends ConsumerWidget { trailing: completed.isEmpty ? null : '${completed.length} · ${_fmtBytes(downloads.totalBytes)}', - action: Row( - mainAxisSize: MainAxisSize.min, - children: [ - InkWell( - onTap: savedSongs.isEmpty - ? null - : () => playback.playSongs(savedSongs), - child: Padding( - padding: const EdgeInsets.all(TimbreSpacing.xs), - child: Icon( - Icons.play_arrow, - size: 18, - color: TimbreColors.dimmed, - ), - ), - ), - InkWell( - onTap: savedSongs.isEmpty - ? null - : () { - playback.toggleShuffle(); - playback.playSongs(savedSongs); - }, - child: Padding( - padding: const EdgeInsets.all(TimbreSpacing.xs), - child: Icon( - Icons.shuffle, - size: 18, - color: TimbreColors.dimmed, - ), - ), - ), - ], - ), - padding: const EdgeInsets.symmetric( - vertical: TimbreSpacing.md, - ), + padding: + const EdgeInsets.symmetric(vertical: TimbreSpacing.md), child: completed.isEmpty ? Padding( padding: EdgeInsets.all(TimbreSpacing.lg), - child: Text( - 'Nothing saved for offline yet.', - style: TextStyle(color: TimbreColors.dimmed), - ), + child: Text('Nothing saved for offline yet.', + style: TextStyle(color: TimbreColors.dimmed)), ) : Column( children: [ - for (final (i, d) in completed.indexed) + for (final d in completed) _SavedRow( info: d, - artUri: resolveArtUriW( - ref, - coverArt: d.song.coverArt, - size: 128, - )?.toString(), - onPlay: () => playback.playSongs( - savedSongs, - startIndex: i, - ), - onPlayNext: () => playback.playNext(d.song), - onAddToQueue: () => - playback.addToQueue(d.song), + onPlay: () => + playback.playSongs([d.song]), onRemove: () => controller.remove(d.song.id), ), ], @@ -145,26 +89,21 @@ class DownloadsScreen extends ConsumerWidget { } Future _confirmClear( - BuildContext context, - DownloadController controller, - ) async { + BuildContext context, DownloadController controller) async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: TimbreColors.surface, title: const Text('Remove all downloads?'), content: const Text( - 'This deletes every saved file for this server. It cannot be undone.', - ), + 'This deletes every saved file for this server. It cannot be undone.'), actions: [ TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel'), - ), + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel')), TextButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Remove all'), - ), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Remove all')), ], ), ); @@ -182,27 +121,21 @@ class _ActiveRow extends StatelessWidget { final failed = info.status == DownloadStatus.failed; return Padding( padding: const EdgeInsets.symmetric( - horizontal: TimbreSpacing.lg, - vertical: TimbreSpacing.xs, - ), + horizontal: TimbreSpacing.lg, vertical: TimbreSpacing.xs), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - info.song.title ?? 'Untitled', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: TimbreColors.foreground), - ), + Text(info.song.title ?? 'Untitled', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: TimbreColors.foreground)), const SizedBox(height: TimbreSpacing.xs), if (failed) - const Text( - 'Failed', - style: TextStyle(color: Color(0xFFE06C75), fontSize: 12), - ) + const Text('Failed', + style: TextStyle(color: Color(0xFFE06C75), fontSize: 12)) else LinearProgressIndicator( value: info.progress > 0 ? info.progress : null, @@ -229,55 +162,35 @@ class _ActiveRow extends StatelessWidget { class _SavedRow extends StatelessWidget { const _SavedRow({ required this.info, - required this.artUri, required this.onPlay, - required this.onPlayNext, - required this.onAddToQueue, required this.onRemove, }); final DownloadInfo info; - - /// Resolved cover-art URI (downloaded art is local, so it shows offline). - final String? artUri; final VoidCallback onPlay; - final VoidCallback onPlayNext; - final VoidCallback onAddToQueue; final VoidCallback onRemove; @override Widget build(BuildContext context) { - final quality = - info.format ?? + final quality = info.format ?? (info.bitRate != null ? '${info.bitRate} kbps' : 'Original'); return InkWell( onTap: onPlay, child: Container( - constraints: const BoxConstraints( - minHeight: TimbreSpacing.minTouchTarget, - ), + constraints: + const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget), padding: const EdgeInsets.only(left: TimbreSpacing.lg), child: Row( children: [ - ArtImage( - artUri, - width: 40, - height: 40, - fit: BoxFit.cover, - borderRadius: BorderRadius.circular(4), - ), - const SizedBox(width: TimbreSpacing.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - info.song.title ?? 'Untitled', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: TimbreColors.foreground), - ), + Text(info.song.title ?? 'Untitled', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: TimbreColors.foreground)), Text( [ info.song.artist, @@ -285,37 +198,21 @@ class _SavedRow extends StatelessWidget { ].where((e) => e != null && e.isNotEmpty).join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: TimbreColors.dimmed, fontSize: 12), + style: + TextStyle(color: TimbreColors.dimmed, fontSize: 12), ), ], ), ), - PopupMenuButton( - icon: Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed), - color: TimbreColors.surface, - onSelected: (v) { - switch (v) { - case 'next': - onPlayNext(); - showToast(context, 'Playing next', icon: Icons.check); - case 'queue': - onAddToQueue(); - showToast(context, 'Added to queue', icon: Icons.check); - case 'remove': - onRemove(); - } - }, - itemBuilder: (_) => [ - const PopupMenuItem(value: 'next', child: Text('Play next')), - const PopupMenuItem( - value: 'queue', - child: Text('Add to queue'), - ), - const PopupMenuItem( - value: 'remove', - child: Text('Remove download'), - ), - ], + InkWell( + onTap: onRemove, + customBorder: const CircleBorder(), + child: SizedBox( + width: TimbreSpacing.minTouchTarget, + height: TimbreSpacing.minTouchTarget, + child: Icon(Icons.delete_outline, + size: 20, color: TimbreColors.dimmed), + ), ), ], ), diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index bd21f52..a0aabbd 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -5,7 +5,6 @@ import '../history/play_history.dart'; import '../subsonic/models.dart'; import '../state/providers.dart'; import '../theme/tokens.dart'; -import '../widgets/art_image.dart'; import '../widgets/block_progress_bar.dart'; import 'browser_screen.dart'; @@ -23,7 +22,9 @@ class HomeScreen extends ConsumerWidget { final random = ref.watch(randomAlbumsProvider); String? artFor(String? coverArt, {int size = 300}) => - resolveArtUriW(ref, coverArt: coverArt, size: size)?.toString(); + (client != null && coverArt != null) + ? client.coverArtUri(coverArt, size: size).toString() + : null; return ListView( padding: const EdgeInsets.fromLTRB( @@ -54,7 +55,11 @@ class HomeScreen extends ConsumerWidget { ), // Recently Added — server discovery shelf. - _AlbumShelf(title: 'Recently Added', albums: newest, artFor: artFor), + _AlbumShelf( + title: 'Recently Added', + albums: newest, + artFor: artFor, + ), // Random — a single spotlighted album, re-rolled via the shuffle action. _RandomAlbum( @@ -76,9 +81,9 @@ class HomeScreen extends ConsumerWidget { } static void _pushAlbum(BuildContext context, String albumId) { - Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: albumId))); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => AlbumScreen(id: albumId)), + ); } } @@ -91,6 +96,7 @@ class _HeroCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final accent = Theme.of(context).colorScheme.primary; + final client = ref.watch(subsonicClientProvider); final current = ref.watch(activePlaybackProvider.select((s) => s.current)); // Fall back to the most recent track so the hero is useful before playback. @@ -98,11 +104,9 @@ class _HeroCard extends ConsumerWidget { final PlayRecord? fallback = recent.isEmpty ? null : recent.first; final String? coverArt = current?.coverArt ?? fallback?.coverArt; - final artUri = resolveArtUriW( - ref, - coverArt: coverArt, - size: 240, - )?.toString(); + final artUri = (client != null && coverArt != null) + ? client.coverArtUri(coverArt, size: 240).toString() + : null; final title = current?.title ?? fallback?.title; final subtitle = current?.artist ?? fallback?.artist; @@ -114,17 +118,12 @@ class _HeroCard extends ConsumerWidget { onTap: () => ref.read(selectedTabProvider.notifier).state = 1, child: Row( children: [ - Icon( - Icons.library_music_outlined, - color: TimbreColors.dimmed, - size: 40, - ), + Icon(Icons.library_music_outlined, + color: TimbreColors.dimmed, size: 40), const SizedBox(width: TimbreSpacing.lg), Expanded( - child: Text( - 'Browse your library to start listening', - style: TextStyle(color: TimbreColors.foreground), - ), + child: Text('Browse your library to start listening', + style: TextStyle(color: TimbreColors.foreground)), ), ], ), @@ -146,7 +145,18 @@ class _HeroCard extends ConsumerWidget { SizedBox( width: 64, height: 64, - child: ArtImage(artUri, fit: BoxFit.cover), + child: ColoredBox( + color: TimbreColors.surface, + child: artUri != null + ? Image.network(artUri, + key: ValueKey(artUri), + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => Icon( + Icons.album_outlined, color: TimbreColors.dimmed)) + : Icon(Icons.album_outlined, + color: TimbreColors.dimmed), + ), ), const SizedBox(width: TimbreSpacing.lg), Expanded( @@ -156,40 +166,29 @@ class _HeroCard extends ConsumerWidget { children: [ Row( children: [ - Icon( - hasCurrent ? Icons.play_arrow : Icons.history, - size: 14, - color: accent, - ), + Icon(hasCurrent ? Icons.play_arrow : Icons.history, + size: 14, color: accent), const SizedBox(width: TimbreSpacing.xs), - Text( - hasCurrent ? 'NOW PLAYING' : 'RESUME', - style: TextStyle( - color: accent, - fontSize: 11, - letterSpacing: 1, - fontWeight: FontWeight.w700, - ), - ), + Text(hasCurrent ? 'NOW PLAYING' : 'RESUME', + style: TextStyle( + color: accent, + fontSize: 11, + letterSpacing: 1, + fontWeight: FontWeight.w700)), ], ), const SizedBox(height: TimbreSpacing.xs), - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: TimbreColors.foreground, - fontWeight: FontWeight.w700, - ), - ), - if (subtitle != null) - Text( - subtitle, + Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: TimbreColors.dimmed), - ), + style: TextStyle( + color: TimbreColors.foreground, + fontWeight: FontWeight.w700)), + if (subtitle != null) + Text(subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: TimbreColors.dimmed)), if (hasCurrent) ...[ const SizedBox(height: TimbreSpacing.md), const _HeroProgress(), @@ -211,19 +210,14 @@ class _HeroProgress extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final progress = ref.watch( - activePlaybackProvider.select((s) => s.progress), - ); + final progress = ref.watch(activePlaybackProvider.select((s) => s.progress)); return BlockProgressBar(progress: progress, cells: 32, height: 6); } } class _HeroShell extends StatelessWidget { - const _HeroShell({ - required this.child, - required this.accent, - required this.onTap, - }); + const _HeroShell( + {required this.child, required this.accent, required this.onTap}); final Widget child; final Color accent; @@ -288,14 +282,11 @@ class _ShelfHeader extends StatelessWidget { Widget build(BuildContext context) { return Row( children: [ - Text( - title, - style: TextStyle( - color: TimbreColors.foreground, - fontWeight: FontWeight.w700, - letterSpacing: 0.5, - ), - ), + Text(title, + style: TextStyle( + color: TimbreColors.foreground, + fontWeight: FontWeight.w700, + letterSpacing: 0.5)), const Spacer(), if (onShuffle != null) InkWell( @@ -330,11 +321,7 @@ class _AlbumShelf extends StatelessWidget { return albums.when( loading: () => _Shelf( title: title, - cards: const [ - _ArtCardSkeleton(), - _ArtCardSkeleton(), - _ArtCardSkeleton(), - ], + cards: const [_ArtCardSkeleton(), _ArtCardSkeleton(), _ArtCardSkeleton()], ), error: (_, _) => const SizedBox.shrink(), data: (list) => _Shelf( @@ -345,9 +332,9 @@ class _AlbumShelf extends StatelessWidget { artUri: artFor(a.coverArt), title: a.name ?? 'Unknown album', subtitle: a.artist, - onTap: () => Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id))), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id)), + ), ), ], ), @@ -391,9 +378,9 @@ class _RandomAlbum extends StatelessWidget { const _RandomSkeleton() else InkWell( - onTap: () => Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id))), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id)), + ), child: _RandomBody(album: a, artUri: artFor(a.coverArt)), ), ], @@ -415,11 +402,20 @@ class _RandomBody extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(4), - child: ArtImage( - artUri, - fit: BoxFit.cover, + child: SizedBox( width: _RandomAlbum._size, height: _RandomAlbum._size, + child: ColoredBox( + color: TimbreColors.surface, + child: artUri != null + ? Image.network(artUri!, + key: ValueKey(artUri), + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => Icon( + Icons.album_outlined, color: TimbreColors.dimmed)) + : Icon(Icons.album_outlined, color: TimbreColors.dimmed), + ), ), ), const SizedBox(width: TimbreSpacing.lg), @@ -428,35 +424,28 @@ class _RandomBody extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text( - album.name ?? 'Unknown album', - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: TimbreColors.foreground, - fontWeight: FontWeight.w700, - ), - ), + Text(album.name ?? 'Unknown album', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: TimbreColors.foreground, + fontWeight: FontWeight.w700)), const SizedBox(height: TimbreSpacing.xs), if (album.artist != null) - Text( - album.artist!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: TimbreColors.dimmed), - ), + Text(album.artist!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: TimbreColors.dimmed)), if (album.year != null) - Text( - '${album.year}', - style: TextStyle(color: TimbreColors.dimmed, fontSize: 12), - ), + Text('${album.year}', + style: TextStyle( + color: TimbreColors.dimmed, fontSize: 12)), if (album.genre != null) - Text( - album.genre!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: TimbreColors.dimmed, fontSize: 12), - ), + Text(album.genre!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: TimbreColors.dimmed, fontSize: 12)), ], ), ), @@ -508,27 +497,34 @@ class _ArtCard extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(4), - child: ArtImage( - artUri, - fit: BoxFit.cover, + child: SizedBox( width: _size, height: _size, + child: ColoredBox( + color: TimbreColors.surface, + child: artUri != null + ? Image.network(artUri!, + key: ValueKey(artUri), + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => Icon( + Icons.album_outlined, color: TimbreColors.dimmed)) + : Icon(Icons.album_outlined, + color: TimbreColors.dimmed), + ), ), ), const SizedBox(height: TimbreSpacing.sm), - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: TimbreColors.foreground), - ), - if (subtitle != null) - Text( - subtitle!, + Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: TimbreColors.dimmed, fontSize: 12), - ), + style: TextStyle(color: TimbreColors.foreground)), + if (subtitle != null) + Text(subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: + TextStyle(color: TimbreColors.dimmed, fontSize: 12)), ], ), ), diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 3706351..68ee04b 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -10,7 +10,6 @@ import '../state/providers.dart'; import '../state/remote_providers.dart'; import '../subsonic/models.dart'; import '../theme/tokens.dart'; -import '../widgets/art_image.dart'; import '../widgets/block_progress_bar.dart'; import '../widgets/cassette_view.dart'; import '../widgets/hairline_panel.dart'; @@ -58,25 +57,20 @@ class _NowPlayingScreenState extends ConsumerState { final current = state.current; // Re-seed the favorites store whenever the track changes. - ref.listen( - activePlaybackProvider.select((s) => s.current?.id), - (_, _) => _seedFavorites(), - ); + ref.listen(activePlaybackProvider.select((s) => s.current?.id), + (_, _) => _seedFavorites()); if (current == null) { return Center( - child: Text( - 'Nothing playing.', - style: TextStyle(color: TimbreColors.dimmed), - ), + child: Text('Nothing playing.', + style: TextStyle(color: TimbreColors.dimmed)), ); } // Cross-device control is offered next to the queue toggle (compact) or // beneath the transport (wide); hidden where the platform can't host/browse. - final remoteSupported = ref.watch( - remoteControlProvider.select((s) => s.supported), - ); + final remoteSupported = + ref.watch(remoteControlProvider.select((s) => s.supported)); // Everything below the art region — shared by both layouts. The queue // toggle is deliberately excluded: it belongs only to the compact layout @@ -294,10 +288,8 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> { if (index == null || index < 0) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!_controller.hasClients) return; - final target = (index * _rowExtent).clamp( - 0.0, - _controller.position.maxScrollExtent, - ); + final target = + (index * _rowExtent).clamp(0.0, _controller.position.maxScrollExtent); _controller.animateTo( target, duration: const Duration(milliseconds: 300), @@ -383,9 +375,8 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> { overflow: TextOverflow.ellipsis, style: TextStyle( color: titleColor, - fontWeight: isCurrent - ? FontWeight.w700 - : FontWeight.w400, + fontWeight: + isCurrent ? FontWeight.w700 : FontWeight.w400, ), ), if (song.artist != null) @@ -401,21 +392,17 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> { ], ), ), - Text( - _fmt(song.duration), - style: TextStyle(color: TimbreColors.dimmed), - ), + Text(_fmt(song.duration), + style: TextStyle(color: TimbreColors.dimmed)), InkWell( - onTap: () => ref.read(playbackCommandsProvider).removeAt(i), + onTap: () => + ref.read(playbackCommandsProvider).removeAt(i), customBorder: const CircleBorder(), child: SizedBox( width: TimbreSpacing.minTouchTarget, height: TimbreSpacing.minTouchTarget, - child: Icon( - Icons.close, - size: 18, - color: TimbreColors.dimmed, - ), + child: Icon(Icons.close, + size: 18, color: TimbreColors.dimmed), ), ), ], @@ -443,9 +430,8 @@ class _FittedArt extends StatelessWidget { alignment: Alignment.topCenter, child: LayoutBuilder( builder: (context, c) { - final side = c.maxHeight.isFinite - ? c.maxHeight.clamp(0.0, c.maxWidth) - : c.maxWidth; + final side = + c.maxHeight.isFinite ? c.maxHeight.clamp(0.0, c.maxWidth) : c.maxWidth; return SizedBox( width: side, height: side, @@ -466,17 +452,14 @@ class _AlbumArtPanel extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final coverArt = ref.watch( - activePlaybackProvider.select((s) => s.current?.coverArt), - ); - final cassette = ref.watch( - settingsProvider.select((s) => s.nowPlayingCassette), - ); - final artUri = resolveArtUriW( - ref, - coverArt: coverArt, - size: 512, - )?.toString(); + final coverArt = + ref.watch(activePlaybackProvider.select((s) => s.current?.coverArt)); + final client = ref.watch(subsonicClientProvider); + final cassette = + ref.watch(settingsProvider.select((s) => s.nowPlayingCassette)); + final artUri = (client != null && coverArt != null) + ? client.coverArtUri(coverArt, size: 512).toString() + : null; return HairlinePanel( title: cassette ? 'Cassette' : 'Album Art', @@ -485,10 +468,17 @@ class _AlbumArtPanel extends ConsumerWidget { ? Center(child: CassetteView(artUri: artUri)) : AspectRatio( aspectRatio: 1, - child: ArtImage( - artUri, - fit: BoxFit.cover, - placeholder: const _ArtFallback(), + child: ColoredBox( + color: TimbreColors.surface, + child: artUri != null + ? Image.network( + artUri, + key: ValueKey(artUri), + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => const _ArtFallback(), + ) + : const _ArtFallback(), ), ), ); @@ -507,16 +497,13 @@ class _FavRating extends ConsumerWidget { final fav = ref.watch(favoritesProvider); final accent = Theme.of(context).colorScheme.primary; final starred = fav.isSongStarred(song.id); - final rating = fav.ratingFor(song.id) != 0 - ? fav.ratingFor(song.id) - : (song.userRating ?? 0); + final rating = + fav.ratingFor(song.id) != 0 ? fav.ratingFor(song.id) : (song.userRating ?? 0); - final downloadStatus = ref.watch( - downloadManagerProvider.select((s) => s.byId[song.id]?.status), - ); + final downloadStatus = + ref.watch(downloadManagerProvider.select((s) => s.byId[song.id]?.status)); final isDownloaded = downloadStatus == DownloadStatus.done; - final isDownloading = - downloadStatus == DownloadStatus.queued || + final isDownloading = downloadStatus == DownloadStatus.queued || downloadStatus == DownloadStatus.downloading; return Row( @@ -554,11 +541,8 @@ class _FavRating extends ConsumerWidget { customBorder: const CircleBorder(), child: Padding( padding: const EdgeInsets.all(TimbreSpacing.sm), - child: Icon( - Icons.playlist_add, - size: 22, - color: TimbreColors.dimmed, - ), + child: Icon(Icons.playlist_add, + size: 22, color: TimbreColors.dimmed), ), ), InkWell( @@ -653,16 +637,12 @@ class _InfoStrip extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - song.title ?? 'Untitled', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: accent, fontWeight: FontWeight.w700), - ), - Text( - song.artist ?? 'Unknown artist', - style: TextStyle(color: TimbreColors.foreground), - ), + Text(song.title ?? 'Untitled', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: accent, fontWeight: FontWeight.w700)), + Text(song.artist ?? 'Unknown artist', + style: TextStyle(color: TimbreColors.foreground)), if (album.isNotEmpty) Text(album, style: TextStyle(color: TimbreColors.dimmed)), ], @@ -679,35 +659,29 @@ class _NowPlayingProgress extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final position = ref.watch( - activePlaybackProvider.select((s) => s.position), - ); - final duration = ref.watch( - activePlaybackProvider.select((s) => s.effectiveDuration), - ); - final progress = ref.watch( - activePlaybackProvider.select((s) => s.progress), - ); + final position = ref.watch(activePlaybackProvider.select((s) => s.position)); + final duration = + ref.watch(activePlaybackProvider.select((s) => s.effectiveDuration)); + final progress = ref.watch(activePlaybackProvider.select((s) => s.progress)); return Row( children: [ - Text(_fmtDur(position), style: TextStyle(color: TimbreColors.dimmed)), + Text(_fmtDur(position), + style: TextStyle(color: TimbreColors.dimmed)), const SizedBox(width: TimbreSpacing.md), Expanded( child: BlockProgressBar(progress: progress, cells: 28, height: 18), ), const SizedBox(width: TimbreSpacing.md), - Text(_fmtDur(duration), style: TextStyle(color: TimbreColors.dimmed)), + Text(_fmtDur(duration), + style: TextStyle(color: TimbreColors.dimmed)), ], ); } } class _Transport extends StatelessWidget { - const _Transport({ - required this.state, - required this.ref, - required this.accent, - }); + const _Transport( + {required this.state, required this.ref, required this.accent}); final PlaybackState state; final WidgetRef ref; @@ -723,34 +697,21 @@ class _Transport extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _btn( - Icons.shuffle, - controller.toggleShuffle, - color: state.shuffle ? accent : TimbreColors.dimmed, - ), + _btn(Icons.shuffle, controller.toggleShuffle, + color: state.shuffle ? accent : TimbreColors.dimmed), _btn(Icons.skip_previous, controller.previous), - _btn( - state.playing ? Icons.pause : Icons.play_arrow, - controller.togglePlayPause, - color: accent, - size: 40, - ), + _btn(state.playing ? Icons.pause : Icons.play_arrow, + controller.togglePlayPause, + color: accent, size: 40), _btn(Icons.skip_next, controller.next), - _btn( - loopIcon, - controller.cycleLoop, - color: state.loop != LoopMode.off ? accent : TimbreColors.dimmed, - ), + _btn(loopIcon, controller.cycleLoop, + color: state.loop != LoopMode.off ? accent : TimbreColors.dimmed), ], ); } - Widget _btn( - IconData icon, - VoidCallback onTap, { - Color? color, - double size = 28, - }) { + Widget _btn(IconData icon, VoidCallback onTap, + {Color? color, double size = 28}) { return IconButton( onPressed: onTap, icon: Icon(icon, color: color ?? TimbreColors.foreground, size: size), @@ -762,8 +723,9 @@ class _ArtFallback extends StatelessWidget { const _ArtFallback(); @override Widget build(BuildContext context) => Center( - child: Icon(Icons.album_outlined, color: TimbreColors.dimmed, size: 48), - ); + child: Icon(Icons.album_outlined, + color: TimbreColors.dimmed, size: 48), + ); } String _fmt(int? seconds) { diff --git a/lib/state/providers.dart b/lib/state/providers.dart index d89f7f9..bd1fb69 100644 --- a/lib/state/providers.dart +++ b/lib/state/providers.dart @@ -1,14 +1,10 @@ -import 'dart:io'; - -import 'package:flutter/widgets.dart' - show FileImage, ImageProvider, NetworkImage; +import 'package:flutter/widgets.dart' show NetworkImage; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../downloads/download_manager.dart'; import '../history/play_history.dart'; import '../library/browse_query.dart'; import '../library/library_index.dart'; -import '../library/offline_library.dart'; import '../playback/playback_engine.dart'; import '../playlists/playlists.dart'; import '../settings/settings_store.dart'; @@ -65,16 +61,15 @@ class ConnectionState { ); } -final credentialStoreProvider = Provider( - (_) => CredentialStore(), -); +final credentialStoreProvider = + Provider((_) => CredentialStore()); /// Owns the active server connection and the list of saved servers: builds the /// client, pings, persists credentials, auto-restores on launch, and switches /// between servers (one active at a time — TODO #2). class ConnectionController extends StateNotifier { ConnectionController(this._store) - : super(const ConnectionState(status: ConnStatus.disconnected)) { + : super(const ConnectionState(status: ConnStatus.disconnected)) { _restore(); } @@ -211,10 +206,8 @@ class ConnectionController extends StateNotifier { } } - Future _addOrUpdate( - SubsonicCredentials creds, { - required bool makeActive, - }) async { + Future _addOrUpdate(SubsonicCredentials creds, + {required bool makeActive}) async { final idx = _servers.indexWhere((s) => s.id == creds.id); final next = [..._servers]; if (idx >= 0) { @@ -240,8 +233,8 @@ class ConnectionController extends StateNotifier { final connectionProvider = StateNotifierProvider( - (ref) => ConnectionController(ref.watch(credentialStoreProvider)), - ); + (ref) => ConnectionController(ref.watch(credentialStoreProvider)), +); /// The active client, or null when not connected. final subsonicClientProvider = Provider( @@ -271,20 +264,9 @@ final browseModeProvider = StateProvider((_) => BrowseMode.artists); // ---- Library ------------------------------------------------------------ -/// The downloaded tracks as full [Song]s — the single source that backs every -/// offline browse view so Albums / Artists / Tracks stay consistent. -final downloadedSongsProvider = Provider>( - (ref) => - ref.watch(downloadManagerProvider).completed.map((d) => d.song).toList(), -); - final artistsProvider = FutureProvider>((ref) async { final client = ref.watch(subsonicClientProvider); - // Offline: synthesize the artist list from what's downloaded, so it - // repopulates as downloads complete and recomputes on connect/disconnect. - if (client == null) { - return artistsFromSongs(ref.watch(downloadedSongsProvider)); - } + if (client == null) return const []; final result = await client.getArtists(); return result.all; }); @@ -293,11 +275,7 @@ final artistsProvider = FutureProvider>((ref) async { /// truncated. Backs the Albums cover-art grid. final albumsProvider = FutureProvider>((ref) async { final client = ref.watch(subsonicClientProvider); - // Offline: synthesize the album grid from downloaded songs (watched - // synchronously up-front, before any await, so it recomputes as they land). - if (client == null) { - return albumsFromSongs(ref.watch(downloadedSongsProvider)); - } + if (client == null) return const []; const pageSize = 500; final all = []; var offset = 0; @@ -314,26 +292,23 @@ final albumsProvider = FutureProvider>((ref) async { /// (and any in-flight build cancelled) whenever the server changes. final libraryIndexProvider = StateNotifierProvider((ref) { - final controller = LibraryIndexController( - () => ref.read(subsonicClientProvider), - ); - ref.listen(connectionProvider, (_, _) { - controller.onConnectionChanged(); - }); - return controller; - }); + final controller = + LibraryIndexController(() => ref.read(subsonicClientProvider)); + ref.listen(connectionProvider, (_, _) { + controller.onConnectionChanged(); + }); + return controller; +}); // ---- Browse filtering / sorting ----------------------------------------- /// Session-only genre/year filter for the Albums grid (resets on restart). -final albumFilterProvider = StateProvider( - (_) => const BrowseFilter(), -); +final albumFilterProvider = + StateProvider((_) => const BrowseFilter()); /// Session-only genre/year filter for the Tracks list (resets on restart). -final trackFilterProvider = StateProvider( - (_) => const BrowseFilter(), -); +final trackFilterProvider = + StateProvider((_) => const BrowseFilter()); /// Distinct genres present across all albums, for the album genre picker. final albumGenresProvider = Provider>((ref) { @@ -357,36 +332,23 @@ final visibleAlbumsProvider = Provider>>((ref) { .whenData((albums) => applyAlbumQuery(albums, filter, sort)); }); -/// Distinct genres present across all indexed tracks. Falls back to the -/// downloaded songs when offline (the crawled index is wiped without a server). +/// Distinct genres present across all indexed tracks. final trackGenresProvider = Provider>((ref) { - final offline = ref.watch(subsonicClientProvider) == null; - final songs = offline - ? ref.watch(downloadedSongsProvider) - : ref.watch(libraryIndexProvider).songs; + final songs = ref.watch(libraryIndexProvider).songs; return distinctGenres(songs.map((s) => s.genre)); }); /// Distinct release years present across all indexed tracks, newest first. -/// Falls back to the downloaded songs when offline. final trackYearsProvider = Provider>((ref) { - final offline = ref.watch(subsonicClientProvider) == null; - final songs = offline - ? ref.watch(downloadedSongsProvider) - : ref.watch(libraryIndexProvider).songs; + final songs = ref.watch(libraryIndexProvider).songs; return distinctYears(songs.map((s) => s.year)); }); /// Indexed tracks after applying the session filter and the persisted sort. -/// When offline the crawled index is empty, so the Tracks view is backed by the -/// downloaded songs instead — the same filter/sort pipeline applies to both. final visibleTracksProvider = Provider>((ref) { final filter = ref.watch(trackFilterProvider); final sort = ref.watch(settingsProvider.select((s) => s.trackSort)); - final offline = ref.watch(subsonicClientProvider) == null; - final songs = offline - ? ref.watch(downloadedSongsProvider) - : ref.watch(libraryIndexProvider).songs; + final songs = ref.watch(libraryIndexProvider).songs; // Live ratings so the Rating sort/filter reacts to star changes immediately. final ratings = ref.watch(favoritesProvider).ratings; return applyTrackQuery(songs, filter, sort, ratings: ratings); @@ -411,32 +373,18 @@ final randomAlbumsProvider = FutureProvider>((ref) async { final artistProvider = FutureProvider.family((ref, id) async { final client = ref.watch(subsonicClientProvider); - // Offline: rebuild the artist from downloaded songs. [id] is whatever - // [artistsFromSongs] produced (real id or name), so it's passed straight - // through. Keep throwing when absent so the FutureProvider error state works. - if (client == null) { - final artist = artistFromSongs(ref.watch(downloadedSongsProvider), id); - if (artist == null) throw StateError('Not found offline'); - return artist; - } + if (client == null) throw StateError('Not connected'); return client.getArtist(id); }); final albumProvider = FutureProvider.family((ref, id) async { final client = ref.watch(subsonicClientProvider); - // Offline: rebuild the album from downloaded songs (see [artistProvider]). - if (client == null) { - final album = albumFromSongs(ref.watch(downloadedSongsProvider), id); - if (album == null) throw StateError('Not found offline'); - return album; - } + if (client == null) throw StateError('Not connected'); return client.getAlbum(id); }); -final searchProvider = FutureProvider.family(( - ref, - query, -) async { +final searchProvider = + FutureProvider.family((ref, query) async { final client = ref.watch(subsonicClientProvider); final q = query.trim(); if (client == null || q.isEmpty) { @@ -446,9 +394,7 @@ final searchProvider = FutureProvider.family(( // "Standard" trims the server's broad matches down to name/title hits; // "Discovery" (default) returns the server result unchanged. final mode = ref.watch(settingsProvider).searchMode; - return mode == SearchMode.standard - ? filterSearchToStandard(result, q) - : result; + return mode == SearchMode.standard ? filterSearchToStandard(result, q) : result; }); /// Narrows a [SearchResult3] to items whose *own* name/title contains [query] @@ -468,8 +414,8 @@ SearchResult3 filterSearchToStandard(SearchResult3 r, String query) { final playHistoryProvider = StateNotifierProvider>( - (ref) => HistoryController(), - ); + (ref) => HistoryController(), +); final recentSongsProvider = Provider>( (ref) => recentSongs(ref.watch(playHistoryProvider)), @@ -492,19 +438,18 @@ final rediscoverProvider = Provider>((ref) { final favoritesProvider = StateNotifierProvider((ref) { - final controller = FavoritesController( - () => ref.read(subsonicClientProvider), - ); - // Re-hydrate on connect, clear on disconnect. - ref.listen(connectionProvider, (prev, next) { - if (next.isOnline) { - controller.hydrate(); - } else { - controller.clear(); - } - }); - return controller; - }); + final controller = + FavoritesController(() => ref.read(subsonicClientProvider)); + // Re-hydrate on connect, clear on disconnect. + ref.listen(connectionProvider, (prev, next) { + if (next.isOnline) { + controller.hydrate(); + } else { + controller.clear(); + } + }); + return controller; +}); /// Full starred set for the Favorites screen. final starredProvider = FutureProvider((ref) async { @@ -523,21 +468,21 @@ final starredProvider = FutureProvider((ref) async { /// their status. Reloads its manifest whenever the server key changes. final downloadManagerProvider = StateNotifierProvider((ref) { - final controller = DownloadController( - clientGetter: () => ref.read(subsonicClientProvider), - settingsGetter: () => ref.read(settingsProvider), - serverKeyGetter: () => ref.read(serverKeyProvider), - ); - ref.listen(serverKeyProvider, (_, _) { - controller.reloadForServer(); - }); - // Raising the concurrency cap should launch queued downloads right away. - ref.listen( - settingsProvider.select((s) => s.maxConcurrentDownloads), - (_, _) => controller.onConcurrencyChanged(), - ); - return controller; - }); + final controller = DownloadController( + clientGetter: () => ref.read(subsonicClientProvider), + settingsGetter: () => ref.read(settingsProvider), + serverKeyGetter: () => ref.read(serverKeyProvider), + ); + ref.listen(serverKeyProvider, (_, _) { + controller.reloadForServer(); + }); + // Raising the concurrency cap should launch queued downloads right away. + ref.listen( + settingsProvider.select((s) => s.maxConcurrentDownloads), + (_, _) => controller.onConcurrencyChanged(), + ); + return controller; +}); // ---- Playlists ---------------------------------------------------------- @@ -545,38 +490,34 @@ final downloadManagerProvider = /// the server key changes (connect / disconnect / server switch). final playlistsProvider = StateNotifierProvider((ref) { - final controller = PlaylistsController( - clientGetter: () => ref.read(subsonicClientProvider), - serverKeyGetter: () => ref.read(serverKeyProvider), - ); - ref.listen(serverKeyProvider, (_, _) { - controller.reloadForServer(); - }); - return controller; - }); + final controller = PlaylistsController( + clientGetter: () => ref.read(subsonicClientProvider), + serverKeyGetter: () => ref.read(serverKeyProvider), + ); + ref.listen(serverKeyProvider, (_, _) { + controller.reloadForServer(); + }); + return controller; +}); /// User-facing playlists — everything *not* marked as a Timbre tag. Backs the /// Playlists screen and the "add to playlist" sheet. -final realPlaylistsProvider = Provider>( - (ref) => ref - .watch(playlistsProvider) - .playlists - .where((p) => !isTagPlaylist(p)) - .toList(), -); +final realPlaylistsProvider = Provider>((ref) => ref + .watch(playlistsProvider) + .playlists + .where((p) => !isTagPlaylist(p)) + .toList()); /// Tags — playlists carrying the tag comment marker. Backs the Tags screen and /// the "add tag" sheet. Same underlying store as [playlistsProvider]; only the /// partition differs. final tagsProvider = Provider>( - (ref) => ref.watch(playlistsProvider).playlists.where(isTagPlaylist).toList(), -); + (ref) => ref.watch(playlistsProvider).playlists.where(isTagPlaylist).toList()); /// The signed-in user's name on the active server, or null when disconnected. /// Used to split owned playlists from ones shared by other users. final currentUsernameProvider = Provider( - (ref) => ref.watch(connectionProvider).credentials?.username, -); + (ref) => ref.watch(connectionProvider).credentials?.username); /// The user's own playlists (owned, or owner unknown). Backs the main list and /// the "add to playlist" sheet — you can only add tracks to your own playlists. @@ -597,53 +538,10 @@ final sharedPlaylistsProvider = Provider>((ref) { .toList(); }); -// ---- Art resolution ----------------------------------------------------- - -/// Shared implementation for the art resolvers below. Takes the two things it -/// needs as plain values so it can serve both provider (`Ref`) and widget -/// (`WidgetRef`) callers, which share no common ref supertype in Riverpod 2.x. -Uri? _resolveArt( - String? Function(String?) localArtPathFor, - SubsonicClient? client, { - String? coverArt, - int size = 512, -}) { - if (coverArt == null) return null; - final local = localArtPathFor(coverArt); - if (local != null) return Uri.file(local); - if (client == null) return null; - return client.coverArtUri(coverArt, size: size); -} - -/// Resolves the best art URI for a cover-art id: a cached local file when the -/// track is downloaded, else the server URL when online, else null (offline & -/// uncached → callers show a placeholder). -/// -/// Provider-side entry point (playback closures, other providers have a [Ref]). -/// Widgets, which hold a `WidgetRef`, use [resolveArtUriW] instead. -Uri? resolveArtUri(Ref ref, {String? coverArt, int size = 512}) => _resolveArt( - ref.read(downloadManagerProvider.notifier).localArtPathFor, - ref.read(subsonicClientProvider), - coverArt: coverArt, - size: size, -); - -/// Widget-side twin of [resolveArtUri] for callers holding a `WidgetRef` -/// (`WidgetRef` is not a [Ref] in Riverpod 2.x). Phase 3 widgets call this, -/// passing their `ref`. -Uri? resolveArtUriW(WidgetRef ref, {String? coverArt, int size = 512}) => - _resolveArt( - ref.read(downloadManagerProvider.notifier).localArtPathFor, - ref.read(subsonicClientProvider), - coverArt: coverArt, - size: size, - ); - // ---- Playback ----------------------------------------------------------- -final playbackProvider = StateNotifierProvider(( - ref, -) { +final playbackProvider = + StateNotifierProvider((ref) { // Prefer a local downloaded file when one exists (works offline / survives // service interruptions); otherwise stream at the configured bitrate. Uri? streamUriFor(Song s) { @@ -651,23 +549,17 @@ final playbackProvider = StateNotifierProvider 0, + maxBitRate: ref.read(settingsProvider).streamMaxBitRate, ); } - // Prefer the local cached art file, then fall back to the server URL — this - // makes offline art work in Now Playing / the lock screen where a downloaded - // file exists. Null only when there's no id and no local/remote source. - Uri? coverArtUriFor(Song s) => - resolveArtUri(ref, coverArt: s.coverArt, size: 512); + Uri? coverArtUriFor(Song s) { + final client = ref.read(subsonicClientProvider); + if (client == null || s.coverArt == null) return null; + return client.coverArtUri(s.coverArt!, size: 512); + } final controller = PlaybackController( streamUriFor: streamUriFor, @@ -677,12 +569,7 @@ final playbackProvider = StateNotifierProvider 0` or a [format]); original streams already carry a real - /// length. - Uri streamUri( - String id, { - int maxBitRate = 0, - String? format, - bool estimateContentLength = false, - }) => + Uri streamUri(String id, {int maxBitRate = 0, String? format}) => _uri('stream', { 'id': id, if (maxBitRate > 0) 'maxBitRate': '$maxBitRate', if (format != null && format.isNotEmpty) 'format': format, - if (estimateContentLength) 'estimateContentLength': 'true', }); /// Signed cover-art URL. [size] is clamped to Subsonic's 32–2048 range. diff --git a/lib/widgets/art_image.dart b/lib/widgets/art_image.dart deleted file mode 100644 index 13d405c..0000000 --- a/lib/widgets/art_image.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; - -import '../theme/tokens.dart'; - -/// Cover art that renders from EITHER a local file (`file://` URI) or a network -/// URL, picking [FileImage] vs [NetworkImage] purely by the URI scheme. This is -/// the single place all art-rendering call sites go through, so a downloaded -/// track's art shows offline (the caller hands us the already-built URI string; -/// resolving download-vs-network happens upstream in `providers.dart`). -/// -/// Behaviour preserved from the old scattered `Image.network(...)` call sites: -/// * `fit: BoxFit.cover`, `gaplessPlayback: true`, and a `ValueKey(uri)` so -/// switching tracks keeps the previous frame until the new art decodes (no -/// flash) and rebuilds cleanly. -/// * a surface-filled [placeholder] with a muted album icon for the null, -/// loading-error, and missing-file cases (via `errorBuilder`). -/// -/// Sizing is never hardcoded — [width]/[height]/[fit] are respected as passed. -/// Optional [borderRadius] clips the art with a [ClipRRect]; callers that pass -/// it should drop their own outer `ClipRRect` so we don't double-clip. -class ArtImage extends StatelessWidget { - const ArtImage( - this.uri, { - super.key, - this.fit = BoxFit.cover, - this.width, - this.height, - this.placeholder, - this.borderRadius, - }); - - /// The already-built art URI. A `file://` URI loads from disk; anything else - /// (http/https) loads over the network. `null` → [placeholder]. - final String? uri; - - final BoxFit fit; - final double? width; - final double? height; - - /// Shown for null/error/missing art. Defaults to [_ArtPlaceholder]. - final Widget? placeholder; - - /// If set, the art (and placeholder) are clipped to these rounded corners. - final BorderRadius? borderRadius; - - @override - Widget build(BuildContext context) { - final fallback = placeholder ?? const _ArtPlaceholder(); - - Widget child; - if (uri == null) { - child = fallback; - } else { - final parsed = Uri.tryParse(uri!); - final ImageProvider provider = (parsed != null && parsed.scheme == 'file') - ? FileImage(File(parsed.toFilePath())) - : NetworkImage(uri!); - child = Image( - image: provider, - key: ValueKey(uri), - fit: fit, - gaplessPlayback: true, - errorBuilder: (_, _, _) => fallback, - ); - } - - // Back the art with the surface fill so transparent/loading gaps read as - // a panel rather than the bare canvas (matches the old ColoredBox wrap). - child = ColoredBox(color: TimbreColors.surface, child: child); - - if (width != null || height != null) { - child = SizedBox(width: width, height: height, child: child); - } - if (borderRadius != null) { - child = ClipRRect(borderRadius: borderRadius!, child: child); - } - return child; - } -} - -/// The default art placeholder: a muted album glyph on the surface fill. Used -/// for null art and as the loading/error fallback. -class _ArtPlaceholder extends StatelessWidget { - const _ArtPlaceholder(); - - @override - Widget build(BuildContext context) => ColoredBox( - color: TimbreColors.surface, - child: Center( - child: Icon(Icons.album_outlined, color: TimbreColors.dimmed), - ), - ); -} diff --git a/lib/widgets/cassette_view.dart b/lib/widgets/cassette_view.dart index 1c408a0..3039892 100644 --- a/lib/widgets/cassette_view.dart +++ b/lib/widgets/cassette_view.dart @@ -7,7 +7,6 @@ import 'package:flutter_svg/flutter_svg.dart'; import '../state/providers.dart'; import '../theme/tokens.dart'; -import 'art_image.dart'; /// Animated cassette for the Now Playing screen. Composites, in the shell's /// `469×298` coordinate space, from back to front: @@ -45,18 +44,13 @@ class _CassetteViewState extends ConsumerState } void _onTick(Duration elapsed) { - final dt = - (elapsed - _last).inMicroseconds / Duration.microsecondsPerSecond; + final dt = (elapsed - _last).inMicroseconds / Duration.microsecondsPerSecond; _last = elapsed; if (dt <= 0) return; // Read (not watch) inside the ticker: the model drives repaints itself, and // watching here would rebuild the whole widget every position tick. final s = ref.read(activePlaybackProvider); - _model.update( - dt: dt, - playing: s.playing && s.supported, - progress: s.progress, - ); + _model.update(dt: dt, playing: s.playing && s.supported, progress: s.progress); } @override @@ -124,7 +118,8 @@ class _CassetteViewState extends ConsumerState child: AnimatedBuilder( animation: _model, child: cog, - builder: (_, child) => Transform.rotate(angle: angle(), child: child), + builder: (_, child) => + Transform.rotate(angle: angle(), child: child), ), ); } @@ -138,12 +133,15 @@ class _LabelArt extends StatelessWidget { @override Widget build(BuildContext context) { - // Square art → wide label: crop the sides/top. A plain surface fill backs - // the null/error cases (no album glyph here — the shell frames the label). - return ArtImage( - artUri, - fit: BoxFit.cover, - placeholder: ColoredBox(color: TimbreColors.surface), + if (artUri == null) { + return ColoredBox(color: TimbreColors.surface); + } + return Image.network( + artUri!, + key: ValueKey(artUri), + fit: BoxFit.cover, // square art → wide label: crop the sides/top + gaplessPlayback: true, + errorBuilder: (_, _, _) => ColoredBox(color: TimbreColors.surface), ); } } @@ -195,15 +193,9 @@ class _TapePainter extends CustomPainter { canvas.drawRect(cfg.windowRect, Paint()..color = cfg.padColor); final tape = Paint()..color = cfg.tapeColor; canvas.drawCircle( - cfg.leftReel, - cfg.radius(model.progress, supply: true), - tape, - ); + cfg.leftReel, cfg.radius(model.progress, supply: true), tape); canvas.drawCircle( - cfg.rightReel, - cfg.radius(model.progress, supply: false), - tape, - ); + cfg.rightReel, cfg.radius(model.progress, supply: false), tape); canvas.restore(); } @@ -253,8 +245,7 @@ class _CassetteConfig { /// reel is full at p=0 and empty at p=1; the take-up reel is the reverse. double radius(double progress, {required bool supply}) { final frac = (supply ? 1 - progress : progress).clamp(0.0, 1.0); - final r2 = - hubRadius * hubRadius + + final r2 = hubRadius * hubRadius + (fullRadius * fullRadius - hubRadius * hubRadius) * frac; return math.sqrt(r2); } diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart index ac2f313..11289eb 100644 --- a/lib/widgets/mini_player.dart +++ b/lib/widgets/mini_player.dart @@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../state/providers.dart'; import '../theme/tokens.dart'; -import 'art_image.dart'; /// Persistent mini-player pinned above the tab bar. Visible only while a track /// is loaded; tapping the body jumps to the Now Playing tab. @@ -24,12 +23,11 @@ class MiniPlayer extends ConsumerWidget { final playing = ref.watch(activePlaybackProvider.select((s) => s.playing)); final controller = ref.read(playbackCommandsProvider); + final client = ref.watch(subsonicClientProvider); final accent = Theme.of(context).colorScheme.primary; - final artUri = resolveArtUriW( - ref, - coverArt: current.coverArt, - size: 128, - )?.toString(); + final artUri = (client != null && current.coverArt != null) + ? client.coverArtUri(current.coverArt!, size: 128).toString() + : null; return Column( mainAxisSize: MainAxisSize.min, @@ -47,16 +45,17 @@ class MiniPlayer extends ConsumerWidget { SizedBox( width: 40, height: 40, - // Keep the mini player's darker `background` fill behind the - // art (ArtImage's own fill is `surface`) by handing it a - // background-tinted placeholder for the null/error cases. - child: ArtImage( - artUri, - fit: BoxFit.cover, - placeholder: ColoredBox( - color: TimbreColors.background, - child: const _ArtFallback(), - ), + child: ColoredBox( + color: TimbreColors.background, + child: artUri != null + ? Image.network( + artUri, + key: ValueKey(artUri), + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => const _ArtFallback(), + ) + : const _ArtFallback(), ), ), const SizedBox(width: TimbreSpacing.md), @@ -84,11 +83,9 @@ class MiniPlayer extends ConsumerWidget { ), ), _btn(Icons.skip_previous, controller.previous), - _btn( - playing ? Icons.pause : Icons.play_arrow, - controller.togglePlayPause, - color: accent, - ), + _btn(playing ? Icons.pause : Icons.play_arrow, + controller.togglePlayPause, + color: accent), _btn(Icons.skip_next, controller.next), ], ), @@ -114,9 +111,7 @@ class _MiniProgress extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final progress = ref.watch( - activePlaybackProvider.select((s) => s.progress), - ); + final progress = ref.watch(activePlaybackProvider.select((s) => s.progress)); final accent = Theme.of(context).colorScheme.primary; return SizedBox( height: 2, @@ -134,6 +129,7 @@ class _ArtFallback extends StatelessWidget { const _ArtFallback(); @override Widget build(BuildContext context) => Center( - child: Icon(Icons.album_outlined, color: TimbreColors.dimmed, size: 22), - ); + child: Icon(Icons.album_outlined, + color: TimbreColors.dimmed, size: 22), + ); } diff --git a/offline-parity-plan.md b/offline-parity-plan.md deleted file mode 100644 index 8e719bd..0000000 --- a/offline-parity-plan.md +++ /dev/null @@ -1,184 +0,0 @@ -# Offline Parity: Browse, Artwork & Downloads Queue - -## Context - -When the device is offline, the app is far less usable than when streaming, even -though downloaded content exists on disk: - -- **Albums / Artists / Tracks load blank.** `browser_screen.dart` hard-gates the - whole Browse area behind a live `SubsonicClient`, and the backing providers - (`artistsProvider`, `albumsProvider`) return `const []` while - `LibraryIndexController.ensureBuilt()` bails before touching its cache when - `client == null`. So the only way to reach downloaded music offline is the - Downloads tab. -- **The Downloads tab plays one song at a time.** Tapping a saved track calls - `playback.playSongs([d.song])` — a single-element queue — and the screen has no - shuffle / play-all / play-next / add-to-queue controls that every streaming - screen has. -- **No offline artwork.** Downloads save only the audio file and the `coverArt` - *id*; the image itself is never cached, and all art URIs are built inline via - `client.coverArtUri(...)` guarded by `client != null`, so offline every cover - goes blank. - -**Decisions made with the user:** -1. Offline browse views show **downloaded content only** (reconstructed from the - downloads manifest) — everything shown is guaranteed playable. Online browse is - unchanged (server-backed). -2. Downloading a track **also caches its cover art** to disk so offline browse and - Now Playing show real artwork. - -**Intended outcome:** offline, the Albums / Artists / Tracks tabs, their detail -screens, artwork, and the Downloads tab all behave like a first-class local -library with full queue controls — parity with the streaming experience, scoped to -what's been downloaded. - -The key enabler already exists: the downloads manifest persists the **full -`Song`** per completed track (`DownloadInfo.song` → `song.toJson()`), carrying -`albumId`, `artistId`, `album`, `artist`, `coverArt`, `year`, `genre`, `track`, -`discNumber`. That is enough to reconstruct Albums/Artists/Tracks. The -`playlists.dart` controller is an in-repo precedent for the offline-mirror pattern. - ---- - -## Part A — Reconstruct the offline library from downloads - -### A1. New pure module: `lib/library/offline_library.dart` -Pure, I/O-free grouping functions over `List` (mirrors the style of -`library/browse_query.dart`): -- `List albumsFromSongs(List songs)` — group by `albumId` (fallback: - album name), synthesize an `Album` (id, name, artist, artistId, coverArt, year, - genre, `songCount`, and `songs:` sorted by disc/track). -- `List artistsFromSongs(List songs)` — group by `artistId` - (fallback: artist name), synthesize an `Artist` (id, name, coverArt from any - album, `albumCount`, and `albums:` built via `albumsFromSongs`). -- `Album? albumFromSongs(...)` / `Artist? artistFromSongs(...)` helpers keyed by id - for the detail providers. - -These reuse the existing `Album`/`Artist`/`Song` models in `subsonic/models.dart`. - -### A2. Providers gain an offline branch — `lib/state/providers.dart` -Introduce one shared source-of-songs seam so all three views stay consistent: -- `downloadedSongsProvider` → `ref.watch(downloadManagerProvider).completed.map((d) => d.song)`. - -Then wire offline fallbacks (offline = `subsonicClientProvider == null`): -- `artistsProvider`: when `client == null`, return `artistsFromSongs(downloadedSongs)` - instead of `const []`. -- `albumsProvider`: when `client == null`, return `albumsFromSongs(downloadedSongs)`. -- Tracks: add offline handling to `visibleTracksProvider`, `trackGenresProvider`, - `trackYearsProvider` so that when offline they derive from `downloadedSongs` - rather than the (empty, wiped-on-disconnect) `libraryIndexProvider`. Keep - reusing `applyTrackQuery` / `applyAlbumQuery` / `distinctGenres` / `distinctYears` - for sort+filter so offline behaves identically to online. -- Detail families `artistProvider` / `albumProvider`: when `client == null`, build - from `albumFromSongs` / `artistFromSongs` instead of throwing `StateError`. - -Each affected provider must also `watch` `downloadManagerProvider` so the lists -populate as downloads complete and recompute on connect/disconnect. - -### A3. Un-gate the Browse screen — `lib/screens/browser_screen.dart` -- Replace the blanket `client == null → _NotConnected` gate (≈ lines 84–96) with: - render the `_ArtistsPanel` / `_AlbumsPanel` / `_TracksPanel` whenever there is - data to show; keep `_NotConnected` only when offline **and** there are zero - downloads (message tuned to "You're offline — download music to browse it here"). -- `_TracksPanelState.initState`: only call `ensureBuilt()` when online; offline the - songs come from the A2 fallback, so skip the crawl. -- Leave `LibraryIndexController` untouched — the offline path deliberately does not - use the full cached catalog (decision: downloaded-only). - ---- - -## Part B — Cache cover art on download + offline art resolver - -### B1. Save artwork with each download — `lib/downloads/download_manager.dart` -- In `_run(...)`, after the audio file lands, if `song.coverArt != null` and that - art id isn't already cached, fetch `client.coverArtUri(song.coverArt!, size: 512)` - via the existing `_dio` and save to `downloads//art/.jpg` - (temp `.part` + rename, like the audio path). Dedup by `coverArt` id so all tracks - of an album share one file. Art failure is soft (never fails the audio download). -- Track cached art in state: add `Map artById` (coverArt id → - absolute path) to `DownloadState`, populated in `reloadForServer` (scan the `art/` - dir or re-derive from completed songs' `coverArt`) and on each completed download. -- Public accessor `String? localArtPathFor(String? coverArtId)` (sync, reads state) - parallel to the existing `localPathFor(id)`. -- `remove` / `clearAll`: delete an album's art only when no remaining download - references that `coverArt` id (and wipe the `art/` dir on `clearAll`). - -### B2. Central art resolver + dual-source image widget -- Add a resolver in `providers.dart` — a function/provider - `resolveArtUri(ref, {String? coverArt, int size})` that returns: - `localArtPathFor(coverArt)` as `Uri.file(...)` if cached → else - `client.coverArtUri(coverArt, size)` if online → else `null`. -- New widget `lib/widgets/art_image.dart` (`ArtImage(uri, ...)`) that picks - `FileImage` vs `NetworkImage` by URI scheme, preserving the current - `Image.network` styling/`ValueKey(artUri)`/placeholder behavior. -- Replace the inline `client.coverArtUri(...)` art-URI construction and the raw - `Image.network(artUri, ...)` calls with the resolver + `ArtImage` in: - `browser_screen.dart` (album tiles ≈240, track rows ≈382), `mini_player.dart` - (≈28/51), `now_playing_screen.dart` (≈460), `home_screen.dart` (`artFor`, ≈24–26 - and its `Image.network` sites), and `cassette_view.dart` (≈139). This makes - offline artwork appear everywhere a downloaded track's art is cached. -- In the playback closure `coverArtUriFor` (`providers.dart` ≈564) prefer the local - art file too, and switch the `onArt` accent extraction to `FileImage` when the - resolved art URI is a `file://` (so accent extraction works offline). - ---- - -## Part C — Downloads tab: queue parity - -### `lib/screens/downloads_screen.dart` (template: `playlists_screen.dart`) -- Tapping a saved row plays the **whole** saved list from that index: - `playback.playSongs(completed.map((d) => d.song).toList(), startIndex: i)` - (replacing `playSongs([d.song])` at line 79). Keep the list order stable and - consistent between what's shown and what's enqueued. -- Add header controls to the "Saved" panel mirroring `playlists_screen.dart` - (≈346–356): **Play all** (`playSongs(songs)`), **Shuffle** - (`toggleShuffle()` then `playSongs(songs)`, or set shuffle + play). Reuse the same - button widgets/tokens the playlists header uses. -- Add a per-row `PopupMenuButton` (like `playlists_screen.dart` ≈494–496): - **Play next** (`playback.playNext(d.song)`), **Add to queue** - (`playback.addToQueue(d.song)`), alongside the existing delete action. -- Render each row's artwork via the B2 resolver + `ArtImage` (downloaded art is - always local, so covers show offline). - ---- - -## Critical files - -- New: `lib/library/offline_library.dart`, `lib/widgets/art_image.dart` -- `lib/state/providers.dart` — offline provider branches, `downloadedSongsProvider`, - art resolver, `coverArtUriFor`/`onArt`. -- `lib/downloads/download_manager.dart` — art download + `artById` + `localArtPathFor`. -- `lib/screens/browser_screen.dart` — un-gate offline, art via resolver. -- `lib/screens/downloads_screen.dart` — full-list enqueue + queue controls + art. -- `lib/screens/{home_screen,now_playing_screen}.dart`, `lib/widgets/{mini_player,cassette_view}.dart` - — swap art rendering to resolver + `ArtImage`. - -## Reused, not rebuilt - -- `applyAlbumQuery` / `applyTrackQuery` / `distinctGenres` / `distinctYears` - (`library/browse_query.dart`) — offline sort/filter. -- `DownloadState.completed`, `localPathFor` pattern (`downloads/download_manager.dart`). -- `PlaybackCommands.playSongs / toggleShuffle / playNext / addToQueue` - (`playback/playback_engine.dart`) — already fully local-file aware. -- `playlists_screen.dart` header + row-menu widgets — copy for the Downloads screen. -- Playback already prefers local files (`streamUriFor`, `providers.dart` ≈547). - -## Verification - -1. `flutter analyze` clean; run existing tests (`flutter test`). -2. **Online seed:** connect to a server, download a few tracks spanning ≥2 albums - and ≥2 artists (some sharing an album). -3. **Go offline** (airplane mode, or stop the server / an unreachable URL so - `ping` fails and `client == null`). -4. Browse tab: - - Artists / Albums / Tracks list exactly the downloaded content, sorted/filtered - like online; artwork shows (from cached art). - - Open an album and an artist detail — populated, all rows playable. - - With zero downloads offline, the friendly offline-empty state shows (not blank). -5. Tap a track in an offline Album → whole album enqueues starting at that track; - Shuffle / Play next / Add to queue behave like the streaming screens. -6. Downloads tab: tapping a saved song enqueues the full saved list at that index; - Play all / Shuffle header + per-row Play next / Add to queue work; covers render. -7. **Reconnect** → Browse returns to the full server catalog; nothing regressed. -8. Confirm downloaded-track artwork also renders offline in the mini-player and Now - Playing, and the accent color still extracts from the local art. diff --git a/test/offline_library_test.dart b/test/offline_library_test.dart deleted file mode 100644 index 7512444..0000000 --- a/test/offline_library_test.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:timbre/library/offline_library.dart'; -import 'package:timbre/subsonic/models.dart'; - -/// Builds a downloaded-track [Song] with just the fields the offline -/// reconstruction reads. Everything else defaults to null. -Song _song( - String id, { - String? title, - String? album, - String? albumId, - String? artist, - String? artistId, - String? coverArt, - int? track, - int? discNumber, - int? year, - String? genre, -}) => - Song( - id: id, - title: title, - album: album, - albumId: albumId, - artist: artist, - artistId: artistId, - coverArt: coverArt, - track: track, - discNumber: discNumber, - year: year, - genre: genre, - ); - -void main() { - group('offline_library — album reconstruction', () { - test('groups songs by albumId and synthesizes album metadata', () { - final songs = [ - _song('1', - title: 'A', - album: 'Rumours', - albumId: 'alb1', - artist: 'Fleetwood Mac', - artistId: 'art1', - coverArt: 'cov1', - track: 2, - year: 1977, - genre: 'Rock'), - _song('2', - title: 'B', - album: 'Rumours', - albumId: 'alb1', - artist: 'Fleetwood Mac', - artistId: 'art1', - coverArt: 'cov1', - track: 1, - year: 1977, - genre: 'Rock'), - ]; - final albums = albumsFromSongs(songs); - expect(albums, hasLength(1)); - final a = albums.single; - expect(a.id, 'alb1'); - expect(a.name, 'Rumours'); - expect(a.artist, 'Fleetwood Mac'); - expect(a.artistId, 'art1'); - expect(a.coverArt, 'cov1'); - expect(a.year, 1977); - expect(a.genre, 'Rock'); - expect(a.songCount, 2); - }); - - test('orders an album by disc then track, nulls last', () { - final songs = [ - _song('1', album: 'X', albumId: 'x', title: 'no-track'), - _song('2', album: 'X', albumId: 'x', title: 'd1t2', discNumber: 1, track: 2), - _song('3', album: 'X', albumId: 'x', title: 'd2t1', discNumber: 2, track: 1), - _song('4', album: 'X', albumId: 'x', title: 'd1t1', discNumber: 1, track: 1), - ]; - final ids = albumsFromSongs(songs).single.songs.map((s) => s.title).toList(); - expect(ids, ['d1t1', 'd1t2', 'd2t1', 'no-track']); - }); - - test('falls back to album name as key and id when albumId is missing', () { - final songs = [ - _song('1', album: 'Untitled Sessions', title: 'A'), - _song('2', album: 'Untitled Sessions', title: 'B'), - ]; - final albums = albumsFromSongs(songs); - expect(albums, hasLength(1)); - expect(albums.single.id, 'Untitled Sessions'); - expect(albums.single.songCount, 2); - }); - - test('skips songs with no album identity', () { - final songs = [ - _song('1', title: 'orphan'), - _song('2', album: 'Real', albumId: 'r', title: 'kept'), - ]; - final albums = albumsFromSongs(songs); - expect(albums, hasLength(1)); - expect(albums.single.id, 'r'); - }); - - test('sorts albums by name case-insensitively', () { - final songs = [ - _song('1', album: 'zebra', albumId: 'z'), - _song('2', album: 'Apple', albumId: 'a'), - _song('3', album: 'mango', albumId: 'm'), - ]; - final names = albumsFromSongs(songs).map((a) => a.name).toList(); - expect(names, ['Apple', 'mango', 'zebra']); - }); - }); - - group('offline_library — artist reconstruction', () { - test('groups by artistId and nests albums', () { - final songs = [ - _song('1', - album: 'One', - albumId: 'a1', - artist: 'Radiohead', - artistId: 'r', - coverArt: 'c1'), - _song('2', - album: 'Two', - albumId: 'a2', - artist: 'Radiohead', - artistId: 'r', - coverArt: 'c2'), - ]; - final artists = artistsFromSongs(songs); - expect(artists, hasLength(1)); - final a = artists.single; - expect(a.id, 'r'); - expect(a.name, 'Radiohead'); - expect(a.albumCount, 2); - expect(a.albums.map((al) => al.id), containsAll(['a1', 'a2'])); - expect(a.coverArt, isNotNull); - }); - - test('falls back to artist name as key and id', () { - final songs = [_song('1', album: 'X', albumId: 'x', artist: 'Nameless Band')]; - final artists = artistsFromSongs(songs); - expect(artists.single.id, 'Nameless Band'); - }); - }); - - group('offline_library — keyed lookups', () { - final songs = [ - _song('1', album: 'One', albumId: 'a1', artist: 'Band', artistId: 'b1'), - _song('2', album: 'Two', albumId: 'a2', artist: 'Band', artistId: 'b1'), - ]; - - test('albumFromSongs returns the matching album or null', () { - expect(albumFromSongs(songs, 'a2')?.name, 'Two'); - expect(albumFromSongs(songs, 'nope'), isNull); - }); - - test('artistFromSongs returns the matching artist or null', () { - final artist = artistFromSongs(songs, 'b1'); - expect(artist?.name, 'Band'); - expect(artist?.albumCount, 2); - expect(artistFromSongs(songs, 'nope'), isNull); - }); - }); - - test('empty input yields empty output', () { - expect(albumsFromSongs(const []), isEmpty); - expect(artistsFromSongs(const []), isEmpty); - expect(albumFromSongs(const [], 'x'), isNull); - expect(artistFromSongs(const [], 'x'), isNull); - }); -}