fix: track hover across the entire player to prevent overlay feedback flicker

This commit is contained in:
2026-10-20 22:00:31 +02:00
parent 885dea74f2
commit 7036109152
4 changed files with 117 additions and 7 deletions
+19
View File
@@ -36,6 +36,22 @@ class StartupStreamingServer(StreamingServer):
super()._get(request) 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): def check_window_drag(root, app, fixtures):
result_path = root / "window-drag.json" result_path = root / "window-drag.json"
diagnostics = root / "window-drag.log" 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: if launch.returncode != 0 or not result["passed"] or result["bundleIdentifier"] != identifier:
raise RuntimeError(f"packaged startup failed: {result}; {stdout}{stderr}") 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") 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) check_window_drag(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_DRAG_ONLY") != "1": if os.environ.get("TEREBI_CHECK_DRAG_ONLY") != "1":
check_queue_drag(root, app, fixtures) check_queue_drag(root, app, fixtures)
+87
View File
@@ -1,4 +1,5 @@
import AppKit import AppKit
import Combine
import Darwin import Darwin
import MediaPlayer import MediaPlayer
import ObjectiveC import ObjectiveC
@@ -6,6 +7,10 @@ import ObjectiveC
@_cdecl("terebi_app_main") @_cdecl("terebi_app_main")
@MainActor @MainActor
public func checkAppStartup() { 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") { if let index = CommandLine.arguments.firstIndex(of: "--check-window-drag") {
WindowDragCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1])) WindowDragCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1]))
return return
@@ -25,6 +30,88 @@ public func checkAppStartup() {
AppStartupCheck.run() 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 @MainActor
private enum WindowDragCheck { private enum WindowDragCheck {
static var timer: Timer? static var timer: Timer?
+4
View File
@@ -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. 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. 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. 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. 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. `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. 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.
+7 -7
View File
@@ -2838,6 +2838,13 @@ private struct PlayerContent: View {
.transition(.opacity) .transition(.opacity)
} }
} }
.onContinuousHover { phase in
switch phase {
case .active:
model.setPointerInsideWindow(true)
case .ended: model.setPointerInsideWindow(false)
}
}
} }
private var windowDragBackground: some View { private var windowDragBackground: some View {
@@ -3024,13 +3031,6 @@ private struct PlayerContent: View {
dropTargeted = $0 dropTargeted = $0
model.revealControls() model.revealControls()
} }
.onContinuousHover { phase in
switch phase {
case .active:
model.setPointerInsideWindow(true)
case .ended: model.setPointerInsideWindow(false)
}
}
} }
} }
} }