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

2092 lines
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**
```bash
cd /Users/laurentmorvillier/code/Music
git init
echo ".superpowers/" >> .gitignore
echo ".DS_Store" >> .gitignore
echo "*.xcuserdata" >> .gitignore
```
- [ ] **Step 2: Add GRDB.swift as SPM dependency**
Add GRDB.swift to the Music target via Xcode's SPM integration:
- URL: `https://github.com/groue/GRDB.swift`
- Version: 7.0.0 up to next major
- Product: `GRDB` linked to the `Music` target
Also link `GRDB` to the `MusicTests` target so tests can use in-memory databases.
- [ ] **Step 3: Create directory structure**
```bash
mkdir -p Music/Models Music/Services Music/ViewModels Music/Views
```
- [ ] **Step 4: Verify build**
```bash
xcodebuild build -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | tail -5
```
Expected: `** BUILD SUCCEEDED **`
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "chore: initialize project with GRDB dependency and directory structure"
```
---
### Task 2: Track Model
**Files:**
- Create: `Music/Models/Track.swift`
- Create: `MusicTests/TrackTests.swift`
- Delete: `MusicTests/MusicTests.swift`
- [ ] **Step 1: Create Track.swift**
Create `Music/Models/Track.swift` with the complete GRDB record type. Add this file to the `Music` target.
```swift
import Foundation
import GRDB
struct Track: Codable, Identifiable, Equatable, Hashable {
var id: Int64?
var fileURL: String
var title: String
var artist: String
var albumArtist: String
var album: String
var genre: String
var year: Int?
var trackNumber: Int?
var discNumber: Int?
var duration: Double
var bpm: Int?
var composer: String
var fileFormat: String
var bitrate: Int?
var sampleRate: Int?
var fileSize: Int64
var artworkData: Data?
var playCount: Int
var lastPlayedAt: Date?
var rating: Int
var dateAdded: Date
var dateModified: Date
var fileHash: String
static func computeHash(fileSize: Int64, modificationDate: Date) -> String {
"\(fileSize)_\(Int(modificationDate.timeIntervalSince1970))"
}
}
extension Track: FetchableRecord, MutablePersistableRecord {
static let databaseTableName = "tracks"
mutating func didInsert(_ inserted: InsertionSuccess) {
id = inserted.rowID
}
}
```
- [ ] **Step 2: Create test fixture helper**
Add a static `fixture` method at the bottom of `Track.swift` for use in tests:
```swift
#if DEBUG
extension Track {
static func fixture(
id: Int64? = nil,
fileURL: String = "/tmp/test.mp3",
title: String = "Test Song",
artist: String = "Test Artist",
albumArtist: String = "Test Artist",
album: String = "Test Album",
genre: String = "Rock",
year: Int? = 2024,
trackNumber: Int? = 1,
discNumber: Int? = 1,
duration: Double = 210.0,
bpm: Int? = 120,
composer: String = "Test Composer",
fileFormat: String = "mp3",
bitrate: Int? = 320,
sampleRate: Int? = 44100,
fileSize: Int64 = 5_000_000,
artworkData: Data? = nil,
playCount: Int = 0,
lastPlayedAt: Date? = nil,
rating: Int = 0,
dateAdded: Date = Date(),
dateModified: Date = Date(),
fileHash: String = "5000000_1700000000"
) -> Track {
Track(
id: id, fileURL: fileURL, title: title, artist: artist,
albumArtist: albumArtist, album: album, genre: genre, year: year,
trackNumber: trackNumber, discNumber: discNumber, duration: duration,
bpm: bpm, composer: composer, fileFormat: fileFormat, bitrate: bitrate,
sampleRate: sampleRate, fileSize: fileSize, artworkData: artworkData,
playCount: playCount, lastPlayedAt: lastPlayedAt, rating: rating,
dateAdded: dateAdded, dateModified: dateModified, fileHash: fileHash
)
}
}
#endif
```
- [ ] **Step 3: Write tests for Track**
Delete `MusicTests/MusicTests.swift`. Create `MusicTests/TrackTests.swift` and add it to the `MusicTests` target:
```swift
import Testing
import GRDB
@testable import Music
struct TrackTests {
// Verifies that Track can round-trip through GRDB's encoding/decoding,
// meaning it correctly conforms to FetchableRecord and PersistableRecord.
@Test func roundTripThroughDatabase() throws {
let dbQueue = try DatabaseQueue()
try dbQueue.write { db in
try db.create(table: "tracks") { t in
t.autoIncrementedPrimaryKey("id")
t.column("fileURL", .text).notNull().unique()
t.column("title", .text).notNull()
t.column("artist", .text).notNull()
t.column("albumArtist", .text).notNull()
t.column("album", .text).notNull()
t.column("genre", .text).notNull()
t.column("year", .integer)
t.column("trackNumber", .integer)
t.column("discNumber", .integer)
t.column("duration", .double).notNull()
t.column("bpm", .integer)
t.column("composer", .text).notNull()
t.column("fileFormat", .text).notNull()
t.column("bitrate", .integer)
t.column("sampleRate", .integer)
t.column("fileSize", .integer).notNull()
t.column("artworkData", .blob)
t.column("playCount", .integer).notNull().defaults(to: 0)
t.column("lastPlayedAt", .datetime)
t.column("rating", .integer).notNull().defaults(to: 0)
t.column("dateAdded", .datetime).notNull()
t.column("dateModified", .datetime).notNull()
t.column("fileHash", .text).notNull()
}
var track = Track.fixture(title: "Bohemian Rhapsody", artist: "Queen")
try track.insert(db)
#expect(track.id != nil)
let fetched = try Track.fetchOne(db, key: track.id)
#expect(fetched?.title == "Bohemian Rhapsody")
#expect(fetched?.artist == "Queen")
#expect(fetched?.duration == 210.0)
}
}
// Verifies that didInsert assigns the auto-incremented ID after insertion.
@Test func didInsertAssignsId() throws {
let dbQueue = try DatabaseQueue()
try dbQueue.write { db in
try db.create(table: "tracks") { t in
t.autoIncrementedPrimaryKey("id")
t.column("fileURL", .text).notNull().unique()
t.column("title", .text).notNull()
t.column("artist", .text).notNull()
t.column("albumArtist", .text).notNull()
t.column("album", .text).notNull()
t.column("genre", .text).notNull()
t.column("year", .integer)
t.column("trackNumber", .integer)
t.column("discNumber", .integer)
t.column("duration", .double).notNull()
t.column("bpm", .integer)
t.column("composer", .text).notNull()
t.column("fileFormat", .text).notNull()
t.column("bitrate", .integer)
t.column("sampleRate", .integer)
t.column("fileSize", .integer).notNull()
t.column("artworkData", .blob)
t.column("playCount", .integer).notNull().defaults(to: 0)
t.column("lastPlayedAt", .datetime)
t.column("rating", .integer).notNull().defaults(to: 0)
t.column("dateAdded", .datetime).notNull()
t.column("dateModified", .datetime).notNull()
t.column("fileHash", .text).notNull()
}
var track = Track.fixture()
#expect(track.id == nil)
try track.insert(db)
#expect(track.id != nil)
}
}
// Verifies the fileHash computation produces a deterministic string from size + date.
@Test func computeHashIsDeterministic() {
let date = Date(timeIntervalSince1970: 1700000000)
let hash1 = Track.computeHash(fileSize: 5_000_000, modificationDate: date)
let hash2 = Track.computeHash(fileSize: 5_000_000, modificationDate: date)
#expect(hash1 == hash2)
#expect(hash1 == "5000000_1700000000")
let different = Track.computeHash(fileSize: 999, modificationDate: date)
#expect(different != hash1)
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
xcodebuild test -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "(Test Case|passed|failed|SUCCEEDED|FAILED)"
```
Expected: All 3 tests pass.
- [ ] **Step 5: Commit**
```bash
git add Music/Models/Track.swift MusicTests/TrackTests.swift
git rm MusicTests/MusicTests.swift
git commit -m "feat: add Track model with GRDB record conformance and tests"
```
---
### Task 3: DatabaseService
**Files:**
- Create: `Music/Services/DatabaseService.swift`
- Create: `MusicTests/DatabaseServiceTests.swift`
- [ ] **Step 1: Write failing test for database creation and track insertion**
Create `MusicTests/DatabaseServiceTests.swift` and add it to the `MusicTests` target:
```swift
import Testing
import GRDB
@testable import Music
struct DatabaseServiceTests {
// Creates an in-memory DatabaseService, inserts a track, and fetches it back.
@Test func insertAndFetchTrack() throws {
let db = try DatabaseService(inMemory: true)
var track = Track.fixture(title: "Test Song", artist: "Test Artist")
try db.insert(&track)
let tracks = try db.fetchTracks(search: "", sortColumn: "title", ascending: true)
#expect(tracks.count == 1)
#expect(tracks[0].title == "Test Song")
}
}
```
- [ ] **Step 2: Run test to verify it fails**
```bash
xcodebuild test -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "(Test Case|passed|failed|error:)"
```
Expected: FAIL — `DatabaseService` not defined.
- [ ] **Step 3: Implement DatabaseService**
Create `Music/Services/DatabaseService.swift` and add it to the `Music` target:
```swift
import Foundation
import GRDB
final class DatabaseService: Sendable {
let dbPool: DatabaseWriter
init(path: String) throws {
let dbPool = try DatabasePool(path: path)
self.dbPool = dbPool
try Self.migrate(dbPool)
}
init(inMemory: Bool) throws {
let dbQueue = try DatabaseQueue()
self.dbPool = dbQueue
try Self.migrate(dbQueue)
}
private static func migrate(_ db: DatabaseWriter) throws {
var migrator = DatabaseMigrator()
migrator.registerMigration("v1-create-tracks") { db in
try db.create(table: "tracks") { t in
t.autoIncrementedPrimaryKey("id")
t.column("fileURL", .text).notNull().unique()
t.column("title", .text).notNull()
t.column("artist", .text).notNull()
t.column("albumArtist", .text).notNull()
t.column("album", .text).notNull()
t.column("genre", .text).notNull()
t.column("year", .integer)
t.column("trackNumber", .integer)
t.column("discNumber", .integer)
t.column("duration", .double).notNull()
t.column("bpm", .integer)
t.column("composer", .text).notNull()
t.column("fileFormat", .text).notNull()
t.column("bitrate", .integer)
t.column("sampleRate", .integer)
t.column("fileSize", .integer).notNull()
t.column("artworkData", .blob)
t.column("playCount", .integer).notNull().defaults(to: 0)
t.column("lastPlayedAt", .datetime)
t.column("rating", .integer).notNull().defaults(to: 0)
t.column("dateAdded", .datetime).notNull()
t.column("dateModified", .datetime).notNull()
t.column("fileHash", .text).notNull()
}
try db.create(index: "idx_tracks_artist", on: "tracks", columns: ["artist"])
try db.create(index: "idx_tracks_album", on: "tracks", columns: ["album"])
try db.create(index: "idx_tracks_genre", on: "tracks", columns: ["genre"])
try db.create(index: "idx_tracks_year", on: "tracks", columns: ["year"])
try db.create(
index: "idx_tracks_album_order",
on: "tracks",
columns: ["albumArtist", "album", "discNumber", "trackNumber"]
)
try db.create(virtualTable: "tracks_ft", using: FTS5()) { t in
t.synchronize(withTable: "tracks")
t.tokenize(with: .unicode61())
t.column("title")
t.column("artist")
t.column("albumArtist")
t.column("album")
t.column("genre")
t.column("composer")
}
}
try migrator.migrate(db)
}
// MARK: - Write
func insert(_ track: inout Track) throws {
try dbPool.write { db in
try track.insert(db)
}
}
func insertBatch(_ tracks: [Track]) throws {
try dbPool.write { db in
for var track in tracks {
try track.insert(db, onConflict: .ignore)
}
}
}
func updatePlayStats(trackId: Int64, playCount: Int, lastPlayedAt: Date) throws {
try dbPool.write { db in
try db.execute(
sql: "UPDATE tracks SET playCount = ?, lastPlayedAt = ? WHERE id = ?",
arguments: [playCount, lastPlayedAt, trackId]
)
}
}
func deleteTracksWithURLs(_ urls: Set<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**
```bash
xcodebuild test -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "(Test Case|passed|failed|SUCCEEDED|FAILED)"
```
Expected: `insertAndFetchTrack` passes.
- [ ] **Step 5: Write remaining database tests**
Add these tests to `DatabaseServiceTests.swift`:
```swift
// Inserts multiple tracks and verifies sorting by different columns.
@Test func fetchTracksSortedByArtist() throws {
let db = try DatabaseService(inMemory: true)
var t1 = Track.fixture(fileURL: "/a.mp3", title: "Zebra", artist: "Alpha")
var t2 = Track.fixture(fileURL: "/b.mp3", title: "Alpha", artist: "Zebra")
try db.insert(&t1)
try db.insert(&t2)
let ascending = try db.fetchTracks(search: "", sortColumn: "artist", ascending: true)
#expect(ascending[0].artist == "Alpha")
#expect(ascending[1].artist == "Zebra")
let descending = try db.fetchTracks(search: "", sortColumn: "artist", ascending: false)
#expect(descending[0].artist == "Zebra")
}
// Searches using FTS5 and verifies only matching tracks are returned.
@Test func fts5Search() throws {
let db = try DatabaseService(inMemory: true)
var t1 = Track.fixture(fileURL: "/a.mp3", title: "Bohemian Rhapsody", artist: "Queen")
var t2 = Track.fixture(fileURL: "/b.mp3", title: "Hotel California", artist: "Eagles")
var t3 = Track.fixture(fileURL: "/c.mp3", title: "Stairway to Heaven", artist: "Led Zeppelin")
try db.insert(&t1)
try db.insert(&t2)
try db.insert(&t3)
let results = try db.fetchTracks(search: "queen", sortColumn: "title", ascending: true)
#expect(results.count == 1)
#expect(results[0].title == "Bohemian Rhapsody")
}
// Searches with a prefix to verify autocomplete-style matching.
@Test func fts5PrefixSearch() throws {
let db = try DatabaseService(inMemory: true)
var t1 = Track.fixture(fileURL: "/a.mp3", title: "Bohemian Rhapsody", artist: "Queen")
var t2 = Track.fixture(fileURL: "/b.mp3", title: "Hotel California", artist: "Eagles")
try db.insert(&t1)
try db.insert(&t2)
let results = try db.fetchTracks(search: "boh", sortColumn: "title", ascending: true)
#expect(results.count == 1)
#expect(results[0].title == "Bohemian Rhapsody")
}
// Inserts a batch of tracks and verifies duplicates are silently ignored.
@Test func batchInsertIgnoresDuplicates() throws {
let db = try DatabaseService(inMemory: true)
let tracks = [
Track.fixture(fileURL: "/a.mp3", title: "Song A"),
Track.fixture(fileURL: "/b.mp3", title: "Song B"),
Track.fixture(fileURL: "/a.mp3", title: "Song A Duplicate"),
]
try db.insertBatch(tracks)
let all = try db.fetchTracks(search: "", sortColumn: "title", ascending: true)
#expect(all.count == 2)
}
// Updates play stats and verifies they persist.
@Test func updatePlayStats() throws {
let db = try DatabaseService(inMemory: true)
var track = Track.fixture()
try db.insert(&track)
let now = Date()
try db.updatePlayStats(trackId: track.id!, playCount: 5, lastPlayedAt: now)
let fetched = try db.fetchTracks(search: "", sortColumn: "title", ascending: true)
#expect(fetched[0].playCount == 5)
#expect(fetched[0].lastPlayedAt != nil)
}
// Validates that an invalid sort column falls back to "title".
@Test func invalidSortColumnFallsBackToTitle() throws {
let db = try DatabaseService(inMemory: true)
var t1 = Track.fixture(fileURL: "/a.mp3", title: "Zebra")
var t2 = Track.fixture(fileURL: "/b.mp3", title: "Alpha")
try db.insert(&t1)
try db.insert(&t2)
let result = try db.fetchTracks(search: "", sortColumn: "DROP TABLE tracks", ascending: true)
#expect(result[0].title == "Alpha")
}
```
- [ ] **Step 6: Run all tests**
```bash
xcodebuild test -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "(Test Case|passed|failed|SUCCEEDED|FAILED)"
```
Expected: All tests pass.
- [ ] **Step 7: Commit**
```bash
git add Music/Services/DatabaseService.swift MusicTests/DatabaseServiceTests.swift
git commit -m "feat: add DatabaseService with schema, FTS5 search, and query methods"
```
---
### Task 4: ScannerService
**Files:**
- Create: `Music/Services/ScannerService.swift`
- Create: `MusicTests/ScannerServiceTests.swift`
- [ ] **Step 1: Write failing test for file discovery**
Create `MusicTests/ScannerServiceTests.swift` and add it to the `MusicTests` target:
```swift
import Testing
import Foundation
@testable import Music
struct ScannerServiceTests {
// Creates a temp directory with audio and non-audio files, verifies only audio files are discovered.
@Test func discoverAudioFiles() throws {
let tmpDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tmpDir) }
// Create test files (empty files with correct extensions)
let audioFiles = ["song.mp3", "track.m4a", "audio.flac", "sound.wav", "music.aiff"]
let nonAudioFiles = ["readme.txt", "image.png", "data.json"]
for name in audioFiles + nonAudioFiles {
FileManager.default.createFile(atPath: tmpDir.appendingPathComponent(name).path, contents: Data())
}
// Create a subdirectory with one more audio file
let subDir = tmpDir.appendingPathComponent("subfolder")
try FileManager.default.createDirectory(at: subDir, withIntermediateDirectories: true)
FileManager.default.createFile(atPath: subDir.appendingPathComponent("nested.mp3").path, contents: Data())
let discovered = ScannerService.discoverAudioFiles(in: tmpDir)
#expect(discovered.count == 6)
let extensions = Set(discovered.map { $0.pathExtension.lowercased() })
#expect(extensions.isSubset(of: ["mp3", "m4a", "flac", "wav", "aiff", "aac", "alac"]))
}
}
```
- [ ] **Step 2: Run test to verify it fails**
```bash
xcodebuild test -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "(error:|ScannerService)"
```
Expected: FAIL — `ScannerService` not defined.
- [ ] **Step 3: Implement ScannerService**
Create `Music/Services/ScannerService.swift` and add it to the `Music` target:
```swift
import Foundation
import AVFoundation
@Observable
final class ScannerService {
var isScanning = false
var scanProgress: (current: Int, total: Int) = (0, 0)
private let db: DatabaseService
init(db: DatabaseService) {
self.db = db
}
static let audioExtensions: Set<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**
```bash
xcodebuild test -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "(Test Case|passed|failed|SUCCEEDED|FAILED)"
```
Expected: `discoverAudioFiles` passes. All previous tests still pass.
- [ ] **Step 5: Commit**
```bash
git add Music/Services/ScannerService.swift MusicTests/ScannerServiceTests.swift
git commit -m "feat: add ScannerService with file discovery, metadata extraction, and batch import"
```
---
### Task 5: AudioService
**Files:**
- Create: `Music/Services/AudioService.swift`
- [ ] **Step 1: Create AudioService**
Create `Music/Services/AudioService.swift` and add it to the `Music` target. This wraps AVPlayer for playback control.
```swift
import AVFoundation
import Observation
@Observable
final class AudioService {
var isPlaying = false
var currentTime: Double = 0
var duration: Double = 0
var volume: Float = 0.65 {
didSet { player?.volume = volume }
}
private var player: AVPlayer?
private var timeObserver: Any?
private var endObserver: NSObjectProtocol?
var onTrackFinished: (() -> Void)?
func play(url: URL) {
cleanup()
let item = AVPlayerItem(url: url)
player = AVPlayer(playerItem: item)
player?.volume = volume
timeObserver = player?.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
queue: .main
) { [weak self] time in
guard let self else { return }
self.currentTime = time.seconds
if let dur = self.player?.currentItem?.duration, dur.isValid, !dur.isIndefinite {
self.duration = dur.seconds
}
}
endObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: item,
queue: .main
) { [weak self] _ in
self?.isPlaying = false
self?.currentTime = 0
self?.onTrackFinished?()
}
player?.play()
isPlaying = true
}
func pause() {
player?.pause()
isPlaying = false
}
func resume() {
player?.play()
isPlaying = true
}
func togglePlayPause() {
if isPlaying { pause() } else { resume() }
}
func seek(to time: Double) {
player?.seek(to: CMTime(seconds: time, preferredTimescale: 600))
}
func stop() {
cleanup()
isPlaying = false
currentTime = 0
duration = 0
}
private func cleanup() {
if let obs = timeObserver {
player?.removeTimeObserver(obs)
timeObserver = nil
}
if let obs = endObserver {
NotificationCenter.default.removeObserver(obs)
endObserver = nil
}
player?.pause()
player = nil
}
deinit {
cleanup()
}
}
```
- [ ] **Step 2: Verify build**
```bash
xcodebuild build -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | tail -5
```
Expected: `** BUILD SUCCEEDED **`
- [ ] **Step 3: Commit**
```bash
git add Music/Services/AudioService.swift
git commit -m "feat: add AudioService with AVPlayer playback, seeking, and volume control"
```
---
### Task 6: PlayerViewModel
**Files:**
- Create: `Music/ViewModels/PlayerViewModel.swift`
- Create: `MusicTests/PlayerViewModelTests.swift`
- [ ] **Step 1: Write failing tests for queue management**
Create `MusicTests/PlayerViewModelTests.swift` and add it to the `MusicTests` target:
```swift
import Testing
import Foundation
@testable import Music
struct PlayerViewModelTests {
private func makeTracks(_ count: Int) -> [Track] {
(0..<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**
```bash
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:
```swift
import Foundation
import Observation
@Observable
final class PlayerViewModel {
var currentTrack: Track?
var currentIndex: Int?
var isShuffled = false
private(set) var queue: [Track] = []
private var originalQueue: [Track] = []
private let audio: AudioService
private let db: DatabaseService?
private var halfwayReported = false
init(audio: AudioService, db: DatabaseService?) {
self.audio = audio
self.db = db
audio.onTrackFinished = { [weak self] in
self?.trackDidFinish()
}
}
func setQueue(_ tracks: [Track]) {
originalQueue = tracks
if isShuffled {
queue = buildShuffledQueue(from: tracks, startingWith: currentTrack)
} else {
queue = tracks
}
if let current = currentTrack {
currentIndex = queue.firstIndex(where: { $0.id == current.id })
}
}
func play(_ track: Track) {
currentTrack = track
currentIndex = queue.firstIndex(where: { $0.id == track.id })
halfwayReported = false
guard let url = URL(string: track.fileURL) else { return }
audio.play(url: url)
}
func next() {
guard let idx = currentIndex else { return }
let nextIdx = idx + 1
if nextIdx < queue.count {
play(queue[nextIdx])
} else {
audio.stop()
currentTrack = nil
currentIndex = nil
}
}
func previous() {
guard let idx = currentIndex else { return }
let prevIdx = max(0, idx - 1)
play(queue[prevIdx])
}
func toggleShuffle() {
isShuffled.toggle()
if isShuffled {
queue = buildShuffledQueue(from: originalQueue, startingWith: currentTrack)
} else {
queue = originalQueue
}
if let current = currentTrack {
currentIndex = queue.firstIndex(where: { $0.id == current.id })
}
}
func checkHalfway() {
guard !halfwayReported,
audio.duration > 0,
audio.currentTime >= audio.duration * 0.5,
let track = currentTrack,
let trackId = track.id else { return }
halfwayReported = true
let newCount = track.playCount + 1
try? db?.updatePlayStats(trackId: trackId, playCount: newCount, lastPlayedAt: Date())
}
private func trackDidFinish() {
if let track = currentTrack, let trackId = track.id, !halfwayReported {
let newCount = track.playCount + 1
try? db?.updatePlayStats(trackId: trackId, playCount: newCount, lastPlayedAt: Date())
}
next()
}
private func buildShuffledQueue(from tracks: [Track], startingWith current: Track?) -> [Track] {
var shuffled = tracks.shuffled()
if let current, let idx = shuffled.firstIndex(where: { $0.id == current.id }) {
shuffled.remove(at: idx)
shuffled.insert(current, at: 0)
}
return shuffled
}
}
```
- [ ] **Step 4: Run tests**
```bash
xcodebuild test -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "(Test Case|passed|failed|SUCCEEDED|FAILED)"
```
Expected: All PlayerViewModel tests pass. All previous tests still pass.
- [ ] **Step 5: Commit**
```bash
git add Music/ViewModels/PlayerViewModel.swift MusicTests/PlayerViewModelTests.swift
git commit -m "feat: add PlayerViewModel with queue management, shuffle, and play tracking"
```
---
### Task 7: LibraryViewModel
**Files:**
- Create: `Music/ViewModels/LibraryViewModel.swift`
- [ ] **Step 1: Implement LibraryViewModel**
Create `Music/ViewModels/LibraryViewModel.swift` and add it to the `Music` target:
```swift
import Foundation
import Observation
import GRDB
@Observable
final class LibraryViewModel {
var tracks: [Track] = []
var searchText = ""
var sortColumn = "title"
var sortAscending = true
var trackCount = 0
private let db: DatabaseService
private var cancellable: AnyDatabaseCancellable?
private var searchTask: Task<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`:
```swift
// Add to DatabaseService.swift — used by ValueObservation which already holds a database access
func fetchTracks(db: Database, search: String, sortColumn: String, ascending: Bool) throws -> [Track] {
let col = Self.validSortColumns.contains(sortColumn) ? sortColumn : "title"
let order = ascending ? "ASC" : "DESC"
if search.trimmingCharacters(in: .whitespaces).isEmpty {
return try Track.fetchAll(
db,
sql: "SELECT * FROM tracks ORDER BY \(col) COLLATE NOCASE \(order)"
)
}
let terms = search.split(separator: " ").map { "\($0)*" }
let pattern = terms.joined(separator: " ")
return try Track.fetchAll(
db,
sql: """
SELECT tracks.* FROM tracks
JOIN tracks_ft ON tracks_ft.rowid = tracks.id
WHERE tracks_ft MATCH ?
ORDER BY \(col) COLLATE NOCASE \(order)
""",
arguments: [pattern]
)
}
```
Then update `LibraryViewModel.updateQuery()` to use this method:
```swift
private func updateQuery() {
cancellable?.cancel()
let search = searchText
let col = sortColumn
let asc = sortAscending
let observation = ValueObservation.tracking { [db] dbAccess in
try db.fetchTracks(db: dbAccess, search: search, sortColumn: col, ascending: asc)
}
cancellable = observation.start(
in: db.dbPool,
onError: { error in
print("Library observation error: \(error)")
},
onChange: { [weak self] tracks in
self?.tracks = tracks
self?.trackCount = tracks.count
}
)
}
```
- [ ] **Step 2: Verify build**
```bash
xcodebuild build -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | tail -5
```
Expected: `** BUILD SUCCEEDED **`
- [ ] **Step 3: Run all existing tests to verify no regressions**
```bash
xcodebuild test -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "(Test Case|passed|failed|SUCCEEDED|FAILED)"
```
Expected: All tests pass.
- [ ] **Step 4: Commit**
```bash
git add Music/ViewModels/LibraryViewModel.swift Music/Services/DatabaseService.swift
git commit -m "feat: add LibraryViewModel with reactive queries, debounced search, and column sorting"
```
---
### Task 8: TrackTableView
**Files:**
- Create: `Music/Views/TrackTableView.swift`
- [ ] **Step 1: Implement TrackTableView**
Create `Music/Views/TrackTableView.swift` and add it to the `Music` target. This wraps `NSTableView` in `NSViewRepresentable` for maximum scroll performance with 10K+ rows.
```swift
import SwiftUI
import AppKit
struct TrackTableView: NSViewRepresentable {
let tracks: [Track]
let playingTrackId: Int64?
let onSort: (String) -> Void
let onDoubleClick: (Track) -> Void
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true
scrollView.autohidesScrollers = true
let tableView = NSTableView()
tableView.style = .plain
tableView.usesAlternatingRowBackgroundColors = true
tableView.allowsMultipleSelection = false
tableView.rowHeight = 24
tableView.intercellSpacing = NSSize(width: 10, height: 0)
let columns: [(id: String, title: String, width: CGFloat)] = [
("title", "Title", 300),
("artist", "Artist", 200),
("album", "Album", 200),
("genre", "Genre", 100),
("duration", "Duration", 70),
]
for col in columns {
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(col.id))
column.title = col.title
column.width = col.width
column.minWidth = 50
column.sortDescriptorPrototype = NSSortDescriptor(key: col.id, ascending: true)
if col.id == "duration" {
column.headerCell.alignment = .right
}
tableView.addTableColumn(column)
}
tableView.delegate = context.coordinator
tableView.dataSource = context.coordinator
tableView.doubleAction = #selector(Coordinator.handleDoubleClick(_:))
tableView.target = context.coordinator
scrollView.documentView = tableView
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
guard let tableView = scrollView.documentView as? NSTableView else { return }
let tracksChanged = context.coordinator.tracks != tracks
let playingChanged = context.coordinator.playingTrackId != playingTrackId
context.coordinator.parent = self
guard tracksChanged || playingChanged else { return }
let selectedIds = Set(tableView.selectedRowIndexes.compactMap { idx -> Int64? in
guard idx < context.coordinator.tracks.count else { return nil }
return context.coordinator.tracks[idx].id
})
context.coordinator.tracks = tracks
context.coordinator.playingTrackId = playingTrackId
tableView.reloadData()
if !selectedIds.isEmpty {
let newSelection = IndexSet(tracks.enumerated().compactMap { index, track in
selectedIds.contains(track.id ?? -1) ? index : nil
})
tableView.selectRowIndexes(newSelection, byExtendingSelection: false)
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
final class Coordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource {
var parent: TrackTableView
var tracks: [Track] = []
init(_ parent: TrackTableView) {
self.parent = parent
}
func numberOfRows(in tableView: NSTableView) -> Int {
tracks.count
}
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
guard row < tracks.count else { return nil }
let track = tracks[row]
let colId = tableColumn?.identifier.rawValue ?? ""
let cellId = NSUserInterfaceItemIdentifier("Cell_\(colId)")
let cell: NSTextField
if let existing = tableView.makeView(withIdentifier: cellId, owner: nil) as? NSTextField {
cell = existing
} else {
cell = NSTextField(labelWithString: "")
cell.identifier = cellId
cell.lineBreakMode = .byTruncatingTail
cell.font = .systemFont(ofSize: 12)
}
switch colId {
case "title":
let isPlaying = track.id == parent.playingTrackId
if isPlaying {
cell.stringValue = "▶ \(track.title)"
cell.textColor = .systemBlue
cell.font = .systemFont(ofSize: 12, weight: .medium)
} else {
cell.stringValue = track.title
cell.textColor = .labelColor
cell.font = .systemFont(ofSize: 12)
}
case "artist":
cell.stringValue = track.artist
cell.textColor = .secondaryLabelColor
case "album":
cell.stringValue = track.album
cell.textColor = .tertiaryLabelColor
case "genre":
cell.stringValue = track.genre
cell.textColor = .tertiaryLabelColor
case "duration":
cell.stringValue = Self.formatDuration(track.duration)
cell.textColor = .tertiaryLabelColor
cell.alignment = .right
default:
cell.stringValue = ""
}
return cell
}
func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) {
if let sort = tableView.sortDescriptors.first, let key = sort.key {
parent.onSort(key)
}
}
@objc func handleDoubleClick(_ sender: NSTableView) {
let row = sender.clickedRow
guard row >= 0, row < tracks.count else { return }
parent.onDoubleClick(tracks[row])
}
static func formatDuration(_ seconds: Double) -> String {
guard seconds.isFinite, seconds > 0 else { return "0:00" }
let mins = Int(seconds) / 60
let secs = Int(seconds) % 60
return "\(mins):\(String(format: "%02d", secs))"
}
}
}
```
- [ ] **Step 2: Verify build**
```bash
xcodebuild build -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | tail -5
```
Expected: `** BUILD SUCCEEDED **`
- [ ] **Step 3: Commit**
```bash
git add Music/Views/TrackTableView.swift
git commit -m "feat: add TrackTableView with NSTableView for high-performance track list"
```
---
### Task 9: UI Views & App Assembly
**Files:**
- Create: `Music/Views/SearchBarView.swift`
- Create: `Music/Views/PlayerControlsView.swift`
- Modify: `Music/ContentView.swift`
- Modify: `Music/MusicApp.swift`
- [ ] **Step 1: Create SearchBarView**
Create `Music/Views/SearchBarView.swift` and add it to the `Music` target:
```swift
import SwiftUI
struct SearchBarView: View {
@State private var searchText = ""
let trackCount: Int
let onSearch: (String) -> Void
var body: some View {
HStack(spacing: 12) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search by title, artist, album, genre...", text: $searchText)
.textFieldStyle(.plain)
.onChange(of: searchText) { _, newValue in
onSearch(newValue)
}
if !searchText.isEmpty {
Button {
searchText = ""
onSearch("")
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
.padding(8)
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 8))
Text("\(trackCount) tracks")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
```
- [ ] **Step 2: Create PlayerControlsView**
Create `Music/Views/PlayerControlsView.swift` and add it to the `Music` target:
```swift
import SwiftUI
struct PlayerControlsView: View {
let currentTrack: Track?
let isPlaying: Bool
let currentTime: Double
let duration: Double
let volume: Float
let isShuffled: Bool
let onPlayPause: () -> Void
let onNext: () -> Void
let onPrevious: () -> Void
let onSeek: (Double) -> Void
let onVolumeChange: (Float) -> Void
let onShuffleToggle: () -> Void
var body: some View {
HStack(spacing: 0) {
nowPlayingSection
.frame(maxWidth: .infinity, alignment: .leading)
transportSection
.frame(maxWidth: .infinity)
volumeSection
.frame(maxWidth: .infinity, alignment: .trailing)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.bar)
}
private var nowPlayingSection: some View {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 6)
.fill(.quaternary)
.frame(width: 44, height: 44)
.overlay {
if let data = currentTrack?.artworkData,
let nsImage = NSImage(data: data) {
Image(nsImage: nsImage)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Image(systemName: "music.note")
.foregroundStyle(.secondary)
}
}
.clipShape(RoundedRectangle(cornerRadius: 6))
if let track = currentTrack {
VStack(alignment: .leading, spacing: 2) {
Text(track.title)
.font(.system(size: 13, weight: .medium))
.lineLimit(1)
Text("\(track.artist)\(track.album)")
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
}
private var transportSection: some View {
VStack(spacing: 4) {
HStack(spacing: 20) {
Button(action: onShuffleToggle) {
Image(systemName: "shuffle")
.font(.system(size: 12))
.foregroundStyle(isShuffled ? .blue : .secondary)
}
.buttonStyle(.plain)
Button(action: onPrevious) {
Image(systemName: "backward.fill")
.font(.system(size: 14))
}
.buttonStyle(.plain)
Button(action: onPlayPause) {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 22))
}
.buttonStyle(.plain)
Button(action: onNext) {
Image(systemName: "forward.fill")
.font(.system(size: 14))
}
.buttonStyle(.plain)
Spacer()
.frame(width: 12)
}
HStack(spacing: 8) {
Text(Self.formatTime(currentTime))
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 35, alignment: .trailing)
Slider(
value: Binding(
get: { currentTime },
set: { onSeek($0) }
),
in: 0...max(duration, 1)
)
.controlSize(.small)
Text(Self.formatTime(duration))
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 35, alignment: .leading)
}
}
.frame(maxWidth: 400)
}
private var volumeSection: some View {
HStack(spacing: 8) {
Image(systemName: volumeIconName)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.frame(width: 16)
Slider(
value: Binding(
get: { Double(volume) },
set: { onVolumeChange(Float($0)) }
),
in: 0...1
)
.controlSize(.small)
.frame(width: 80)
}
}
private var volumeIconName: String {
if volume == 0 { return "speaker.slash.fill" }
if volume < 0.33 { return "speaker.wave.1.fill" }
if volume < 0.66 { return "speaker.wave.2.fill" }
return "speaker.wave.3.fill"
}
static func formatTime(_ seconds: Double) -> String {
guard seconds.isFinite, seconds >= 0 else { return "0:00" }
let mins = Int(seconds) / 60
let secs = Int(seconds) % 60
return "\(mins):\(String(format: "%02d", secs))"
}
}
```
- [ ] **Step 3: Update ContentView with 3-zone layout**
Replace the content of `Music/ContentView.swift`:
```swift
import SwiftUI
struct ContentView: View {
var library: LibraryViewModel
var player: PlayerViewModel
var scanner: ScannerService
var audio: AudioService
var body: some View {
VStack(spacing: 0) {
SearchBarView(
trackCount: library.trackCount,
onSearch: { library.search($0) }
)
if scanner.isScanning {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text("Scanning... \(scanner.scanProgress.current) / \(scanner.scanProgress.total) tracks")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
TrackTableView(
tracks: library.tracks,
playingTrackId: player.currentTrack?.id,
onSort: { column in
library.sort(by: column)
},
onDoubleClick: { track in
player.setQueue(library.tracks)
player.play(track)
}
)
PlayerControlsView(
currentTrack: player.currentTrack,
isPlaying: audio.isPlaying,
currentTime: audio.currentTime,
duration: audio.duration,
volume: audio.volume,
isShuffled: player.isShuffled,
onPlayPause: { audio.togglePlayPause() },
onNext: { player.next() },
onPrevious: { player.previous() },
onSeek: { audio.seek(to: $0) },
onVolumeChange: { audio.volume = $0 },
onShuffleToggle: { player.toggleShuffle() }
)
}
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
handleDrop(providers)
return true
}
.onChange(of: audio.currentTime) { _, _ in
player.checkHalfway()
}
}
private func handleDrop(_ providers: [NSItemProvider]) {
for provider in providers {
provider.loadItem(forTypeIdentifier: "public.file-url") { data, _ in
guard let data = data as? Data,
let url = URL(dataRepresentation: data, relativeTo: nil) else { return }
var isDir: ObjCBool = false
FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir)
Task {
if isDir.boolValue {
await scanner.scanFolder(url)
} else if ScannerService.audioExtensions.contains(url.pathExtension.lowercased()) {
if var track = await ScannerService.extractMetadata(from: url) {
try? scanner.db.insert(&track)
}
}
}
}
}
}
}
```
Note: `ScannerService.db` needs to be accessible. Change its access level from `private` to `let`:
In `ScannerService.swift`, change:
```swift
private let db: DatabaseService
```
to:
```swift
let db: DatabaseService
```
- [ ] **Step 4: Update MusicApp with dependency injection and folder picker**
Replace the content of `Music/MusicApp.swift`:
```swift
import SwiftUI
@main
struct MusicApp: App {
@State private var dbService: DatabaseService?
@State private var libraryVM: LibraryViewModel?
@State private var playerVM: PlayerViewModel?
@State private var scannerService: ScannerService?
@State private var audioService = AudioService()
@State private var initError: String?
var body: some Scene {
WindowGroup {
Group {
if let db = dbService,
let library = libraryVM,
let player = playerVM,
let scanner = scannerService {
ContentView(
library: library,
player: player,
scanner: scanner,
audio: audioService
)
} else if let error = initError {
Text("Failed to initialize database: \(error)")
.padding()
} else {
ProgressView("Loading...")
.onAppear { initialize() }
}
}
.frame(minWidth: 800, minHeight: 500)
}
.commands {
CommandGroup(after: .newItem) {
Button("Open Music Folder...") {
pickFolder()
}
.keyboardShortcut("o")
}
}
}
private func initialize() {
do {
let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory, in: .userDomainMask
).first!.appendingPathComponent("Music", isDirectory: true)
try FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true)
let dbPath = appSupport.appendingPathComponent("db.sqlite").path
let db = try DatabaseService(path: dbPath)
let scanner = ScannerService(db: db)
let library = LibraryViewModel(db: db)
let player = PlayerViewModel(audio: audioService, db: db)
self.dbService = db
self.scannerService = scanner
self.libraryVM = library
self.playerVM = player
if let savedFolder = UserDefaults.standard.string(forKey: "musicFolderPath"),
let url = URL(string: savedFolder) {
Task {
await scanner.rescan(url)
}
}
} catch {
initError = error.localizedDescription
}
}
private func pickFolder() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.message = "Select your music folder"
guard panel.runModal() == .OK, let url = panel.url else { return }
UserDefaults.standard.set(url.absoluteString, forKey: "musicFolderPath")
if let scanner = scannerService {
Task {
await scanner.scanFolder(url)
}
}
}
}
```
- [ ] **Step 5: Verify build**
```bash
xcodebuild build -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | tail -10
```
Expected: `** BUILD SUCCEEDED **`
- [ ] **Step 6: Run all tests to verify no regressions**
```bash
xcodebuild test -project Music.xcodeproj -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "(Test Case|passed|failed|SUCCEEDED|FAILED)"
```
Expected: All tests pass.
- [ ] **Step 7: Launch the app and verify end-to-end flow**
Run the app from Xcode (Cmd+R) or via:
```bash
xcodebuild build -project Music.xcodeproj -scheme Music -destination 'platform=macOS' && open build/Release/Music.app
```
Verify:
1. App launches and shows empty track list
2. File > Open Music Folder... opens a folder picker
3. Selecting a folder with audio files triggers scanning (progress bar visible)
4. Tracks appear in the table as scanning progresses
5. Typing in the search bar filters tracks instantly
6. Clicking column headers sorts the list
7. Double-clicking a track starts playback
8. Player controls (play/pause, next, prev, seek, volume) work
9. Shuffle toggle works
10. Dragging an audio file or folder onto the window imports it
- [ ] **Step 8: Commit**
```bash
git add Music/Views/SearchBarView.swift Music/Views/PlayerControlsView.swift Music/ContentView.swift Music/MusicApp.swift Music/Services/ScannerService.swift
git commit -m "feat: assemble UI with search bar, track table, player controls, folder picker, and drag-and-drop"
```