Skip to content

Architecture

Eric Slutz edited this page Jun 11, 2026 · 4 revisions

Architecture

A Playa Named Gus is one multiplatform SwiftUI app target with five supported destinations (iOS, iPadOS, tvOS, visionOS, macOS) plus a second watchOS application target (GusWatch). It uses the pure SwiftUI lifecycle (@main struct GusApp: App, no AppDelegate).

Native-First Mandate

The core engineering rule is: use Apple/system frameworks before writing custom code or adding dependencies. The only runtime dependencies are jellyfin-sdk-swift and — on iOS/iPadOS only — the Readium toolkit for in-app EPUB reading (ADR 0009). XcodeGen is a build-time tool, not a shipped dependency.

Concern Project choice
Navigation NavigationStack, NavigationSplitView, TabView
State and dependency injection Observation @Observable plus SwiftUI @Environment
Video playback AVKit VideoPlayer and AVPlayerViewController
Audio playback AVPlayer queue engine over the Jellyfin universal audio endpoint
Images AsyncImage plus shared URLCache
Secrets Security framework SecItem*
Persistence Codable, FileManager, UserDefaults
Downloads Background URLSessionDownloadTask
Now Playing MediaPlayer
System integration Core Spotlight, NSUserActivity (Handoff), App Intents, CarPlay templates
Diagnostics OSLog, OSSignposter, MetricKit (no third-party analytics)
Logging OSLog Logger (subsystem dev.ericslutz.gus)
Strings String Catalog
Immersive visionOS UI RealityKit and ImmersiveSpace
EPUB rendering (iOS/iPadOS) Readium toolkit (ADR 0009 dependency exception)

Source Layout

Sources/
  App/        @main entry point + RootView (signed-out/signed-in switch), App Intents
  Models/     Codable value types (ServerConnection, StoredUser, ContentLink, ...)
  Providers/  Media-server provider boundary (Jellyfin impl behind a protocol)
  Services/   Stateless helpers and persistence: client factory, Keychain, URL builders,
              downloads, diagnostics, content-rating gate, sockets, Spotlight, watch relay
  Stores/     @Observable state objects (the "view models")
  Features/   Connect, Home, Item, Player, Search, Settings, Music, Photos, LiveTV, Books
  SharedUI/   Reusable SwiftUI views, display helpers, GlassStyle (Liquid Glass)
  Platform/   ALL #if os(...) divergence: routing, modifiers, commands, availability,
              deep links, Handoff user activities
  Immersive/  visionOS Gus Cinema and stereoscopic rendering
  TopShelf/   tvOS Top Shelf extension (content-aware Continue Watching)
  CarPlay/    iOS-only CarPlay audio templates
  Watch/      watchOS companion app UI (GusWatch target)

Media Provider Boundary

Jellyfin-specific API and DTO assumptions are isolated behind a provider abstraction (Sources/Providers, ADR 0008). Feature stores talk to a MediaProviderSession rather than calling the Jellyfin SDK directly, so A Playa Named Gus stays Jellyfin-only at launch while preserving a clean route to future backends such as Emby. The Jellyfin implementation lives under Providers/Jellyfin.

App and Session State

GusApp creates AppModel and injects it into the environment. AppModel owns known servers, stored users, and the optional current session.

RootView switches on AppModel.currentSession:

  • nil shows ConnectFlowView.
  • a signed-in session creates and injects SessionStore.

Feature stores such as HomeStore, LibraryStore, SearchStore, ItemDetailStore, PlaybackStore, and AudioPlayerStore are @Observable @MainActor classes created inside a view's .task. They use async/await through the provider session. State is Observation-framework based (no Combine @Published, no DI container).

Authentication

The connect flow normalizes a server URL, creates a tokenless JellyfinClient, fetches public system info (following redirects), and persists a ServerConnection.

Sign-in calls Jellyfin authentication APIs. Access tokens are stored in Keychain using an account key of serverID:userID. Token-free StoredUser records are persisted through ServerStore (Codable JSON in Application Support).

On launch, AppModel.restoreLastSession() rebuilds an authenticated client from the stored token when available.

Navigation

