offline-download-v1.md 6.9 KB

Offline Download v1 — Shaped Brief

Approved: 2026-03-18 Deliberation: 3 models (Codex scope, Gemini challenge, Claude requirements) + Designer consultation

Problem

Cloud tracks from Chad Music can only be streamed. No way to make them available offline. Without connectivity, cloud tracks are unplayable. Users want to download tracks for offline use — at the song, album, and playlist level — similar to Spotify/Apple Music.

Goal

User can mark cloud tracks as "download for offline." Downloaded tracks play locally via AudioEngine (with EQ, BPM detection, waveform) instead of streaming. Available at three levels: individual track, full album, all cloud tracks in a playlist.

Non-goals (v1)

  • Auto-download toggle on playlists (Spotify-style auto-sync)
  • Resumable/partial downloads on app restart
  • Transcoding at download time
  • Disk space pre-check
  • OGG Vorbis support through AudioEngine (fallback to AVPlayer if AudioEngine can't open the format)

Acceptance Criteria

  • Single cloud track: click download icon in track row → downloads to persistent local storage
  • Album: "Download All" button in album header → batch downloads all tracks (bounded concurrency, max 3)
  • Playlist: download button in playlist header → downloads all cloud tracks not yet downloaded
  • Four visual states per track: not downloaded, downloading (with progress), downloaded, error
  • Downloaded cloud track plays via AudioEngine (with EQ). If AudioEngine can't open the format, falls back to AVPlayer with notice
  • "Remove Download" in context menu deletes local file, reverts track to stream-only
  • If cached file is missing (manual deletion/corruption), track gracefully reverts to streaming
  • Auth headers on all download requests
  • Cancel in-progress download (single or batch)

Appetite

Medium:

  • New Track model fields: localCachePath: String?, downloadState enum
  • Extend DownloadService for persistent mode (vs existing export-temp mode)
  • PlayerViewModel routing change: check hasPlayableLocalFile instead of isCloud
  • UI: DownloadIndicator component, album header button, playlist header button, context menu items
  • ~5-6 files changed, no new dependencies

Architecture (from deliberation)

Model — Option C (all 3 reviewers converged)

  • Add localCachePath: String? to Track (separate from filePath)
  • Add downloadState: DownloadState (none/downloading/downloaded/error) — new enum, stored as raw string value
  • hasLocalFile stays UNCHANGED: !filePath.isEmpty && !isCloud (protects export and all existing consumers)
  • New computed: hasPlayableLocalFile = !filePath.isEmpty || (localCachePath != nil && file exists at localCachePath)

Storage location — Application Support, NOT Caches

  • ~/Library/Application Support/MixBoard/CloudTracks/{cloudTrackId}.{ext}
  • All 3 reviewers agreed: ~/Library/Caches/ is wrong for explicit user-initiated downloads (macOS can purge it)
  • Application Support survives OS cleanup, Time Machine backup

Playback routing change

// Before (current)
if track.isCloud → StreamingPlayer

// After
if track.hasPlayableLocalFile → AudioEngine
else → StreamingPlayer (covers both streaming cloud + any edge cases)

With format fallback: if AVAudioFile can't open the downloaded file, fall back to AVPlayer and log a warning.

Stale file recovery

  • hasPlayableLocalFile checks file existence at the path before returning true
  • If file missing: set downloadState = .none, nil out localCachePath, play via streaming
  • Handles manual deletion, disk corruption, migration

UI (from designer consultation — Direction A: Inline Icon Button)

Track row

Inline DownloadIndicator (20pt icon button) between Spacer() and duration column.

State Icon Style
Not downloaded arrow.down.circle .tertiary
Downloading Circular progress ring Accent color
Downloaded checkmark.circle.fill .secondary
Error exclamationmark.circle.fill .red

Album header

AlbumDownloadButton next to existing "Add All" — shows:

  • "Download All" (none downloaded)
  • "3 of 12" (downloading in progress)
  • "Downloaded" (all done)
  • "5 remaining" (partially downloaded)

Playlist header

Download button visible only when playlist has cloud tracks. Shows downloaded/total count (e.g., "↓ 2/4").

Playlist TrackRow

Downloaded cloud tracks replace cloud.fill badge with green arrow.down.circle.fill.

Context menu additions

  • Not downloaded: "Download" (arrow.down.circle)
  • Downloaded: "Remove Download" (trash) — destructive role
  • Downloading: "Cancel Download" (stop.circle)
  • Error: "Retry Download" (arrow.clockwise)

Animation

  • Download start: 150ms cross-fade to progress ring
  • Completion: progress fill → checkmark with scale bounce (1.0→1.15→1.0, 200ms)
  • Error: 3px horizontal shake, 300ms
  • Reduced motion: instant cuts per design system

Technical Constraints

  • PlayerViewModel routes by track.isCloud today — must change to hasPlayableLocalFile
  • AudioEngine uses AVAudioFile — supports MP3, WAV, AIFF, FLAC, M4A. NOT OGG.
  • OGG decoder (stb_vorbis) exists in codebase — unclear if wired into AudioEngine
  • Chad Music backend can serve OGG files → need format fallback to AVPlayer
  • SwiftData model migration: adding localCachePath + downloadState triggers lightweight migration

Dependencies & Blockers

None. DownloadService, ChadMusicAPIClient, and Track model all exist.

Risks

  • Playback routing change touches core code path — needs careful testing
  • AudioEngine format gaps for OGG → fallback covers this but EQ won't work for those tracks
  • Large album downloads (500MB+) → bounded concurrency + progress UI mitigates
  • Model migration — adding fields to SwiftData @Model triggers lightweight migration (should be automatic for optional fields)

Deferred (v2+)

  • Auto-download toggle on playlists
  • Resumable downloads / download queue persistence across app restarts
  • OGG transcoding to FLAC/M4A at download time
  • Disk space pre-check before batch downloads
  • Storage usage UI / "Manage Downloads" settings panel
  • Download progress surviving app restart

Deliberation summary

3 models (Codex scope, Gemini challenge, Claude requirements) + Designer.

  • All 3: Use Application Support, not Caches. Add explicit downloadState enum.
  • All 3: Don't change hasLocalFile — too many consumers. Add hasPlayableLocalFile.
  • Claude: Recommended Option C (localCachePath) — clearest data model separation.
  • Gemini: Flagged OGG format risk with AudioEngine. Resolved with AVPlayer fallback.
  • Codex: Proposed phasing track-only first. Overruled — album batch is low incremental cost since DownloadService.downloadBatch already exists.
  • Designer: Direction A (inline icon button), four-state iconography, no auto-download toggles. Updated design-system.md with new tokens.