Compare commits
5 Commits
66fb024b51
...
499558edc2
| Author | SHA1 | Date |
|---|---|---|
|
|
499558edc2 | 1 month ago |
|
|
e2f6d70356 | 1 month ago |
|
|
6399eca302 | 1 month ago |
|
|
104fd64631 | 1 month ago |
|
|
3f788a2f81 | 1 month ago |
@ -0,0 +1,57 @@ |
||||
import Observation |
||||
import ShazamKit |
||||
|
||||
@Observable |
||||
final class ShazamService { |
||||
var isListening = false |
||||
var matchedTitle: String? |
||||
var matchedArtist: String? |
||||
var errorMessage: String? |
||||
|
||||
private let session = SHManagedSession() |
||||
private var listeningTask: Task<Void, Never>? |
||||
|
||||
func startListening() { |
||||
guard !isListening else { |
||||
stopListening() |
||||
return |
||||
} |
||||
|
||||
matchedTitle = nil |
||||
matchedArtist = nil |
||||
errorMessage = nil |
||||
isListening = true |
||||
|
||||
listeningTask = Task { |
||||
let result = await session.result() |
||||
guard !Task.isCancelled else { return } |
||||
switch result { |
||||
case .match(let match): |
||||
if let item = match.mediaItems.first { |
||||
matchedTitle = item.title |
||||
matchedArtist = item.artist |
||||
} |
||||
case .noMatch: |
||||
errorMessage = "No match found. Try again in a quieter environment." |
||||
case .error(let error, _): |
||||
errorMessage = "Recognition failed: \(error.localizedDescription)" |
||||
@unknown default: |
||||
break |
||||
} |
||||
isListening = false |
||||
} |
||||
} |
||||
|
||||
func stopListening() { |
||||
listeningTask?.cancel() |
||||
listeningTask = nil |
||||
session.cancel() |
||||
isListening = false |
||||
} |
||||
|
||||
func clearResult() { |
||||
matchedTitle = nil |
||||
matchedArtist = nil |
||||
errorMessage = nil |
||||
} |
||||
} |
||||
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