stream edits

This commit is contained in:
Forrest 2026-08-13 13:40:42 -04:00
parent db33f0764b
commit 7a199fe4df
2 changed files with 251 additions and 23 deletions

View file

@ -193,6 +193,24 @@ class PlaybackController extends StateNotifier<PlaybackState>
/// one disk write per window. /// one disk write per window.
Timer? _saveTimer; 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 /// 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. /// the empty launch state can't clobber a snapshot before restore runs.
String? _restoredKey; String? _restoredKey;
@ -232,20 +250,43 @@ class PlaybackController extends StateNotifier<PlaybackState>
ps == ProcessingState.buffering, ps == ProcessingState.buffering,
); );
}); });
player.positionStream.listen((p) {
state = state.copyWith(position: p);
});
player.durationStream.listen((d) { player.durationStream.listen((d) {
if (d != null) state = state.copyWith(duration: d); if (d != null) state = state.copyWith(duration: d);
}); });
// just_audio surfaces load/decode failures (e.g. an unreachable remote // The event stream carries the raw, unclamped `updatePosition`; anchor on it
// source after the network drops) as errors on the event stream. Without a // (and re-anchor on every seek / pause / track change) and reflect it
// handler the platform player runs its own recovery — restarting the item // immediately so paused/seeked positions are exact. Steady-state advancing
// at 0 or auto-advancing — which is the reported "scrub back / skip" bug. // 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( player.playbackEventStream.listen(
(_) {}, (event) {
_posAnchor = event.updatePosition;
_posAnchorAt = event.updateTime;
state = state.copyWith(position: event.updatePosition);
},
onError: (Object e, StackTrace st) => _onPlayerError(e), 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 /// Id of the song we last ran play side effects for. Queue edits shift
@ -333,19 +374,15 @@ class PlaybackController extends StateNotifier<PlaybackState>
song.duration != null ? Duration(seconds: song.duration!) : null, song.duration != null ? Duration(seconds: song.duration!) : null,
artUri: art, artUri: art,
); );
// Wrap remote streams in a caching source on mobile: it fetches bytes to an // Remote streams go straight to the native player (AVPlayer / ExoPlayer),
// OS-evictable temp file, giving the native player a genuinely seekable // which fetches the origin directly via its own networking stack. We
// source with a known length — robust against chunked transcodes that omit // deliberately do NOT wrap in LockCachingAudioSource nor set a player
// Content-Length and against brief network drops. Downloads (file://) are // userAgent: both route the fetch through just_audio's localhost proxy,
// already seekable, and the media_kit desktop backend uses just_audio's // whose bare dart:io HttpClient sends a default User-Agent and demands an
// localhost proxy path we don't rely on, so both stay on the plain source. // exact HTTP 200 — off-LAN edges (reverse proxy / WAF) reject or redirect
if (isRemote && (Platform.isAndroid || Platform.isIOS)) { // that, breaking streaming while dio downloads still work. The proxy also
// LockCachingAudioSource is marked experimental in just_audio but is // hides the stream's Content-Length, which leaves the native duration
// stable in practice; the streaming reliability it provides is the whole // indefinite and freezes the playhead (see the _wireStreams ticker).
// point of this path.
// ignore: experimental_member_use
return LockCachingAudioSource(uri, tag: tag);
}
return AudioSource.uri(uri, tag: tag); return AudioSource.uri(uri, tag: tag);
} }
@ -894,9 +931,15 @@ class PlaybackController extends StateNotifier<PlaybackState>
void resyncFromPlayer() { void resyncFromPlayer() {
final player = _player; final player = _player;
if (player == null) return; if (player == null) return;
final pos = player.position;
final dur = player.duration ?? state.duration;
final playing = player.playing; 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. // player.currentIndex is a window-relative index; map it back to logical.
final idx = player.currentIndex != null final idx = player.currentIndex != null
? _windowStart + player.currentIndex! ? _windowStart + player.currentIndex!
@ -1062,6 +1105,7 @@ class PlaybackController extends StateNotifier<PlaybackState>
@override @override
void dispose() { void dispose() {
_saveTimer?.cancel(); _saveTimer?.cancel();
_positionTicker?.cancel();
_player?.dispose(); _player?.dispose();
super.dispose(); super.dispose();
} }

184
offline-parity-plan.md Normal file
View file

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