# 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" ```