fix: restore window and keyboard interactions with queue
This commit is contained in:
@@ -285,6 +285,25 @@ def check_cli_instances(root, app, fixtures):
|
|||||||
and state["position"] > 0 and state["hasVideo"] for state in states.values()),
|
and state["position"] > 0 and state["hasVideo"] for state in states.values()),
|
||||||
"cold default CLI opens one actual player window")
|
"cold default CLI opens one actual player window")
|
||||||
first_pid = next(iter(first))
|
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():
|
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}")
|
raise RuntimeError(f"missing config must use defaults without creating a file: {first}")
|
||||||
invoke([])
|
invoke([])
|
||||||
|
|||||||
@@ -98,6 +98,46 @@ private enum CLIInstanceCheck {
|
|||||||
return nil
|
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) {
|
static func run(_ configuration: URL) {
|
||||||
let settings = try! JSONSerialization.jsonObject(with: Data(contentsOf: configuration)) as! [String: String]
|
let settings = try! JSONSerialization.jsonObject(with: Data(contentsOf: configuration)) as! [String: String]
|
||||||
let directory = URL(fileURLWithPath: settings["directory"]!, isDirectory: true)
|
let directory = URL(fileURLWithPath: settings["directory"]!, isDirectory: true)
|
||||||
@@ -147,6 +187,9 @@ private enum CLIInstanceCheck {
|
|||||||
switch action {
|
switch action {
|
||||||
case "youtube":
|
case "youtube":
|
||||||
model.openYouTube(command["url"] as! String)
|
model.openYouTube(command["url"] as! String)
|
||||||
|
case "queue":
|
||||||
|
model.showsQueue = command["visible"] as! Bool
|
||||||
|
model.revealControls()
|
||||||
case "caption":
|
case "caption":
|
||||||
guard let view = window.contentView, let selector = captionSelector(in: view),
|
guard let view = window.contentView, let selector = captionSelector(in: view),
|
||||||
let cell = selector.cell as? NSPopUpButtonCell, let menu = selector.menu else {
|
let cell = selector.cell as? NSPopUpButtonCell, let menu = selector.menu else {
|
||||||
@@ -252,6 +295,8 @@ private enum CLIInstanceCheck {
|
|||||||
"mainWindow": windows.first?.isMainWindow ?? false,
|
"mainWindow": windows.first?.isMainWindow ?? false,
|
||||||
"editorText": editorText,
|
"editorText": editorText,
|
||||||
"compact": model.isCompact,
|
"compact": model.isCompact,
|
||||||
|
"showsQueue": model.showsQueue,
|
||||||
|
"window": windowSnapshot(windows.first),
|
||||||
"onTop": model.isOnTop,
|
"onTop": model.isOnTop,
|
||||||
]
|
]
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -2,6 +2,20 @@ import AppKit
|
|||||||
import MediaPlayer
|
import MediaPlayer
|
||||||
import TerebiConfiguration
|
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
|
@main
|
||||||
@MainActor
|
@MainActor
|
||||||
struct SystemPlaybackChecks {
|
struct SystemPlaybackChecks {
|
||||||
@@ -17,6 +31,50 @@ struct SystemPlaybackChecks {
|
|||||||
require(condition(), message)
|
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() {
|
static func checkCompactProgress() {
|
||||||
let times: [(Double, Double, String)] = [
|
let times: [(Double, Double, String)] = [
|
||||||
(12.9, 45.9, "12 / 45 s"),
|
(12.9, 45.9, "12 / 45 s"),
|
||||||
@@ -99,6 +157,103 @@ struct SystemPlaybackChecks {
|
|||||||
"playback failure stops on failed queue entry rather than skipping")
|
"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() {
|
static func main() {
|
||||||
checkCompactProgress()
|
checkCompactProgress()
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
@@ -106,7 +261,9 @@ struct SystemPlaybackChecks {
|
|||||||
let suite = "terebi.system-playback-check.\(UUID().uuidString)"
|
let suite = "terebi.system-playback-check.\(UUID().uuidString)"
|
||||||
let defaults = UserDefaults(suiteName: suite)!
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
defer { defaults.removePersistentDomain(forName: suite) }
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
|
checkKeyboardRouting(defaults: defaults)
|
||||||
checkQueue(directory: URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true), defaults: defaults)
|
checkQueue(directory: URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true), defaults: defaults)
|
||||||
|
checkWindowSurface(defaults: defaults)
|
||||||
defaults.removePersistentDomain(forName: suite)
|
defaults.removePersistentDomain(forName: suite)
|
||||||
let initialModel = PlayerModel(defaults: defaults)
|
let initialModel = PlayerModel(defaults: defaults)
|
||||||
require(!initialModel.showsBufferInfo, "buffer readout defaults off before playback")
|
require(!initialModel.showsBufferInfo, "buffer readout defaults off before playback")
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -79,11 +79,14 @@ extension PlayerModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool {
|
private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool {
|
||||||
guard event.window === window, NSApp.modalWindow == nil, window.attachedSheet == nil,
|
guard event.window === window, NSApp.modalWindow == nil, window.attachedSheet == nil else { return false }
|
||||||
!(window.firstResponder is NSTextView), !(window.firstResponder is NSTextField) else { return false }
|
let responder = window.firstResponder
|
||||||
// Preserve focus traversal, control activation/navigation and VoiceOver's own chords.
|
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 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,
|
chord.modifiers.subtracting(.shift).isEmpty,
|
||||||
Self.nativeNavigationKeys.contains(chord.key) {
|
Self.nativeNavigationKeys.contains(chord.key) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
+49
-33
@@ -1809,14 +1809,6 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
func castVolumeSliderFeedbackCheck() -> Bool { PlaybackSlider.Slider.feedbackCheck() }
|
func castVolumeSliderFeedbackCheck() -> Bool { PlaybackSlider.Slider.feedbackCheck() }
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private struct VideoSurface: NSViewRepresentable {
|
|
||||||
let view: NSView
|
|
||||||
|
|
||||||
func makeNSView(context: Context) -> NSView { view }
|
|
||||||
func updateNSView(_ nsView: NSView, context: Context) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private struct CaptionSelector: NSViewRepresentable {
|
private struct CaptionSelector: NSViewRepresentable {
|
||||||
let model: PlayerModel
|
let model: PlayerModel
|
||||||
@@ -1888,22 +1880,46 @@ private struct CaptionSelector: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private struct CompactDragSurface: NSViewRepresentable {
|
struct PlaybackSurface: NSViewRepresentable {
|
||||||
let model: PlayerModel
|
let model: PlayerModel
|
||||||
|
|
||||||
func makeNSView(context: Context) -> DragView { DragView(model: model) }
|
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 {
|
final class DragView: NSView {
|
||||||
private let model: PlayerModel
|
private let model: PlayerModel
|
||||||
private var hoverTracking: NSTrackingArea?
|
private var hoverTracking: NSTrackingArea?
|
||||||
|
private var mouseDownEvent: NSEvent?
|
||||||
|
|
||||||
init(model: PlayerModel) {
|
init(model: PlayerModel) {
|
||||||
self.model = model
|
self.model = model
|
||||||
super.init(frame: .zero)
|
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 }
|
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() {
|
override func updateTrackingAreas() {
|
||||||
super.updateTrackingAreas()
|
super.updateTrackingAreas()
|
||||||
if let hoverTracking { removeTrackingArea(hoverTracking) }
|
if let hoverTracking { removeTrackingArea(hoverTracking) }
|
||||||
@@ -1916,18 +1932,33 @@ private struct CompactDragSurface: NSViewRepresentable {
|
|||||||
|
|
||||||
override func mouseEntered(with event: NSEvent) { model.revealControls() }
|
override func mouseEntered(with event: NSEvent) { model.revealControls() }
|
||||||
override func mouseMoved(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) {
|
override func mouseDown(with event: NSEvent) {
|
||||||
|
mouseDownEvent = nil
|
||||||
if event.clickCount == 2 {
|
if event.clickCount == 2 {
|
||||||
model.toggleCompact()
|
if model.isCompact { model.toggleCompact() } else { model.toggleFullScreen() }
|
||||||
return
|
} else {
|
||||||
|
mouseDownEvent = event
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseDragged(with event: NSEvent) {
|
||||||
|
guard let down = mouseDownEvent, let window else { return }
|
||||||
|
mouseDownEvent = nil
|
||||||
model.setControlsDragging(true)
|
model.setControlsDragging(true)
|
||||||
window?.performDrag(with: event)
|
window.performDrag(with: down)
|
||||||
model.compactWindow.snapAfterDrag()
|
if model.isCompact { model.compactWindow.snapAfterDrag() }
|
||||||
model.setControlsDragging(false)
|
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 {
|
} else {
|
||||||
Color.black
|
Color.black
|
||||||
}
|
}
|
||||||
if let view = model.videoView {
|
PlaybackSurface(model: model)
|
||||||
VideoSurface(view: view)
|
.accessibilityLabel("Video")
|
||||||
.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)
|
|
||||||
}
|
|
||||||
if !model.subtitleText.isEmpty {
|
if !model.subtitleText.isEmpty {
|
||||||
Text(model.subtitleText)
|
Text(model.subtitleText)
|
||||||
.font(.system(size: max(16, min(28, geometry.size.width / 32)), weight: .semibold))
|
.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) {
|
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
||||||
model.setControlsDragging(true)
|
|
||||||
window?.performDrag(with: event)
|
|
||||||
model.compactWindow.snapAfterDrag()
|
|
||||||
model.setControlsDragging(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func detach() {
|
func detach() {
|
||||||
if let monitor { NSEvent.removeMonitor(monitor) }
|
if let monitor { NSEvent.removeMonitor(monitor) }
|
||||||
|
|||||||
Reference in New Issue
Block a user