parent
e2f6d70356
commit
499558edc2
File diff suppressed because it is too large
Load Diff
@ -1,218 +0,0 @@ |
|||||||
# 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