From 499558edc20eb79defef3ea8322a695eba86b1bd Mon Sep 17 00:00:00 2001 From: Laurent Date: Mon, 25 May 2026 12:05:04 +0200 Subject: [PATCH] stuff --- Music.xcodeproj/project.pbxproj | 4 +- Music/Services/ShazamService.swift | 72 +- .../plans/2026-05-23-music-player.md | 2092 ----------------- .../specs/2026-05-23-music-player-design.md | 218 -- 4 files changed, 25 insertions(+), 2361 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-23-music-player.md delete mode 100644 docs/superpowers/specs/2026-05-23-music-player-design.md diff --git a/Music.xcodeproj/project.pbxproj b/Music.xcodeproj/project.pbxproj index 1e294f4..1f654a0 100644 --- a/Music.xcodeproj/project.pbxproj +++ b/Music.xcodeproj/project.pbxproj @@ -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; diff --git a/Music/Services/ShazamService.swift b/Music/Services/ShazamService.swift index b9c5f32..4403a7c 100644 --- a/Music/Services/ShazamService.swift +++ b/Music/Services/ShazamService.swift @@ -1,17 +1,15 @@ -import AVFAudio import Observation import ShazamKit @Observable -final class ShazamService: NSObject, SHSessionDelegate { +final class ShazamService { var isListening = false var matchedTitle: String? var matchedArtist: String? var errorMessage: String? - private var session: SHSession? - private let audioEngine = AVAudioEngine() - private var timeoutTask: Task? + private let session = SHManagedSession() + private var listeningTask: Task? func startListening() { guard !isListening else { @@ -22,43 +20,32 @@ final class ShazamService: NSObject, SHSessionDelegate { matchedTitle = nil matchedArtist = nil errorMessage = nil - - let session = SHSession() - session.delegate = self - self.session = session - - let inputNode = audioEngine.inputNode - let format = inputNode.outputFormat(forBus: 0) - - inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { buffer, time in - session.matchStreamingBuffer(buffer, at: time) - } - - do { - try audioEngine.start() - isListening = true - - timeoutTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(15)) - guard !Task.isCancelled else { return } - self?.stopListening() - if self?.matchedTitle == nil { - self?.errorMessage = "No match found. Try again in a quieter environment." + 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 } - } catch { - errorMessage = "Could not access microphone." + isListening = false } } func stopListening() { - timeoutTask?.cancel() - timeoutTask = nil - if audioEngine.isRunning { - audioEngine.inputNode.removeTap(onBus: 0) - audioEngine.stop() - } - session = nil + listeningTask?.cancel() + listeningTask = nil + session.cancel() isListening = false } @@ -67,17 +54,4 @@ final class ShazamService: NSObject, SHSessionDelegate { matchedArtist = nil errorMessage = nil } - - nonisolated func session(_ session: SHSession, didFind match: SHMatch) { - Task { @MainActor [weak self] in - guard let item = match.mediaItems.first else { return } - self?.matchedTitle = item.title - self?.matchedArtist = item.artist - self?.stopListening() - } - } - - nonisolated func session(_ session: SHSession, didNotFindMatchFor signature: SHSignature) { - // Session continues listening; the 15s timeout handles the "no match" case. - } } diff --git a/docs/superpowers/plans/2026-05-23-music-player.md b/docs/superpowers/plans/2026-05-23-music-player.md deleted file mode 100644 index 6eb855a..0000000 --- a/docs/superpowers/plans/2026-05-23-music-player.md +++ /dev/null @@ -1,2092 +0,0 @@ -# Music Player Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build a high-performance macOS music player that scans local audio files, indexes metadata into SQLite with FTS5 full-text search, and plays music via AVPlayer — all behind a responsive NSTableView-based UI. - -**Architecture:** Four-layer architecture (UI → ViewModel → Service → Data) with strict downward dependencies. GRDB's reactive `ValueObservation` drives the UI: any database change (scan, search, sort) automatically propagates to the track list. NSTableView provides native virtualized rendering for 10K+ rows. - -**Tech Stack:** SwiftUI + AppKit (NSTableView via NSViewRepresentable), GRDB 7.x (SQLite + FTS5), AVFoundation (AVPlayer + AVAsset metadata), Swift Testing, macOS 15.6+ - -**Spec:** `docs/superpowers/specs/2026-05-23-music-player-design.md` - ---- - -## File Structure - -``` -Music/ -├── MusicApp.swift (modify — dependency injection, folder picker menu) -├── ContentView.swift (modify — 3-zone layout: search, table, controls) -├── Models/ -│ └── Track.swift (create — GRDB record with all metadata fields) -├── Services/ -│ ├── DatabaseService.swift (create — schema, migrations, FTS5, queries) -│ ├── ScannerService.swift (create — folder walking, metadata extraction) -│ └── AudioService.swift (create — AVPlayer wrapper) -├── ViewModels/ -│ ├── LibraryViewModel.swift (create — reactive query state, search/sort) -│ └── PlayerViewModel.swift (create — queue, shuffle, playback coordination) -├── Views/ -│ ├── SearchBarView.swift (create — search field + track count) -│ ├── TrackTableView.swift (create — NSTableView in NSViewRepresentable) -│ └── PlayerControlsView.swift (create — now playing, transport, volume) - -MusicTests/ -├── MusicTests.swift (delete — template file, not needed) -├── TrackTests.swift (create — model encoding/decoding) -├── DatabaseServiceTests.swift (create — CRUD, search, sort, FTS5) -├── ScannerServiceTests.swift (create — file discovery) -├── PlayerViewModelTests.swift (create — queue, shuffle, next/prev) -``` - ---- - -### Task 1: Project Setup - -**Files:** -- Modify: `Music.xcodeproj` (add GRDB SPM dependency) -- Create: `Music/Models/` directory -- Create: `Music/Services/` directory -- Create: `Music/ViewModels/` directory -- Create: `Music/Views/` directory - -- [ ] **Step 1: Initialize git repository** - -```bash -cd /Users/laurentmorvillier/code/Music -git init -echo ".superpowers/" >> .gitignore -echo ".DS_Store" >> .gitignore -echo "*.xcuserdata" >> .gitignore -``` - -- [ ] **Step 2: Add GRDB.swift as SPM dependency** - -Add GRDB.swift to the Music target via Xcode's SPM integration: -- URL: `https://github.com/groue/GRDB.swift` -- Version: 7.0.0 up to next major -- Product: `GRDB` linked to the `Music` target - -Also link `GRDB` to the `MusicTests` target so tests can use in-memory databases. - -- [ ] **Step 3: Create directory structure** - -```bash -mkdir -p Music/Models Music/Services Music/ViewModels Music/Views -``` - -- [ ] **Step 4: Verify build** - -```bash -xcodebuild build -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | tail -5 -``` - -Expected: `** BUILD SUCCEEDED **` - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "chore: initialize project with GRDB dependency and directory structure" -``` - ---- - -### Task 2: Track Model - -**Files:** -- Create: `Music/Models/Track.swift` -- Create: `MusicTests/TrackTests.swift` -- Delete: `MusicTests/MusicTests.swift` - -- [ ] **Step 1: Create Track.swift** - -Create `Music/Models/Track.swift` with the complete GRDB record type. Add this file to the `Music` target. - -```swift -import Foundation -import GRDB - -struct Track: Codable, Identifiable, Equatable, Hashable { - var id: Int64? - var fileURL: String - var title: String - var artist: String - var albumArtist: String - var album: String - var genre: String - var year: Int? - var trackNumber: Int? - var discNumber: Int? - var duration: Double - var bpm: Int? - var composer: String - var fileFormat: String - var bitrate: Int? - var sampleRate: Int? - var fileSize: Int64 - var artworkData: Data? - var playCount: Int - var lastPlayedAt: Date? - var rating: Int - var dateAdded: Date - var dateModified: Date - var fileHash: String - - static func computeHash(fileSize: Int64, modificationDate: Date) -> String { - "\(fileSize)_\(Int(modificationDate.timeIntervalSince1970))" - } -} - -extension Track: FetchableRecord, MutablePersistableRecord { - static let databaseTableName = "tracks" - - mutating func didInsert(_ inserted: InsertionSuccess) { - id = inserted.rowID - } -} -``` - -- [ ] **Step 2: Create test fixture helper** - -Add a static `fixture` method at the bottom of `Track.swift` for use in tests: - -```swift -#if DEBUG -extension Track { - static func fixture( - id: Int64? = nil, - fileURL: String = "/tmp/test.mp3", - title: String = "Test Song", - artist: String = "Test Artist", - albumArtist: String = "Test Artist", - album: String = "Test Album", - genre: String = "Rock", - year: Int? = 2024, - trackNumber: Int? = 1, - discNumber: Int? = 1, - duration: Double = 210.0, - bpm: Int? = 120, - composer: String = "Test Composer", - fileFormat: String = "mp3", - bitrate: Int? = 320, - sampleRate: Int? = 44100, - fileSize: Int64 = 5_000_000, - artworkData: Data? = nil, - playCount: Int = 0, - lastPlayedAt: Date? = nil, - rating: Int = 0, - dateAdded: Date = Date(), - dateModified: Date = Date(), - fileHash: String = "5000000_1700000000" - ) -> Track { - Track( - id: id, fileURL: fileURL, title: title, artist: artist, - albumArtist: albumArtist, album: album, genre: genre, year: year, - trackNumber: trackNumber, discNumber: discNumber, duration: duration, - bpm: bpm, composer: composer, fileFormat: fileFormat, bitrate: bitrate, - sampleRate: sampleRate, fileSize: fileSize, artworkData: artworkData, - playCount: playCount, lastPlayedAt: lastPlayedAt, rating: rating, - dateAdded: dateAdded, dateModified: dateModified, fileHash: fileHash - ) - } -} -#endif -``` - -- [ ] **Step 3: Write tests for Track** - -Delete `MusicTests/MusicTests.swift`. Create `MusicTests/TrackTests.swift` and add it to the `MusicTests` target: - -```swift -import Testing -import GRDB -@testable import Music - -struct TrackTests { - // Verifies that Track can round-trip through GRDB's encoding/decoding, - // meaning it correctly conforms to FetchableRecord and PersistableRecord. - @Test func roundTripThroughDatabase() throws { - let dbQueue = try DatabaseQueue() - try dbQueue.write { db in - try db.create(table: "tracks") { t in - t.autoIncrementedPrimaryKey("id") - t.column("fileURL", .text).notNull().unique() - t.column("title", .text).notNull() - t.column("artist", .text).notNull() - t.column("albumArtist", .text).notNull() - t.column("album", .text).notNull() - t.column("genre", .text).notNull() - t.column("year", .integer) - t.column("trackNumber", .integer) - t.column("discNumber", .integer) - t.column("duration", .double).notNull() - t.column("bpm", .integer) - t.column("composer", .text).notNull() - t.column("fileFormat", .text).notNull() - t.column("bitrate", .integer) - t.column("sampleRate", .integer) - t.column("fileSize", .integer).notNull() - t.column("artworkData", .blob) - t.column("playCount", .integer).notNull().defaults(to: 0) - t.column("lastPlayedAt", .datetime) - t.column("rating", .integer).notNull().defaults(to: 0) - t.column("dateAdded", .datetime).notNull() - t.column("dateModified", .datetime).notNull() - t.column("fileHash", .text).notNull() - } - - var track = Track.fixture(title: "Bohemian Rhapsody", artist: "Queen") - try track.insert(db) - - #expect(track.id != nil) - - let fetched = try Track.fetchOne(db, key: track.id) - #expect(fetched?.title == "Bohemian Rhapsody") - #expect(fetched?.artist == "Queen") - #expect(fetched?.duration == 210.0) - } - } - - // Verifies that didInsert assigns the auto-incremented ID after insertion. - @Test func didInsertAssignsId() throws { - let dbQueue = try DatabaseQueue() - try dbQueue.write { db in - try db.create(table: "tracks") { t in - t.autoIncrementedPrimaryKey("id") - t.column("fileURL", .text).notNull().unique() - t.column("title", .text).notNull() - t.column("artist", .text).notNull() - t.column("albumArtist", .text).notNull() - t.column("album", .text).notNull() - t.column("genre", .text).notNull() - t.column("year", .integer) - t.column("trackNumber", .integer) - t.column("discNumber", .integer) - t.column("duration", .double).notNull() - t.column("bpm", .integer) - t.column("composer", .text).notNull() - t.column("fileFormat", .text).notNull() - t.column("bitrate", .integer) - t.column("sampleRate", .integer) - t.column("fileSize", .integer).notNull() - t.column("artworkData", .blob) - t.column("playCount", .integer).notNull().defaults(to: 0) - t.column("lastPlayedAt", .datetime) - t.column("rating", .integer).notNull().defaults(to: 0) - t.column("dateAdded", .datetime).notNull() - t.column("dateModified", .datetime).notNull() - t.column("fileHash", .text).notNull() - } - - var track = Track.fixture() - #expect(track.id == nil) - try track.insert(db) - #expect(track.id != nil) - } - } - - // Verifies the fileHash computation produces a deterministic string from size + date. - @Test func computeHashIsDeterministic() { - let date = Date(timeIntervalSince1970: 1700000000) - let hash1 = Track.computeHash(fileSize: 5_000_000, modificationDate: date) - let hash2 = Track.computeHash(fileSize: 5_000_000, modificationDate: date) - #expect(hash1 == hash2) - #expect(hash1 == "5000000_1700000000") - - let different = Track.computeHash(fileSize: 999, modificationDate: date) - #expect(different != hash1) - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -xcodebuild test -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "(Test Case|passed|failed|SUCCEEDED|FAILED)" -``` - -Expected: All 3 tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add Music/Models/Track.swift MusicTests/TrackTests.swift -git rm MusicTests/MusicTests.swift -git commit -m "feat: add Track model with GRDB record conformance and tests" -``` - ---- - -### Task 3: DatabaseService - -**Files:** -- Create: `Music/Services/DatabaseService.swift` -- Create: `MusicTests/DatabaseServiceTests.swift` - -- [ ] **Step 1: Write failing test for database creation and track insertion** - -Create `MusicTests/DatabaseServiceTests.swift` and add it to the `MusicTests` target: - -```swift -import Testing -import GRDB -@testable import Music - -struct DatabaseServiceTests { - // Creates an in-memory DatabaseService, inserts a track, and fetches it back. - @Test func insertAndFetchTrack() throws { - let db = try DatabaseService(inMemory: true) - var track = Track.fixture(title: "Test Song", artist: "Test Artist") - try db.insert(&track) - - let tracks = try db.fetchTracks(search: "", sortColumn: "title", ascending: true) - #expect(tracks.count == 1) - #expect(tracks[0].title == "Test Song") - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -xcodebuild test -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "(Test Case|passed|failed|error:)" -``` - -Expected: FAIL — `DatabaseService` not defined. - -- [ ] **Step 3: Implement DatabaseService** - -Create `Music/Services/DatabaseService.swift` and add it to the `Music` target: - -```swift -import Foundation -import GRDB - -final class DatabaseService: Sendable { - let dbPool: DatabaseWriter - - init(path: String) throws { - let dbPool = try DatabasePool(path: path) - self.dbPool = dbPool - try Self.migrate(dbPool) - } - - init(inMemory: Bool) throws { - let dbQueue = try DatabaseQueue() - self.dbPool = dbQueue - try Self.migrate(dbQueue) - } - - private static func migrate(_ db: DatabaseWriter) throws { - var migrator = DatabaseMigrator() - - migrator.registerMigration("v1-create-tracks") { db in - try db.create(table: "tracks") { t in - t.autoIncrementedPrimaryKey("id") - t.column("fileURL", .text).notNull().unique() - t.column("title", .text).notNull() - t.column("artist", .text).notNull() - t.column("albumArtist", .text).notNull() - t.column("album", .text).notNull() - t.column("genre", .text).notNull() - t.column("year", .integer) - t.column("trackNumber", .integer) - t.column("discNumber", .integer) - t.column("duration", .double).notNull() - t.column("bpm", .integer) - t.column("composer", .text).notNull() - t.column("fileFormat", .text).notNull() - t.column("bitrate", .integer) - t.column("sampleRate", .integer) - t.column("fileSize", .integer).notNull() - t.column("artworkData", .blob) - t.column("playCount", .integer).notNull().defaults(to: 0) - t.column("lastPlayedAt", .datetime) - t.column("rating", .integer).notNull().defaults(to: 0) - t.column("dateAdded", .datetime).notNull() - t.column("dateModified", .datetime).notNull() - t.column("fileHash", .text).notNull() - } - - try db.create(index: "idx_tracks_artist", on: "tracks", columns: ["artist"]) - try db.create(index: "idx_tracks_album", on: "tracks", columns: ["album"]) - try db.create(index: "idx_tracks_genre", on: "tracks", columns: ["genre"]) - try db.create(index: "idx_tracks_year", on: "tracks", columns: ["year"]) - try db.create( - index: "idx_tracks_album_order", - on: "tracks", - columns: ["albumArtist", "album", "discNumber", "trackNumber"] - ) - - try db.create(virtualTable: "tracks_ft", using: FTS5()) { t in - t.synchronize(withTable: "tracks") - t.tokenize(with: .unicode61()) - t.column("title") - t.column("artist") - t.column("albumArtist") - t.column("album") - t.column("genre") - t.column("composer") - } - } - - try migrator.migrate(db) - } - - // MARK: - Write - - func insert(_ track: inout Track) throws { - try dbPool.write { db in - try track.insert(db) - } - } - - func insertBatch(_ tracks: [Track]) throws { - try dbPool.write { db in - for var track in tracks { - try track.insert(db, onConflict: .ignore) - } - } - } - - func updatePlayStats(trackId: Int64, playCount: Int, lastPlayedAt: Date) throws { - try dbPool.write { db in - try db.execute( - sql: "UPDATE tracks SET playCount = ?, lastPlayedAt = ? WHERE id = ?", - arguments: [playCount, lastPlayedAt, trackId] - ) - } - } - - func deleteTracksWithURLs(_ urls: Set) throws { - try dbPool.write { db in - let placeholders = databaseQuestionMarks(count: urls.count) - try db.execute( - sql: "DELETE FROM tracks WHERE fileURL IN (\(placeholders))", - arguments: StatementArguments(Array(urls)) - ) - } - } - - // MARK: - Read - - private static let validSortColumns: Set = [ - "title", "artist", "albumArtist", "album", "genre", "year", "duration", - "trackNumber", "dateAdded", "playCount", "rating", "bpm" - ] - - func fetchTracks(search: String, sortColumn: String, ascending: Bool) throws -> [Track] { - try dbPool.read { db in - let col = Self.validSortColumns.contains(sortColumn) ? sortColumn : "title" - let order = ascending ? "ASC" : "DESC" - - if search.trimmingCharacters(in: .whitespaces).isEmpty { - return try Track.fetchAll( - db, - sql: "SELECT * FROM tracks ORDER BY \(col) COLLATE NOCASE \(order)" - ) - } - - let terms = search.split(separator: " ").map { "\($0)*" } - let pattern = terms.joined(separator: " ") - - return try Track.fetchAll( - db, - sql: """ - SELECT tracks.* FROM tracks - JOIN tracks_ft ON tracks_ft.rowid = tracks.id - WHERE tracks_ft MATCH ? - ORDER BY \(col) COLLATE NOCASE \(order) - """, - arguments: [pattern] - ) - } - } - - func allFileURLs() throws -> Set { - try dbPool.read { db in - let urls = try String.fetchAll(db, sql: "SELECT fileURL FROM tracks") - return Set(urls) - } - } - - func trackCount() throws -> Int { - try dbPool.read { db in - try Track.fetchCount(db) - } - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -```bash -xcodebuild test -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "(Test Case|passed|failed|SUCCEEDED|FAILED)" -``` - -Expected: `insertAndFetchTrack` passes. - -- [ ] **Step 5: Write remaining database tests** - -Add these tests to `DatabaseServiceTests.swift`: - -```swift - // Inserts multiple tracks and verifies sorting by different columns. - @Test func fetchTracksSortedByArtist() throws { - let db = try DatabaseService(inMemory: true) - var t1 = Track.fixture(fileURL: "/a.mp3", title: "Zebra", artist: "Alpha") - var t2 = Track.fixture(fileURL: "/b.mp3", title: "Alpha", artist: "Zebra") - try db.insert(&t1) - try db.insert(&t2) - - let ascending = try db.fetchTracks(search: "", sortColumn: "artist", ascending: true) - #expect(ascending[0].artist == "Alpha") - #expect(ascending[1].artist == "Zebra") - - let descending = try db.fetchTracks(search: "", sortColumn: "artist", ascending: false) - #expect(descending[0].artist == "Zebra") - } - - // Searches using FTS5 and verifies only matching tracks are returned. - @Test func fts5Search() throws { - let db = try DatabaseService(inMemory: true) - var t1 = Track.fixture(fileURL: "/a.mp3", title: "Bohemian Rhapsody", artist: "Queen") - var t2 = Track.fixture(fileURL: "/b.mp3", title: "Hotel California", artist: "Eagles") - var t3 = Track.fixture(fileURL: "/c.mp3", title: "Stairway to Heaven", artist: "Led Zeppelin") - try db.insert(&t1) - try db.insert(&t2) - try db.insert(&t3) - - let results = try db.fetchTracks(search: "queen", sortColumn: "title", ascending: true) - #expect(results.count == 1) - #expect(results[0].title == "Bohemian Rhapsody") - } - - // Searches with a prefix to verify autocomplete-style matching. - @Test func fts5PrefixSearch() throws { - let db = try DatabaseService(inMemory: true) - var t1 = Track.fixture(fileURL: "/a.mp3", title: "Bohemian Rhapsody", artist: "Queen") - var t2 = Track.fixture(fileURL: "/b.mp3", title: "Hotel California", artist: "Eagles") - try db.insert(&t1) - try db.insert(&t2) - - let results = try db.fetchTracks(search: "boh", sortColumn: "title", ascending: true) - #expect(results.count == 1) - #expect(results[0].title == "Bohemian Rhapsody") - } - - // Inserts a batch of tracks and verifies duplicates are silently ignored. - @Test func batchInsertIgnoresDuplicates() throws { - let db = try DatabaseService(inMemory: true) - let tracks = [ - Track.fixture(fileURL: "/a.mp3", title: "Song A"), - Track.fixture(fileURL: "/b.mp3", title: "Song B"), - Track.fixture(fileURL: "/a.mp3", title: "Song A Duplicate"), - ] - try db.insertBatch(tracks) - - let all = try db.fetchTracks(search: "", sortColumn: "title", ascending: true) - #expect(all.count == 2) - } - - // Updates play stats and verifies they persist. - @Test func updatePlayStats() throws { - let db = try DatabaseService(inMemory: true) - var track = Track.fixture() - try db.insert(&track) - - let now = Date() - try db.updatePlayStats(trackId: track.id!, playCount: 5, lastPlayedAt: now) - - let fetched = try db.fetchTracks(search: "", sortColumn: "title", ascending: true) - #expect(fetched[0].playCount == 5) - #expect(fetched[0].lastPlayedAt != nil) - } - - // Validates that an invalid sort column falls back to "title". - @Test func invalidSortColumnFallsBackToTitle() throws { - let db = try DatabaseService(inMemory: true) - var t1 = Track.fixture(fileURL: "/a.mp3", title: "Zebra") - var t2 = Track.fixture(fileURL: "/b.mp3", title: "Alpha") - try db.insert(&t1) - try db.insert(&t2) - - let result = try db.fetchTracks(search: "", sortColumn: "DROP TABLE tracks", ascending: true) - #expect(result[0].title == "Alpha") - } -``` - -- [ ] **Step 6: Run all tests** - -```bash -xcodebuild test -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "(Test Case|passed|failed|SUCCEEDED|FAILED)" -``` - -Expected: All tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add Music/Services/DatabaseService.swift MusicTests/DatabaseServiceTests.swift -git commit -m "feat: add DatabaseService with schema, FTS5 search, and query methods" -``` - ---- - -### Task 4: ScannerService - -**Files:** -- Create: `Music/Services/ScannerService.swift` -- Create: `MusicTests/ScannerServiceTests.swift` - -- [ ] **Step 1: Write failing test for file discovery** - -Create `MusicTests/ScannerServiceTests.swift` and add it to the `MusicTests` target: - -```swift -import Testing -import Foundation -@testable import Music - -struct ScannerServiceTests { - // Creates a temp directory with audio and non-audio files, verifies only audio files are discovered. - @Test func discoverAudioFiles() throws { - let tmpDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tmpDir) } - - // Create test files (empty files with correct extensions) - let audioFiles = ["song.mp3", "track.m4a", "audio.flac", "sound.wav", "music.aiff"] - let nonAudioFiles = ["readme.txt", "image.png", "data.json"] - - for name in audioFiles + nonAudioFiles { - FileManager.default.createFile(atPath: tmpDir.appendingPathComponent(name).path, contents: Data()) - } - - // Create a subdirectory with one more audio file - let subDir = tmpDir.appendingPathComponent("subfolder") - try FileManager.default.createDirectory(at: subDir, withIntermediateDirectories: true) - FileManager.default.createFile(atPath: subDir.appendingPathComponent("nested.mp3").path, contents: Data()) - - let discovered = ScannerService.discoverAudioFiles(in: tmpDir) - #expect(discovered.count == 6) - - let extensions = Set(discovered.map { $0.pathExtension.lowercased() }) - #expect(extensions.isSubset(of: ["mp3", "m4a", "flac", "wav", "aiff", "aac", "alac"])) - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -xcodebuild test -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "(error:|ScannerService)" -``` - -Expected: FAIL — `ScannerService` not defined. - -- [ ] **Step 3: Implement ScannerService** - -Create `Music/Services/ScannerService.swift` and add it to the `Music` target: - -```swift -import Foundation -import AVFoundation - -@Observable -final class ScannerService { - var isScanning = false - var scanProgress: (current: Int, total: Int) = (0, 0) - - private let db: DatabaseService - - init(db: DatabaseService) { - self.db = db - } - - static let audioExtensions: Set = ["mp3", "m4a", "aac", "wav", "aiff", "alac", "flac"] - - static func discoverAudioFiles(in folder: URL) -> [URL] { - var results: [URL] = [] - guard let enumerator = FileManager.default.enumerator( - at: folder, - includingPropertiesForKeys: [.isRegularFileKey], - options: [.skipsHiddenFiles] - ) else { return results } - - while let url = enumerator.nextObject() as? URL { - if audioExtensions.contains(url.pathExtension.lowercased()) { - results.append(url) - } - } - return results - } - - func scanFolder(_ folder: URL) async { - isScanning = true - defer { isScanning = false } - - let audioFiles = Self.discoverAudioFiles(in: folder) - scanProgress = (0, audioFiles.count) - - let existingURLs = (try? db.allFileURLs()) ?? [] - let batchSize = 50 - - for batchStart in stride(from: 0, to: audioFiles.count, by: batchSize) { - let batchEnd = min(batchStart + batchSize, audioFiles.count) - let batch = Array(audioFiles[batchStart.. Track? { - let asset = AVURLAsset(url: url) - - var title = url.deletingPathExtension().lastPathComponent - var artist = "Unknown" - var albumArtist = "Unknown" - var album = "Unknown" - var genre = "" - var year: Int? - var trackNumber: Int? - var discNumber: Int? - var bpm: Int? - var composer = "" - var artworkData: Data? - - do { - let metadata = try await asset.load(.metadata) - - for item in metadata { - guard let key = item.commonKey else { continue } - switch key { - case .commonKeyTitle: - title = (try? await item.load(.stringValue)) ?? title - case .commonKeyArtist: - let val = (try? await item.load(.stringValue)) ?? "Unknown" - artist = val - if albumArtist == "Unknown" { albumArtist = val } - case .commonKeyAlbumName: - album = (try? await item.load(.stringValue)) ?? "Unknown" - case .commonKeyArtwork: - artworkData = try? await item.load(.dataValue) - case .commonKeyCreator: - composer = (try? await item.load(.stringValue)) ?? "" - default: - break - } - } - - // Format-specific metadata (ID3 / iTunes) - for item in metadata { - if let identifier = item.identifier { - switch identifier { - case .id3MetadataContentType, .iTunesMetadataUserGenre: - genre = (try? await item.load(.stringValue)) ?? genre - case .id3MetadataTrackNumber: - if let val = try? await item.load(.stringValue), - let num = Int(val.split(separator: "/").first ?? "") { - trackNumber = num - } - case .id3MetadataPartOfASet: - if let val = try? await item.load(.stringValue), - let num = Int(val.split(separator: "/").first ?? "") { - discNumber = num - } - case .id3MetadataBeatsPerMinute: - if let val = try? await item.load(.stringValue) { - bpm = Int(val) - } - case .id3MetadataBand: - albumArtist = (try? await item.load(.stringValue)) ?? albumArtist - case .id3MetadataYear: - if let val = try? await item.load(.stringValue) { - year = Int(val) - } - default: - break - } - } - } - - let duration = try await asset.load(.duration) - let durationSeconds = CMTimeGetSeconds(duration) - - // Audio track properties - var bitrate: Int? - var sampleRate: Int? - if let audioTrack = try await asset.loadTracks(withMediaType: .audio).first { - let estimatedRate = try await audioTrack.load(.estimatedDataRate) - bitrate = Int(estimatedRate / 1000) - let descriptions = try await audioTrack.load(.formatDescriptions) - if let desc = descriptions.first { - if let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) { - sampleRate = Int(asbd.pointee.mSampleRate) - } - } - } - - // File attributes - let attrs = try FileManager.default.attributesOfItem(atPath: url.path) - let fileSize = attrs[.size] as? Int64 ?? 0 - let modDate = attrs[.modificationDate] as? Date ?? Date() - - return Track( - fileURL: url.absoluteString, - title: title, - artist: artist, - albumArtist: albumArtist, - album: album, - genre: genre, - year: year, - trackNumber: trackNumber, - discNumber: discNumber, - duration: durationSeconds.isNaN ? 0 : durationSeconds, - bpm: bpm, - composer: composer, - fileFormat: url.pathExtension.lowercased(), - bitrate: bitrate, - sampleRate: sampleRate, - fileSize: fileSize, - artworkData: artworkData, - playCount: 0, - lastPlayedAt: nil, - rating: 0, - dateAdded: Date(), - dateModified: modDate, - fileHash: Track.computeHash(fileSize: fileSize, modificationDate: modDate) - ) - } catch { - print("Failed to extract metadata from \(url.lastPathComponent): \(error)") - return nil - } - } -} -``` - -- [ ] **Step 4: Run tests** - -```bash -xcodebuild test -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "(Test Case|passed|failed|SUCCEEDED|FAILED)" -``` - -Expected: `discoverAudioFiles` passes. All previous tests still pass. - -- [ ] **Step 5: Commit** - -```bash -git add Music/Services/ScannerService.swift MusicTests/ScannerServiceTests.swift -git commit -m "feat: add ScannerService with file discovery, metadata extraction, and batch import" -``` - ---- - -### Task 5: AudioService - -**Files:** -- Create: `Music/Services/AudioService.swift` - -- [ ] **Step 1: Create AudioService** - -Create `Music/Services/AudioService.swift` and add it to the `Music` target. This wraps AVPlayer for playback control. - -```swift -import AVFoundation -import Observation - -@Observable -final class AudioService { - var isPlaying = false - var currentTime: Double = 0 - var duration: Double = 0 - var volume: Float = 0.65 { - didSet { player?.volume = volume } - } - - private var player: AVPlayer? - private var timeObserver: Any? - private var endObserver: NSObjectProtocol? - - var onTrackFinished: (() -> Void)? - - func play(url: URL) { - cleanup() - - let item = AVPlayerItem(url: url) - player = AVPlayer(playerItem: item) - player?.volume = volume - - timeObserver = player?.addPeriodicTimeObserver( - forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), - queue: .main - ) { [weak self] time in - guard let self else { return } - self.currentTime = time.seconds - if let dur = self.player?.currentItem?.duration, dur.isValid, !dur.isIndefinite { - self.duration = dur.seconds - } - } - - endObserver = NotificationCenter.default.addObserver( - forName: .AVPlayerItemDidPlayToEndTime, - object: item, - queue: .main - ) { [weak self] _ in - self?.isPlaying = false - self?.currentTime = 0 - self?.onTrackFinished?() - } - - player?.play() - isPlaying = true - } - - func pause() { - player?.pause() - isPlaying = false - } - - func resume() { - player?.play() - isPlaying = true - } - - func togglePlayPause() { - if isPlaying { pause() } else { resume() } - } - - func seek(to time: Double) { - player?.seek(to: CMTime(seconds: time, preferredTimescale: 600)) - } - - func stop() { - cleanup() - isPlaying = false - currentTime = 0 - duration = 0 - } - - private func cleanup() { - if let obs = timeObserver { - player?.removeTimeObserver(obs) - timeObserver = nil - } - if let obs = endObserver { - NotificationCenter.default.removeObserver(obs) - endObserver = nil - } - player?.pause() - player = nil - } - - deinit { - cleanup() - } -} -``` - -- [ ] **Step 2: Verify build** - -```bash -xcodebuild build -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | tail -5 -``` - -Expected: `** BUILD SUCCEEDED **` - -- [ ] **Step 3: Commit** - -```bash -git add Music/Services/AudioService.swift -git commit -m "feat: add AudioService with AVPlayer playback, seeking, and volume control" -``` - ---- - -### Task 6: PlayerViewModel - -**Files:** -- Create: `Music/ViewModels/PlayerViewModel.swift` -- Create: `MusicTests/PlayerViewModelTests.swift` - -- [ ] **Step 1: Write failing tests for queue management** - -Create `MusicTests/PlayerViewModelTests.swift` and add it to the `MusicTests` target: - -```swift -import Testing -import Foundation -@testable import Music - -struct PlayerViewModelTests { - private func makeTracks(_ count: Int) -> [Track] { - (0..&1 | grep -E "(error:|PlayerViewModel)" -``` - -Expected: FAIL — `PlayerViewModel` not defined. - -- [ ] **Step 3: Implement PlayerViewModel** - -Create `Music/ViewModels/PlayerViewModel.swift` and add it to the `Music` target: - -```swift -import Foundation -import Observation - -@Observable -final class PlayerViewModel { - var currentTrack: Track? - var currentIndex: Int? - var isShuffled = false - - private(set) var queue: [Track] = [] - private var originalQueue: [Track] = [] - private let audio: AudioService - private let db: DatabaseService? - private var halfwayReported = false - - init(audio: AudioService, db: DatabaseService?) { - self.audio = audio - self.db = db - - audio.onTrackFinished = { [weak self] in - self?.trackDidFinish() - } - } - - func setQueue(_ tracks: [Track]) { - originalQueue = tracks - if isShuffled { - queue = buildShuffledQueue(from: tracks, startingWith: currentTrack) - } else { - queue = tracks - } - if let current = currentTrack { - currentIndex = queue.firstIndex(where: { $0.id == current.id }) - } - } - - func play(_ track: Track) { - currentTrack = track - currentIndex = queue.firstIndex(where: { $0.id == track.id }) - halfwayReported = false - - guard let url = URL(string: track.fileURL) else { return } - audio.play(url: url) - } - - func next() { - guard let idx = currentIndex else { return } - let nextIdx = idx + 1 - if nextIdx < queue.count { - play(queue[nextIdx]) - } else { - audio.stop() - currentTrack = nil - currentIndex = nil - } - } - - func previous() { - guard let idx = currentIndex else { return } - let prevIdx = max(0, idx - 1) - play(queue[prevIdx]) - } - - func toggleShuffle() { - isShuffled.toggle() - if isShuffled { - queue = buildShuffledQueue(from: originalQueue, startingWith: currentTrack) - } else { - queue = originalQueue - } - if let current = currentTrack { - currentIndex = queue.firstIndex(where: { $0.id == current.id }) - } - } - - func checkHalfway() { - guard !halfwayReported, - audio.duration > 0, - audio.currentTime >= audio.duration * 0.5, - let track = currentTrack, - let trackId = track.id else { return } - - halfwayReported = true - let newCount = track.playCount + 1 - try? db?.updatePlayStats(trackId: trackId, playCount: newCount, lastPlayedAt: Date()) - } - - private func trackDidFinish() { - if let track = currentTrack, let trackId = track.id, !halfwayReported { - let newCount = track.playCount + 1 - try? db?.updatePlayStats(trackId: trackId, playCount: newCount, lastPlayedAt: Date()) - } - next() - } - - private func buildShuffledQueue(from tracks: [Track], startingWith current: Track?) -> [Track] { - var shuffled = tracks.shuffled() - if let current, let idx = shuffled.firstIndex(where: { $0.id == current.id }) { - shuffled.remove(at: idx) - shuffled.insert(current, at: 0) - } - return shuffled - } -} -``` - -- [ ] **Step 4: Run tests** - -```bash -xcodebuild test -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "(Test Case|passed|failed|SUCCEEDED|FAILED)" -``` - -Expected: All PlayerViewModel tests pass. All previous tests still pass. - -- [ ] **Step 5: Commit** - -```bash -git add Music/ViewModels/PlayerViewModel.swift MusicTests/PlayerViewModelTests.swift -git commit -m "feat: add PlayerViewModel with queue management, shuffle, and play tracking" -``` - ---- - -### Task 7: LibraryViewModel - -**Files:** -- Create: `Music/ViewModels/LibraryViewModel.swift` - -- [ ] **Step 1: Implement LibraryViewModel** - -Create `Music/ViewModels/LibraryViewModel.swift` and add it to the `Music` target: - -```swift -import Foundation -import Observation -import GRDB - -@Observable -final class LibraryViewModel { - var tracks: [Track] = [] - var searchText = "" - var sortColumn = "title" - var sortAscending = true - var trackCount = 0 - - private let db: DatabaseService - private var cancellable: AnyDatabaseCancellable? - private var searchTask: Task? - - init(db: DatabaseService) { - self.db = db - updateQuery() - } - - func search(_ text: String) { - searchText = text - searchTask?.cancel() - searchTask = Task { @MainActor [weak self] in - try? await Task.sleep(for: .milliseconds(150)) - guard !Task.isCancelled else { return } - self?.updateQuery() - } - } - - func sort(by column: String) { - if sortColumn == column { - sortAscending.toggle() - } else { - sortColumn = column - sortAscending = true - } - updateQuery() - } - - private func updateQuery() { - cancellable?.cancel() - let search = searchText - let col = sortColumn - let asc = sortAscending - - let observation = ValueObservation.tracking { [db] dbAccess in - try db.fetchTracks(search: search, sortColumn: col, ascending: asc) - } - - cancellable = observation.start( - in: db.dbPool, - onError: { error in - print("Library observation error: \(error)") - }, - onChange: { [weak self] tracks in - self?.tracks = tracks - self?.trackCount = tracks.count - } - ) - } -} -``` - -Note: `ValueObservation.tracking` closure receives a `Database` argument but we need to call our `DatabaseService` method which does its own `dbPool.read`. We need to refactor the fetch method to accept a `Database` directly. Add this internal method to `DatabaseService`: - -```swift - // Add to DatabaseService.swift — used by ValueObservation which already holds a database access - func fetchTracks(db: Database, search: String, sortColumn: String, ascending: Bool) throws -> [Track] { - let col = Self.validSortColumns.contains(sortColumn) ? sortColumn : "title" - let order = ascending ? "ASC" : "DESC" - - if search.trimmingCharacters(in: .whitespaces).isEmpty { - return try Track.fetchAll( - db, - sql: "SELECT * FROM tracks ORDER BY \(col) COLLATE NOCASE \(order)" - ) - } - - let terms = search.split(separator: " ").map { "\($0)*" } - let pattern = terms.joined(separator: " ") - - return try Track.fetchAll( - db, - sql: """ - SELECT tracks.* FROM tracks - JOIN tracks_ft ON tracks_ft.rowid = tracks.id - WHERE tracks_ft MATCH ? - ORDER BY \(col) COLLATE NOCASE \(order) - """, - arguments: [pattern] - ) - } -``` - -Then update `LibraryViewModel.updateQuery()` to use this method: - -```swift - private func updateQuery() { - cancellable?.cancel() - let search = searchText - let col = sortColumn - let asc = sortAscending - - let observation = ValueObservation.tracking { [db] dbAccess in - try db.fetchTracks(db: dbAccess, search: search, sortColumn: col, ascending: asc) - } - - cancellable = observation.start( - in: db.dbPool, - onError: { error in - print("Library observation error: \(error)") - }, - onChange: { [weak self] tracks in - self?.tracks = tracks - self?.trackCount = tracks.count - } - ) - } -``` - -- [ ] **Step 2: Verify build** - -```bash -xcodebuild build -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | tail -5 -``` - -Expected: `** BUILD SUCCEEDED **` - -- [ ] **Step 3: Run all existing tests to verify no regressions** - -```bash -xcodebuild test -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "(Test Case|passed|failed|SUCCEEDED|FAILED)" -``` - -Expected: All tests pass. - -- [ ] **Step 4: Commit** - -```bash -git add Music/ViewModels/LibraryViewModel.swift Music/Services/DatabaseService.swift -git commit -m "feat: add LibraryViewModel with reactive queries, debounced search, and column sorting" -``` - ---- - -### Task 8: TrackTableView - -**Files:** -- Create: `Music/Views/TrackTableView.swift` - -- [ ] **Step 1: Implement TrackTableView** - -Create `Music/Views/TrackTableView.swift` and add it to the `Music` target. This wraps `NSTableView` in `NSViewRepresentable` for maximum scroll performance with 10K+ rows. - -```swift -import SwiftUI -import AppKit - -struct TrackTableView: NSViewRepresentable { - let tracks: [Track] - let playingTrackId: Int64? - let onSort: (String) -> Void - let onDoubleClick: (Track) -> Void - - func makeNSView(context: Context) -> NSScrollView { - let scrollView = NSScrollView() - scrollView.hasVerticalScroller = true - scrollView.autohidesScrollers = true - - let tableView = NSTableView() - tableView.style = .plain - tableView.usesAlternatingRowBackgroundColors = true - tableView.allowsMultipleSelection = false - tableView.rowHeight = 24 - tableView.intercellSpacing = NSSize(width: 10, height: 0) - - let columns: [(id: String, title: String, width: CGFloat)] = [ - ("title", "Title", 300), - ("artist", "Artist", 200), - ("album", "Album", 200), - ("genre", "Genre", 100), - ("duration", "Duration", 70), - ] - - for col in columns { - let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(col.id)) - column.title = col.title - column.width = col.width - column.minWidth = 50 - column.sortDescriptorPrototype = NSSortDescriptor(key: col.id, ascending: true) - if col.id == "duration" { - column.headerCell.alignment = .right - } - tableView.addTableColumn(column) - } - - tableView.delegate = context.coordinator - tableView.dataSource = context.coordinator - tableView.doubleAction = #selector(Coordinator.handleDoubleClick(_:)) - tableView.target = context.coordinator - - scrollView.documentView = tableView - return scrollView - } - - func updateNSView(_ scrollView: NSScrollView, context: Context) { - guard let tableView = scrollView.documentView as? NSTableView else { return } - - let tracksChanged = context.coordinator.tracks != tracks - let playingChanged = context.coordinator.playingTrackId != playingTrackId - - context.coordinator.parent = self - - guard tracksChanged || playingChanged else { return } - - let selectedIds = Set(tableView.selectedRowIndexes.compactMap { idx -> Int64? in - guard idx < context.coordinator.tracks.count else { return nil } - return context.coordinator.tracks[idx].id - }) - - context.coordinator.tracks = tracks - context.coordinator.playingTrackId = playingTrackId - - tableView.reloadData() - - if !selectedIds.isEmpty { - let newSelection = IndexSet(tracks.enumerated().compactMap { index, track in - selectedIds.contains(track.id ?? -1) ? index : nil - }) - tableView.selectRowIndexes(newSelection, byExtendingSelection: false) - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - final class Coordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource { - var parent: TrackTableView - var tracks: [Track] = [] - - init(_ parent: TrackTableView) { - self.parent = parent - } - - func numberOfRows(in tableView: NSTableView) -> Int { - tracks.count - } - - func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - guard row < tracks.count else { return nil } - let track = tracks[row] - let colId = tableColumn?.identifier.rawValue ?? "" - - let cellId = NSUserInterfaceItemIdentifier("Cell_\(colId)") - let cell: NSTextField - if let existing = tableView.makeView(withIdentifier: cellId, owner: nil) as? NSTextField { - cell = existing - } else { - cell = NSTextField(labelWithString: "") - cell.identifier = cellId - cell.lineBreakMode = .byTruncatingTail - cell.font = .systemFont(ofSize: 12) - } - - switch colId { - case "title": - let isPlaying = track.id == parent.playingTrackId - if isPlaying { - cell.stringValue = "▶ \(track.title)" - cell.textColor = .systemBlue - cell.font = .systemFont(ofSize: 12, weight: .medium) - } else { - cell.stringValue = track.title - cell.textColor = .labelColor - cell.font = .systemFont(ofSize: 12) - } - case "artist": - cell.stringValue = track.artist - cell.textColor = .secondaryLabelColor - case "album": - cell.stringValue = track.album - cell.textColor = .tertiaryLabelColor - case "genre": - cell.stringValue = track.genre - cell.textColor = .tertiaryLabelColor - case "duration": - cell.stringValue = Self.formatDuration(track.duration) - cell.textColor = .tertiaryLabelColor - cell.alignment = .right - default: - cell.stringValue = "" - } - - return cell - } - - func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { - if let sort = tableView.sortDescriptors.first, let key = sort.key { - parent.onSort(key) - } - } - - @objc func handleDoubleClick(_ sender: NSTableView) { - let row = sender.clickedRow - guard row >= 0, row < tracks.count else { return } - parent.onDoubleClick(tracks[row]) - } - - static func formatDuration(_ seconds: Double) -> String { - guard seconds.isFinite, seconds > 0 else { return "0:00" } - let mins = Int(seconds) / 60 - let secs = Int(seconds) % 60 - return "\(mins):\(String(format: "%02d", secs))" - } - } -} -``` - -- [ ] **Step 2: Verify build** - -```bash -xcodebuild build -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | tail -5 -``` - -Expected: `** BUILD SUCCEEDED **` - -- [ ] **Step 3: Commit** - -```bash -git add Music/Views/TrackTableView.swift -git commit -m "feat: add TrackTableView with NSTableView for high-performance track list" -``` - ---- - -### Task 9: UI Views & App Assembly - -**Files:** -- Create: `Music/Views/SearchBarView.swift` -- Create: `Music/Views/PlayerControlsView.swift` -- Modify: `Music/ContentView.swift` -- Modify: `Music/MusicApp.swift` - -- [ ] **Step 1: Create SearchBarView** - -Create `Music/Views/SearchBarView.swift` and add it to the `Music` target: - -```swift -import SwiftUI - -struct SearchBarView: View { - @State private var searchText = "" - let trackCount: Int - let onSearch: (String) -> Void - - var body: some View { - HStack(spacing: 12) { - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .foregroundStyle(.secondary) - TextField("Search by title, artist, album, genre...", text: $searchText) - .textFieldStyle(.plain) - .onChange(of: searchText) { _, newValue in - onSearch(newValue) - } - if !searchText.isEmpty { - Button { - searchText = "" - onSearch("") - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - } - } - .padding(8) - .background(.quaternary) - .clipShape(RoundedRectangle(cornerRadius: 8)) - - Text("\(trackCount) tracks") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - } -} -``` - -- [ ] **Step 2: Create PlayerControlsView** - -Create `Music/Views/PlayerControlsView.swift` and add it to the `Music` target: - -```swift -import SwiftUI - -struct PlayerControlsView: View { - let currentTrack: Track? - let isPlaying: Bool - let currentTime: Double - let duration: Double - let volume: Float - let isShuffled: Bool - let onPlayPause: () -> Void - let onNext: () -> Void - let onPrevious: () -> Void - let onSeek: (Double) -> Void - let onVolumeChange: (Float) -> Void - let onShuffleToggle: () -> Void - - var body: some View { - HStack(spacing: 0) { - nowPlayingSection - .frame(maxWidth: .infinity, alignment: .leading) - - transportSection - .frame(maxWidth: .infinity) - - volumeSection - .frame(maxWidth: .infinity, alignment: .trailing) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(.bar) - } - - private var nowPlayingSection: some View { - HStack(spacing: 12) { - RoundedRectangle(cornerRadius: 6) - .fill(.quaternary) - .frame(width: 44, height: 44) - .overlay { - if let data = currentTrack?.artworkData, - let nsImage = NSImage(data: data) { - Image(nsImage: nsImage) - .resizable() - .aspectRatio(contentMode: .fill) - } else { - Image(systemName: "music.note") - .foregroundStyle(.secondary) - } - } - .clipShape(RoundedRectangle(cornerRadius: 6)) - - if let track = currentTrack { - VStack(alignment: .leading, spacing: 2) { - Text(track.title) - .font(.system(size: 13, weight: .medium)) - .lineLimit(1) - Text("\(track.artist) — \(track.album)") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - } - } - - private var transportSection: some View { - VStack(spacing: 4) { - HStack(spacing: 20) { - Button(action: onShuffleToggle) { - Image(systemName: "shuffle") - .font(.system(size: 12)) - .foregroundStyle(isShuffled ? .blue : .secondary) - } - .buttonStyle(.plain) - - Button(action: onPrevious) { - Image(systemName: "backward.fill") - .font(.system(size: 14)) - } - .buttonStyle(.plain) - - Button(action: onPlayPause) { - Image(systemName: isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 22)) - } - .buttonStyle(.plain) - - Button(action: onNext) { - Image(systemName: "forward.fill") - .font(.system(size: 14)) - } - .buttonStyle(.plain) - - Spacer() - .frame(width: 12) - } - - HStack(spacing: 8) { - Text(Self.formatTime(currentTime)) - .font(.system(size: 10).monospacedDigit()) - .foregroundStyle(.secondary) - .frame(width: 35, alignment: .trailing) - - Slider( - value: Binding( - get: { currentTime }, - set: { onSeek($0) } - ), - in: 0...max(duration, 1) - ) - .controlSize(.small) - - Text(Self.formatTime(duration)) - .font(.system(size: 10).monospacedDigit()) - .foregroundStyle(.secondary) - .frame(width: 35, alignment: .leading) - } - } - .frame(maxWidth: 400) - } - - private var volumeSection: some View { - HStack(spacing: 8) { - Image(systemName: volumeIconName) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .frame(width: 16) - - Slider( - value: Binding( - get: { Double(volume) }, - set: { onVolumeChange(Float($0)) } - ), - in: 0...1 - ) - .controlSize(.small) - .frame(width: 80) - } - } - - private var volumeIconName: String { - if volume == 0 { return "speaker.slash.fill" } - if volume < 0.33 { return "speaker.wave.1.fill" } - if volume < 0.66 { return "speaker.wave.2.fill" } - return "speaker.wave.3.fill" - } - - static func formatTime(_ seconds: Double) -> String { - guard seconds.isFinite, seconds >= 0 else { return "0:00" } - let mins = Int(seconds) / 60 - let secs = Int(seconds) % 60 - return "\(mins):\(String(format: "%02d", secs))" - } -} -``` - -- [ ] **Step 3: Update ContentView with 3-zone layout** - -Replace the content of `Music/ContentView.swift`: - -```swift -import SwiftUI - -struct ContentView: View { - var library: LibraryViewModel - var player: PlayerViewModel - var scanner: ScannerService - var audio: AudioService - - var body: some View { - VStack(spacing: 0) { - SearchBarView( - trackCount: library.trackCount, - onSearch: { library.search($0) } - ) - - if scanner.isScanning { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text("Scanning... \(scanner.scanProgress.current) / \(scanner.scanProgress.total) tracks") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.vertical, 4) - } - - TrackTableView( - tracks: library.tracks, - playingTrackId: player.currentTrack?.id, - onSort: { column in - library.sort(by: column) - }, - onDoubleClick: { track in - player.setQueue(library.tracks) - player.play(track) - } - ) - - PlayerControlsView( - currentTrack: player.currentTrack, - isPlaying: audio.isPlaying, - currentTime: audio.currentTime, - duration: audio.duration, - volume: audio.volume, - isShuffled: player.isShuffled, - onPlayPause: { audio.togglePlayPause() }, - onNext: { player.next() }, - onPrevious: { player.previous() }, - onSeek: { audio.seek(to: $0) }, - onVolumeChange: { audio.volume = $0 }, - onShuffleToggle: { player.toggleShuffle() } - ) - } - .onDrop(of: [.fileURL], isTargeted: nil) { providers in - handleDrop(providers) - return true - } - .onChange(of: audio.currentTime) { _, _ in - player.checkHalfway() - } - } - - private func handleDrop(_ providers: [NSItemProvider]) { - for provider in providers { - provider.loadItem(forTypeIdentifier: "public.file-url") { data, _ in - guard let data = data as? Data, - let url = URL(dataRepresentation: data, relativeTo: nil) else { return } - - var isDir: ObjCBool = false - FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) - - Task { - if isDir.boolValue { - await scanner.scanFolder(url) - } else if ScannerService.audioExtensions.contains(url.pathExtension.lowercased()) { - if var track = await ScannerService.extractMetadata(from: url) { - try? scanner.db.insert(&track) - } - } - } - } - } - } -} -``` - -Note: `ScannerService.db` needs to be accessible. Change its access level from `private` to `let`: - -In `ScannerService.swift`, change: -```swift - private let db: DatabaseService -``` -to: -```swift - let db: DatabaseService -``` - -- [ ] **Step 4: Update MusicApp with dependency injection and folder picker** - -Replace the content of `Music/MusicApp.swift`: - -```swift -import SwiftUI - -@main -struct MusicApp: App { - @State private var dbService: DatabaseService? - @State private var libraryVM: LibraryViewModel? - @State private var playerVM: PlayerViewModel? - @State private var scannerService: ScannerService? - @State private var audioService = AudioService() - @State private var initError: String? - - var body: some Scene { - WindowGroup { - Group { - if let db = dbService, - let library = libraryVM, - let player = playerVM, - let scanner = scannerService { - ContentView( - library: library, - player: player, - scanner: scanner, - audio: audioService - ) - } else if let error = initError { - Text("Failed to initialize database: \(error)") - .padding() - } else { - ProgressView("Loading...") - .onAppear { initialize() } - } - } - .frame(minWidth: 800, minHeight: 500) - } - .commands { - CommandGroup(after: .newItem) { - Button("Open Music Folder...") { - pickFolder() - } - .keyboardShortcut("o") - } - } - } - - private func initialize() { - do { - let appSupport = FileManager.default.urls( - for: .applicationSupportDirectory, in: .userDomainMask - ).first!.appendingPathComponent("Music", isDirectory: true) - try FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) - - let dbPath = appSupport.appendingPathComponent("db.sqlite").path - let db = try DatabaseService(path: dbPath) - let scanner = ScannerService(db: db) - let library = LibraryViewModel(db: db) - let player = PlayerViewModel(audio: audioService, db: db) - - self.dbService = db - self.scannerService = scanner - self.libraryVM = library - self.playerVM = player - - if let savedFolder = UserDefaults.standard.string(forKey: "musicFolderPath"), - let url = URL(string: savedFolder) { - Task { - await scanner.rescan(url) - } - } - } catch { - initError = error.localizedDescription - } - } - - private func pickFolder() { - let panel = NSOpenPanel() - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.message = "Select your music folder" - - guard panel.runModal() == .OK, let url = panel.url else { return } - - UserDefaults.standard.set(url.absoluteString, forKey: "musicFolderPath") - - if let scanner = scannerService { - Task { - await scanner.scanFolder(url) - } - } - } -} -``` - -- [ ] **Step 5: Verify build** - -```bash -xcodebuild build -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | tail -10 -``` - -Expected: `** BUILD SUCCEEDED **` - -- [ ] **Step 6: Run all tests to verify no regressions** - -```bash -xcodebuild test -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "(Test Case|passed|failed|SUCCEEDED|FAILED)" -``` - -Expected: All tests pass. - -- [ ] **Step 7: Launch the app and verify end-to-end flow** - -Run the app from Xcode (Cmd+R) or via: - -```bash -xcodebuild build -project Music.xcodeproj -scheme Music -destination 'platform=macOS' && open build/Release/Music.app -``` - -Verify: -1. App launches and shows empty track list -2. File > Open Music Folder... opens a folder picker -3. Selecting a folder with audio files triggers scanning (progress bar visible) -4. Tracks appear in the table as scanning progresses -5. Typing in the search bar filters tracks instantly -6. Clicking column headers sorts the list -7. Double-clicking a track starts playback -8. Player controls (play/pause, next, prev, seek, volume) work -9. Shuffle toggle works -10. Dragging an audio file or folder onto the window imports it - -- [ ] **Step 8: Commit** - -```bash -git add Music/Views/SearchBarView.swift Music/Views/PlayerControlsView.swift Music/ContentView.swift Music/MusicApp.swift Music/Services/ScannerService.swift -git commit -m "feat: assemble UI with search bar, track table, player controls, folder picker, and drag-and-drop" -``` diff --git a/docs/superpowers/specs/2026-05-23-music-player-design.md b/docs/superpowers/specs/2026-05-23-music-player-design.md deleted file mode 100644 index 89031ff..0000000 --- a/docs/superpowers/specs/2026-05-23-music-player-design.md +++ /dev/null @@ -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