diff --git a/Checks/app-startup.swift b/Checks/app-startup.swift index ed2b8b1..8f21c87 100644 --- a/Checks/app-startup.swift +++ b/Checks/app-startup.swift @@ -275,6 +275,10 @@ private enum WindowDragCheck { } static func interceptDrags(in window: NSWindow) { + for (method, original) in implementations.reversed() { + imp_removeBlock(method_setImplementation(method, original)) + } + implementations.removeAll() target = window let method = class_getInstanceMethod(type(of: window), #selector(NSWindow.performDrag(with:)))! let original = unsafeBitCast(method_getImplementation(method), @@ -309,8 +313,9 @@ private enum WindowDragCheck { 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 + guard let window = model.compactWindow.presentationWindow + ?? NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return } let fixtures = URL(fileURLWithPath: CommandLine.arguments.last!, isDirectory: true) if target == nil { guard model.canTogglePlayback else { return } @@ -322,10 +327,11 @@ private enum WindowDragCheck { window.makeMain() NSApp.activate(ignoringOtherApps: true) } + if target !== window { interceptDrags(in: window) } if phase == 0 { - guard NSApp.isActive, window.isKeyWindow, window.isMainWindow else { + guard NSApp.isActive, window.isKeyWindow, !window.canBecomeMain || window.isMainWindow else { window.makeKeyAndOrderFront(nil) - window.makeMain() + if window.canBecomeMain { window.makeMain() } NSApp.activate(ignoringOtherApps: true) return } @@ -588,7 +594,10 @@ private enum WindowLayoutCheck { static var timer: Timer? static var resultURL: URL! static var step = 0 - static var windowIdentity: ObjectIdentifier? + static var normalWindow: NSWindow? + static var renderer: NSView? + static var content: NSView? + static var activationPolicy: NSApplication.ActivationPolicy? static var normalFrame = NSRect.zero static var compactFrame = NSRect.zero static var animationStart: NSRect? @@ -676,7 +685,28 @@ private enum WindowLayoutCheck { let model = PlayerModel.shared guard ContinuousClock.now < deadline else { finish("Window layout did not settle at step \(step)"); return } guard model.canTogglePlayback, model.state.has_video, - let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return } + let normal = normalWindow ?? NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }), + let video = model.videoView else { return } + let window = model.compactWindow.presentationWindow ?? normal + let hostedContent = model.hostedPlayerView ?? window.contentView + if normalWindow == nil { + normalWindow = normal + renderer = video + content = hostedContent + activationPolicy = NSApp.activationPolicy() + } + guard video === renderer, hostedContent === content, hostedContent?.window === window, + video.window === window, NSApp.activationPolicy() == activationPolicy else { + finish("Transition replaced the content/renderer or changed app activation policy"); return + } + if window !== normal { + guard let panel = window as? NSPanel, panel.styleMask.contains(.nonactivatingPanel), + !normal.isVisible, !normal.isKeyWindow else { + finish("Compact presentation is not an independent nonactivating panel hiding the normal window"); return + } + } else if model.isCompact { + finish("Compact playback retained the normal window"); return + } if model.compactWindow.isTransitioning || model.isQueueTransitioning { window.layoutIfNeeded() if model.isQueueTransitioning && !model.isCompact && !sidebarHeaderIsVisible(in: window) { @@ -718,8 +748,9 @@ private enum WindowLayoutCheck { queueVideoSize = nil observingQueue = false } - if windowIdentity == nil { windowIdentity = ObjectIdentifier(window) } - guard ObjectIdentifier(window) == windowIdentity else { finish("Transition replaced the playback window"); return } + guard model.isCompact ? window !== normal : window === normal else { + finish("Settled presentation does not match the requested mode"); return + } if model.fullScreenIntent != nil { return } window.layoutIfNeeded() if compactQueueOnly && step == 0 { @@ -1079,20 +1110,21 @@ private enum CLIInstanceCheck { let model = PlayerModel.shared let pid = ProcessInfo.processInfo.processIdentifier // Text input can create non-main auxiliary windows; count actual player windows. - let windows = NSApp.windows.filter { $0.canBecomeMain && !($0 is NSPanel) && $0.contentView != nil } + let windows = NSApp.windows.filter { $0.canBecomeMain && !($0 is NSPanel) } + let presentation = model.compactWindow.presentationWindow ?? windows.first for window in windows { window.alphaValue = 0 } let request = directory.appendingPathComponent("keys-\(pid).json") var pendingRequestID = "" if let data = try? Data(contentsOf: request), let command = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let id = command["id"] as? String, id != requestID, let window = windows.first { + let id = command["id"] as? String, id != requestID, let window = presentation { pendingRequestID = id - if !NSApp.isActive || !window.isKeyWindow || !window.isMainWindow { + if !NSApp.isActive || !window.isKeyWindow || window.canBecomeMain && !window.isMainWindow { // Synthetic native key requests need foreground focus; cooperative activation // can be refused by the unrelated app currently active on the workstation. NSApp.activate(ignoringOtherApps: true) window.makeKeyAndOrderFront(nil) - window.makeMain() + if window.canBecomeMain { window.makeMain() } } else { requestID = id if let media = command["media"] as? String { model.open(URL(fileURLWithPath: media)) } @@ -1140,8 +1172,9 @@ private enum CLIInstanceCheck { } } if let events = command["events"] as? [[String: Any]] { - window.makeKey() for input in events { + let window = model.compactWindow.presentationWindow ?? window + window.makeKey() if let title = input["select"] as? String { guard let menu = NSApp.mainMenu else { fatalError("Native application menu is missing") } prepareMenu(menu) @@ -1206,14 +1239,14 @@ private enum CLIInstanceCheck { "requestID": requestID, "pendingRequestID": pendingRequestID, "active": NSApp.isActive, - "keyWindow": windows.first?.isKeyWindow ?? false, - "mainWindow": windows.first?.isMainWindow ?? false, + "keyWindow": presentation?.isKeyWindow ?? false, + "mainWindow": presentation?.isMainWindow ?? false, "editorText": editorText, "compact": model.isCompact, "showsQueue": model.showsQueue, "queueURLs": model.queue.items.map(\.url.absoluteString), "queueIndex": model.queue.items.firstIndex { $0.id == model.queue.currentID } ?? -1, - "window": windowSnapshot(windows.first), + "window": windowSnapshot(presentation), "onTop": model.isOnTop, ] do { diff --git a/Checks/compact-window.swift b/Checks/compact-window.swift index 4e27550..31040f1 100644 --- a/Checks/compact-window.swift +++ b/Checks/compact-window.swift @@ -99,12 +99,20 @@ struct CompactWindowChecks { line: UInt = #line, checking: () throws -> Void = {}) throws { let deadline = ContinuousClock.now.advanced(by: .seconds(5)) while controller.isTransitioning { + if let presentation = controller.presentationWindow { + try require(!presentation.isVisible && !presentation.isKeyWindow, + "native transitions never display or key a hidden presentation") + } try checking() try require(ContinuousClock.now < deadline, "native compact transition completes (call at line \(line), compact \(controller.isCompact), target \(compact))") CFRunLoopRunInMode(.defaultMode, 0.01, false) } try checking() try require(controller.isCompact == compact, "settled mode matches the latest destination") + if let presentation = controller.presentationWindow { + try require(!presentation.isVisible && !presentation.isKeyWindow, + "settled hidden presentation remains undisplayed and non-key") + } } @MainActor @@ -151,6 +159,12 @@ struct CompactWindowChecks { } } + @MainActor + final class WindowDelegate: NSObject, NSWindowDelegate { + var closes = 0 + func windowWillClose(_ notification: Notification) { closes += 1 } + } + @MainActor final class WindowFixture { let window: NSWindow @@ -158,7 +172,8 @@ struct CompactWindowChecks { let sentinel: NSView let sentinelFrame: NSRect let original: WindowState - let identity: ObjectIdentifier + let delegate = WindowDelegate() + let activationPolicy: NSApplication.ActivationPolicy init() throws { // Never orderFront, makeKey, or create an actual native fullscreen Space. @@ -180,19 +195,24 @@ struct CompactWindowChecks { window.level = .normal window.hidesOnDeactivate = true window.isMovableByWindowBackground = false + window.delegate = delegate original = WindowState(window) - identity = ObjectIdentifier(window) + activationPolicy = NSApp.activationPolicy() } - func preservedContent() throws { - try CompactWindowChecks.require(ObjectIdentifier(window) == identity && sentinel.window === window, - "compact mode keeps the actual rendering window") - try CompactWindowChecks.require(window.contentView === content && sentinel.superview === content + func preservedContent(in presentation: NSWindow? = nil) throws { + let presentation = presentation ?? window + try CompactWindowChecks.require(sentinel.window === presentation + && presentation.contentView === content + && sentinel.superview === content && content.subviews.count == 1 && CompactWindowChecks.sameFrame(sentinel.frame, sentinelFrame), - "compact mode preserves content and attached sentinel without reparenting") - try CompactWindowChecks.require(!window.isVisible && !window.isKeyWindow, - "checks never display or key a window") + "presentation retains the same content subtree and renderer sentinel") + try CompactWindowChecks.require(!window.isVisible && !window.isKeyWindow + && !presentation.isVisible && !presentation.isKeyWindow, + "hidden checks never display or key either window") + try CompactWindowChecks.require(NSApp.activationPolicy() == activationPolicy, + "compact preserves the app's Dock and CmdTab activation policy") } func restored() throws { @@ -200,28 +220,35 @@ struct CompactWindowChecks { try preservedContent() } - func compact(aspect: Double) throws { - try CompactWindowChecks.require(window.level == .floating && !window.hidesOnDeactivate, + func compact(_ controller: CompactWindowController, aspect: Double) throws { + guard let panel = controller.presentationWindow as? NSPanel else { + throw Failure(description: "FAIL: compact presents a dedicated NSPanel") + } + try CompactWindowChecks.require(panel !== window && panel.styleMask.contains(.nonactivatingPanel), + "compact uses a nonactivating panel, not the normal window") + try CompactWindowChecks.require(window.contentView !== content, + "hidden normal window relinquishes the renderer content") + try original.requireMatches(window) + try CompactWindowChecks.require(panel.level == .floating && !panel.hidesOnDeactivate, "compact floats and remains visible on deactivate") - try CompactWindowChecks.require(window.collectionBehavior.contains( + try CompactWindowChecks.require(panel.collectionBehavior.contains( [.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]), "compact can join Spaces, other-app fullscreen, and Stage Manager") - try CompactWindowChecks.require(window.collectionBehavior.intersection( + try CompactWindowChecks.require(panel.collectionBehavior.intersection( [.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone]).isEmpty, "compact removes incompatible primary and auxiliary policies") - try CompactWindowChecks.require(window.contentMinSize == NSSize(width: 160, height: 90), + try CompactWindowChecks.require(panel.contentMinSize == NSSize(width: 160, height: 90), "compact uses 160x90 content minimum") - try CompactWindowChecks.require(window.canBecomeKey, "compact accepts keyboard playback controls") - try CompactWindowChecks.require(window.titleVisibility == .hidden - && window.titlebarAppearsTransparent, + try CompactWindowChecks.require(panel.canBecomeKey, "compact accepts keyboard playback controls") + try CompactWindowChecks.require(panel.titleVisibility == .hidden && panel.titlebarAppearsTransparent, "compact hides native title chrome") - let visible = (window.screen ?? NSScreen.main!).visibleFrame - try CompactWindowChecks.require(CompactWindowChecks.close(window.frame.maxX, visible.maxX - 16) - && CompactWindowChecks.close(window.frame.maxY, visible.maxY - 16) - && CompactWindowChecks.close(window.frame.width / window.frame.height, + let visible = (panel.screen ?? NSScreen.main!).visibleFrame + try CompactWindowChecks.require(CompactWindowChecks.close(panel.frame.maxX, visible.maxX - 16) + && CompactWindowChecks.close(panel.frame.maxY, visible.maxY - 16) + && CompactWindowChecks.close(panel.frame.width / panel.frame.height, CGFloat(aspect)), "compact enters at inset top right with playback aspect") - try preservedContent() + try preservedContent(in: panel) } } @@ -238,7 +265,7 @@ struct CompactWindowChecks { controller.toggle(aspect: 16.0 / 9.0) try require(controller.isTransitioning, "native completion barrier is an actual animated entry") try settled(controller, compact: true, checking: checking) - try fixture.compact(aspect: 16.0 / 9.0) + try fixture.compact(controller, aspect: 16.0 / 9.0) controller.restore(resumeFullScreen: false) try settled(controller, compact: false, checking: checking) try fixture.restored() @@ -257,47 +284,53 @@ struct CompactWindowChecks { window.close() } controller.attach(window) - try require(!controller.isCompact && !controller.isTransitioning, "attached window starts ordinary") + try require(!controller.isCompact && !controller.isTransitioning + && controller.presentationWindow === window, "attached window starts ordinary") try require(controller.snappingEnabled, "isolated controller defaults snapping on") controller.toggle(aspect: 16.0 / 9.0) try require(controller.isCompact && controller.isTransitioning, "entry presents the compact destination while native geometry settles") - try fixture.preservedContent() + try fixture.preservedContent(in: controller.presentationWindow) try settled(controller, compact: true) - try fixture.compact(aspect: 16.0 / 9.0) + try fixture.compact(controller, aspect: 16.0 / 9.0) - let visible = (window.screen ?? NSScreen.main!).visibleFrame + let panel = controller.presentationWindow! + let visible = (panel.screen ?? NSScreen.main!).visibleFrame let nearEdge = NSRect(x: visible.minX + 26, y: visible.minY + 27, - width: window.frame.width, height: window.frame.height) - window.setFrame(nearEdge, display: false) - try require(sameFrame(window.frame, nearEdge), "native move installs unsnapped drag position") + width: panel.frame.width, height: panel.frame.height) + panel.setFrame(nearEdge, display: false) + try require(sameFrame(panel.frame, nearEdge), "native move installs unsnapped drag position") controller.snapAfterDrag() - try require(close(window.frame.minX, visible.minX + 16) && close(window.frame.minY, visible.minY + 16) - && window.frame.size == nearEdge.size, "enabled preference snaps the actual moved window") + try require(close(panel.frame.minX, visible.minX + 16) && close(panel.frame.minY, visible.minY + 16) + && panel.frame.size == nearEdge.size, "enabled preference snaps the actual moved window") controller.snappingEnabled = false let second = CompactWindowController(snappingEnabled: false, reduceMotion: { false }) try require(!second.snappingEnabled, "configuration can disable snapping for a new controller") - window.setFrame(nearEdge, display: false) + panel.setFrame(nearEdge, display: false) controller.snapAfterDrag() - try require(sameFrame(window.frame, nearEdge), "disabled preference leaves an actual moved window untouched") + try require(sameFrame(panel.frame, nearEdge), "disabled preference leaves an actual moved window untouched") second.snappingEnabled = true let third = CompactWindowController(snappingEnabled: false, reduceMotion: { false }) try require(!third.snappingEnabled, "a session override does not change the next controller's settings") controller.snappingEnabled = true let resized = NSRect(x: nearEdge.minX, y: nearEdge.minY, width: 240, height: 135) - window.setFrame(resized, display: false) + panel.setFrame(resized, display: false) controller.updateAspect(16.0 / 9.0) - try require(sameFrame(window.frame, resized), + try require(sameFrame(panel.frame, resized), "repeated aspect updates preserve a user's compact resize and placement") controller.updateAspect(4.0 / 3.0) try require(controller.isCompact && !controller.isTransitioning, "aspect update retains compact mode") - try require(close(window.frame.width / window.frame.height, 4.0 / 3.0), + try require(close(panel.frame.width / panel.frame.height, 4.0 / 3.0), "actual compact window follows changed playback aspect") - try fixture.preservedContent() + try fixture.preservedContent(in: panel) controller.toggle(aspect: 4.0 / 3.0) + try require(controller.presentationWindow === panel, + "restoring continues presenting the panel until content is handed back") try settled(controller, compact: false) try fixture.restored() + try require(controller.presentationWindow === window && panel.contentView !== fixture.content + && !panel.isVisible && !panel.isKeyWindow, "restoration retires the compact panel") // Enter -> exit before entry completes: the original normal snapshot must survive. controller.toggle(aspect: 16.0 / 9.0) @@ -314,7 +347,7 @@ struct CompactWindowChecks { "exit presents the ordinary destination while native geometry settles") controller.toggle(aspect: 4.0 / 3.0) try settled(controller, compact: true) - try fixture.compact(aspect: 4.0 / 3.0) + try fixture.compact(controller, aspect: 4.0 / 3.0) controller.restore(resumeFullScreen: false) try settled(controller, compact: false) try fixture.restored() @@ -328,7 +361,7 @@ struct CompactWindowChecks { } try settled(controller, compact: compactDestination) if compactDestination { - try fixture.compact(aspect: 16.0 / 9.0) + try fixture.compact(controller, aspect: 16.0 / 9.0) controller.restore(resumeFullScreen: false) try settled(controller, compact: false) } @@ -354,17 +387,39 @@ struct CompactWindowChecks { "stale completion cannot revive an urgently restored session") try fixture.restored() } + controller.toggle(aspect: 16.0 / 9.0) + try settled(controller, compact: true) + let interruptedPanel = controller.presentationWindow! + controller.restore(resumeFullScreen: false) + try require(controller.isTransitioning && controller.presentationWindow === interruptedPanel, + "animated restoration keeps presenting the renderer's panel") + try fixture.preservedContent(in: interruptedPanel) + controller.restore(resumeFullScreen: false, animated: false) + try require(!controller.isTransitioning && controller.presentationWindow === window + && interruptedPanel.contentView !== fixture.content, + "urgent restore interrupts exit and returns content immediately") + try fixture.restored() + try nativeCompletionOpportunity { + try require(controller.presentationWindow === window && !controller.isTransitioning, + "stale exit completion cannot reclaim restored content") + try fixture.restored() + } let replacement = try WindowFixture() defer { replacement.window.close() } controller.toggle(aspect: 16.0 / 9.0) try require(controller.isTransitioning, "attachment interrupts native entry") + let replacedPanel = controller.presentationWindow! controller.attach(replacement.window) try require(!controller.isCompact && !controller.isTransitioning, "replacement starts ordinary") try fixture.restored() + try require(controller.presentationWindow === replacement.window + && window.delegate === fixture.delegate && replacedPanel.contentView !== fixture.content + && !replacedPanel.isVisible && !replacedPanel.isKeyWindow, + "replacement restores old delegate and content and retires its panel") controller.toggle(aspect: 4.0 / 3.0) try settled(controller, compact: true) { try fixture.restored() } - try replacement.compact(aspect: 4.0 / 3.0) + try replacement.compact(controller, aspect: 4.0 / 3.0) controller.restore(resumeFullScreen: false) try settled(controller, compact: false) { try fixture.restored() } try replacement.restored() @@ -386,8 +441,13 @@ struct CompactWindowChecks { controller.toggle(aspect: 16.0 / 9.0) try require(controller.isTransitioning, "close interrupts native entry") + let closingPanel = controller.presentationWindow! window.close() try require(!controller.isCompact && !controller.isTransitioning, "closed window ends its session") + try require(controller.presentationWindow == nil && window.delegate === fixture.delegate + && fixture.delegate.closes == 1 && closingPanel.contentView !== fixture.content + && !closingPanel.isVisible && !closingPanel.isKeyWindow, + "closing the normal window restores its delegate and closes its compact panel") let closedState = WindowState(window) controller.attach(replacement.window) controller.toggle(aspect: 16.0 / 9.0) @@ -396,10 +456,23 @@ struct CompactWindowChecks { try fixture.preservedContent() } try settled(controller, compact: true, checking: closedWindowUnchanged) - try replacement.compact(aspect: 16.0 / 9.0) + try replacement.compact(controller, aspect: 16.0 / 9.0) controller.restore(resumeFullScreen: false) try settled(controller, compact: false, checking: closedWindowUnchanged) try replacement.restored() + controller.toggle(aspect: 16.0 / 9.0) + let userClosedPanel = controller.presentationWindow! + userClosedPanel.close() + try require(!controller.isCompact && !controller.isTransitioning + && controller.presentationWindow == nil && replacement.delegate.closes == 1 + && replacement.window.delegate === replacement.delegate, + "closing compact presentation also closes and detaches the normal window") + try replacement.restored() + try nativeCompletionOpportunity { + try require(controller.presentationWindow == nil && !controller.isTransitioning, + "stale completion cannot revive a user-closed compact panel") + try replacement.restored() + } } static func rememberedSize() throws { @@ -414,19 +487,21 @@ struct CompactWindowChecks { } controller.attach(fixture.window) controller.toggle(aspect: 16.0 / 9.0) - var resized = fixture.window.frame + let panel = controller.presentationWindow! + var resized = panel.frame resized.size = NSSize(width: 240, height: 135) - fixture.window.setFrame(resized, display: false) - NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: fixture.window) + panel.setFrame(resized, display: false) + NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: panel) controller.restore(resumeFullScreen: false) try fixture.restored() controller.toggle(aspect: 16.0 / 9.0) - try require(close(fixture.window.frame.width, 240) && close(fixture.window.frame.height, 135), + let reentered = controller.presentationWindow! + try require(close(reentered.frame.width, 240) && close(reentered.frame.height, 135), "reentering compact restores the manually chosen size") // Programmatic fitting and ordinary-window resizing must not replace the preference. resized.size = NSSize(width: 320, height: 180) - fixture.window.setFrame(resized, display: false) + reentered.setFrame(resized, display: false) controller.updateAspect(4.0 / 3.0) controller.restore(resumeFullScreen: false) try fixture.restored() @@ -440,9 +515,10 @@ struct CompactWindowChecks { } reloaded.attach(replacement.window) reloaded.toggle(aspect: 4.0 / 3.0) - try require(close(replacement.window.frame.width, 240) && close(replacement.window.frame.height, 180), + let reloadedPanel = reloaded.presentationWindow! + try require(close(reloadedPanel.frame.width, 240) && close(reloadedPanel.frame.height, 180), "a recreated controller restores preferred width with the new video's aspect") - try replacement.compact(aspect: 4.0 / 3.0) + try replacement.compact(reloaded, aspect: 4.0 / 3.0) reloaded.restore(resumeFullScreen: false) try replacement.restored() } @@ -458,7 +534,7 @@ struct CompactWindowChecks { controller.toggle(aspect: 16.0 / 9.0) try require(controller.isCompact && !controller.isTransitioning, "Reduce Motion enters with geometry and controls already settled") - try fixture.compact(aspect: 16.0 / 9.0) + try fixture.compact(controller, aspect: 16.0 / 9.0) controller.toggle(aspect: 16.0 / 9.0) try require(!controller.isCompact && !controller.isTransitioning, "Reduce Motion restores without a pending frame animation") @@ -507,13 +583,14 @@ struct CompactWindowChecks { static func compactQueue() throws { let fixture = try WindowFixture() let controller = CompactWindowController(reduceMotion: { true }) - let window = fixture.window + let normal = fixture.window defer { controller.restore(resumeFullScreen: false, animated: false) - window.close() + normal.close() } - controller.attach(window) + controller.attach(normal) controller.toggle(aspect: 16.0 / 9.0) + let window = controller.presentationWindow! try require(controller.queueHeight == 0, "compact entry hides the queue") let videoFrame = window.frame let expanded = controller.queueLayout(visible: true)! @@ -559,7 +636,7 @@ struct CompactWindowChecks { try fixture.restored() controller.toggle(aspect: 4.0 / 3.0) try require(controller.queueHeight == 0, "each compact entry resets queue visibility") - try fixture.compact(aspect: 4.0 / 3.0) + try fixture.compact(controller, aspect: 4.0 / 3.0) controller.restore(resumeFullScreen: false) try fixture.restored() } diff --git a/Checks/system-playback.swift b/Checks/system-playback.swift index ab45aef..9cacb95 100644 --- a/Checks/system-playback.swift +++ b/Checks/system-playback.swift @@ -169,6 +169,7 @@ struct SystemPlaybackChecks { styleMask: [.titled, .resizable], backing: .buffered, defer: false) window.isReleasedWhenClosed = false defer { window.close() } + model.attachWindow(window) 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, @@ -252,6 +253,33 @@ struct SystemPlaybackChecks { let modal = NSApp.beginModalSession(for: window) require(!model.handleKeyEvent(shortcuts[0], in: window), "modal session blocks player shortcuts") NSApp.endModalSession(modal) + window.orderOut(nil) + model.setVolume(0) + model.open(URL(fileURLWithPath: CommandLine.arguments[1]).appendingPathComponent("cache.mp4")) + until("keyboard consumer has actual video playback") { model.canTogglePlayback && model.state.has_video } + model.setPlayback(playing: false) + until("keyboard consumer playback pauses") { model.state.status == PLAYER_PAUSED } + require(model.perform(.enterCompact), "video playback accepts compact entry") + until("keyboard consumer compact entry settles") { !model.compactWindow.isTransitioning } + guard let panel = model.compactWindow.presentationWindow as? NSPanel else { + fatalError("FAIL: keyboard consumer requires the compact presentation panel") + } + model.setShowsBufferInfo(false) + require(!model.handleKeyEvent(shortcuts[4], in: window) && !model.showsBufferInfo, + "hidden normal window cannot dispatch compact playback shortcuts") + let compactShortcut = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: .command, + timestamp: 0, windowNumber: panel.windowNumber, context: nil, characters: "b", + charactersIgnoringModifiers: "b", isARepeat: false, keyCode: 11)! + require(model.handleKeyEvent(compactShortcut, in: panel) && model.showsBufferInfo, + "active compact panel dispatches the configured playback shortcut") + require(!window.isVisible && !window.isKeyWindow && !panel.isVisible && !panel.isKeyWindow, + "keyboard consumer checks never display or key either presentation") + model.compactWindow.restore(resumeFullScreen: false, animated: false) + model.setShowsBufferInfo(false) + focus(lists[0]) + require(model.handleKeyEvent(shortcuts[4], in: window) && model.showsBufferInfo + && model.compactWindow.presentationWindow === window, + "restored normal window resumes dispatching playback shortcuts") } static func main() { @@ -296,7 +324,12 @@ struct SystemPlaybackChecks { 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) + model.open(directory.appendingPathComponent("cache.mp4")) + until("queue geometry fixture has actual video playback") { model.canTogglePlayback && model.state.has_video } let videoFrame = window.frame model.showsQueue = true until("queue opening settles") { !model.isQueueTransitioning } @@ -308,16 +341,30 @@ struct SystemPlaybackChecks { require(abs(window.frame.width - videoFrame.width) < 0.01 && abs(window.frame.height - videoFrame.height) < 0.01, "closing queue restores video-only window size") + guard let renderer = model.videoView else { fatalError("FAIL: native renderer is unavailable") } + let content = window.contentView + let activationPolicy = NSApp.activationPolicy() model.open(directory.appendingPathComponent("anamorphic.mp4")) require(model.isBusy && model.perform(.enterCompact) && model.isCompact, "compact entry is available before native media opening acknowledges") until("decoded anamorphic metadata replaces provisional compact geometry") { - model.state.status == PLAYER_PLAYING && model.isCompact && !model.compactWindow.isTransitioning && - abs(window.frame.width / window.frame.height - 8.0 / 3.0) < 0.01 + guard let presentation = model.compactWindow.presentationWindow else { return false } + return model.state.status == PLAYER_PLAYING && model.isCompact && !model.compactWindow.isTransitioning && + abs(presentation.frame.width / presentation.frame.height - 8.0 / 3.0) < 0.01 } + let panel = model.compactWindow.presentationWindow! + require(panel !== window && panel is NSPanel && panel.styleMask.contains(.nonactivatingPanel) + && panel.contentView === content && model.videoView === renderer && renderer.window === panel + && !window.isVisible && !window.isKeyWindow && !panel.isVisible && !panel.isKeyWindow + && NSApp.activationPolicy() == activationPolicy, + "loading metadata resizes the compact panel without replacing renderer or app policy") model.closeFile() require(!model.isCompact && !model.compactWindow.isTransitioning, "closing loading/loaded media restores immediately") + require(model.compactWindow.presentationWindow === window && window.contentView === content + && model.videoView === renderer && renderer.window === window && !window.isVisible && !window.isKeyWindow + && panel.contentView !== content && !panel.isVisible && !panel.isKeyWindow, + "urgent media close returns retained content to the hidden normal window and retires the panel") until("anamorphic source closes before the ordinary playback checks") { model.state.status == PLAYER_EMPTY } require(!model.canPerform(.enterCompact), "empty playback does not enable compact entry") diff --git a/README.md b/README.md index 5d50bd6..654d9a0 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self - **Sponsored ranges:** optional [SponsorBlock](https://sponsor.ajay.app/) sponsor/skip ranges paint the existing track crimson without adding chapter boundaries or changing chapter gaps. Hover shows **Sponsor** alongside any chapter title, centered over the sponsored range itself even when it crosses chapter boundaries; accessibility also identifies sponsored ranges. Inside a range, **Skip Sponsor** uses the opening/ending button without a timeout or countdown fill. Clicking seeks to the range's end; automatic skipping is a separate opt-in. - **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider. Inside a segment, **Skip Opening** or **Skip Ending** appears at the top-right below the titlebar for up to ten seconds or until the segment ends; clicking seeks to its end. A light background fill grows as the offer expires, with a short entrance/exit transition; Reduce Motion uses stepped countdown updates without the transition. Ignored offers stay dismissed until the media is closed or replaced. Missing/invalid metadata leaves playback usable, and timings may differ between cuts. Automatic skipping is configurable below. - **Resume:** videos resume their saved position; finished videos restart from the beginning. Live and audio-only media do not save progress. -- **Compact mode:** press **P** for a floating player; double-click it to restore. Drag noninteractive areas to move the window. The queue starts hidden each time you enter compact mode; **⇧⌘L** or **gear → Show Queue** expands it below the video. Its top fade has a 12-point solid section and a 20-point transition, with the first row starting below both. The same shortcut toggles the normal sidebar, whose visibility is preserved when leaving compact mode. Use the titlebar pin to keep the normal window on top. +- **Compact mode:** press **P** for a floating player; double-click it to restore. It follows you across desktops and other apps' native fullscreen Spaces while keeping Terebi in the Dock and **⌘Tab**. Moving between compact and normal mode preserves the running player and restores the original window. Drag noninteractive areas to move the window. The queue starts hidden each time you enter compact mode; **⇧⌘L** or **gear → Show Queue** expands it below the video. Its top fade has a 12-point solid section and a 20-point transition, with the first row starting below both. The same shortcut toggles the normal sidebar, whose visibility is preserved when leaving compact mode. Use the titlebar pin to keep the normal window on top. - **Quality:** yt-dlp defaults to SDR up to 1080p when resolution is reported. Missing resolution/codec hints are left to native probing. Use **gear → Quality** for other reported resolutions or automatic quality. ### Keyboard shortcuts diff --git a/Sources/App/CompactWindow.swift b/Sources/App/CompactWindow.swift index 0594a3a..d2b58e7 100644 --- a/Sources/App/CompactWindow.swift +++ b/Sources/App/CompactWindow.swift @@ -7,6 +7,7 @@ final class CompactWindowController: NSObject { var normalVideoLayout: (() -> (aspect: Double, sidebarWidth: CGFloat)?)? private(set) var isCompact = false private(set) var isTransitioning = false + var presentationWindow: NSWindow? { compactPanel ?? window } private(set) var queueHeight: CGFloat = 0 static let transitionDuration: TimeInterval = 0.25 @@ -30,9 +31,13 @@ final class CompactWindowController: NSObject { let level: NSWindow.Level let hidesOnDeactivate: Bool let isMovableByWindowBackground: Bool + let isVisible: Bool + let firstResponder: NSResponder? + let contentSuperview: NSView? + let contentFrame: NSRect @MainActor - init(_ window: NSWindow) { + init(_ window: NSWindow, contentView: NSView) { frame = window.frame minSize = window.minSize contentMinSize = window.contentMinSize @@ -45,12 +50,19 @@ final class CompactWindowController: NSObject { level = window.level hidesOnDeactivate = window.hidesOnDeactivate isMovableByWindowBackground = window.isMovableByWindowBackground + isVisible = window.isVisible + firstResponder = window.firstResponder + contentSuperview = contentView === window.contentView ? nil : contentView.superview + contentFrame = contentView.frame } } private enum FullScreenTransition { case entering, exiting } private weak var window: NSWindow? + private weak var contentView: NSView? + private var compactPanel: NSPanel? + private var panelDelegate: FullScreenDelegate? private var snapshot: Snapshot? private var fullScreenTransition: FullScreenTransition? private var delegateProxy: FullScreenDelegate? @@ -77,7 +89,7 @@ final class CompactWindowController: NSObject { deinit { NotificationCenter.default.removeObserver(self) } - func attach(_ window: NSWindow) { + func attach(_ window: NSWindow, contentView: NSView? = nil) { guard window !== self.window else { return } stopFrameAnimation() if let oldWindow = self.window, fullScreenTransition == nil, @@ -87,6 +99,7 @@ final class CompactWindowController: NSObject { restoreDelegate() NotificationCenter.default.removeObserver(self) self.window = window + self.contentView = contentView ?? window.contentView snapshot = nil fullScreenTransition = nil sessionActive = false @@ -133,13 +146,13 @@ final class CompactWindowController: NSObject { func updateAspect(_ aspect: Double) { guard aspect.isFinite, aspect > 0, aspect != videoAspect else { return } videoAspect = aspect - guard isCompact, !isTransitioning, let window else { return } + guard isCompact, !isTransitioning, let window = presentationWindow else { return } fitCompactWindow(window, initial: false) } // The reveal and native frame share the model's animation clock. func queueLayout(visible: Bool) -> (height: CGFloat, frame: NSRect)? { - guard isCompact, !isTransitioning, let window else { return nil } + guard isCompact, !isTransitioning, let window = presentationWindow else { return nil } let usable = Self.usableFrame(visibleFrame(for: window)) let height = visible ? min(240, max(0, usable.height - 90)) : 0 let video = Self.compactSize(aspect: videoAspect, width: window.frame.width, @@ -152,7 +165,7 @@ final class CompactWindowController: NSObject { } func updateQueue(height: CGFloat, frame: NSRect) { - guard isCompact, let window else { return } + guard isCompact, let window = presentationWindow else { return } window.resizeIncrements = NSSize(width: 1, height: 1) queueHeight = height fittedVideoAspect = frame.width / (frame.height - height) @@ -166,7 +179,7 @@ final class CompactWindowController: NSObject { } func snapAfterDrag() { - guard isCompact, !isTransitioning, snappingEnabled, let window else { return } + guard isCompact, !isTransitioning, snappingEnabled, let window = presentationWindow else { return } let frame = Self.snappedFrame(window.frame, visibleFrame: visibleFrame(for: window)) if frame.origin != window.frame.origin { window.setFrameOrigin(frame.origin) } } @@ -174,7 +187,7 @@ final class CompactWindowController: NSObject { @objc private func windowNotification(_ notification: Notification) { switch notification.name { case NSWindow.didEndLiveResizeNotification: - guard isCompact, !isTransitioning, let window, + guard isCompact, !isTransitioning, let window = presentationWindow, window.frame.width.isFinite, window.frame.width > 0 else { return } defaults.set(Double(window.frame.width), forKey: Self.widthKey) case NSWindow.willEnterFullScreenNotification: @@ -187,7 +200,12 @@ final class CompactWindowController: NSObject { fullScreenTransition = nil reconcile() case NSWindow.willCloseNotification: + if notification.object as? NSWindow === compactPanel { + window?.close() + return + } stopFrameAnimation() + if let window { removeCompactPanel(returningContentTo: window) } restoreDelegate() NotificationCenter.default.removeObserver(self) window = nil @@ -318,7 +336,7 @@ final class CompactWindowController: NSObject { } else if !isCompact { enterCompact(window, animated: animated) } else { - if fittedAspect != videoAspect { fitCompactWindow(window, initial: false) } + if fittedAspect != videoAspect, let panel = compactPanel { fitCompactWindow(panel, initial: false) } setState(compact: true, transitioning: false) } return @@ -333,11 +351,12 @@ final class CompactWindowController: NSObject { } if let snapshot { queueHeight = 0 - setState(compact: false, transitioning: animated) - window.aspectRatio = .zero - window.minSize = .zero - window.contentMinSize = .zero - animateFrame(snapshot.frame, of: window, animated: animated) { [weak self, weak window] in + setState(compact: false, transitioning: true) + let restoringWindow = presentationWindow ?? window + restoringWindow.aspectRatio = .zero + restoringWindow.minSize = .zero + restoringWindow.contentMinSize = .zero + animateFrame(snapshot.frame, of: restoringWindow, animated: animated) { [weak self, weak window] in guard let self, let window else { return } self.restoreSnapshot(to: window) self.reconcile() @@ -357,33 +376,84 @@ final class CompactWindowController: NSObject { window.toggleFullScreen(nil) } - private func enterCompact(_ window: NSWindow, animated: Bool) { - // After didExitFullScreen, this is the normal frame, not the fullscreen frame. - snapshot = Snapshot(window) - queueHeight = 0 - setState(compact: true, transitioning: animated) - window.aspectRatio = .zero - window.minSize = .zero - window.contentMinSize = .zero - // A borderless NSWindow cannot become key; retain native keyboard and resize behavior. - window.styleMask.formUnion([.titled, .fullSizeContentView, .resizable]) + private func enterCompact(_ normalWindow: NSWindow, animated: Bool) { + // A regular NSWindow cannot join another app's native fullscreen Space. + // Keep the scene and renderer alive, but present their content in a nonactivating panel. + guard let content = contentView else { + finishSession() + return + } + let snapshot = Snapshot(normalWindow, contentView: content) + self.snapshot = snapshot + let window = NSPanel(contentRect: .zero, + styleMask: [.titled, .closable, .resizable, .fullSizeContentView, .nonactivatingPanel], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.isFloatingPanel = true + window.hidesOnDeactivate = false + window.title = normalWindow.title + window.representedURL = normalWindow.representedURL window.titleVisibility = .hidden window.titlebarAppearsTransparent = true - window.minSize = NSSize(width: 160, height: 90) - window.contentMinSize = NSSize(width: 160, height: 90) - var behavior = window.collectionBehavior - behavior.subtract([.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone, .moveToActiveSpace]) - behavior.formUnion([.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]) - window.collectionBehavior = behavior + window.titlebarSeparatorStyle = .none + window.appearance = normalWindow.appearance + window.isOpaque = normalWindow.isOpaque + window.backgroundColor = normalWindow.backgroundColor + window.hasShadow = normalWindow.hasShadow + window.alphaValue = normalWindow.alphaValue + window.ignoresMouseEvents = normalWindow.ignoresMouseEvents + window.acceptsMouseMovedEvents = normalWindow.acceptsMouseMovedEvents window.level = .floating - window.hidesOnDeactivate = false - // Explicit drag surfaces preserve controls' mouse tracking in compact mode too. + window.collectionBehavior = [.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary] window.isMovableByWindowBackground = false + window.setFrame(normalWindow.frame, display: false) + let proxy = FullScreenDelegate(owner: self, original: nil) + panelDelegate = proxy + window.delegate = proxy + compactPanel = window + for name in [NSWindow.willCloseNotification, NSWindow.didEndLiveResizeNotification] { + NotificationCenter.default.addObserver(self, selector: #selector(windowNotification(_:)), + name: name, object: window) + } + queueHeight = 0 + setState(compact: true, transitioning: animated) + normalWindow.orderOut(nil) + if content === normalWindow.contentView { normalWindow.contentView = nil } + else { content.removeFromSuperview() } + window.contentView = content + window.minSize = NSSize(width: 160, height: 90) + window.contentMinSize = window.minSize + if snapshot.isVisible { + window.orderFrontRegardless() + if NSApp.isActive { window.makeKey() } + window.makeFirstResponder(snapshot.firstResponder) + } fitCompactWindow(window, initial: true, animated: animated) } + private func removeCompactPanel(returningContentTo window: NSWindow) { + guard let panel = compactPanel else { return } + NotificationCenter.default.removeObserver(self, name: nil, object: panel) + panel.delegate = nil + panelDelegate = nil + compactPanel = nil + window.title = panel.title + window.representedURL = panel.representedURL + let content = panel.contentView + panel.contentView = nil + if let content, let snapshot, let superview = snapshot.contentSuperview { + superview.addSubview(content) + content.frame = snapshot.contentFrame + } else { + window.contentView = content + } + panel.close() + } + private func restoreSnapshot(to window: NSWindow) { guard let snapshot else { return } + let wasKey = compactPanel?.isKeyWindow ?? false + removeCompactPanel(returningContentTo: window) window.aspectRatio = .zero window.minSize = .zero window.contentMinSize = .zero @@ -402,7 +472,13 @@ final class CompactWindowController: NSObject { } else { window.aspectRatio = snapshot.aspectRatio } + if snapshot.isVisible { + if wasKey { window.makeKeyAndOrderFront(nil) } + else { window.orderFront(nil) } + window.makeFirstResponder(snapshot.firstResponder) + } self.snapshot = nil + onChange?() } private func finishSession() { @@ -429,7 +505,7 @@ final class CompactWindowController: NSObject { window.animator().setFrame(frame, display: true) } completionHandler: { [weak self, weak window] in MainActor.assumeIsolated { - guard let self, let window, self.window === window, self.frameAnimating, + guard let self, let window, self.presentationWindow === window, self.frameAnimating, self.frameAnimationGeneration == generation else { return } self.frameAnimating = false completion() @@ -441,7 +517,7 @@ final class CompactWindowController: NSObject { guard frameAnimating else { return } frameAnimating = false frameAnimationGeneration &+= 1 - guard let window else { return } + guard let window = presentationWindow else { return } NSAnimationContext.runAnimationGroup { context in context.duration = 0 window.animator().setFrame(window.frame, display: true) diff --git a/Sources/App/PlayerActions.swift b/Sources/App/PlayerActions.swift index e9c0804..ccf3c9f 100644 --- a/Sources/App/PlayerActions.swift +++ b/Sources/App/PlayerActions.swift @@ -93,7 +93,8 @@ extension PlayerModel { } private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool { - guard event.window === window, NSApp.modalWindow == nil, window.attachedSheet == nil else { return false } + guard self.window === window, event.window === window, + NSApp.modalWindow == nil, window.attachedSheet == nil else { return false } if chord.modifiers.contains(.command), keyBindings.shortcuts(for: .openStream).contains(chord) { return perform(.openStream) } @@ -127,7 +128,7 @@ extension PlayerModel { perform(action) return } - if let window = event.window ?? NSApp.keyWindow, + if let window = window ?? event.window ?? NSApp.keyWindow, !handleKeyEvent(event, in: window, chord: chord), let responder = window.firstResponder as? NSView { if !responder.performKeyEquivalent(with: event) { responder.keyDown(with: event) } @@ -245,12 +246,12 @@ struct PlayerSponsorBlockMenu: View { @MainActor struct PlayerCommands: Commands { + @ObservedObject var model: PlayerModel @FocusedObject private var focusedModel: PlayerModel? var body: some Commands { - // The player has one scene; app-level Open/Quit still work before it becomes focused. - let model = focusedModel ?? PlayerModel.shared - let hasFocusedScene = focusedModel != nil + // Nonactivating compact panels need not provide SwiftUI's scene focus. + let hasFocusedScene = focusedModel != nil || model.window?.isKeyWindow == true let playback = model.playbackAction let mute = model.muteAction let fullscreen = model.fullscreenAction @@ -280,8 +281,8 @@ struct PlayerCommands: Commands { .disabled(!hasFocusedScene || !model.canPerform(.closeFile)) } CommandGroup(replacing: .saveItem) { - Button("Close Window") { NSApp.keyWindow?.performClose(nil) } - .disabled(focusedModel == nil) + Button("Close Window") { model.window?.performClose(nil) } + .disabled(!hasFocusedScene) } CommandMenu("Playback") { CastDeviceMenu(model: model, icon: false) diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index 52939e9..72e9e08 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -241,7 +241,9 @@ final class PlayerModel: ObservableObject { let videoView: NSView? private var handle: OpaquePointer? private var timer: Timer? - private weak var window: NSWindow? + private weak var attachedWindow: NSWindow? + weak var hostedPlayerView: NSView? + var window: NSWindow? { compactWindow.presentationWindow ?? attachedWindow } private var hideDeadline: ContinuousClock.Instant? private var pointerInsideWindow = true private var keyboardNavigation = false @@ -329,6 +331,7 @@ final class PlayerModel: ObservableObject { self.compactIntent = nil self.fullScreenIntent = nil } + self.updateWindowState() self.objectWillChange.send() } timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in @@ -1423,17 +1426,17 @@ final class PlayerModel: ObservableObject { } func attachWindow(_ window: NSWindow) { - guard self.window !== window else { return } + guard !window.styleMask.contains(.nonactivatingPanel), attachedWindow !== window else { return } startContentHeight = 400 - (window.frame.height - window.contentLayoutRect.height) finishQueueTransition() - self.window = window + attachedWindow = window window.title = fileName ?? "Terebi" window.contentMinSize = NSSize(width: 400, height: 240) compactWindow.normalVideoLayout = { [weak self] in guard let self, self.showsQueue, self.state.has_video else { return nil } return (self.state.video_aspect_ratio, 260) } - compactWindow.attach(window) + compactWindow.attach(window, contentView: hostedPlayerView) updateWindowState() if fileName == nil { configureStartWindow(showing: true) } fitVideoWindow() @@ -4233,7 +4236,9 @@ private struct WindowChrome: NSViewRepresentable { window.isOpaque = false window.backgroundColor = .clear window.acceptsMouseMovedEvents = true - window.collectionBehavior.insert(.fullScreenPrimary) + if !window.styleMask.contains(.nonactivatingPanel) { + window.collectionBehavior.insert(.fullScreenPrimary) + } window.appearance = NSAppearance(named: .darkAqua) for name in [NSWindow.willEnterFullScreenNotification, NSWindow.willStartLiveResizeNotification, NSWindow.willCloseNotification] { @@ -4280,8 +4285,9 @@ private struct WindowChrome: NSViewRepresentable { return consumed ? nil : event } Task { @MainActor [weak self, weak window] in - guard let self, let window else { return } + guard let self, let window, self.configuredWindow === window, self.window === window else { return } self.model.attachWindow(window) + self.model.updateWindowState() self.updateButtons() window.makeFirstResponder(nil) } @@ -4359,6 +4365,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + let model = PlayerModel.shared + guard model.isCompact || model.compactWindow.isTransitioning, + let window = model.window else { return true } + window.makeKeyAndOrderFront(nil) + return false + } + // Let SwiftUI create the initial window for a Finder/document launch. func application(_ application: NSApplication, open urls: [URL]) { if urls.contains(where: { $0.scheme == "terebi-enqueue" }) { @@ -4368,13 +4382,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate { else if let url = urls.first { PlayerModel.shared.open(url) } } - func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + // AppKit doesn't count the compact panel as a normal application window. + let compact = PlayerModel.shared.compactWindow + return !compact.isCompact && !compact.isTransitioning + } func applicationWillTerminate(_ notification: Notification) { PlayerModel.shared.shutdown() } } +@MainActor +private struct PlayerSceneContent: NSViewRepresentable { + let model: PlayerModel + + func makeNSView(context: Context) -> NSView { + // Keep SwiftUI's scene host in place; only this app-owned player host moves. + let container = NSView() + let player = NSHostingView(rootView: PlayerContent(model: model)) + player.sizingOptions = [] + player.frame = container.bounds + player.autoresizingMask = [.width, .height] + container.addSubview(player) + model.hostedPlayerView = player + return container + } + + func updateNSView(_ view: NSView, context: Context) {} +} + @MainActor struct TerebiApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate @@ -4382,13 +4419,14 @@ struct TerebiApp: App { var body: some Scene { Window("\(model.fileName ?? "Terebi")", id: "terebi") { - PlayerContent(model: model) + PlayerSceneContent(model: model) + .ignoresSafeArea() .focusedSceneObject(model) } .defaultSize(width: 640, height: 400) .windowResizability(.contentSize) .windowStyle(.hiddenTitleBar) - .commands { PlayerCommands() } + .commands { PlayerCommands(model: model) } } } diff --git a/TODO.md b/TODO.md index ddc8ed0..c0c2082 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,3 @@ -- [x] make sponsored blocks skippable: - - display the skip button used for openings and endings but for sponsored blocks it shouldn't disappear after a timeout - - add a setting for autoskipping (shouldn't activate if seeking onto the sponsored segment (or just before it) to avoid fighting user intention) - ### Terebi handoff: restore opening/ending metadata for ZokoAnime **Finding:** ZokoAnime already returns episode-relative timestamps in its decoded configuration: