diff --git a/lib/playback/playback_engine.dart b/lib/playback/playback_engine.dart index b2c8216..1c6418b 100644 --- a/lib/playback/playback_engine.dart +++ b/lib/playback/playback_engine.dart @@ -193,6 +193,24 @@ 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; @@ -232,20 +250,43 @@ class PlaybackController extends StateNotifier ps == ProcessingState.buffering, ); }); - player.positionStream.listen((p) { - state = state.copyWith(position: p); - }); player.durationStream.listen((d) { if (d != null) state = state.copyWith(duration: d); }); - // 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. + // 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. player.playbackEventStream.listen( - (_) {}, + (event) { + _posAnchor = event.updatePosition; + _posAnchorAt = event.updateTime; + state = state.copyWith(position: event.updatePosition); + }, 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; + final elapsed = DateTime.now().difference(_posAnchorAt); + if (elapsed.isNegative) return; + var pos = _posAnchor + elapsed; + final total = state.effectiveDuration; + if (total > Duration.zero && pos > total) pos = total; + state = state.copyWith(position: pos); } /// Id of the song we last ran play side effects for. Queue edits shift @@ -333,19 +374,15 @@ class PlaybackController extends StateNotifier song.duration != null ? Duration(seconds: song.duration!) : null, artUri: art, ); - // Wrap remote streams in a caching source on mobile: it fetches bytes to an - // OS-evictable temp file, giving the native player a genuinely seekable - // source with a known length — robust against chunked transcodes that omit - // Content-Length and against brief network drops. Downloads (file://) are - // already seekable, and the media_kit desktop backend uses just_audio's - // localhost proxy path we don't rely on, so both stay on the plain source. - if (isRemote && (Platform.isAndroid || Platform.isIOS)) { - // LockCachingAudioSource is marked experimental in just_audio but is - // stable in practice; the streaming reliability it provides is the whole - // point of this path. - // ignore: experimental_member_use - return LockCachingAudioSource(uri, tag: tag); - } + // 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); } @@ -894,9 +931,15 @@ class PlaybackController extends StateNotifier void resyncFromPlayer() { final player = _player; if (player == null) return; - final pos = player.position; - final dur = player.duration ?? state.duration; 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 dur = player.duration ?? state.duration; // player.currentIndex is a window-relative index; map it back to logical. final idx = player.currentIndex != null ? _windowStart + player.currentIndex! @@ -1062,6 +1105,7 @@ class PlaybackController extends StateNotifier @override void dispose() { _saveTimer?.cancel(); + _positionTicker?.cancel(); _player?.dispose(); super.dispose(); } diff --git a/offline-parity-plan.md b/offline-parity-plan.md new file mode 100644 index 0000000..8e719bd --- /dev/null +++ b/offline-parity-plan.md @@ -0,0 +1,184 @@ +# 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.