68 KiB
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
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:
GRDBlinked to theMusictarget
Also link GRDB to the MusicTests target so tests can use in-memory databases.
- Step 3: Create directory structure
mkdir -p Music/Models Music/Services Music/ViewModels Music/Views
- Step 4: Verify build
xcodebuild build -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | tail -5
Expected: ** BUILD SUCCEEDED **
- Step 5: Commit
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.
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:
#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:
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
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
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:
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
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:
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<String>) 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<String> = [
"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<String> {
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
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:
// 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
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
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:
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
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:
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<String> = ["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..<batchEnd])
var tracks: [Track] = []
for fileURL in batch {
let urlString = fileURL.absoluteString
if existingURLs.contains(urlString) { continue }
if let track = await Self.extractMetadata(from: fileURL) {
tracks.append(track)
}
}
if !tracks.isEmpty {
try? db.insertBatch(tracks)
}
scanProgress = (batchEnd, audioFiles.count)
}
}
func rescan(_ folder: URL) async {
let audioFiles = Self.discoverAudioFiles(in: folder)
let currentFileURLs = Set(audioFiles.map { $0.absoluteString })
let storedURLs = (try? db.allFileURLs()) ?? []
// Remove tracks whose files no longer exist
let removedURLs = storedURLs.subtracting(currentFileURLs)
if !removedURLs.isEmpty {
try? db.deleteTracksWithURLs(removedURLs)
}
// Scan will insert new files (existing ones are skipped via ON CONFLICT IGNORE)
await scanFolder(folder)
}
static func extractMetadata(from url: URL) async -> 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
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
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.
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
xcodebuild build -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | tail -5
Expected: ** BUILD SUCCEEDED **
- Step 3: Commit
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:
import Testing
import Foundation
@testable import Music
struct PlayerViewModelTests {
private func makeTracks(_ count: Int) -> [Track] {
(0..<count).map { i in
Track.fixture(
id: Int64(i + 1),
fileURL: "/track\(i).mp3",
title: "Track \(i)"
)
}
}
// Sets the queue and plays a track, verifies current track and index are set.
@Test func playTrackSetsCurrentTrackAndIndex() {
let vm = PlayerViewModel(audio: AudioService(), db: nil)
let tracks = makeTracks(5)
vm.setQueue(tracks)
vm.play(tracks[2])
#expect(vm.currentTrack?.id == 3)
#expect(vm.currentIndex == 2)
}
// Calls next() and verifies it advances to the next track.
@Test func nextAdvancesToNextTrack() {
let vm = PlayerViewModel(audio: AudioService(), db: nil)
let tracks = makeTracks(5)
vm.setQueue(tracks)
vm.play(tracks[0])
vm.next()
#expect(vm.currentTrack?.id == 2)
#expect(vm.currentIndex == 1)
}
// Calls next() on the last track and verifies it stops (no wrap for v1).
@Test func nextAtEndStops() {
let vm = PlayerViewModel(audio: AudioService(), db: nil)
let tracks = makeTracks(3)
vm.setQueue(tracks)
vm.play(tracks[2])
vm.next()
#expect(vm.currentTrack == nil)
}
// Calls previous() and verifies it goes to the previous track.
@Test func previousGoesToPreviousTrack() {
let vm = PlayerViewModel(audio: AudioService(), db: nil)
let tracks = makeTracks(5)
vm.setQueue(tracks)
vm.play(tracks[3])
vm.previous()
#expect(vm.currentTrack?.id == 3)
}
// Calls previous() on the first track and verifies it stays at the first track.
@Test func previousAtStartStaysAtFirst() {
let vm = PlayerViewModel(audio: AudioService(), db: nil)
let tracks = makeTracks(3)
vm.setQueue(tracks)
vm.play(tracks[0])
vm.previous()
#expect(vm.currentTrack?.id == 1)
#expect(vm.currentIndex == 0)
}
// Enables shuffle and verifies the shuffled queue contains all tracks
// and starts with the current track.
@Test func shuffleContainsAllTracksStartingWithCurrent() {
let vm = PlayerViewModel(audio: AudioService(), db: nil)
let tracks = makeTracks(20)
vm.setQueue(tracks)
vm.play(tracks[5])
vm.toggleShuffle()
#expect(vm.isShuffled == true)
#expect(vm.currentTrack?.id == 6)
let shuffledIds = Set(vm.queue.map { $0.id })
let originalIds = Set(tracks.map { $0.id })
#expect(shuffledIds == originalIds)
}
// Disables shuffle and verifies the queue returns to original order
// and current track is preserved.
@Test func unshuffleRestoresOriginalOrder() {
let vm = PlayerViewModel(audio: AudioService(), db: nil)
let tracks = makeTracks(10)
vm.setQueue(tracks)
vm.play(tracks[3])
vm.toggleShuffle()
vm.toggleShuffle()
#expect(vm.isShuffled == false)
#expect(vm.currentTrack?.id == 4)
#expect(vm.queue.map { $0.id } == tracks.map { $0.id })
}
}
- Step 2: Run tests to verify they fail
xcodebuild test -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&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:
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
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
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:
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<Void, Never>?
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:
// 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:
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
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
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
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.
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
xcodebuild build -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | tail -5
Expected: ** BUILD SUCCEEDED **
- Step 3: Commit
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:
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:
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:
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:
private let db: DatabaseService
to:
let db: DatabaseService
- Step 4: Update MusicApp with dependency injection and folder picker
Replace the content of Music/MusicApp.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
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
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:
xcodebuild build -project Music.xcodeproj -scheme Music -destination 'platform=macOS' && open build/Release/Music.app
Verify:
- App launches and shows empty track list
- File > Open Music Folder... opens a folder picker
- Selecting a folder with audio files triggers scanning (progress bar visible)
- Tracks appear in the table as scanning progresses
- Typing in the search bar filters tracks instantly
- Clicking column headers sorts the list
- Double-clicking a track starts playback
- Player controls (play/pause, next, prev, seek, volume) work
- Shuffle toggle works
- Dragging an audio file or folder onto the window imports it
- Step 8: Commit
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"