diff --git a/App/AppDelegate.swift b/App/AppDelegate.swift index b0fc7e3..ae3274b 100644 --- a/App/AppDelegate.swift +++ b/App/AppDelegate.swift @@ -32,6 +32,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Register for system sleep/wake notifications self.registerForSleepWakeNotifications() + + // Restore saved queue if available + self.playerService?.restoreQueueFromPersistence() + } + + func applicationWillTerminate(_: Notification) { + // Save queue for persistence on next launch + self.playerService?.saveQueueForPersistence() + DiagnosticsLogger.player.info("Application will terminate - saved queue for persistence") } /// Registers for system sleep and wake notifications to handle playback appropriately. diff --git a/App/KasetApp.swift b/App/KasetApp.swift index 3374e97..3320cb6 100644 --- a/App/KasetApp.swift +++ b/App/KasetApp.swift @@ -76,6 +76,10 @@ struct KasetApp: App { _notificationService = State(initialValue: NotificationService(playerService: player)) _accountService = State(initialValue: account) + // Wire up PlayerService to AppDelegate immediately (not in onAppear) + // This ensures playerService is available for lifecycle events like queue restoration + self.appDelegate.playerService = player + if UITestConfig.isUITestMode { DiagnosticsLogger.ui.info("App launched in UI Test mode") } diff --git a/Core/Models/QueueDisplayMode.swift b/Core/Models/QueueDisplayMode.swift new file mode 100644 index 0000000..62f1562 --- /dev/null +++ b/Core/Models/QueueDisplayMode.swift @@ -0,0 +1,23 @@ +import Foundation + +// MARK: - QueueDisplayMode + +/// Display mode for the playback queue panel. +enum QueueDisplayMode: String, Codable, CaseIterable, Sendable { + case popup + case sidepanel + + var displayName: String { + switch self { + case .popup: "Popup" + case .sidepanel: "Side Panel" + } + } + + var description: String { + switch self { + case .popup: "Compact overlay view" + case .sidepanel: "Full-width panel with reordering" + } + } +} diff --git a/Core/Models/Song.swift b/Core/Models/Song.swift index badae94..a013524 100644 --- a/Core/Models/Song.swift +++ b/Core/Models/Song.swift @@ -1,3 +1,4 @@ +import CoreTransferable import Foundation // MARK: - Song @@ -140,3 +141,11 @@ extension Song { hasher.combine(self.videoId) } } + +// MARK: Transferable + +extension Song: Transferable { + static var transferRepresentation: some TransferRepresentation { + CodableRepresentation(for: Song.self, contentType: .data) + } +} diff --git a/Core/Services/Player/PlayerService+Library.swift b/Core/Services/Player/PlayerService+Library.swift index 048bdcc..95d376e 100644 --- a/Core/Services/Player/PlayerService+Library.swift +++ b/Core/Services/Player/PlayerService+Library.swift @@ -146,6 +146,25 @@ extension PlayerService { } self.logger.info("Updated track metadata - inLibrary: \(self.currentTrackInLibrary), hasTokens: \(self.currentTrackFeedbackTokens != nil)") + + // Also update the corresponding song in the queue with enriched metadata + // This ensures the queue displays complete info without separate API calls + if let queueIndex = self.queue.firstIndex(where: { $0.videoId == videoId }) { + // Only update if the queue entry is missing metadata + let currentQueueSong = self.queue[queueIndex] + let needsUpdate = currentQueueSong.artists.isEmpty || + currentQueueSong.artists.allSatisfy { $0.name.isEmpty || $0.name == "Unknown Artist" } || + currentQueueSong.title.isEmpty || + currentQueueSong.title == "Loading..." || + currentQueueSong.thumbnailURL == nil + + if needsUpdate { + self.queue[queueIndex] = songData + self.logger.debug("Enriched queue entry at index \(queueIndex): '\(songData.title)' with artists: \(songData.artistsDisplay)") + // Save the enriched queue to persistence + self.saveQueueForPersistence() + } + } } } catch { self.logger.warning("Failed to fetch song metadata: \(error.localizedDescription)") diff --git a/Core/Services/Player/PlayerService+Queue.swift b/Core/Services/Player/PlayerService+Queue.swift index 42e7f1b..68d7be0 100644 --- a/Core/Services/Player/PlayerService+Queue.swift +++ b/Core/Services/Player/PlayerService+Queue.swift @@ -7,6 +7,7 @@ extension PlayerService { /// Plays a queue of songs starting at the specified index. func playQueue(_ songs: [Song], startingAt index: Int = 0) async { guard !songs.isEmpty else { return } + self.recordQueueStateForUndo() let safeIndex = max(0, min(index, songs.count - 1)) self.queue = songs self.currentIndex = safeIndex @@ -15,12 +16,14 @@ extension PlayerService { if let song = songs[safe: safeIndex] { await self.play(song: song) } + self.saveQueueForPersistence() } /// Plays a song and fetches similar songs (radio queue) in the background. /// The queue will be populated with similar songs from YouTube Music's radio feature. func playWithRadio(song: Song) async { self.logger.info("Playing with radio: \(song.title)") + self.recordQueueStateForUndo() // Clear mix continuation since this is a song radio, not a mix self.mixContinuationToken = nil @@ -32,6 +35,7 @@ extension PlayerService { // Fetch radio queue in background await self.fetchAndApplyRadioQueue(for: song.videoId) + self.saveQueueForPersistence() } /// Plays an artist mix from a mix playlist ID. @@ -42,6 +46,7 @@ extension PlayerService { /// - startVideoId: Optional video ID to start with. If nil, API picks a random starting point. func playWithMix(playlistId: String, startVideoId: String?) async { self.logger.info("Playing mix playlist: \(playlistId), startVideoId: \(startVideoId ?? "nil (random)")") + self.recordQueueStateForUndo() guard let client = self.ytMusicClient else { self.logger.warning("No YTMusicClient available for playing mix") @@ -73,6 +78,7 @@ extension PlayerService { await self.play(videoId: shuffledSongs[0].videoId) self.logger.info("Mix queue loaded with \(shuffledSongs.count) songs, hasContinuation: \(result.continuationToken != nil)") + self.saveQueueForPersistence() } catch { self.logger.warning("Failed to fetch mix queue: \(error.localizedDescription)") } @@ -114,6 +120,7 @@ extension PlayerService { updatedQueue.append(contentsOf: newSongs) self.queue = updatedQueue self.logger.info("Added \(newSongs.count) new songs to queue, total: \(self.queue.count)") + self.saveQueueForPersistence() } // Update continuation token for next batch @@ -169,28 +176,43 @@ extension PlayerService { newQueue.append(contentsOf: radioSongs) } + self.recordQueueStateForUndo() self.queue = newQueue self.currentIndex = 0 self.logger.info("Radio queue updated with \(newQueue.count) songs (current song at front)") + self.saveQueueForPersistence() } catch { self.logger.warning("Failed to fetch radio queue: \(error.localizedDescription)") } } + /// Clears the entire queue and current track (for "Clear" in side panel). Records state for undo. + func clearQueueEntirely() { + self.recordQueueStateForUndo() + self.mixContinuationToken = nil + self.queue = [] + self.currentIndex = 0 + self.logger.info("Queue cleared entirely") + self.saveQueueForPersistence() + } + /// Clears the playback queue except for the currently playing track. func clearQueue() { + self.recordQueueStateForUndo() // Clear mix continuation since queue is being manually cleared self.mixContinuationToken = nil guard let currentTrack else { self.queue = [] self.currentIndex = 0 + self.saveQueueForPersistence() return } // Keep only the current track self.queue = [currentTrack] self.currentIndex = 0 self.logger.info("Queue cleared, keeping current track") + self.saveQueueForPersistence() } /// Plays a song from the queue at the specified index. @@ -202,20 +224,24 @@ extension PlayerService { } // Check if we need to fetch more songs for infinite mix await self.fetchMoreMixSongsIfNeeded() + self.saveQueueForPersistence() } /// Inserts songs immediately after the current track. /// - Parameter songs: The songs to insert into the queue. func insertNextInQueue(_ songs: [Song]) { guard !songs.isEmpty else { return } + self.recordQueueStateForUndo() let insertIndex = min(self.currentIndex + 1, self.queue.count) self.queue.insert(contentsOf: songs, at: insertIndex) self.logger.info("Inserted \(songs.count) songs at position \(insertIndex)") + self.saveQueueForPersistence() } /// Removes songs from the queue by video ID. /// - Parameter videoIds: Set of video IDs to remove. func removeFromQueue(videoIds: Set) { + self.recordQueueStateForUndo() let previousCount = self.queue.count self.queue.removeAll { videoIds.contains($0.videoId) } @@ -229,11 +255,44 @@ extension PlayerService { } self.logger.info("Removed \(previousCount - self.queue.count) songs from queue") + self.saveQueueForPersistence() + } + + /// Reorders the queue by moving items from source indices to destination offset. + /// Used for drag-and-drop reordering; does not allow moving the current track. + /// - Parameters: + /// - source: Indices of items to move. + /// - destination: Index where items will be placed (after removal from source). + func reorderQueue(from source: IndexSet, to destination: Int) { + guard !source.contains(self.currentIndex) else { + self.logger.warning("Cannot reorder: cannot move current track") + return + } + guard destination != self.currentIndex else { + self.logger.warning("Cannot reorder: destination is current track") + return + } + self.recordQueueStateForUndo() + + var newQueue = self.queue + newQueue.move(fromOffsets: source, toOffset: destination) + + // Adjust currentIndex if needed (current track moved in the array) + if let oldCurrent = self.queue[safe: self.currentIndex], + let newCurrentIndex = newQueue.firstIndex(where: { $0.videoId == oldCurrent.videoId }) + { + self.currentIndex = newCurrentIndex + } + + self.queue = newQueue + self.logger.info("Queue reordered: moved from \(source) to \(destination)") + self.saveQueueForPersistence() } /// Reorders the queue based on a new order of video IDs. /// - Parameter videoIds: The new order of video IDs. func reorderQueue(videoIds: [String]) { + self.recordQueueStateForUndo() var reordered: [Song] = [] var videoIdToSong: [String: Song] = [:] @@ -257,11 +316,13 @@ extension PlayerService { } self.logger.info("Queue reordered with \(reordered.count) songs") + self.saveQueueForPersistence() } /// Shuffles the queue, keeping the current track in place at the front. func shuffleQueue() { guard self.queue.count > 1 else { return } + self.recordQueueStateForUndo() // Remove current track, shuffle the rest, put current track at front if let currentSong = queue[safe: currentIndex] { @@ -277,13 +338,173 @@ extension PlayerService { } self.logger.info("Queue shuffled") + self.saveQueueForPersistence() } /// Adds songs to the end of the queue. /// - Parameter songs: The songs to append to the queue. func appendToQueue(_ songs: [Song]) { guard !songs.isEmpty else { return } + self.recordQueueStateForUndo() self.queue.append(contentsOf: songs) self.logger.info("Appended \(songs.count) songs to queue") + self.saveQueueForPersistence() + } + + // MARK: - Queue Persistence + + /// UserDefaults keys for queue persistence (no expiry; saved queue is kept until overwritten or cleared). + private static let savedQueueKey = "kaset.saved.queue" + private static let savedQueueIndexKey = "kaset.saved.queueIndex" + + /// Saves the current queue to UserDefaults for restoration on next launch. + func saveQueueForPersistence() { + guard !self.queue.isEmpty else { + UserDefaults.standard.removeObject(forKey: Self.savedQueueKey) + UserDefaults.standard.removeObject(forKey: Self.savedQueueIndexKey) + self.logger.info("Cleared saved queue (queue is empty)") + return + } + + do { + let encoder = JSONEncoder() + let queueData = try encoder.encode(self.queue) + UserDefaults.standard.set(queueData, forKey: Self.savedQueueKey) + UserDefaults.standard.set(self.currentIndex, forKey: Self.savedQueueIndexKey) + self.logger.info("Saved queue with \(self.queue.count) songs at index \(self.currentIndex)") + } catch { + self.logger.error("Failed to save queue: \(error.localizedDescription)") + } + } + + /// Restores the queue from UserDefaults if available. + /// - Returns: True if queue was restored, false otherwise. + @discardableResult + func restoreQueueFromPersistence() -> Bool { + guard let queueData = UserDefaults.standard.data(forKey: Self.savedQueueKey), + let savedIndex = UserDefaults.standard.object(forKey: Self.savedQueueIndexKey) as? Int + else { + self.logger.info("No saved queue found") + return false + } + + do { + let decoder = JSONDecoder() + let savedQueue = try decoder.decode([Song].self, from: queueData) + guard !savedQueue.isEmpty else { + self.logger.info("Saved queue is empty") + self.clearSavedQueue() + return false + } + + self.queue = savedQueue + self.currentIndex = min(savedIndex, savedQueue.count - 1) + self.logger.info("Restored queue with \(savedQueue.count) songs at index \(self.currentIndex)") + return true + } catch { + self.logger.error("Failed to restore queue: \(error.localizedDescription)") + self.clearSavedQueue() + return false + } + } + + /// Clears the saved queue from UserDefaults. + func clearSavedQueue() { + UserDefaults.standard.removeObject(forKey: Self.savedQueueKey) + UserDefaults.standard.removeObject(forKey: Self.savedQueueIndexKey) + self.logger.info("Cleared saved queue") + } + + // MARK: - Queue Metadata Enrichment + + /// Starts the background metadata enrichment service. + /// This periodically checks the queue for songs with incomplete metadata and fetches full details. + func startQueueEnrichmentService() { + // Cancel any existing task + enrichmentTask?.cancel() + + enrichmentTask = Task { [weak self] in + guard let self else { return } + + while !Task.isCancelled { + // Wait 30 seconds between checks + try? await Task.sleep(for: .seconds(30)) + + guard !Task.isCancelled else { break } + + // Perform enrichment + await self.enrichQueueMetadata() + } + } + } + + /// Stops the background enrichment service. + func stopQueueEnrichmentService() { + enrichmentTask?.cancel() + enrichmentTask = nil + } + + /// Identifies songs in the queue that need metadata enrichment. + /// - Returns: Array of tuples containing index and videoId for songs needing enrichment. + func identifySongsNeedingEnrichment() -> [(index: Int, videoId: String)] { + var songsNeedingEnrichment: [(index: Int, videoId: String)] = [] + + for (index, song) in queue.enumerated() { + // Check if song needs enrichment: + // 1. No artists or all artists are empty/unknown + // 2. Title is placeholder ("Loading..." or empty) + // 3. No thumbnail + let needsEnrichment = song.artists.isEmpty || + song.artists.allSatisfy { $0.name.isEmpty || $0.name == "Unknown Artist" } || + song.title.isEmpty || + song.title == "Loading..." || + song.thumbnailURL == nil + + if needsEnrichment { + songsNeedingEnrichment.append((index: index, videoId: song.videoId)) + } + } + + return songsNeedingEnrichment + } + + /// Enriches queue metadata by fetching full song details for incomplete entries. + /// This updates the queue in-place and persists the enriched data. + func enrichQueueMetadata() async { + guard let client = self.ytMusicClient else { return } + + let songsToEnrich = self.identifySongsNeedingEnrichment() + + guard !songsToEnrich.isEmpty else { return } + + self.logger.info("Enriching metadata for \(songsToEnrich.count) songs in queue") + + // Process in small batches to avoid overwhelming the API + // Process one song at a time to be gentle on the API + for (index, videoId) in songsToEnrich { + // Check if still needed (song might have been removed) + guard index < queue.count, queue[index].videoId == videoId else { continue } + + do { + let enrichedSong = try await client.getSong(videoId: videoId) + + // Update the queue in-place + if index < queue.count, queue[index].videoId == videoId { + queue[index] = enrichedSong + self.logger.debug("Enriched song \(index): '\(enrichedSong.title)' - artists: \(enrichedSong.artistsDisplay)") + } + + // Small delay between requests to be API-friendly + if songsToEnrich.count > 1 { + try? await Task.sleep(for: .milliseconds(100)) + } + } catch { + self.logger.warning("Failed to enrich metadata for song \(videoId): \(error.localizedDescription)") + } + } + + // Save the enriched queue to persistence + self.saveQueueForPersistence() + self.logger.info("Queue metadata enrichment complete, saved to persistence") } } diff --git a/Core/Services/Player/PlayerService.swift b/Core/Services/Player/PlayerService.swift index 01f96a8..0b1f4ee 100644 --- a/Core/Services/Player/PlayerService.swift +++ b/Core/Services/Player/PlayerService.swift @@ -111,6 +111,9 @@ final class PlayerService: NSObject, PlayerServiceProtocol { } } + /// Display mode for the queue panel (popup vs side panel). + var queueDisplayMode: QueueDisplayMode = .popup + /// Whether the queue panel is visible. var showQueue: Bool = false { didSet { @@ -146,6 +149,14 @@ final class PlayerService: NSObject, PlayerServiceProtocol { /// Whether we're currently fetching more mix songs. var isFetchingMoreMixSongs: Bool = false + /// UserDefaults key for persisting queue display mode. + static let queueDisplayModeKey = "kaset.queue.displayMode" + + /// Undo/redo history for queue (up to 10 states). In-memory only. + private var queueUndoHistory: [([Song], Int)] = [] + private var queueRedoHistory: [([Song], Int)] = [] + private static let queueUndoMaxCount = 10 + /// UserDefaults key for persisting volume. static let volumeKey = "playerVolume" /// UserDefaults key for persisting volume before mute. @@ -155,6 +166,9 @@ final class PlayerService: NSObject, PlayerServiceProtocol { /// UserDefaults key for persisting repeat mode. static let repeatModeKey = "playerRepeatMode" + /// Task handle for the background queue metadata enrichment service. + var enrichmentTask: Task? + // MARK: - Initialization override init() { @@ -197,8 +211,80 @@ final class PlayerService: NSObject, PlayerServiceProtocol { } } + // Restore queue display mode + if let savedMode = UserDefaults.standard.string(forKey: Self.queueDisplayModeKey), + let mode = QueueDisplayMode(rawValue: savedMode) + { + self.queueDisplayMode = mode + self.logger.info("Restored queue display mode: \(mode.displayName)") + } + // Load mock state for UI tests self.loadMockStateIfNeeded() + + // Start queue metadata enrichment service + self.startQueueEnrichmentService() + } + + /// Returns true if the given song is the current track. + func isCurrentTrack(_ song: Song) -> Bool { + self.currentTrack?.videoId == song.videoId + } + + /// Toggles between popup and side panel queue display modes. + func toggleQueueDisplayMode() { + if self.queueDisplayMode == .popup { + self.queueDisplayMode = .sidepanel + } else { + self.queueDisplayMode = .popup + } + UserDefaults.standard.set(self.queueDisplayMode.rawValue, forKey: Self.queueDisplayModeKey) + self.logger.info("Queue display mode: \(self.queueDisplayMode.displayName)") + } + + // MARK: - Queue Undo / Redo + + /// Whether queue undo is available. + var canUndoQueue: Bool { + !self.queueUndoHistory.isEmpty + } + + /// Whether queue redo is available. + var canRedoQueue: Bool { + !self.queueRedoHistory.isEmpty + } + + /// Records current queue state for undo (call before mutating queue). Clears redo. Keeps up to 3 states. + func recordQueueStateForUndo() { + let state = (self.queue, self.currentIndex) + self.queueUndoHistory.append(state) + if self.queueUndoHistory.count > Self.queueUndoMaxCount { + self.queueUndoHistory.removeFirst() + } + self.queueRedoHistory.removeAll() + self.logger.debug("Recorded queue state for undo, undo count: \(self.queueUndoHistory.count)") + } + + /// Restores the previous queue state. Does nothing if undo history is empty. + func undoQueue() { + guard let state = self.queueUndoHistory.popLast() else { return } + let (previousQueue, previousIndex) = state + self.queueRedoHistory.append((self.queue, self.currentIndex)) + self.queue = previousQueue + self.currentIndex = min(previousIndex, max(0, previousQueue.count - 1)) + self.saveQueueForPersistence() + self.logger.info("Undid queue to \(previousQueue.count) songs at index \(self.currentIndex)") + } + + /// Restores the next queue state after an undo. Does nothing if redo history is empty. + func redoQueue() { + guard let state = self.queueRedoHistory.popLast() else { return } + let (nextQueue, nextIndex) = state + self.queueUndoHistory.append((self.queue, self.currentIndex)) + self.queue = nextQueue + self.currentIndex = min(nextIndex, max(0, nextQueue.count - 1)) + self.saveQueueForPersistence() + self.logger.info("Redid queue to \(nextQueue.count) songs at index \(self.currentIndex)") } /// Loads mock player state from environment variables for UI testing. @@ -413,6 +499,7 @@ final class PlayerService: NSObject, PlayerServiceProtocol { // Track matches our queue, update the index self.currentIndex = expectedNextIndex self.logger.info("Track advanced to queue index \(expectedNextIndex)") + self.saveQueueForPersistence() } } } @@ -532,8 +619,8 @@ final class PlayerService: NSObject, PlayerServiceProtocol { if let nextSong = queue[safe: currentIndex] { await self.play(song: nextSong) } - // Check if we should fetch more songs await self.fetchMoreMixSongsIfNeeded() + self.saveQueueForPersistence() return } @@ -545,12 +632,14 @@ final class PlayerService: NSObject, PlayerServiceProtocol { } // Check if we should fetch more songs await self.fetchMoreMixSongsIfNeeded() + self.saveQueueForPersistence() } else if self.repeatMode == .all { // Loop back to start if repeat all is enabled self.currentIndex = 0 if let firstSong = queue.first { await self.play(song: firstSong) } + self.saveQueueForPersistence() } else if self.mixContinuationToken != nil { // At end of queue but have continuation - fetch more and continue let previousCount = self.queue.count @@ -561,6 +650,7 @@ final class PlayerService: NSObject, PlayerServiceProtocol { if let nextSong = queue[safe: currentIndex] { await self.play(song: nextSong) } + self.saveQueueForPersistence() } } // At end of queue with repeat off and no continuation, don't do anything @@ -591,6 +681,7 @@ final class PlayerService: NSObject, PlayerServiceProtocol { if let prevSong = queue[safe: currentIndex] { await self.play(song: prevSong) } + self.saveQueueForPersistence() } else { // At start of queue, just restart current track if self.pendingPlayVideoId != nil { diff --git a/Kaset.xcodeproj/project.pbxproj b/Kaset.xcodeproj/project.pbxproj index ed1f1ba..14dfd10 100644 --- a/Kaset.xcodeproj/project.pbxproj +++ b/Kaset.xcodeproj/project.pbxproj @@ -180,16 +180,19 @@ FCF216A14E8D8CA9425230E9 /* PlayerService+Library.swift in Sources */ = {isa = PBXBuildFile; fileRef = C05DB41D343AFD0297AFB9CF /* PlayerService+Library.swift */; }; FF4F2B2CD2A970D17C21F57A /* AccountsListParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB9C4422DFE12C15B9CBE844 /* AccountsListParser.swift */; }; NETM000100000001 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = NETM000200000001 /* NetworkMonitor.swift */; }; + QDM0000100000001 /* QueueDisplayMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = QDM0000200000001 /* QueueDisplayMode.swift */; }; + QSP0000100000001 /* QueueSidePanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = QSP0000200000001 /* QueueSidePanelView.swift */; }; + QTC0000100000001 /* QueueTableCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = QTC0000200000001 /* QueueTableCellView.swift */; }; RAD0000100000001 /* StartRadioContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = RAD0000200000001 /* StartRadioContextMenu.swift */; }; + SCR0000100000001 /* Kaset.sdef in Resources */ = {isa = PBXBuildFile; fileRef = SCR0000200000001 /* Kaset.sdef */; }; + SCR0000100000002 /* ScriptCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = SCR0000200000002 /* ScriptCommands.swift */; }; + SCR0000100000003 /* ScriptCommandsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SCR0000200000003 /* ScriptCommandsTests.swift */; }; SHR0000100000001 /* ShareService.swift in Sources */ = {isa = PBXBuildFile; fileRef = SHR0000200000001 /* ShareService.swift */; }; SHR0000100000002 /* ShareContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = SHR0000200000002 /* ShareContextMenu.swift */; }; SHR0000100000003 /* ShareableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SHR0000200000003 /* ShareableTests.swift */; }; SLS0000100000001 /* SongLikeStatusManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = SLS0000200000001 /* SongLikeStatusManager.swift */; }; URL0000100000001 /* URLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = URL0000200000001 /* URLHandler.swift */; }; URL0000100000002 /* URLHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = URL0000200000002 /* URLHandlerTests.swift */; }; - SCR0000100000001 /* Kaset.sdef in Resources */ = {isa = PBXBuildFile; fileRef = SCR0000200000001 /* Kaset.sdef */; }; - SCR0000100000002 /* ScriptCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = SCR0000200000002 /* ScriptCommands.swift */; }; - SCR0000100000003 /* ScriptCommandsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SCR0000200000003 /* ScriptCommandsTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -221,7 +224,7 @@ 1D7DDDC369AB4D72D7306F02 /* SkeletonView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SkeletonView.swift; sourceTree = ""; }; 1E9D7950D7FAE88739595001 /* InteractiveCardStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InteractiveCardStyle.swift; sourceTree = ""; }; 1F870C29ED7A61E4BFD552C1 /* LyricsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LyricsView.swift; path = Views/macOS/LyricsView.swift; sourceTree = ""; }; - 2350B1E98FBAEB45DF475C5A /* AccountRowView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AccountRowView.swift; path = AccountRowView.swift; sourceTree = ""; }; + 2350B1E98FBAEB45DF475C5A /* AccountRowView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountRowView.swift; sourceTree = ""; }; 2D1A78D6AA4AB654E9663E2D /* ArtistDetail.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ArtistDetail.swift; sourceTree = ""; }; 35ABA754941E379E40CEC3DA /* VideoPlayerWindow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VideoPlayerWindow.swift; sourceTree = ""; }; 35ABA754941E379E40CEC3DB /* SingletonPlayerWebView+VideoMode.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "SingletonPlayerWebView+VideoMode.swift"; sourceTree = ""; }; @@ -245,19 +248,19 @@ 63AB4670ED0A0CAF11944C93 /* MusicVideoType.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MusicVideoType.swift; sourceTree = ""; }; 67EEAB06629FA5154C0CD393 /* PlayerService+Queue.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "PlayerService+Queue.swift"; sourceTree = ""; }; 6BE87B6EFFFDDC581F4793ED /* NewReleasesView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NewReleasesView.swift; path = Views/macOS/NewReleasesView.swift; sourceTree = ""; }; - 72E19702E3760593E3A00498 /* ToastView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ToastView.swift; path = ToastView.swift; sourceTree = ""; }; + 72E19702E3760593E3A00498 /* ToastView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; 74A7B3F1E5D9DD334BDCC588 /* Kaset.Debug.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; name = Kaset.Debug.entitlements; path = App/Kaset.Debug.entitlements; sourceTree = ""; }; 75733D592D5AD5DBECEFA881 /* Podcast.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Podcast.swift; sourceTree = ""; }; 7A40C879039FC38C73C120FC /* MoodCategoryDetailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MoodCategoryDetailView.swift; sourceTree = ""; }; - 7BACB805D2FB587F379E2CC9 /* AccountService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AccountService.swift; path = AccountService.swift; sourceTree = ""; }; + 7BACB805D2FB587F379E2CC9 /* AccountService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountService.swift; sourceTree = ""; }; 7CAC6AE62FD1331D6D7197DE /* MusicIntent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MusicIntent.swift; sourceTree = ""; }; 814910C684FB517EB8004E3C /* LikedMusicViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LikedMusicViewModel.swift; path = Core/ViewModels/LikedMusicViewModel.swift; sourceTree = ""; }; - 8361B0ACCD53B4838197C400 /* SidebarProfileView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SidebarProfileView.swift; path = SidebarProfileView.swift; sourceTree = ""; }; + 8361B0ACCD53B4838197C400 /* SidebarProfileView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SidebarProfileView.swift; sourceTree = ""; }; 849D68199B6752D76523AED4 /* PodcastsViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PodcastsViewModel.swift; sourceTree = ""; }; - 86F725B5EEB1FEEC244E81C2 /* AccountsListResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AccountsListResponse.swift; path = AccountsListResponse.swift; sourceTree = ""; }; + 86F725B5EEB1FEEC244E81C2 /* AccountsListResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountsListResponse.swift; sourceTree = ""; }; 88AC8367F416CA8DBAF1D2E1 /* PodcastParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PodcastParser.swift; sourceTree = ""; }; 8B650A64B02738C0BBF3A524 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; - 8F90F821C83D56B2A6FA857F /* UserAccount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = UserAccount.swift; path = UserAccount.swift; sourceTree = ""; }; + 8F90F821C83D56B2A6FA857F /* UserAccount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserAccount.swift; sourceTree = ""; }; 90B59F4F81AEF77844FE5D23 /* NewReleasesViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NewReleasesViewModel.swift; path = Core/ViewModels/NewReleasesViewModel.swift; sourceTree = ""; }; 951CB3DFC58A537A241137ED /* FoundationModelsService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FoundationModelsService.swift; sourceTree = ""; }; 954C4D81429D4D66DC13015A /* SearchSuggestionsParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchSuggestionsParser.swift; sourceTree = ""; }; @@ -269,7 +272,7 @@ ACD98FCCD196FEFA93C2F3D2 /* CommandBarView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CommandBarView.swift; sourceTree = ""; }; B28AB8530C16716F70CFEEE4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; B5B735BB57DD2ACF165D683F /* MiniPlayerWebView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MiniPlayerWebView.swift; sourceTree = ""; }; - B8D4518D3E5C71D494A0D335 /* AccountSwitcherPopover.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AccountSwitcherPopover.swift; path = AccountSwitcherPopover.swift; sourceTree = ""; }; + B8D4518D3E5C71D494A0D335 /* AccountSwitcherPopover.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountSwitcherPopover.swift; sourceTree = ""; }; C05DB41D343AFD0297AFB9CF /* PlayerService+Library.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "PlayerService+Library.swift"; sourceTree = ""; }; C8E539792EF7A65A0032CA24 /* kaset.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = kaset.icon; sourceTree = ""; }; CBABB9FAFB133BB2F35BDA14 /* AnimationModifiers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AnimationModifiers.swift; sourceTree = ""; }; @@ -378,7 +381,7 @@ E50000020000000000000708 /* AppLaunchUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLaunchUITests.swift; sourceTree = ""; }; E50000020000000000000709 /* VideoUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoUITests.swift; sourceTree = ""; }; E50000020000000000000800 /* QueueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueueView.swift; sourceTree = ""; }; - EB9C4422DFE12C15B9CBE844 /* AccountsListParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AccountsListParser.swift; path = AccountsListParser.swift; sourceTree = ""; }; + EB9C4422DFE12C15B9CBE844 /* AccountsListParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountsListParser.swift; sourceTree = ""; }; F3F9BF1AB1DBD6A559E0C840 /* SongActionsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SongActionsHelper.swift; sourceTree = ""; }; F525663B77A8906E1792B4EC /* ArtistDetailViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ArtistDetailViewModel.swift; sourceTree = ""; }; FAV0000200000001 /* FavoriteItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteItem.swift; sourceTree = ""; }; @@ -390,16 +393,19 @@ FB268027D394E5FBB41AE579 /* QueueTool.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = QueueTool.swift; sourceTree = ""; }; FDE5C3A60C3FC3C8CFD8BBB0 /* LoadingState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LoadingState.swift; sourceTree = ""; }; NETM000200000001 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; + QDM0000200000001 /* QueueDisplayMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueueDisplayMode.swift; sourceTree = ""; }; + QSP0000200000001 /* QueueSidePanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueueSidePanelView.swift; sourceTree = ""; }; + QTC0000200000001 /* QueueTableCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueueTableCellView.swift; sourceTree = ""; }; RAD0000200000001 /* StartRadioContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartRadioContextMenu.swift; sourceTree = ""; }; + SCR0000200000001 /* Kaset.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = Kaset.sdef; sourceTree = ""; }; + SCR0000200000002 /* ScriptCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptCommands.swift; sourceTree = ""; }; + SCR0000200000003 /* ScriptCommandsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptCommandsTests.swift; sourceTree = ""; }; SHR0000200000001 /* ShareService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareService.swift; sourceTree = ""; }; SHR0000200000002 /* ShareContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareContextMenu.swift; sourceTree = ""; }; SHR0000200000003 /* ShareableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareableTests.swift; sourceTree = ""; }; SLS0000200000001 /* SongLikeStatusManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SongLikeStatusManager.swift; sourceTree = ""; }; URL0000200000001 /* URLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLHandler.swift; sourceTree = ""; }; URL0000200000002 /* URLHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLHandlerTests.swift; sourceTree = ""; }; - SCR0000200000001 /* Kaset.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = Kaset.sdef; sourceTree = ""; }; - SCR0000200000002 /* ScriptCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptCommands.swift; sourceTree = ""; }; - SCR0000200000003 /* ScriptCommandsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptCommandsTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -634,6 +640,7 @@ 63AB4670ED0A0CAF11944C93 /* MusicVideoType.swift */, 8F90F821C83D56B2A6FA857F /* UserAccount.swift */, 86F725B5EEB1FEEC244E81C2 /* AccountsListResponse.swift */, + QDM0000200000001 /* QueueDisplayMode.swift */, ); path = Models; sourceTree = ""; @@ -726,14 +733,6 @@ path = Parsers; sourceTree = ""; }; - SCR0000600000001 /* Scripting */ = { - isa = PBXGroup; - children = ( - SCR0000200000002 /* ScriptCommands.swift */, - ); - path = Scripting; - sourceTree = ""; - }; E50000060000000000000030 /* ViewModels */ = { isa = PBXGroup; children = ( @@ -785,6 +784,8 @@ D2E3F4A5B6C7D8E9F0A1B2C3 /* MiniPlayerViews.swift */, E50000020000000000000093 /* AccentBackground.swift */, E50000020000000000000800 /* QueueView.swift */, + QSP0000200000001 /* QueueSidePanelView.swift */, + QTC0000200000001 /* QueueTableCellView.swift */, 01CA5C8085EDDF4F1A4CFE22 /* SharedViews */, ACD98FCCD196FEFA93C2F3D2 /* CommandBarView.swift */, 0CE90C8E602477823742B6E1 /* IntelligenceSettingsView.swift */, @@ -880,6 +881,14 @@ path = KasetUITests; sourceTree = ""; }; + SCR0000600000001 /* Scripting */ = { + isa = PBXGroup; + children = ( + SCR0000200000002 /* ScriptCommands.swift */, + ); + path = Scripting; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -1062,7 +1071,10 @@ B1A24892B22867B3A96E3116 /* LikedMusicView.swift in Sources */, 0A5EAC082557373484B2690F /* LyricsView.swift in Sources */, E50000010000000000000800 /* QueueView.swift in Sources */, + QSP0000100000001 /* QueueSidePanelView.swift in Sources */, + QTC0000100000001 /* QueueTableCellView.swift in Sources */, 9757065D24FEA188EEC1C958 /* TopSongsView.swift in Sources */, + QDM0000100000001 /* QueueDisplayMode.swift in Sources */, 40975001FCBE18063AB035FF /* LoadingState.swift in Sources */, 50DA263A5F4825558938406A /* LoadingView.swift in Sources */, 92B599384FFC157929331ABA /* ErrorView.swift in Sources */, diff --git a/Tests/KasetTests/Helpers/MockYTMusicClient.swift b/Tests/KasetTests/Helpers/MockYTMusicClient.swift index b59b4f6..c43fcbc 100644 --- a/Tests/KasetTests/Helpers/MockYTMusicClient.swift +++ b/Tests/KasetTests/Helpers/MockYTMusicClient.swift @@ -32,6 +32,12 @@ final class MockYTMusicClient: YTMusicClientProtocol { var moodCategoryResponse: HomeResponse = .init(sections: []) var lyricsResponses: [String: Lyrics] = [:] var radioQueueSongs: [String: [Song]] = [:] + var songResponses: [String: Song] = [:] + + // MARK: - Call Tracking + + private(set) var getSongCalled = false + private(set) var getSongVideoIds: [String] = [] // MARK: - Continuation State @@ -581,8 +587,10 @@ final class MockYTMusicClient: YTMusicClientProtocol { } func getSong(videoId: String) async throws -> Song { + self.getSongCalled = true + self.getSongVideoIds.append(videoId) if let error = shouldThrowError { throw error } - return Song( + return self.songResponses[videoId] ?? Song( id: videoId, title: "Mock Song", artists: [Artist(id: "mock-artist", name: "Mock Artist")], diff --git a/Tests/KasetTests/PlayerServiceMixTests.swift b/Tests/KasetTests/PlayerServiceMixTests.swift index 083a676..4762650 100644 --- a/Tests/KasetTests/PlayerServiceMixTests.swift +++ b/Tests/KasetTests/PlayerServiceMixTests.swift @@ -222,6 +222,35 @@ struct PlayerServiceMixTests { #expect(self.playerService.currentIndex == 2) } + @Test("reorderQueue(from:to:) moves item and maintains current track") + func reorderQueueFromTo() async { + let queue = TestFixtures.makeSongs(count: 4) + await self.playerService.playQueue(queue, startingAt: 1) + + // [video-0, video-1*, video-2, video-3] - current is video-1 at index 1 + self.playerService.reorderQueue(from: IndexSet(integer: 0), to: 3) + + // video-0 moves to index 3: [video-1, video-2, video-3, video-0] + #expect(self.playerService.queue[0].videoId == "video-1") + #expect(self.playerService.queue[1].videoId == "video-2") + #expect(self.playerService.queue[2].videoId == "video-3") + #expect(self.playerService.queue[3].videoId == "video-0") + #expect(self.playerService.currentIndex == 0) + } + + @Test("reorderQueue(from:to:) from current index fails gracefully") + func reorderQueueFromCurrentIndexFails() async { + let queue = TestFixtures.makeSongs(count: 3) + await self.playerService.playQueue(queue, startingAt: 0) + + self.playerService.reorderQueue(from: IndexSet(integer: 0), to: 1) + + #expect(self.playerService.queue[0].videoId == "video-0") + #expect(self.playerService.queue[1].videoId == "video-1") + #expect(self.playerService.queue[2].videoId == "video-2") + #expect(self.playerService.currentIndex == 0) + } + // MARK: - shuffleQueue Tests @Test("shuffleQueue keeps current track at front") diff --git a/Tests/KasetTests/PlayerServiceQueueTests.swift b/Tests/KasetTests/PlayerServiceQueueTests.swift new file mode 100644 index 0000000..a5821f0 --- /dev/null +++ b/Tests/KasetTests/PlayerServiceQueueTests.swift @@ -0,0 +1,432 @@ +import Foundation +import Testing +@testable import Kaset + +/// Tests for PlayerService queue operations, undo/redo, and metadata enrichment. +@Suite("PlayerService Queue", .serialized, .tags(.service)) +@MainActor +struct PlayerServiceQueueTests { + var playerService: PlayerService + var mockClient: MockYTMusicClient + + init() { + // Clean up UserDefaults before each test + UserDefaults.standard.removeObject(forKey: "kaset.saved.queue") + UserDefaults.standard.removeObject(forKey: "kaset.saved.queueIndex") + + self.mockClient = MockYTMusicClient() + self.playerService = PlayerService() + self.playerService.setYTMusicClient(self.mockClient) + } + + // MARK: - Queue Reordering Tests + + @Test("Reorder queue moves song from source to destination") + func reorderQueue() async { + // Arrange + let songs = TestFixtures.makeSongs(count: 5) + await self.playerService.playQueue(songs, startingAt: 0) + + // Verify initial order + #expect(self.playerService.queue.count == 5) + #expect(self.playerService.queue[0].title == "Song 0") + #expect(self.playerService.queue[4].title == "Song 4") + + // Act - Move song at index 4 to index 1 + self.playerService.reorderQueue(from: IndexSet(integer: 4), to: 1) + + // Assert + #expect(self.playerService.queue[0].title == "Song 0") + #expect(self.playerService.queue[1].title == "Song 4") // Moved song + #expect(self.playerService.queue[2].title == "Song 1") + #expect(self.playerService.queue[3].title == "Song 2") + #expect(self.playerService.queue[4].title == "Song 3") + } + + @Test("Reorder queue with invalid indices does nothing") + func reorderQueueInvalidIndices() async { + // Arrange + let songs = TestFixtures.makeSongs(count: 3) + await self.playerService.playQueue(songs, startingAt: 0) + let originalOrder = self.playerService.queue.map(\.title) + + // Act - Try to reorder with out of bounds index + self.playerService.reorderQueue(from: IndexSet(integer: 10), to: 1) + + // Assert - Queue unchanged + #expect(self.playerService.queue.map(\.title) == originalOrder) + } + + @Test("Reorder queue updates current index correctly when moving before current") + func reorderQueueUpdatesCurrentIndexBefore() async { + // Arrange - Current index is 2 + let songs = TestFixtures.makeSongs(count: 5) + await self.playerService.playQueue(songs, startingAt: 2) + #expect(self.playerService.currentIndex == 2) + + // Act - Move song at index 4 to index 0 (before current) + self.playerService.reorderQueue(from: IndexSet(integer: 4), to: 0) + + // Assert - Current index should increment + #expect(self.playerService.currentIndex == 3) + } + + @Test("Reorder queue updates current index correctly when moving after current") + func reorderQueueUpdatesCurrentIndexAfter() async { + // Arrange - Current index is 2 + let songs = TestFixtures.makeSongs(count: 5) + await self.playerService.playQueue(songs, startingAt: 2) + #expect(self.playerService.currentIndex == 2) + + // Act - Move song at index 0 to index 4 (after current) + self.playerService.reorderQueue(from: IndexSet(integer: 0), to: 5) + + // Assert - Current index should decrement + #expect(self.playerService.currentIndex == 1) + } + + // MARK: - Undo/Redo Tests + + @Test("Undo restores previous queue state") + func undoQueue() async { + // Arrange + let songs = TestFixtures.makeSongs(count: 3) + await self.playerService.playQueue(songs, startingAt: 0) + let originalQueue = self.playerService.queue + + // Act - Make a change, then undo + self.playerService.clearQueue() + #expect(self.playerService.queue.isEmpty) + + self.playerService.undoQueue() + + // Assert + #expect(self.playerService.queue.count == originalQueue.count) + #expect(self.playerService.queue[0].title == originalQueue[0].title) + } + + @Test("Redo restores undone queue state") + func redoQueue() async { + // Arrange + let songs = TestFixtures.makeSongs(count: 3) + await self.playerService.playQueue(songs, startingAt: 0) + + // Act - Clear, undo, then redo + self.playerService.clearQueue() + self.playerService.undoQueue() + #expect(!self.playerService.queue.isEmpty) + + self.playerService.redoQueue() + + // Assert + #expect(self.playerService.queue.isEmpty) + } + + @Test("Can undo returns correct state") + func canUndo() async { + // Arrange + let songs = TestFixtures.makeSongs(count: 3) + + // Assert - Initially can't undo + #expect(self.playerService.canUndoQueue == false) + + // Act + await self.playerService.playQueue(songs, startingAt: 0) + + // Assert - Can undo after state change + #expect(self.playerService.canUndoQueue == true) + + // Act - Undo all history + self.playerService.undoQueue() + + // Assert - Can't undo anymore + #expect(self.playerService.canUndoQueue == false) + } + + @Test("Can redo returns correct state") + func canRedo() async { + // Arrange + let songs = TestFixtures.makeSongs(count: 3) + await self.playerService.playQueue(songs, startingAt: 0) + + // Assert - Initially can't redo + #expect(self.playerService.canRedoQueue == false) + + // Act + self.playerService.clearQueue() + self.playerService.undoQueue() + + // Assert - Can redo after undo + #expect(self.playerService.canRedoQueue == true) + + // Act + self.playerService.redoQueue() + + // Assert - Can't redo anymore + #expect(self.playerService.canRedoQueue == false) + } + + @Test("Multiple undo operations work correctly") + func multipleUndoOperations() async { + // Arrange - Create 3 different states + let songs1 = TestFixtures.makeSongs(count: 3) + let songs2 = TestFixtures.makeSongs(count: 2) + let songs3 = TestFixtures.makeSongs(count: 4) + + await self.playerService.playQueue(songs1, startingAt: 0) + await self.playerService.playQueue(songs2, startingAt: 0) + await self.playerService.playQueue(songs3, startingAt: 0) + + #expect(self.playerService.queue.count == 4) + + // Act - Undo multiple times + self.playerService.undoQueue() + #expect(self.playerService.queue.count == 2) + + self.playerService.undoQueue() + #expect(self.playerService.queue.count == 3) + } + + @Test("Undo history limit is enforced (10 states)") + func undoHistoryLimit() async { + // Arrange - Create more than 10 states + for i in 1 ... 12 { + let songs = TestFixtures.makeSongs(count: i) + await self.playerService.playQueue(songs, startingAt: 0) + } + + // Act - Undo 10 times (should work) + for _ in 1 ... 10 { + self.playerService.undoQueue() + } + + // The 11th undo should not change anything (oldest state dropped) + let queueAfter10Undos = self.playerService.queue.count + self.playerService.undoQueue() + + // Assert - Queue unchanged after 10 undos + #expect(self.playerService.queue.count == queueAfter10Undos) + } + + // MARK: - Queue Persistence Tests + + @Test("Save and restore queue persists data correctly") + func queuePersistence() async { + // Arrange + let songs = TestFixtures.makeSongs(count: 3) + await self.playerService.playQueue(songs, startingAt: 1) + + // Act + self.playerService.saveQueueForPersistence() + + // Create new service instance and restore + let newService = PlayerService() + newService.setYTMusicClient(self.mockClient) + let restored = newService.restoreQueueFromPersistence() + + // Assert + #expect(restored == true) + #expect(newService.queue.count == 3) + #expect(newService.currentIndex == 1) + #expect(newService.queue[0].title == "Song 0") + } + + @Test("Clear saved queue removes persistence data") + func clearSavedQueue() async { + // Arrange + let songs = TestFixtures.makeSongs(count: 3) + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.saveQueueForPersistence() + + // Act + self.playerService.clearSavedQueue() + + // Create new service and try to restore + let newService = PlayerService() + newService.setYTMusicClient(self.mockClient) + let restored = newService.restoreQueueFromPersistence() + + // Assert + #expect(restored == false) + #expect(newService.queue.isEmpty) + } + + @Test("Restore queue with invalid data returns false") + func restoreInvalidQueue() { + // Arrange - Put invalid data in UserDefaults + UserDefaults.standard.set(Data("invalid data".utf8), forKey: "kaset.saved.queue") + UserDefaults.standard.set(0, forKey: "kaset.saved.queueIndex") + + // Act + let restored = self.playerService.restoreQueueFromPersistence() + + // Assert + #expect(restored == false) + } + + // MARK: - Metadata Enrichment Tests + + @Test("Identify songs needing enrichment detects missing metadata") + func identifySongsNeedingEnrichment() async { + // Arrange - Create songs with incomplete metadata + let completeSong = TestFixtures.makeSong(id: "complete", title: "Complete Song", artistName: "Test Artist") + let incompleteSong = Song( + id: "incomplete", + title: "Loading...", + artists: [], + videoId: "incomplete" + ) + + await playerService.playQueue([completeSong, incompleteSong], startingAt: 0) + + // Act + let needingEnrichment = self.playerService.identifySongsNeedingEnrichment() + + // Assert + #expect(needingEnrichment.count == 1) + #expect(needingEnrichment[0].videoId == "incomplete") + } + + @Test("Enrich queue metadata fetches and updates incomplete songs") + func enrichQueueMetadata() async { + // Arrange + let incompleteSong = Song( + id: "test-id", + title: "Loading...", + artists: [], + videoId: "test-id" + ) + + let enrichedSong = Song( + id: "test-id", + title: "Enriched Title", + artists: [Artist(id: "artist-1", name: "Enriched Artist")], + videoId: "test-id" + ) + + self.mockClient.songResponses["test-id"] = enrichedSong + await self.playerService.playQueue([incompleteSong], startingAt: 0) + + // Act + await self.playerService.enrichQueueMetadata() + + // Assert + #expect(self.mockClient.getSongCalled == true) + #expect(self.playerService.queue[0].title == "Enriched Title") + #expect(self.playerService.queue[0].artists[0].name == "Enriched Artist") + } + + @Test("Metadata enrichment updates queue during playback") + func metadataEnrichmentDuringPlayback() async { + // Arrange + let incompleteSong = Song( + id: "playback-test", + title: "Loading...", + artists: [], + videoId: "playback-test" + ) + + let enrichedSong = Song( + id: "playback-test", + title: "Enriched During Playback", + artists: [Artist(id: "artist-1", name: "Playback Artist")], + videoId: "playback-test" + ) + + self.mockClient.songResponses["playback-test"] = enrichedSong + await self.playerService.playQueue([incompleteSong], startingAt: 0) + + // Act - Simulate playback which triggers fetchSongMetadata + await self.playerService.play(song: incompleteSong) + + // Wait a bit for async operations + try? await Task.sleep(for: .milliseconds(100)) + + // Assert - Queue should be updated + #expect(self.playerService.queue[0].title == "Enriched During Playback") + #expect(self.playerService.queue[0].artists[0].name == "Playback Artist") + } + + @Test("Enrichment does not overwrite good data with worse data") + func enrichmentPreservesGoodData() async { + // Arrange + let completeSong = Song( + id: "complete-id", + title: "Good Title", + artists: [Artist(id: "artist-1", name: "Good Artist")], + videoId: "complete-id" + ) + + let differentSong = Song( + id: "complete-id", + title: "Different Title", + artists: [Artist(id: "artist-2", name: "Different Artist")], + videoId: "complete-id" + ) + + self.mockClient.songResponses["complete-id"] = differentSong + await self.playerService.playQueue([completeSong], startingAt: 0) + + // Act + await self.playerService.play(song: completeSong) + try? await Task.sleep(for: .milliseconds(100)) + + // Assert - Original good data preserved + #expect(self.playerService.queue[0].title == "Good Title") + #expect(self.playerService.queue[0].artists[0].name == "Good Artist") + } + + @Test("Metadata enrichment handles API errors gracefully") + func enrichmentHandlesErrors() async { + // Arrange + let incompleteSong = Song( + id: "error-test", + title: "Loading...", + artists: [], + videoId: "error-test" + ) + + self.mockClient.shouldThrowError = NSError(domain: "Test", code: 500) + await self.playerService.playQueue([incompleteSong], startingAt: 0) + + // Act - Should not throw + await self.playerService.enrichQueueMetadata() + + // Assert - Queue unchanged but no crash + #expect(self.playerService.queue[0].title == "Loading...") + #expect(self.mockClient.getSongCalled == true) + } + + // MARK: - Queue Display Mode Tests + + @Test("Toggle queue display mode switches between popup and side panel") + func toggleQueueDisplayMode() { + // Arrange + let initialMode = self.playerService.queueDisplayMode + + // Act + self.playerService.toggleQueueDisplayMode() + + // Assert + #expect(self.playerService.queueDisplayMode != initialMode) + + // Act again + self.playerService.toggleQueueDisplayMode() + + // Assert - Back to original + #expect(self.playerService.queueDisplayMode == initialMode) + } + + @Test("Queue display mode persists to UserDefaults") + func queueDisplayModePersistence() { + // Arrange + self.playerService.queueDisplayMode = .popup + + // Act + self.playerService.toggleQueueDisplayMode() + + // Assert + let savedMode = UserDefaults.standard.string(forKey: "kaset.queue.displayMode") + #expect(savedMode == QueueDisplayMode.sidepanel.rawValue) + } +} diff --git a/Views/macOS/ArtistDetailView.swift b/Views/macOS/ArtistDetailView.swift index 4f36d9b..58f6623 100644 --- a/Views/macOS/ArtistDetailView.swift +++ b/Views/macOS/ArtistDetailView.swift @@ -345,6 +345,10 @@ struct ArtistDetailView: View { ShareContextMenu.menuItem(for: song) + Divider() + + AddToQueueContextMenu(song: song, playerService: self.playerService) + // Go to Album - show if album has valid browse ID if let album = song.album, album.hasNavigableId { Divider() diff --git a/Views/macOS/HomeView.swift b/Views/macOS/HomeView.swift index 730672e..f4612f1 100644 --- a/Views/macOS/HomeView.swift +++ b/Views/macOS/HomeView.swift @@ -147,6 +147,10 @@ struct HomeView: View { Divider() + AddToQueueContextMenu(song: song, playerService: self.playerService) + + Divider() + if let artist = song.artists.first, !artist.id.isEmpty, !artist.id.contains("-") { NavigationLink(value: artist) { Label("Go to Artist", systemImage: "person") @@ -176,8 +180,43 @@ struct HomeView: View { Divider() + // Play / Play Next / Add to Queue for albums + Button { + SongActionsHelper.playAlbum( + album, + client: self.viewModel.client, + playerService: self.playerService + ) + } label: { + Label("Play", systemImage: "play.fill") + } + + Button { + SongActionsHelper.addAlbumToQueueNext( + album, + client: self.viewModel.client, + playerService: self.playerService + ) + } label: { + Label("Play Next", systemImage: "text.insert") + } + + Button { + SongActionsHelper.addAlbumToQueueLast( + album, + client: self.viewModel.client, + playerService: self.playerService + ) + } label: { + Label("Add to Queue", systemImage: "text.append") + } + + Divider() + FavoritesContextMenu.menuItem(for: album, manager: self.favoritesManager) + Divider() + ShareContextMenu.menuItem(for: album) case let .playlist(playlist): diff --git a/Views/macOS/LikedMusicView.swift b/Views/macOS/LikedMusicView.swift index 506e4ce..db4c69c 100644 --- a/Views/macOS/LikedMusicView.swift +++ b/Views/macOS/LikedMusicView.swift @@ -279,6 +279,10 @@ struct LikedMusicView: View { Divider() + AddToQueueContextMenu(song: song, playerService: self.playerService) + + Divider() + // Go to Artist - show first artist with valid ID if let artist = song.artists.first(where: { $0.hasNavigableId }) { NavigationLink(value: artist) { diff --git a/Views/macOS/MainWindow.swift b/Views/macOS/MainWindow.swift index b649fdc..536cfcc 100644 --- a/Views/macOS/MainWindow.swift +++ b/Views/macOS/MainWindow.swift @@ -230,7 +230,11 @@ struct MainWindow: View { if self.playerService.showLyrics { LyricsView(client: client) } else if self.playerService.showQueue { - QueueView() + if self.playerService.queueDisplayMode == .sidepanel { + QueueSidePanelView() + } else { + QueueView() + } } } .frame(maxHeight: .infinity) diff --git a/Views/macOS/PlaylistDetailView.swift b/Views/macOS/PlaylistDetailView.swift index 76e032e..ebeff44 100644 --- a/Views/macOS/PlaylistDetailView.swift +++ b/Views/macOS/PlaylistDetailView.swift @@ -110,7 +110,15 @@ struct PlaylistDetailView: View { Divider() // Tracks - self.tracksView(detail.tracks, isAlbum: detail.isAlbum) + let fallbackAlbum = Album( + id: detail.id, + title: detail.title, + artists: detail.author.map { [Artist(id: "unknown", name: $0)] }, + thumbnailURL: detail.thumbnailURL, + year: nil, + trackCount: detail.tracks.count + ) + self.tracksView(detail.tracks, isAlbum: detail.isAlbum, author: detail.author, fallbackAlbum: fallbackAlbum) } .padding(24) } @@ -155,64 +163,112 @@ struct PlaylistDetailView: View { Spacer() - HStack(spacing: 16) { - // Play all button - Button { - self.playAll(detail.tracks) - } label: { - Label("Play", systemImage: "play.fill") - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .disabled(detail.tracks.isEmpty) - - // Add/Remove Library button - let currentlyInLibrary = self.isInLibrary || self.isAddedToLibrary - Button { - self.toggleLibrary() - } label: { - Label( - currentlyInLibrary ? "Added to Library" : "Add to Library", - systemImage: currentlyInLibrary ? "checkmark.circle.fill" : "plus.circle" - ) - } - .buttonStyle(.bordered) - .controlSize(.large) - - // Refine Playlist button (AI-powered) - if !detail.isAlbum { - Button { - self.showRefineSheet = true - } label: { - Label("Refine", systemImage: "sparkles") - } - .buttonStyle(.bordered) - .controlSize(.large) - .requiresIntelligence() - } + self.headerButtons(detail) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } - // Track count - Text("\(detail.tracks.count) songs") - .font(.subheadline) - .foregroundStyle(.secondary) + private func makeFallbackAlbum(from detail: PlaylistDetail) -> Album { + Album( + id: detail.id, + title: detail.title, + artists: detail.author.map { [Artist(id: "unknown", name: $0)] }, + thumbnailURL: detail.thumbnailURL, + year: nil, + trackCount: detail.tracks.count + ) + } - if let duration = detail.duration { - Text("•") - .foregroundStyle(.secondary) - Text(duration) - .font(.subheadline) - .foregroundStyle(.secondary) - } + private func headerButtons(_ detail: PlaylistDetail) -> some View { + HStack(spacing: 16) { + // Play all button + Button { + let fallbackAlbum = self.makeFallbackAlbum(from: detail) + self.playAll(detail.tracks, fallbackArtist: detail.author, fallbackAlbum: fallbackAlbum) + } label: { + Label("Play", systemImage: "play.fill") + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(detail.tracks.isEmpty) + + // Play Next button + Button { + let fallbackAlbum = self.makeFallbackAlbum(from: detail) + SongActionsHelper.addSongsToQueueNext( + detail.tracks, + playerService: self.playerService, + fallbackArtist: detail.author, + fallbackAlbum: fallbackAlbum + ) + } label: { + Label("Play Next", systemImage: "text.insert") + } + .buttonStyle(.bordered) + .controlSize(.large) + .disabled(detail.tracks.isEmpty) + + // Add to Queue button + Button { + let fallbackAlbum = self.makeFallbackAlbum(from: detail) + SongActionsHelper.addSongsToQueueLast( + detail.tracks, + playerService: self.playerService, + fallbackArtist: detail.author, + fallbackAlbum: fallbackAlbum + ) + } label: { + Label("Add to Queue", systemImage: "text.append") + } + .buttonStyle(.bordered) + .controlSize(.large) + .disabled(detail.tracks.isEmpty) + + // Add/Remove Library button + let currentlyInLibrary = self.isInLibrary || self.isAddedToLibrary + Button { + self.toggleLibrary() + } label: { + Label( + currentlyInLibrary ? "Added to Library" : "Add to Library", + systemImage: currentlyInLibrary ? "checkmark.circle.fill" : "plus.circle" + ) + } + .buttonStyle(.bordered) + .controlSize(.large) + + // Refine Playlist button (AI-powered) + if !detail.isAlbum { + Button { + self.showRefineSheet = true + } label: { + Label("Refine", systemImage: "sparkles") } + .buttonStyle(.bordered) + .controlSize(.large) + .requiresIntelligence() + } + + // Track count + Text("\(detail.tracks.count) songs") + .font(.subheadline) + .foregroundStyle(.secondary) + + if let duration = detail.duration { + Text("•") + .foregroundStyle(.secondary) + Text(duration) + .font(.subheadline) + .foregroundStyle(.secondary) } - .frame(maxWidth: .infinity, alignment: .leading) } } - private func tracksView(_ tracks: [Song], isAlbum: Bool) -> some View { + private func tracksView(_ tracks: [Song], isAlbum: Bool, author: String?, fallbackAlbum: Album? = nil) -> some View { LazyVStack(spacing: 0) { ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in - self.trackRow(track, index: index, tracks: tracks, isAlbum: isAlbum) + self.trackRow(track, index: index, tracks: tracks, isAlbum: isAlbum, author: author, fallbackAlbum: fallbackAlbum) .onAppear { // Load more when reaching the last few items if index >= tracks.count - 3, self.viewModel.hasMore { @@ -241,9 +297,9 @@ struct PlaylistDetailView: View { } } - private func trackRow(_ track: Song, index: Int, tracks: [Song], isAlbum: Bool) -> some View { + private func trackRow(_ track: Song, index: Int, tracks: [Song], isAlbum: Bool, author: String?, fallbackAlbum: Album? = nil) -> some View { Button { - self.playTrackInQueue(tracks: tracks, startingAt: index) + self.playTrackInQueue(tracks: tracks, startingAt: index, fallbackArtist: author, fallbackAlbum: fallbackAlbum) } label: { HStack(spacing: 12) { // Now playing indicator or index @@ -301,7 +357,7 @@ struct PlaylistDetailView: View { .staggeredAppearance(index: min(index, 10)) .contextMenu { Button { - self.playTrackInQueue(tracks: tracks, startingAt: index) + self.playTrackInQueue(tracks: tracks, startingAt: index, fallbackArtist: author, fallbackAlbum: fallbackAlbum) } label: { Label("Play", systemImage: "play.fill") } @@ -332,6 +388,10 @@ struct PlaylistDetailView: View { Divider() + AddToQueueContextMenu(song: track, playerService: self.playerService) + + Divider() + // Go to Artist - show first artist with valid ID if let artist = track.artists.first(where: { $0.hasNavigableId }) { NavigationLink(value: artist) { @@ -358,16 +418,65 @@ struct PlaylistDetailView: View { // MARK: - Actions - private func playTrackInQueue(tracks: [Song], startingAt index: Int) { + private func playTrackInQueue(tracks: [Song], startingAt index: Int, fallbackArtist: String? = nil, fallbackAlbum: Album? = nil) { + let cleanedTracks = self.cleanTracks(tracks, fallbackArtist: fallbackArtist, fallbackAlbum: fallbackAlbum) Task { - await self.playerService.playQueue(tracks, startingAt: index) + await self.playerService.playQueue(cleanedTracks, startingAt: index) } } - private func playAll(_ tracks: [Song]) { + private func playAll(_ tracks: [Song], fallbackArtist: String? = nil, fallbackAlbum: Album? = nil) { guard !tracks.isEmpty else { return } + let cleanedTracks = self.cleanTracks(tracks, fallbackArtist: fallbackArtist, fallbackAlbum: fallbackAlbum) Task { - await self.playerService.playQueue(tracks, startingAt: 0) + await self.playerService.playQueue(cleanedTracks, startingAt: 0) + } + } + + /// Cleans track artists and applies fallback artist/album when needed. + private func cleanTracks(_ tracks: [Song], fallbackArtist: String?, fallbackAlbum: Album? = nil) -> [Song] { + tracks.map { song in + var cleanedArtists = song.artists.compactMap { artist -> Artist? in + if artist.name == "Album" { return nil } + var cleanName = artist.name + if cleanName.hasPrefix("Album, ") { + cleanName = String(cleanName.dropFirst(7)) + } + return Artist(id: artist.id, name: cleanName) + } + + // Use fallback artist if artists are empty (and clean the fallback too) + if cleanedArtists.isEmpty, let fallback = fallbackArtist, !fallback.isEmpty { + var cleanFallback = fallback + if cleanFallback == "Album" { + cleanFallback = "Unknown Artist" + } else if cleanFallback.hasPrefix("Album, ") { + cleanFallback = String(cleanFallback.dropFirst(7)) + } + // Also handle case where it's "Album, Artist" but we got it as a combined string + if cleanFallback.contains("Album,") { + let parts = cleanFallback.split(separator: ",", maxSplits: 1) + if parts.count > 1 { + cleanFallback = String(parts[1]).trimmingCharacters(in: .whitespaces) + } + } + cleanedArtists = [Artist(id: "unknown", name: cleanFallback)] + } + + // Use fallback album if song doesn't have album info + let finalAlbum = song.album ?? fallbackAlbum + // Use fallback thumbnail if song doesn't have one + let finalThumbnail = song.thumbnailURL ?? fallbackAlbum?.thumbnailURL + + return Song( + id: song.id, + title: song.title, + artists: cleanedArtists, + album: finalAlbum, + duration: song.duration, + thumbnailURL: finalThumbnail, + videoId: song.videoId + ) } } diff --git a/Views/macOS/QueueSidePanelView.swift b/Views/macOS/QueueSidePanelView.swift new file mode 100644 index 0000000..c4ed236 --- /dev/null +++ b/Views/macOS/QueueSidePanelView.swift @@ -0,0 +1,672 @@ +import SwiftUI + +// MARK: - QueueSidePanelView + +@available(macOS 26.0, *) +struct QueueSidePanelView: View { + @Environment(PlayerService.self) private var playerService + @Environment(FavoritesManager.self) private var favoritesManager + + var body: some View { + // Use regular material: GlassEffectContainer breaks NSTableView drag-and-drop + // (drop target gap and acceptDrop never fire when the table is inside glass). + VStack(spacing: 0) { + QueueSidePanelHeader() + + Divider() + .opacity(0.3) + + if self.playerService.queue.isEmpty { + self.emptyQueueView + } else { + QueueListControllerRepresentable( + queue: self.playerService.queue, + currentIndex: self.playerService.currentIndex, + isPlaying: self.playerService.isPlaying, + favoritesManager: self.favoritesManager, + onSelect: { index in + Task { + await self.playerService.playFromQueue(at: index) + } + }, + onReorder: { source, destination in + self.playerService.reorderQueue(from: IndexSet(integer: source), to: destination) + }, + onRemove: { videoId in + Task { + await self.playerService.removeFromQueue(videoIds: Set([videoId])) + } + }, + onStartRadio: { song in + Task { + await self.playerService.playWithRadio(song: song) + } + } + ) + .accessibilityIdentifier(AccessibilityID.Queue.scrollView) + } + + Divider() + .opacity(0.3) + + QueueFooterActions() + } + .frame(width: 400) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .accessibilityIdentifier(AccessibilityID.Queue.container) + } + + private var emptyQueueView: some View { + VStack(spacing: 12) { + Image(systemName: "list.bullet") + .font(.system(size: 40)) + .foregroundStyle(.tertiary) + + Text("No Queue") + .font(.headline) + .foregroundStyle(.secondary) + + Text("Play songs from a playlist or album to build your queue.") + .font(.subheadline) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier(AccessibilityID.Queue.emptyState) + } +} + +// MARK: - QueueListControllerRepresentable + +@available(macOS 26.0, *) +struct QueueListControllerRepresentable: NSViewControllerRepresentable { + let queue: [Song] + let currentIndex: Int + let isPlaying: Bool + let favoritesManager: FavoritesManager + let onSelect: (Int) -> Void + let onReorder: (Int, Int) -> Void + let onRemove: (String) -> Void + let onStartRadio: (Song) -> Void + + func makeNSViewController(context: Context) -> QueueListViewController { + let viewController = QueueListViewController() + viewController.coordinator = context.coordinator + context.coordinator.viewController = viewController + return viewController + } + + func updateNSViewController(_ viewController: QueueListViewController, context: Context) { + context.coordinator.queue = self.queue + context.coordinator.currentIndex = self.currentIndex + context.coordinator.isPlaying = self.isPlaying + context.coordinator.favoritesManager = self.favoritesManager + + if !context.coordinator.isDragging { + viewController.tableView?.reloadData() + } + + // Update current track highlighting and waveform animation + if let tableView = viewController.tableView { + for row in 0 ..< self.queue.count { + if let cellView = tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? QueueTableCellView { + cellView.updateAppearance( + isCurrentTrack: row == self.currentIndex, + isPlaying: self.isPlaying, + index: row + ) + } + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator( + queue: self.queue, + currentIndex: self.currentIndex, + isPlaying: self.isPlaying, + favoritesManager: self.favoritesManager, + onSelect: self.onSelect, + onReorder: self.onReorder, + onRemove: self.onRemove, + onStartRadio: self.onStartRadio + ) + } + + // MARK: - View Controller + + class QueueListViewController: NSViewController { + var tableView: DraggableTableView? + weak var coordinator: Coordinator? + + override func loadView() { + let scrollView = NSScrollView() + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.borderType = .noBorder + scrollView.backgroundColor = .clear + scrollView.drawsBackground = false + scrollView.hasHorizontalScroller = false // Disable horizontal scrolling + scrollView.horizontalScrollElasticity = .none // No horizontal bounce + + let tableView = DraggableTableView() + tableView.headerView = nil + tableView.selectionHighlightStyle = .none + tableView.backgroundColor = .clear + tableView.allowsEmptySelection = true + tableView.allowsColumnResizing = false + tableView.columnAutoresizingStyle = .uniformColumnAutoresizingStyle + tableView.intercellSpacing = NSSize(width: 0, height: 0) + tableView.rowHeight = 56 + + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("QueueColumn")) + column.title = "" + column.minWidth = 350 + column.maxWidth = 400 + column.width = 350 // Matches container width minus scroll bar space + tableView.addTableColumn(column) + + let dragType = NSPasteboard.PasteboardType("com.kaset.queueitem") + tableView.registerForDraggedTypes([dragType, .string]) + tableView.verticalMotionCanBeginDrag = true + tableView.draggingDestinationFeedbackStyle = .gap // Show gap where item will be dropped + + scrollView.documentView = tableView + self.tableView = tableView + self.view = scrollView + } + + override func viewDidLoad() { + super.viewDidLoad() + if let tableView { + tableView.delegate = self.coordinator + tableView.dataSource = self.coordinator + tableView.coordinator = self.coordinator + } + } + } + + // MARK: - Coordinator + + class Coordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource { + var queue: [Song] + var currentIndex: Int + var isPlaying: Bool + var favoritesManager: FavoritesManager + let onSelect: (Int) -> Void + let onReorder: (Int, Int) -> Void + let onRemove: (String) -> Void + let onStartRadio: (Song) -> Void + weak var viewController: QueueListViewController? + var isDragging = false + private let dragType = NSPasteboard.PasteboardType("com.kaset.queueitem") + + init(queue: [Song], currentIndex: Int, isPlaying: Bool, favoritesManager: FavoritesManager, + onSelect: @escaping (Int) -> Void, onReorder: @escaping (Int, Int) -> Void, onRemove: @escaping (String) -> Void, onStartRadio: @escaping (Song) -> Void) + { + self.queue = queue + self.currentIndex = currentIndex + self.isPlaying = isPlaying + self.favoritesManager = favoritesManager + self.onSelect = onSelect + self.onReorder = onReorder + self.onRemove = onRemove + self.onStartRadio = onStartRadio + super.init() + } + + /// Removes the row with slide-out animation, then calls onRemove. + /// - Parameter slideDirection: -1 = slide left, +1 = slide right (matches swipe direction). + func removeRowWithAnimation(row: Int, song: Song, slideDirection: CGFloat) { + guard let tableView = viewController?.tableView else { + self.onRemove(song.videoId) + return + } + guard let rowView = tableView.rowView(atRow: row, makeIfNecessary: false) else { + self.onRemove(song.videoId) + return + } + let videoId = song.videoId + let offsetX = slideDirection * rowView.bounds.width + let originalFrame = rowView.frame + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.25 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + rowView.animator().alphaValue = 0 + rowView.animator().frame.origin.x += offsetX + } completionHandler: { [weak self] in + // Reset row view so it can be reused without a stuck frame/alpha (fixes misaligned rows). + rowView.alphaValue = 1 + rowView.frame = originalFrame + self?.onRemove(videoId) + } + } + + func numberOfRows(in _: NSTableView) -> Int { + self.queue.count + } + + func tableView(_: NSTableView, viewFor _: NSTableColumn?, row: Int) -> NSView? { + let cellView = QueueTableCellView() + let song = self.queue[row] + cellView.configure( + song: song, + index: row, + isCurrentTrack: row == self.currentIndex, + isPlaying: self.isPlaying, + actions: QueueCellActions( + onPlay: { [weak self] in self?.onSelect(row) }, + onRemove: { [weak self] in self?.onRemove(song.videoId) } + ) + ) + return cellView + } + + func tableView(_: NSTableView, heightOfRow _: Int) -> CGFloat { + 56 + } + + func tableViewSelectionDidChange(_ notification: Notification) { + guard let tableView = notification.object as? NSTableView else { return } + let selectedRow = tableView.selectedRow + if selectedRow >= 0 { + self.onSelect(selectedRow) + tableView.deselectAll(nil) + } + } + + /// Drag Source + func tableView(_: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? { + guard row != self.currentIndex else { return nil } + let item = NSPasteboardItem() + item.setString(String(row), forType: self.dragType) + self.isDragging = true + return item + } + + func tableView(_: NSTableView, draggingSession _: NSDraggingSession, willBeginAt _: NSPoint, forRowIndexes _: IndexSet) { + // Dragging session began + } + + func tableView(_: NSTableView, draggingSession _: NSDraggingSession, endedAt _: NSPoint, operation _: NSDragOperation) { + self.isDragging = false + } + + /// Drop Destination + func tableView(_: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation { + guard dropOperation == .above else { return [] } + guard let str = info.draggingPasteboard.string(forType: dragType), + let srcRow = Int(str) else { return [] } + let destRow = row + guard destRow != self.currentIndex, srcRow != destRow else { return [] } + return .move + } + + func tableView(_: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation _: NSTableView.DropOperation) -> Bool { + guard let str = info.draggingPasteboard.string(forType: dragType), + let srcRow = Int(str) else { return false } + let destRow = row + guard srcRow != self.currentIndex, destRow != self.currentIndex, srcRow != destRow else { return false } + self.onReorder(srcRow, destRow) + self.isDragging = false + return true + } + + // MARK: - Context Menu + + func tableView(_: NSTableView, menuForRow row: Int, event _: NSEvent) -> NSMenu? { + guard row >= 0, let song = queue[safe: row] else { return nil } + let menu = NSMenu() + let manager = self.favoritesManager + let isPinned = MainActor.assumeIsolated { manager.isPinned(song: song) } + + let favoritesItem = NSMenuItem( + title: isPinned ? "Remove from Favorites" : "Add to Favorites", + action: #selector(Coordinator.contextMenuFavorites(_:)), + keyEquivalent: "" + ) + favoritesItem.target = self + favoritesItem.representedObject = song + favoritesItem.image = NSImage(systemSymbolName: isPinned ? "heart.slash" : "heart", accessibilityDescription: nil) + menu.addItem(favoritesItem) + + menu.addItem(NSMenuItem.separator()) + + let startRadioItem = NSMenuItem(title: "Start Radio", action: #selector(Coordinator.contextMenuStartRadio(_:)), keyEquivalent: "") + startRadioItem.target = self + startRadioItem.representedObject = song + startRadioItem.image = NSImage(systemSymbolName: "dot.radiowaves.left.and.right", accessibilityDescription: nil) + menu.addItem(startRadioItem) + + menu.addItem(NSMenuItem.separator()) + + if song.shareURL != nil { + let shareItem = NSMenuItem(title: "Share", action: #selector(Coordinator.contextMenuShare(_:)), keyEquivalent: "") + shareItem.target = self + shareItem.representedObject = song + shareItem.image = NSImage(systemSymbolName: "square.and.arrow.up", accessibilityDescription: nil) + menu.addItem(shareItem) + menu.addItem(NSMenuItem.separator()) + } + + if row != self.currentIndex { + let removeItem = NSMenuItem(title: "Remove from Queue", action: #selector(Coordinator.contextMenuRemove(_:)), keyEquivalent: "") + removeItem.target = self + removeItem.representedObject = song + removeItem.image = NSImage(systemSymbolName: "minus.circle", accessibilityDescription: nil) + menu.addItem(removeItem) + } + + return menu + } + + @objc private func contextMenuFavorites(_ sender: NSMenuItem) { + guard let song = sender.representedObject as? Song else { return } + let manager = self.favoritesManager + MainActor.assumeIsolated { manager.toggle(song: song) } + } + + @objc private func contextMenuStartRadio(_ sender: NSMenuItem) { + guard let song = sender.representedObject as? Song else { return } + self.onStartRadio(song) + } + + @objc private func contextMenuShare(_ sender: NSMenuItem) { + guard let song = sender.representedObject as? Song, let url = song.shareURL else { return } + MainActor.assumeIsolated { + ShareContextMenu.showSharePicker(for: url) + } + } + + @objc private func contextMenuRemove(_ sender: NSMenuItem) { + guard let song = sender.representedObject as? Song else { return } + self.onRemove(song.videoId) + } + } +} + +// MARK: - DraggableTableView + +@available(macOS 26.0, *) +class DraggableTableView: NSTableView { + weak var coordinator: QueueListControllerRepresentable.Coordinator? + + /// Accumulated scroll deltas during the current gesture (used to detect swipe-to-remove). + private var horizontalSwipeAccumulator: CGFloat = 0 + private var verticalSwipeAccumulator: CGFloat = 0 + /// Row index under the cursor when the gesture *started* (.began), so we remove that row even if content scrolls by .ended. + private var swipeRemoveTargetRow: Int = -1 + /// When non-nil, we're showing real-time slide feedback; value is the row view's initial origin.x to restore on cancel. + private var swipeTrackedInitialOriginX: CGFloat? + /// Cooldown after a remove so we don't trigger again from leftover events. + private var swipeRemoveCooldownUntil: CFAbsoluteTime = 0 + /// Minimum horizontal delta to "commit" and start moving the row (avoids vertical scroll moving a row). + private static let swipeCommitThreshold: CGFloat = 10 + /// Horizontal swipe distance (pt) beyond which release counts as delete. Increase for a more deliberate confirm, decrease for quicker remove. + private static let swipeRemoveDeltaThreshold: CGFloat = 100 + private static let swipeRemoveCooldown: CFAbsoluteTime = 0.5 + /// Max horizontal drag (multiple of row width) for real-time feedback. + private static let swipeMaxDragFactor: CGFloat = 1.2 + + override func awakeFromNib() { + super.awakeFromNib() + self.setupTable() + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + self.setupTable() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + self.setupTable() + } + + private func setupTable() { + // Enable gap feedback style for drag-and-drop + self.draggingDestinationFeedbackStyle = .gap + } + + /// Two-finger horizontal trackpad swipe: row follows finger in real time; release past threshold to remove, or return to cancel. + override func scrollWheel(with event: NSEvent) { + let dx = event.scrollingDeltaX + let dy = event.scrollingDeltaY + + switch event.phase { + case .began: + self.handleSwipeBegan(dx: dx, dy: dy, event: event) + case .changed: + self.handleSwipeChanged(dx: dx, dy: dy) + case .ended, .cancelled: + if self.handleSwipeEnded(event: event) { return } + default: + if event.momentumPhase == .ended || event.momentumPhase == .cancelled { + self.horizontalSwipeAccumulator = 0 + self.verticalSwipeAccumulator = 0 + self.swipeRemoveTargetRow = -1 + self.swipeTrackedInitialOriginX = nil + } + } + + super.scrollWheel(with: event) + } + + /// Handles the `.began` phase of a trackpad swipe gesture. + private func handleSwipeBegan(dx: CGFloat, dy: CGFloat, event: NSEvent) { + self.horizontalSwipeAccumulator = dx + self.verticalSwipeAccumulator = dy + self.swipeRemoveTargetRow = -1 + self.swipeTrackedInitialOriginX = nil + if self.coordinator != nil { + let point = event.locationInWindow + let localPoint = self.convert(point, from: nil) + let rowAtStart = self.row(at: localPoint) + self.swipeRemoveTargetRow = rowAtStart + } + } + + /// Handles the `.changed` phase of a trackpad swipe gesture, sliding the row in real time. + private func handleSwipeChanged(dx: CGFloat, dy: CGFloat) { + self.horizontalSwipeAccumulator += dx + self.verticalSwipeAccumulator += dy + // Real-time row slide: once horizontal movement passes commit threshold, move the row with the finger. + if let coord = coordinator, + swipeRemoveTargetRow >= 0, + swipeRemoveTargetRow != coord.currentIndex, + coord.queue[safe: swipeRemoveTargetRow] != nil, + abs(horizontalSwipeAccumulator) > Self.swipeCommitThreshold, + abs(horizontalSwipeAccumulator) > abs(verticalSwipeAccumulator) + { + guard let rowView = self.rowView(atRow: swipeRemoveTargetRow, makeIfNecessary: false) else { + return + } + if self.swipeTrackedInitialOriginX == nil { + self.swipeTrackedInitialOriginX = rowView.frame.origin.x + } + let initialX = self.swipeTrackedInitialOriginX! + let maxDrag = rowView.bounds.width * Self.swipeMaxDragFactor + let clamped = max(-maxDrag, min(maxDrag, self.horizontalSwipeAccumulator)) + var f = rowView.frame + f.origin.x = initialX + clamped + rowView.frame = f + } + } + + /// Handles the `.ended` / `.cancelled` phase of a trackpad swipe gesture. Returns `true` if the event was fully consumed. + private func handleSwipeEnded(event: NSEvent) -> Bool { + let accH = self.horizontalSwipeAccumulator + let accV = self.verticalSwipeAccumulator + let rowAtEnd = self.row(at: self.convert(event.locationInWindow, from: nil)) + self.horizontalSwipeAccumulator = 0 + self.verticalSwipeAccumulator = 0 + + if let initialX = swipeTrackedInitialOriginX { + self.swipeTrackedInitialOriginX = nil + guard let coord = coordinator, + swipeRemoveTargetRow >= 0, + let song = coord.queue[safe: swipeRemoveTargetRow] + else { + self.swipeRemoveTargetRow = -1 + return false + } + let row = self.swipeRemoveTargetRow + self.swipeRemoveTargetRow = -1 + guard let rowView = self.rowView(atRow: row, makeIfNecessary: false) else { + return false + } + + let passed = CFAbsoluteTimeGetCurrent() >= self.swipeRemoveCooldownUntil + && abs(accH) >= Self.swipeRemoveDeltaThreshold + && abs(accH) > abs(accV) + && row != coord.currentIndex + + if passed { + let slideDirection: CGFloat = accH > 0 ? 1 : -1 + let targetX = initialX + slideDirection * rowView.bounds.width + let videoId = song.videoId + self.swipeRemoveCooldownUntil = CFAbsoluteTimeGetCurrent() + Self.swipeRemoveCooldown + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.2 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + var f = rowView.frame + f.origin.x = targetX + rowView.animator().frame = f + rowView.animator().alphaValue = 0 + } completionHandler: { + rowView.alphaValue = 1 + var f = rowView.frame + f.origin.x = initialX + rowView.frame = f + coord.onRemove(videoId) + } + return true + } else { + // Cancel: animate row back to initial position. + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.2 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + var f = rowView.frame + f.origin.x = initialX + rowView.animator().frame = f + } completionHandler: {} + return true + } + } + + if CFAbsoluteTimeGetCurrent() < self.swipeRemoveCooldownUntil { return false } + guard abs(accH) >= Self.swipeRemoveDeltaThreshold, + abs(accH) > abs(accV) + else { return false } + guard let coord = coordinator else { return false } + let row = self.swipeRemoveTargetRow >= 0 ? self.swipeRemoveTargetRow : rowAtEnd + self.swipeRemoveTargetRow = -1 + if row < 0 { return false } + if row == coord.currentIndex { return false } + guard let song = coord.queue[safe: row] else { return false } + let slideDirection: CGFloat = accH > 0 ? 1 : -1 + self.swipeRemoveCooldownUntil = CFAbsoluteTimeGetCurrent() + Self.swipeRemoveCooldown + coord.removeRowWithAnimation(row: row, song: song, slideDirection: slideDirection) + return true + } +} + +// MARK: - QueueSidePanelHeader + +@available(macOS 26.0, *) +private struct QueueSidePanelHeader: View { + @Environment(PlayerService.self) private var playerService + + var body: some View { + HStack { + Text("Up Next") + .font(.headline) + .foregroundStyle(.primary) + + Spacer() + + Text("\(self.playerService.queue.count) songs") + .font(.caption) + .foregroundStyle(.secondary) + + Button { + self.playerService.toggleQueueDisplayMode() + } label: { + Label("Done", systemImage: "checkmark") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Close side panel") + .accessibilityLabel("Close side panel") + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } +} + +// MARK: - QueueFooterActions + +@available(macOS 26.0, *) +private struct QueueFooterActions: View { + @Environment(PlayerService.self) private var playerService + + var body: some View { + HStack(spacing: 12) { + Button { + self.playerService.undoQueue() + } label: { + Label("Undo", systemImage: "arrow.uturn.backward") + } + .disabled(!self.playerService.canUndoQueue) + .buttonStyle(.plain) + + Button { + self.playerService.redoQueue() + } label: { + Label("Redo", systemImage: "arrow.uturn.forward") + } + .disabled(!self.playerService.canRedoQueue) + .buttonStyle(.plain) + + Button { + self.playerService.shuffleQueue() + } label: { + Label("Shuffle", systemImage: "shuffle") + } + .disabled(self.playerService.queue.isEmpty) + .buttonStyle(.plain) + + Button { + Task { + if self.playerService.isPlaying { + await self.playerService.stop() + } + self.playerService.clearQueueEntirely() + } + } label: { + Label("Clear", systemImage: "trash") + .foregroundStyle(.red) + } + .disabled(self.playerService.queue.isEmpty) + .buttonStyle(.plain) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } +} + +// MARK: - Preview + +@available(macOS 26.0, *) +#Preview("Queue Side Panel") { + let playerService = PlayerService() + QueueSidePanelView() + .environment(playerService) + .environment(FavoritesManager.shared) + .frame(height: 600) +} diff --git a/Views/macOS/QueueTableCellView.swift b/Views/macOS/QueueTableCellView.swift new file mode 100644 index 0000000..96d8127 --- /dev/null +++ b/Views/macOS/QueueTableCellView.swift @@ -0,0 +1,351 @@ +import AppKit +import SwiftUI + +// MARK: - QueueCellActions + +@available(macOS 26.0, *) +struct QueueCellActions { + let onPlay: () -> Void + let onRemove: () -> Void +} + +// MARK: - QueueTableCellView + +@available(macOS 26.0, *) +class QueueTableCellView: NSView { + private var onPlay: (() -> Void)? + private var onRemove: (() -> Void)? + private var isCurrentTrack: Bool = false + private var isPlaying: Bool = false + private var indicatorLabel = NSTextField() + private var waveformView: NSView? + private let thumbnailImageView = NSImageView() + private var imageLoadTask: Task? + private var currentSongId: String? + private let titleLabel = NSTextField() + private let artistLabel = NSTextField() + private let durationLabel = NSTextField() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + self.setupView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + self.setupView() + } + + private func setupView() { + wantsLayer = true + // Fill the row view so layout is consistent when the table reuses row views (fixes misaligned rows). + autoresizingMask = [.width, .height] + + let stackView = NSStackView() + stackView.orientation = .horizontal + stackView.spacing = 12 + stackView.alignment = .centerY + stackView.edgeInsets = NSEdgeInsets(top: 8, left: 12, bottom: 8, right: 8) // Reduced right padding + stackView.translatesAutoresizingMaskIntoConstraints = false + + // Indicator container (for number or waveform) — keep fixed so long text doesn't shift row layout + let indicatorContainer = NSView() + indicatorContainer.translatesAutoresizingMaskIntoConstraints = false + let indicatorWidth = indicatorContainer.widthAnchor.constraint(equalToConstant: 24) + indicatorWidth.priority = .required + indicatorWidth.isActive = true + indicatorContainer.heightAnchor.constraint(equalToConstant: 20).isActive = true + indicatorContainer.setContentHuggingPriority(.required, for: .horizontal) + indicatorContainer.setContentCompressionResistancePriority(.required, for: .horizontal) + + self.indicatorLabel.isEditable = false + self.indicatorLabel.isBordered = false + self.indicatorLabel.backgroundColor = .clear + self.indicatorLabel.alignment = .center + self.indicatorLabel.font = NSFont.systemFont(ofSize: 12) + self.indicatorLabel.translatesAutoresizingMaskIntoConstraints = false + indicatorContainer.addSubview(self.indicatorLabel) + NSLayoutConstraint.activate([ + self.indicatorLabel.centerXAnchor.constraint(equalTo: indicatorContainer.centerXAnchor), + self.indicatorLabel.centerYAnchor.constraint(equalTo: indicatorContainer.centerYAnchor), + ]) + + self.thumbnailImageView.wantsLayer = true + self.thumbnailImageView.layer?.cornerRadius = 4 + self.thumbnailImageView.layer?.masksToBounds = true + self.thumbnailImageView.widthAnchor.constraint(equalToConstant: 40).isActive = true + self.thumbnailImageView.heightAnchor.constraint(equalToConstant: 40).isActive = true + self.thumbnailImageView.setContentHuggingPriority(.required, for: .horizontal) + self.thumbnailImageView.setContentCompressionResistancePriority(.required, for: .horizontal) + + let infoStackView = NSStackView() + infoStackView.orientation = .vertical + infoStackView.spacing = 2 + infoStackView.alignment = .leading + + self.titleLabel.isEditable = false + self.titleLabel.isBordered = false + self.titleLabel.backgroundColor = .clear + self.titleLabel.lineBreakMode = .byTruncatingTail + + self.artistLabel.isEditable = false + self.artistLabel.isBordered = false + self.artistLabel.backgroundColor = .clear + self.artistLabel.lineBreakMode = .byTruncatingTail + self.artistLabel.font = NSFont.systemFont(ofSize: 11) + self.artistLabel.textColor = NSColor.secondaryLabelColor + + infoStackView.addArrangedSubview(self.titleLabel) + infoStackView.addArrangedSubview(self.artistLabel) + + self.durationLabel.isEditable = false + self.durationLabel.isBordered = false + self.durationLabel.backgroundColor = .clear + self.durationLabel.alignment = .right + self.durationLabel.font = NSFont.systemFont(ofSize: 11) + self.durationLabel.textColor = NSColor.tertiaryLabelColor + self.durationLabel.setContentCompressionResistancePriority(.required, for: .horizontal) // Don't compress duration + + // Spacer takes all flexible space so title/artist and duration stay consistently aligned across rows + let spacerView = NSView() + spacerView.translatesAutoresizingMaskIntoConstraints = false + spacerView.setContentHuggingPriority(.defaultLow, for: .horizontal) + spacerView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + infoStackView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) // Truncate before spacer grows + + stackView.addArrangedSubview(indicatorContainer) + stackView.addArrangedSubview(self.thumbnailImageView) + stackView.addArrangedSubview(infoStackView) + stackView.addArrangedSubview(spacerView) + stackView.addArrangedSubview(self.durationLabel) + + addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + stackView.topAnchor.constraint(equalTo: topAnchor), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + let clickGesture = NSClickGestureRecognizer(target: self, action: #selector(handleClick)) + addGestureRecognizer(clickGesture) + } + + override func layout() { + super.layout() + // Ensure we always fill the row view so reused rows don't keep a stale frame (fixes misaligned rows). + if let sv = superview, !sv.bounds.isEmpty, frame != sv.bounds { + frame = sv.bounds + } + } + + func configure(song: Song, index: Int, isCurrentTrack: Bool, isPlaying: Bool, actions: QueueCellActions) { + self.onPlay = actions.onPlay + self.onRemove = actions.onRemove + self.isCurrentTrack = isCurrentTrack + self.isPlaying = isPlaying + self.updateAppearance(isCurrentTrack: isCurrentTrack, isPlaying: isPlaying, index: index) + + self.titleLabel.stringValue = song.title + self.titleLabel.font = NSFont.systemFont(ofSize: 13, weight: isCurrentTrack ? .semibold : .regular) + self.titleLabel.textColor = isCurrentTrack ? NSColor.systemRed : NSColor.labelColor + + self.artistLabel.stringValue = song.artistsDisplay.isEmpty ? "Unknown Artist" : song.artistsDisplay + + if let duration = song.duration { + let mins = Int(duration) / 60 + let secs = Int(duration) % 60 + self.durationLabel.stringValue = String(format: "%d:%02d", mins, secs) + } else { + self.durationLabel.stringValue = "" + } + + let songId = song.id + self.currentSongId = songId + self.imageLoadTask?.cancel() + if let url = song.thumbnailURL?.highQualityThumbnailURL { + self.imageLoadTask = Task { [weak self] in + let image = await ImageCache.shared.image(for: url, targetSize: CGSize(width: 40, height: 40)) + guard !Task.isCancelled, self?.currentSongId == songId else { return } + self?.thumbnailImageView.image = image + } + } else { + self.thumbnailImageView.image = nil + } + } + + func updateAppearance(isCurrentTrack: Bool, isPlaying: Bool, index: Int) { + self.isCurrentTrack = isCurrentTrack + self.isPlaying = isPlaying + + if isCurrentTrack { + // Show animated waveform for current track + self.indicatorLabel.stringValue = "" + self.indicatorLabel.isHidden = true + + // Create or update waveform view + if self.waveformView == nil { + let waveView = WaveformView(frame: NSRect(x: 0, y: 0, width: 24, height: 16)) + waveView.translatesAutoresizingMaskIntoConstraints = false + self.waveformView = waveView + + // Find indicator container and add waveform + if let indicatorContainer = indicatorLabel.superview { + indicatorContainer.addSubview(waveView) + NSLayoutConstraint.activate([ + waveView.centerXAnchor.constraint(equalTo: indicatorContainer.centerXAnchor), + waveView.centerYAnchor.constraint(equalTo: indicatorContainer.centerYAnchor), + waveView.widthAnchor.constraint(equalToConstant: 24), + waveView.heightAnchor.constraint(equalToConstant: 16), + ]) + } + } + + if let waveView = waveformView as? WaveformView { + waveView.isHidden = false + waveView.isAnimating = isPlaying + waveView.tintColor = isPlaying ? NSColor.systemRed : NSColor.tertiaryLabelColor + } + + layer?.backgroundColor = NSColor.systemRed.withAlphaComponent(0.1).cgColor + } else { + // Show number for non-current tracks + self.indicatorLabel.isHidden = false + self.indicatorLabel.stringValue = "\(index + 1)" + self.indicatorLabel.textColor = NSColor.tertiaryLabelColor + + // Hide waveform + self.waveformView?.isHidden = true + + layer?.backgroundColor = NSColor.clear.cgColor + } + } + + @objc private func handleClick() { + self.onPlay?() + } + + override func prepareForReuse() { + super.prepareForReuse() + self.imageLoadTask?.cancel() + self.imageLoadTask = nil + self.currentSongId = nil + self.thumbnailImageView.image = nil + self.waveformView?.removeFromSuperview() + self.waveformView = nil + } +} + +// MARK: - WaveformView + +@available(macOS 26.0, *) +class WaveformView: NSView { + var isAnimating: Bool = false { + didSet { + self.updateAnimation() + } + } + + var tintColor: NSColor = .systemRed { + didSet { + layer?.sublayers?.forEach { $0.backgroundColor = self.tintColor.cgColor } + } + } + + private var timer: Timer? + private var bars: [CALayer] = [] + private var startTime: CFTimeInterval = 0 + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + self.setupBars() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + self.setupBars() + } + + private func setupBars() { + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + + // Create 3 bars for the waveform + let barWidth: CGFloat = 3 + let barSpacing: CGFloat = 2 + let totalWidth = CGFloat(3) * barWidth + CGFloat(2) * barSpacing + let startX = (bounds.width - totalWidth) / 2 + + for i in 0 ..< 3 { + let bar = CALayer() + bar.backgroundColor = self.tintColor.cgColor + bar.cornerRadius = 1 + bar.frame = NSRect( + x: startX + CGFloat(i) * (barWidth + barSpacing), + y: bounds.height / 2 - 4, + width: barWidth, + height: 8 + ) + layer?.addSublayer(bar) + self.bars.append(bar) + } + } + + private func updateAnimation() { + if self.isAnimating { + self.startAnimation() + } else { + self.stopAnimation() + // Reset to static middle position + for bar in self.bars { + bar.frame.size.height = 8 + bar.frame.origin.y = (bounds.height - 8) / 2 + } + } + } + + private func startAnimation() { + guard timer == nil else { return } + + self.startTime = CACurrentMediaTime() + + // Use Timer for 30fps animation - simpler and safer than CVDisplayLink + timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in + self?.updateBars() + } + // Add to common run loop modes to ensure it runs during tracking/dragging + if let timer { + RunLoop.current.add(timer, forMode: .common) + } + } + + private func stopAnimation() { + self.timer?.invalidate() + self.timer = nil + } + + private func updateBars() { + guard self.isAnimating else { return } + + let elapsed = CACurrentMediaTime() - self.startTime + let barHeights: [CGFloat] = [ + 4 + 8 * CGFloat(abs(sin(elapsed * 4))), + 4 + 10 * CGFloat(abs(sin(elapsed * 3 + 1))), + 4 + 6 * CGFloat(abs(sin(elapsed * 5 + 2))), + ] + + CATransaction.begin() + CATransaction.setDisableActions(true) // Disable implicit animations + for (i, bar) in self.bars.enumerated() { + let height = min(barHeights[i], bounds.height) + bar.frame.size.height = height + bar.frame.origin.y = (bounds.height - height) / 2 + } + CATransaction.commit() + } + + deinit { + stopAnimation() + } +} diff --git a/Views/macOS/QueueView.swift b/Views/macOS/QueueView.swift index dfe4895..c102e51 100644 --- a/Views/macOS/QueueView.swift +++ b/Views/macOS/QueueView.swift @@ -54,6 +54,17 @@ struct QueueView: View { .buttonStyle(.plain) .accessibilityIdentifier(AccessibilityID.Queue.clearButton) } + + Button { + self.playerService.toggleQueueDisplayMode() + } label: { + Label("Edit", systemImage: "square.and.pencil") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Open queue in side panel") + .accessibilityLabel("Open queue in side panel") } .padding(.horizontal, 16) .padding(.vertical, 14) @@ -101,7 +112,7 @@ struct QueueView: View { favoritesManager: self.favoritesManager, playerService: self.playerService, onRemove: { - self.playerService.removeFromQueue(videoIds: [song.videoId]) + self.playerService.removeFromQueue(videoIds: Set([song.videoId])) }, onTap: { Task { diff --git a/Views/macOS/SearchView.swift b/Views/macOS/SearchView.swift index f9ca9db..793d9ca 100644 --- a/Views/macOS/SearchView.swift +++ b/Views/macOS/SearchView.swift @@ -396,136 +396,194 @@ struct SearchView: View { private func contextMenuItems(for item: SearchResultItem) -> some View { switch item { case let .song(song): - Button { - Task { await self.playerService.play(song: song) } - } label: { - Label("Play", systemImage: "play.fill") - } + self.songContextMenu(song) + case let .album(album): + self.albumContextMenu(album) + case let .artist(artist): + self.artistContextMenu(artist) + case let .playlist(playlist): + self.playlistContextMenu(playlist) + case let .podcastShow(show): + self.podcastShowContextMenu(show) + } + } - Divider() + @ViewBuilder + private func songContextMenu(_ song: Song) -> some View { + Button { + Task { await self.playerService.playWithRadio(song: song) } + } label: { + Label("Play", systemImage: "play.fill") + } - FavoritesContextMenu.menuItem(for: song, manager: self.favoritesManager) + Divider() - Divider() + FavoritesContextMenu.menuItem(for: song, manager: self.favoritesManager) - LikeDislikeContextMenu(song: song, likeStatusManager: self.likeStatusManager) + Divider() - Divider() + LikeDislikeContextMenu(song: song, likeStatusManager: self.likeStatusManager) - StartRadioContextMenu.menuItem(for: song, playerService: self.playerService) + Divider() - Divider() + StartRadioContextMenu.menuItem(for: song, playerService: self.playerService) - Button { - SongActionsHelper.addToLibrary(song, playerService: self.playerService) - } label: { - Label("Add to Library", systemImage: "plus.circle") - } + Divider() - Divider() + Button { + SongActionsHelper.addToLibrary(song, playerService: self.playerService) + } label: { + Label("Add to Library", systemImage: "plus.circle") + } - ShareContextMenu.menuItem(for: song) + Divider() - Divider() + ShareContextMenu.menuItem(for: song) - // Go to Artist - show first artist with valid ID - if let artist = song.artists.first(where: { $0.hasNavigableId }) { - NavigationLink(value: artist) { - Label("Go to Artist", systemImage: "person") - } - } + Divider() - // Go to Album - show if album has valid browse ID - if let album = song.album, album.hasNavigableId { - let playlist = Playlist( - id: album.id, - title: album.title, - description: nil, - thumbnailURL: album.thumbnailURL ?? song.thumbnailURL, - trackCount: album.trackCount, - author: album.artistsDisplay - ) - NavigationLink(value: playlist) { - Label("Go to Album", systemImage: "square.stack") - } - } + AddToQueueContextMenu(song: song, playerService: self.playerService) - case let .album(album): - Button { - let playlist = Playlist( - id: album.id, - title: album.title, - description: nil, - thumbnailURL: album.thumbnailURL, - trackCount: album.trackCount, - author: album.artistsDisplay - ) - self.navigationPath.append(playlist) - } label: { - Label("View Album", systemImage: "square.stack") + Divider() + + // Go to Artist - show first artist with valid ID + if let artist = song.artists.first(where: { $0.hasNavigableId }) { + NavigationLink(value: artist) { + Label("Go to Artist", systemImage: "person") } + } - Divider() + // Go to Album - show if album has valid browse ID + if let album = song.album, album.hasNavigableId { + let playlist = Playlist( + id: album.id, + title: album.title, + description: nil, + thumbnailURL: album.thumbnailURL ?? song.thumbnailURL, + trackCount: album.trackCount, + author: album.artistsDisplay + ) + NavigationLink(value: playlist) { + Label("Go to Album", systemImage: "square.stack") + } + } + } - FavoritesContextMenu.menuItem(for: album, manager: self.favoritesManager) + @ViewBuilder + private func albumContextMenu(_ album: Album) -> some View { + Button { + let playlist = Playlist( + id: album.id, + title: album.title, + description: nil, + thumbnailURL: album.thumbnailURL, + trackCount: album.trackCount, + author: album.artistsDisplay + ) + self.navigationPath.append(playlist) + } label: { + Label("View Album", systemImage: "square.stack") + } - ShareContextMenu.menuItem(for: album) + Divider() - case let .artist(artist): - Button { - self.navigationPath.append(artist) - } label: { - Label("View Artist", systemImage: "person") - } + // Play / Play Next / Add to Queue for albums + Button { + SongActionsHelper.playAlbum( + album, + client: self.viewModel.client, + playerService: self.playerService + ) + } label: { + Label("Play", systemImage: "play.fill") + } - Divider() + Button { + SongActionsHelper.addAlbumToQueueNext( + album, + client: self.viewModel.client, + playerService: self.playerService + ) + } label: { + Label("Play Next", systemImage: "text.insert") + } - FavoritesContextMenu.menuItem(for: artist, manager: self.favoritesManager) + Button { + SongActionsHelper.addAlbumToQueueLast( + album, + client: self.viewModel.client, + playerService: self.playerService + ) + } label: { + Label("Add to Queue", systemImage: "text.append") + } - ShareContextMenu.menuItem(for: artist) + Divider() - case let .playlist(playlist): - Button { - Task { - await SongActionsHelper.addPlaylistToLibrary( - playlist, - client: self.viewModel.client, - libraryViewModel: self.libraryViewModel - ) - } - } label: { - Label("Add to Library", systemImage: "plus.circle") - } + FavoritesContextMenu.menuItem(for: album, manager: self.favoritesManager) - Divider() + ShareContextMenu.menuItem(for: album) + } - FavoritesContextMenu.menuItem(for: playlist, manager: self.favoritesManager) + @ViewBuilder + private func artistContextMenu(_ artist: Artist) -> some View { + Button { + self.navigationPath.append(artist) + } label: { + Label("View Artist", systemImage: "person") + } - Divider() + Divider() - ShareContextMenu.menuItem(for: playlist) + FavoritesContextMenu.menuItem(for: artist, manager: self.favoritesManager) - Divider() + ShareContextMenu.menuItem(for: artist) + } - Button { - self.navigationPath.append(playlist) - } label: { - Label("View Playlist", systemImage: "music.note.list") + @ViewBuilder + private func playlistContextMenu(_ playlist: Playlist) -> some View { + Button { + Task { + await SongActionsHelper.addPlaylistToLibrary( + playlist, + client: self.viewModel.client, + libraryViewModel: self.libraryViewModel + ) } + } label: { + Label("Add to Library", systemImage: "plus.circle") + } - case let .podcastShow(show): - Button { - self.navigationPath.append(show) - } label: { - Label("View Podcast", systemImage: "mic.fill") - } + Divider() + + FavoritesContextMenu.menuItem(for: playlist, manager: self.favoritesManager) - Divider() + Divider() - FavoritesContextMenu.menuItem(for: show, manager: self.favoritesManager) + ShareContextMenu.menuItem(for: playlist) + + Divider() + + Button { + self.navigationPath.append(playlist) + } label: { + Label("View Playlist", systemImage: "music.note.list") } } + @ViewBuilder + private func podcastShowContextMenu(_ show: PodcastShow) -> some View { + Button { + self.navigationPath.append(show) + } label: { + Label("View Podcast", systemImage: "mic.fill") + } + + Divider() + + FavoritesContextMenu.menuItem(for: show, manager: self.favoritesManager) + } + // MARK: - Helpers private func iconForItem(_ item: SearchResultItem) -> String { diff --git a/Views/macOS/SharedViews/FavoritesSection.swift b/Views/macOS/SharedViews/FavoritesSection.swift index 3d8082d..c72e343 100644 --- a/Views/macOS/SharedViews/FavoritesSection.swift +++ b/Views/macOS/SharedViews/FavoritesSection.swift @@ -169,6 +169,12 @@ struct FavoritesSection: View { ShareContextMenu.menuItem(for: item) + // Add to Queue for songs + if case let .song(song) = item.itemType { + Divider() + AddToQueueContextMenu(song: song, playerService: self.playerService) + } + Divider() // Navigation to related content diff --git a/Views/macOS/SharedViews/ShareContextMenu.swift b/Views/macOS/SharedViews/ShareContextMenu.swift index 2860d9e..8fb35df 100644 --- a/Views/macOS/SharedViews/ShareContextMenu.swift +++ b/Views/macOS/SharedViews/ShareContextMenu.swift @@ -9,7 +9,8 @@ import SwiftUI @MainActor enum ShareContextMenu { /// Shows the share picker at the current mouse location. - private static func showSharePicker(for url: URL) { + /// Exposed for AppKit context menus (e.g. queue side panel) that cannot use the SwiftUI menu item. + static func showSharePicker(for url: URL) { let picker = NSSharingServicePicker(items: [url]) // Get the current mouse location in screen coordinates diff --git a/Views/macOS/SharedViews/SongActionsHelper.swift b/Views/macOS/SharedViews/SongActionsHelper.swift index b89be30..d222e16 100644 --- a/Views/macOS/SharedViews/SongActionsHelper.swift +++ b/Views/macOS/SharedViews/SongActionsHelper.swift @@ -3,7 +3,7 @@ import SwiftUI // MARK: - SongActionsHelper -/// Helper for common song actions like liking, disliking, and adding to library. +/// Helper for common song actions like liking, disliking, adding to library, and queue management. @MainActor enum SongActionsHelper { /// Likes a song via the API (does not play the song). @@ -100,6 +100,389 @@ enum SongActionsHelper { await libraryViewModel?.refresh() DiagnosticsLogger.api.info("Unsubscribed from podcast: \(show.title)") } + + // MARK: - Queue Actions + + /// Adds a song to play next (immediately after current track). + static func addToQueueNext(_ song: Song, playerService: PlayerService) { + playerService.insertNextInQueue([song]) + DiagnosticsLogger.ui.info("Added song to play next: \(song.title)") + } + + /// Adds a song to the end of the queue. + static func addToQueueLast(_ song: Song, playerService: PlayerService) { + playerService.appendToQueue([song]) + DiagnosticsLogger.ui.info("Added song to end of queue: \(song.title)") + } + + /// Adds multiple songs (e.g., from an album) to play next. + /// - Parameters: + /// - fallbackArtist: Artist name to use when songs have empty artists (e.g., album author) + /// - fallbackAlbum: Album info to use when songs don't have album metadata (e.g., album title/cover) + static func addSongsToQueueNext( + _ songs: [Song], + playerService: PlayerService, + fallbackArtist: String? = nil, + fallbackAlbum: Album? = nil + ) { + guard !songs.isEmpty else { return } + + // Clean artists and use fallback when empty + let cleanedSongs = songs.map { song in + var cleanedArtists = song.artists.compactMap { artist -> Artist? in + if artist.name == "Album" { return nil } + var cleanName = artist.name + if cleanName.hasPrefix("Album, ") { + cleanName = String(cleanName.dropFirst(7)) + } + return Artist(id: artist.id, name: cleanName) + } + + // Use fallback artist if artists are empty (and clean the fallback too) + if cleanedArtists.isEmpty, let fallback = fallbackArtist, !fallback.isEmpty { + // Clean the fallback string - it might have "Album, " prefix or be "Album" + var cleanFallback = fallback + if cleanFallback == "Album" { + cleanFallback = "Unknown Artist" + } else if cleanFallback.hasPrefix("Album, ") { + cleanFallback = String(cleanFallback.dropFirst(7)) + } + // Also handle case where it's "Album, Artist" but we got it as a combined string + if cleanFallback.contains("Album,") { + // Try to extract just the artist part after "Album," + let parts = cleanFallback.split(separator: ",", maxSplits: 1) + if parts.count > 1 { + cleanFallback = String(parts[1]).trimmingCharacters(in: .whitespaces) + } + } + cleanedArtists = [Artist(id: "unknown", name: cleanFallback)] + } + + // Use fallback album if song doesn't have album info + let finalAlbum = song.album ?? fallbackAlbum + // Use fallback thumbnail if song doesn't have one + let finalThumbnail = song.thumbnailURL ?? fallbackAlbum?.thumbnailURL + + return Song( + id: song.id, + title: song.title, + artists: cleanedArtists, + album: finalAlbum, + duration: song.duration, + thumbnailURL: finalThumbnail, + videoId: song.videoId + ) + } + + playerService.insertNextInQueue(cleanedSongs) + DiagnosticsLogger.ui.info("Added \(cleanedSongs.count) songs to play next") + } + + /// Adds multiple songs (e.g., from an album) to the end of the queue. + /// - Parameters: + /// - fallbackArtist: Artist name to use when songs have empty artists (e.g., album author) + /// - fallbackAlbum: Album info to use when songs don't have album metadata (e.g., album title/cover) + static func addSongsToQueueLast( + _ songs: [Song], + playerService: PlayerService, + fallbackArtist: String? = nil, + fallbackAlbum: Album? = nil + ) { + guard !songs.isEmpty else { return } + + // Clean artists and use fallback when empty + let cleanedSongs = songs.map { song in + var cleanedArtists = song.artists.compactMap { artist -> Artist? in + if artist.name == "Album" { return nil } + var cleanName = artist.name + if cleanName.hasPrefix("Album, ") { + cleanName = String(cleanName.dropFirst(7)) + } + return Artist(id: artist.id, name: cleanName) + } + + // Use fallback artist if artists are empty (and clean the fallback too) + if cleanedArtists.isEmpty, let fallback = fallbackArtist, !fallback.isEmpty { + // Clean the fallback string - it might have "Album, " prefix or be "Album" + var cleanFallback = fallback + if cleanFallback == "Album" { + cleanFallback = "Unknown Artist" + } else if cleanFallback.hasPrefix("Album, ") { + cleanFallback = String(cleanFallback.dropFirst(7)) + } + // Also handle case where it's "Album, Artist" but we got it as a combined string + if cleanFallback.contains("Album,") { + // Try to extract just the artist part after "Album," + let parts = cleanFallback.split(separator: ",", maxSplits: 1) + if parts.count > 1 { + cleanFallback = String(parts[1]).trimmingCharacters(in: .whitespaces) + } + } + cleanedArtists = [Artist(id: "unknown", name: cleanFallback)] + } + + // Use fallback album if song doesn't have album info + let finalAlbum = song.album ?? fallbackAlbum + // Use fallback thumbnail if song doesn't have one + let finalThumbnail = song.thumbnailURL ?? fallbackAlbum?.thumbnailURL + + return Song( + id: song.id, + title: song.title, + artists: cleanedArtists, + album: finalAlbum, + duration: song.duration, + thumbnailURL: finalThumbnail, + videoId: song.videoId + ) + } + + playerService.appendToQueue(cleanedSongs) + DiagnosticsLogger.ui.info("Added \(cleanedSongs.count) songs to end of queue") + } + + // MARK: - Album Queue Actions + + /// Adds an album's songs to play next (immediately after current track). + static func addAlbumToQueueNext( + _ album: Album, + client: any YTMusicClientProtocol, + playerService: PlayerService + ) { + Task { + do { + // Fetch album tracks - albums are treated as playlists + let response = try await client.getPlaylist(id: album.id) + var songs = response.detail.tracks + + guard !songs.isEmpty else { return } + + // Clean up album artists - filter out "Album" keyword and clean names + let cleanAlbumArtists = (album.artists ?? []).compactMap { artist -> Artist? in + var cleanName = artist.name + + // Skip artists that are literally just "Album" (the keyword, not an artist name) + if cleanName == "Album" { + return nil + } + + // Also clean "Album, " prefix if present + if cleanName.hasPrefix("Album, ") { + cleanName = String(cleanName.dropFirst(7)) + } + + return Artist(id: artist.id, name: cleanName) + } + + // Populate album and artist info for each song + songs = songs.map { song in + // Use song artists if available and not empty, otherwise use cleaned album artists + let baseArtists = !song.artists.isEmpty ? song.artists : cleanAlbumArtists + + // Also clean song artists - filter "Album" keyword and clean names + let effectiveArtists = baseArtists.compactMap { artist -> Artist? in + var cleanName = artist.name + + // Skip artists that are literally just "Album" + if cleanName == "Album" { + return nil + } + + // Clean "Album, " prefix if present + if cleanName.hasPrefix("Album, ") { + cleanName = String(cleanName.dropFirst(7)) + } + return Artist(id: artist.id, name: cleanName) + } + + // Create updated song with album info and proper artists + return Song( + id: song.id, + title: song.title, + artists: effectiveArtists, + album: Album( + id: album.id, + title: album.title, + artists: cleanAlbumArtists, + thumbnailURL: album.thumbnailURL, + year: nil, + trackCount: album.trackCount + ), + duration: song.duration, + thumbnailURL: song.thumbnailURL ?? album.thumbnailURL, + videoId: song.videoId + ) + } + + playerService.insertNextInQueue(songs) + DiagnosticsLogger.ui.info("Added album '\(album.title)' (\(songs.count) songs) to play next") + } catch { + DiagnosticsLogger.ui.error("Failed to add album to queue: \(error.localizedDescription)") + } + } + } + + /// Adds an album's songs to the end of the queue. + static func addAlbumToQueueLast( + _ album: Album, + client: any YTMusicClientProtocol, + playerService: PlayerService + ) { + Task { + do { + // Fetch album tracks - albums are treated as playlists + let response = try await client.getPlaylist(id: album.id) + var songs = response.detail.tracks + + guard !songs.isEmpty else { return } + + // Clean up album artists - filter out "Album" keyword and clean names + let cleanAlbumArtists = (album.artists ?? []).compactMap { artist -> Artist? in + var cleanName = artist.name + + // Skip artists that are literally just "Album" (the keyword, not an artist name) + if cleanName == "Album" { + return nil + } + + // Also clean "Album, " prefix if present + if cleanName.hasPrefix("Album, ") { + cleanName = String(cleanName.dropFirst(7)) + } + + return Artist(id: artist.id, name: cleanName) + } + + // Populate album and artist info for each song + songs = songs.map { song in + // Use song artists if available and not empty, otherwise use cleaned album artists + let baseArtists = !song.artists.isEmpty ? song.artists : cleanAlbumArtists + + // Also clean song artists - filter "Album" keyword and clean names + let effectiveArtists = baseArtists.compactMap { artist -> Artist? in + var cleanName = artist.name + + // Skip artists that are literally just "Album" + if cleanName == "Album" { + return nil + } + + // Clean "Album, " prefix if present + if cleanName.hasPrefix("Album, ") { + cleanName = String(cleanName.dropFirst(7)) + } + return Artist(id: artist.id, name: cleanName) + } + + // Create updated song with album info and proper artists + return Song( + id: song.id, + title: song.title, + artists: effectiveArtists, + album: Album( + id: album.id, + title: album.title, + artists: cleanAlbumArtists, + thumbnailURL: album.thumbnailURL, + year: nil, + trackCount: album.trackCount + ), + duration: song.duration, + thumbnailURL: song.thumbnailURL ?? album.thumbnailURL, + videoId: song.videoId + ) + } + + playerService.appendToQueue(songs) + DiagnosticsLogger.ui.info("Added album '\(album.title)' (\(songs.count) songs) to end of queue") + } catch { + DiagnosticsLogger.ui.error("Failed to add album to queue: \(error.localizedDescription)") + } + } + } + + /// Plays an album immediately, replacing the current queue. + static func playAlbum( + _ album: Album, + client: any YTMusicClientProtocol, + playerService: PlayerService + ) { + Task { + do { + // Fetch album tracks - albums are treated as playlists + let response = try await client.getPlaylist(id: album.id) + var songs = response.detail.tracks + + guard !songs.isEmpty else { return } + + // Clean up album artists - filter out "Album" keyword and clean names + let cleanAlbumArtists = (album.artists ?? []).compactMap { artist -> Artist? in + var cleanName = artist.name + + // Skip artists that are literally just "Album" (the keyword, not an artist name) + if cleanName == "Album" { + return nil + } + + // Also clean "Album, " prefix if present + if cleanName.hasPrefix("Album, ") { + cleanName = String(cleanName.dropFirst(7)) + } + + return Artist(id: artist.id, name: cleanName) + } + + // Populate album and artist info for each song + songs = songs.map { song in + // Use song artists if available and not empty, otherwise use cleaned album artists + let baseArtists = !song.artists.isEmpty ? song.artists : cleanAlbumArtists + + // Also clean song artists - filter "Album" keyword and clean names + let effectiveArtists = baseArtists.compactMap { artist -> Artist? in + var cleanName = artist.name + + // Skip artists that are literally just "Album" + if cleanName == "Album" { + return nil + } + + // Clean "Album, " prefix if present + if cleanName.hasPrefix("Album, ") { + cleanName = String(cleanName.dropFirst(7)) + } + return Artist(id: artist.id, name: cleanName) + } + + // Create album object for the song + let songAlbum = Album( + id: album.id, + title: album.title, + artists: cleanAlbumArtists.isEmpty ? nil : cleanAlbumArtists, + thumbnailURL: album.thumbnailURL, + year: album.year, + trackCount: songs.count + ) + + // Create updated song with album info and proper artists + return Song( + id: song.id, + title: song.title, + artists: effectiveArtists.isEmpty ? cleanAlbumArtists : effectiveArtists, + album: songAlbum, + duration: song.duration, + thumbnailURL: song.thumbnailURL ?? album.thumbnailURL, + videoId: song.videoId + ) + } + + // Stop current playback and play the album + await playerService.playQueue(songs, startingAt: 0) + DiagnosticsLogger.ui.info("Playing album '\(album.title)' (\(songs.count) songs)") + } catch { + DiagnosticsLogger.ui.error("Failed to play album: \(error.localizedDescription)") + } + } + } } // MARK: - LikeDislikeContextMenu @@ -142,3 +525,26 @@ struct LikeDislikeContextMenu: View { } } } + +// MARK: - AddToQueueContextMenu + +/// Reusable context menu items for adding songs to the queue. +@available(macOS 26.0, *) +struct AddToQueueContextMenu: View { + let song: Song + let playerService: PlayerService + + var body: some View { + Button { + SongActionsHelper.addToQueueNext(self.song, playerService: self.playerService) + } label: { + Label("Play Next", systemImage: "text.insert") + } + + Button { + SongActionsHelper.addToQueueLast(self.song, playerService: self.playerService) + } label: { + Label("Add to Queue", systemImage: "text.append") + } + } +} diff --git a/Views/macOS/TopSongsView.swift b/Views/macOS/TopSongsView.swift index 83ec06c..f94477a 100644 --- a/Views/macOS/TopSongsView.swift +++ b/Views/macOS/TopSongsView.swift @@ -158,6 +158,10 @@ struct TopSongsView: View { Divider() + AddToQueueContextMenu(song: song, playerService: self.playerService) + + Divider() + // Go to Artist - show first artist with valid ID if let artist = song.artists.first(where: { $0.hasNavigableId }) { NavigationLink(value: artist) { diff --git a/docs/new_features_queue_enhancement.md b/docs/new_features_queue_enhancement.md new file mode 100644 index 0000000..a33662d --- /dev/null +++ b/docs/new_features_queue_enhancement.md @@ -0,0 +1,1062 @@ +# Queue Enhancement Feature Proposal + +## Overview + +This document details the proposed enhancements to the Queue (Up Next) feature in Kaset. The current implementation provides basic queue functionality but lacks advanced playlist management features that power users expect from a dedicated music client. + +## Critique and Implementation Notes + +### Strengths + +- **Clear gap analysis** — Priorities (side panel, drag/drop, Play Next / Add to Bottom, Save as Playlist) match power-user expectations. +- **Phased delivery** — Side panel → drag/drop → context menus → Save as Playlist → polish is a good order; each phase has testable exit criteria. +- **Alignment with codebase** — Uses existing `PlayerService` queue APIs (`insertNextInQueue`, `appendToQueue`, `reorderQueue(videoIds:)`); proposes an index-based `reorderQueue(from:to:)` overload that fits List reordering. + +### Issues and Corrections + +1. **API Explorer (mandatory)** + Per AGENTS.md, before implementing **Save as Playlist** or **Add to Playlist**, the following must be explored with `Tools/api-explorer.swift` and documented in `docs/api-discovery.md`: + - `playlist/create` — request/response shape, error codes (quota, auth). + - `playlist/get_add_to_playlist` — structure of playlists returned for "Add to" menu. + - `browse/edit_playlist` — how to add videos to an existing playlist. + Do not implement parsers or client methods from guesswork. + +2. **`removeFromQueue` signature** + Existing API is `removeFromQueue(videoIds: Set)`. Use `Set([song.videoId])` (or `[song.videoId] as Set`), not `[song.videoId]`, when calling from context menus. + +3. **`insertNextInQueue` / `appendToQueue` are synchronous** + They are not `async`. Use `playerService.insertNextInQueue([song])` (and same for `appendToQueue`) directly; no `Task { await ... }` and no `await`. If UI must not block, wrap in `Task { @MainActor in ... }` without `await` on the call. + +4. **`isCurrentTrack(song:)`** + Not present on `PlayerService`. Either add a helper (e.g. `currentTrack?.videoId == song.videoId` or `currentTrack?.id == song.id`) or use that expression inline in the context menu. + +5. **YTMusicClient in sheets/menus** + `SaveQueueAsPlaylistSheet` and `AddToPlaylistMenu` need access to `YTMusicClient`. Prefer `@Environment(PlayerService.self)` and use `playerService.ytMusicClient` if exposed, or inject `YTMusicClient` via environment and document the requirement. Do not assume a global client. + +6. **QueueRowView drag/drop and `PlayerService`** + The proposed `QueueRowView` uses `findSong(by:)` and `handleDrop`; it must have access to the current queue and reorder (e.g. `@Environment(PlayerService.self)`). Ensure the row does not capture a stale queue snapshot; read from `playerService.queue` at use time. + +7. **Save as Playlist — task and cancellation** + The save button starts a `Task` and sets `isSaving = false` only in the `catch` block. On success, `dismiss()` is called without resetting `isSaving`. Prefer: store the task, set `isSaving = false` in both success and failure paths, and cancel the task on dismiss (or use a task that checks cancellation). Avoid fire-and-forget without error handling (AGENTS.md). + +8. **ForEach identity** + Existing `QueueView` uses `ForEach(Array(queue.enumerated()), id: \.element.videoId)`. Prefer `id: \.element.id` if `Song.id` is the canonical stable identifier; otherwise document that `videoId` is unique in the queue. + +9. **Toast feedback** + Phase 3 exit criteria mention "Toast feedback on action completion". The codebase may not have a shared toast system. Either add a short spec (e.g. transient message in player bar or a dedicated overlay) or mark toast as optional and drop it from exit criteria until a pattern exists. + +10. **Reorder semantics** + The proposed `reorderQueue(from: IndexSet, to: Int)` should: (1) forbid moving the **current** track (source must not contain `currentIndex`); (2) forbid dropping onto the current track’s index if that would change which song is "now playing". The snippet’s `guard destination != currentIndex` is a start; also guard that `source` does not contain `currentIndex`. + +### Additions + +- **Accessibility** — Phase 2 exit criteria should explicitly require VoiceOver labels for the drag handle (e.g. "Reorder, song title") and for drop targets. Phase 5 already mentions VoiceOver; ensure drag/drop is covered. +- **Clear confirmation** — Phase 5 says "Clear shows confirmation for >10 items". Align with existing `QueueView` behavior (destructive "Clear" without confirmation); if adding confirmation, specify copy (e.g. "Clear 12 songs from queue?") and destructive action. +- **Queue persistence** — Recommend as a **follow-up** (see [Queue persistence and undo](#queue-persistence-and-undo-follow-up) below). Use file-based JSON in Application Support (same pattern as `FavoritesManager`); no SQLite needed for a small history. + +### Removals / Simplifications + +- **`QueueContextMenu(song: nil)` on header** — The header context menu with "Shuffle Queue" / "Clear Queue" is redundant if the footer already has those actions; consider removing the header context menu or keeping it for power users. Not a removal from the doc, but an optional simplification. +- **`lastQueueAction` / `queueActionTimestamp`** — The "Queue intent tracking for UI feedback" state is unused in the spec. Either remove from the proposal or add one sentence on how it will drive UI (e.g. toast or inline message). Removed from the proposed state below to avoid dead code. + +### Optional: Side panel vs overlay + +- The plan uses "popup" vs "side panel". Today the queue is already shown in the **right sidebar overlay** in `MainWindow`. Clarify whether "side panel" is (a) a different **view** in the same overlay (e.g. wider, with drag/drop), or (b) a separate **NavigationSplitView** column. If (a), the toggle simply swaps `QueueView` (compact) vs `QueueSidePanelView` (full) in the same overlay; if (b), layout and navigation changes are required. + +--- + +## Current State Analysis + +### Existing Functionality + +| Feature | Status | Implementation | +|---------|--------|----------------| +| Play queue from index | ✅ Working | `PlayerService+Queue.swift:197-205` | +| Play with Radio/Mix | ✅ Working | `PlayerService+Queue.swift:22-79` | +| Append to queue | ✅ Working | `PlayerService+Queue.swift:284-288` | +| Insert after current | ✅ Working | `PlayerService+Queue.swift:209-214` | +| Remove from queue | ✅ Working | `PlayerService+Queue.swift:218-232` | +| Reorder queue | ✅ Working | `PlayerService+Queue.swift:236-260` | +| Shuffle queue | ✅ Working | `PlayerService+Queue.swift:263-280` | +| Clear queue | ✅ Working | `PlayerService+Queue.swift:181-194` | +| Queue popup UI | ✅ Working | `QueueView.swift` | + +### Gaps Identified + +| Gap | Impact | Priority | +|-----|--------|----------| +| No side panel mode | Limited visibility for queue management | High | +| No drag/drop reordering | Manual reorder tedious for large queues | High | +| No "Play next" context menu | Users can't easily queue songs for immediate play | High | +| No "Add to bottom" context menu | Can't build queue while browsing | High | +| No "Save as Playlist" | Can't persist queue for later | Medium | +| Queue opens as popup only | Limits discoverability of features | Medium | + +### Current Queue Data Model + +**PlayerService State** (`Core/Services/Player/PlayerService.swift:79-84`): +```swift +@MainActor +@Observable +class PlayerService { + var queue: [Song] = [] + var currentIndex: Int = 0 + var showQueue: Bool = false // Controls popup visibility +} +``` + +**Song Model** (`Core/Models/Song.swift:6-71`): +```swift +struct Song: Identifiable, Codable, Hashable, Sendable { + let id: String + let title: String + let artists: [Artist] + let album: Album? + let duration: TimeInterval? + let thumbnailURL: URL? + let videoId: String +} +``` + +--- + +## Proposed Architecture + +### New Queue Mode System + +Introduce a `QueueDisplayMode` enum to support both popup and side panel modes: + +```swift +enum QueueDisplayMode: String, Codable, CaseIterable, Sendable { + case popup + case sidepanel + + var displayName: String { + switch self { + case .popup: return "Popup" + case .sidepanel: return "Side Panel" + } + } + + var description: String { + switch self { + case .popup: return "Compact overlay view" + case .sidepanel: return "Full-width panel with reordering" + } + } +} +``` + +### Extended PlayerService State + +```swift +@MainActor +@Observable +class PlayerService { + // ... existing state ... + var queueDisplayMode: QueueDisplayMode = .popup + var isSidePanelPresented: Bool = false + + /// Returns true if the given song is the current track. + func isCurrentTrack(_ song: Song) -> Bool { + currentTrack?.videoId == song.videoId + } + + func toggleQueueDisplayMode() { + if queueDisplayMode == .popup { + queueDisplayMode = .sidepanel + isSidePanelPresented = true + } else { + queueDisplayMode = .popup + isSidePanelPresented = false + } + } +} +``` + +--- + +## Feature Specifications + +### 1. Side Panel Mode + +#### UI Implementation + +**New `QueueSidePanelView.swift`**: + +```swift +struct QueueSidePanelView: View { + @Environment(PlayerService.self) var playerService + @State private var draggedSong: Song? + @Binding var isPresented: Bool + + var body: some View { + VStack(spacing: 0) { + // Header + QueueSidePanelHeader(isPresented: $isPresented) + + Divider() + + // Queue list with drag/drop + QueueListView( + songs: playerService.queue, + currentIndex: playerService.currentIndex, + draggedSong: $draggedSong, + onReorder: handleReorder, + onPlayAt: playerService.playFromQueue(at:) + ) + + Divider() + + // Footer actions + QueueFooterActions() + } + .frame(width: 350) + .glassEffect(.regular, in: .rect) + } + + private func handleReorder(from source: IndexSet, to destination: Int) { + playerService.reorderQueue(from: source, to: destination) + } +} +``` + +#### Header Component + +```swift +struct QueueSidePanelHeader: View { + @Environment(PlayerService.self) var playerService + @Binding var isPresented: Bool + @State private var showingClearConfirmation = false + + var body: some View { + HStack { + Text("Up Next") + .font(.headline) + .foregroundStyle(.primary) + + Spacer() + + // Song count + Text("\(playerService.queue.count) songs") + .font(.caption) + .foregroundStyle(.secondary) + + // Collapse button + Button { + isPresented = false + } label: { + Image(systemName: "sidebar.right") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(.horizontal) + .padding(.vertical, 12) + .contextMenu { + QueueContextMenu(song: nil) + } + } +} +``` + +#### Footer Actions Component + +```swift +struct QueueFooterActions: View { + @Environment(PlayerService.self) var playerService + @State private var showingSaveDialog = false + + var body: some View { + HStack(spacing: 12) { + // Shuffle + Button { + playerService.shuffleQueue() + } label: { + Label("Shuffle", systemImage: "shuffle") + } + .disabled(playerService.queue.isEmpty) + + // Clear + Button(role: .destructive) { + playerService.clearQueue() + } label: { + Label("Clear", systemImage: "trash") + } + .disabled(playerService.currentIndex >= playerService.queue.count - 1) + + // Save as Playlist + Button { + showingSaveDialog = true + } label: { + Label("Save as Playlist", systemImage: "plus.square.on.square") + } + .disabled(playerService.queue.isEmpty) + + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 8) + .sheet(isPresented: $showingSaveDialog) { + SaveQueueAsPlaylistSheet() + } + } +} +``` + +--- + +### 2. Drag and Drop Reordering + +#### QueueRowView Enhancement + +Extend existing `QueueRowView` (`QueueView.swift:124-242`) with draggable capability: + +```swift +struct QueueRowView: View { + let song: Song + let index: Int + let isCurrentTrack: Bool + @Binding var draggedSong: Song? + + var body: some View { + HStack(spacing: 12) { + // Drag handle (only visible in side panel mode) + if !isCurrentTrack { + Image(systemName: "line.3.horizontal") + .foregroundStyle(.tertiary) + .font(.caption) + .draggable(song.id) { + HStack { + Image(systemName: "line.3.horizontal") + .foregroundStyle(.secondary) + songRowContent + } + .padding(8) + .background(.quaternary) + .clipShape(.rect(cornerRadius: 8)) + } + } else { + Spacer() + .frame(width: 20) + } + + songRowContent + + Spacer() + + // Context menu trigger + contextMenuButton + } + .contentShape(.rect) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .hoverEffect(.highlight) + .background(isCurrentTrack ? Color.accentColor.opacity(0.1) : Color.clear) + .clipShape(.rect(cornerRadius: 8)) + .dropDestination(for: String.self) { droppedIds, _ in + guard let droppedId = droppedIds.first, + let droppedSong = findSong(by: droppedId) else { + return false + } + handleDrop(droppedSong, onto: song) + return true + } + } + + @ViewBuilder + private var songRowContent: some View { + // Thumbnail + AsyncImage(url: song.thumbnailURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Rectangle() + .fill(.tertiary) + } + .frame(width: 44, height: 44) + .clipShape(.rect(cornerRadius: 6)) + + // Title and artist + VStack(alignment: .leading, spacing: 2) { + Text(song.title) + .font(.body) + .lineLimit(1) + .foregroundStyle(isCurrentTrack ? .primary : .primary) + + Text(song.artists.map(\.name).joined(separator: ", ")) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + // Duration + if let duration = song.duration { + Text(formatDuration(duration)) + .font(.caption) + .foregroundStyle(.tertiary) + .monospacedDigit() + } + + // Now playing indicator + if isCurrentTrack { + WaveformView() + .frame(width: 20, height: 20) + } + } + + @ViewBuilder + private var contextMenuButton: some View { + Button { + // Show context menu via NSMenu + } label: { + Image(systemName: "ellipsis") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .menuIndicator(.hidden) + .popover(isPresented: .constant(false)) { + EmptyView() + } + .contextMenu { + QueueContextMenu(song: song) + } + } + + private func handleDrop(droppedSong: Song, onto targetSong: Song) { + // Calculate new index and trigger reorder + guard let droppedIndex = playerService.queue.firstIndex(where: { $0.id == droppedSong.id }), + let targetIndex = playerService.queue.firstIndex(where: { $0.id == targetSong.id }) else { + return + } + + let adjustedTarget = targetIndex > droppedIndex ? targetIndex - 1 : targetIndex + playerService.reorderQueue(from: IndexSet(integer: droppedIndex), to: adjustedTarget) + } +} +``` + +#### Reorder Service Integration + +```swift +extension PlayerService { + func reorderQueue(from source: IndexSet, to destination: Int) { + guard !source.contains(currentIndex) else { + DiagnosticsLogger.logWarning("Cannot reorder: cannot move current track") + return + } + guard destination != currentIndex else { + DiagnosticsLogger.logWarning("Cannot reorder: destination is current track") + return + } + + var newQueue = queue + newQueue.move(fromOffsets: source, toOffset: destination) + + // Adjust currentIndex if needed + let oldCurrent = queue[currentIndex] + if let newCurrentIndex = newQueue.firstIndex(where: { $0.id == oldCurrent.id }) { + currentIndex = newCurrentIndex + } + + queue = newQueue + DiagnosticsLogger.logInfo("Queue reordered: moved from \(source) to \(destination)") + } +} +``` + +--- + +### 3. Context Menu Actions + +#### New "Play Next" Action + +Add to `SongActionsHelper.swift` or create new `QueueActionsHelper.swift`: + +```swift +@MainActor +func playNext(song: Song, playerService: PlayerService) { + playerService.insertNextInQueue([song]) + DiagnosticsLogger.logInfo("Play next: \(song.title)") +} + +@MainActor +func addToBottom(song: Song, playerService: PlayerService) { + playerService.appendToQueue([song]) + DiagnosticsLogger.logInfo("Added to bottom: \(song.title)") +} +``` + +#### Extended SongContextMenu + +```swift +struct QueueContextMenu: View { + let song: Song? + @Environment(PlayerService.self) var playerService + + var body: some View { + if let song = song { + // Play Next + Button { + playerService.insertNextInQueue([song]) + } label: { + Label("Play Next", systemImage: "text.insert") + } + + // Add to Bottom + Button { + playerService.appendToQueue([song]) + } label: { + Label("Add to Bottom of Queue", systemImage: "plus") + } + + Divider() + + // Play Radio from here + StartRadioContextMenu(song: song) + + Divider() + + // Save to Playlist + AddToPlaylistMenu(song: song) + + Divider() + + // Existing: Favorites + FavoritesContextMenu(song: song) + + // Existing: Like/Dislike + LikeDislikeContextMenu(song: song) + + Divider() + + // Remove from Queue (only if not current) + if !playerService.isCurrentTrack(song) { + Button(role: .destructive) { + playerService.removeFromQueue(videoIds: Set([song.videoId])) + } label: { + Label("Remove from Queue", systemImage: "trash") + } + } + + Divider() + + // Share + ShareContextMenu(song: song) + } else { + // Queue header context menu + Button { + playerService.shuffleQueue() + } label: { + Label("Shuffle Queue", systemImage: "shuffle") + } + + Button(role: .destructive) { + playerService.clearQueue() + } label: { + Label("Clear Queue", systemImage: "trash") + } + } + } +} +``` + +#### Add to Playlist Menu + +```swift +struct AddToPlaylistMenu: View { + let song: Song + @Environment(PlayerService.self) var playerService + @State private var playlists: [Playlist] = [] + @State private var showingCreateDialog = false + + var body: some View { + Menu("Add to Playlist") { + ForEach(playlists) { playlist in + Button { + Task { await addToPlaylist(playlist) } + } label: { + Label(playlist.title, systemImage: "music.note.list") + } + } + + Divider() + + Button { + showingCreateDialog = true + } label: { + Label("New Playlist...", systemImage: "plus") + } + } + .task { + await loadPlaylists() + } + .sheet(isPresented: $showingCreateDialog) { + CreatePlaylistSheet(songToAdd: song) + } + } + + private func loadPlaylists() async { + guard let client = playerService.ytMusicClient else { return } + do { + playlists = try await client.fetchUserPlaylists() + } catch { + DiagnosticsLogger.logError("Failed to load playlists: \(error)") + } + } + + private func addToPlaylist(_ playlist: Playlist) async { + guard let client = playerService.ytMusicClient else { return } + do { + try await client.addToPlaylist(playlistId: playlist.id, videoIds: [song.videoId]) + DiagnosticsLogger.logInfo("Added \(song.title) to \(playlist.title)") + } catch { + DiagnosticsLogger.logError("Failed to add to playlist: \(error)") + } + } +} +``` + +--- + +### 4. Save Queue as Playlist + +#### API Integration + +```swift +extension YTMusicClient { + func createPlaylist(title: String, videoIds: [String], description: String = "", privacyStatus: String = "PRIVATE") async throws { + let body: [String: Any] = [ + "title": title, + "description": description, + "privacyStatus": privacyStatus, + "videoIds": videoIds + ] + let response = try await Self.request("playlist/create", body: body) + DiagnosticsLogger.logInfo("Created playlist: \(title) with \(videoIds.count) songs") + return response + } +} +``` + +#### Save Dialog + +```swift +struct SaveQueueAsPlaylistSheet: View { + @Environment(\.dismiss) var dismiss + @Environment(PlayerService.self) var playerService + @State private var playlistName = "" + @State private var privacyStatus = "PRIVATE" + @State private var isSaving = false + @State private var error: Error? + + var body: some View { + VStack(spacing: 20) { + Text("Save Queue as Playlist") + .font(.headline) + + TextField("Playlist Name", text: $playlistName) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + + Picker("Privacy", selection: $privacyStatus) { + Text("Private").tag("PRIVATE") + Text("Unlisted").tag("UNLISTED") + Text("Public").tag("PUBLIC") + } + .pickerStyle(.segmented) + + if let error = error { + Text(error.localizedDescription) + .font(.caption) + .foregroundStyle(.red) + } + + HStack { + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.escape) + + Spacer() + + Button("Save") { + savePlaylist() + } + .keyboardShortcut(.return) + .disabled(playlistName.isEmpty || isSaving) + } + } + .padding() + .frame(width: 400) + .task { + // Pre-fill with timestamp + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm" + playlistName = "Queue \(formatter.string(from: Date()))" + } + } + + private func savePlaylist() { + isSaving = true + Task { @MainActor in + do { + let videoIds = playerService.queue.map(\.videoId) + guard let client = self.playerService.ytMusicClient else { + self.error = YTMusicError.authExpired + self.isSaving = false + return + } + try await client.createPlaylist( + title: playlistName, + videoIds: videoIds, + privacyStatus: privacyStatus + ) + self.isSaving = false + dismiss() + } catch { + self.error = error + self.isSaving = false + } + } + } +} +``` + +--- + +### 5. Queue Persistence + +Add persistence for queue display mode and expanded state: + +```swift +extension UserDefaults { + private enum Keys { + static let queueDisplayMode = "kaset.queue.displayMode" + static let queueExpanded = "kaset.queue.expanded" + } + + var queueDisplayMode: QueueDisplayMode { + get { + guard let raw = string(forKey: Keys.queueDisplayMode), + let mode = QueueDisplayMode(rawValue: raw) else { + return .popup + } + return mode + } + set { + set(newValue.rawValue, forKey: Keys.queueDisplayMode) + } + } + + var queueExpanded: Bool { + get { bool(forKey: Keys.queueExpanded) } + set { set(newValue, forKey: Keys.queueExpanded) } + } +} +``` + +--- + +## Implementation Phases + +### Phase 1: Side Panel Foundation + +**Deliverables:** +- `QueueDisplayMode` enum and state management +- `QueueSidePanelView` scaffold with header/footer +- Toggle button to switch between popup and side panel +- State persistence + +**Exit Criteria:** +- [ ] Toggle button appears in QueueView header +- [ ] Side panel opens with correct width (350pt) and glass effect +- [ ] Switching modes persists across app restarts +- [ ] Build succeeds with `xcodebuild -scheme Kaset -destination 'platform=macOS' build` + +--- + +### Phase 2: Drag and Drop Reordering + +**Deliverables:** +- `draggable()` modifier on QueueRowView +- `dropDestination()` for reordering +- Visual feedback during drag (opacity change, scale) +- Current track exclusion from drag (can't move playing song) + +**Exit Criteria:** +- [ ] Drag handle visible on non-current songs in side panel +- [ ] Songs can be reordered via drag +- [ ] Current track cannot be dragged; reorder cannot drop onto current index +- [ ] Animation smooth on drop +- [ ] VoiceOver: drag handle has accessibility label (e.g. "Reorder, [song title]") +- [ ] Unit tests pass for `reorderQueue(from:to:)` logic + +--- + +### Phase 3: Context Menu Actions + +**Deliverables:** +- "Play Next" action in all song context menus +- "Add to Bottom of Queue" action in all song context menus +- Integration with existing context menu infrastructure +- Toast feedback on action completion + +**Exit Criteria:** +- [ ] "Play Next" appears in LibraryView, SearchView, HomeView song lists +- [ ] "Add to Bottom" appears in same locations +- [ ] Both actions work from QueueView (side panel) context menu +- [ ] Optional: toast/notification feedback (if a shared toast pattern exists; otherwise omit) +- [ ] `swiftlint --strict && swiftformat .` passes + +--- + +### Phase 4: Save as Playlist + +**Prerequisite:** API Explorer run for `playlist/create`; response and error handling documented in `docs/api-discovery.md`. + +**Deliverables:** +- "Save as Playlist" button in side panel footer +- SaveQueueAsPlaylistSheet dialog +- `createPlaylist` API integration (after endpoint verified) +- Error handling for quota exceeded, network failure, auth expired + +**Exit Criteria:** +- [ ] Button disabled when queue empty +- [ ] Dialog allows naming and privacy selection +- [ ] Playlist created successfully in YouTube Music +- [ ] Error shown if playlist creation fails + +--- + +### Phase 5: Queue Footer Actions Polish + +**Deliverables:** +- Shuffle queue (already exists, wire to footer) +- Clear queue confirmation if non-destructive +- Animation polish for queue operations +- Accessibility labels for VoiceOver + +**Exit Criteria:** +- [ ] Footer actions accessible via keyboard +- [ ] Shuffle maintains current track position +- [ ] Clear shows confirmation for >10 items +- [ ] VoiceOver announces queue changes + +--- + +## File Changes Summary + +### New Files + +| File | Purpose | +|------|---------| +| `Views/macOS/QueueSidePanelView.swift` | Side panel container | +| `Views/macOS/QueueRowView+Drag.swift` | Drag/drop modifiers for rows | +| `Views/macOS/QueueFooterActions.swift` | Footer with Shuffle/Save/Clear | +| `Views/macOS/QueueContextMenu+Actions.swift` | Extended context menu | +| `Views/macOS/AddToPlaylistMenu.swift` | Add to playlist submenu | +| `Views/macOS/SaveQueueAsPlaylistSheet.swift` | Save dialog | +| `Views/macOS/QueueSidePanelHeader.swift` | Side panel header | + +### Modified Files + +| File | Changes | +|------|---------| +| `Core/Services/Player/PlayerService.swift` | Add `queueDisplayMode`, `isSidePanelPresented` | +| `Core/Services/Player/PlayerService+Queue.swift` | Add `reorderQueue(from:to:)` overload | +| `Views/macOS/QueueView.swift` | Add toggle button, header improvements | +| `Views/macOS/SharedViews/QueueRowView.swift` | Add draggable, new layout options | +| `Views/macOS/SharedViews/SongActionsHelper.swift` | Add `playNext`, `addToBottom` | + +--- + +## Testing Strategy + +### Unit Tests (Swift Testing) + +```swift +@Suite +struct QueueReorderTests { + @Test func reorderQueue_maintainsCurrentTrack() { + let service = PlayerService() + service.queue = [song1, song2, song3] + service.currentIndex = 1 // song2 + + service.reorderQueue(from: IndexSet(integer: 0), to: 2) + + #expect(service.currentIndex == 1) // Unchanged + #expect(service.queue == [song2, song3, song1]) + } + + @Test func reorderQueue_fromCurrentIndex_failsGracefully() { + let service = PlayerService() + service.queue = [song1, song2, song3] + service.currentIndex = 0 + + service.reorderQueue(from: IndexSet(integer: 0), to: 1) + + #expect(service.queue == [song1, song2, song3]) // Unchanged + } +} +``` + +### Integration Tests + +- [ ] Queue persists when app relaunches +- [ ] Side panel mode persists +- [ ] Context menu actions work from all song lists +- [ ] Drag reordering works with 50+ songs +- [ ] Save as playlist creates correctly named playlist + +--- + +## API Dependencies + +| Feature | Endpoint | Auth Required | +|---------|----------|---------------| +| Save as Playlist | `playlist/create` | ✅ Yes | +| Add to Playlist | `browse/edit_playlist` | ✅ Yes | +| Fetch Playlists | `playlist/get_add_to_playlist` | ✅ Yes | + +No new API endpoints required—all features use existing YouTube Music API. + +**Before implementing Phase 4 (Save as Playlist) or Add to Playlist menu:** Run `./Tools/api-explorer.swift` to exercise `playlist/create`, `playlist/get_add_to_playlist`, and `browse/edit_playlist`; document request/response shapes and errors in `docs/api-discovery.md`. Do not implement client methods or parsers from guesswork (see AGENTS.md). + +--- + +## Performance Considerations + +1. **Queue Rendering**: Use `LazyVStack` for queue list (already in place) +2. **Drag Performance**: Debounce rapid reorder operations +3. **Context Menu**: Load playlists asynchronously to not block menu opening +4. **Memory**: Queue is typically <100 songs; no pagination needed + +--- + +## Accessibility + +- All actions keyboard accessible (shortcuts in menu) +- VoiceOver labels on drag handles +- Dynamic type support for text sizes +- Focus ring on keyboard navigation + +--- + +## Open Questions and Recommendations + +1. **Queue persist across app restarts?** **Recommendation: Implement as follow-up.** See [Queue persistence and undo](#queue-persistence-and-undo-follow-up) for a file-based design (no SQLite). Restore on launch + "Restore previous queue" (undo) with a history of 3 queues is feasible and matches common macOS patterns. + +2. **"Play Next" auto-expand the queue panel?** Optional UX polish: when the user chooses "Play Next" from a list, consider briefly opening or highlighting the queue (e.g. set `showQueue = true`) so they see the result. Not required for Phase 3. + +3. **Shuffle affect current track?** Current implementation keeps the current track at the front (index 0). **Recommendation: keep as-is** — matches common expectations (now playing stays, rest shuffle). + +4. **"Save as Playlist" — full queue or pending only?** **Recommendation: save full queue** (all `playerService.queue` by default). Optionally in a later iteration, add a control to "Save only upcoming" (songs after `currentIndex`). Document the chosen behavior in the Save sheet (e.g. "Saves all X songs in the queue"). + +--- + +## Queue persistence and undo (follow-up) + +Persisting the queue across app restarts and offering **"Restore previous queue"** (undo) when the user accidentally replaces the queue is feasible without a database. The recommended approach is **file-based storage** in Application Support, matching the existing `FavoritesManager` pattern. SQLite is unnecessary for a single current queue plus a small history of 3 snapshots. + +### Why file-based (JSON) instead of SQLite? + +| Approach | Typical use on macOS | For queue + 3 history | +|--------|-----------------------|------------------------| +| **JSON/PropertyList in Application Support** | App state, last-used data, small lists | ✅ Ideal: one file for "last queue", one for "history" (or combined). No schema, Codable, same pattern as `FavoritesManager`. | +| **UserDefaults** | Preferences, small key-value | Possible but better to keep prefs separate from "documents"; Application Support is clearer. | +| **SQLite** | Large datasets, search, relations | Overkill for 3 queue snapshots; adds dependency or C API usage. Common in Mail, Safari, etc., not for tiny state. | +| **SwiftData / Core Data** | Rich object graphs, many entities | Heavier than needed for a few arrays of songs. | + +**Conclusion:** Use **Application Support + JSON** (Codable). It’s the most common simple storage for this kind of state in macOS apps and keeps the implementation minimal. + +### Two features + +1. **Persistence** — Save the current queue (and `currentIndex`) when the app goes to background or terminates; restore on next launch so the user resumes with the same queue. +2. **Undo ("Restore previous queue")** — Keep a **history of the last 3 queues**. Whenever something *replaces* the queue (e.g. Play Album, Play Radio, Play Mix, or Clear), push the current queue into history (if it’s worth keeping) before replacing. Expose a "Restore previous queue" action (toolbar, queue header, or menu) that restores the most recent history entry and removes it from history. No need for a separate "redo" unless you want "restore next" in a stack; "last 3" usually means restore #1, then #2, then #3. + +### When to push current queue to history + +Push the current queue to history **only when it is about to be replaced** (and is non-empty and not already identical to the new queue). Hook into: + +- `playQueue(_:startingAt:)` — before `self.queue = songs` +- `playWithRadio(song:)` — before `self.queue = [song]` +- `playWithMix(playlistId:startVideoId:)` — before setting the new queue +- `clearQueue()` — when the queue has more than one item (so "clear" replaces a real queue) + +Do **not** push to history on: `appendToQueue`, `insertNextInQueue`, `reorderQueue`, `shuffleQueue`, or when only fetching more mix songs (queue is extended, not replaced). + +### Data model (minimal snapshot) + +Store enough to restore the list and, if desired, re-fetch metadata later. For example: + +```swift +struct PersistedQueueSnapshot: Codable { + var songs: [PersistedSong] + var currentIndex: Int + var savedAt: Date + + struct PersistedSong: Codable { + var id: String + var videoId: String + var title: String + var artistsDisplay: String // or [String]; minimal for display + var duration: TimeInterval? + var thumbnailURL: URL? + } +} +``` + +Build `PersistedSong` from `Song` (or a subset of fields). On restore, either use these as the in-memory queue (if `Song` can be created from them) or use `videoId` to re-fetch metadata from the API when needed. + +### File layout (Application Support) + +- **Current queue (persist across restarts):** + `~/Library/Application Support/Kaset/queue_current.json` + Single `PersistedQueueSnapshot` (or equivalent) for the current queue + `currentIndex`. Save on resign active / terminate; load on launch. + +- **Undo history (last 3 queues):** + `~/Library/Application Support/Kaset/queue_history.json` + Array of up to 3 `PersistedQueueSnapshot` values (newest first). Before any "replace queue" action, push current snapshot to this array (trim to 3); on "Restore previous queue", pop the first and apply it, then save. + +Reuse the same `Application Support/Kaset` directory and `FileManager` patterns as `FavoritesManager` and `WebKitManager` (create directory if needed, write atomically if desired). + +### Undo UI + +- **Placement:** Queue side-panel header, or PlayerBar/queue popup: a "Restore previous queue" button (e.g. arrow.uturn.backward) and/or menu item. +- **State:** Enable only when `queueHistory.count > 0`. Optional: show "Restore previous queue (3 available)" in a tooltip. +- **Keyboard:** Optional shortcut (e.g. ⌘Z in queue context or a dedicated shortcut). + +### Edge cases + +- **Empty or single-song queue:** Optionally do not push to history when the current queue is empty or has one item, to avoid cluttering history with trivial states. +- **Stale tracks:** Restored songs might be deleted or private later. Restore by `videoId`; if playback or metadata fetch fails, show "Unavailable" or skip that item and keep the rest. +- **Launch with no file:** If `queue_current.json` is missing, keep default empty queue (current behavior). + +### Implementation outline (follow-up phase) + +1. Add `PersistedQueueSnapshot` (and `PersistedSong`) to Core/Models or a dedicated QueuePersistence module. +2. Add a `QueuePersistenceService` (or extend `PlayerService`) that: + - Reads/writes `queue_current.json` and `queue_history.json` under Application Support. + - Exposes `loadCurrentQueue()`, `saveCurrentQueue()`, `pushCurrentToHistory()`, `restorePreviousQueue()` (and optionally `canRestorePreviousQueue`). +3. In `PlayerService`, before each queue-replacing call, invoke `pushCurrentToHistory()` (if queue is non-empty and not already matching). After replacing, call `saveCurrentQueue()`. On app init, call `loadCurrentQueue()` and apply if valid. +4. Subscribe to `NSApplication.willTerminate` / `scenePhase` (or equivalent) to save current queue when app backgrounds or quits. +5. Add "Restore previous queue" to the queue UI and wire to `restorePreviousQueue()`. + +This keeps persistence and undo simple, consistent with the rest of the app, and avoids any database dependency. + +--- + +## Timeline Estimate + +| Phase | Estimated Time | +|-------|----------------| +| Phase 1: Side Panel Foundation | 2-3 days | +| Phase 2: Drag and Drop | 2-3 days | +| Phase 3: Context Menu Actions | 1-2 days | +| Phase 4: Save as Playlist | 1-2 days | +| Phase 5: Polish | 1 day | +| **Total** | **7-11 days** |