Compare commits

...

5 Commits

Author SHA1 Message Date
Laurent 499558edc2 stuff 1 month ago
Laurent e2f6d70356 fix dimensions 1 month ago
Laurent 6399eca302 feat: wire ShazamService into app and display results in alerts 1 month ago
Laurent 104fd64631 feat: add Shazam button to SearchBarView 1 month ago
Laurent 3f788a2f81 feat: add ShazamService for music recognition 1 month ago
  1. 4
      Music.xcodeproj/project.pbxproj
  2. 25
      Music/ContentView.swift
  3. 2
      Music/MusicApp.swift
  4. 57
      Music/Services/ShazamService.swift
  5. 4
      Music/Views/PlayerControlsView.swift
  6. 11
      Music/Views/SearchBarView.swift
  7. 2092
      docs/superpowers/plans/2026-05-23-music-player.md
  8. 218
      docs/superpowers/specs/2026-05-23-music-player-design.md

@ -426,7 +426,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 895UN7FKH2;
DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
@ -460,7 +460,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 895UN7FKH2;
DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;

@ -7,6 +7,7 @@ struct ContentView: View {
var scanner: ScannerService
var audio: AudioService
var playlist: PlaylistViewModel
var shazam: ShazamService
@Binding var showNewPlaylistAlert: Bool
@State private var showRenameAlert = false
@State private var playlistNameInput = ""
@ -21,7 +22,9 @@ struct ContentView: View {
if playlist.selectedPlaylist != nil {
playlist.search(text)
}
}
},
isShazamListening: shazam.isListening,
onShazam: { shazam.isListening ? shazam.stopListening() : shazam.startListening() }
)
if scanner.isScanning {
@ -155,6 +158,26 @@ struct ContentView: View {
playlistToRename = nil
}
}
.alert("Song Identified", isPresented: Binding(
get: { shazam.matchedTitle != nil },
set: { if !$0 { shazam.clearResult() } }
)) {
Button("OK") { shazam.clearResult() }
} message: {
if let title = shazam.matchedTitle {
Text("\(title)\(shazam.matchedArtist ?? "Unknown Artist")")
}
}
.alert("Shazam Error", isPresented: Binding(
get: { shazam.errorMessage != nil },
set: { if !$0 { shazam.clearResult() } }
)) {
Button("OK") { shazam.clearResult() }
} message: {
if let error = shazam.errorMessage {
Text(error)
}
}
}
private func handleDrop(_ providers: [NSItemProvider]) {

@ -7,6 +7,7 @@ struct MusicApp: App {
@State private var playerVM: PlayerViewModel?
@State private var scannerService: ScannerService?
@State private var audioService = AudioService()
@State private var shazamService = ShazamService()
@State private var playlistVM: PlaylistViewModel?
@State private var showNewPlaylistAlert = false
@State private var initError: String?
@ -25,6 +26,7 @@ struct MusicApp: App {
scanner: scanner,
audio: audioService,
playlist: playlist,
shazam: shazamService,
showNewPlaylistAlert: $showNewPlaylistAlert
)
} else if let error = initError {

@ -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
}
}

@ -98,7 +98,7 @@ struct PlayerControlsView: View {
Text(Self.formatTime(currentTime))
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 35, alignment: .trailing)
.frame(width: 45, alignment: .trailing)
Slider(
value: Binding(
@ -112,7 +112,7 @@ struct PlayerControlsView: View {
Text(Self.formatTime(duration))
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 35, alignment: .leading)
.frame(width: 45, alignment: .leading)
}
}
.frame(maxWidth: 400)

@ -4,6 +4,8 @@ struct SearchBarView: View {
@State private var searchText = ""
let trackCount: Int
let onSearch: (String) -> Void
let isShazamListening: Bool
let onShazam: () -> Void
var body: some View {
HStack(spacing: 12) {
@ -33,6 +35,15 @@ struct SearchBarView: View {
Text("\(trackCount) tracks")
.font(.caption)
.foregroundStyle(.secondary)
Button(action: onShazam) {
Image(systemName: "shazam.logo")
.font(.system(size: 16))
.foregroundStyle(isShazamListening ? .blue : .secondary)
.symbolEffect(.pulse, isActive: isShazamListening)
}
.buttonStyle(.plain)
.help(isShazamListening ? "Stop listening" : "Identify song with Shazam")
}
.padding(.horizontal, 16)
.padding(.vertical, 8)

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…
Cancel
Save