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