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