fix: use a nonactivating compact panel across fullscreen spaces

This commit is contained in:
2026-10-25 22:29:11 +01:00
parent 599e2f548a
commit d8fb648c90
8 changed files with 393 additions and 125 deletions
+48 -15
View File
@@ -275,6 +275,10 @@ private enum WindowDragCheck {
} }
static func interceptDrags(in window: NSWindow) { static func interceptDrags(in window: NSWindow) {
for (method, original) in implementations.reversed() {
imp_removeBlock(method_setImplementation(method, original))
}
implementations.removeAll()
target = window target = window
let method = class_getInstanceMethod(type(of: window), #selector(NSWindow.performDrag(with:)))! let method = class_getInstanceMethod(type(of: window), #selector(NSWindow.performDrag(with:)))!
let original = unsafeBitCast(method_getImplementation(method), let original = unsafeBitCast(method_getImplementation(method),
@@ -309,8 +313,9 @@ private enum WindowDragCheck {
guard ContinuousClock.now < deadline else { guard ContinuousClock.now < deadline else {
finish("Window drag timed out at \(names[probe]), phase \(phase)"); return 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 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) let fixtures = URL(fileURLWithPath: CommandLine.arguments.last!, isDirectory: true)
if target == nil { if target == nil {
guard model.canTogglePlayback else { return } guard model.canTogglePlayback else { return }
@@ -322,10 +327,11 @@ private enum WindowDragCheck {
window.makeMain() window.makeMain()
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
} }
if target !== window { interceptDrags(in: window) }
if phase == 0 { 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.makeKeyAndOrderFront(nil)
window.makeMain() if window.canBecomeMain { window.makeMain() }
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
return return
} }
@@ -588,7 +594,10 @@ private enum WindowLayoutCheck {
static var timer: Timer? static var timer: Timer?
static var resultURL: URL! static var resultURL: URL!
static var step = 0 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 normalFrame = NSRect.zero
static var compactFrame = NSRect.zero static var compactFrame = NSRect.zero
static var animationStart: NSRect? static var animationStart: NSRect?
@@ -676,7 +685,28 @@ private enum WindowLayoutCheck {
let model = PlayerModel.shared let model = PlayerModel.shared
guard ContinuousClock.now < deadline else { finish("Window layout did not settle at step \(step)"); return } guard ContinuousClock.now < deadline else { finish("Window layout did not settle at step \(step)"); return }
guard model.canTogglePlayback, model.state.has_video, 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 { if model.compactWindow.isTransitioning || model.isQueueTransitioning {
window.layoutIfNeeded() window.layoutIfNeeded()
if model.isQueueTransitioning && !model.isCompact && !sidebarHeaderIsVisible(in: window) { if model.isQueueTransitioning && !model.isCompact && !sidebarHeaderIsVisible(in: window) {
@@ -718,8 +748,9 @@ private enum WindowLayoutCheck {
queueVideoSize = nil queueVideoSize = nil
observingQueue = false observingQueue = false
} }
if windowIdentity == nil { windowIdentity = ObjectIdentifier(window) } guard model.isCompact ? window !== normal : window === normal else {
guard ObjectIdentifier(window) == windowIdentity else { finish("Transition replaced the playback window"); return } finish("Settled presentation does not match the requested mode"); return
}
if model.fullScreenIntent != nil { return } if model.fullScreenIntent != nil { return }
window.layoutIfNeeded() window.layoutIfNeeded()
if compactQueueOnly && step == 0 { if compactQueueOnly && step == 0 {
@@ -1079,20 +1110,21 @@ private enum CLIInstanceCheck {
let model = PlayerModel.shared let model = PlayerModel.shared
let pid = ProcessInfo.processInfo.processIdentifier let pid = ProcessInfo.processInfo.processIdentifier
// Text input can create non-main auxiliary windows; count actual player windows. // 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 } for window in windows { window.alphaValue = 0 }
let request = directory.appendingPathComponent("keys-\(pid).json") let request = directory.appendingPathComponent("keys-\(pid).json")
var pendingRequestID = "" var pendingRequestID = ""
if let data = try? Data(contentsOf: request), if let data = try? Data(contentsOf: request),
let command = try? JSONSerialization.jsonObject(with: data) as? [String: Any], 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 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 // Synthetic native key requests need foreground focus; cooperative activation
// can be refused by the unrelated app currently active on the workstation. // can be refused by the unrelated app currently active on the workstation.
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
window.makeMain() if window.canBecomeMain { window.makeMain() }
} else { } else {
requestID = id requestID = id
if let media = command["media"] as? String { model.open(URL(fileURLWithPath: media)) } 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]] { if let events = command["events"] as? [[String: Any]] {
window.makeKey()
for input in events { for input in events {
let window = model.compactWindow.presentationWindow ?? window
window.makeKey()
if let title = input["select"] as? String { if let title = input["select"] as? String {
guard let menu = NSApp.mainMenu else { fatalError("Native application menu is missing") } guard let menu = NSApp.mainMenu else { fatalError("Native application menu is missing") }
prepareMenu(menu) prepareMenu(menu)
@@ -1206,14 +1239,14 @@ private enum CLIInstanceCheck {
"requestID": requestID, "requestID": requestID,
"pendingRequestID": pendingRequestID, "pendingRequestID": pendingRequestID,
"active": NSApp.isActive, "active": NSApp.isActive,
"keyWindow": windows.first?.isKeyWindow ?? false, "keyWindow": presentation?.isKeyWindow ?? false,
"mainWindow": windows.first?.isMainWindow ?? false, "mainWindow": presentation?.isMainWindow ?? false,
"editorText": editorText, "editorText": editorText,
"compact": model.isCompact, "compact": model.isCompact,
"showsQueue": model.showsQueue, "showsQueue": model.showsQueue,
"queueURLs": model.queue.items.map(\.url.absoluteString), "queueURLs": model.queue.items.map(\.url.absoluteString),
"queueIndex": model.queue.items.firstIndex { $0.id == model.queue.currentID } ?? -1, "queueIndex": model.queue.items.firstIndex { $0.id == model.queue.currentID } ?? -1,
"window": windowSnapshot(windows.first), "window": windowSnapshot(presentation),
"onTop": model.isOnTop, "onTop": model.isOnTop,
] ]
do { do {
+131 -54
View File
@@ -99,12 +99,20 @@ struct CompactWindowChecks {
line: UInt = #line, checking: () throws -> Void = {}) throws { line: UInt = #line, checking: () throws -> Void = {}) throws {
let deadline = ContinuousClock.now.advanced(by: .seconds(5)) let deadline = ContinuousClock.now.advanced(by: .seconds(5))
while controller.isTransitioning { 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 checking()
try require(ContinuousClock.now < deadline, "native compact transition completes (call at line \(line), compact \(controller.isCompact), target \(compact))") try require(ContinuousClock.now < deadline, "native compact transition completes (call at line \(line), compact \(controller.isCompact), target \(compact))")
CFRunLoopRunInMode(.defaultMode, 0.01, false) CFRunLoopRunInMode(.defaultMode, 0.01, false)
} }
try checking() try checking()
try require(controller.isCompact == compact, "settled mode matches the latest destination") 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 @MainActor
@@ -151,6 +159,12 @@ struct CompactWindowChecks {
} }
} }
@MainActor
final class WindowDelegate: NSObject, NSWindowDelegate {
var closes = 0
func windowWillClose(_ notification: Notification) { closes += 1 }
}
@MainActor @MainActor
final class WindowFixture { final class WindowFixture {
let window: NSWindow let window: NSWindow
@@ -158,7 +172,8 @@ struct CompactWindowChecks {
let sentinel: NSView let sentinel: NSView
let sentinelFrame: NSRect let sentinelFrame: NSRect
let original: WindowState let original: WindowState
let identity: ObjectIdentifier let delegate = WindowDelegate()
let activationPolicy: NSApplication.ActivationPolicy
init() throws { init() throws {
// Never orderFront, makeKey, or create an actual native fullscreen Space. // Never orderFront, makeKey, or create an actual native fullscreen Space.
@@ -180,19 +195,24 @@ struct CompactWindowChecks {
window.level = .normal window.level = .normal
window.hidesOnDeactivate = true window.hidesOnDeactivate = true
window.isMovableByWindowBackground = false window.isMovableByWindowBackground = false
window.delegate = delegate
original = WindowState(window) original = WindowState(window)
identity = ObjectIdentifier(window) activationPolicy = NSApp.activationPolicy()
} }
func preservedContent() throws { func preservedContent(in presentation: NSWindow? = nil) throws {
try CompactWindowChecks.require(ObjectIdentifier(window) == identity && sentinel.window === window, let presentation = presentation ?? window
"compact mode keeps the actual rendering window") try CompactWindowChecks.require(sentinel.window === presentation
try CompactWindowChecks.require(window.contentView === content && sentinel.superview === content && presentation.contentView === content
&& sentinel.superview === content
&& content.subviews.count == 1 && content.subviews.count == 1
&& CompactWindowChecks.sameFrame(sentinel.frame, sentinelFrame), && CompactWindowChecks.sameFrame(sentinel.frame, sentinelFrame),
"compact mode preserves content and attached sentinel without reparenting") "presentation retains the same content subtree and renderer sentinel")
try CompactWindowChecks.require(!window.isVisible && !window.isKeyWindow, try CompactWindowChecks.require(!window.isVisible && !window.isKeyWindow
"checks never display or key a window") && !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 { func restored() throws {
@@ -200,28 +220,35 @@ struct CompactWindowChecks {
try preservedContent() try preservedContent()
} }
func compact(aspect: Double) throws { func compact(_ controller: CompactWindowController, aspect: Double) throws {
try CompactWindowChecks.require(window.level == .floating && !window.hidesOnDeactivate, 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") "compact floats and remains visible on deactivate")
try CompactWindowChecks.require(window.collectionBehavior.contains( try CompactWindowChecks.require(panel.collectionBehavior.contains(
[.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]), [.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]),
"compact can join Spaces, other-app fullscreen, and Stage Manager") "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, [.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone]).isEmpty,
"compact removes incompatible primary and auxiliary policies") "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") "compact uses 160x90 content minimum")
try CompactWindowChecks.require(window.canBecomeKey, "compact accepts keyboard playback controls") try CompactWindowChecks.require(panel.canBecomeKey, "compact accepts keyboard playback controls")
try CompactWindowChecks.require(window.titleVisibility == .hidden try CompactWindowChecks.require(panel.titleVisibility == .hidden && panel.titlebarAppearsTransparent,
&& window.titlebarAppearsTransparent,
"compact hides native title chrome") "compact hides native title chrome")
let visible = (window.screen ?? NSScreen.main!).visibleFrame let visible = (panel.screen ?? NSScreen.main!).visibleFrame
try CompactWindowChecks.require(CompactWindowChecks.close(window.frame.maxX, visible.maxX - 16) try CompactWindowChecks.require(CompactWindowChecks.close(panel.frame.maxX, visible.maxX - 16)
&& CompactWindowChecks.close(window.frame.maxY, visible.maxY - 16) && CompactWindowChecks.close(panel.frame.maxY, visible.maxY - 16)
&& CompactWindowChecks.close(window.frame.width / window.frame.height, && CompactWindowChecks.close(panel.frame.width / panel.frame.height,
CGFloat(aspect)), CGFloat(aspect)),
"compact enters at inset top right with playback 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) controller.toggle(aspect: 16.0 / 9.0)
try require(controller.isTransitioning, "native completion barrier is an actual animated entry") try require(controller.isTransitioning, "native completion barrier is an actual animated entry")
try settled(controller, compact: true, checking: checking) 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) controller.restore(resumeFullScreen: false)
try settled(controller, compact: false, checking: checking) try settled(controller, compact: false, checking: checking)
try fixture.restored() try fixture.restored()
@@ -257,47 +284,53 @@ struct CompactWindowChecks {
window.close() window.close()
} }
controller.attach(window) 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") try require(controller.snappingEnabled, "isolated controller defaults snapping on")
controller.toggle(aspect: 16.0 / 9.0) controller.toggle(aspect: 16.0 / 9.0)
try require(controller.isCompact && controller.isTransitioning, try require(controller.isCompact && controller.isTransitioning,
"entry presents the compact destination while native geometry settles") "entry presents the compact destination while native geometry settles")
try fixture.preservedContent() try fixture.preservedContent(in: controller.presentationWindow)
try settled(controller, compact: true) 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, let nearEdge = NSRect(x: visible.minX + 26, y: visible.minY + 27,
width: window.frame.width, height: window.frame.height) width: panel.frame.width, height: panel.frame.height)
window.setFrame(nearEdge, display: false) panel.setFrame(nearEdge, display: false)
try require(sameFrame(window.frame, nearEdge), "native move installs unsnapped drag position") try require(sameFrame(panel.frame, nearEdge), "native move installs unsnapped drag position")
controller.snapAfterDrag() controller.snapAfterDrag()
try require(close(window.frame.minX, visible.minX + 16) && close(window.frame.minY, visible.minY + 16) try require(close(panel.frame.minX, visible.minX + 16) && close(panel.frame.minY, visible.minY + 16)
&& window.frame.size == nearEdge.size, "enabled preference snaps the actual moved window") && panel.frame.size == nearEdge.size, "enabled preference snaps the actual moved window")
controller.snappingEnabled = false controller.snappingEnabled = false
let second = CompactWindowController(snappingEnabled: false, reduceMotion: { false }) let second = CompactWindowController(snappingEnabled: false, reduceMotion: { false })
try require(!second.snappingEnabled, "configuration can disable snapping for a new controller") try require(!second.snappingEnabled, "configuration can disable snapping for a new controller")
window.setFrame(nearEdge, display: false) panel.setFrame(nearEdge, display: false)
controller.snapAfterDrag() 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 second.snappingEnabled = true
let third = CompactWindowController(snappingEnabled: false, reduceMotion: { false }) let third = CompactWindowController(snappingEnabled: false, reduceMotion: { false })
try require(!third.snappingEnabled, "a session override does not change the next controller's settings") try require(!third.snappingEnabled, "a session override does not change the next controller's settings")
controller.snappingEnabled = true controller.snappingEnabled = true
let resized = NSRect(x: nearEdge.minX, y: nearEdge.minY, width: 240, height: 135) 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) 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") "repeated aspect updates preserve a user's compact resize and placement")
controller.updateAspect(4.0 / 3.0) controller.updateAspect(4.0 / 3.0)
try require(controller.isCompact && !controller.isTransitioning, "aspect update retains compact mode") 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") "actual compact window follows changed playback aspect")
try fixture.preservedContent() try fixture.preservedContent(in: panel)
controller.toggle(aspect: 4.0 / 3.0) 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 settled(controller, compact: false)
try fixture.restored() 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. // Enter -> exit before entry completes: the original normal snapshot must survive.
controller.toggle(aspect: 16.0 / 9.0) controller.toggle(aspect: 16.0 / 9.0)
@@ -314,7 +347,7 @@ struct CompactWindowChecks {
"exit presents the ordinary destination while native geometry settles") "exit presents the ordinary destination while native geometry settles")
controller.toggle(aspect: 4.0 / 3.0) controller.toggle(aspect: 4.0 / 3.0)
try settled(controller, compact: true) 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) controller.restore(resumeFullScreen: false)
try settled(controller, compact: false) try settled(controller, compact: false)
try fixture.restored() try fixture.restored()
@@ -328,7 +361,7 @@ struct CompactWindowChecks {
} }
try settled(controller, compact: compactDestination) try settled(controller, compact: compactDestination)
if compactDestination { if compactDestination {
try fixture.compact(aspect: 16.0 / 9.0) try fixture.compact(controller, aspect: 16.0 / 9.0)
controller.restore(resumeFullScreen: false) controller.restore(resumeFullScreen: false)
try settled(controller, compact: false) try settled(controller, compact: false)
} }
@@ -354,17 +387,39 @@ struct CompactWindowChecks {
"stale completion cannot revive an urgently restored session") "stale completion cannot revive an urgently restored session")
try fixture.restored() 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() let replacement = try WindowFixture()
defer { replacement.window.close() } defer { replacement.window.close() }
controller.toggle(aspect: 16.0 / 9.0) controller.toggle(aspect: 16.0 / 9.0)
try require(controller.isTransitioning, "attachment interrupts native entry") try require(controller.isTransitioning, "attachment interrupts native entry")
let replacedPanel = controller.presentationWindow!
controller.attach(replacement.window) controller.attach(replacement.window)
try require(!controller.isCompact && !controller.isTransitioning, "replacement starts ordinary") try require(!controller.isCompact && !controller.isTransitioning, "replacement starts ordinary")
try fixture.restored() 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) controller.toggle(aspect: 4.0 / 3.0)
try settled(controller, compact: true) { try fixture.restored() } 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) controller.restore(resumeFullScreen: false)
try settled(controller, compact: false) { try fixture.restored() } try settled(controller, compact: false) { try fixture.restored() }
try replacement.restored() try replacement.restored()
@@ -386,8 +441,13 @@ struct CompactWindowChecks {
controller.toggle(aspect: 16.0 / 9.0) controller.toggle(aspect: 16.0 / 9.0)
try require(controller.isTransitioning, "close interrupts native entry") try require(controller.isTransitioning, "close interrupts native entry")
let closingPanel = controller.presentationWindow!
window.close() window.close()
try require(!controller.isCompact && !controller.isTransitioning, "closed window ends its session") 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) let closedState = WindowState(window)
controller.attach(replacement.window) controller.attach(replacement.window)
controller.toggle(aspect: 16.0 / 9.0) controller.toggle(aspect: 16.0 / 9.0)
@@ -396,10 +456,23 @@ struct CompactWindowChecks {
try fixture.preservedContent() try fixture.preservedContent()
} }
try settled(controller, compact: true, checking: closedWindowUnchanged) 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) controller.restore(resumeFullScreen: false)
try settled(controller, compact: false, checking: closedWindowUnchanged) try settled(controller, compact: false, checking: closedWindowUnchanged)
try replacement.restored() 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 { static func rememberedSize() throws {
@@ -414,19 +487,21 @@ struct CompactWindowChecks {
} }
controller.attach(fixture.window) controller.attach(fixture.window)
controller.toggle(aspect: 16.0 / 9.0) 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) resized.size = NSSize(width: 240, height: 135)
fixture.window.setFrame(resized, display: false) panel.setFrame(resized, display: false)
NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: fixture.window) NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: panel)
controller.restore(resumeFullScreen: false) controller.restore(resumeFullScreen: false)
try fixture.restored() try fixture.restored()
controller.toggle(aspect: 16.0 / 9.0) 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") "reentering compact restores the manually chosen size")
// Programmatic fitting and ordinary-window resizing must not replace the preference. // Programmatic fitting and ordinary-window resizing must not replace the preference.
resized.size = NSSize(width: 320, height: 180) 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.updateAspect(4.0 / 3.0)
controller.restore(resumeFullScreen: false) controller.restore(resumeFullScreen: false)
try fixture.restored() try fixture.restored()
@@ -440,9 +515,10 @@ struct CompactWindowChecks {
} }
reloaded.attach(replacement.window) reloaded.attach(replacement.window)
reloaded.toggle(aspect: 4.0 / 3.0) 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") "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) reloaded.restore(resumeFullScreen: false)
try replacement.restored() try replacement.restored()
} }
@@ -458,7 +534,7 @@ struct CompactWindowChecks {
controller.toggle(aspect: 16.0 / 9.0) controller.toggle(aspect: 16.0 / 9.0)
try require(controller.isCompact && !controller.isTransitioning, try require(controller.isCompact && !controller.isTransitioning,
"Reduce Motion enters with geometry and controls already settled") "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) controller.toggle(aspect: 16.0 / 9.0)
try require(!controller.isCompact && !controller.isTransitioning, try require(!controller.isCompact && !controller.isTransitioning,
"Reduce Motion restores without a pending frame animation") "Reduce Motion restores without a pending frame animation")
@@ -507,13 +583,14 @@ struct CompactWindowChecks {
static func compactQueue() throws { static func compactQueue() throws {
let fixture = try WindowFixture() let fixture = try WindowFixture()
let controller = CompactWindowController(reduceMotion: { true }) let controller = CompactWindowController(reduceMotion: { true })
let window = fixture.window let normal = fixture.window
defer { defer {
controller.restore(resumeFullScreen: false, animated: false) controller.restore(resumeFullScreen: false, animated: false)
window.close() normal.close()
} }
controller.attach(window) controller.attach(normal)
controller.toggle(aspect: 16.0 / 9.0) controller.toggle(aspect: 16.0 / 9.0)
let window = controller.presentationWindow!
try require(controller.queueHeight == 0, "compact entry hides the queue") try require(controller.queueHeight == 0, "compact entry hides the queue")
let videoFrame = window.frame let videoFrame = window.frame
let expanded = controller.queueLayout(visible: true)! let expanded = controller.queueLayout(visible: true)!
@@ -559,7 +636,7 @@ struct CompactWindowChecks {
try fixture.restored() try fixture.restored()
controller.toggle(aspect: 4.0 / 3.0) controller.toggle(aspect: 4.0 / 3.0)
try require(controller.queueHeight == 0, "each compact entry resets queue visibility") 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) controller.restore(resumeFullScreen: false)
try fixture.restored() try fixture.restored()
} }
+49 -2
View File
@@ -169,6 +169,7 @@ struct SystemPlaybackChecks {
styleMask: [.titled, .resizable], backing: .buffered, defer: false) styleMask: [.titled, .resizable], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false window.isReleasedWhenClosed = false
defer { window.close() } defer { window.close() }
model.attachWindow(window)
func event(_ code: UInt16, _ text: String, _ flags: NSEvent.ModifierFlags = []) -> NSEvent { func event(_ code: UInt16, _ text: String, _ flags: NSEvent.ModifierFlags = []) -> NSEvent {
NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0, NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0,
windowNumber: window.windowNumber, context: nil, characters: text, windowNumber: window.windowNumber, context: nil, characters: text,
@@ -252,6 +253,33 @@ struct SystemPlaybackChecks {
let modal = NSApp.beginModalSession(for: window) let modal = NSApp.beginModalSession(for: window)
require(!model.handleKeyEvent(shortcuts[0], in: window), "modal session blocks player shortcuts") require(!model.handleKeyEvent(shortcuts[0], in: window), "modal session blocks player shortcuts")
NSApp.endModalSession(modal) 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() { static func main() {
@@ -296,7 +324,12 @@ struct SystemPlaybackChecks {
backing: .buffered, defer: false) backing: .buffered, defer: false)
window.isReleasedWhenClosed = false window.isReleasedWhenClosed = false
defer { window.close() } defer { window.close() }
let surface = PlaybackSurface.DragView(model: model)
surface.frame = window.contentView!.bounds
window.contentView!.addSubview(surface)
model.attachWindow(window) 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 let videoFrame = window.frame
model.showsQueue = true model.showsQueue = true
until("queue opening settles") { !model.isQueueTransitioning } until("queue opening settles") { !model.isQueueTransitioning }
@@ -308,16 +341,30 @@ struct SystemPlaybackChecks {
require(abs(window.frame.width - videoFrame.width) < 0.01 && require(abs(window.frame.width - videoFrame.width) < 0.01 &&
abs(window.frame.height - videoFrame.height) < 0.01, abs(window.frame.height - videoFrame.height) < 0.01,
"closing queue restores video-only window size") "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")) model.open(directory.appendingPathComponent("anamorphic.mp4"))
require(model.isBusy && model.perform(.enterCompact) && model.isCompact, require(model.isBusy && model.perform(.enterCompact) && model.isCompact,
"compact entry is available before native media opening acknowledges") "compact entry is available before native media opening acknowledges")
until("decoded anamorphic metadata replaces provisional compact geometry") { until("decoded anamorphic metadata replaces provisional compact geometry") {
model.state.status == PLAYER_PLAYING && model.isCompact && !model.compactWindow.isTransitioning && guard let presentation = model.compactWindow.presentationWindow else { return false }
abs(window.frame.width / window.frame.height - 8.0 / 3.0) < 0.01 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() model.closeFile()
require(!model.isCompact && !model.compactWindow.isTransitioning, require(!model.isCompact && !model.compactWindow.isTransitioning,
"closing loading/loaded media restores immediately") "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 } 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") require(!model.canPerform(.enterCompact), "empty playback does not enable compact entry")
+1 -1
View File
@@ -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. - **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. - **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. - **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. - **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 ### Keyboard shortcuts
+109 -33
View File
@@ -7,6 +7,7 @@ final class CompactWindowController: NSObject {
var normalVideoLayout: (() -> (aspect: Double, sidebarWidth: CGFloat)?)? var normalVideoLayout: (() -> (aspect: Double, sidebarWidth: CGFloat)?)?
private(set) var isCompact = false private(set) var isCompact = false
private(set) var isTransitioning = false private(set) var isTransitioning = false
var presentationWindow: NSWindow? { compactPanel ?? window }
private(set) var queueHeight: CGFloat = 0 private(set) var queueHeight: CGFloat = 0
static let transitionDuration: TimeInterval = 0.25 static let transitionDuration: TimeInterval = 0.25
@@ -30,9 +31,13 @@ final class CompactWindowController: NSObject {
let level: NSWindow.Level let level: NSWindow.Level
let hidesOnDeactivate: Bool let hidesOnDeactivate: Bool
let isMovableByWindowBackground: Bool let isMovableByWindowBackground: Bool
let isVisible: Bool
let firstResponder: NSResponder?
let contentSuperview: NSView?
let contentFrame: NSRect
@MainActor @MainActor
init(_ window: NSWindow) { init(_ window: NSWindow, contentView: NSView) {
frame = window.frame frame = window.frame
minSize = window.minSize minSize = window.minSize
contentMinSize = window.contentMinSize contentMinSize = window.contentMinSize
@@ -45,12 +50,19 @@ final class CompactWindowController: NSObject {
level = window.level level = window.level
hidesOnDeactivate = window.hidesOnDeactivate hidesOnDeactivate = window.hidesOnDeactivate
isMovableByWindowBackground = window.isMovableByWindowBackground 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 enum FullScreenTransition { case entering, exiting }
private weak var window: NSWindow? private weak var window: NSWindow?
private weak var contentView: NSView?
private var compactPanel: NSPanel?
private var panelDelegate: FullScreenDelegate?
private var snapshot: Snapshot? private var snapshot: Snapshot?
private var fullScreenTransition: FullScreenTransition? private var fullScreenTransition: FullScreenTransition?
private var delegateProxy: FullScreenDelegate? private var delegateProxy: FullScreenDelegate?
@@ -77,7 +89,7 @@ final class CompactWindowController: NSObject {
deinit { NotificationCenter.default.removeObserver(self) } deinit { NotificationCenter.default.removeObserver(self) }
func attach(_ window: NSWindow) { func attach(_ window: NSWindow, contentView: NSView? = nil) {
guard window !== self.window else { return } guard window !== self.window else { return }
stopFrameAnimation() stopFrameAnimation()
if let oldWindow = self.window, fullScreenTransition == nil, if let oldWindow = self.window, fullScreenTransition == nil,
@@ -87,6 +99,7 @@ final class CompactWindowController: NSObject {
restoreDelegate() restoreDelegate()
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
self.window = window self.window = window
self.contentView = contentView ?? window.contentView
snapshot = nil snapshot = nil
fullScreenTransition = nil fullScreenTransition = nil
sessionActive = false sessionActive = false
@@ -133,13 +146,13 @@ final class CompactWindowController: NSObject {
func updateAspect(_ aspect: Double) { func updateAspect(_ aspect: Double) {
guard aspect.isFinite, aspect > 0, aspect != videoAspect else { return } guard aspect.isFinite, aspect > 0, aspect != videoAspect else { return }
videoAspect = aspect videoAspect = aspect
guard isCompact, !isTransitioning, let window else { return } guard isCompact, !isTransitioning, let window = presentationWindow else { return }
fitCompactWindow(window, initial: false) fitCompactWindow(window, initial: false)
} }
// The reveal and native frame share the model's animation clock. // The reveal and native frame share the model's animation clock.
func queueLayout(visible: Bool) -> (height: CGFloat, frame: NSRect)? { 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 usable = Self.usableFrame(visibleFrame(for: window))
let height = visible ? min(240, max(0, usable.height - 90)) : 0 let height = visible ? min(240, max(0, usable.height - 90)) : 0
let video = Self.compactSize(aspect: videoAspect, width: window.frame.width, let video = Self.compactSize(aspect: videoAspect, width: window.frame.width,
@@ -152,7 +165,7 @@ final class CompactWindowController: NSObject {
} }
func updateQueue(height: CGFloat, frame: NSRect) { 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) window.resizeIncrements = NSSize(width: 1, height: 1)
queueHeight = height queueHeight = height
fittedVideoAspect = frame.width / (frame.height - height) fittedVideoAspect = frame.width / (frame.height - height)
@@ -166,7 +179,7 @@ final class CompactWindowController: NSObject {
} }
func snapAfterDrag() { 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)) let frame = Self.snappedFrame(window.frame, visibleFrame: visibleFrame(for: window))
if frame.origin != window.frame.origin { window.setFrameOrigin(frame.origin) } if frame.origin != window.frame.origin { window.setFrameOrigin(frame.origin) }
} }
@@ -174,7 +187,7 @@ final class CompactWindowController: NSObject {
@objc private func windowNotification(_ notification: Notification) { @objc private func windowNotification(_ notification: Notification) {
switch notification.name { switch notification.name {
case NSWindow.didEndLiveResizeNotification: case NSWindow.didEndLiveResizeNotification:
guard isCompact, !isTransitioning, let window, guard isCompact, !isTransitioning, let window = presentationWindow,
window.frame.width.isFinite, window.frame.width > 0 else { return } window.frame.width.isFinite, window.frame.width > 0 else { return }
defaults.set(Double(window.frame.width), forKey: Self.widthKey) defaults.set(Double(window.frame.width), forKey: Self.widthKey)
case NSWindow.willEnterFullScreenNotification: case NSWindow.willEnterFullScreenNotification:
@@ -187,7 +200,12 @@ final class CompactWindowController: NSObject {
fullScreenTransition = nil fullScreenTransition = nil
reconcile() reconcile()
case NSWindow.willCloseNotification: case NSWindow.willCloseNotification:
if notification.object as? NSWindow === compactPanel {
window?.close()
return
}
stopFrameAnimation() stopFrameAnimation()
if let window { removeCompactPanel(returningContentTo: window) }
restoreDelegate() restoreDelegate()
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
window = nil window = nil
@@ -318,7 +336,7 @@ final class CompactWindowController: NSObject {
} else if !isCompact { } else if !isCompact {
enterCompact(window, animated: animated) enterCompact(window, animated: animated)
} else { } else {
if fittedAspect != videoAspect { fitCompactWindow(window, initial: false) } if fittedAspect != videoAspect, let panel = compactPanel { fitCompactWindow(panel, initial: false) }
setState(compact: true, transitioning: false) setState(compact: true, transitioning: false)
} }
return return
@@ -333,11 +351,12 @@ final class CompactWindowController: NSObject {
} }
if let snapshot { if let snapshot {
queueHeight = 0 queueHeight = 0
setState(compact: false, transitioning: animated) setState(compact: false, transitioning: true)
window.aspectRatio = .zero let restoringWindow = presentationWindow ?? window
window.minSize = .zero restoringWindow.aspectRatio = .zero
window.contentMinSize = .zero restoringWindow.minSize = .zero
animateFrame(snapshot.frame, of: window, animated: animated) { [weak self, weak window] in restoringWindow.contentMinSize = .zero
animateFrame(snapshot.frame, of: restoringWindow, animated: animated) { [weak self, weak window] in
guard let self, let window else { return } guard let self, let window else { return }
self.restoreSnapshot(to: window) self.restoreSnapshot(to: window)
self.reconcile() self.reconcile()
@@ -357,33 +376,84 @@ final class CompactWindowController: NSObject {
window.toggleFullScreen(nil) window.toggleFullScreen(nil)
} }
private func enterCompact(_ window: NSWindow, animated: Bool) { private func enterCompact(_ normalWindow: NSWindow, animated: Bool) {
// After didExitFullScreen, this is the normal frame, not the fullscreen frame. // A regular NSWindow cannot join another app's native fullscreen Space.
snapshot = Snapshot(window) // Keep the scene and renderer alive, but present their content in a nonactivating panel.
queueHeight = 0 guard let content = contentView else {
setState(compact: true, transitioning: animated) finishSession()
window.aspectRatio = .zero return
window.minSize = .zero }
window.contentMinSize = .zero let snapshot = Snapshot(normalWindow, contentView: content)
// A borderless NSWindow cannot become key; retain native keyboard and resize behavior. self.snapshot = snapshot
window.styleMask.formUnion([.titled, .fullSizeContentView, .resizable]) 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.titleVisibility = .hidden
window.titlebarAppearsTransparent = true window.titlebarAppearsTransparent = true
window.minSize = NSSize(width: 160, height: 90) window.titlebarSeparatorStyle = .none
window.contentMinSize = NSSize(width: 160, height: 90) window.appearance = normalWindow.appearance
var behavior = window.collectionBehavior window.isOpaque = normalWindow.isOpaque
behavior.subtract([.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone, .moveToActiveSpace]) window.backgroundColor = normalWindow.backgroundColor
behavior.formUnion([.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]) window.hasShadow = normalWindow.hasShadow
window.collectionBehavior = behavior window.alphaValue = normalWindow.alphaValue
window.ignoresMouseEvents = normalWindow.ignoresMouseEvents
window.acceptsMouseMovedEvents = normalWindow.acceptsMouseMovedEvents
window.level = .floating window.level = .floating
window.hidesOnDeactivate = false window.collectionBehavior = [.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]
// Explicit drag surfaces preserve controls' mouse tracking in compact mode too.
window.isMovableByWindowBackground = false 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) 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) { private func restoreSnapshot(to window: NSWindow) {
guard let snapshot else { return } guard let snapshot else { return }
let wasKey = compactPanel?.isKeyWindow ?? false
removeCompactPanel(returningContentTo: window)
window.aspectRatio = .zero window.aspectRatio = .zero
window.minSize = .zero window.minSize = .zero
window.contentMinSize = .zero window.contentMinSize = .zero
@@ -402,7 +472,13 @@ final class CompactWindowController: NSObject {
} else { } else {
window.aspectRatio = snapshot.aspectRatio window.aspectRatio = snapshot.aspectRatio
} }
if snapshot.isVisible {
if wasKey { window.makeKeyAndOrderFront(nil) }
else { window.orderFront(nil) }
window.makeFirstResponder(snapshot.firstResponder)
}
self.snapshot = nil self.snapshot = nil
onChange?()
} }
private func finishSession() { private func finishSession() {
@@ -429,7 +505,7 @@ final class CompactWindowController: NSObject {
window.animator().setFrame(frame, display: true) window.animator().setFrame(frame, display: true)
} completionHandler: { [weak self, weak window] in } completionHandler: { [weak self, weak window] in
MainActor.assumeIsolated { 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.frameAnimationGeneration == generation else { return }
self.frameAnimating = false self.frameAnimating = false
completion() completion()
@@ -441,7 +517,7 @@ final class CompactWindowController: NSObject {
guard frameAnimating else { return } guard frameAnimating else { return }
frameAnimating = false frameAnimating = false
frameAnimationGeneration &+= 1 frameAnimationGeneration &+= 1
guard let window else { return } guard let window = presentationWindow else { return }
NSAnimationContext.runAnimationGroup { context in NSAnimationContext.runAnimationGroup { context in
context.duration = 0 context.duration = 0
window.animator().setFrame(window.frame, display: true) window.animator().setFrame(window.frame, display: true)
+8 -7
View File
@@ -93,7 +93,8 @@ extension PlayerModel {
} }
private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool { 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) { if chord.modifiers.contains(.command), keyBindings.shortcuts(for: .openStream).contains(chord) {
return perform(.openStream) return perform(.openStream)
} }
@@ -127,7 +128,7 @@ extension PlayerModel {
perform(action) perform(action)
return return
} }
if let window = event.window ?? NSApp.keyWindow, if let window = window ?? event.window ?? NSApp.keyWindow,
!handleKeyEvent(event, in: window, chord: chord), !handleKeyEvent(event, in: window, chord: chord),
let responder = window.firstResponder as? NSView { let responder = window.firstResponder as? NSView {
if !responder.performKeyEquivalent(with: event) { responder.keyDown(with: event) } if !responder.performKeyEquivalent(with: event) { responder.keyDown(with: event) }
@@ -245,12 +246,12 @@ struct PlayerSponsorBlockMenu: View {
@MainActor @MainActor
struct PlayerCommands: Commands { struct PlayerCommands: Commands {
@ObservedObject var model: PlayerModel
@FocusedObject private var focusedModel: PlayerModel? @FocusedObject private var focusedModel: PlayerModel?
var body: some Commands { var body: some Commands {
// The player has one scene; app-level Open/Quit still work before it becomes focused. // Nonactivating compact panels need not provide SwiftUI's scene focus.
let model = focusedModel ?? PlayerModel.shared let hasFocusedScene = focusedModel != nil || model.window?.isKeyWindow == true
let hasFocusedScene = focusedModel != nil
let playback = model.playbackAction let playback = model.playbackAction
let mute = model.muteAction let mute = model.muteAction
let fullscreen = model.fullscreenAction let fullscreen = model.fullscreenAction
@@ -280,8 +281,8 @@ struct PlayerCommands: Commands {
.disabled(!hasFocusedScene || !model.canPerform(.closeFile)) .disabled(!hasFocusedScene || !model.canPerform(.closeFile))
} }
CommandGroup(replacing: .saveItem) { CommandGroup(replacing: .saveItem) {
Button("Close Window") { NSApp.keyWindow?.performClose(nil) } Button("Close Window") { model.window?.performClose(nil) }
.disabled(focusedModel == nil) .disabled(!hasFocusedScene)
} }
CommandMenu("Playback") { CommandMenu("Playback") {
CastDeviceMenu(model: model, icon: false) CastDeviceMenu(model: model, icon: false)
+47 -9
View File
@@ -241,7 +241,9 @@ final class PlayerModel: ObservableObject {
let videoView: NSView? let videoView: NSView?
private var handle: OpaquePointer? private var handle: OpaquePointer?
private var timer: Timer? 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 hideDeadline: ContinuousClock.Instant?
private var pointerInsideWindow = true private var pointerInsideWindow = true
private var keyboardNavigation = false private var keyboardNavigation = false
@@ -329,6 +331,7 @@ final class PlayerModel: ObservableObject {
self.compactIntent = nil self.compactIntent = nil
self.fullScreenIntent = nil self.fullScreenIntent = nil
} }
self.updateWindowState()
self.objectWillChange.send() self.objectWillChange.send()
} }
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
@@ -1423,17 +1426,17 @@ final class PlayerModel: ObservableObject {
} }
func attachWindow(_ window: NSWindow) { 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) startContentHeight = 400 - (window.frame.height - window.contentLayoutRect.height)
finishQueueTransition() finishQueueTransition()
self.window = window attachedWindow = window
window.title = fileName ?? "Terebi" window.title = fileName ?? "Terebi"
window.contentMinSize = NSSize(width: 400, height: 240) window.contentMinSize = NSSize(width: 400, height: 240)
compactWindow.normalVideoLayout = { [weak self] in compactWindow.normalVideoLayout = { [weak self] in
guard let self, self.showsQueue, self.state.has_video else { return nil } guard let self, self.showsQueue, self.state.has_video else { return nil }
return (self.state.video_aspect_ratio, 260) return (self.state.video_aspect_ratio, 260)
} }
compactWindow.attach(window) compactWindow.attach(window, contentView: hostedPlayerView)
updateWindowState() updateWindowState()
if fileName == nil { configureStartWindow(showing: true) } if fileName == nil { configureStartWindow(showing: true) }
fitVideoWindow() fitVideoWindow()
@@ -4233,7 +4236,9 @@ private struct WindowChrome: NSViewRepresentable {
window.isOpaque = false window.isOpaque = false
window.backgroundColor = .clear window.backgroundColor = .clear
window.acceptsMouseMovedEvents = true window.acceptsMouseMovedEvents = true
window.collectionBehavior.insert(.fullScreenPrimary) if !window.styleMask.contains(.nonactivatingPanel) {
window.collectionBehavior.insert(.fullScreenPrimary)
}
window.appearance = NSAppearance(named: .darkAqua) window.appearance = NSAppearance(named: .darkAqua)
for name in [NSWindow.willEnterFullScreenNotification, NSWindow.willStartLiveResizeNotification, for name in [NSWindow.willEnterFullScreenNotification, NSWindow.willStartLiveResizeNotification,
NSWindow.willCloseNotification] { NSWindow.willCloseNotification] {
@@ -4280,8 +4285,9 @@ private struct WindowChrome: NSViewRepresentable {
return consumed ? nil : event return consumed ? nil : event
} }
Task { @MainActor [weak self, weak window] in 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.attachWindow(window)
self.model.updateWindowState()
self.updateButtons() self.updateButtons()
window.makeFirstResponder(nil) 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. // Let SwiftUI create the initial window for a Finder/document launch.
func application(_ application: NSApplication, open urls: [URL]) { func application(_ application: NSApplication, open urls: [URL]) {
if urls.contains(where: { $0.scheme == "terebi-enqueue" }) { 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) } 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) { func applicationWillTerminate(_ notification: Notification) {
PlayerModel.shared.shutdown() 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 @MainActor
struct TerebiApp: App { struct TerebiApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@@ -4382,13 +4419,14 @@ struct TerebiApp: App {
var body: some Scene { var body: some Scene {
Window("\(model.fileName ?? "Terebi")", id: "terebi") { Window("\(model.fileName ?? "Terebi")", id: "terebi") {
PlayerContent(model: model) PlayerSceneContent(model: model)
.ignoresSafeArea()
.focusedSceneObject(model) .focusedSceneObject(model)
} }
.defaultSize(width: 640, height: 400) .defaultSize(width: 640, height: 400)
.windowResizability(.contentSize) .windowResizability(.contentSize)
.windowStyle(.hiddenTitleBar) .windowStyle(.hiddenTitleBar)
.commands { PlayerCommands() } .commands { PlayerCommands(model: model) }
} }
} }
-4
View File
@@ -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 ### Terebi handoff: restore opening/ending metadata for ZokoAnime
**Finding:** ZokoAnime already returns episode-relative timestamps in its decoded configuration: **Finding:** ZokoAnime already returns episode-relative timestamps in its decoded configuration: