From 07243f0ed741247d600640c1adc6f2611c9540c9 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 18 Oct 2026 01:51:51 +0200 Subject: [PATCH] fix: restore window and keyboard interactions with queue --- Checks/app-startup.py | 19 ++++ Checks/app-startup.swift | 45 +++++++++ Checks/system-playback.swift | 157 ++++++++++++++++++++++++++++++++ README.md | 4 +- Sources/App/PlayerActions.swift | 11 ++- Sources/App/TerebiApp.swift | 82 ++++++++++------- 6 files changed, 279 insertions(+), 39 deletions(-) diff --git a/Checks/app-startup.py b/Checks/app-startup.py index 1c86891..57af21a 100644 --- a/Checks/app-startup.py +++ b/Checks/app-startup.py @@ -285,6 +285,25 @@ def check_cli_instances(root, app, fixtures): and state["position"] > 0 and state["hasVideo"] for state in states.values()), "cold default CLI opens one actual player window") first_pid = next(iter(first)) + opened = input_request(first_pid, 1, lambda state: state["showsQueue"] + and state["window"].get("videoOwnsMouse") is True + and abs(state["window"]["videoWidth"] - first[first_pid]["window"]["videoWidth"]) < 1, + "queue preserves video size and mouse target", action="queue", visible=True) + window = opened[first_pid]["window"] + if (not window["resizable"] or window["aspectWidth"] != 0 or window["aspectHeight"] != 0 + or window["minWidth"] > 660 or window["minHeight"] > 300 + or window["maxWidth"] <= window["width"] + 80 + or window["maxHeight"] <= window["height"] + 80 + or not window["controlsOwnMouse"] or not window["sidebarOwnsMouse"] + or not window["edgesAvoidDragSurface"] or window["surfaceMovesOnMouseDown"]): + raise RuntimeError(f"queue must preserve native resize limits and independent mouse targets: {window}") + input_request(first_pid, 1, lambda state: not state["showsQueue"] + and abs(state["window"]["videoWidth"] - first[first_pid]["window"]["videoWidth"]) < 1, + "queue closes without changing video size", action="queue", visible=False) + print(f"PASS native queue window: independent video/controls/sidebar/edge targets, " + f"resizable min {window['minWidth']}x{window['minHeight']}, unconstrained maxima and cleared aspect") + if os.environ.get("TEREBI_CHECK_WINDOW_ONLY") == "1": + return if settings(first[first_pid]) != (False, False, True) or config.parent.exists(): raise RuntimeError(f"missing config must use defaults without creating a file: {first}") invoke([]) diff --git a/Checks/app-startup.swift b/Checks/app-startup.swift index ea3c63e..d79da8a 100644 --- a/Checks/app-startup.swift +++ b/Checks/app-startup.swift @@ -98,6 +98,46 @@ private enum CLIInstanceCheck { return nil } + static func windowSnapshot(_ window: NSWindow?) -> [String: Any] { + guard let window, let content = window.contentView else { return [:] } + func surface(in view: NSView) -> NSView? { + if view.identifier?.rawValue == "PlaybackSurface" { return view } + return view.subviews.lazy.compactMap { surface(in: $0) }.first + } + var result: [String: Any] = [ + "width": window.frame.width, "height": window.frame.height, + "minWidth": window.minSize.width, "minHeight": window.minSize.height, + "maxWidth": window.maxSize.width, "maxHeight": window.maxSize.height, + "contentMinWidth": window.contentMinSize.width, "contentMinHeight": window.contentMinSize.height, + "contentMaxWidth": window.contentMaxSize.width, "contentMaxHeight": window.contentMaxSize.height, + "aspectWidth": window.aspectRatio.width, "aspectHeight": window.aspectRatio.height, + "resizable": window.styleMask.contains(.resizable), + ] + if let surface = surface(in: content) { + func hit(_ point: NSPoint) -> NSView? { + content.hitTest(surface.convert(point, to: content.superview)) + } + result["videoOwnsMouse"] = hit(NSPoint(x: surface.bounds.midX, y: surface.bounds.midY)) === surface + result["controlsOwnMouse"] = hit(NSPoint(x: surface.bounds.midX, y: 46)) !== surface + result["sidebarOwnsMouse"] = hit(NSPoint(x: surface.bounds.maxX + 130, y: surface.bounds.midY)) !== surface + result["videoWidth"] = surface.bounds.width + result["videoHeight"] = surface.bounds.height + result["surfaceMovesOnMouseDown"] = surface.mouseDownCanMoveWindow + let edges = [NSPoint(x: 1, y: content.bounds.midY), + NSPoint(x: content.bounds.maxX - 1, y: content.bounds.midY), + NSPoint(x: content.bounds.midX, y: 1), + NSPoint(x: content.bounds.midX, y: content.bounds.maxY - 1)] + let targets = edges.map { point in + content.hitTest(content.convert(point, to: content.superview)) + } + result["edgeTargets"] = targets.map { $0.map { String(describing: type(of: $0)) } ?? "native window" } + result["edgesAvoidDragSurface"] = targets.allSatisfy { target in + target !== surface && !(target.map { String(describing: type(of: $0)).contains("ChromeView") } ?? false) + } + } + return result + } + static func run(_ configuration: URL) { let settings = try! JSONSerialization.jsonObject(with: Data(contentsOf: configuration)) as! [String: String] let directory = URL(fileURLWithPath: settings["directory"]!, isDirectory: true) @@ -147,6 +187,9 @@ private enum CLIInstanceCheck { switch action { case "youtube": model.openYouTube(command["url"] as! String) + case "queue": + model.showsQueue = command["visible"] as! Bool + model.revealControls() case "caption": guard let view = window.contentView, let selector = captionSelector(in: view), let cell = selector.cell as? NSPopUpButtonCell, let menu = selector.menu else { @@ -252,6 +295,8 @@ private enum CLIInstanceCheck { "mainWindow": windows.first?.isMainWindow ?? false, "editorText": editorText, "compact": model.isCompact, + "showsQueue": model.showsQueue, + "window": windowSnapshot(windows.first), "onTop": model.isOnTop, ] do { diff --git a/Checks/system-playback.swift b/Checks/system-playback.swift index 79adb07..0ba9aa2 100644 --- a/Checks/system-playback.swift +++ b/Checks/system-playback.swift @@ -2,6 +2,20 @@ import AppKit import MediaPlayer import TerebiConfiguration +// Native focus eligibility normally depends on the user's Full Keyboard Access setting. +// Force eligibility only; keep the controls' real AppKit responder/key behavior. +private final class KeyboardCheckButton: NSButton { + override var acceptsFirstResponder: Bool { true } +} + +private final class KeyboardCheckSlider: NSSlider { + override var acceptsFirstResponder: Bool { true } +} + +private final class KeyboardCheckLabel: NSTextField { + override var acceptsFirstResponder: Bool { true } +} + @main @MainActor struct SystemPlaybackChecks { @@ -17,6 +31,50 @@ struct SystemPlaybackChecks { require(condition(), message) } + private final class DragCheckWindow: NSWindow { + var drags = 0 + var fullscreenToggles = 0 + override func performDrag(with event: NSEvent) { drags += 1 } + override func toggleFullScreen(_ sender: Any?) { fullscreenToggles += 1 } + } + + static func checkWindowSurface(defaults: UserDefaults) { + let model = PlayerModel(defaults: defaults) + defer { model.shutdown() } + let window = DragCheckWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360), + styleMask: [.titled, .resizable, .fullSizeContentView], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + let surface = PlaybackSurface.DragView(model: model) + surface.frame = window.contentView!.bounds + window.contentView!.addSubview(surface) + model.attachWindow(window) + @MainActor func event(_ type: NSEvent.EventType, clicks: Int = 1) -> NSEvent { + NSEvent.mouseEvent(with: type, location: NSPoint(x: 100, y: 100), modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, + eventNumber: 0, clickCount: clicks, pressure: 0)! + } + require(surface.hitTest(NSPoint(x: 100, y: 100)) === surface, + "video renderer and unused background route mouse input to their owning surface") + require(!surface.mouseDownCanMoveWindow, "native background movement cannot turn clicks or resize into drags") + for point in [NSPoint(x: 1, y: 100), NSPoint(x: 639, y: 100), + NSPoint(x: 100, y: 1), NSPoint(x: 100, y: surface.bounds.maxY - 1)] { + require(surface.hitTest(point) == nil, "playback drag surface leaves all native resize edges untouched") + } + surface.mouseDown(with: event(.leftMouseDown)) + require(window.drags == 0, "mouse-down alone never begins window dragging") + surface.mouseUp(with: event(.leftMouseUp)) + require(window.drags == 0 && window.fullscreenToggles == 0, "single video click never drags or enters fullscreen") + surface.mouseDown(with: event(.leftMouseDown)) + surface.mouseDragged(with: event(.leftMouseDragged)) + surface.mouseUp(with: event(.leftMouseUp)) + require(window.drags == 1 && window.fullscreenToggles == 0, "real mouse-drag callback invokes native window dragging once") + surface.mouseDown(with: event(.leftMouseDown, clicks: 2)) + surface.mouseUp(with: event(.leftMouseUp, clicks: 2)) + require(window.drags == 1 && window.fullscreenToggles == 1, "double video click retains fullscreen instead of dragging") + } + static func checkCompactProgress() { let times: [(Double, Double, String)] = [ (12.9, 45.9, "12 / 45 s"), @@ -99,6 +157,103 @@ struct SystemPlaybackChecks { "playback failure stops on failed queue entry rather than skipping") } + static func checkKeyboardRouting(defaults: UserDefaults) { + let bindings = try! KeyBindings(overrides: [ + "play": [], "pause": [], "seek_backward_5s": [], + "show_buffer_info": ["space", "return", "left", "shift+left", "tab", "shift+tab", + "cmd+b", "f12", "ctrl+alt+b"] + ]) + let model = PlayerModel(defaults: defaults, configuration: PlayerConfiguration(keys: bindings)) + defer { model.shutdown() } + let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360), + styleMask: [.titled, .resizable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + func event(_ code: UInt16, _ text: String, _ flags: NSEvent.ModifierFlags = []) -> NSEvent { + NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0, + windowNumber: window.windowNumber, context: nil, characters: text, + charactersIgnoringModifiers: text, isARepeat: false, keyCode: code)! + } + let shortcuts = [event(49, " "), event(36, "\r"), event(123, "\u{F702}"), + event(123, "\u{F702}", .shift), event(11, "b", .command), + event(111, "\u{F70F}", .function)] + func focus(_ responder: NSView) { + window.makeFirstResponder(nil) + window.contentView?.subviews.forEach { $0.removeFromSuperview() } + responder.frame = NSRect(x: 0, y: 0, width: 300, height: 200) + window.contentView?.addSubview(responder) + require(window.makeFirstResponder(responder), "keyboard regression fixture accepts actual window focus") + let focusedResponder = (responder as? NSTextField)?.currentEditor() ?? responder + require(window.firstResponder === focusedResponder, + "keyboard regression fixture owns actual control or field-editor focus") + } + let lists = [NSTableView(), NSOutlineView()] + for list in lists { + list.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("title"))) + focus(list) + for shortcut in shortcuts { + model.setShowsBufferInfo(false) + require(model.handleKeyEvent(shortcut, in: window) && model.showsBufferInfo, + "focused queue list dispatches configured \(KeyChord.from(shortcut)!) binding") + require(window.firstResponder === list, "playback shortcuts do not steal queue focus") + } + let traversalModifiers: [NSEvent.ModifierFlags] = [[], .shift] + for flags in traversalModifiers { + model.setShowsBufferInfo(false) + require(!model.handleKeyEvent(event(48, "\t", flags), in: window) && !model.showsBufferInfo, + "Tab and Shift-Tab remain native traversal even when configured") + } + } + let label = KeyboardCheckLabel(labelWithString: "Queue item") + focus(label) + model.setShowsBufferInfo(false) + require(model.handleKeyEvent(shortcuts[0], in: window) && model.showsBufferInfo, + "noneditable nonselectable queue label does not block playback") + let controls: [NSControl] = [KeyboardCheckButton(), KeyboardCheckSlider()] + for control in controls { + focus(control) + for shortcut in shortcuts.prefix(4) { + model.setShowsBufferInfo(false) + require(!model.handleKeyEvent(shortcut, in: window) && !model.showsBufferInfo, + "deliberately focused native button/slider retains activation and navigation") + require(window.firstResponder === control, "native control keyboard focus is preserved") + } + require(model.handleKeyEvent(shortcuts[4], in: window) && model.showsBufferInfo, + "modified configured bindings still work with native control focus") + } + let editor = NSTextView() + focus(editor) + for shortcut in shortcuts { + model.setShowsBufferInfo(false) + require(!model.handleKeyEvent(shortcut, in: window) && !model.showsBufferInfo, + "text editing retains configured keys instead of dispatching playback") + } + editor.keyDown(with: shortcuts[0]) + require(editor.string == " ", "unconsumed Space reaches actual text entry") + label.isSelectable = true + focus(label) + require(!model.handleKeyEvent(shortcuts[0], in: window), + "selectable text retains native text interaction") + label.isSelectable = false + label.isEditable = true + require(!model.handleKeyEvent(shortcuts[0], in: window), + "editable text field retains native text entry") + focus(lists[0]) + let voiceOverChord = event(11, "b", [.control, .option]) + require(model.handleKeyEvent(voiceOverChord, in: window) != NSWorkspace.shared.isVoiceOverEnabled, + "VoiceOver retains its Control-Option chords when enabled") + let sheet = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 200, height: 100), + styleMask: .titled, backing: .buffered, defer: false) + sheet.isReleasedWhenClosed = false + window.beginSheet(sheet) + require(!model.handleKeyEvent(shortcuts[0], in: window), "attached sheet blocks player shortcuts") + window.endSheet(sheet) + sheet.close() + let modal = NSApp.beginModalSession(for: window) + require(!model.handleKeyEvent(shortcuts[0], in: window), "modal session blocks player shortcuts") + NSApp.endModalSession(modal) + } + static func main() { checkCompactProgress() _ = NSApplication.shared @@ -106,7 +261,9 @@ struct SystemPlaybackChecks { let suite = "terebi.system-playback-check.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } + checkKeyboardRouting(defaults: defaults) checkQueue(directory: URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true), defaults: defaults) + checkWindowSurface(defaults: defaults) defaults.removePersistentDomain(forName: suite) let initialModel = PlayerModel(defaults: defaults) require(!initialModel.showsBufferInfo, "buffer readout defaults off before playback") diff --git a/README.md b/README.md index d708c71..e30c52c 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Open one or more local files with **⌘O**, drag them onto the video, or use Fin Compact mode keeps playback in the same floating window; drag to move it and double-click to restore. The titlebar pin keeps the normal window on top. macOS media keys also control playback; previous/next seek five seconds rather than changing tracks. -Playback controls stay anchored at the bottom center of the video and cannot be dragged. +Drag the video or unused playback background to move the window, including with the queue open. A single video click hides the controls; double-click toggles fullscreen. Playback controls stay anchored at the bottom center of the video and cannot be dragged. Native window edges remain available for resizing; the open queue allows free resizing with letterboxed video. Active playback prevents idle system sleep. Local video also keeps the display awake; audio-only playback allows display sleep. Pause, end, close, playback failure and quit release sleep prevention. @@ -59,7 +59,7 @@ 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. -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. +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. diff --git a/Sources/App/PlayerActions.swift b/Sources/App/PlayerActions.swift index 4f2868b..fdcb6d9 100644 --- a/Sources/App/PlayerActions.swift +++ b/Sources/App/PlayerActions.swift @@ -79,11 +79,14 @@ extension PlayerModel { } private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool { - guard event.window === window, NSApp.modalWindow == nil, window.attachedSheet == nil, - !(window.firstResponder is NSTextView), !(window.firstResponder is NSTextField) else { return false } - // Preserve focus traversal, control activation/navigation and VoiceOver's own chords. + guard event.window === window, NSApp.modalWindow == nil, window.attachedSheet == nil else { return false } + let responder = window.firstResponder + if let text = responder as? NSTextView, text.isEditable || text.isSelectable { return false } + if let text = responder as? NSTextField, text.isEditable || text.isSelectable { return false } + // Lists and inert labels are playback surfaces, not text entry or activation controls. + // Keep deliberate button/slider focus, traversal and VoiceOver's own chords native. if chord.key == "tab" && chord.modifiers.subtracting(.shift).isEmpty { return false } - if window.firstResponder is NSControl, + if responder is NSControl, !(responder is NSTableView), !(responder is NSTextField), chord.modifiers.subtracting(.shift).isEmpty, Self.nativeNavigationKeys.contains(chord.key) { return false diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index 5c8c20b..77cd99e 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -1809,14 +1809,6 @@ private struct PlaybackSlider: NSViewRepresentable { func castVolumeSliderFeedbackCheck() -> Bool { PlaybackSlider.Slider.feedbackCheck() } #endif -@MainActor -private struct VideoSurface: NSViewRepresentable { - let view: NSView - - func makeNSView(context: Context) -> NSView { view } - func updateNSView(_ nsView: NSView, context: Context) {} -} - @MainActor private struct CaptionSelector: NSViewRepresentable { let model: PlayerModel @@ -1888,22 +1880,46 @@ private struct CaptionSelector: NSViewRepresentable { } @MainActor -private struct CompactDragSurface: NSViewRepresentable { +struct PlaybackSurface: NSViewRepresentable { let model: PlayerModel func makeNSView(context: Context) -> DragView { DragView(model: model) } - func updateNSView(_ view: DragView, context: Context) {} + func updateNSView(_ view: DragView, context: Context) { view.updateVideo() } final class DragView: NSView { private let model: PlayerModel private var hoverTracking: NSTrackingArea? + private var mouseDownEvent: NSEvent? init(model: PlayerModel) { self.model = model super.init(frame: .zero) + identifier = NSUserInterfaceItemIdentifier("PlaybackSurface") + if let video = model.videoView { + video.frame = bounds + video.autoresizingMask = [.width, .height] + addSubview(video) + } + updateVideo() } required init?(coder: NSCoder) { nil } + func updateVideo() { + model.videoView?.isHidden = model.fileName == nil || model.castDevice != nil + } + + override var mouseDownCanMoveWindow: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard super.hitTest(point) != nil else { return nil } + // Leave the outer native resize border alone, not the internal queue boundary. + if let content = window?.contentView, + !content.bounds.insetBy(dx: 5, dy: 5).contains(content.convert(point, from: superview)) { + return nil + } + return self + } + override func updateTrackingAreas() { super.updateTrackingAreas() if let hoverTracking { removeTrackingArea(hoverTracking) } @@ -1916,18 +1932,33 @@ private struct CompactDragSurface: NSViewRepresentable { override func mouseEntered(with event: NSEvent) { model.revealControls() } override func mouseMoved(with event: NSEvent) { model.revealControls() } - override func mouseExited(with event: NSEvent) { model.hideControls(hideCursor: false) } + override func mouseExited(with event: NSEvent) { + if model.isCompact { model.hideControls(hideCursor: false) } + } override func mouseDown(with event: NSEvent) { + mouseDownEvent = nil if event.clickCount == 2 { - model.toggleCompact() - return + if model.isCompact { model.toggleCompact() } else { model.toggleFullScreen() } + } else { + mouseDownEvent = event } + } + + override func mouseDragged(with event: NSEvent) { + guard let down = mouseDownEvent, let window else { return } + mouseDownEvent = nil model.setControlsDragging(true) - window?.performDrag(with: event) - model.compactWindow.snapAfterDrag() + window.performDrag(with: down) + if model.isCompact { model.compactWindow.snapAfterDrag() } model.setControlsDragging(false) } + + override func mouseUp(with event: NSEvent) { + guard mouseDownEvent != nil else { return } + mouseDownEvent = nil + if !model.isCompact { model.hideControls() } + } } } @@ -2350,18 +2381,8 @@ private struct PlayerContent: View { } else { Color.black } - if let view = model.videoView { - VideoSurface(view: view) - .accessibilityLabel("Video") - .opacity(model.fileName == nil || model.castDevice != nil ? 0 : 1) - .gesture( - TapGesture(count: 2).onEnded { model.toggleFullScreen() } - .exclusively(before: TapGesture().onEnded { model.hideControls() }) - ) - } - if model.isCompact { - CompactDragSurface(model: model).accessibilityHidden(true) - } + PlaybackSurface(model: model) + .accessibilityLabel("Video") if !model.subtitleText.isEmpty { Text(model.subtitleText) .font(.system(size: max(16, min(28, geometry.size.width / 32)), weight: .semibold)) @@ -2703,12 +2724,7 @@ private struct WindowChrome: NSViewRepresentable { } } - override func mouseDown(with event: NSEvent) { - model.setControlsDragging(true) - window?.performDrag(with: event) - model.compactWindow.snapAfterDrag() - model.setControlsDragging(false) - } + override func hitTest(_ point: NSPoint) -> NSView? { nil } func detach() { if let monitor { NSEvent.removeMonitor(monitor) }