fix: restore window and keyboard interactions with queue

This commit is contained in:
2026-10-18 01:51:51 +02:00
parent afb0c30e09
commit 07243f0ed7
6 changed files with 279 additions and 39 deletions
+19
View File
@@ -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([])
+45
View File
@@ -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 {
+157
View File
@@ -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")
+2 -2
View File
@@ -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.
+7 -4
View File
@@ -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
+49 -33
View File
@@ -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) }