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

10 KiB
Raw Permalink Blame History

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.