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.
2092 lines
68 KiB
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"
|
|
```
|
|
|