feat: add expandable compact queue and sidebar shortcut
This commit is contained in:
@@ -7,6 +7,7 @@ final class CompactWindowController: NSObject {
|
||||
var normalVideoLayout: (() -> (aspect: Double, sidebarWidth: CGFloat)?)?
|
||||
private(set) var isCompact = false
|
||||
private(set) var isTransitioning = false
|
||||
private(set) var queueHeight: CGFloat = 0
|
||||
static let transitionDuration: TimeInterval = 0.25
|
||||
|
||||
var snappingEnabled: Bool {
|
||||
@@ -59,6 +60,7 @@ final class CompactWindowController: NSObject {
|
||||
private var originatedFullScreen = false
|
||||
private var videoAspect = 16.0 / 9.0
|
||||
private var fittedAspect: Double?
|
||||
private var fittedVideoAspect = 16.0 / 9.0
|
||||
private let reduceMotion: () -> Bool
|
||||
private let defaults: UserDefaults
|
||||
private static let widthKey = "compactWindowWidth"
|
||||
@@ -135,6 +137,34 @@ final class CompactWindowController: NSObject {
|
||||
fitCompactWindow(window, initial: false)
|
||||
}
|
||||
|
||||
// The reveal and native frame share the model's animation clock.
|
||||
func queueLayout(visible: Bool) -> (height: CGFloat, frame: NSRect)? {
|
||||
guard isCompact, !isTransitioning, let window else { return nil }
|
||||
let usable = Self.usableFrame(visibleFrame(for: window))
|
||||
let height = visible ? min(240, max(0, usable.height - 90)) : 0
|
||||
let video = Self.compactSize(aspect: videoAspect, width: window.frame.width,
|
||||
available: NSSize(width: usable.width, height: usable.height - height))
|
||||
let size = NSSize(width: video.width, height: video.height + height)
|
||||
let frame = NSRect(x: min(max(window.frame.minX, usable.minX), usable.maxX - size.width),
|
||||
y: max(usable.minY, min(window.frame.maxY, usable.maxY) - size.height),
|
||||
width: size.width, height: size.height)
|
||||
return (height, frame)
|
||||
}
|
||||
|
||||
func updateQueue(height: CGFloat, frame: NSRect) {
|
||||
guard isCompact, let window else { return }
|
||||
window.resizeIncrements = NSSize(width: 1, height: 1)
|
||||
queueHeight = height
|
||||
fittedVideoAspect = frame.width / (frame.height - height)
|
||||
window.minSize = NSSize(width: 160, height: 90 + height)
|
||||
window.contentMinSize = window.minSize
|
||||
window.setFrame(frame, display: true)
|
||||
if height == 0, Self.canFitAspect(videoAspect, available: Self.usableFrame(visibleFrame(for: window)).size) {
|
||||
window.aspectRatio = NSSize(width: videoAspect, height: 1)
|
||||
}
|
||||
onChange?()
|
||||
}
|
||||
|
||||
func snapAfterDrag() {
|
||||
guard isCompact, !isTransitioning, snappingEnabled, let window else { return }
|
||||
let frame = Self.snappedFrame(window.frame, visibleFrame: visibleFrame(for: window))
|
||||
@@ -229,12 +259,23 @@ final class CompactWindowController: NSObject {
|
||||
|
||||
func windowWillResize(_ sender: NSWindow, to proposedSize: NSSize) -> NSSize {
|
||||
let proposed = original?.windowWillResize?(sender, to: proposedSize) ?? proposedSize
|
||||
guard let owner, !owner.isCompact, !owner.isTransitioning,
|
||||
!sender.styleMask.contains(.fullScreen),
|
||||
let layout = owner.normalVideoLayout?(),
|
||||
layout.aspect.isFinite, layout.aspect > 0 else { return proposed }
|
||||
guard let owner, !owner.isTransitioning,
|
||||
!sender.styleMask.contains(.fullScreen) else { return proposed }
|
||||
let layout: (aspect: Double, sidebarWidth: CGFloat)
|
||||
let extraHeight: CGFloat
|
||||
if owner.isCompact {
|
||||
guard owner.queueHeight > 0 else { return proposed }
|
||||
layout = (owner.fittedVideoAspect, 0)
|
||||
extraHeight = owner.queueHeight
|
||||
} else {
|
||||
guard let normal = owner.normalVideoLayout?(),
|
||||
normal.aspect.isFinite, normal.aspect > 0 else { return proposed }
|
||||
layout = normal
|
||||
extraHeight = 0
|
||||
}
|
||||
// The sidebar stays fixed-width; only the video area is aspect-locked.
|
||||
let chrome = sender.frame.height - sender.contentRect(forFrameRect: sender.frame).height
|
||||
let chrome = owner.isCompact ? extraHeight
|
||||
: sender.frame.height - sender.contentRect(forFrameRect: sender.frame).height
|
||||
let start = resizeStartSize ?? sender.frame.size
|
||||
let widthChange = abs(proposed.width - start.width)
|
||||
let heightChange = abs(proposed.height - start.height) * layout.aspect
|
||||
@@ -291,6 +332,7 @@ final class CompactWindowController: NSObject {
|
||||
return
|
||||
}
|
||||
if let snapshot {
|
||||
queueHeight = 0
|
||||
setState(compact: false, transitioning: animated)
|
||||
window.aspectRatio = .zero
|
||||
window.minSize = .zero
|
||||
@@ -318,6 +360,7 @@ final class CompactWindowController: NSObject {
|
||||
private func enterCompact(_ window: NSWindow, animated: Bool) {
|
||||
// After didExitFullScreen, this is the normal frame, not the fullscreen frame.
|
||||
snapshot = Snapshot(window)
|
||||
queueHeight = 0
|
||||
setState(compact: true, transitioning: animated)
|
||||
window.aspectRatio = .zero
|
||||
window.minSize = .zero
|
||||
@@ -422,18 +465,22 @@ final class CompactWindowController: NSObject {
|
||||
let aspect = videoAspect
|
||||
let savedWidth = initial ? defaults.double(forKey: Self.widthKey) : Double(window.frame.width)
|
||||
let width = savedWidth.isFinite && savedWidth > 0 ? CGFloat(savedWidth) : 320
|
||||
var frame = Self.initialFrame(aspect: videoAspect, visibleFrame: visibleFrame, width: width)
|
||||
let video = Self.compactSize(aspect: videoAspect, width: width,
|
||||
available: NSSize(width: usable.width, height: max(0, usable.height - queueHeight)))
|
||||
var frame = NSRect(x: usable.maxX - video.width, y: usable.maxY - video.height - queueHeight,
|
||||
width: video.width, height: video.height + queueHeight)
|
||||
if !initial {
|
||||
frame.origin.x = min(max(window.frame.minX, usable.minX), usable.maxX - frame.width)
|
||||
frame.origin.y = min(max(window.frame.minY, usable.minY), usable.maxY - frame.height)
|
||||
frame.origin.y = max(usable.minY, min(window.frame.maxY, usable.maxY) - frame.height)
|
||||
}
|
||||
window.resizeIncrements = NSSize(width: 1, height: 1)
|
||||
animateFrame(frame, of: window, animated: animated) { [weak self, weak window] in
|
||||
guard let self, let window else { return }
|
||||
if Self.canFitAspect(aspect, available: usable.size) {
|
||||
if self.queueHeight == 0, Self.canFitAspect(aspect, available: usable.size) {
|
||||
window.aspectRatio = NSSize(width: aspect, height: 1)
|
||||
}
|
||||
self.fittedAspect = aspect
|
||||
self.fittedVideoAspect = video.width / video.height
|
||||
if initial { self.reconcile() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,8 +282,9 @@ struct PlayerCommands: Commands {
|
||||
.disabled(!hasFocusedScene || !model.canPerform(bufferInfo))
|
||||
}
|
||||
CommandGroup(replacing: .sidebar) {
|
||||
Button(model.showsQueue ? "Hide Queue" : "Show Queue") { model.showsQueue.toggle() }
|
||||
.disabled(!hasFocusedScene || model.isCompact)
|
||||
Button(model.queueVisible ? "Hide Queue" : "Show Queue") { model.performMenuAction(model.queueAction) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: model.queueAction).first?.keyboardShortcut)
|
||||
.disabled(!hasFocusedScene || !model.canPerform(model.queueAction))
|
||||
Button(fullscreen == .exitFullscreen ? "Exit Full Screen" : "Enter Full Screen") {
|
||||
model.performMenuAction(fullscreen)
|
||||
}
|
||||
|
||||
+60
-11
@@ -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) {
|
||||
|
||||
@@ -83,6 +83,7 @@ public struct KeyBindings: Equatable, Sendable {
|
||||
.enterFullscreen: ["f", "ctrl+cmd+f"], .exitFullscreen: ["f", "ctrl+cmd+f", "escape"],
|
||||
.enterCompact: ["p", "ctrl+cmd+p"], .exitCompact: ["p", "ctrl+cmd+p"],
|
||||
.enableAlwaysOnTop: ["shift+p"], .disableAlwaysOnTop: ["shift+p"],
|
||||
.showQueue: ["cmd+shift+l"], .hideQueue: ["cmd+shift+l"],
|
||||
.openFile: ["o", "cmd+o"], .openStream: ["cmd+shift+o"],
|
||||
.closeFile: ["cmd+shift+w"], .quit: ["q", "cmd+q"]
|
||||
]
|
||||
|
||||
@@ -6,6 +6,7 @@ public enum PlayerAction: Hashable, Sendable {
|
||||
case enableAlwaysOnTop, disableAlwaysOnTop
|
||||
case openFile, openStream, closeFile, quit
|
||||
case showBufferInfo, hideBufferInfo
|
||||
case showQueue, hideQueue
|
||||
case enableCompactSnapping, disableCompactSnapping
|
||||
case enableAutomaticQuality, disableAutomaticQuality
|
||||
case qualityDefault, quality(Int)
|
||||
@@ -21,6 +22,7 @@ public enum PlayerAction: Hashable, Sendable {
|
||||
("enable_always_on_top", .enableAlwaysOnTop), ("disable_always_on_top", .disableAlwaysOnTop),
|
||||
("open_file", .openFile), ("open_stream", .openStream), ("close_file", .closeFile), ("quit", .quit),
|
||||
("show_buffer_info", .showBufferInfo), ("hide_buffer_info", .hideBufferInfo),
|
||||
("show_queue", .showQueue), ("hide_queue", .hideQueue),
|
||||
("enable_compact_snapping", .enableCompactSnapping), ("disable_compact_snapping", .disableCompactSnapping),
|
||||
("enable_automatic_quality", .enableAutomaticQuality), ("disable_automatic_quality", .disableAutomaticQuality),
|
||||
("quality_default", .qualityDefault)
|
||||
@@ -60,6 +62,8 @@ public enum PlayerAction: Hashable, Sendable {
|
||||
case .disableAlwaysOnTop: return .enableAlwaysOnTop
|
||||
case .showBufferInfo: return .hideBufferInfo
|
||||
case .hideBufferInfo: return .showBufferInfo
|
||||
case .showQueue: return .hideQueue
|
||||
case .hideQueue: return .showQueue
|
||||
case .enableCompactSnapping: return .disableCompactSnapping
|
||||
case .disableCompactSnapping: return .enableCompactSnapping
|
||||
case .enableAutomaticQuality: return .disableAutomaticQuality
|
||||
|
||||
Reference in New Issue
Block a user