# 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.