diff --git a/Checks/app-startup.py b/Checks/app-startup.py index 9375728..d598a19 100644 --- a/Checks/app-startup.py +++ b/Checks/app-startup.py @@ -36,6 +36,21 @@ class StartupStreamingServer(StreamingServer): super()._get(request) +def check_window_drag(root, app, fixtures): + result_path = root / "window-drag.json" + diagnostics = root / "window-drag.log" + result = subprocess.run( + ["open", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app), + str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-window-drag", str(result_path), str(fixtures)], + capture_output=True, text=True, timeout=30, + ) + report = json.loads(result_path.read_text()) if result_path.exists() else {} + if result.returncode != 0 or not report.get("passed"): + log = diagnostics.read_text(errors="replace") if diagnostics.exists() else "" + raise RuntimeError(f"native window drag failed: {report}; {result.stdout}{result.stderr}{log}") + print("PASS native window dragging: " + str(report)) + + def check_queue_drag(root, app, fixtures): result_path = root / "queue-drag.json" diagnostics = root / "queue-drag.log" @@ -671,11 +686,13 @@ 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_queue_drag(root, app, fixtures) - if os.environ.get("TEREBI_CHECK_QUEUE_ONLY") != "1": - check_window_layout(root, app, fixtures) - if os.environ.get("TEREBI_CHECK_LAYOUT_ONLY") != "1": - check_cli_instances(root, app, fixtures) + check_window_drag(root, app, fixtures) + if os.environ.get("TEREBI_CHECK_DRAG_ONLY") != "1": + check_queue_drag(root, app, fixtures) + if os.environ.get("TEREBI_CHECK_QUEUE_ONLY") != "1": + check_window_layout(root, app, fixtures) + if os.environ.get("TEREBI_CHECK_LAYOUT_ONLY") != "1": + check_cli_instances(root, app, fixtures) finally: os.close(descriptor) subprocess.run( diff --git a/Checks/app-startup.swift b/Checks/app-startup.swift index 3d0d57e..b29075a 100644 --- a/Checks/app-startup.swift +++ b/Checks/app-startup.swift @@ -1,10 +1,15 @@ import AppKit import Darwin import MediaPlayer +import ObjectiveC @_cdecl("terebi_app_main") @MainActor public func checkAppStartup() { + if let index = CommandLine.arguments.firstIndex(of: "--check-window-drag") { + WindowDragCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1])) + return + } if let index = CommandLine.arguments.firstIndex(of: "--check-queue-drag") { QueueDragCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1])) return @@ -20,6 +25,195 @@ public func checkAppStartup() { AppStartupCheck.run() } +@MainActor +private enum WindowDragCheck { + static var timer: Timer? + static var resultURL: URL! + static var phase = 0 + static var probe = 0 + static var drags = 0 + static var previousDrags = 0 + static var start = NSPoint.zero + static var implementations: [(Method, IMP)] = [] + static var target: NSWindow? + static var dragDepth = 0 + static let names = ["video", "title", "playback padding", "play button", "volume slider", + "start whitespace", "start button", "start artwork", "URL text field", + "queue whitespace", "queue footer", "queue row", "queue button", + "error backdrop", "error button", "audio artwork", + "compact video", "compact padding", "compact button", "hidden controls"] + static let controls: Set = [3, 4, 6, 8, 11, 12, 14, 18] + // Infrastructure timeout only; verdicts depend on input routing, not elapsed time. + static let deadline = ContinuousClock.now.advanced(by: .seconds(25)) + + static func run(result: URL) { + resultURL = result + _ = NSApplication.shared + timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in + MainActor.assumeIsolated { advance() } + } + RunLoop.main.add(timer!, forMode: .common) + terebiAppMain() + } + + static func finish(_ error: String? = nil) { + timer?.invalidate() + for (method, original) in implementations { + imp_removeBlock(method_setImplementation(method, original)) + } + try! JSONSerialization.data(withJSONObject: [ + "passed": error == nil, "error": error ?? "", "probes": probe, "drags": drags, + ]).write(to: resultURL, options: .atomic) + PlayerModel.shared.shutdown() + NSApp.terminate(nil) + } + + static func nativeView(in view: NSView, matching predicate: (NSView) -> Bool) -> NSView? { + if predicate(view) { return view } + for child in view.subviews { + if let found = nativeView(in: child, matching: predicate) { return found } + } + return nil + } + + static func point(in window: NSWindow) -> NSPoint? { + let content = window.contentView! + let w = content.bounds.width + let h = content.bounds.height + let view: NSView? + switch probe { + case 0, 7: return NSPoint(x: 100, y: 160) + case 1: return NSPoint(x: 150, y: h - 16) + case 2: return NSPoint(x: w / 2 - 210, y: 48) + case 3: return NSPoint(x: w / 2, y: 64) + case 4: view = nativeView(in: content) { ($0 as? NSSlider)?.accessibilityLabel() == "Volume" } + case 5: return NSPoint(x: 600, y: 160) + case 6: return NSPoint(x: 400, y: 272) + case 8: view = nativeView(in: content) { ($0 as? NSTextField)?.isEditable == true } + case 9: return NSPoint(x: w - 130, y: h / 2) + case 10: return NSPoint(x: w - 160, y: 18) + case 11: return NSPoint(x: w - 130, y: h - 80) + case 12: return NSPoint(x: w - 183, y: 18) + case 13: return NSPoint(x: 100, y: h / 2) + case 14: return NSPoint(x: w / 2, y: h / 2 - 51.5) + case 15, 19: return NSPoint(x: w / 2, y: h / 2) + case 16: return NSPoint(x: 100, y: 100) + case 17: return NSPoint(x: w / 2 - 77, y: 28) + case 18: return NSPoint(x: w / 2 - 60, y: 28) + default: return nil + } + guard let view, !view.bounds.isEmpty else { return nil } + return view.convert(NSPoint(x: view.bounds.midX, y: view.bounds.midY), to: nil) + } + + static func interceptDrags(in window: NSWindow) { + target = window + let method = class_getInstanceMethod(type(of: window), #selector(NSWindow.performDrag(with:)))! + let original = unsafeBitCast(method_getImplementation(method), + to: (@convention(c) (NSWindow, Selector, NSEvent) -> Void).self) + let handler: @convention(block) (NSWindow, NSEvent) -> Void = { window, event in + MainActor.assumeIsolated { + if window === target && dragDepth == 0 { drags += 1 } + dragDepth += 1 + original(window, #selector(NSWindow.performDrag(with:)), event) + dragDepth -= 1 + } + } + implementations.append((method, method_setImplementation(method, imp_implementationWithBlock(handler)))) + // SwiftUI uses AppKit's completion-taking entry point on newer macOS releases. + let selector = NSSelectorFromString("performWindowDragWithEvent:completionHandler:") + if let method = class_getInstanceMethod(type(of: window), selector) { + let original = unsafeBitCast(method_getImplementation(method), + to: (@convention(c) (NSWindow, Selector, NSEvent, AnyObject?) -> Void).self) + let handler: @convention(block) (NSWindow, NSEvent, AnyObject?) -> Void = { window, event, done in + MainActor.assumeIsolated { + if window === target && dragDepth == 0 { drags += 1 } + dragDepth += 1 + original(window, selector, event, done) + dragDepth -= 1 + } + } + implementations.append((method, method_setImplementation(method, imp_implementationWithBlock(handler)))) + } + } + + static func advance() { + guard ContinuousClock.now < deadline else { + finish("Window drag timed out at \(names[probe]), phase \(phase)"); return + } + guard let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return } + let model = PlayerModel.shared + let fixtures = URL(fileURLWithPath: CommandLine.arguments.last!, isDirectory: true) + if target == nil { + guard model.canTogglePlayback else { return } + interceptDrags(in: window) + model.setVolume(0) + model.setPlayback(playing: false) + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + if phase == 0 { + guard NSApp.isActive, window.isKeyWindow else { + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + model.setVolume(0) + switch probe { + case 5: model.closeFile() + case 8: + for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { + let event = NSEvent.mouseEvent(with: type, location: NSPoint(x: 400, y: 272), + modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, context: nil, eventNumber: type == .leftMouseDown ? 1 : 2, + clickCount: 1, pressure: type == .leftMouseDown ? 1 : 0)! + NSApp.postEvent(NSEvent(cgEvent: event.cgEvent!)!, atStart: false) + } + case 9: + model.open(fixtures.appendingPathComponent("cache.mp4")) + model.showsQueue = true + case 13: + model.showsQueue = false + model.openYouTube("not a video URL") + case 15: model.open(fixtures.appendingPathComponent("audio.wav")) + case 16: model.open(fixtures.appendingPathComponent("cache.mp4")) + case 19: model.hideControls(hideCursor: false) + default: break + } + previousDrags = drags + } else if phase == 1 { + guard !model.isQueueTransitioning, !model.compactWindow.isTransitioning, !model.isBusy else { return } + if probe == 16 && !model.isCompact { model.toggleCompact(); return } + // Posted events do not move the physical pointer into the compact window. + if probe != 19 { model.setPointerInsideWindow(true) } + guard let point = point(in: window) else { return } + start = point + } else if (2...4).contains(phase) { + let type: NSEvent.EventType = phase == 2 ? .leftMouseDown : (phase == 3 ? .leftMouseDragged : .leftMouseUp) + let point = phase == 2 ? start : NSPoint(x: start.x + 20, y: start.y + 48) + let event = NSEvent.mouseEvent(with: type, location: point, modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, + context: nil, eventNumber: probe * 8 + phase, clickCount: 1, pressure: phase == 4 ? 0 : 1)! + let native = event.cgEvent! + if type == .leftMouseDragged { + native.setDoubleValueField(.mouseEventDeltaX, value: 20) + native.setDoubleValueField(.mouseEventDeltaY, value: -48) + } + NSApp.postEvent(NSEvent(cgEvent: native)!, atStart: false) + } else if phase == 6 { + let expected = controls.contains(probe) ? 0 : 1 + guard drags - previousDrags == expected else { + finish("\(names[probe]): expected \(expected) native drag requests, got \(drags - previousDrags)"); return + } + probe += 1 + if probe == names.count { finish(); return } + phase = 0 + return + } + phase += 1 + } +} + @MainActor private enum QueueDragCheck { static var timer: Timer? diff --git a/Checks/compact-window.swift b/Checks/compact-window.swift index b854c0f..5e6360a 100644 --- a/Checks/compact-window.swift +++ b/Checks/compact-window.swift @@ -211,8 +211,6 @@ struct CompactWindowChecks { "compact removes incompatible primary and auxiliary policies") try CompactWindowChecks.require(window.contentMinSize == NSSize(width: 160, height: 90), "compact uses 160x90 content minimum") - try CompactWindowChecks.require(window.isMovableByWindowBackground, - "compact allows background dragging") try CompactWindowChecks.require(window.canBecomeKey, "compact accepts keyboard playback controls") try CompactWindowChecks.require(window.titleVisibility == .hidden && window.titlebarAppearsTransparent, diff --git a/Checks/system-playback.swift b/Checks/system-playback.swift index ed1a508..ab45aef 100644 --- a/Checks/system-playback.swift +++ b/Checks/system-playback.swift @@ -47,9 +47,9 @@ struct SystemPlaybackChecks { window.isReleasedWhenClosed = false defer { window.close() } let surface = PlaybackSurface.DragView(model: model) - surface.frame = window.contentView!.bounds window.contentView!.addSubview(surface) model.attachWindow(window) + surface.frame = window.contentView!.bounds @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, diff --git a/README.md b/README.md index d27c887..b34af76 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ 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. -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. +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. diff --git a/Sources/App/CompactWindow.swift b/Sources/App/CompactWindow.swift index 690414a..4d6d1e3 100644 --- a/Sources/App/CompactWindow.swift +++ b/Sources/App/CompactWindow.swift @@ -334,7 +334,8 @@ final class CompactWindowController: NSObject { window.collectionBehavior = behavior window.level = .floating window.hidesOnDeactivate = false - window.isMovableByWindowBackground = true + // Explicit drag surfaces preserve controls' mouse tracking in compact mode too. + window.isMovableByWindowBackground = false fitCompactWindow(window, initial: true, animated: animated) } diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index 67fe62b..fb9fcdd 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -2101,6 +2101,7 @@ private struct PlayerContent: View { @ViewState private var queueFrames: [UUID: CGRect] = [:] @ViewState private var queueDrag: PlaybackQueueDrag? @GestureState private var queueDragging = false + @GestureState private var windowDragging = false @ViewState private var queueScroll = ScrollPosition(y: 0) @ViewState private var queueScrollOffset: CGFloat = 0 @ViewState private var queueViewportHeight: CGFloat = 0 @@ -2228,6 +2229,8 @@ private struct PlayerContent: View { .padding(.horizontal, size.width < 600 ? 14 : 30) .padding(.top, 64) .padding(.bottom, 24) + .frame(maxWidth: .infinity, minHeight: size.height, alignment: .topLeading) + .background(windowDragBackground) } .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -2337,6 +2340,7 @@ private struct PlayerContent: View { .padding(.trailing, 92 - 88 * model.queueWidth / 260) .frame(height: 32) .foregroundStyle(Color(SunsetPalette.text)) + .background(windowDragBackground) .onHover(perform: model.setTitleHovered) .animation(controlsVisibilityAnimation) { content in content.opacity(model.controlsVisible ? 1 : 0) @@ -2357,6 +2361,7 @@ private struct PlayerContent: View { } .padding(.horizontal, 4 * (1 - model.queueWidth / 260)) .frame(height: 24) + .background(windowDragBackground) .background { PlayerGlass(cornerRadius: 8) .clipShape(RoundedRectangle(cornerRadius: 8)) @@ -2434,6 +2439,7 @@ private struct PlayerContent: View { .padding(.horizontal, 12) .padding(.vertical, showsBufferReadout ? 6 : 8) .foregroundStyle(Color(SunsetPalette.text)) + .background(windowDragBackground) .background(PlayerGlass(cornerRadius: 10).clipShape(RoundedRectangle(cornerRadius: 10))) .onHover(perform: model.setControlsHovered) .animation(controlsVisibilityAnimation) { content in @@ -2673,6 +2679,8 @@ private struct PlayerContent: View { .highPriorityGesture(queueRowDrag(item)) } } + .frame(maxWidth: .infinity, minHeight: queueViewportHeight, alignment: .top) + .background(windowDragBackground) .coordinateSpace(name: "queueContent") .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { queueContentHeight = $0 } } @@ -2743,6 +2751,7 @@ private struct PlayerContent: View { } .padding(.horizontal, 12) .padding(.vertical, 8) + .background(windowDragBackground) .background { queueChromeBackground(top: false) } } .frame(width: 260) @@ -2794,6 +2803,14 @@ private struct PlayerContent: View { .foregroundStyle(Color(SunsetPalette.text)) .tint(Color(SunsetPalette.accent)) .background(WindowChrome(model: model)) + .onChange(of: windowDragging) { _, active in model.setControlsDragging(active) } + } + + private var windowDragBackground: some View { + Color.clear.contentShape(Rectangle()) + .gesture(WindowDragGesture() + .updating($windowDragging) { _, active, _ in active = true }) + .padding(5) // Preserve the native resize border. } private var videoContent: some View { @@ -2837,6 +2854,7 @@ private struct PlayerContent: View { .background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false)) .padding(.top, 44).padding(.trailing, 16) .frame(maxWidth: .infinity, alignment: .trailing) + .allowsHitTesting(false) } if let error = model.errorText { VStack(spacing: 12) { @@ -2849,6 +2867,7 @@ private struct PlayerContent: View { .frame(maxWidth: 420) .background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false)) .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(windowDragBackground) } else if model.isBusy { VStack(spacing: 12) { ProgressView().controlSize(.large) @@ -2857,6 +2876,7 @@ private struct PlayerContent: View { .font(.callout).foregroundStyle(Color(SunsetPalette.secondaryText)) } .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) } else if model.fileName == nil { startScreen(in: geometry.size) } else if let device = model.castDevice { @@ -2867,6 +2887,7 @@ private struct PlayerContent: View { } .foregroundStyle(Color(SunsetPalette.text)) .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(windowDragBackground) } else if !model.state.has_video && model.state.has_audio { VStack(spacing: 16) { Image(systemName: "waveform").font(.system(size: 64, weight: .ultraLight)) @@ -2875,6 +2896,7 @@ private struct PlayerContent: View { .foregroundStyle(Color(SunsetPalette.secondaryText)) .padding(40) .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) } if model.state.buffering && !model.isBusy && model.errorText == nil { ProgressView("Buffering…") @@ -2928,6 +2950,7 @@ private struct PlayerContent: View { if model.canTogglePlayback { playbackControls .frame(width: 440, height: controlHeight) + .background(windowDragBackground) .background { PlayerGlass() .clipShape(RoundedRectangle(cornerRadius: 12)) diff --git a/TODO.md b/TODO.md index bc18c80..feedfb1 100644 --- a/TODO.md +++ b/TODO.md @@ -10,6 +10,9 @@ - local seek preroll skips disposable non-reference pictures without sacrificing exact target frames; measured 1080p/10-second-GOP paused seek improved from 541 ms to 400 ms and playing seek from 682 ms to 488 ms; timestamp/pixel checks passed across six decoder formats; long-GOP reference decoding remains necessary, and the originally affected files still need manual verification - [x] make the sidebar expand/collapse with a nice transition - synchronized 250 ms sidebar/window reveal, reversible mid-transition, with immediate Reduce Motion changes; top-right controls stay anchored to the moving window corner; native checks passed for rendered controls during transitions, stable video size, reversals, screen-edge fitting, fullscreen and compact restoration; animation feel and Reduce Motion still need manual verification -- [ ] make the window draggable (in all cases/view states) so long as a button/clickable element is not hovered. -- [ ] in the "choose what to play" screen, we should change the youtube button to general streaming - - we can figure out the stream and how to handle it from the url (i.e. if youtube url, handle as youtube stream; if miruro url, handle accordingly) \ No newline at end of file +- [x] make the window draggable (in all cases/view states) so long as a button/clickable element is not hovered. + - scoped native background dragging across start/playback/queue/error/Cast/compact views, with passive loading/audio overlays passing input through; 20 native input-routing probes passed, including controls and row-reorder exclusions; physical pointer dragging and Cast-screen interaction still need manual verification +- [ ] on the start screen: + - [ ] make the youtube url input field that appears when clicking the youtube button appear with a nice transition + - [ ] change the youtube button to general streaming + - we can figure out the stream and how to handle it from the url (i.e. if youtube url, handle as youtube stream; if miruro url, handle accordingly) \ No newline at end of file