10 KiB
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.darthard-gates the whole Browse area behind a liveSubsonicClient, and the backing providers (artistsProvider,albumsProvider) returnconst []whileLibraryIndexController.ensureBuilt()bails before touching its cache whenclient == 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
coverArtid; the image itself is never cached, and all art URIs are built inline viaclient.coverArtUri(...)guarded byclient != null, so offline every cover goes blank.
Decisions made with the user:
- Offline browse views show downloaded content only (reconstructed from the downloads manifest) — everything shown is guaranteed playable. Online browse is unchanged (server-backed).
- 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 byalbumId(fallback: album name), synthesize anAlbum(id, name, artist, artistId, coverArt, year, genre,songCount, andsongs:sorted by disc/track).List<Artist> artistsFromSongs(List<Song> songs)— group byartistId(fallback: artist name), synthesize anArtist(id, name, coverArt from any album,albumCount, andalbums:built viaalbumsFromSongs).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: whenclient == null, returnartistsFromSongs(downloadedSongs)instead ofconst [].albumsProvider: whenclient == null, returnalbumsFromSongs(downloadedSongs).- Tracks: add offline handling to
visibleTracksProvider,trackGenresProvider,trackYearsProviderso that when offline they derive fromdownloadedSongsrather than the (empty, wiped-on-disconnect)libraryIndexProvider. Keep reusingapplyTrackQuery/applyAlbumQuery/distinctGenres/distinctYearsfor sort+filter so offline behaves identically to online. - Detail families
artistProvider/albumProvider: whenclient == null, build fromalbumFromSongs/artistFromSongsinstead of throwingStateError.
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 → _NotConnectedgate (≈ lines 84–96) with: render the_ArtistsPanel/_AlbumsPanel/_TracksPanelwhenever there is data to show; keep_NotConnectedonly when offline and there are zero downloads (message tuned to "You're offline — download music to browse it here"). _TracksPanelState.initState: only callensureBuilt()when online; offline the songs come from the A2 fallback, so skip the crawl.- Leave
LibraryIndexControlleruntouched — 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, ifsong.coverArt != nulland that art id isn't already cached, fetchclient.coverArtUri(song.coverArt!, size: 512)via the existing_dioand save todownloads/<key>/art/<sanitized coverArt>.jpg(temp.part+ rename, like the audio path). Dedup bycoverArtid 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) toDownloadState, populated inreloadForServer(scan theart/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 existinglocalPathFor(id). remove/clearAll: delete an album's art only when no remaining download references thatcoverArtid (and wipe theart/dir onclearAll).
B2. Central art resolver + dual-source image widget
- Add a resolver in
providers.dart— a function/providerresolveArtUri(ref, {String? coverArt, int size})that returns:localArtPathFor(coverArt)asUri.file(...)if cached → elseclient.coverArtUri(coverArt, size)if online → elsenull. - New widget
lib/widgets/art_image.dart(ArtImage(uri, ...)) that picksFileImagevsNetworkImageby URI scheme, preserving the currentImage.networkstyling/ValueKey(artUri)/placeholder behavior. - Replace the inline
client.coverArtUri(...)art-URI construction and the rawImage.network(artUri, ...)calls with the resolver +ArtImagein: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 itsImage.networksites), andcassette_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 theonArtaccent extraction toFileImagewhen the resolved art URI is afile://(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)(replacingplaySongs([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()thenplaySongs(songs), or set shuffle + play). Reuse the same button widgets/tokens the playlists header uses. - Add a per-row
PopupMenuButton(likeplaylists_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,localPathForpattern (downloads/download_manager.dart).PlaybackCommands.playSongs / toggleShuffle / playNext / addToQueue(playback/playback_engine.dart) — already fully local-file aware.playlists_screen.dartheader + row-menu widgets — copy for the Downloads screen.- Playback already prefers local files (
streamUriFor,providers.dart≈547).
Verification
flutter analyzeclean; run existing tests (flutter test).- Online seed: connect to a server, download a few tracks spanning ≥2 albums and ≥2 artists (some sharing an album).
- Go offline (airplane mode, or stop the server / an unreachable URL so
pingfails andclient == null). - 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).
- 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.
- 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.
- Reconnect → Browse returns to the full server catalog; nothing regressed.
- Confirm downloaded-track artwork also renders offline in the mini-player and Now Playing, and the accent color still extracts from the local art.