mobile-music/offline-parity-plan.md
2026-08-13 13:40:42 -04:00

184 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<Song>` (mirrors the style of
`library/browse_query.dart`):
- `List<Album> albumsFromSongs(List<Song> 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<Artist> artistsFromSongs(List<Song> 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/<key>/art/<sanitized coverArt>.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<String,String> 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.