Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Sources/Kaset/Services/SettingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Sources/Kaset/Utilities/MainWindowLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions Sources/Kaset/Views/GeneralSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 28 additions & 14 deletions Sources/Kaset/Views/LegacyFallbackViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
42 changes: 28 additions & 14 deletions Sources/Kaset/Views/LyricsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
170 changes: 117 additions & 53 deletions Sources/Kaset/Views/MainWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Loading
Loading