feat: add expandable compact queue and sidebar shortcut

This commit is contained in:
2026-10-22 18:11:13 +02:00
parent 6ed7db8167
commit 98e6dcb3c5
11 changed files with 330 additions and 39 deletions
+60 -11
View File
@@ -41,9 +41,14 @@ final class PlayerModel: ObservableObject {
@Published private(set) var startContentHeight: CGFloat = 400
@Published var showsQueue = false {
didSet {
if showsQueue != oldValue { transitionQueue() }
guard showsQueue != oldValue else { return }
if isCompact { queueWidth = showsQueue ? 260 : 0 }
else { transitionQueue() }
}
}
@Published private(set) var showsCompactQueue = false
var queueVisible: Bool { isCompact ? showsCompactQueue : showsQueue }
var queueAction: PlayerAction { queueVisible ? .hideQueue : .showQueue }
@Published private(set) var queueWidth: CGFloat = 0
private var queueAnimation: Timer?
private var queueAnimationCompletion: (() -> Void)?
@@ -156,7 +161,7 @@ final class PlayerModel: ObservableObject {
var compactContentMinimumHeight: CGFloat {
guard let window else { return 90 }
// Hosting-view minimums include the native titlebar safe area, even with full-size content.
return max(0, 90 - (window.frame.height - window.contentLayoutRect.height))
return max(0, 90 + compactWindow.queueHeight - (window.frame.height - window.contentLayoutRect.height))
}
let videoView: NSView?
@@ -1013,6 +1018,7 @@ final class PlayerModel: ObservableObject {
selectSubtitle(nil)
subtitleTracks = []
compactIntent = nil
finishQueueTransition()
compactWindow.restore(resumeFullScreen: false, animated: false)
fileName = nil
fileIcon = nil
@@ -1077,6 +1083,21 @@ final class PlayerModel: ObservableObject {
queueAnimation?.invalidate()
queueAnimation = nil
queueAnimationCompletion = nil
if isCompact {
guard let window, let target = compactWindow.queueLayout(visible: showsCompactQueue) else { return }
let startFrame = window.frame
let startHeight = compactWindow.queueHeight
let update: @MainActor (Double) -> Void = { [weak self] progress in
let frame = NSRect(x: startFrame.minX + (target.frame.minX - startFrame.minX) * progress,
y: startFrame.minY + (target.frame.minY - startFrame.minY) * progress,
width: startFrame.width + (target.frame.width - startFrame.width) * progress,
height: startFrame.height + (target.frame.height - startFrame.height) * progress)
self?.compactWindow.updateQueue(height: startHeight + (target.height - startHeight) * progress, frame: frame)
}
queueAnimationCompletion = { update(1) }
animateQueue(update)
return
}
let targetWidth: CGFloat = showsQueue ? 260 : 0
guard let window, fileName != nil, !isCompact, !compactWindow.isTransitioning else {
queueWidth = targetWidth
@@ -1114,6 +1135,10 @@ final class PlayerModel: ObservableObject {
window.aspectRatio = NSSize(width: self.state.video_aspect_ratio, height: 1)
}
}
animateQueue(update)
}
private func animateQueue(_ update: @escaping @MainActor (Double) -> Void) {
guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else {
finishQueueTransition()
return
@@ -1223,6 +1248,9 @@ final class PlayerModel: ObservableObject {
case .quit: return true
case .showBufferInfo: return !showsBufferInfo
case .hideBufferInfo: return showsBufferInfo
case .showQueue, .hideQueue:
return window != nil && !compactWindow.isTransitioning &&
(action == .showQueue ? !queueVisible : queueVisible)
case .enableCompactSnapping: return !compactWindow.snappingEnabled
case .disableCompactSnapping: return compactWindow.snappingEnabled
case .enableAutomaticQuality: return !automaticYouTubeQuality
@@ -1262,12 +1290,14 @@ final class PlayerModel: ObservableObject {
revealControls()
case .enterCompact:
finishQueueTransition()
showsCompactQueue = false
compactIntent = true
compactWindow.toggle(aspect: state.video_aspect_ratio)
controlsHovered = false
titleHovered = false
revealControls()
case .exitCompact:
finishQueueTransition()
compactIntent = false
compactWindow.restore()
revealControls()
@@ -1281,6 +1311,14 @@ final class PlayerModel: ObservableObject {
case .quit: NSApp.terminate(nil)
case .showBufferInfo: setShowsBufferInfo(true)
case .hideBufferInfo: setShowsBufferInfo(false)
case .showQueue, .hideQueue:
if isCompact {
showsCompactQueue = action == .showQueue
transitionQueue()
} else {
showsQueue = action == .showQueue
}
revealControls()
case .enableCompactSnapping: compactWindow.snappingEnabled = true
case .disableCompactSnapping: compactWindow.snappingEnabled = false
case .enableAutomaticQuality: setAutomaticYouTubeQuality(true)
@@ -2896,7 +2934,8 @@ private struct PlayerContent: View {
Menu {
PlayerActionButton(model: model, action: .openFile, title: "Open File…")
Button("Add to Queue…", action: model.addQueuePanel).disabled(!model.canOpen)
Button(model.showsQueue ? "Hide Queue" : "Show Queue") { model.showsQueue.toggle() }
PlayerActionButton(model: model, action: model.queueAction,
title: model.queueVisible ? "Hide Queue" : "Show Queue")
Button("Previous Queue Item") { model.advanceQueue(-1) }.disabled(!model.canPreviousQueueItem)
Button("Next Queue Item") { model.advanceQueue(1) }.disabled(!model.canNextQueueItem)
PlayerActionButton(model: model, action: .openStream, title: "Open Stream…")
@@ -3212,7 +3251,7 @@ private struct PlayerContent: View {
ScrollView {
VStack(spacing: 0) {
Color.clear
.frame(height: 52)
.frame(height: model.isCompact ? 12 : 52)
.allowsHitTesting(false)
.accessibilityHidden(true)
ForEach(Array(model.queue.items.enumerated()), id: \.element.id) { index, item in
@@ -3258,6 +3297,7 @@ private struct PlayerContent: View {
.fill(Color(SunsetPalette.actionFill))
}
}
.padding(.horizontal, 8)
.contentShape(Rectangle())
.onTapGesture { model.selectQueueItem(item.id) }
.help((item.url.isFileURL ? item.url.path : item.displayName) + "\nClick to play; drag to reorder.")
@@ -3366,7 +3406,6 @@ private struct PlayerContent: View {
}
.animation(queueAddAnimation, value: showsQueueURLInput)
.onDisappear(perform: dismissQueueAdd)
.frame(width: 260)
.background(queueBackground)
.overlay(alignment: .top) {
queueChromeBackground(top: true)
@@ -3383,14 +3422,24 @@ private struct PlayerContent: View {
var body: some View {
let titleControlsVisible = model.showsQueue || model.isQueueTransitioning || model.controlsVisible
let startScreen = model.fileName == nil && model.queue.items.isEmpty && !model.isCompact && !model.compactWindow.isTransitioning
HStack(spacing: 0) {
videoContent
if !model.isCompact && !model.compactWindow.isTransitioning && model.queueWidth > 0 {
VStack(spacing: 0) {
HStack(spacing: 0) {
videoContent
if !model.isCompact && !model.compactWindow.isTransitioning && model.queueWidth > 0 {
queueSidebar
.frame(width: 260)
.frame(width: model.queueWidth, alignment: .leading)
.clipped()
.allowsHitTesting(model.showsQueue)
.accessibilityHidden(!model.showsQueue)
}
}
if model.isCompact && model.compactWindow.queueHeight > 0 {
queueSidebar
.frame(width: model.queueWidth, alignment: .leading)
.frame(height: model.compactWindow.queueHeight, alignment: .top)
.clipped()
.allowsHitTesting(model.showsQueue)
.accessibilityHidden(!model.showsQueue)
.allowsHitTesting(model.showsCompactQueue)
.accessibilityHidden(!model.showsCompactQueue)
}
}
.overlay(alignment: .topTrailing) {