You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
Music/docs/superpowers/plans/2026-05-23-music-player.md

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: 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
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:

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