Flutter half of offline-replay capture. Play events no longer
fire-and-forget: the reporter now tracks each play as a completed
unit (track, original start time, source, duration reached)
independently of connectivity.
- EventsApi.playOffline: single timestamp-preserving call → the new
/api/events play_offline (47aa178).
- MutationQueue: new play.offline kind + handler (EventsApi).
- PlayEventsReporter rework:
- _beginTrack captures start context + fires live play_started;
the server id is adopted only if it lands while still on-track.
- position progress gated on the tracked track id so a track
change can't clobber the finishing track's last values.
- _closeCurrent: if a server id registered, attempt the live
ended/skipped and fall back to the offline queue on failure; if
no id (offline start) enqueue the completed play directly. The
server applies the canonical skip rule, so the offline payload
only carries duration.
- app paused/detached closes durably via the queue (survives a
process kill; a teardown POST would not).
Result: listening to cached tracks fully offline now records
history / recs / scrobble / #415 rotation once back online, with
the original timestamps. Web stays best-effort by standing
occasional-use scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removed in f6ee837 thinking it was unused, but drain() still
reads connectivityProvider.future to gate replay attempts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ref.listen(connectivityProvider, …) at start-time mounted the
StreamProvider immediately, which kicked off checkConnectivity()
with a 2s timeout. In tests that never reach the auth state
(smoke_test cold-launch path), that Timer leaked past widget tree
dispose and tripped the still-pending-timer assertion.
Drop the edge trigger — the 3s initial + 1min periodic + post-
enqueue nudge already cover the drain paths. Worst case on
reconnect is ~60s extra latency before the queue drains, which
is acceptable for an offline-resilience layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Smoke test failed: "A Timer is still pending even after the widget
tree was disposed." Both workers fired their initial-delay Timer
via `Timer(duration, _sweep)` and stored only the periodic ticker
in the cancellable field — the one-shot Timer leaked past dispose
and tripped the test framework's invariant check.
Track both as _initialTimer + _intervalTimer; cancel both in
dispose(). Behavior is unchanged in production (ref.onDispose only
fires on process death normally); this is purely a test-harness
fix.
User intent (likes, hides, playlist adds, Lidarr requests, cancels)
now persists across network loss. Controllers write their optimistic
local state to drift first, then try the REST call; on failure
the call is enqueued in cached_mutations rather than rolled back.
MutationReplayer drains the queue on connectivity transitions and
a 1-minute periodic tick.
**Infrastructure (schema 8):**
* CachedMutations table — id / kind / payload (JSON) / createdAt
/ lastAttemptAt / attempts. Drop-after-5-attempts semantics: a
permanently-failing mutation eventually drops, and next sync
reconciles drift to the server's authoritative state.
* MutationQueue.enqueue / pendingCount
* MutationReplayer.start + .drain — start fires from app.dart's
postFrameCallback alongside SyncController / Prefetcher / etc.
* Kind registry: like.add / like.remove / quarantine.flag /
quarantine.unflag / playlist.append / request.create /
request.cancel — each with a Ref+payload handler that re-fires
the corresponding REST call.
**Wired surfaces:**
* LikesController.toggle — optimistic drift like/unlike stays
across REST failure; queues the call. Drops the old rollback.
* MyQuarantineController.flag / .unflag — same pattern. Hide/unhide
visibly persists offline; replays when back online.
* addToPlaylistActionProvider — now does an optimistic
cached_playlist_tracks write (position = max + 1) so the
playlist detail screen shows the new track instantly. Queues
appendTracks on REST failure.
* DiscoverScreen._request — queues request.create on DioException.
No drift state for the request itself (myRequestsProvider is
still REST-only) so the row won't show on /requests until replay
succeeds — acceptable for v1.
* MyRequestsController.cancel — optimistic in-memory remove no
longer restores on failure; queues request.cancel instead.
**Test update:**
quarantine_provider_test "flag rolls back on server failure"
renamed and rewritten to assert the new offline behavior:
optimistic drift row persists, mutation is enqueued for replay.
**Out of scope (v2):**
* Playlist create / rename / delete (no Flutter UI exposes these yet)
* Lidarr request optimistic local row (would need a cached_requests
drift table)
* UI "syncing N pending changes" indicator (operator preference:
silent unless we find a concrete need)