diff --git a/Application/AppCoordinator.swift b/Application/AppCoordinator.swift index 0e352f4a..97adc841 100644 --- a/Application/AppCoordinator.swift +++ b/Application/AppCoordinator.swift @@ -2,6 +2,7 @@ import SwiftUI class AppCoordinator: ObservableObject { // MARK: - Managers + static var shared: AppCoordinator? let libraryManager: LibraryManager let playlistManager: PlaylistManager let audioPlayerManager: AudioPlayerManager @@ -24,5 +25,6 @@ class AppCoordinator: ObservableObject { // Setup now playing nowPlayingManager = NowPlayingManager() nowPlayingManager.connectRemoteCommandCenter(audioPlayer: audioPlayerManager, playlistManager: playlistManager) + Self.shared = self } } diff --git a/Application/AppDelegate.swift b/Application/AppDelegate.swift index 49101032..7525d810 100644 --- a/Application/AppDelegate.swift +++ b/Application/AppDelegate.swift @@ -14,10 +14,32 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { print("App is terminating...") - // Any cleanup code can go here + // Stop audio playback cleanly + if let coordinator = AppCoordinator.shared { + coordinator.audioPlayerManager.stop() + } } func applicationDidFinishLaunching(_ notification: Notification) { print("App finished launching") + + // Ensure main window is visible + if let window = NSApp.windows.first { + window.makeKeyAndOrderFront(nil) + } + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + // If no windows are visible, show the main window + if !flag { + // Create a new window if needed + if NSApp.windows.isEmpty { + // The window should be created by SwiftUI, just make it visible + NSApp.activate(ignoringOtherApps: true) + } else if let window = NSApp.windows.first { + window.makeKeyAndOrderFront(nil) + } + } + return true } } diff --git a/Managers/AudioPlayerManager.swift b/Managers/AudioPlayerManager.swift index db061d57..6424a45f 100644 --- a/Managers/AudioPlayerManager.swift +++ b/Managers/AudioPlayerManager.swift @@ -27,7 +27,9 @@ class AudioPlayerManager: ObservableObject { } deinit { + stop() timer?.invalidate() + timer = nil } // MARK: - Playback Controls diff --git a/Managers/Database/DatabaseManager.swift b/Managers/Database/DatabaseManager.swift new file mode 100644 index 00000000..96e554d5 --- /dev/null +++ b/Managers/Database/DatabaseManager.swift @@ -0,0 +1,947 @@ +import Foundation +import SQLite3 + +// MARK: - SQLite Constants +private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + +// MARK: - Array Extension for Chunking +extension Array { + func chunked(into size: Int) -> [[Element]] { + return stride(from: 0, to: count, by: size).map { + Array(self[$0 ..< Swift.min($0 + size, count)]) + } + } +} + +class DatabaseManager: ObservableObject { + // MARK: - Properties + private var db: OpaquePointer? + private let dbQueue = DispatchQueue(label: "com.petrichor.database") + private let dbPath: String + + // MARK: - Published Properties for UI Updates + @Published var isScanning: Bool = false + @Published var scanProgress: Double = 0.0 + @Published var scanStatusMessage: String = "" + + // MARK: - Table Names + private let foldersTable = "folders" + private let tracksTable = "tracks" + + // MARK: - Initialization + init() { + // Create database in app support directory + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, + in: .userDomainMask).first! + let appDirectory = appSupport.appendingPathComponent("Petrichor", isDirectory: true) + + // Create directory if it doesn't exist + try? FileManager.default.createDirectory(at: appDirectory, + withIntermediateDirectories: true, + attributes: nil) + + dbPath = appDirectory.appendingPathComponent("petrichor.db").path + + openDatabase() + createTables() + } + + deinit { + closeDatabase() + } + + // MARK: - Database Setup + + private func openDatabase() { + if sqlite3_open(dbPath, &db) != SQLITE_OK { + print("Unable to open database at \(dbPath)") + return + } + + // Enable foreign keys + let enableForeignKeys = "PRAGMA foreign_keys = ON;" + if sqlite3_exec(db, enableForeignKeys, nil, nil, nil) != SQLITE_OK { + print("Failed to enable foreign keys") + } + + // Set journal mode to WAL for better concurrency + let walMode = "PRAGMA journal_mode = WAL;" + if sqlite3_exec(db, walMode, nil, nil, nil) != SQLITE_OK { + print("Failed to set WAL mode") + } + } + + private func closeDatabase() { + if db != nil { + sqlite3_close(db) + db = nil + } + } + + private func createTables() { + // Create folders table + let createFoldersTable = """ + CREATE TABLE IF NOT EXISTS \(foldersTable) ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + track_count INTEGER DEFAULT 0, + date_added REAL NOT NULL, + date_updated REAL NOT NULL + ); + """ + + // Create tracks table + let createTracksTable = """ + CREATE TABLE IF NOT EXISTS \(tracksTable) ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + folder_id INTEGER NOT NULL, + path TEXT NOT NULL UNIQUE, + filename TEXT NOT NULL, + title TEXT, + artist TEXT, + album TEXT, + genre TEXT, + year TEXT, + duration REAL, + format TEXT, + file_size INTEGER, + date_added REAL NOT NULL, + date_modified REAL, + artwork_data BLOB, + FOREIGN KEY (folder_id) REFERENCES \(foldersTable)(id) ON DELETE CASCADE + ); + """ + + // Create indices for better performance + let createIndices = [ + "CREATE INDEX IF NOT EXISTS idx_tracks_folder_id ON \(tracksTable)(folder_id);", + "CREATE INDEX IF NOT EXISTS idx_tracks_artist ON \(tracksTable)(artist);", + "CREATE INDEX IF NOT EXISTS idx_tracks_album ON \(tracksTable)(album);", + "CREATE INDEX IF NOT EXISTS idx_tracks_genre ON \(tracksTable)(genre);", + "CREATE INDEX IF NOT EXISTS idx_tracks_year ON \(tracksTable)(year);", + "CREATE INDEX IF NOT EXISTS idx_folders_path ON \(foldersTable)(path);" + ] + + // Execute table creation on the serial queue + dbQueue.sync { + if sqlite3_exec(self.db, createFoldersTable, nil, nil, nil) != SQLITE_OK { + print("Failed to create folders table") + } + + if sqlite3_exec(self.db, createTracksTable, nil, nil, nil) != SQLITE_OK { + print("Failed to create tracks table") + } + + // Create indices + for index in createIndices { + if sqlite3_exec(self.db, index, nil, nil, nil) != SQLITE_OK { + print("Failed to create index: \(index)") + } + } + } + } + + // MARK: - Folder Management + + func addFolders(_ urls: [URL], completion: @escaping (Result<[DatabaseFolder], Error>) -> Void) { + // Move all work to background queue immediately + dbQueue.async { [weak self] in + guard let self = self else { return } + + DispatchQueue.main.async { + self.isScanning = true + self.scanProgress = 0.0 + self.scanStatusMessage = "Adding folders..." + } + + var addedFolders: [DatabaseFolder] = [] + let insertSQL = """ + INSERT OR IGNORE INTO \(self.foldersTable) + (name, path, date_added, date_updated) + VALUES (?, ?, ?, ?); + """ + + // Begin transaction for better performance + sqlite3_exec(self.db, "BEGIN TRANSACTION", nil, nil, nil) + + for url in urls { + var stmt: OpaquePointer? + + if sqlite3_prepare_v2(self.db, insertSQL, -1, &stmt, nil) == SQLITE_OK { + let name = url.lastPathComponent + let path = url.path + let now = Date().timeIntervalSince1970 + + sqlite3_bind_text(stmt, 1, name, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(stmt, 2, path, -1, SQLITE_TRANSIENT) + sqlite3_bind_double(stmt, 3, now) + sqlite3_bind_double(stmt, 4, now) + + if sqlite3_step(stmt) == SQLITE_DONE { + let folderId = sqlite3_last_insert_rowid(self.db) + let folder = DatabaseFolder( + id: Int(folderId), + name: name, + path: path, + trackCount: 0, + dateAdded: Date(timeIntervalSince1970: now), + dateUpdated: Date(timeIntervalSince1970: now) + ) + addedFolders.append(folder) + } + } + sqlite3_finalize(stmt) + } + + // Commit transaction + sqlite3_exec(self.db, "COMMIT", nil, nil, nil) + + // Now scan the folders for tracks in background + self.scanFoldersForTracks(addedFolders) { result in + DispatchQueue.main.async { + self.isScanning = false + completion(.success(addedFolders)) + } + } + } + } + + func getAllFolders() -> [DatabaseFolder] { + var folders: [DatabaseFolder] = [] + let query = "SELECT id, name, path, track_count, date_added, date_updated FROM \(foldersTable) ORDER BY name;" + + dbQueue.sync { + var stmt: OpaquePointer? + + if sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK { + while sqlite3_step(stmt) == SQLITE_ROW { + let id = Int(sqlite3_column_int(stmt, 0)) + let name = String(cString: sqlite3_column_text(stmt, 1)) + let path = String(cString: sqlite3_column_text(stmt, 2)) + let trackCount = Int(sqlite3_column_int(stmt, 3)) + let dateAdded = Date(timeIntervalSince1970: sqlite3_column_double(stmt, 4)) + let dateUpdated = Date(timeIntervalSince1970: sqlite3_column_double(stmt, 5)) + + let folder = DatabaseFolder( + id: id, + name: name, + path: path, + trackCount: trackCount, + dateAdded: dateAdded, + dateUpdated: dateUpdated + ) + folders.append(folder) + } + } + sqlite3_finalize(stmt) + } + + return folders + } + + func removeFolder(_ folder: DatabaseFolder, completion: @escaping (Result) -> Void) { + dbQueue.async { [weak self] in + guard let self = self else { return } + + let deleteSQL = "DELETE FROM \(self.foldersTable) WHERE id = ?;" + var stmt: OpaquePointer? + + if sqlite3_prepare_v2(self.db, deleteSQL, -1, &stmt, nil) == SQLITE_OK { + sqlite3_bind_int(stmt, 1, Int32(folder.id)) + + if sqlite3_step(stmt) == SQLITE_DONE { + DispatchQueue.main.async { + completion(.success(())) + } + } else { + let error = NSError(domain: "DatabaseManager", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to delete folder"]) + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + sqlite3_finalize(stmt) + } + } + + // MARK: - Track Management + + private func scanFoldersForTracks(_ folders: [DatabaseFolder], completion: @escaping (Result) -> Void) { + // Ensure this runs on background queue + dbQueue.async { [weak self] in + guard let self = self else { return } + + let supportedExtensions = ["mp3", "m4a", "wav", "aac", "aiff", "flac"] + let totalFolders = folders.count + var processedFolders = 0 + + for folder in folders { + autoreleasepool { + DispatchQueue.main.async { + self.scanStatusMessage = "Scanning \(folder.name)..." + self.scanProgress = Double(processedFolders) / Double(totalFolders) + } + + self.scanSingleFolder(folder, supportedExtensions: supportedExtensions) + processedFolders += 1 + } + } + + DispatchQueue.main.async { + self.scanProgress = 1.0 + self.scanStatusMessage = "Scan complete" + completion(.success(())) + } + } + } + + private func scanSingleFolder(_ folder: DatabaseFolder, supportedExtensions: [String]) { + let fileManager = FileManager.default + let folderURL = URL(fileURLWithPath: folder.path) + + guard let enumerator = fileManager.enumerator( + at: folderURL, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) else { return } + + // Collect all music files first + var musicFiles: [URL] = [] + for case let fileURL as URL in enumerator { + let fileExtension = fileURL.pathExtension.lowercased() + if supportedExtensions.contains(fileExtension) { + musicFiles.append(fileURL) + } + } + + // Update progress + DispatchQueue.main.async { + self.scanStatusMessage = "Found \(musicFiles.count) tracks in \(folder.name)" + } + + // Process in batches for better performance + let batchSize = 50 + var processedCount = 0 + + for batch in musicFiles.chunked(into: batchSize) { + autoreleasepool { + // Begin transaction for batch + sqlite3_exec(db, "BEGIN TRANSACTION", nil, nil, nil) + + let insertSQL = """ + INSERT OR IGNORE INTO \(tracksTable) + (folder_id, path, filename, title, artist, album, genre, year, + duration, format, file_size, date_added, artwork_data) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + """ + + for fileURL in batch { + autoreleasepool { + var stmt: OpaquePointer? + + if sqlite3_prepare_v2(db, insertSQL, -1, &stmt, nil) == SQLITE_OK { + // Extract metadata using optimized extractor + let metadata = MetadataExtractor.extractMetadataSync(from: fileURL) + + // Bind values + sqlite3_bind_int(stmt, 1, Int32(folder.id)) + sqlite3_bind_text(stmt, 2, fileURL.path, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(stmt, 3, fileURL.lastPathComponent, -1, SQLITE_TRANSIENT) + + // Use metadata or defaults + let title = metadata.title ?? fileURL.deletingPathExtension().lastPathComponent + let artist = metadata.artist ?? "Unknown Artist" + let album = metadata.album ?? "Unknown Album" + let genre = metadata.genre ?? "Unknown Genre" + let year = metadata.year ?? "" + + sqlite3_bind_text(stmt, 4, title, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(stmt, 5, artist, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(stmt, 6, album, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(stmt, 7, genre, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(stmt, 8, year, -1, SQLITE_TRANSIENT) + sqlite3_bind_double(stmt, 9, metadata.duration) + sqlite3_bind_text(stmt, 10, fileURL.pathExtension.lowercased(), -1, SQLITE_TRANSIENT) + + // Get file size + if let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path), + let fileSize = attributes[.size] as? Int64 { + sqlite3_bind_int64(stmt, 11, fileSize) + } else { + sqlite3_bind_null(stmt, 11) + } + + sqlite3_bind_double(stmt, 12, Date().timeIntervalSince1970) + + // Bind artwork data if available + if let artworkData = metadata.artworkData { + artworkData.withUnsafeBytes { bytes in + sqlite3_bind_blob(stmt, 13, bytes.baseAddress, Int32(artworkData.count), SQLITE_TRANSIENT) + } + } else { + sqlite3_bind_null(stmt, 13) + } + + if sqlite3_step(stmt) == SQLITE_DONE { + processedCount += 1 + } + } + sqlite3_finalize(stmt) + } + } + + // Commit batch transaction + sqlite3_exec(db, "COMMIT", nil, nil, nil) + + // Update progress + let progress = Double(processedCount) / Double(musicFiles.count) + DispatchQueue.main.async { + self.scanStatusMessage = "Processing \(folder.name): \(processedCount)/\(musicFiles.count) tracks" + } + } + } + + // Update folder track count + updateFolderTrackCount(folder.id, trackCount: processedCount) + } + + private func updateFolderTrackCount(_ folderId: Int, trackCount: Int) { + let updateSQL = """ + UPDATE \(foldersTable) + SET track_count = ?, date_updated = ? + WHERE id = ?; + """ + + var stmt: OpaquePointer? + if sqlite3_prepare_v2(db, updateSQL, -1, &stmt, nil) == SQLITE_OK { + sqlite3_bind_int(stmt, 1, Int32(trackCount)) + sqlite3_bind_double(stmt, 2, Date().timeIntervalSince1970) + sqlite3_bind_int(stmt, 3, Int32(folderId)) + sqlite3_step(stmt) + } + sqlite3_finalize(stmt) + } + + // MARK: - Track Queries + + func getAllTracks() -> [DatabaseTrack] { + var tracks: [DatabaseTrack] = [] + let query = """ + SELECT t.id, t.folder_id, t.path, t.filename, t.title, t.artist, + t.album, t.genre, t.year, t.duration, t.format, t.file_size, + t.date_added, t.artwork_data, f.name as folder_name + FROM \(tracksTable) t + JOIN \(foldersTable) f ON t.folder_id = f.id + ORDER BY t.artist, t.album, t.title; + """ + + dbQueue.sync { + var stmt: OpaquePointer? + + if sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK { + while sqlite3_step(stmt) == SQLITE_ROW { + let track = extractTrackFromStatement(stmt) + tracks.append(track) + } + } + sqlite3_finalize(stmt) + } + + return tracks + } + + // Get all tracks WITHOUT artwork data for better performance + func getAllTracksLightweight() -> [DatabaseTrack] { + var tracks: [DatabaseTrack] = [] + let query = """ + SELECT t.id, t.folder_id, t.path, t.filename, t.title, t.artist, + t.album, t.genre, t.year, t.duration, t.format, t.file_size, + t.date_added, NULL as artwork_data, f.name as folder_name + FROM \(tracksTable) t + JOIN \(foldersTable) f ON t.folder_id = f.id + ORDER BY t.artist, t.album, t.title; + """ + + dbQueue.sync { + var stmt: OpaquePointer? + + if sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK { + while sqlite3_step(stmt) == SQLITE_ROW { + let track = extractTrackFromStatement(stmt) + tracks.append(track) + } + } + sqlite3_finalize(stmt) + } + + return tracks + } + + func getTracksForFolder(_ folderId: Int) -> [DatabaseTrack] { + var tracks: [DatabaseTrack] = [] + let query = """ + SELECT t.id, t.folder_id, t.path, t.filename, t.title, t.artist, + t.album, t.genre, t.year, t.duration, t.format, t.file_size, + t.date_added, t.artwork_data, f.name as folder_name + FROM \(tracksTable) t + JOIN \(foldersTable) f ON t.folder_id = f.id + WHERE t.folder_id = ? + ORDER BY t.filename; + """ + + dbQueue.sync { + var stmt: OpaquePointer? + + if sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK { + sqlite3_bind_int(stmt, 1, Int32(folderId)) + + while sqlite3_step(stmt) == SQLITE_ROW { + let track = extractTrackFromStatement(stmt) + tracks.append(track) + } + } + sqlite3_finalize(stmt) + } + + return tracks + } + + // Get tracks for folder WITHOUT artwork + func getTracksForFolderLightweight(_ folderId: Int) -> [DatabaseTrack] { + var tracks: [DatabaseTrack] = [] + let query = """ + SELECT t.id, t.folder_id, t.path, t.filename, t.title, t.artist, + t.album, t.genre, t.year, t.duration, t.format, t.file_size, + t.date_added, NULL as artwork_data, f.name as folder_name + FROM \(tracksTable) t + JOIN \(foldersTable) f ON t.folder_id = f.id + WHERE t.folder_id = ? + ORDER BY t.filename; + """ + + dbQueue.sync { + var stmt: OpaquePointer? + + if sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK { + sqlite3_bind_int(stmt, 1, Int32(folderId)) + + while sqlite3_step(stmt) == SQLITE_ROW { + let track = extractTrackFromStatement(stmt) + tracks.append(track) + } + } + sqlite3_finalize(stmt) + } + + return tracks + } + + func getTracksByArtist(_ artist: String) -> [DatabaseTrack] { + var tracks: [DatabaseTrack] = [] + let query = """ + SELECT t.id, t.folder_id, t.path, t.filename, t.title, t.artist, + t.album, t.genre, t.year, t.duration, t.format, t.file_size, + t.date_added, t.artwork_data, f.name as folder_name + FROM \(tracksTable) t + JOIN \(foldersTable) f ON t.folder_id = f.id + WHERE t.artist LIKE ? + ORDER BY t.album, t.title; + """ + + dbQueue.sync { + var stmt: OpaquePointer? + + if sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK { + sqlite3_bind_text(stmt, 1, "%\(artist)%", -1, SQLITE_TRANSIENT) + + while sqlite3_step(stmt) == SQLITE_ROW { + let track = extractTrackFromStatement(stmt) + tracks.append(track) + } + } + sqlite3_finalize(stmt) + } + + return tracks + } + + // Get tracks by artist WITHOUT artwork + func getTracksByArtistLightweight(_ artist: String) -> [DatabaseTrack] { + var tracks: [DatabaseTrack] = [] + let query = """ + SELECT t.id, t.folder_id, t.path, t.filename, t.title, t.artist, + t.album, t.genre, t.year, t.duration, t.format, t.file_size, + t.date_added, NULL as artwork_data, f.name as folder_name + FROM \(tracksTable) t + JOIN \(foldersTable) f ON t.folder_id = f.id + WHERE t.artist LIKE ? + ORDER BY t.album, t.title; + """ + + dbQueue.sync { + var stmt: OpaquePointer? + + if sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK { + sqlite3_bind_text(stmt, 1, "%\(artist)%", -1, SQLITE_TRANSIENT) + + while sqlite3_step(stmt) == SQLITE_ROW { + let track = extractTrackFromStatement(stmt) + tracks.append(track) + } + } + sqlite3_finalize(stmt) + } + + return tracks + } + + func getTracksByAlbum(_ album: String) -> [DatabaseTrack] { + var tracks: [DatabaseTrack] = [] + let query = """ + SELECT t.id, t.folder_id, t.path, t.filename, t.title, t.artist, + t.album, t.genre, t.year, t.duration, t.format, t.file_size, + t.date_added, t.artwork_data, f.name as folder_name + FROM \(tracksTable) t + JOIN \(foldersTable) f ON t.folder_id = f.id + WHERE t.album = ? + ORDER BY t.title; + """ + + dbQueue.sync { + var stmt: OpaquePointer? + + if sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK { + sqlite3_bind_text(stmt, 1, album, -1, SQLITE_TRANSIENT) + + while sqlite3_step(stmt) == SQLITE_ROW { + let track = extractTrackFromStatement(stmt) + tracks.append(track) + } + } + sqlite3_finalize(stmt) + } + + return tracks + } + + // Get tracks by album WITHOUT artwork + func getTracksByAlbumLightweight(_ album: String) -> [DatabaseTrack] { + var tracks: [DatabaseTrack] = [] + let query = """ + SELECT t.id, t.folder_id, t.path, t.filename, t.title, t.artist, + t.album, t.genre, t.year, t.duration, t.format, t.file_size, + t.date_added, NULL as artwork_data, f.name as folder_name + FROM \(tracksTable) t + JOIN \(foldersTable) f ON t.folder_id = f.id + WHERE t.album = ? + ORDER BY t.title; + """ + + dbQueue.sync { + var stmt: OpaquePointer? + + if sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK { + sqlite3_bind_text(stmt, 1, album, -1, SQLITE_TRANSIENT) + + while sqlite3_step(stmt) == SQLITE_ROW { + let track = extractTrackFromStatement(stmt) + tracks.append(track) + } + } + sqlite3_finalize(stmt) + } + + return tracks + } + + func getTracksByGenre(_ genre: String) -> [DatabaseTrack] { + var tracks: [DatabaseTrack] = [] + let query = """ + SELECT t.id, t.folder_id, t.path, t.filename, t.title, t.artist, + t.album, t.genre, t.year, t.duration, t.format, t.file_size, + t.date_added, t.artwork_data, f.name as folder_name + FROM \(tracksTable) t + JOIN \(foldersTable) f ON t.folder_id = f.id + WHERE t.genre = ? + ORDER BY t.artist, t.title; + """ + + dbQueue.sync { + var stmt: OpaquePointer? + + if sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK { + sqlite3_bind_text(stmt, 1, genre, -1, SQLITE_TRANSIENT) + + while sqlite3_step(stmt) == SQLITE_ROW { + let track = extractTrackFromStatement(stmt) + tracks.append(track) + } + } + sqlite3_finalize(stmt) + } + + return tracks + } + + func getTracksByYear(_ year: String) -> [DatabaseTrack] { + var tracks: [DatabaseTrack] = [] + let query = """ + SELECT t.id, t.folder_id, t.path, t.filename, t.title, t.artist, + t.album, t.genre, t.year, t.duration, t.format, t.file_size, + t.date_added, t.artwork_data, f.name as folder_name + FROM \(tracksTable) t + JOIN \(foldersTable) f ON t.folder_id = f.id + WHERE t.year = ? + ORDER BY t.artist, t.album, t.title; + """ + + dbQueue.sync { + var stmt: OpaquePointer? + + if sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK { + sqlite3_bind_text(stmt, 1, year, -1, SQLITE_TRANSIENT) + + while sqlite3_step(stmt) == SQLITE_ROW { + let track = extractTrackFromStatement(stmt) + tracks.append(track) + } + } + sqlite3_finalize(stmt) + } + + return tracks + } + + // MARK: - Aggregate Queries + + func getAllArtists() -> [String] { + var artists: [String] = [] + let query = "SELECT DISTINCT artist FROM \(tracksTable) WHERE artist IS NOT NULL ORDER BY artist;" + + dbQueue.sync { + var stmt: OpaquePointer? + + if sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK { + while sqlite3_step(stmt) == SQLITE_ROW { + if let artist = sqlite3_column_text(stmt, 0) { + artists.append(String(cString: artist)) + } + } + } + sqlite3_finalize(stmt) + } + + return artists + } + + func getAllAlbums() -> [String] { + var albums: [String] = [] + let query = "SELECT DISTINCT album FROM \(tracksTable) WHERE album IS NOT NULL ORDER BY album;" + + dbQueue.sync { + var stmt: OpaquePointer? + + if sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK { + while sqlite3_step(stmt) == SQLITE_ROW { + if let album = sqlite3_column_text(stmt, 0) { + albums.append(String(cString: album)) + } + } + } + sqlite3_finalize(stmt) + } + + return albums + } + + func getAllGenres() -> [String] { + var genres: [String] = [] + let query = "SELECT DISTINCT genre FROM \(tracksTable) WHERE genre IS NOT NULL ORDER BY genre;" + + dbQueue.sync { + var stmt: OpaquePointer? + + if sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK { + while sqlite3_step(stmt) == SQLITE_ROW { + if let genre = sqlite3_column_text(stmt, 0) { + genres.append(String(cString: genre)) + } + } + } + sqlite3_finalize(stmt) + } + + return genres + } + + func getAllYears() -> [String] { + var years: [String] = [] + let query = "SELECT DISTINCT year FROM \(tracksTable) WHERE year IS NOT NULL ORDER BY year DESC;" + + dbQueue.sync { + var stmt: OpaquePointer? + + if sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK { + while sqlite3_step(stmt) == SQLITE_ROW { + if let year = sqlite3_column_text(stmt, 0) { + years.append(String(cString: year)) + } + } + } + sqlite3_finalize(stmt) + } + + return years + } + + // Get artwork for a specific track when needed + func getArtworkForTrack(_ trackId: Int) -> Data? { + var artworkData: Data? = nil + let query = "SELECT artwork_data FROM \(tracksTable) WHERE id = ?;" + + dbQueue.sync { + var stmt: OpaquePointer? + + if sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK { + sqlite3_bind_int(stmt, 1, Int32(trackId)) + + if sqlite3_step(stmt) == SQLITE_ROW { + if let artworkBlob = sqlite3_column_blob(stmt, 0) { + let artworkSize = sqlite3_column_bytes(stmt, 0) + artworkData = Data(bytes: artworkBlob, count: Int(artworkSize)) + } + } + } + sqlite3_finalize(stmt) + } + + return artworkData + } + + // MARK: - Helper Methods + + private func extractTrackFromStatement(_ stmt: OpaquePointer?) -> DatabaseTrack { + let id = Int(sqlite3_column_int(stmt, 0)) + let folderId = Int(sqlite3_column_int(stmt, 1)) + let path = String(cString: sqlite3_column_text(stmt, 2)) + let filename = String(cString: sqlite3_column_text(stmt, 3)) + + let title = sqlite3_column_text(stmt, 4) != nil ? String(cString: sqlite3_column_text(stmt, 4)) : filename + let artist = sqlite3_column_text(stmt, 5) != nil ? String(cString: sqlite3_column_text(stmt, 5)) : "Unknown Artist" + let album = sqlite3_column_text(stmt, 6) != nil ? String(cString: sqlite3_column_text(stmt, 6)) : "Unknown Album" + let genre = sqlite3_column_text(stmt, 7) != nil ? String(cString: sqlite3_column_text(stmt, 7)) : "Unknown Genre" + let year = sqlite3_column_text(stmt, 8) != nil ? String(cString: sqlite3_column_text(stmt, 8)) : "" + + let duration = sqlite3_column_double(stmt, 9) + let format = String(cString: sqlite3_column_text(stmt, 10)) + let fileSize = sqlite3_column_int64(stmt, 11) + let dateAdded = Date(timeIntervalSince1970: sqlite3_column_double(stmt, 12)) + + var artworkData: Data? = nil + if let artworkBlob = sqlite3_column_blob(stmt, 13) { + let artworkSize = sqlite3_column_bytes(stmt, 13) + artworkData = Data(bytes: artworkBlob, count: Int(artworkSize)) + } + + let folderName = String(cString: sqlite3_column_text(stmt, 14)) + + return DatabaseTrack( + id: id, + folderId: folderId, + folderName: folderName, + path: path, + filename: filename, + title: title, + artist: artist, + album: album, + genre: genre, + year: year, + duration: duration, + format: format, + fileSize: fileSize, + dateAdded: dateAdded, + artworkData: artworkData + ) + } + + // MARK: - Refresh Methods + + func refreshFolder(_ folder: DatabaseFolder, completion: @escaping (Result) -> Void) { + dbQueue.async { [weak self] in + guard let self = self else { return } + + DispatchQueue.main.async { + self.isScanning = true + self.scanStatusMessage = "Refreshing \(folder.name)..." + } + + // Delete existing tracks for this folder + let deleteSQL = "DELETE FROM \(self.tracksTable) WHERE folder_id = ?;" + var stmt: OpaquePointer? + + if sqlite3_prepare_v2(self.db, deleteSQL, -1, &stmt, nil) == SQLITE_OK { + sqlite3_bind_int(stmt, 1, Int32(folder.id)) + sqlite3_step(stmt) + } + sqlite3_finalize(stmt) + + // Rescan the folder + self.scanSingleFolder(folder, supportedExtensions: ["mp3", "m4a", "wav", "aac", "aiff", "flac"]) + + DispatchQueue.main.async { + self.isScanning = false + self.scanStatusMessage = "" + completion(.success(())) + } + } + } +} + +// MARK: - Database Models + +struct DatabaseFolder: Identifiable, Equatable { + let id: Int + let name: String + let path: String + let trackCount: Int + let dateAdded: Date + let dateUpdated: Date +} + +struct DatabaseTrack: Identifiable, Equatable { + let id: Int + let folderId: Int + let folderName: String + let path: String + let filename: String + let title: String + let artist: String + let album: String + let genre: String + let year: String + let duration: Double + let format: String + let fileSize: Int64 + let dateAdded: Date + let artworkData: Data? + + // Convert to Track model for playback + func toTrack() -> Track { + let url = URL(fileURLWithPath: path) + let track = Track(url: url) + + // Set the metadata immediately from database + track.title = title + track.artist = artist + track.album = album + track.genre = genre + track.year = year + track.duration = duration + track.artworkData = artworkData + track.isMetadataLoaded = true + + return track + } +} diff --git a/Managers/Database/MetadataExtractor.swift b/Managers/Database/MetadataExtractor.swift new file mode 100644 index 00000000..6bc890d5 --- /dev/null +++ b/Managers/Database/MetadataExtractor.swift @@ -0,0 +1,165 @@ +import Foundation +import AVFoundation + +class MetadataExtractor { + + static func extractMetadata(from url: URL, completion: @escaping (TrackMetadata) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let asset = AVURLAsset(url: url) + var metadata = TrackMetadata(url: url) + + // Get all common metadata + let metadataList = asset.commonMetadata + + // Process metadata + for item in metadataList { + if let stringValue = item.stringValue { + if let key = item.commonKey { + switch key { + case AVMetadataKey.commonKeyTitle: + metadata.title = stringValue + case AVMetadataKey.commonKeyArtist: + metadata.artist = stringValue + case AVMetadataKey.commonKeyAlbumName: + metadata.album = stringValue + default: + break + } + } + + // Look for genre and year in various possible keys + let keyString = item.key as? String ?? "" + if keyString.lowercased().contains("genre") { + metadata.genre = stringValue + } else if keyString.lowercased().contains("year") || keyString.lowercased().contains("date") { + metadata.year = stringValue + } + } + + // Look for artwork + if item.commonKey == AVMetadataKey.commonKeyArtwork, let data = item.dataValue { + metadata.artworkData = data + } + } + + // Extract genre and year using ID3 metadata if we didn't find them above + if metadata.genre == nil || metadata.year == nil { + for format in [AVMetadataFormat.id3Metadata, AVMetadataFormat.iTunesMetadata] { + let formatMetadata = asset.metadata(forFormat: format) + if !formatMetadata.isEmpty { + for item in formatMetadata { + let keyString = item.key as? String ?? "" + if metadata.genre == nil && keyString.lowercased().contains("genre"), + let stringValue = item.stringValue { + metadata.genre = stringValue + } + if metadata.year == nil && (keyString.lowercased().contains("year") || + keyString.lowercased().contains("date")), + let stringValue = item.stringValue { + // Extract just the year from date strings + let components = stringValue.components(separatedBy: CharacterSet.decimalDigits.inverted) + if let yearString = components.first(where: { $0.count == 4 && Int($0) != nil }) { + metadata.year = yearString + } else { + metadata.year = stringValue + } + } + } + } + if metadata.genre != nil && metadata.year != nil { break } + } + } + + // Get duration + metadata.duration = CMTimeGetSeconds(asset.duration) + + // Return the metadata + DispatchQueue.main.async { + completion(metadata) + } + } + } + + // Synchronous version for batch processing in database operations + static func extractMetadataSync(from url: URL) -> TrackMetadata { + let asset = AVURLAsset(url: url) + var metadata = TrackMetadata(url: url) + + // Set a timeout for loading + let semaphore = DispatchSemaphore(value: 0) + var loadingComplete = false + + // Load metadata asynchronously but wait for it + asset.loadValuesAsynchronously(forKeys: ["commonMetadata", "duration"]) { + defer { + loadingComplete = true + semaphore.signal() + } + + var error: NSError? + let metadataStatus = asset.statusOfValue(forKey: "commonMetadata", error: &error) + let durationStatus = asset.statusOfValue(forKey: "duration", error: &error) + + guard metadataStatus == .loaded && durationStatus == .loaded else { + return + } + + // Process metadata + let metadataList = asset.commonMetadata + + for item in metadataList { + if let stringValue = item.stringValue { + if let key = item.commonKey { + switch key { + case AVMetadataKey.commonKeyTitle: + metadata.title = stringValue + case AVMetadataKey.commonKeyArtist: + metadata.artist = stringValue + case AVMetadataKey.commonKeyAlbumName: + metadata.album = stringValue + default: + break + } + } + + let keyString = item.key as? String ?? "" + if keyString.lowercased().contains("genre") { + metadata.genre = stringValue + } else if keyString.lowercased().contains("year") || keyString.lowercased().contains("date") { + metadata.year = stringValue + } + } + + if item.commonKey == AVMetadataKey.commonKeyArtwork, let data = item.dataValue { + metadata.artworkData = data + } + } + + // Get duration + metadata.duration = CMTimeGetSeconds(asset.duration) + } + + // Wait for loading to complete (with timeout) + let timeout = DispatchTime.now() + .seconds(5) + if semaphore.wait(timeout: timeout) == .timedOut { + print("MetadataExtractor: Timeout loading metadata for \(url.lastPathComponent)") + } + + return metadata + } +} + +struct TrackMetadata { + let url: URL + var title: String? + var artist: String? + var album: String? + var genre: String? + var year: String? + var duration: Double = 0 + var artworkData: Data? + + init(url: URL) { + self.url = url + } +} diff --git a/Managers/Database/TrackCacheManager.swift b/Managers/Database/TrackCacheManager.swift new file mode 100644 index 00000000..82ff2917 --- /dev/null +++ b/Managers/Database/TrackCacheManager.swift @@ -0,0 +1,152 @@ +import Foundation + +class TrackCacheManager { + private var trackCache: [Int: Track] = [:] // Track ID to Track + private var folderTracksCache: [Int: [Track]] = [:] // Folder ID to Tracks + private let cacheQueue = DispatchQueue(label: "com.petrichor.trackcache", attributes: .concurrent) + + // Cache size limits + private let maxTrackCacheSize = 1000 + private let maxFolderCacheSize = 10 + + // LRU tracking + private var trackAccessOrder: [Int] = [] + private var folderAccessOrder: [Int] = [] + + // Get or create a track from database track + func getTrack(from dbTrack: DatabaseTrack, using databaseManager: DatabaseManager) -> Track { + return cacheQueue.sync { + if let cachedTrack = trackCache[dbTrack.id] { + // Move to end (most recently used) + trackAccessOrder.removeAll { $0 == dbTrack.id } + trackAccessOrder.append(dbTrack.id) + return cachedTrack + } + + let track = LightweightTrack(from: dbTrack, databaseManager: databaseManager) + + // Add to cache + trackCache[dbTrack.id] = track + trackAccessOrder.append(dbTrack.id) + + // Evict oldest if cache is too large + if trackCache.count > maxTrackCacheSize { + evictOldestTracks() + } + + return track + } + } + + // Get tracks for a folder (cached) + func getTracksForFolder(_ folderId: Int, from dbTracks: [DatabaseTrack], using databaseManager: DatabaseManager) -> [Track] { + return cacheQueue.sync { + // Check if we have cached tracks for this folder + if let cachedTracks = folderTracksCache[folderId] { + // Move to end (most recently used) + folderAccessOrder.removeAll { $0 == folderId } + folderAccessOrder.append(folderId) + return cachedTracks + } + + // Create tracks and cache them + let tracks = dbTracks.map { dbTrack in + if let cachedTrack = trackCache[dbTrack.id] { + return cachedTrack + } else { + let track = LightweightTrack(from: dbTrack, databaseManager: databaseManager) + trackCache[dbTrack.id] = track + trackAccessOrder.append(dbTrack.id) + return track + } + } + + // Cache the folder's tracks + folderTracksCache[folderId] = tracks + folderAccessOrder.append(folderId) + + // Evict oldest if cache is too large + if folderTracksCache.count > maxFolderCacheSize { + evictOldestFolderCache() + } + + if trackCache.count > maxTrackCacheSize { + evictOldestTracks() + } + + return tracks + } + } + + // Clear cache for a specific folder + func clearFolderCache(_ folderId: Int) { + cacheQueue.async(flags: .barrier) { + self.folderTracksCache.removeValue(forKey: folderId) + self.folderAccessOrder.removeAll { $0 == folderId } + } + } + + // Clear all caches + func clearAllCaches() { + cacheQueue.async(flags: .barrier) { + self.trackCache.removeAll() + self.folderTracksCache.removeAll() + self.trackAccessOrder.removeAll() + self.folderAccessOrder.removeAll() + } + } + + // Update cache when library is refreshed + func refreshCaches() { + clearAllCaches() + } + + // MARK: - Private Methods + + private func evictOldestTracks() { + // Remove the 20% oldest tracks + let countToRemove = maxTrackCacheSize / 5 + let idsToRemove = trackAccessOrder.prefix(countToRemove) + + for id in idsToRemove { + trackCache.removeValue(forKey: id) + } + + trackAccessOrder.removeFirst(countToRemove) + } + + private func evictOldestFolderCache() { + // Remove the oldest folder cache + if let oldestFolderId = folderAccessOrder.first { + folderTracksCache.removeValue(forKey: oldestFolderId) + folderAccessOrder.removeFirst() + } + } + + // Memory pressure handler + func handleMemoryPressure() { + cacheQueue.async(flags: .barrier) { + // Clear half of the caches + let trackCountToKeep = self.trackCache.count / 2 + let folderCountToKeep = self.folderTracksCache.count / 2 + + // Keep only the most recent tracks + if self.trackAccessOrder.count > trackCountToKeep { + let idsToRemove = self.trackAccessOrder.prefix(self.trackAccessOrder.count - trackCountToKeep) + for id in idsToRemove { + self.trackCache.removeValue(forKey: id) + } + self.trackAccessOrder.removeFirst(idsToRemove.count) + } + + // Keep only the most recent folder caches + if self.folderAccessOrder.count > folderCountToKeep { + let idsToRemove = self.folderAccessOrder.prefix(self.folderAccessOrder.count - folderCountToKeep) + for id in idsToRemove { + self.folderTracksCache.removeValue(forKey: id) + } + self.folderAccessOrder.removeFirst(idsToRemove.count) + } + } + } +} diff --git a/Managers/LibraryManager.swift b/Managers/LibraryManager.swift index c39ebef9..74035fe3 100644 --- a/Managers/LibraryManager.swift +++ b/Managers/LibraryManager.swift @@ -6,6 +6,8 @@ class LibraryManager: ObservableObject { @Published var tracks: [Track] = [] @Published var folders: [Folder] = [] @Published var isScanning: Bool = false + @Published var scanProgress: Double = 0.0 + @Published var scanStatusMessage: String = "" // MARK: - Private Properties private let fileManager = FileManager.default @@ -13,10 +15,17 @@ class LibraryManager: ObservableObject { private let userDefaults = UserDefaults.standard private var securityBookmarks: [URL: Data] = [:] + // Database manager + private let databaseManager = DatabaseManager() + + // Cache manager + private let cacheManager = TrackCacheManager() + + // Cache for database folders only + private var databaseFolders: [DatabaseFolder] = [] + // Keys for UserDefaults private enum UserDefaultsKeys { - static let savedFolders = "SavedMusicFolders" - static let savedTracks = "SavedMusicTracks" static let lastScanDate = "LastScanDate" static let securityBookmarks = "SecurityBookmarks" } @@ -24,9 +33,31 @@ class LibraryManager: ObservableObject { // MARK: - Initialization init() { print("LibraryManager: Initializing...") + + // Observe database manager scanning state + databaseManager.$isScanning + .receive(on: DispatchQueue.main) + .assign(to: &$isScanning) + + databaseManager.$scanProgress + .receive(on: DispatchQueue.main) + .assign(to: &$scanProgress) + + databaseManager.$scanStatusMessage + .receive(on: DispatchQueue.main) + .assign(to: &$scanStatusMessage) + loadSecurityBookmarks() loadMusicLibrary() startFileWatcher() + + // Register for memory pressure notifications + NotificationCenter.default.addObserver( + self, + selector: #selector(handleMemoryPressure), + name: NSNotification.Name.NSProcessInfoPowerStateDidChange, + object: nil + ) } deinit { @@ -60,9 +91,9 @@ class LibraryManager: ObservableObject { do { var isStale = false let url = try URL(resolvingBookmarkData: bookmarkData, - options: [.withSecurityScope], - relativeTo: nil, - bookmarkDataIsStale: &isStale) + options: [.withSecurityScope], + relativeTo: nil, + bookmarkDataIsStale: &isStale) if isStale { print("LibraryManager: Bookmark is stale for \(urlString)") @@ -87,8 +118,8 @@ class LibraryManager: ObservableObject { private func createSecurityBookmark(for url: URL) -> Data? { do { let bookmarkData = try url.bookmarkData(options: [.withSecurityScope], - includingResourceValuesForKeys: nil, - relativeTo: nil) + includingResourceValuesForKeys: nil, + relativeTo: nil) securityBookmarks[url] = bookmarkData saveSecurityBookmarks() return bookmarkData @@ -111,30 +142,45 @@ class LibraryManager: ObservableObject { openPanel.begin { [weak self] response in guard let self = self, response == .OK else { return } + var urlsToAdd: [URL] = [] + for url in openPanel.urls { - let folder = Folder(url: url) - - // Check if folder already exists - if !self.folders.contains(where: { $0.url == url }) { - self.folders.append(folder) - print("LibraryManager: Added folder - \(folder.name) at \(url.path)") - - // Create and save security bookmark - _ = self.createSecurityBookmark(for: url) - - // Start scanning for music files immediately - self.scanFolderForMusicFiles(url) - } else { - print("LibraryManager: Folder already exists - \(folder.name)") + // Create and save security bookmark + if self.createSecurityBookmark(for: url) != nil { + urlsToAdd.append(url) + print("LibraryManager: Added folder - \(url.lastPathComponent) at \(url.path)") } } - // Save the updated folder list - self.saveMusicLibrary() + // Add folders to database + if !urlsToAdd.isEmpty { + // Show scanning immediately + self.isScanning = true + self.scanStatusMessage = "Preparing to scan folders..." + + // Small delay to ensure UI updates + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.databaseManager.addFolders(urlsToAdd) { result in + switch result { + case .success(let dbFolders): + print("LibraryManager: Successfully added \(dbFolders.count) folders to database") + self.loadMusicLibrary() // Reload to reflect changes + case .failure(let error): + print("LibraryManager: Failed to add folders to database: \(error)") + } + } + } + } } } func removeFolder(_ folder: Folder) { + // Find the corresponding database folder + guard let dbFolder = databaseFolders.first(where: { $0.path == folder.url.path }) else { + print("LibraryManager: Folder not found in database") + return + } + // Stop accessing the security scoped resource if securityBookmarks[folder.url] != nil { folder.url.stopAccessingSecurityScopedResource() @@ -142,123 +188,42 @@ class LibraryManager: ObservableObject { saveSecurityBookmarks() } - folders.removeAll(where: { $0.id == folder.id }) - - // Remove tracks that were in this folder - let folderPrefix = folder.url.path - tracks.removeAll(where: { $0.url.path.hasPrefix(folderPrefix) }) - - saveMusicLibrary() + // Remove from database + databaseManager.removeFolder(dbFolder) { [weak self] result in + switch result { + case .success: + print("LibraryManager: Successfully removed folder from database") + self?.loadMusicLibrary() // Reload to reflect changes + case .failure(let error): + print("LibraryManager: Failed to remove folder from database: \(error)") + } + } } - // MARK: - File Scanning + // MARK: - Data Management - func scanFolderForMusicFiles(_ folderURL: URL) { - print("LibraryManager: Starting scan of folder - \(folderURL.path)") - - // Set scanning state - DispatchQueue.main.async { - self.isScanning = true - } + func loadMusicLibrary() { + print("LibraryManager: Loading music library from database...") - // Supported audio formats - let supportedExtensions = ["mp3", "m4a", "wav", "aac", "aiff", "flac"] + // Clear caches when reloading + cacheManager.clearAllCaches() - // Use a background thread for scanning - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - guard let self = self else { return } - - var newTracks: [Track] = [] - var scannedFiles = 0 - - do { - // Check if we have access to this folder (should already be started from bookmark) - let hasAccess = self.securityBookmarks[folderURL] != nil - - if !hasAccess { - print("LibraryManager: No security bookmark found for \(folderURL.path)") - DispatchQueue.main.async { self.isScanning = false } - return - } - - // Get all files in the directory and subdirectories - let resourceKeys: [URLResourceKey] = [.isRegularFileKey, .nameKey, .fileSizeKey] - let directoryEnumerator = self.fileManager.enumerator( - at: folderURL, - includingPropertiesForKeys: resourceKeys, - options: [.skipsHiddenFiles, .skipsPackageDescendants], - errorHandler: { (url, error) -> Bool in - print("LibraryManager: Error accessing \(url.path): \(error.localizedDescription)") - return true - } - ) - - guard let enumerator = directoryEnumerator else { - print("LibraryManager: Failed to create directory enumerator for \(folderURL.path)") - DispatchQueue.main.async { self.isScanning = false } - return - } - - for case let fileURL as URL in enumerator { - scannedFiles += 1 - - do { - let resourceValues = try fileURL.resourceValues(forKeys: Set(resourceKeys)) - - // Skip if not a regular file - guard resourceValues.isRegularFile == true else { continue } - - let fileExtension = fileURL.pathExtension.lowercased() - - // Check if it's a supported audio file - if supportedExtensions.contains(fileExtension) { - // Check if we already have this track - let existingTrackExists = self.tracks.contains { existingTrack in - existingTrack.url.path == fileURL.path - } - - if !existingTrackExists { - let track = Track(url: fileURL) - newTracks.append(track) - print("LibraryManager: Found new track - \(fileURL.lastPathComponent)") - } - } - } catch { - print("LibraryManager: Error reading file properties for \(fileURL.path): \(error.localizedDescription)") - } - } - - print("LibraryManager: Scanned \(scannedFiles) files, found \(newTracks.count) new tracks in \(folderURL.lastPathComponent)") - - // Update UI on main thread - DispatchQueue.main.async { - self.tracks.append(contentsOf: newTracks) - self.isScanning = false - self.saveMusicLibrary() // Save after adding tracks - print("LibraryManager: Total tracks in library: \(self.tracks.count)") - } - - } catch { - print("LibraryManager: Error scanning folder \(folderURL.path): \(error.localizedDescription)") - DispatchQueue.main.async { - self.isScanning = false - } - } + // Load folders from database + databaseFolders = databaseManager.getAllFolders() + folders = databaseFolders.map { dbFolder in + Folder(url: URL(fileURLWithPath: dbFolder.path)) } - } - - // MARK: - File Access Helper - - func ensureFileAccess(for url: URL) -> Bool { - // Check if this file is within one of our secured folders - for securedURL in securityBookmarks.keys { - if url.path.hasPrefix(securedURL.path) { - return true // We have access through the parent folder - } + + // Load lightweight tracks from database (without artwork) + let dbTracks = databaseManager.getAllTracksLightweight() + tracks = dbTracks.map { dbTrack in + cacheManager.getTrack(from: dbTrack, using: databaseManager) } - print("LibraryManager: No access available for file: \(url.path)") - return false + print("LibraryManager: Loaded \(folders.count) folders and \(tracks.count) tracks from database") + + // Update last scan date + userDefaults.set(Date(), forKey: UserDefaultsKeys.lastScanDate) } // MARK: - File Watching @@ -268,181 +233,67 @@ class LibraryManager: ObservableObject { fileWatcherTimer = Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { [weak self] _ in guard let self = self else { return } - // Only scan if we're not currently scanning + // Only refresh if we're not currently scanning if !self.isScanning { - print("LibraryManager: Starting periodic scan...") - for folder in self.folders { - self.scanFolderForMusicFiles(folder.url) - } + print("LibraryManager: Starting periodic refresh...") + self.refreshLibrary() } } } - // MARK: - Data Management - - func saveMusicLibrary() { - print("LibraryManager: Saving music library...") - - // Save folders - let folderData = folders.map { folder in - [ - "id": folder.id.uuidString, - "url": folder.url.absoluteString, - "name": folder.name - ] - } - userDefaults.set(folderData, forKey: UserDefaultsKeys.savedFolders) - - // Save tracks - let trackData = tracks.map { track in - [ - "id": track.id.uuidString, - "url": track.url.absoluteString, - "title": track.title, - "artist": track.artist, - "album": track.album, - "genre": track.genre, - "year": track.year, - "duration": String(track.duration), - "format": track.format - ] - } - userDefaults.set(trackData, forKey: UserDefaultsKeys.savedTracks) - - // Save last scan date - userDefaults.set(Date(), forKey: UserDefaultsKeys.lastScanDate) - - print("LibraryManager: Saved \(folders.count) folders and \(tracks.count) tracks to UserDefaults") - } - - func loadMusicLibrary() { - print("LibraryManager: Loading music library from UserDefaults...") - - // Clear existing data - folders.removeAll() - tracks.removeAll() - - // Load saved folders - if let savedFolderData = userDefaults.array(forKey: UserDefaultsKeys.savedFolders) as? [[String: String]] { - var loadedFolders: [Folder] = [] - - for folderDict in savedFolderData { - guard let urlString = folderDict["url"], - let url = URL(string: urlString) else { - print("LibraryManager: Invalid folder data found, skipping...") - continue - } - - // Check if the folder still exists and we have access - if fileManager.fileExists(atPath: url.path) && securityBookmarks[url] != nil { - let folder = Folder(url: url) - loadedFolders.append(folder) - print("LibraryManager: Loaded folder - \(folder.name)") - } else { - print("LibraryManager: Folder no longer exists or no access - \(url.path)") - } - } - - folders = loadedFolders - } - - // Load saved tracks - if let savedTrackData = userDefaults.array(forKey: UserDefaultsKeys.savedTracks) as? [[String: String]] { - var loadedTracks: [Track] = [] - - for trackDict in savedTrackData { - guard let urlString = trackDict["url"], - let url = URL(string: urlString) else { - print("LibraryManager: Invalid track data found, skipping...") - continue - } - - // Check if the track file still exists and we have access - if fileManager.fileExists(atPath: url.path) && ensureFileAccess(for: url) { - let track = Track(url: url) - - // Restore saved metadata - if let title = trackDict["title"], !title.isEmpty { - track.title = title - } - if let artist = trackDict["artist"], !artist.isEmpty { - track.artist = artist - } - if let album = trackDict["album"], !album.isEmpty { - track.album = album - } - if let genre = trackDict["genre"], !genre.isEmpty { - track.genre = genre - } - if let year = trackDict["year"], !year.isEmpty { - track.year = year - } - if let durationString = trackDict["duration"], - let duration = Double(durationString) { - track.duration = duration - } - - loadedTracks.append(track) - } else { - print("LibraryManager: Track file no longer exists or no access - \(url.path)") - } - } - - tracks = loadedTracks - print("LibraryManager: Loaded \(loadedTracks.count) tracks from UserDefaults") - } - - print("LibraryManager: Library loading complete - \(folders.count) folders, \(tracks.count) tracks") - } - // MARK: - Track Management func getTracksInFolder(_ folder: Folder) -> [Track] { - let folderPrefix = folder.url.path - let folderTracks = tracks.filter { $0.url.path.hasPrefix(folderPrefix) } - print("LibraryManager: Found \(folderTracks.count) tracks in folder \(folder.name)") - return folderTracks + // Find the corresponding database folder + guard let dbFolder = databaseFolders.first(where: { $0.path == folder.url.path }) else { + print("LibraryManager: Folder not found in database") + return [] + } + + // Get lightweight tracks from database and use cache + let dbTracks = databaseManager.getTracksForFolderLightweight(dbFolder.id) + return cacheManager.getTracksForFolder(dbFolder.id, from: dbTracks, using: databaseManager) } func getTracksByArtist(_ artist: String) -> [Track] { - return tracks.filter { $0.artist == artist } + let dbTracks = databaseManager.getTracksByArtistLightweight(artist) + return dbTracks.map { cacheManager.getTrack(from: $0, using: databaseManager) } } func getTracksByArtistContaining(_ artistName: String) -> [Track] { - return tracks.filter { track in - track.artist.localizedCaseInsensitiveContains(artistName) - } + // The database method already uses LIKE with wildcards + return getTracksByArtist(artistName) } func getTracksByAlbum(_ album: String) -> [Track] { - return tracks.filter { $0.album == album } + let dbTracks = databaseManager.getTracksByAlbumLightweight(album) + return dbTracks.map { cacheManager.getTrack(from: $0, using: databaseManager) } } func getTracksByGenre(_ genre: String) -> [Track] { - return tracks.filter { $0.genre == genre } + let dbTracks = databaseManager.getTracksByGenre(genre) + return dbTracks.map { cacheManager.getTrack(from: $0, using: databaseManager) } } func getTracksByYear(_ year: String) -> [Track] { - return tracks.filter { $0.year == year } + let dbTracks = databaseManager.getTracksByYear(year) + return dbTracks.map { cacheManager.getTrack(from: $0, using: databaseManager) } } func getAllArtists() -> [String] { - return Array(Set(tracks.map { $0.artist })).sorted() + return databaseManager.getAllArtists() } func getAllAlbums() -> [String] { - return Array(Set(tracks.map { $0.album })).sorted() + return databaseManager.getAllAlbums() } func getAllGenres() -> [String] { - return Array(Set(tracks.map { $0.genre })).sorted() + return databaseManager.getAllGenres() } func getAllYears() -> [String] { - return Array(Set(tracks.map { $0.year })).sorted { - // Sort years in descending order (newest first) - $0.localizedStandardCompare($1) == .orderedDescending - } + return databaseManager.getAllYears() } // MARK: - Library Maintenance @@ -450,25 +301,94 @@ class LibraryManager: ObservableObject { func refreshLibrary() { print("LibraryManager: Refreshing library...") - // Clear existing tracks and rescan all folders - tracks.removeAll() + // For each folder, trigger a refresh in the database + for dbFolder in databaseFolders { + databaseManager.refreshFolder(dbFolder) { result in + switch result { + case .success: + print("LibraryManager: Successfully refreshed folder \(dbFolder.name)") + case .failure(let error): + print("LibraryManager: Failed to refresh folder \(dbFolder.name): \(error)") + } + } + } + + // Reload the library after refresh + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + self.loadMusicLibrary() + } + } + + func refreshFolder(_ folder: Folder) { + // Find the corresponding database folder + guard let dbFolder = databaseFolders.first(where: { $0.path == folder.url.path }) else { + print("LibraryManager: Folder not found in database") + return + } + + // Clear cache for this folder + cacheManager.clearFolderCache(dbFolder.id) - for folder in folders { - scanFolderForMusicFiles(folder.url) + // Delegate to database manager for refresh + databaseManager.refreshFolder(dbFolder) { [weak self] result in + switch result { + case .success: + print("LibraryManager: Successfully refreshed folder \(dbFolder.name)") + // Reload the library to reflect changes + self?.loadMusicLibrary() + case .failure(let error): + print("LibraryManager: Failed to refresh folder \(dbFolder.name): \(error)") + } } } func cleanupMissingFolders() { - // Remove folders that no longer exist - let existingFolders = folders.filter { folder in - fileManager.fileExists(atPath: folder.url.path) + // Check each folder to see if it still exists + var foldersToRemove: [DatabaseFolder] = [] + + for dbFolder in databaseFolders { + if !fileManager.fileExists(atPath: dbFolder.path) { + foldersToRemove.append(dbFolder) + } } - if existingFolders.count != folders.count { - print("LibraryManager: Cleaning up \(folders.count - existingFolders.count) missing folders") - folders = existingFolders - saveMusicLibrary() - refreshLibrary() + if !foldersToRemove.isEmpty { + print("LibraryManager: Cleaning up \(foldersToRemove.count) missing folders") + + for folder in foldersToRemove { + databaseManager.removeFolder(folder) { _ in } + } + + // Reload after cleanup + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.loadMusicLibrary() + } + } + } + + // MARK: - Memory Management + + @objc private func handleMemoryPressure() { + print("LibraryManager: Handling memory pressure") + cacheManager.handleMemoryPressure() + + // Clear artwork from tracks that aren't currently playing + if let coordinator = AppCoordinator.shared, + let currentTrack = coordinator.audioPlayerManager.currentTrack { + // Clear artwork from all tracks except the current one + for track in tracks { + if track.id != currentTrack.id, + let lightweightTrack = track as? LightweightTrack { + lightweightTrack.clearArtwork() + } + } + } else { + // No track playing, clear all artwork + for track in tracks { + if let lightweightTrack = track as? LightweightTrack { + lightweightTrack.clearArtwork() + } + } } } } diff --git a/Managers/NowPlayingManager.swift b/Managers/NowPlayingManager.swift index 294f8ff0..47b90985 100644 --- a/Managers/NowPlayingManager.swift +++ b/Managers/NowPlayingManager.swift @@ -1,11 +1,3 @@ -// -// NowPlayingManager.swift -// Petrichor -// -// Created by Kushal Pandya on 2025-04-19. -// - - import Foundation import AppKit import MediaPlayer diff --git a/Models/Core/LightweightTrack.swift b/Models/Core/LightweightTrack.swift new file mode 100644 index 00000000..c8250839 --- /dev/null +++ b/Models/Core/LightweightTrack.swift @@ -0,0 +1,77 @@ +import Foundation + +class LightweightTrack: Track { + private let trackId: Int + private weak var databaseManager: DatabaseManager? + private var _artworkData: Data? + private var artworkLoaded = false + private var artworkAccessDate: Date? + + // Artwork cache timeout (30 seconds) + private static let artworkCacheTimeout: TimeInterval = 30 + + init(from dbTrack: DatabaseTrack, databaseManager: DatabaseManager) { + self.trackId = dbTrack.id + self.databaseManager = databaseManager + + let url = URL(fileURLWithPath: dbTrack.path) + super.init(url: url) + + // Set metadata immediately without loading artwork + self.title = dbTrack.title + self.artist = dbTrack.artist + self.album = dbTrack.album + self.genre = dbTrack.genre + self.year = dbTrack.year + self.duration = dbTrack.duration + self.isMetadataLoaded = true + } + + // Override artworkData to load on-demand with cache management + override var artworkData: Data? { + get { + // Check if we should clear cached artwork + if let accessDate = artworkAccessDate, + Date().timeIntervalSince(accessDate) > Self.artworkCacheTimeout { + clearArtwork() + } + + if !artworkLoaded { + artworkLoaded = true + _artworkData = databaseManager?.getArtworkForTrack(trackId) + artworkAccessDate = _artworkData != nil ? Date() : nil + } + + // Update access time when artwork is accessed + if _artworkData != nil { + artworkAccessDate = Date() + } + + return _artworkData + } + set { + _artworkData = newValue + artworkLoaded = true + artworkAccessDate = newValue != nil ? Date() : nil + } + } + + // Method to explicitly clear artwork from memory + func clearArtwork() { + _artworkData = nil + artworkLoaded = false + artworkAccessDate = nil + } + + // Method to preload artwork (useful for visible items) + func preloadArtwork() { + _ = artworkData + } +} + +// Extension to DatabaseTrack for lightweight conversion +extension DatabaseTrack { + func toLightweightTrack(using databaseManager: DatabaseManager) -> Track { + return LightweightTrack(from: self, databaseManager: databaseManager) + } +} diff --git a/Models/Core/Playlist.swift b/Models/Core/Playlist.swift index dbc88ab6..0623f6cd 100644 --- a/Models/Core/Playlist.swift +++ b/Models/Core/Playlist.swift @@ -1,11 +1,3 @@ -// -// Playlist.swift -// Petrichor -// -// Created by Kushal Pandya on 2025-04-19. -// - - import Foundation struct Playlist: Identifiable { @@ -90,4 +82,4 @@ extension Playlist { return String(format: "%d:%02d", minutes, seconds) } } -} \ No newline at end of file +} diff --git a/Models/Core/Track.swift b/Models/Core/Track.swift index b053f7a6..0c86831b 100644 --- a/Models/Core/Track.swift +++ b/Models/Core/Track.swift @@ -1,5 +1,4 @@ import Foundation -import AVFoundation class Track: Identifiable, ObservableObject, Equatable { let id = UUID() @@ -18,7 +17,7 @@ class Track: Identifiable, ObservableObject, Equatable { init(url: URL) { self.url = url - // Default values + // Default values - these will be overridden by LightweightTrack self.title = url.deletingPathExtension().lastPathComponent self.artist = "Unknown Artist" self.album = "Unknown Album" @@ -26,128 +25,10 @@ class Track: Identifiable, ObservableObject, Equatable { self.year = "Unknown Year" self.duration = 0 self.format = url.pathExtension - - // Load metadata - loadMetadata() } // Add Equatable conformance static func == (lhs: Track, rhs: Track) -> Bool { return lhs.id == rhs.id } - - private func loadMetadata() { - // We'll use AVURLAsset for metadata extraction - let asset = AVURLAsset(url: self.url) - - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - guard let self = self else { return } - - // Get all common metadata - let metadataList = asset.commonMetadata - - var title: String? - var artist: String? - var album: String? - var genre: String? - var year: String? - var artwork: Data? - - // Process metadata - for item in metadataList { - if let stringValue = item.stringValue { - if let key = item.commonKey { - switch key { - case AVMetadataKey.commonKeyTitle: - title = stringValue - case AVMetadataKey.commonKeyArtist: - artist = stringValue - case AVMetadataKey.commonKeyAlbumName: - album = stringValue - default: - break - } - } - - // Look for genre and year in various possible keys - let keyString = item.key as? String ?? "" - if keyString.lowercased().contains("genre") { - genre = stringValue - } else if keyString.lowercased().contains("year") || keyString.lowercased().contains("date") { - year = stringValue - } - } - - // Look for artwork - if item.commonKey == AVMetadataKey.commonKeyArtwork, let data = item.dataValue { - artwork = data - } - } - - // Extract genre and year using ID3 metadata if we didn't find them above - if genre == nil || year == nil { - for format in [AVMetadataFormat.id3Metadata, AVMetadataFormat.iTunesMetadata] { - let formatMetadata = asset.metadata(forFormat: format) - // Check if the array is not empty before processing - if !formatMetadata.isEmpty { - for item in formatMetadata { - let keyString = item.key as? String ?? "" - if genre == nil && keyString.lowercased().contains("genre"), let stringValue = item.stringValue { - genre = stringValue - } - if year == nil && (keyString.lowercased().contains("year") || keyString.lowercased().contains("date")), let stringValue = item.stringValue { - // Extract just the year from date strings - let components = stringValue.components(separatedBy: CharacterSet.decimalDigits.inverted) - if let yearString = components.first(where: { $0.count == 4 && Int($0) != nil }) { - year = yearString - } else { - year = stringValue - } - } - } - } - if genre != nil && year != nil { break } - } - } - - // Get duration (this works regardless of metadata) - let duration = CMTimeGetSeconds(asset.duration) - - // Update on main thread - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - - if let title = title, !title.isEmpty { - self.title = title - } - - if let artist = artist, !artist.isEmpty { - self.artist = artist - } - - if let album = album, !album.isEmpty { - self.album = album - } - - if let genre = genre, !genre.isEmpty { - self.genre = genre - } - - if let year = year, !year.isEmpty { - self.year = year - } - - self.duration = duration - self.artworkData = artwork - self.isMetadataLoaded = true - - // Debug log for artwork loading - if artwork != nil { - print("Loaded artwork for: \(self.title)") - } else { - print("No artwork found for: \(self.title)") - } - } - } - } } diff --git a/Models/Enums/AutoScanInterval.swift b/Models/Enums/AutoScanInterval.swift new file mode 100644 index 00000000..e1e80f06 --- /dev/null +++ b/Models/Enums/AutoScanInterval.swift @@ -0,0 +1,34 @@ +import Foundation + +enum AutoScanInterval: String, CaseIterable, Codable { + case every15Minutes = "every15Minutes" + case every30Minutes = "every30Minutes" + case every60Minutes = "every60Minutes" + case onlyOnLaunch = "onlyOnLaunch" + + var displayName: String { + switch self { + case .every15Minutes: + return "Every 15 minutes" + case .every30Minutes: + return "Every 30 minutes" + case .every60Minutes: + return "Every hour" + case .onlyOnLaunch: + return "Only on app launch" + } + } + + var timeInterval: TimeInterval? { + switch self { + case .every15Minutes: + return 15 * 60 // 15 minutes in seconds + case .every30Minutes: + return 30 * 60 // 30 minutes in seconds + case .every60Minutes: + return 60 * 60 // 1 hour in seconds + case .onlyOnLaunch: + return nil // No automatic scanning + } + } +} diff --git a/PetrichorApp.swift b/PetrichorApp.swift index 83285674..52fbd37e 100644 --- a/PetrichorApp.swift +++ b/PetrichorApp.swift @@ -11,6 +11,12 @@ struct PetrichorApp: App { .environmentObject(appCoordinator.audioPlayerManager) .environmentObject(appCoordinator.libraryManager) .environmentObject(appCoordinator.playlistManager) + .onAppear { + // Ensure window is visible on launch + if let window = NSApp.mainWindow { + window.makeKeyAndOrderFront(nil) + } + } } .commands { // Add custom menu commands @@ -31,11 +37,11 @@ struct PetrichorApp: App { } } - #if os(macOS) +#if os(macOS) Settings { SettingsView() .environmentObject(appCoordinator.libraryManager) } - #endif +#endif } } diff --git a/Views/Components/PlayingIndicator.swift b/Views/Components/PlayingIndicator.swift index 571fe45d..28bb205b 100644 --- a/Views/Components/PlayingIndicator.swift +++ b/Views/Components/PlayingIndicator.swift @@ -4,13 +4,13 @@ struct PlayingIndicator: View { @State private var isAnimating = false var body: some View { - HStack(spacing: 1) { + HStack(spacing: 2) { ForEach(0..<3) { index in RoundedRectangle(cornerRadius: 1) .fill(Color.accentColor) - .frame(width: 2, height: isAnimating ? 8 : 3) + .frame(width: 3, height: isAnimating ? 12 : 4) .animation( - Animation.easeInOut(duration: 0.5) + Animation.easeInOut(duration: 0.4) .repeatForever(autoreverses: true) .delay(Double(index) * 0.1), value: isAnimating @@ -18,8 +18,12 @@ struct PlayingIndicator: View { } } .frame(width: 16, height: 12) + .clipped() .onAppear { isAnimating = true } + .onDisappear { + isAnimating = false + } } } diff --git a/Views/Components/VirtualizedTrackGrid.swift b/Views/Components/VirtualizedTrackGrid.swift new file mode 100644 index 00000000..dcbb0f5a --- /dev/null +++ b/Views/Components/VirtualizedTrackGrid.swift @@ -0,0 +1,215 @@ +import SwiftUI + +struct VirtualizedTrackGrid: View { + let tracks: [Track] + @EnvironmentObject var audioPlayerManager: AudioPlayerManager + @Binding var selectedTrackID: UUID? + let onPlayTrack: (Track) -> Void + let contextMenuItems: (Track) -> [ContextMenuItem] + + @State private var gridWidth: CGFloat = 0 + + // Grid configuration + private let itemWidth: CGFloat = 180 + private let itemHeight: CGFloat = 240 + private let spacing: CGFloat = 16 + + private var columns: Int { + max(1, Int((gridWidth + spacing) / (itemWidth + spacing))) + } + + var body: some View { + GeometryReader { geometry in + ScrollView { + LazyVGrid( + columns: Array(repeating: GridItem(.fixed(itemWidth), spacing: spacing), count: columns), + spacing: spacing + ) { + ForEach(tracks) { track in + VirtualizedTrackGridItem( + track: track, + isCurrentTrack: audioPlayerManager.currentTrack?.id == track.id, + isPlaying: audioPlayerManager.currentTrack?.id == track.id && audioPlayerManager.isPlaying, + isSelected: selectedTrackID == track.id, + onSelect: { + selectedTrackID = track.id + }, + onPlay: { + onPlayTrack(track) + selectedTrackID = track.id + } + ) + .frame(width: itemWidth, height: itemHeight) + .contextMenu { + ForEach(contextMenuItems(track), id: \.id) { item in + switch item { + case .button(let title, let role, let action): + Button(title, role: role, action: action) + case .menu(let title, let items): + Menu(title) { + ForEach(items, id: \.id) { subItem in + if case .button(let subTitle, let subRole, let subAction) = subItem { + Button(subTitle, role: subRole, action: subAction) + } + } + } + case .divider: + Divider() + } + } + } + } + } + .padding() + } + .background(Color.clear) + .onAppear { + gridWidth = geometry.size.width - 32 // Account for padding + } + .onChange(of: geometry.size.width) { newWidth in + gridWidth = newWidth - 32 + } + } + } +} + +struct VirtualizedTrackGridItem: View { + @ObservedObject var track: Track + let isCurrentTrack: Bool + let isPlaying: Bool + let isSelected: Bool + let onSelect: () -> Void + let onPlay: () -> Void + + @State private var isHovered = false + @State private var artworkImage: NSImage? + + var body: some View { + VStack(spacing: 8) { + // Album art with play overlay + // Album art with play overlay + // Album art with play overlay + // Album art with play overlay + ZStack { + // Album artwork + Group { + if let artworkImage = artworkImage { + Image(nsImage: artworkImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 160, height: 160) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + RoundedRectangle(cornerRadius: 8) + .fill(Color.gray.opacity(0.2)) + .frame(width: 160, height: 160) + .overlay( + Image(systemName: "music.note") + .font(.system(size: 40)) + .foregroundColor(.secondary) + ) + } + } + .task { + loadArtwork() + } + + // Play overlay + if isHovered || isCurrentTrack { + RoundedRectangle(cornerRadius: 8) + .fill(Color.black.opacity(0.4)) + .frame(width: 160, height: 160) + .overlay( + Button(action: onPlay) { + Image(systemName: isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 24, weight: .medium)) + .foregroundColor(.white) + .frame(width: 44, height: 44) + .background( + Circle() + .fill(Color.black.opacity(0.6)) + ) + } + .buttonStyle(.borderless) + ) + .opacity(isHovered || isCurrentTrack ? 1 : 0) + .animation(.easeInOut(duration: 0.2), value: isHovered) + .animation(.easeInOut(duration: 0.2), value: isCurrentTrack) + } + + // Playing indicator in corner + if isCurrentTrack && isPlaying { + VStack { + HStack { + Spacer() + PlayingIndicator() + .padding(.top, 8) + .padding(.trailing, 8) + } + Spacer() + } + .frame(width: 160, height: 160) + } + } + .onHover { hovering in + isHovered = hovering + } + + // Track info + VStack(alignment: .leading, spacing: 4) { + Text(track.title) + .font(.system(size: 14, weight: isCurrentTrack ? .medium : .regular)) + .foregroundColor(isCurrentTrack ? .accentColor : .primary) + .lineLimit(2) + .multilineTextAlignment(.leading) + + Text(track.artist) + .font(.system(size: 12)) + .foregroundColor(.secondary) + .lineLimit(1) + + if !track.album.isEmpty && track.album != "Unknown Album" { + Text(track.album) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + .frame(width: 160, alignment: .leading) + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(backgroundColor) + .animation(.easeInOut(duration: 0.1), value: isSelected) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(isSelected ? Color.accentColor.opacity(0.3) : Color.clear, lineWidth: 1) + .animation(.easeInOut(duration: 0.1), value: isSelected) + ) + .contentShape(RoundedRectangle(cornerRadius: 10)) + .onTapGesture { + withAnimation(.none) { + onSelect() + } + } + } + + private var backgroundColor: Color { + isSelected ? Color.accentColor.opacity(0.1) : Color.clear + } + + private func loadArtwork() { + guard artworkImage == nil else { return } + + Task { + if let artworkData = track.artworkData, + let image = NSImage(data: artworkData) { + await MainActor.run { + self.artworkImage = image + } + } + } + } +} diff --git a/Views/Components/VirtualizedTrackList.swift b/Views/Components/VirtualizedTrackList.swift new file mode 100644 index 00000000..88e66f35 --- /dev/null +++ b/Views/Components/VirtualizedTrackList.swift @@ -0,0 +1,226 @@ +import SwiftUI + +struct VirtualizedTrackList: View { + let tracks: [Track] + @EnvironmentObject var audioPlayerManager: AudioPlayerManager + @Binding var selectedTrackID: UUID? + let onPlayTrack: (Track) -> Void + let contextMenuItems: (Track) -> [ContextMenuItem] + + var body: some View { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: []) { + ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in + VirtualizedTrackRow( + track: track, + isCurrentTrack: audioPlayerManager.currentTrack?.id == track.id, + isPlaying: audioPlayerManager.currentTrack?.id == track.id && audioPlayerManager.isPlaying, + isSelected: selectedTrackID == track.id, + onSelect: { + withAnimation(.none) { + selectedTrackID = track.id + } + }, + onPlay: { + onPlayTrack(track) + selectedTrackID = track.id + }, + contextMenuItems: { + contextMenuItems(track) + } + ) + .frame(height: 60) + .id(track.id) + } + } + .padding(.vertical, 1) // Small padding to prevent edge clipping + } + + } +} + +// Optimized track row that only loads what's needed +struct VirtualizedTrackRow: View { + @ObservedObject var track: Track + let isCurrentTrack: Bool + let isPlaying: Bool + let isSelected: Bool + let onSelect: () -> Void + let onPlay: () -> Void + let contextMenuItems: () -> [ContextMenuItem] + + @State private var isHovered = false + @State private var artworkImage: NSImage? + + var body: some View { + HStack(spacing: 0) { + // Playing indicator + HStack(spacing: 0) { + if isPlaying { + PlayingIndicator() + .frame(width: 16) + .padding(.leading, 10) + } else { + Spacer() + .frame(width: 20) + } + } + .frame(width: 20) + + // Track content + HStack(spacing: 12) { + // Album art - lazy load + Group { + if let artworkImage = artworkImage { + Image(nsImage: artworkImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 40, height: 40) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } else { + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray.opacity(0.2)) + .frame(width: 40, height: 40) + .overlay( + Image(systemName: "music.note") + .font(.system(size: 16)) + .foregroundColor(.secondary) + ) + } + } + .task { + loadArtwork() + } + + // Track info + VStack(alignment: .leading, spacing: 2) { + Text(track.title) + .font(.system(size: 14, weight: isCurrentTrack ? .medium : .regular)) + .foregroundColor(isCurrentTrack ? .accentColor : .primary) + .lineLimit(1) + + HStack(spacing: 4) { + Text(track.artist) + .font(.system(size: 12)) + .foregroundColor(.secondary) + .lineLimit(1) + + if !track.album.isEmpty && track.album != "Unknown Album" { + Text("•") + .font(.system(size: 12)) + .foregroundColor(.secondary) + + Text(track.album) + .font(.system(size: 12)) + .foregroundColor(.secondary) + .lineLimit(1) + } + + if !track.year.isEmpty && track.year != "Unknown Year" { + Text("•") + .font(.system(size: 12)) + .foregroundColor(.secondary) + + Text(track.year) + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + } + } + + Spacer() + + // Duration + Text(formatDuration(track.duration)) + .font(.system(size: 12)) + .foregroundColor(.secondary) + .monospacedDigit() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .frame(height: 60) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(backgroundColor) + .animation(.easeInOut(duration: 0.1), value: isSelected) + .animation(.easeInOut(duration: 0.1), value: isHovered) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(isSelected ? Color.accentColor.opacity(0.3) : Color.clear, lineWidth: 1) + .animation(.easeInOut(duration: 0.1), value: isSelected) + ) + .contentShape(Rectangle()) + .onHover { hovering in + isHovered = hovering + } + .onTapGesture(count: 2) { + onPlay() + } + .simultaneousGesture( + TapGesture(count: 1) + .onEnded { _ in + onSelect() + } + ) + .contextMenu { + ForEach(contextMenuItems(), id: \.id) { item in + switch item { + case .button(let title, let role, let action): + Button(title, role: role, action: action) + case .menu(let title, let items): + Menu(title) { + ForEach(items, id: \.id) { subItem in + if case .button(let subTitle, let subRole, let subAction) = subItem { + Button(subTitle, role: subRole, action: subAction) + } + } + } + case .divider: + Divider() + } + } + } + } + + private var backgroundColor: Color { + if isSelected { + return Color.accentColor.opacity(0.1) + } else if isHovered { + return Color.gray.opacity(0.05) + } else { + return Color.clear + } + } + + private func loadArtwork() { + guard artworkImage == nil else { return } + + Task { + if let artworkData = track.artworkData, + let image = NSImage(data: artworkData) { + await MainActor.run { + self.artworkImage = image + } + } + } + } + + private func formatDuration(_ seconds: Double) -> String { + let totalSeconds = Int(max(0, seconds)) + let minutes = totalSeconds / 60 + let remainingSeconds = totalSeconds % 60 + return String(format: "%d:%02d", minutes, remainingSeconds) + } +} + +#Preview { + VirtualizedTrackList( + tracks: [], + selectedTrackID: .constant(nil), + onPlayTrack: { _ in }, + contextMenuItems: { _ in [] } + ) + .environmentObject(AudioPlayerManager(libraryManager: LibraryManager(), playlistManager: PlaylistManager())) +} diff --git a/Views/Folders/FolderRowView.swift b/Views/Folders/FolderRowView.swift deleted file mode 100644 index bc7b1239..00000000 --- a/Views/Folders/FolderRowView.swift +++ /dev/null @@ -1,144 +0,0 @@ -import SwiftUI - -struct FolderRowView: View { - let folder: Folder - let trackCount: Int - let onRemove: () -> Void - - @State private var isExpanded = false - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - // Folder icon - Image(systemName: "folder.fill") - .foregroundColor(.accentColor) - .font(.title3) - - VStack(alignment: .leading, spacing: 2) { - Text(folder.name) - .font(.headline) - .lineLimit(1) - - HStack { - Text(folder.url.path) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - - Spacer() - - Text("\(trackCount) tracks") - .font(.caption) - .foregroundColor(.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(Color.secondary.opacity(0.1)) - .cornerRadius(4) - } - } - - Spacer() - - // Actions - HStack(spacing: 8) { - Button(action: { isExpanded.toggle() }) { - Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .font(.caption) - } - .buttonStyle(.borderless) - - Button(action: onRemove) { - Image(systemName: "minus.circle.fill") - .foregroundColor(.red) - } - .buttonStyle(.borderless) - } - } - - // Expanded details - if isExpanded { - VStack(alignment: .leading, spacing: 4) { - Divider() - - HStack { - Text("Full Path:") - .font(.caption) - .fontWeight(.medium) - - Text(folder.url.path) - .font(.caption) - .foregroundColor(.secondary) - .textSelection(.enabled) - } - - HStack { - Text("Added:") - .font(.caption) - .fontWeight(.medium) - - Text("Recently") // You could store this date if needed - .font(.caption) - .foregroundColor(.secondary) - } - - if trackCount > 0 { - HStack { - Text("Status:") - .font(.caption) - .fontWeight(.medium) - - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - .font(.caption) - - Text("Active") - .font(.caption) - .foregroundColor(.green) - } - } - } else { - HStack { - Text("Status:") - .font(.caption) - .fontWeight(.medium) - - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) - .font(.caption) - - Text("No tracks found") - .font(.caption) - .foregroundColor(.orange) - } - } - } - } - .padding(.leading, 32) - } - } - .padding(.vertical, 4) - } -} - -#Preview { - let sampleURL = URL(fileURLWithPath: "/Users/username/Music") - let sampleFolder = Folder(url: sampleURL) - - return VStack { - FolderRowView( - folder: sampleFolder, - trackCount: 42, - onRemove: { print("Remove folder") } - ) - - FolderRowView( - folder: sampleFolder, - trackCount: 0, - onRemove: { print("Remove folder") } - ) - } - .padding() -} diff --git a/Views/Folders/FolderTracksContainer.swift b/Views/Folders/FolderTracksContainer.swift new file mode 100644 index 00000000..2989443a --- /dev/null +++ b/Views/Folders/FolderTracksContainer.swift @@ -0,0 +1,141 @@ +import SwiftUI + +struct FolderTracksContainer: View { + let folder: Folder + let viewType: LibraryViewType + + @EnvironmentObject var audioPlayerManager: AudioPlayerManager + @EnvironmentObject var libraryManager: LibraryManager + @EnvironmentObject var playlistManager: PlaylistManager + @State private var selectedTrackID: UUID? + @State private var folderTracks: [Track] = [] + @State private var isLoadingTracks = false + + var body: some View { + Group { + if isLoadingTracks { + ProgressView("Loading tracks...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if folderTracks.isEmpty { + VStack(spacing: 16) { + Image(systemName: "music.note.list") + .font(.system(size: 48)) + .foregroundColor(.gray) + + Text("No Music Files") + .font(.headline) + + Text("No playable music files found in this folder") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } else { + Group { + switch viewType { + case .list: + VirtualizedTrackList( + tracks: folderTracks, + selectedTrackID: $selectedTrackID, + onPlayTrack: { track in + playlistManager.setCurrentQueue(fromFolder: folder) + audioPlayerManager.playTrack(track) + selectedTrackID = track.id + }, + contextMenuItems: { track in + createFolderContextMenu(for: track) + } + ) + case .grid: + VirtualizedTrackGrid( + tracks: folderTracks, + selectedTrackID: $selectedTrackID, + onPlayTrack: { track in + playlistManager.setCurrentQueue(fromFolder: folder) + audioPlayerManager.playTrack(track) + selectedTrackID = track.id + }, + contextMenuItems: { track in + createFolderContextMenu(for: track) + } + ) + } + } + } + } + .onAppear { + loadTracksIfNeeded() + } + .onDisappear { + // Clear tracks when view disappears to free memory + folderTracks.removeAll() + isLoadingTracks = false + } + } + + private func loadTracksIfNeeded() { + guard folderTracks.isEmpty else { return } + + isLoadingTracks = true + + // Load tracks asynchronously to avoid blocking UI + DispatchQueue.global(qos: .userInitiated).async { + let tracks = libraryManager.getTracksInFolder(folder) + .sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + + DispatchQueue.main.async { + self.folderTracks = tracks + self.isLoadingTracks = false + } + } + } + + private func createFolderContextMenu(for track: Track) -> [ContextMenuItem] { + var items: [ContextMenuItem] = [] + + items.append(.button(title: "Play") { + playlistManager.setCurrentQueue(fromFolder: folder) + audioPlayerManager.playTrack(track) + selectedTrackID = track.id + }) + + if !playlistManager.playlists.isEmpty { + let playlistItems = playlistManager.playlists.map { playlist in + ContextMenuItem.button(title: playlist.name) { + playlistManager.addTrackToPlaylist(track: track, playlistID: playlist.id) + } + } + + var allPlaylistItems = playlistItems + allPlaylistItems.append(.divider) + allPlaylistItems.append(.button(title: "New Playlist...") { + // TODO: Implement new playlist creation + }) + + items.append(.menu(title: "Add to Playlist", items: allPlaylistItems)) + } else { + items.append(.button(title: "Create Playlist with This Track") { + // TODO: Implement playlist creation + }) + } + + items.append(.divider) + items.append(.button(title: "Show in Finder") { + NSWorkspace.shared.selectFile(track.url.path, inFileViewerRootedAtPath: folder.url.path) + }) + + return items + } +} + +#Preview { + FolderTracksContainer( + folder: Folder(url: URL(fileURLWithPath: "/Users/test/Music")), + viewType: .list + ) + .environmentObject(AudioPlayerManager(libraryManager: LibraryManager(), playlistManager: PlaylistManager())) + .environmentObject(LibraryManager()) + .environmentObject(PlaylistManager()) +} diff --git a/Views/Folders/FoldersView.swift b/Views/Folders/FoldersView.swift index 1a3c3564..05cf4ed1 100644 --- a/Views/Folders/FoldersView.swift +++ b/Views/Folders/FoldersView.swift @@ -55,7 +55,7 @@ struct FoldersView: View { } } .padding(.horizontal, 6) - .padding(.vertical, 3) + .padding(.vertical, 2) .background(Color(NSColor.textBackgroundColor)) .cornerRadius(4) @@ -105,6 +105,7 @@ struct FoldersView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() + .background(Color(NSColor.textBackgroundColor)) } else if filteredAndSortedFolders.isEmpty { // No folders match search VStack(spacing: 16) { @@ -122,6 +123,7 @@ struct FoldersView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() + .background(Color(NSColor.textBackgroundColor)) } else { // Simple list with native selection SimpleFolderListView( @@ -193,40 +195,20 @@ struct FoldersView: View { Spacer() } } + .background(Color(NSColor.windowBackgroundColor)) + .overlay( + Rectangle() + .fill(Color(NSColor.separatorColor)) + .frame(height: 1), + alignment: .bottom + ) - Divider() - .padding(.horizontal) // Tracks list content if let folder = selectedFolder { - let folderTracks = libraryManager.getTracksInFolder(folder) - - if folderTracks.isEmpty { - VStack(spacing: 16) { - Image(systemName: "music.note.list") - .font(.system(size: 48)) - .foregroundColor(.gray) - - Text("No Music Files") - .font(.headline) - - Text("No playable music files found in this folder") - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() - } else { - Group { - switch viewType { - case .list: - folderTracksListView(folderTracks: folderTracks, folder: folder) - case .grid: - folderTracksGridView(folderTracks: folderTracks, folder: folder) - } - } - } + FolderTracksContainer(folder: folder, viewType: viewType) + .id(folder.id) // Force refresh only when folder changes + .background(Color(NSColor.textBackgroundColor)) } else { VStack(spacing: 16) { Image(systemName: "folder") @@ -243,10 +225,13 @@ struct FoldersView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() + .background(Color(NSColor.textBackgroundColor)) } } .frame(minWidth: 300) + .background(Color(NSColor.textBackgroundColor)) } + .background(Color(NSColor.windowBackgroundColor)) .alert("Remove Folder", isPresented: $showingRemoveFolderAlert) { Button("Cancel", role: .cancel) { folderToRemove = nil @@ -267,122 +252,8 @@ struct FoldersView: View { // MARK: - Helper Methods private func refreshFolder(_ folder: Folder) { - // Remove existing tracks from this folder - let folderPrefix = folder.url.path - libraryManager.tracks.removeAll(where: { $0.url.path.hasPrefix(folderPrefix) }) - - // Trigger a rescan of this specific folder - libraryManager.scanFolderForMusicFiles(folder.url) - } - - // MARK: - Helper Views - - private func folderTracksListView(folderTracks: [Track], folder: Folder) -> some View { - List { - ForEach(folderTracks) { track in - TrackRowContainer( - track: track, - isCurrentTrack: audioPlayerManager.currentTrack?.id == track.id, - isPlaying: audioPlayerManager.currentTrack?.id == track.id && audioPlayerManager.isPlaying, - isSelected: selectedTrackID == track.id, - onSelect: { - selectedTrackID = track.id - }, - onPlay: { - audioPlayerManager.playTrack(track) - selectedTrackID = track.id - }, - contextMenuItems: { - createFolderContextMenu(for: track, in: folder) - } - ) - } - } - .listStyle(.plain) - } - - private func folderTracksGridView(folderTracks: [Track], folder: Folder) -> some View { - ScrollView { - LazyVGrid( - columns: [ - GridItem(.adaptive(minimum: 176, maximum: 200), spacing: 16) - ], - spacing: 16 - ) { - ForEach(folderTracks) { track in - TrackGridItem( - track: track, - isCurrentTrack: audioPlayerManager.currentTrack?.id == track.id, - isPlaying: audioPlayerManager.currentTrack?.id == track.id && audioPlayerManager.isPlaying, - isSelected: selectedTrackID == track.id, - onSelect: { - selectedTrackID = track.id - }, - onPlay: { - audioPlayerManager.playTrack(track) - selectedTrackID = track.id - } - ) - .contextMenu { - ForEach(createFolderContextMenu(for: track, in: folder), id: \.id) { item in - switch item { - case .button(let title, let role, let action): - Button(title, role: role, action: action) - case .menu(let title, let items): - Menu(title) { - ForEach(items, id: \.id) { subItem in - if case .button(let subTitle, let subRole, let subAction) = subItem { - Button(subTitle, role: subRole, action: subAction) - } - } - } - case .divider: - Divider() - } - } - } - } - } - .padding() - } - } - - // MARK: - Context Menu Helper - - private func createFolderContextMenu(for track: Track, in folder: Folder) -> [ContextMenuItem] { - var items: [ContextMenuItem] = [] - - items.append(.button(title: "Play") { - audioPlayerManager.playTrack(track) - selectedTrackID = track.id - }) - - if !playlistManager.playlists.isEmpty { - let playlistItems = playlistManager.playlists.map { playlist in - ContextMenuItem.button(title: playlist.name) { - playlistManager.addTrackToPlaylist(track: track, playlistID: playlist.id) - } - } - - var allPlaylistItems = playlistItems - allPlaylistItems.append(.divider) - allPlaylistItems.append(.button(title: "New Playlist...") { - // TODO: Implement new playlist creation - }) - - items.append(.menu(title: "Add to Playlist", items: allPlaylistItems)) - } else { - items.append(.button(title: "Create Playlist with This Track") { - // TODO: Implement playlist creation - }) - } - - items.append(.divider) - items.append(.button(title: "Show in Finder") { - NSWorkspace.shared.selectFile(track.url.path, inFileViewerRootedAtPath: folder.url.path) - }) - - return items + // Use the new refreshFolder method from LibraryManager + libraryManager.refreshFolder(folder) } } diff --git a/Views/Folders/SimpleFolderListView.swift b/Views/Folders/SimpleFolderListView.swift index f4278231..59bffa2c 100644 --- a/Views/Folders/SimpleFolderListView.swift +++ b/Views/Folders/SimpleFolderListView.swift @@ -1,11 +1,3 @@ -// -// SimpleFolderListView.swift -// Petrichor -// -// Created by Kushal Pandya on 2025-05-23. -// - - import SwiftUI struct SimpleFolderListView: View { @@ -23,6 +15,8 @@ struct SimpleFolderListView: View { trackCount: libraryManager.getTracksInFolder(folder).count ) .tag(folder) + .listRowSeparatorTint(Color(NSColor.separatorColor).opacity(0.3)) + .listRowSeparator(.visible, edges: .bottom) .contextMenu { Button("Refresh") { onRefresh(folder) @@ -40,6 +34,8 @@ struct SimpleFolderListView: View { } } .listStyle(.sidebar) + .scrollContentBackground(.hidden) // Hide the default List background + .background(Color(NSColor.textBackgroundColor)) // Add our custom background } } @@ -89,4 +85,4 @@ struct SimpleFolderRow: View { return manager }()) .frame(width: 250, height: 400) -} \ No newline at end of file +} diff --git a/Views/Library/FilterSidebarView.swift b/Views/Library/FilterSidebarView.swift index d2bc8332..a5093fd8 100644 --- a/Views/Library/FilterSidebarView.swift +++ b/Views/Library/FilterSidebarView.swift @@ -199,6 +199,7 @@ struct FilterSidebarView: View { } .padding(.vertical, 4) } + .background(Color(NSColor.textBackgroundColor)) } // MARK: - Computed Properties diff --git a/Views/Library/LibraryView.swift b/Views/Library/LibraryView.swift index 600a4161..7bc10dcc 100644 --- a/Views/Library/LibraryView.swift +++ b/Views/Library/LibraryView.swift @@ -108,83 +108,39 @@ struct LibraryView: View { tracksGridContent } } + .background(Color(NSColor.textBackgroundColor)) } } } - // MARK: - List Content + // MARK: - List Content with Virtualization private var tracksListContent: some View { - List { - ForEach(filteredTracks) { track in - TrackRowContainer( - track: track, - isCurrentTrack: audioPlayerManager.currentTrack?.id == track.id, - isPlaying: audioPlayerManager.currentTrack?.id == track.id && audioPlayerManager.isPlaying, - isSelected: selectedTrackID == track.id, - onSelect: { - selectedTrackID = track.id - }, - onPlay: { - // Use the new playTrack method that handles queue creation - playlistManager.playTrack(track) - selectedTrackID = track.id - }, - contextMenuItems: { - createLibraryContextMenu(for: track) - } - ) + VirtualizedTrackList( + tracks: filteredTracks, + selectedTrackID: $selectedTrackID, + onPlayTrack: { track in + playlistManager.playTrack(track) + }, + contextMenuItems: { track in + createLibraryContextMenu(for: track) } - } - .listStyle(.plain) + ) } - // MARK: - Grid Content + // MARK: - Grid Content with Virtualization private var tracksGridContent: some View { - ScrollView { - LazyVGrid( - columns: [ - GridItem(.adaptive(minimum: 176, maximum: 200), spacing: 16) - ], - spacing: 16 - ) { - ForEach(filteredTracks) { track in - TrackGridItem( - track: track, - isCurrentTrack: audioPlayerManager.currentTrack?.id == track.id, - isPlaying: audioPlayerManager.currentTrack?.id == track.id && audioPlayerManager.isPlaying, - isSelected: selectedTrackID == track.id, - onSelect: { - selectedTrackID = track.id - }, - onPlay: { - playlistManager.playTrack(track) - selectedTrackID = track.id - } - ) - .contextMenu { - ForEach(createLibraryContextMenu(for: track), id: \.id) { item in - switch item { - case .button(let title, let role, let action): - Button(title, role: role, action: action) - case .menu(let title, let items): - Menu(title) { - ForEach(items, id: \.id) { subItem in - if case .button(let subTitle, let subRole, let subAction) = subItem { - Button(subTitle, role: subRole, action: subAction) - } - } - } - case .divider: - Divider() - } - } - } - } + VirtualizedTrackGrid( + tracks: filteredTracks, + selectedTrackID: $selectedTrackID, + onPlayTrack: { track in + playlistManager.playTrack(track) + }, + contextMenuItems: { track in + createLibraryContextMenu(for: track) } - .padding() - } + ) } // MARK: - Tracks List Header @@ -199,10 +155,6 @@ struct LibraryView: View { } else { Text(filterItem.name) .font(.headline) - - Text(selectedFilterType.rawValue.dropLast()) - .font(.caption) - .foregroundColor(.secondary) } } else { Text("All Tracks") @@ -253,28 +205,31 @@ struct LibraryView: View { private var filteredTracks: [Track] { guard let filterItem = selectedFilterItem else { - return libraryManager.tracks + return libraryManager.tracks.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } } if filterItem.name.hasPrefix("All") { - return libraryManager.tracks + return libraryManager.tracks.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } } + let unsortedTracks: [Track] switch selectedFilterType { case .artists: // For artists, check if the filter item name appears anywhere in the track's artist field // This handles both exact matches and collaborations - return libraryManager.tracks.filter { track in + unsortedTracks = libraryManager.tracks.filter { track in track.artist.localizedCaseInsensitiveContains(filterItem.name) || track.artist == filterItem.name } case .albums: - return libraryManager.getTracksByAlbum(filterItem.name) + unsortedTracks = libraryManager.getTracksByAlbum(filterItem.name) case .genres: - return libraryManager.getTracksByGenre(filterItem.name) + unsortedTracks = libraryManager.getTracksByGenre(filterItem.name) case .years: - return libraryManager.getTracksByYear(filterItem.name) + unsortedTracks = libraryManager.getTracksByYear(filterItem.name) } + + return unsortedTracks.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } } // MARK: - Context Menu Helper diff --git a/Views/Library/TrackGridItem.swift b/Views/Library/TrackGridItem.swift index 91ff9a25..78b5fb91 100644 --- a/Views/Library/TrackGridItem.swift +++ b/Views/Library/TrackGridItem.swift @@ -23,7 +23,7 @@ struct TrackGridItem: View { .aspectRatio(contentMode: .fill) .frame(width: 160, height: 160) .clipShape(RoundedRectangle(cornerRadius: 8)) - } else if track.isMetadataLoaded { + } else { RoundedRectangle(cornerRadius: 8) .fill(Color.gray.opacity(0.2)) .frame(width: 160, height: 160) @@ -32,14 +32,6 @@ struct TrackGridItem: View { .font(.system(size: 40)) .foregroundColor(.secondary) ) - } else { - RoundedRectangle(cornerRadius: 8) - .fill(Color.gray.opacity(0.1)) - .frame(width: 160, height: 160) - .overlay( - ProgressView() - .scaleEffect(0.8) - ) } } @@ -90,15 +82,13 @@ struct TrackGridItem: View { .foregroundColor(isCurrentTrack ? .accentColor : .primary) .lineLimit(2) .multilineTextAlignment(.leading) - .redacted(reason: track.isMetadataLoaded ? [] : .placeholder) Text(track.artist) .font(.system(size: 12)) .foregroundColor(.secondary) .lineLimit(1) - .redacted(reason: track.isMetadataLoaded ? [] : .placeholder) - if track.isMetadataLoaded && !track.album.isEmpty && track.album != "Unknown Album" { + if !track.album.isEmpty && track.album != "Unknown Album" { Text(track.album) .font(.system(size: 11)) .foregroundColor(.secondary) @@ -130,7 +120,6 @@ struct TrackGridItem: View { sampleTrack.artist = "Sample Artist" sampleTrack.album = "Sample Album" sampleTrack.duration = 180.0 - sampleTrack.isMetadataLoaded = true return HStack { TrackGridItem( diff --git a/Views/Library/TrackRowContainer.swift b/Views/Library/TrackRowContainer.swift index 84a9934e..531b49d3 100644 --- a/Views/Library/TrackRowContainer.swift +++ b/Views/Library/TrackRowContainer.swift @@ -40,7 +40,6 @@ struct TrackRowContainer: View { if isPlaying { PlayingIndicator() .frame(width: 16) - .padding(.trailing, 4) } else { // Empty space to maintain alignment Spacer() diff --git a/Views/Main/ContentView.swift b/Views/Main/ContentView.swift index 9bbf7687..bae7894b 100644 --- a/Views/Main/ContentView.swift +++ b/Views/Main/ContentView.swift @@ -61,6 +61,7 @@ struct ContentView: View { SettingsView() .environmentObject(libraryManager) } + .scanningOverlay() // Add the scanning overlay } } diff --git a/Views/Main/PlayerView.swift b/Views/Main/PlayerView.swift index 080c1569..db2b1897 100644 --- a/Views/Main/PlayerView.swift +++ b/Views/Main/PlayerView.swift @@ -9,7 +9,7 @@ struct PlayerView: View { @State private var tempProgressValue: Double = 0 var body: some View { - VStack(spacing: 12) { + VStack(spacing: 0) { // Track info section HStack(spacing: 16) { // Album art with proper clipping @@ -36,7 +36,7 @@ struct PlayerView: View { // Track details VStack(alignment: .leading, spacing: 4) { - Text(audioPlayerManager.currentTrack?.title ?? "No Track Selected") + Text(audioPlayerManager.currentTrack?.title ?? "") .font(.system(size: 16, weight: .medium)) .lineLimit(1) .foregroundColor(.primary) @@ -73,8 +73,10 @@ struct PlayerView: View { } .padding(.horizontal, 20) + Spacer().frame(height: 10) + // Progress bar section - VStack(spacing: 8) { + VStack(spacing: 5) { // Custom progress bar with fixed height to prevent jumping ZStack { GeometryReader { geometry in @@ -126,7 +128,7 @@ struct PlayerView: View { } } } - .frame(height: 20) // Fixed height to prevent jumping + .frame(height: 10) // Fixed height to prevent jumping // Time labels HStack { @@ -144,9 +146,9 @@ struct PlayerView: View { } } .padding(.horizontal, 20) - + // Control buttons section - HStack(spacing: 20) { + HStack(spacing: 15) { // Shuffle button Button(action: { playlistManager.toggleShuffle() diff --git a/Views/Main/ScanningProgressView.swift b/Views/Main/ScanningProgressView.swift new file mode 100644 index 00000000..afedc86e --- /dev/null +++ b/Views/Main/ScanningProgressView.swift @@ -0,0 +1,122 @@ +import SwiftUI + +struct ScanningProgressView: View { + @EnvironmentObject var libraryManager: LibraryManager + + var body: some View { + VStack(spacing: 24) { + // Icon + Image(systemName: "magnifyingglass.circle") + .font(.system(size: 60)) + .foregroundColor(.accentColor) + .symbolEffect(.pulse) + + // Title + Text("Scanning Music Library") + .font(.title2) + .fontWeight(.semibold) + + // Status message + if !libraryManager.scanStatusMessage.isEmpty { + Text(libraryManager.scanStatusMessage) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(2) + .multilineTextAlignment(.center) + .frame(maxWidth: 300) + } + + // Progress bar + ProgressView(value: libraryManager.scanProgress) { + EmptyView() + } currentValueLabel: { + Text("\(Int(libraryManager.scanProgress * 100))%") + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + } + .progressViewStyle(.linear) + .frame(width: 300) + + // Additional info + VStack(spacing: 8) { + if libraryManager.tracks.count > 0 { + HStack { + Image(systemName: "music.note") + .font(.caption) + .foregroundColor(.secondary) + Text("\(libraryManager.tracks.count) tracks found") + .font(.caption) + .foregroundColor(.secondary) + } + } + + if libraryManager.folders.count > 0 { + HStack { + Image(systemName: "folder") + .font(.caption) + .foregroundColor(.secondary) + Text("\(libraryManager.folders.count) folders added") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + // Tips + Text("This may take a few minutes for large music libraries") + .font(.caption) + .foregroundColor(.secondary) + .italic() + .padding(.top, 8) + } + .padding(40) + .frame(minWidth: 400, minHeight: 350) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(NSColor.controlBackgroundColor)) + .shadow(radius: 10) + ) + } +} + +// Overlay modifier for showing scanning progress +struct ScanningOverlay: ViewModifier { + @EnvironmentObject var libraryManager: LibraryManager + + func body(content: Content) -> some View { + ZStack { + content + .blur(radius: libraryManager.isScanning ? 3 : 0) + .disabled(libraryManager.isScanning) + .animation(.easeInOut(duration: 0.3), value: libraryManager.isScanning) + + if libraryManager.isScanning { + Color.black.opacity(0.3) + .ignoresSafeArea() + .transition(.opacity) + + ScanningProgressView() + .transition(.scale.combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.3), value: libraryManager.isScanning) + } +} + +extension View { + func scanningOverlay() -> some View { + modifier(ScanningOverlay()) + } +} + +#Preview { + ScanningProgressView() + .environmentObject({ + let manager = LibraryManager() + manager.isScanning = true + manager.scanProgress = 0.65 + manager.scanStatusMessage = "Scanning My Music Collection..." + return manager + }()) +} diff --git a/Views/Playlists/CreatePlaylistSheet.swift b/Views/Playlists/CreatePlaylistSheet.swift index 0bc6c573..c9683984 100644 --- a/Views/Playlists/CreatePlaylistSheet.swift +++ b/Views/Playlists/CreatePlaylistSheet.swift @@ -1,11 +1,3 @@ -// -// CreatePlaylistSheet.swift -// Petrichor -// -// Created by Kushal Pandya on 2025-05-22. -// - - import SwiftUI struct CreatePlaylistSheet: View { @@ -30,4 +22,4 @@ struct CreatePlaylistSheet: View { #Preview { CreatePlaylistSheet() -} \ No newline at end of file +} diff --git a/Views/Playlists/PlaylistCard.swift b/Views/Playlists/PlaylistCard.swift index c14dc6bf..8312527a 100644 --- a/Views/Playlists/PlaylistCard.swift +++ b/Views/Playlists/PlaylistCard.swift @@ -1,11 +1,3 @@ -// -// PlaylistCard.swift -// Petrichor -// -// Created by Kushal Pandya on 2025-05-22. -// - - import SwiftUI struct PlaylistCard: View { @@ -59,4 +51,4 @@ struct PlaylistCard: View { } .frame(width: 200) .padding() -} \ No newline at end of file +} diff --git a/Views/Playlists/PlaylistTrackRow.swift b/Views/Playlists/PlaylistTrackRow.swift index ed26d5d3..ae1922c3 100644 --- a/Views/Playlists/PlaylistTrackRow.swift +++ b/Views/Playlists/PlaylistTrackRow.swift @@ -37,7 +37,7 @@ struct PlaylistTrackRow: View { .aspectRatio(contentMode: .fill) .frame(width: 32, height: 32) .clipShape(RoundedRectangle(cornerRadius: 3)) - } else if track.isMetadataLoaded { + } else { RoundedRectangle(cornerRadius: 3) .fill(Color.gray.opacity(0.2)) .frame(width: 32, height: 32) @@ -46,14 +46,6 @@ struct PlaylistTrackRow: View { .font(.system(size: 12)) .foregroundColor(.secondary) ) - } else { - RoundedRectangle(cornerRadius: 3) - .fill(Color.gray.opacity(0.1)) - .frame(width: 32, height: 32) - .overlay( - ProgressView() - .scaleEffect(0.4) - ) } } @@ -64,14 +56,12 @@ struct PlaylistTrackRow: View { .fontWeight(isCurrentTrack ? .medium : .regular) .foregroundColor(isCurrentTrack ? .accentColor : .primary) .lineLimit(1) - .redacted(reason: track.isMetadataLoaded ? [] : .placeholder) HStack { Text(track.artist) .font(.system(size: 12)) .foregroundColor(.secondary) .lineLimit(1) - .redacted(reason: track.isMetadataLoaded ? [] : .placeholder) if !track.album.isEmpty && track.album != "Unknown Album" { Text("•") @@ -82,7 +72,6 @@ struct PlaylistTrackRow: View { .font(.system(size: 12)) .foregroundColor(.secondary) .lineLimit(1) - .redacted(reason: track.isMetadataLoaded ? [] : .placeholder) } } } @@ -94,7 +83,6 @@ struct PlaylistTrackRow: View { .font(.system(size: 12)) .foregroundColor(.secondary) .monospacedDigit() - .redacted(reason: track.isMetadataLoaded ? [] : .placeholder) } .padding(.vertical, 4) .contentShape(Rectangle()) // Makes entire row clickable @@ -114,7 +102,6 @@ struct PlaylistTrackRow: View { sampleTrack.artist = "Sample Artist" sampleTrack.album = "Sample Album" sampleTrack.duration = 180.0 - sampleTrack.isMetadataLoaded = true return VStack { PlaylistTrackRow( diff --git a/Views/Settings/SettingsView.swift b/Views/Settings/SettingsView.swift index 3897774f..0ad67404 100644 --- a/Views/Settings/SettingsView.swift +++ b/Views/Settings/SettingsView.swift @@ -143,7 +143,7 @@ struct SettingsView: View { LazyVStack(spacing: 0) { ForEach(libraryManager.folders) { folder in VStack(spacing: 0) { - FolderRowView( + SettingsFolderRow( folder: folder, trackCount: libraryManager.getTracksInFolder(folder).count, onRemove: { @@ -357,38 +357,128 @@ struct SettingsView: View { } } -// MARK: - Auto Scan Interval Enum +// MARK: - Settings Folder Row Component -enum AutoScanInterval: String, CaseIterable, Codable { - case every15Minutes = "every15Minutes" - case every30Minutes = "every30Minutes" - case every60Minutes = "every60Minutes" - case onlyOnLaunch = "onlyOnLaunch" +struct SettingsFolderRow: View { + let folder: Folder + let trackCount: Int + let onRemove: () -> Void - var displayName: String { - switch self { - case .every15Minutes: - return "Every 15 minutes" - case .every30Minutes: - return "Every 30 minutes" - case .every60Minutes: - return "Every hour" - case .onlyOnLaunch: - return "Only on app launch" - } - } + @State private var isExpanded = false - var timeInterval: TimeInterval? { - switch self { - case .every15Minutes: - return 15 * 60 // 15 minutes in seconds - case .every30Minutes: - return 30 * 60 // 30 minutes in seconds - case .every60Minutes: - return 60 * 60 // 1 hour in seconds - case .onlyOnLaunch: - return nil // No automatic scanning + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + // Folder icon + Image(systemName: "folder.fill") + .foregroundColor(.accentColor) + .font(.title3) + + VStack(alignment: .leading, spacing: 2) { + Text(folder.name) + .font(.headline) + .lineLimit(1) + + HStack { + Text(folder.url.path) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + + Spacer() + + Text("\(trackCount) tracks") + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.1)) + .cornerRadius(4) + } + } + + Spacer() + + // Actions + HStack(spacing: 8) { + Button(action: { isExpanded.toggle() }) { + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption) + } + .buttonStyle(.borderless) + + Button(action: onRemove) { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + .buttonStyle(.borderless) + } + } + + // Expanded details + if isExpanded { + VStack(alignment: .leading, spacing: 4) { + Divider() + + HStack { + Text("Full Path:") + .font(.caption) + .fontWeight(.medium) + + Text(folder.url.path) + .font(.caption) + .foregroundColor(.secondary) + .textSelection(.enabled) + } + + HStack { + Text("Added:") + .font(.caption) + .fontWeight(.medium) + + Text("Recently") // You could store this date if needed + .font(.caption) + .foregroundColor(.secondary) + } + + if trackCount > 0 { + HStack { + Text("Status:") + .font(.caption) + .fontWeight(.medium) + + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.caption) + + Text("Active") + .font(.caption) + .foregroundColor(.green) + } + } + } else { + HStack { + Text("Status:") + .font(.caption) + .fontWeight(.medium) + + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .font(.caption) + + Text("No tracks found") + .font(.caption) + .foregroundColor(.orange) + } + } + } + } + .padding(.leading, 32) + } } + .padding(.vertical, 4) } }