diff --git a/Checks/app-startup.py b/Checks/app-startup.py index d598a19..728cb4a 100644 --- a/Checks/app-startup.py +++ b/Checks/app-startup.py @@ -36,6 +36,22 @@ class StartupStreamingServer(StreamingServer): super()._get(request) +def check_overlay_hover(root, app, fixtures): + result_path = root / "overlay-hover.json" + diagnostics = root / "overlay-hover.log" + result = subprocess.run( + ["open", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app), + str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-overlay-hover", str(result_path)], + capture_output=True, text=True, timeout=25, + ) + report = json.loads(result_path.read_text()) if result_path.exists() else {} + if result.returncode != 0 or not report.get("passed") or report.get("steps") != 7: + log = diagnostics.read_text(errors="replace") if diagnostics.exists() else "" + raise RuntimeError(f"native overlay hover failed: {report}; {result.stdout}{result.stderr}{log}") + print("PASS native overlay hover: video, title, playback, top-right and queue controls stay visible; " + "window exit hides and reentry reveals controls") + + def check_window_drag(root, app, fixtures): result_path = root / "window-drag.json" diagnostics = root / "window-drag.log" @@ -686,6 +702,9 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory: if launch.returncode != 0 or not result["passed"] or result["bundleIdentifier"] != identifier: raise RuntimeError(f"packaged startup failed: {result}; {stdout}{stderr}") print("PASS packaged bootstrap: registered app PID/identity, in-process player load, actual playback and Now Playing") + check_overlay_hover(root, app, fixtures) + if os.environ.get("TEREBI_CHECK_HOVER_ONLY") == "1": + sys.exit(0) check_window_drag(root, app, fixtures) if os.environ.get("TEREBI_CHECK_DRAG_ONLY") != "1": check_queue_drag(root, app, fixtures) diff --git a/Checks/app-startup.swift b/Checks/app-startup.swift index 266bb66..c197e2a 100644 --- a/Checks/app-startup.swift +++ b/Checks/app-startup.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import Darwin import MediaPlayer import ObjectiveC @@ -6,6 +7,10 @@ import ObjectiveC @_cdecl("terebi_app_main") @MainActor public func checkAppStartup() { + if let index = CommandLine.arguments.firstIndex(of: "--check-overlay-hover") { + OverlayHoverCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1])) + return + } if let index = CommandLine.arguments.firstIndex(of: "--check-window-drag") { WindowDragCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1])) return @@ -25,6 +30,88 @@ public func checkAppStartup() { AppStartupCheck.run() } +@MainActor +private enum OverlayHoverCheck { + static var timer: Timer? + static var observation: AnyCancellable? + static var resultURL: URL! + static var step = 0 + static var sample = 0 + static var hidden = 0 + static var savedPointer = NSPoint.zero + static let names = ["video", "title", "playback controls", "top-right controls", + "queue controls", "window exit", "window reentry"] + // Infrastructure watchdog only; verdicts use native hover events and visibility changes. + static let deadline = ContinuousClock.now.advanced(by: .seconds(20)) + + static func run(result: URL) { + resultURL = result + _ = NSApplication.shared + savedPointer = NSEvent.mouseLocation + timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in + MainActor.assumeIsolated { advance() } + } + terebiAppMain() + } + + static func finish(_ error: String = "") { + timer?.invalidate() + observation = nil + CGWarpMouseCursorPosition(CGPoint(x: savedPointer.x, y: NSScreen.screens[0].frame.maxY - savedPointer.y)) + let report: [String: Any] = ["passed": error.isEmpty, "error": error, "steps": step] + try! JSONSerialization.data(withJSONObject: report).write(to: resultURL, options: .atomic) + PlayerModel.shared.shutdown() + NSApp.terminate(nil) + } + + static func advance() { + guard ContinuousClock.now < deadline else { finish("Overlay hover timed out"); return } + let model = PlayerModel.shared + guard model.canTogglePlayback, model.state.has_video, + let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }), + let content = window.contentView else { return } + if observation == nil { + model.setVolume(0) + model.setPlayback(playing: false) + window.makeKeyAndOrderFront(nil) + window.makeMain() + NSApp.activate(ignoringOtherApps: true) + observation = model.$controlsVisible.removeDuplicates().dropFirst().sink { visible in + if !visible { hidden += 1 } + } + } + if step == names.count { finish(); return } + if step == 4 && !model.showsQueue { model.showsQueue = true; return } + guard !model.isQueueTransitioning else { return } + if sample == 0 { + let w = content.bounds.width + let h = content.bounds.height + let points = [NSPoint(x: w / 2, y: h / 2), NSPoint(x: 150, y: h - 16), + NSPoint(x: w / 2, y: 64), NSPoint(x: w - 46, y: h - 16), + NSPoint(x: w - 46, y: h - 16), NSPoint(x: -20, y: h / 2), + NSPoint(x: w - 46, y: h - 16)] + hidden = 0 + let screen = window.convertPoint(toScreen: points[step]) + CGWarpMouseCursorPosition(CGPoint(x: screen.x, y: NSScreen.screens[0].frame.maxY - screen.y)) + let event = NSEvent.mouseEvent(with: .mouseMoved, location: points[step], modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, + eventNumber: step, clickCount: 0, pressure: 0)! + NSApp.postEvent(NSEvent(cgEvent: event.cgEvent!)!, atStart: false) + } + // Let native tracking and SwiftUI updates settle; no auto-hide timer is under test. + sample += 1 + if sample == 20 { + let outside = step == 5 + guard model.controlsVisible != outside, outside || hidden == 0 else { + finish("\(names[step]): visible=\(model.controlsVisible), hidden transitions=\(hidden)") + return + } + sample = 0 + step += 1 + } + } +} + @MainActor private enum WindowDragCheck { static var timer: Timer? diff --git a/README.md b/README.md index c9e07ea..8923974 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ Compact entry and exit animate asynchronously over 0.25 seconds, preserving the The normal titlebar control container uses matching 4-point top and right margins. Its horizontal padding accounts for the buttons' internal spacing to visually match the title container. +Pointer tracking covers the entire player, including the top-right controls and queue sidebar. Crossing from the video into those overlays does not count as leaving the window or trigger a hide/show loop. + Drag the video or any noninteractive background to move the window, including the start screen, queue, playback chrome, loading/error screens, audio-only playback, casting and compact mode. Buttons, menus, sliders, editable/selectable text and queue rows retain their own mouse input. A single video click hides the controls; double-click toggles fullscreen. Playback controls stay anchored at the bottom center of the video. Native edges remain available for resizing; macOS fullscreen keeps its fixed window bounds. 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. @@ -187,6 +189,8 @@ 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. +`TEREBI_CHECK_HOVER_ONLY=1 make check-startup` runs packaged startup and the native overlay-hover regression: video, title, playback controls, top-right controls, queue controls, window exit and reentry. It temporarily moves the pointer and restores its original position. + `make check-local-seek` measures paused/playing local seek-to-picture latency and compares the resulting timestamps and pixels against full decoding, including B-frame targets. It covers a 1080p H.264 long-GOP file with audio and the six hardware/software decoder fixtures; timing is diagnostic, not a machine-dependent pass threshold. Queue-open resize checks require positive native resize increments and exercise continuous horizontal/vertical resize-delegate sequences, including reversals, to preserve video aspect without snap-back. The driving axis is fixed for each live resize gesture so aspect corrections do not feed back into axis selection. Physical edge-drag resizing still needs manual verification. diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index 7b2b199..11fe8cb 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -2838,6 +2838,13 @@ private struct PlayerContent: View { .transition(.opacity) } } + .onContinuousHover { phase in + switch phase { + case .active: + model.setPointerInsideWindow(true) + case .ended: model.setPointerInsideWindow(false) + } + } } private var windowDragBackground: some View { @@ -3024,13 +3031,6 @@ private struct PlayerContent: View { dropTargeted = $0 model.revealControls() } - .onContinuousHover { phase in - switch phase { - case .active: - model.setPointerInsideWindow(true) - case .ended: model.setPointerInsideWindow(false) - } - } } } }