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