Platform navigation divergence is centralized in Sources/Platform/RootContainer.swift.

  • iPhone and tvOS use tab-style navigation; iPad, macOS, and visionOS use split navigation.
  • Users can customize which sections appear and their order via NavigationPreferencesStore (Settings).
  • Item/library navigation uses typed routes registered once per NavigationStack root.
  • Fixed app destinations (gus://home, gus://search, gus://settings) flow through AppRoute + AppNavigationModel and are shared by URL opens, menu commands, and the tvOS Top Shelf.

System Integration

Content deep links are the shared entry point for system integration (Models/ContentLink.swift, Platform/ContentLinkHandler.swift): gus://item/<id> and gus://play/<id> ride AppNavigationModel via a consume-once pending-link request, resolve through the session's provider, and present the detail sheet or player.

  • HandoffPlatform/UserActivities.swift publishes NSUserActivity from detail/player surfaces using item ids only, never tokens.
  • Core SpotlightServices/SpotlightIndexer.swift donates browsed items with server|user|item identifiers, refuses other-account continuations, and deindexes on sign-out (no watchOS/tvOS).
  • Siri / App IntentsApp/GusAppIntents.swift provides a "Play media" intent and entity search.
  • tvOS Top Shelf — the GusTopShelf extension reads a Continue Watching snapshot from the group.dev.ericslutz.gus App Group, written by HomeStore.
  • CarPlaySources/CarPlay provides iOS-only audio templates (inert until the carplay-audio entitlement is granted).

Playback

Video playback is pure AVKit. StreamURLBuilder POSTs Paths.getPostedPlaybackInfo with a DeviceProfile that leans direct play for everything the device's hardware genuinely decodes (DevicePlaybackCapabilities gates HEVC/AV1 on VTIsHardwareDecodeSupported and HDR ranges on AVPlayer.eligibleForHDRPlayback), falling back to HEVC-preferred fMP4 HLS transcoding with multichannel audio and manifest-delivered text subtitles only when the source needs it. PlaybackStore owns the active AVPlayer, playback state, stream selections, reporting, and teardown.

  • iOS/iPadOS use AVPlayerViewController (PiP); macOS uses AVPlayerView in a dedicated resizable player window (GusPlayerWindow scene — not a sheet); tvOS uses the focus-engine controller via a representable; visionOS uses SwiftUI VideoPlayer.
  • AVPlayerItem.externalMetadata feeds the system title/info chrome (except macOS, which lacks the API).
  • NowPlayingController feeds MPNowPlayingInfoCenter / MPRemoteCommandCenter; on iOS, AVKit's automatic publishing is disabled so there is a single Now Playing writer.
  • Track switching is in place via AVMediaSelection on direct-played files (PlaybackMediaSelectionMatcher, ordinal + language matching) with a server-side rebuild fallback for transcoded streams.
  • At natural end, playback auto-plays the next episode (Settings toggle, default on) or dismisses the player; pause/resume report to the server immediately.
  • A Settings-backed Streaming Quality picker (PlaybackQuality: Maximum/High/Medium/Low) caps the negotiated bitrate.

Songs and audiobooks play through a separate AudioPlayerStore + AudioPlayerView (queue, shuffle/repeat, playback speed, chapters) over the Jellyfin universal audio endpoint. playerPresentation routes by MediaItem.isAudioPlayable.

visionOS 3D and Gus Cinema

Stereo playback is visionOS-only and requires direct play because server transcoding flattens stereo. Media3DDetector maps Jellyfin SBS/TAB/MVC metadata and conservative MV-HEVC hints into a Stereo3DPresentation.

  • MV-HEVC spatial video uses the normal AVKit VideoPlayer path with a Spatial badge.
  • Side-by-side and top-and-bottom sources use the Gus Cinema ImmersiveSpace: a StereoFrameRenderer splits packed frames into per-eye buffers tagged via CMTaggedBufferGroup and rendered through VideoMaterial with .stereo viewing mode.
  • MVC is unsupported and falls back to 2D with a notice.
  • Non-visionOS platforms always resolve to 2D.

Offline Downloads

Downloads are available on iOS, iPadOS, macOS, and visionOS. tvOS is excluded because app storage is purgeable and not suitable for persistent local media without a separate design. The watchOS companion has its own on-watch audio downloads under a separate budget.

The download stack uses:

  • OfflineDownloadStore for user-facing state (status/progress/resume, 20 GB soft cap, low-space guard).
  • DownloadSessionCoordinator for background URLSession transfers.
  • DownloadSourceResolver to choose AVPlayer-native originals or Jellyfin progressive MP4 transcodes.
  • FileManager and Codable records under Application Support, scoped per server/user and excluded from backup.

Platform availability is routed through Platform/DownloadsAvailability.swift so feature views stay free of #ifs.

watchOS Companion

GusWatch is a second application target (platform watchOS), embedded into the iOS app's Watch/ directory and standalone-capable (WKRunsIndependentlyOfCompanionApp). It compiles the verified shared subset — Models/, Providers/, Services/, and the non-video Stores/ (no PlaybackStore/SyncPlayStore) — plus the watch UI in Sources/Watch. Remote control rides RemoteSessionsStore + SessionsSocket (Jellyfin Sessions API); credentials hand off from the iPhone via WatchSessionRelay / WatchCredentialReceiver. See Documentation/watchos-brief.md and ADR 0010.

Diagnostics and Family Safety

  • DiagnosticsDiagnosticsHub records privacy-safe lifecycle markers (numeric/boolean payloads only) and OSSignposter intervals; MetricKitCollector normalizes MetricKit payloads into local DiagnosticSummary records. No third-party analytics (Documentation/AppStore/diagnostics-reliability.md).
  • Family safetyContentRatingGate + RestrictedContentView gate detail and playback against the Declared Age Range (Documentation/family-safety-brief.md).

Architecture Decisions

Accepted ADRs live in Documentation/adr/:

  • 0001 — AVKit-only playback.
  • 0002 — Observation for state.
  • 0003 — XcodeGen as project source of truth.
  • 0004 — Swift Testing for unit tests.
  • 0005 — Offline downloads background and transcode scope.
  • 0006 — Stereoscopic video playback.
  • 0007 — HEVC/HLS transcoding evidence.
  • 0008 — Media provider boundary.
  • 0009 — Readium EPUB reader (dependency exception).
  • 0010 — watchOS companion.

Clone this wiki locally