diff --git a/Checks/system-playback.swift b/Checks/system-playback.swift index 0ba9aa2..f036d90 100644 --- a/Checks/system-playback.swift +++ b/Checks/system-playback.swift @@ -326,6 +326,23 @@ struct SystemPlaybackChecks { model.setPointerInsideWindow(true) } until("open starts authoritative playback") { model.canTogglePlayback && model.state.status == PLAYER_PLAYING } + model.showsQueue = true + for delta in [NSSize(width: 160, height: 0), NSSize(width: 0, height: 90), + NSSize(width: -100, height: 0), NSSize(width: 0, height: -60)] { + let proposal = NSSize(width: window.frame.width + delta.width, height: window.frame.height + delta.height) + guard let constrained = window.delegate?.windowWillResize?(window, to: proposal) else { + fatalError("FAIL: native resize delegate must constrain queue video geometry") + } + var frame = window.frame + frame.size = constrained + window.setFrame(frame, display: true) + let content = window.contentRect(forFrameRect: window.frame) + require(abs((content.width - 260) / content.height - model.state.video_aspect_ratio) < 0.001, + "horizontal and vertical queue resizing preserves video aspect with fixed sidebar width") + require(delta.width == 0 ? abs(frame.height - proposal.height) < 1 : abs(frame.width - proposal.width) < 1, + "aspect-locked queue window follows the dragged axis") + } + model.showsQueue = false require(center.playbackState == .playing, "playing publishes macOS playback ownership") requirePlaybackPower(system: true, display: true, "local video keeps the Mac and display awake") var bufferingState = model.state diff --git a/README.md b/README.md index 1f87a61..ae33d72 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ Use the titlebar's **Queue** icon or **View → Show Queue** to show the editabl In a normal window, showing the queue expands the window outward while preserving the video's size; hiding it removes that extra width. The sidebar meets the video directly, without a separate divider gap. The window shifts left when necessary near the screen's right edge. Fullscreen keeps its fixed bounds. +Resizing with the queue open preserves the video's aspect ratio while keeping the sidebar at a fixed width; closing the queue retains ordinary aspect-locked video resizing. + While the sidebar is open, its persistent top header holds the queue count, queue toggle, pin, Cast and settings controls; these do not fade with the video controls. Closing the sidebar returns those controls to the auto-hiding video titlebar. Space and configured playback shortcuts continue working with queue-list focus; Tab navigation, deliberately focused buttons/sliders and text entry keep their native keyboard behavior. Playback advances automatically and stops at the end, without wrapping. **Previous Queue Item** and **Next Queue Item** are available in the sidebar and Playback menu and are disabled at their respective ends. Reordering or removing another item does not interrupt playback; removing the current item plays its next neighbor, or stops if there is none. A playback error stays on that item rather than skipping it. @@ -157,7 +159,7 @@ The full suite requires a graphical macOS session and a working audio device; it The startup checks verify that playing video remains attached, unhidden and sized to its native drag surface, including with the queue open; playback-state checks alone do not establish renderer visibility. -Queue-open resize checks also require positive native resize increments: clearing AppKit's aspect ratio alone leaves zero increments after aspect-locked video. Physical edge-drag resizing still needs manual verification. +Queue-open resize checks require positive native resize increments and exercise horizontal/vertical resize-delegate proposals to preserve video aspect independently of the fixed-width sidebar. Physical edge-drag resizing still needs manual verification. `make benchmark-playback` measures cold source startup, cached/uncached seeks, and sustained native/Cast playback with repeatable loopback fixtures. It requires the same macOS session/audio device and fixture-generation FFmpeg; results exclude live extraction, WAN/TLS, and physical-receiver latency. The [benchmark runner](Checks/playback-benchmark.py) accepts `--compare-native ` for alternating native-only comparisons; add `--seek-work` to isolate cache-miss seeks when both binaries support that mode. Preserve the baseline executable before rebuilding. diff --git a/Sources/App/CompactWindow.swift b/Sources/App/CompactWindow.swift index 001d7db..1f6b91a 100644 --- a/Sources/App/CompactWindow.swift +++ b/Sources/App/CompactWindow.swift @@ -4,6 +4,7 @@ import QuartzCore @MainActor final class CompactWindowController: NSObject { var onChange: (() -> Void)? + var normalVideoLayout: (() -> (aspect: Double, sidebarWidth: CGFloat)?)? private(set) var isCompact = false private(set) var isTransitioning = false static let transitionDuration: TimeInterval = 0.25 @@ -211,6 +212,23 @@ final class CompactWindowController: NSObject { let responds = MainActor.assumeIsolated { original?.responds(to: selector) ?? false } return responds ? original : nil } + 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 } + // The sidebar stays fixed-width; only the video area is aspect-locked. + let chrome = sender.frame.height - sender.contentRect(forFrameRect: sender.frame).height + let widthDriven = abs(proposed.width - sender.frame.width) >= + abs(proposed.height - sender.frame.height) * layout.aspect + let minimumHeight = max(sender.minSize.height - chrome, + (sender.minSize.width - layout.sidebarWidth) / layout.aspect) + let height = max(minimumHeight, widthDriven + ? (proposed.width - layout.sidebarWidth) / layout.aspect + : proposed.height - chrome) + return NSSize(width: height * layout.aspect + layout.sidebarWidth, height: height + chrome) + } func windowDidFailToEnterFullScreen(_ window: NSWindow) { owner?.fullScreenFailed() diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index cb1a158..dabb8fe 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -764,6 +764,10 @@ final class PlayerModel: ObservableObject { self.window = window window.title = fileName ?? "terebi" window.contentMinSize = NSSize(width: 400, height: 240) + compactWindow.normalVideoLayout = { [weak self] in + guard let self, self.showsQueue, self.state.has_video else { return nil } + return (self.state.video_aspect_ratio, 260) + } compactWindow.attach(window) updateWindowState() fitVideoWindow()