Skip to content

Add offline detection with a generic "You're Offline" screen - #101

Merged
imbenjamin merged 2 commits into
developfrom
feature/offline-detection
Aug 18, 2026
Merged

Add offline detection with a generic "You're Offline" screen#101
imbenjamin merged 2 commits into
developfrom
feature/offline-detection

Conversation

@imbenjamin

Copy link
Copy Markdown
Owner

Summary

Adds app-wide offline/connectivity detection for Dionysus Player, primarily used against LAN-only Jellyfin servers.

  • ConnectivityMonitor — a @MainActor @Observable singleton (isOffline), the single signal any part of the app can route to. The only writer is JellyfinAPIClient.sendRaw, the choke point every endpoint call funnels through: a transport-level failure (no response at all) reports offline; reaching any real HTTP response — success or an HTTP error status — reports back online.
  • Checks happen at launch, resume, and every page load: AppState.start() routes a launch-time connectivity failure to a new .offline phase (distinct from .login); DionysusPlayerApp fires a lightweight GET /health probe on every foreground transition; every screen's own load already goes through sendRaw.
  • A generic "You're Offline" screen (OfflineStateView) that Home, Search, Collection, and Asset Detail gate their "nothing to show yet" states on, with a Retry that reloads whatever the user was doing. Already-loaded/preloaded content is never blanked out by a stale/background offline flag.
  • Profile/Settings stays reachable while offline — the pre-login .offline screen offers a "Go to Settings" button that opens ProfileView directly; post-login the tab bar is always present regardless of connectivity state.
  • Mid-playback connectivity loss is also handled: a new PlaybackState.reconnecting case (bridged from AetherEngine's .stalled phase) drives a "Reconnecting…" indicator distinct from ordinary buffering, and a terminal .failed state now actually surfaces an error overlay (previously silent — the video just froze with no message). When that failure coincides with the app already being offline, the shared offline screen is shown instead, with a retry that resumes in place rather than restarting from zero.

Bugs found and fixed via live on-device testing

  • App hanging indefinitely on the launch splash screen when offline, due to .shared's default 60s request timeout — fixed with a 20s per-request timeout.
  • Tapping an item from an offline/cached Home screen produced a permanently part-loaded detail page that never showed the offline screen — AssetDetailView now checks offline state before its preloaded-item branch.
  • Home's dynamic rails (genre/studio/actor/director) failed silently by design with no way to recover — added tracking + an auto-retry on reconnect.

Follow-up review (second commit)

Two internal performance fixes found reviewing the above, no behavior change:

  • The 20s request timeout was enforced via a withThrowingTaskGroup racing against Task.sleep, spinning up an extra Task on every API call — replaced with a plain per-request URLRequest.timeoutInterval.
  • ConnectivityMonitor.reportFailure()/reportSuccess() wrote isOffline unconditionally, which — since @Observable change-tracking fires on every assignment regardless of whether the value changes, and four screens read isOffline directly in body — was re-rendering whichever of those screens was on screen on every single network call app-wide, including the 10s playback-progress heartbeat during video playback. Guarded both to no-op when the value wouldn't change.

Testing

  • Full unit test suite: 492 tests, 0 failures.
  • Manual on-device verification (physical iPhone): offline-at-launch, offline mid-browse with retry, dynamic-rail auto-recovery, item-detail offline handling, and reconnect flows across Home/Search/Collections.
  • Player mid-stream connectivity handling implemented per plan; automated Simulator driving doesn't work on the Player screen (documented limitation), so this leans on the same manual verification approach as other Player-screen changes.

🤖 Generated with Claude Code

imbenjamin and others added 2 commits August 18, 2026 15:55
Adds app-wide connectivity detection so a LAN-only Jellyfin server
being unreachable gets a clear, recoverable UI instead of a silent
hang or a bare error message.

- ConnectivityMonitor (new): a MainActor singleton, same convention as
  DeviceTiltObserver.shared, updated from the single choke point every
  Jellyfin API call already funnels through (JellyfinAPIClient.sendRaw).
  A transport-level failure reports offline; any real HTTP response
  (success or an error status) reports online.
- AppState gains a Phase.offline case, reached only when session
  restore at launch can't reach the server at all — kept separate from
  .login so a user actively signing in never has the screen pulled out
  from under them by a background connectivity flip.
- Home/Search/Collection/AssetDetail show a shared OfflineStateView in
  place of their "nothing to show yet" states when offline, each with
  a Retry that reloads just that screen. The tab bar (and Profile) stay
  reachable throughout since none of this touches MainTabView.
- DionysusPlayerApp probes Jellyfin's /health endpoint on every
  foreground transition to catch a dropped connection on resume.
- The pre-login offline screen offers "Go to Settings" (embeds
  ProfileView directly) rather than jumping straight to "Change Server".
- sendRaw races the real request against an explicit 20s timeout
  instead of relying on .shared's default 60s, which was making an
  unreachable-but-routable server (e.g. cellular with Wi-Fi off) read
  as an indefinite hang rather than a prompt offline screen.
- AssetDetailView now checks offline-and-not-fully-loaded ahead of its
  preloaded-item branch — previously a preload from Home made a
  genuinely offline detail fetch look like a permanently part-loaded
  page with no way back to a retry.
- HomeViewModel tracks dynamic-rail-discovery failures separately from
  "library has none to offer" and retries once ConnectivityMonitor
  reports back online, since that fetch fails silently by design and
  nothing else was retriggering it.

Mid-playback connectivity loss:
- PlaybackState gains a .reconnecting case, bridged from AetherEngine's
  .stalled phase instead of being folded into .buffering, so
  PlayerControlsOverlay can show "Reconnecting…" for an actual dropped
  source connection.
- PlayerViewModel's onStateChange now derives errorMessage from a
  terminal .failed engine state, not just from start()'s own catch —
  previously a mid-playback failure just froze the video with no
  message. PlayerView picks the shared offline screen or the existing
  generic error view depending on ConnectivityMonitor at that moment;
  the offline retry resumes in place via a new start(resumeSeconds:)
  override rather than restarting from zero.

Verified on a physical device: launch-time offline (Wi-Fi off),
background/foreground resume, per-screen offline+retry, dynamic-rail
self-recovery, and item-detail offline handling, via a live console
capture across a full reconnect session (Search, item details, a full
Show detail, heavy Home scrolling) with no unexpected errors.

489 tests pass (12 new): ConnectivityMonitor, sendRaw's reporting
behavior, healthCheck(), AppState's new offline phase, PlayerViewModel's
failure/resume handling, and HomeViewModel's dynamic-rail retry logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two performance fixes found in review, both internal to sendRaw's
transport-failure classification, no behavior change:

- sendRaw's 20s request timeout was enforced via a withThrowingTaskGroup
  racing session.data(for:) against Task.sleep — spinning up an extra
  Task on every single API call in the app just to get a timeout
  URLRequest already supports natively. Replaced with a per-request
  request.timeoutInterval set in makeRequest, honored by URLSession
  regardless of session config, so it doesn't need the custom-session
  workaround that broke MockURLProtocol interception last time either.

- ConnectivityMonitor.reportFailure()/reportSuccess() wrote isOffline
  unconditionally. @observable's change tracking fires on every
  assignment regardless of whether the value actually changes, and
  these run on every network call app-wide (including the 10s
  playback-progress heartbeat during video playback) — since four
  screens (Home/Search/Collection/AssetDetail) read isOffline directly
  in body, every completed request was re-invalidating and recomputing
  whichever of those screens was on screen, whether or not connectivity
  actually changed. Guarded both methods to no-op when the value
  wouldn't change.

Verified: full test suite (492 tests) passes unmodified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@imbenjamin imbenjamin linked an issue Aug 18, 2026 that may be closed by this pull request
@imbenjamin
imbenjamin merged commit bd625b8 into develop Aug 18, 2026
1 check passed
@imbenjamin
imbenjamin deleted the feature/offline-detection branch August 18, 2026 15:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Offline handling

1 participant