parent
10e147b447
commit
1aac6823fa
@ -1,4 +1,3 @@ |
|||||||
.superpowers/ |
.superpowers/ |
||||||
docs/superpowers/ |
|
||||||
.DS_Store |
.DS_Store |
||||||
xcuserdata/ |
xcuserdata/ |
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,218 @@ |
|||||||
|
# Music Player — Design Spec |
||||||
|
|
||||||
|
A high-performance macOS music player built with SwiftUI + AppKit. Reads a local music collection from disk, indexes metadata into SQLite for fast search/sort/filter, and plays audio via AVPlayer. |
||||||
|
|
||||||
|
## Constraints |
||||||
|
|
||||||
|
- Library size: 10,000+ tracks, 100GB+, growing |
||||||
|
- All search, sort, and filter operations must feel instant (<50ms) |
||||||
|
- macOS 15.6+ (current project target) |
||||||
|
- No external music service integration — local files only |
||||||
|
|
||||||
|
## Data Model |
||||||
|
|
||||||
|
SQLite database via GRDB, stored at `~/Library/Application Support/Music/db.sqlite`. |
||||||
|
|
||||||
|
### `tracks` table |
||||||
|
|
||||||
|
| Column | Type | Notes | |
||||||
|
|--------|------|-------| |
||||||
|
| id | INTEGER PRIMARY KEY | Auto-increment | |
||||||
|
| fileURL | TEXT, UNIQUE | Absolute path to audio file | |
||||||
|
| title | TEXT | From metadata, fallback to filename | |
||||||
|
| artist | TEXT | | |
||||||
|
| albumArtist | TEXT | | |
||||||
|
| album | TEXT | | |
||||||
|
| genre | TEXT | | |
||||||
|
| year | INTEGER | | |
||||||
|
| trackNumber | INTEGER | | |
||||||
|
| discNumber | INTEGER | | |
||||||
|
| duration | DOUBLE | Seconds | |
||||||
|
| bpm | INTEGER | | |
||||||
|
| composer | TEXT | | |
||||||
|
| fileFormat | TEXT | mp3, m4a, flac, etc. | |
||||||
|
| bitrate | INTEGER | kbps | |
||||||
|
| sampleRate | INTEGER | Hz | |
||||||
|
| fileSize | INTEGER | Bytes | |
||||||
|
| artworkData | BLOB, nullable | Embedded cover art | |
||||||
|
| playCount | INTEGER DEFAULT 0 | Tracked by app | |
||||||
|
| lastPlayedAt | DATETIME, nullable | Tracked by app | |
||||||
|
| rating | INTEGER DEFAULT 0 | 0–5, tracked by app | |
||||||
|
| dateAdded | DATETIME | When imported | |
||||||
|
| dateModified | DATETIME | File modification date | |
||||||
|
| fileHash | TEXT | fileSize + modificationDate for change detection | |
||||||
|
|
||||||
|
### Indexes |
||||||
|
|
||||||
|
- Individual: `artist`, `album`, `genre`, `year` |
||||||
|
- Compound: `(albumArtist, album, discNumber, trackNumber)` for album browsing order |
||||||
|
|
||||||
|
### FTS5 virtual table |
||||||
|
|
||||||
|
- Indexed columns: `title`, `artist`, `albumArtist`, `album`, `genre`, `composer` |
||||||
|
- Kept in sync via GRDB FTS5 triggers |
||||||
|
|
||||||
|
## Architecture |
||||||
|
|
||||||
|
Four layers, strict downward dependency only. |
||||||
|
|
||||||
|
### UI Layer |
||||||
|
|
||||||
|
- `NSTableView` wrapped in `NSViewRepresentable` for the track list |
||||||
|
- SwiftUI for search bar and player controls |
||||||
|
- No business logic in views |
||||||
|
|
||||||
|
### ViewModel Layer |
||||||
|
|
||||||
|
**LibraryViewModel** (ObservableObject): |
||||||
|
- Holds current query state: search text, sort column, sort order |
||||||
|
- Subscribes to GRDB `ValueObservation` — receives `[Track]` on every query change |
||||||
|
- Exposes results to the NSTableView data source |
||||||
|
|
||||||
|
**PlayerViewModel** (ObservableObject): |
||||||
|
- Current track, playback state (playing/paused/stopped), current time, duration |
||||||
|
- Queue management: current queue (array of tracks), current index |
||||||
|
- Shuffle state and shuffled queue |
||||||
|
|
||||||
|
### Service Layer |
||||||
|
|
||||||
|
**DatabaseService**: |
||||||
|
- Owns the GRDB `DatabasePool` |
||||||
|
- Schema migrations |
||||||
|
- Query builders for search (FTS5 MATCH), sort (ORDER BY indexed columns), filter |
||||||
|
|
||||||
|
**ScannerService**: |
||||||
|
- Background folder walking with `FileManager` |
||||||
|
- Metadata extraction via `AVAsset.metadata` (async) |
||||||
|
- Artwork extraction from embedded tags |
||||||
|
- Incremental rescan via `fileHash` comparison |
||||||
|
- Progress reporting |
||||||
|
|
||||||
|
**AudioService**: |
||||||
|
- `AVPlayer` wrapper |
||||||
|
- Play, pause, stop, seek, next, previous, volume |
||||||
|
- Periodic time observation for progress bar |
||||||
|
- Updates `playCount`/`lastPlayedAt` in DB when track finishes or passes 50% played |
||||||
|
|
||||||
|
### Data Layer |
||||||
|
|
||||||
|
- SQLite + FTS5 via GRDB |
||||||
|
- Database file at `~/Library/Application Support/Music/db.sqlite` |
||||||
|
|
||||||
|
### Data Flows |
||||||
|
|
||||||
|
**Scan**: User picks folder → `ScannerService` walks directory on background thread → extracts metadata with `AVAsset` → inserts into DB → `ValueObservation` fires → `LibraryViewModel` updates → `NSTableView` reloads |
||||||
|
|
||||||
|
**Search**: User types in search bar → `LibraryViewModel` updates query string → new SQL query with FTS5 MATCH → `ValueObservation` emits new results → table reloads |
||||||
|
|
||||||
|
**Sort**: User clicks column header → `LibraryViewModel` updates sort column/order → new SQL ORDER BY → same reactive flow |
||||||
|
|
||||||
|
**Play**: User double-clicks row → `PlayerViewModel` receives track → `AudioService` loads file URL → playback starts → controls update |
||||||
|
|
||||||
|
## UI Layout |
||||||
|
|
||||||
|
Single window, three vertical zones. |
||||||
|
|
||||||
|
### Zone 1: Search Bar (top) |
||||||
|
|
||||||
|
- Full-width text field with placeholder: "Search by title, artist, album, genre..." |
||||||
|
- Track count label on the right (e.g., "10,342 tracks") |
||||||
|
- SwiftUI `TextField` |
||||||
|
|
||||||
|
### Zone 2: Track List (middle, fills available space) |
||||||
|
|
||||||
|
- `NSTableView` via `NSViewRepresentable` |
||||||
|
- Columns: Title, Artist, Album, Genre, Duration |
||||||
|
- Sortable by clicking column headers (ascending/descending toggle) |
||||||
|
- Currently playing track: blue left accent bar + play indicator in title cell |
||||||
|
- Alternating row colors (white / light gray) for readability |
||||||
|
- Double-click to play, single-click to select |
||||||
|
- Light theme: white backgrounds, Apple system gray text hierarchy |
||||||
|
|
||||||
|
### Zone 3: Player Controls (bottom) |
||||||
|
|
||||||
|
Three sections in a horizontal grid: |
||||||
|
|
||||||
|
**Left — Now Playing**: |
||||||
|
- Album artwork thumbnail (44×44, rounded corners) |
||||||
|
- Track title (bold) + "Artist — Album" subtitle |
||||||
|
|
||||||
|
**Center — Transport**: |
||||||
|
- Shuffle toggle (dimmed when off, highlighted when on) |
||||||
|
- Previous, Play/Pause, Next buttons |
||||||
|
- Progress bar with current time / total time |
||||||
|
|
||||||
|
**Right — Volume**: |
||||||
|
- Speaker icon + volume slider |
||||||
|
|
||||||
|
## Scanner & Import |
||||||
|
|
||||||
|
### Folder scanning (primary) |
||||||
|
|
||||||
|
- User selects root folder via `NSOpenPanel` |
||||||
|
- Recursive directory walk on background thread |
||||||
|
- Supported extensions: `.mp3`, `.m4a`, `.aac`, `.wav`, `.aiff`, `.alac`, `.flac` |
||||||
|
- Metadata extraction via `AVAsset.metadata` |
||||||
|
- Artwork from embedded tags → stored as BLOB |
||||||
|
- `fileHash` = file size + modification date (fast, sufficient for change detection) |
||||||
|
- Progress reported: "Scanning... 4,231 / 10,342 tracks" |
||||||
|
- Watched folder path stored in `UserDefaults` |
||||||
|
|
||||||
|
### Drag and drop (secondary) |
||||||
|
|
||||||
|
- Files/folders dropped onto the window |
||||||
|
- Folders recursively scanned same as above |
||||||
|
- Duplicates detected by `fileURL` uniqueness constraint — silently skipped |
||||||
|
|
||||||
|
### Incremental rescan |
||||||
|
|
||||||
|
- Triggered on app launch or manually |
||||||
|
- New files → insert |
||||||
|
- Changed files (different `fileHash`) → re-extract metadata, update row |
||||||
|
- Missing files → remove from DB |
||||||
|
|
||||||
|
## Playback & Queue |
||||||
|
|
||||||
|
### Queue model |
||||||
|
|
||||||
|
- Current track list (full library or filtered/searched results) acts as the play queue |
||||||
|
- Double-click a track → starts playing from that position in the current queue |
||||||
|
- Next/Previous navigate within the queue |
||||||
|
- Searching/filtering while playing does not interrupt playback — queue only changes on explicit play action |
||||||
|
|
||||||
|
### Shuffle |
||||||
|
|
||||||
|
- Toggle on → build shuffled copy of current queue, starting from current track |
||||||
|
- Toggle off → return to original queue position |
||||||
|
- New search/filter while shuffle is on → re-shuffle new result set |
||||||
|
|
||||||
|
### Play tracking |
||||||
|
|
||||||
|
- `playCount` incremented and `lastPlayedAt` updated when a track finishes or passes 50% played |
||||||
|
|
||||||
|
### Not in v1 |
||||||
|
|
||||||
|
- Repeat modes (repeat-one, repeat-all) |
||||||
|
- Crossfade |
||||||
|
- Equalizer |
||||||
|
- Playlists |
||||||
|
|
||||||
|
## Error Handling |
||||||
|
|
||||||
|
- **Unreadable audio file** (corrupt, DRM, unsupported) → skip during scan, log to console |
||||||
|
- **File moved/deleted after import** → AVPlayer fails to load → inline error in player controls ("File not found"), auto-advance to next track |
||||||
|
- **Metadata extraction fails** → use filename as title, "Unknown" for artist/album, still import |
||||||
|
- **Folder permission denied** → system alert, prompt to grant access in System Settings |
||||||
|
- **Database migration failure** → surface error, do not launch with broken schema |
||||||
|
|
||||||
|
## Dependencies |
||||||
|
|
||||||
|
- **GRDB** (Swift Package) — SQLite wrapper with reactive observation |
||||||
|
- **AVFoundation** (system) — audio playback and metadata extraction |
||||||
|
- **AppKit** (system) — NSTableView for high-performance track list |
||||||
|
- **SwiftUI** (system) — search bar, player controls, app structure |
||||||
|
|
||||||
|
## Audio Format Support |
||||||
|
|
||||||
|
Natively handled by AVFoundation on macOS: |
||||||
|
- MP3, AAC/M4A, WAV, AIFF, ALAC, FLAC |
||||||
Loading…
Reference in new issue