diff --git a/Sources/Kaset/Services/SettingsManager.swift b/Sources/Kaset/Services/SettingsManager.swift index 6fffb860..180fd906 100644 --- a/Sources/Kaset/Services/SettingsManager.swift +++ b/Sources/Kaset/Services/SettingsManager.swift @@ -33,6 +33,7 @@ final class SettingsManager { static let ambientBackdropEnabled = "settings.ambientBackdropEnabled" static let ambientBackdropStyle = "settings.ambientBackdropStyle" static let popOutVideoOnNavigateAway = "settings.popOutVideoOnNavigateAway" + static let lyricsQueueDrawerEnabled = "settings.lyricsQueueDrawerEnabled" #if DEBUG static let useLegacyMacOS15UI = "settings.debug.useLegacyMacOS15UI" #endif @@ -412,6 +413,15 @@ final class SettingsManager { } } + /// When enabled, the Lyrics and Queue panels dock as a right-hand drawer that pushes the + /// content aside (like the left sidebar) instead of floating over it. Defaults to `false` + /// (the floating overlay). + var lyricsQueueDrawerEnabled: Bool { + didSet { + UserDefaults.standard.set(self.lyricsQueueDrawerEnabled, forKey: Keys.lyricsQueueDrawerEnabled) + } + } + /// The style the YouTube watch page should request: the chosen style when /// enabled, `.off` when the feature is disabled. Runtime energy/accessibility /// downgrades are applied inside `AmbientVideoBackdrop`, which observes those @@ -490,6 +500,7 @@ final class SettingsManager { ) self.ambientBackdropEnabled = UserDefaults.standard.object(forKey: Keys.ambientBackdropEnabled) as? Bool ?? true self.popOutVideoOnNavigateAway = UserDefaults.standard.object(forKey: Keys.popOutVideoOnNavigateAway) as? Bool ?? true + self.lyricsQueueDrawerEnabled = UserDefaults.standard.object(forKey: Keys.lyricsQueueDrawerEnabled) as? Bool ?? false #if DEBUG self.useLegacyMacOS15UI = UserDefaults.standard.object(forKey: Keys.useLegacyMacOS15UI) as? Bool ?? false #endif diff --git a/Sources/Kaset/Utilities/MainWindowLayout.swift b/Sources/Kaset/Utilities/MainWindowLayout.swift index ad7494dd..d1f4dad1 100644 --- a/Sources/Kaset/Utilities/MainWindowLayout.swift +++ b/Sources/Kaset/Utilities/MainWindowLayout.swift @@ -15,6 +15,10 @@ enum MainWindowLayout { static let minimumHeight: CGFloat = 600 static let defaultWidth: CGFloat = 1100 static let defaultHeight: CGFloat = 760 + /// Width of the docked lyrics/queue drawer (when the side-drawer setting is enabled). + /// Equals the docked queue-table column width (342) plus the 18pt scroller gutter reserved in + /// `QueueSidePanelView`, so docked rows fill the drawer without clipping under the scroller. + static let rightDrawerWidth: CGFloat = 360 static var minimumContentSize: NSSize { NSSize(width: minimumWidth, height: minimumHeight) diff --git a/Sources/Kaset/Views/GeneralSettingsView.swift b/Sources/Kaset/Views/GeneralSettingsView.swift index 6106ace3..e9004d90 100644 --- a/Sources/Kaset/Views/GeneralSettingsView.swift +++ b/Sources/Kaset/Views/GeneralSettingsView.swift @@ -49,6 +49,10 @@ struct GeneralSettingsView: View { Toggle("Haptic Feedback", isOn: self.$settings.hapticFeedbackEnabled) .help(String(localized: "Provide tactile feedback for actions on Force Touch trackpads")) + // Lyrics & Queue drawer + Toggle("Open lyrics & queue as a side drawer", isOn: self.$settings.lyricsQueueDrawerEnabled) + .help(String(localized: "Dock the lyrics and queue panels as a right-hand drawer that pushes the content aside, like the sidebar, instead of a floating panel")) + // Default Launch Page Picker(String(localized: "Default Page on Launch"), selection: self.$settings.defaultLaunchPage) { ForEach(SettingsManager.LaunchPage.allCases) { page in diff --git a/Sources/Kaset/Views/LegacyFallbackViews.swift b/Sources/Kaset/Views/LegacyFallbackViews.swift index 92f4ea4d..33f70235 100644 --- a/Sources/Kaset/Views/LegacyFallbackViews.swift +++ b/Sources/Kaset/Views/LegacyFallbackViews.swift @@ -229,27 +229,25 @@ struct SimpleLyricsView: View { let client: any YTMusicClientProtocol var showsHeader = true var preferredWidth: CGFloat? = 280 + /// When true, renders flush content for the docked drawer (no fixed width / glass card); + /// the drawer container supplies the background. + var docked = false @State private var lastLoadedVideoId: String? @State private var isLoadingFallback = false var body: some View { - CompatGlassContainer(spacing: 0) { - VStack(spacing: 0) { - if self.showsHeader { - HStack { - Text(String(localized: "Lyrics")) - .font(.headline) - Spacer() - } - .padding() - Divider().opacity(0.3) + Group { + if self.docked { + self.contentStack + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + CompatGlassContainer(spacing: 0) { + self.contentStack + .frame(width: self.preferredWidth) + .compatGlass(interactive: true, in: .rect(cornerRadius: 20)) } - - self.contentView } - .frame(width: self.preferredWidth) - .compatGlass(interactive: true, in: .rect(cornerRadius: 20)) } .onChange(of: self.playerService.currentTrack?.videoId) { _, newVideoId in if let videoId = newVideoId, videoId != self.lastLoadedVideoId { @@ -273,6 +271,22 @@ struct SimpleLyricsView: View { .accessibilityIdentifier(AccessibilityID.Lyrics.fallbackPanel) } + private var contentStack: some View { + VStack(spacing: 0) { + if self.showsHeader { + HStack { + Text(String(localized: "Lyrics")) + .font(.headline) + Spacer() + } + .padding() + Divider().opacity(0.3) + } + + self.contentView + } + } + @ViewBuilder private var contentView: some View { if self.playerService.currentTrack == nil { diff --git a/Sources/Kaset/Views/LyricsView.swift b/Sources/Kaset/Views/LyricsView.swift index 0768edbd..f039f810 100644 --- a/Sources/Kaset/Views/LyricsView.swift +++ b/Sources/Kaset/Views/LyricsView.swift @@ -10,6 +10,9 @@ struct LyricsView: View { let client: any YTMusicClientProtocol var showsHeader = true var preferredWidth: CGFloat? = 280 + /// When true, renders flush content for the docked drawer (no fixed width / glass card); + /// the drawer container supplies the background. + var docked = false @State private var lastLoadedVideoId: String? @State private var isLoadingFallback = false @@ -27,23 +30,20 @@ struct LyricsView: View { @Namespace private var lyricsNamespace var body: some View { - CompatGlassContainer(spacing: 0) { - VStack(spacing: 0) { - if self.showsHeader { - self.headerView - - Divider() - .opacity(0.3) + Group { + if self.docked { + self.contentStack + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + CompatGlassContainer(spacing: 0) { + self.contentStack + .frame(width: self.preferredWidth) + .compatGlass(interactive: true, in: .rect(cornerRadius: 20)) + .compatGlassID("lyricsPanel", in: self.lyricsNamespace) } - - // Content - self.contentView + .compatGlassTransition(.materialize) } - .frame(width: self.preferredWidth) - .compatGlass(interactive: true, in: .rect(cornerRadius: 20)) - .compatGlassID("lyricsPanel", in: self.lyricsNamespace) } - .compatGlassTransition(.materialize) .onChange(of: self.playerService.currentTrack?.videoId) { _, newVideoId in if let videoId = newVideoId, videoId != lastLoadedVideoId { // Reset explanation when track changes @@ -72,6 +72,20 @@ struct LyricsView: View { } } + private var contentStack: some View { + VStack(spacing: 0) { + if self.showsHeader { + self.headerView + + Divider() + .opacity(0.3) + } + + // Content + self.contentView + } + } + private func updateLyricsPolling(for result: LyricResult) { if case let .synced(synced) = result { self.playerService.currentLyricsLineIndex = nil diff --git a/Sources/Kaset/Views/MainWindow.swift b/Sources/Kaset/Views/MainWindow.swift index 4fc82b4e..1063f646 100644 --- a/Sources/Kaset/Views/MainWindow.swift +++ b/Sources/Kaset/Views/MainWindow.swift @@ -398,66 +398,35 @@ struct MainWindow: View { // swiftlint:disable:this type_body_length // MARK: - Main Content private var mainContent: some View { - ZStack(alignment: .trailing) { - // Main navigation content — sidebar and detail swap with the active source. - NavigationSplitView(columnVisibility: self.$columnVisibility) { - if self.settings.appSource == .music { - Sidebar( - selection: self.$navigationSelection, - pinnedSelection: self.$selectedSidebarPinnedItem, - client: self.client, - onReselectNavigationItem: { item in - self.sidebarNavigationReselectGenerations.wrappedValue[item, default: 0] += 1 - if item == .search { - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(100)) - self.searchFocusTrigger.wrappedValue = true - } - } - }, - onReselectPinnedItem: { item in - guard !(self.pinnedNavigationPaths[item.contentId]?.isEmpty ?? true) else { return } - self.pinnedNavigationPaths[item.contentId] = NavigationPath() - } - ) - } else { - YouTubeSidebar( - selection: self.$youtubeNavigationSelection, - onReselect: { _ in - self.youtubeStore.navigationPath = NavigationPath() - } - ) - } - } detail: { - if self.settings.appSource == .music { - self.detailView( - for: self.navigationSelection, - pinnedItem: self.selectedSidebarPinnedItem, - client: self.client - ) - } else { - YouTubeContentView( - selection: self.youtubeNavigationSelection, - store: self.youtubeStore - ) - } - } - .id(self.contentResetID) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { _ in - // Ensure the sidebar returns when the app is re-activated from the Dock or app switcher. - if self.columnVisibility != .all { - self.columnVisibility = .all + // A single stable HStack keeps the NavigationSplitView's identity across the drawer + // setting toggle (a branch swap would rebuild it and reset scroll). Floating mode + // overlays the panel on the content; drawer mode docks it as a sibling column that + // pushes the content aside like the left sidebar. + HStack(spacing: 0) { + self.navigationSplitContent + .overlay(alignment: .trailing) { + if !self.settings.lyricsQueueDrawerEnabled { + // Right sidebar overlay - either lyrics or queue (mutually exclusive) + self.rightSidebarOverlay(client: self.client) + } } - } - // Right sidebar overlay - either lyrics or queue (mutually exclusive) - self.rightSidebarOverlay(client: self.client) + if self.settings.lyricsQueueDrawerEnabled, + self.playerService.showLyrics || self.playerService.showQueue + { + self.rightDrawerColumn(client: self.client) + .transition(.move(edge: .trailing)) + } } .animation(.easeInOut(duration: 0.25), value: self.playerService.showLyrics) .animation(.easeInOut(duration: 0.25), value: self.playerService.showQueue) .frame(minWidth: MainWindowLayout.minimumWidth, minHeight: MainWindowLayout.minimumHeight) .toolbar { + // The AI / command-bar button lives in the window toolbar (titlebar line) so it keeps + // the native toolbar size and stays clickable — the titlebar strip is the OS window-drag + // region, where a manual overlay button would be swallowed. With the drawer docked (its + // header sits below the titlebar), a trailing toolbar item no longer overlaps its + // controls. if self.supportsCommandBarUI { ToolbarItem(placement: .primaryAction) { Button { @@ -475,6 +444,101 @@ struct MainWindow: View { // swiftlint:disable:this type_body_length } } + /// The main sidebar + detail navigation, shared by the floating-overlay and docked-drawer + /// layouts. + private var navigationSplitContent: some View { + NavigationSplitView(columnVisibility: self.$columnVisibility) { + if self.settings.appSource == .music { + Sidebar( + selection: self.$navigationSelection, + pinnedSelection: self.$selectedSidebarPinnedItem, + client: self.client, + onReselectNavigationItem: { item in + self.sidebarNavigationReselectGenerations.wrappedValue[item, default: 0] += 1 + if item == .search { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(100)) + self.searchFocusTrigger.wrappedValue = true + } + } + }, + onReselectPinnedItem: { item in + guard !(self.pinnedNavigationPaths[item.contentId]?.isEmpty ?? true) else { return } + self.pinnedNavigationPaths[item.contentId] = NavigationPath() + } + ) + } else { + YouTubeSidebar( + selection: self.$youtubeNavigationSelection, + onReselect: { _ in + self.youtubeStore.navigationPath = NavigationPath() + } + ) + } + } detail: { + if self.settings.appSource == .music { + self.detailView( + for: self.navigationSelection, + pinnedItem: self.selectedSidebarPinnedItem, + client: self.client + ) + } else { + YouTubeContentView( + selection: self.youtubeNavigationSelection, + store: self.youtubeStore + ) + } + } + .id(self.contentResetID) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { _ in + // Ensure the sidebar returns when the app is re-activated from the Dock or app switcher. + if self.columnVisibility != .all { + self.columnVisibility = .all + } + } + } + + /// Right drawer column (docked): lyrics or queue rendered flush against the trailing edge, + /// full height, pushing the content aside instead of floating over it. + private func rightDrawerColumn(client: any YTMusicClientProtocol) -> some View { + Group { + if self.playerService.showLyrics { + if !self.usesLegacyMacOS15UI, #available(macOS 26.0, *) { + LyricsView(client: client, docked: true) + } else { + SimpleLyricsView(client: client, docked: true) + } + } else if self.playerService.showQueue { + if self.playerService.queueDisplayMode == .sidepanel { + QueueSidePanelView(docked: true) + } else { + QueueView(docked: true) + } + } + } + .frame(width: MainWindowLayout.rightDrawerWidth) + .frame(maxHeight: .infinity) + // Extend only the material (and its leading divider) up behind the transparent titlebar so + // the drawer reads as full-height like the left sidebar. The CONTENT deliberately stays + // within the safe area (below the titlebar) — the top strip is the window's drag region, so + // pushing the Up Next header up there made its Clear/Edit controls swallow clicks as window + // drags. Keeping content below the titlebar leaves those controls clickable. + .background { + Rectangle() + .fill(.regularMaterial) + .overlay(alignment: .leading) { + // Explicit vertical hairline: a bare `Divider()` in an overlay has no stack axis + // and would render as a horizontal rule across the middle, not a leading seam. + Rectangle() + .fill(Color(nsColor: .separatorColor)) + .frame(width: 1) + .frame(maxHeight: .infinity) + } + .ignoresSafeArea(.container, edges: .top) + } + } + private func presentCommandBarIfAvailable() { guard self.supportsCommandBarUI else { return } self.isCommandBarPresented = true diff --git a/Sources/Kaset/Views/QueueSidePanelView.swift b/Sources/Kaset/Views/QueueSidePanelView.swift index 9509a8fd..75337e11 100644 --- a/Sources/Kaset/Views/QueueSidePanelView.swift +++ b/Sources/Kaset/Views/QueueSidePanelView.swift @@ -9,9 +9,42 @@ struct QueueSidePanelView: View { @Environment(FavoritesManager.self) private var favoritesManager @Environment(SongLikeStatusManager.self) private var likeStatusManager + /// When true, renders flush content for the docked drawer (no fixed width / rounded card); + /// the drawer container supplies the background. + var docked: Bool = false + + /// Width of the non-docked floating side panel. + private static let floatingPanelWidth: CGFloat = 400 + /// Trailing space reserved for the vertical scroller so it doesn't cover the duration label. + private static let scrollerGutter: CGFloat = 18 + + /// Fixed table-column width for the current layout: the container width (the docked drawer or the + /// floating panel) minus the scroller gutter. Deterministic — no dependence on live scroll-view + /// geometry, which during the drawer's slide-in transition briefly reports the full window width. + private var tableColumnWidth: CGFloat { + let container = self.docked ? MainWindowLayout.rightDrawerWidth : Self.floatingPanelWidth + return container - Self.scrollerGutter + } + 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). + // (drop target gap and acceptDrop never fire when the table is inside glass). The docked + // drawer likewise backs the panel with regular material, not glass, to keep drag working. + Group { + if self.docked { + self.contentStack + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + self.contentStack + .frame(width: Self.floatingPanelWidth) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + } + .accessibilityIdentifier(AccessibilityID.Queue.container) + } + + private var contentStack: some View { VStack(spacing: 0) { QueueSidePanelHeader() @@ -28,6 +61,7 @@ struct QueueSidePanelView: View { favoritesManager: self.favoritesManager, likeStatusManager: self.likeStatusManager, allowsLikeActions: self.authService.hasPersonalAccount, + columnWidth: self.tableColumnWidth, likeStatusEvent: self.likeStatusManager.lastLikeEvent, onSelect: { entryID in let reservation = self.playerService.reserveMusicPlaybackIntent() @@ -60,10 +94,6 @@ struct QueueSidePanelView: View { QueueFooterActions() } - .frame(width: 400) - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 20)) - .accessibilityIdentifier(AccessibilityID.Queue.container) } private var emptyQueueView: some View { @@ -96,6 +126,10 @@ struct QueueListControllerRepresentable: NSViewControllerRepresentable { let favoritesManager: FavoritesManager let likeStatusManager: SongLikeStatusManager let allowsLikeActions: Bool + /// Fixed width for the single table column. Set from known container widths (the docked drawer or + /// the floating panel) minus a scroller gutter — deterministic, so it never depends on reading + /// transient scroll-view geometry during layout. + let columnWidth: CGFloat /// Observed to refresh visible AppKit rows after optimistic like updates and rollbacks. let likeStatusEvent: LikeStatusEvent? let onSelect: (UUID) -> Void @@ -105,12 +139,20 @@ struct QueueListControllerRepresentable: NSViewControllerRepresentable { func makeNSViewController(context: Context) -> QueueListViewController { let viewController = QueueListViewController() + viewController.columnWidth = self.columnWidth viewController.coordinator = context.coordinator context.coordinator.viewController = viewController return viewController } func updateNSViewController(_ viewController: QueueListViewController, context: Context) { + viewController.columnWidth = self.columnWidth + if let column = viewController.tableView?.tableColumns.first, + abs(column.width - self.columnWidth) > 0.5 + { + column.width = self.columnWidth + } + context.coordinator.favoritesManager = self.favoritesManager context.coordinator.likeStatusManager = self.likeStatusManager context.coordinator.allowsLikeActions = self.allowsLikeActions @@ -165,6 +207,10 @@ struct QueueListControllerRepresentable: NSViewControllerRepresentable { class QueueListViewController: NSViewController { var tableView: DraggableTableView? weak var coordinator: Coordinator? + /// Fixed column width, supplied by the representable (container width minus scroller gutter). + /// The initial value is a never-used placeholder — `makeNSViewController` sets the real width + /// before `loadView` reads it. + var columnWidth: CGFloat = 0 override func loadView() { let scrollView = NSScrollView() @@ -182,15 +228,21 @@ struct QueueListControllerRepresentable: NSViewControllerRepresentable { tableView.backgroundColor = .clear tableView.allowsEmptySelection = true tableView.allowsColumnResizing = false - tableView.columnAutoresizingStyle = .uniformColumnAutoresizingStyle + // The single column uses a fixed width supplied by the representable (container width minus + // a scroller gutter). Autoresizing is off so the table never stretches the column to the + // full clip width — which would push the trailing-pinned duration under the overlay + // scroller (and, mid-transition, past the clip edge where it gets cut off entirely). + tableView.columnAutoresizingStyle = .noColumnAutoresizing 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 + // Low min / effectively-unbounded max: the width is set explicitly to `columnWidth`, and + // these only guard against absurd values — they must never clamp it ABOVE the visible width. + column.minWidth = 200 + column.maxWidth = 10000 + column.width = self.columnWidth tableView.addTableColumn(column) let dragType = NSPasteboard.PasteboardType("com.kaset.queueitem") diff --git a/Sources/Kaset/Views/QueueView.swift b/Sources/Kaset/Views/QueueView.swift index b60b421c..f1284199 100644 --- a/Sources/Kaset/Views/QueueView.swift +++ b/Sources/Kaset/Views/QueueView.swift @@ -12,26 +12,41 @@ struct QueueView: View { /// Namespace for glass effect morphing. @Namespace private var queueNamespace - var body: some View { - CompatGlassContainer(spacing: 0) { - VStack(spacing: 0) { - // Header - self.headerView - - Divider() - .opacity(0.3) + /// When true, renders flush content for the docked drawer (no fixed width / glass card); + /// the drawer container supplies the background. + var docked: Bool = false - // Content - self.contentView + var body: some View { + Group { + if self.docked { + self.contentStack + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + CompatGlassContainer(spacing: 0) { + self.contentStack + .frame(width: 280) + .compatGlass(interactive: true, in: .rect(cornerRadius: 20)) + .compatGlassID("queuePanel", in: self.queueNamespace) + } + .compatGlassTransition(.materialize) } - .frame(width: 280) - .compatGlass(interactive: true, in: .rect(cornerRadius: 20)) - .compatGlassID("queuePanel", in: self.queueNamespace) } - .compatGlassTransition(.materialize) .accessibilityIdentifier(AccessibilityID.Queue.container) } + private var contentStack: some View { + VStack(spacing: 0) { + // Header + self.headerView + + Divider() + .opacity(0.3) + + // Content + self.contentView + } + } + // MARK: - Header private var headerView: some View {