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) {
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 {
+131 -54
View File
@@ -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()
}
+49 -2
View File
@@ -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")
+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.
- **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
+109 -33
View File
@@ -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)
+8 -7
View File
@@ -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)
+46 -8
View File
@@ -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
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) }
}
}
-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
**Finding:** ZokoAnime already returns episode-relative timestamps in its decoded configuration: