fix: track hover across the entire player to prevent overlay feedback flicker
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user