Compare commits
4 Commits
7052fe29af
...
41aa0abd56
| Author | SHA1 | Date | |
|---|---|---|---|
| 41aa0abd56 | |||
| d8fb648c90 | |||
| 599e2f548a | |||
| a604810319 |
+48
-15
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,24 @@ struct ConfigurationChecks {
|
|||||||
try require(try PlayerConfiguration.load(from: sponsor) == PlayerConfiguration(sponsorBlock: enabled),
|
try require(try PlayerConfiguration.load(from: sponsor) == PlayerConfiguration(sponsorBlock: enabled),
|
||||||
"SponsorBlock opt-in and explicit opt-out load without changing other defaults")
|
"SponsorBlock opt-in and explicit opt-out load without changing other defaults")
|
||||||
}
|
}
|
||||||
|
for enabled in [true, false] {
|
||||||
|
try Data("[playback]\nsponsorblock_autoskip = \(enabled)\n".utf8).write(to: sponsor)
|
||||||
|
try require(try PlayerConfiguration.load(from: sponsor) == PlayerConfiguration(sponsorBlockAutoSkip: enabled),
|
||||||
|
"sponsor autoskip loads independently without enabling network lookups")
|
||||||
|
}
|
||||||
|
for value in ["\"true\"", "1", "[]"] {
|
||||||
|
try Data("[playback]\nsponsorblock_autoskip = \(value)\n".utf8).write(to: sponsor)
|
||||||
|
try requireLoadError(at: sponsor, "non-boolean sponsor autoskip", containing: ["sponsorblock_autoskip"])
|
||||||
|
}
|
||||||
|
for enabled in [true, false] {
|
||||||
|
try Data("[playback]\nopening_ending_autoskip = \(enabled)\n".utf8).write(to: sponsor)
|
||||||
|
try require(try PlayerConfiguration.load(from: sponsor) == PlayerConfiguration(openingEndingAutoSkip: enabled),
|
||||||
|
"opening/ending autoskip is independent of SponsorBlock")
|
||||||
|
}
|
||||||
|
for value in ["\"true\"", "1", "[]"] {
|
||||||
|
try Data("[playback]\nopening_ending_autoskip = \(value)\n".utf8).write(to: sponsor)
|
||||||
|
try requireLoadError(at: sponsor, "non-boolean opening/ending autoskip", containing: ["opening_ending_autoskip"])
|
||||||
|
}
|
||||||
let miruro = root.appendingPathComponent("miruro.toml")
|
let miruro = root.appendingPathComponent("miruro.toml")
|
||||||
for (settings, video, captions) in [
|
for (settings, video, captions) in [
|
||||||
("video_provider = \"new-Host_2\"", "new-Host_2", ""),
|
("video_provider = \"new-Host_2\"", "new-Host_2", ""),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ binary = str(Path(sys.argv[1]).resolve())
|
|||||||
fixtures = Path(sys.argv[2]).resolve()
|
fixtures = Path(sys.argv[2]).resolve()
|
||||||
seen = []
|
seen = []
|
||||||
errors = []
|
errors = []
|
||||||
|
zoko_only = "--zoko-intervals" in sys.argv[3:]
|
||||||
|
|
||||||
class Handler(http.server.BaseHTTPRequestHandler):
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
def log_message(self, *_):
|
def log_message(self, *_):
|
||||||
@@ -117,17 +118,21 @@ try:
|
|||||||
"title": "Local resolved fixture", "subtitles": [{"url": base + "/english.jpeg", "format": "vtt", "language": "en",
|
"title": "Local resolved fixture", "subtitles": [{"url": base + "/english.jpeg", "format": "vtt", "language": "en",
|
||||||
"label": "English", "default": True, "headers": {"X-Resource": "subtitle-private"}}]}))
|
"label": "English", "default": True, "headers": {"X-Resource": "subtitle-private"}}]}))
|
||||||
source.chmod(0o600)
|
source.chmod(0o600)
|
||||||
subprocess.run([binary, str(source), str(progress)], check=True, timeout=100)
|
subprocess.run([binary, str(source), "--zoko-intervals"] if zoko_only else
|
||||||
records = [json.loads(line) for line in progress.read_text().splitlines()]
|
[binary, str(source), str(progress)], check=True, timeout=100)
|
||||||
assert records and all(record["version"] == 2 and record["media_identity"] ==
|
if not zoko_only:
|
||||||
{"provider": "miruro", "media_id": "fixture-ID", "episode": 1} for record in records), "stable episode progress identity"
|
records = [json.loads(line) for line in progress.read_text().splitlines()]
|
||||||
assert any(record["completion_eligible"] for record in records), "acknowledged video progress eligible"
|
assert records and all(record["version"] == 2 and record["media_identity"] ==
|
||||||
assert "stream-private" not in progress.read_text() and "token=" not in progress.read_text(), "progress has no transport secrets"
|
{"provider": "miruro", "media_id": "fixture-ID", "episode": 1} for record in records), "stable episode progress identity"
|
||||||
|
assert any(record["completion_eligible"] for record in records), "acknowledged video progress eligible"
|
||||||
|
assert "stream-private" not in progress.read_text() and "token=" not in progress.read_text(), "progress has no transport secrets"
|
||||||
assert not errors, errors
|
assert not errors, errors
|
||||||
assert {"/master.m3u8", "/variant.m3u8", "/english.jpeg"}.issubset(seen), "master, nested playlist and external VTT fetched"
|
assert {"/master.m3u8", "/variant.m3u8"}.issubset(seen), "master and nested playlist fetched"
|
||||||
assert {"/alternate/master.m3u8", "/alternate/english.jpeg"}.issubset(seen), "alternate video host and independent caption provider fetched"
|
if not zoko_only:
|
||||||
|
assert {"/english.jpeg", "/alternate/master.m3u8", "/alternate/english.jpeg"}.issubset(seen), "external VTT, alternate host and independent captions fetched"
|
||||||
assert any(path.endswith(".ts") for path in seen), "media segment fetched"
|
assert any(path.endswith(".ts") for path in seen), "media segment fetched"
|
||||||
print("PASS: model HLS/VTT playback, independent resource headers, tokens, stable progress")
|
print("PASS: local Zoko metadata native playback and source preparation" if zoko_only else
|
||||||
|
"PASS: model HLS/VTT playback, independent resource headers, tokens, stable progress")
|
||||||
finally:
|
finally:
|
||||||
server.shutdown()
|
server.shutdown()
|
||||||
server.server_close()
|
server.server_close()
|
||||||
|
|||||||
+273
-10
@@ -44,6 +44,11 @@ struct ResolvedPlaybackCheck {
|
|||||||
} else {
|
} else {
|
||||||
source = try ResolvedSource.load(from: file)
|
source = try ResolvedSource.load(from: file)
|
||||||
}
|
}
|
||||||
|
if CommandLine.arguments.contains("--zoko-intervals") {
|
||||||
|
try await transportProtocol(source: source)
|
||||||
|
try await zokoIntervalLifecycle(source: source)
|
||||||
|
return
|
||||||
|
}
|
||||||
if CommandLine.arguments.contains("--enqueue-only") {
|
if CommandLine.arguments.contains("--enqueue-only") {
|
||||||
try await enqueueLifecycle(source: source)
|
try await enqueueLifecycle(source: source)
|
||||||
return
|
return
|
||||||
@@ -119,6 +124,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
if !input.hasPrefix("https://") {
|
if !input.hasPrefix("https://") {
|
||||||
try await transportProtocol(source: source)
|
try await transportProtocol(source: source)
|
||||||
model.shutdown()
|
model.shutdown()
|
||||||
|
try await automaticSegmentSkips(file: file)
|
||||||
try await providerLifecycle(source: source)
|
try await providerLifecycle(source: source)
|
||||||
try await dubOnlySelection(source: source)
|
try await dubOnlySelection(source: source)
|
||||||
try await queueMetadataLifecycle(source: source)
|
try await queueMetadataLifecycle(source: source)
|
||||||
@@ -127,32 +133,186 @@ struct ResolvedPlaybackCheck {
|
|||||||
try await streamQueueLifecycle(source: source)
|
try await streamQueueLifecycle(source: source)
|
||||||
try await enqueueLifecycle(source: source)
|
try await enqueueLifecycle(source: source)
|
||||||
try await sidebarQueueLifecycle(source: source)
|
try await sidebarQueueLifecycle(source: source)
|
||||||
|
try await zokoIntervalLifecycle(source: source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func zokoIntervalLifecycle(source: ResolvedSource) async throws {
|
||||||
|
let directory = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("terebi.zoko-interval-check." + UUID().uuidString)
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
let suite = "terebi.zoko-interval-check." + UUID().uuidString
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
|
func url(_ episode: Int) -> URL {
|
||||||
|
URL(string: "https://zokoanime.video/stream/ani/1/\(episode)/sub")!
|
||||||
|
}
|
||||||
|
let rows: [ProviderInterval] = [
|
||||||
|
.init(kind: .intro, start: 0.5, end: 1.5), .init(kind: .outro, start: 2.25, end: 3.25),
|
||||||
|
]
|
||||||
|
let expected = [
|
||||||
|
MiruroInterval(kind: .intro, start: 0.5, end: 1.5, episode: 1),
|
||||||
|
MiruroInterval(kind: .outro, start: 2.25, end: 3.25, episode: 1),
|
||||||
|
]
|
||||||
|
func resolved(_ episode: Int) -> ResolvedSource {
|
||||||
|
var result = ResolvedSource(url: source.url, headers: source.headers, identity: "anilist:1:\(episode)",
|
||||||
|
title: "Zoko interval fixture \(episode)", subtitles: [])
|
||||||
|
result.intervals = episode == 2 ? [] : rows
|
||||||
|
if episode == 3 { result.intervals[1] = .init(kind: .outro, start: 2.25, end: 500) }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
var requests: [Int: Int] = [:]
|
||||||
|
var held: CheckedContinuation<ResolvedSource, Never>?
|
||||||
|
var heldMetadata: CheckedContinuation<MiruroMetadata, Never>?
|
||||||
|
var staleCompleted = false
|
||||||
|
var metadataRequests = 0
|
||||||
|
let clock = Clock()
|
||||||
|
let model = PlayerModel(defaults: defaults, progressFile: directory.appendingPathComponent("direct.jsonl").path,
|
||||||
|
miruroResolver: { _, _ in try providerSources(source) },
|
||||||
|
zokoResolver: { _, episode in
|
||||||
|
requests[episode, default: 0] += 1
|
||||||
|
if episode == 4 {
|
||||||
|
let result = await withCheckedContinuation { held = $0 }
|
||||||
|
staleCompleted = true
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return resolved(episode)
|
||||||
|
}, miruroMetadataResolver: { _, _ in
|
||||||
|
metadataRequests += 1
|
||||||
|
return await withCheckedContinuation { heldMetadata = $0 }
|
||||||
|
}, skipClock: { clock.now })
|
||||||
|
defer { model.shutdown() }
|
||||||
|
model.openStream(url(1).absoluteString)
|
||||||
|
require(model.visibleIntervals.isEmpty, "direct launch hides bands until native media is acknowledged")
|
||||||
|
try await until("direct Zoko native decode and provider bands", model: model) {
|
||||||
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2 &&
|
||||||
|
model.visibleIntervals == expected
|
||||||
|
}
|
||||||
|
require(model.state.has_audio && model.state.has_video && model.state.duration >= 3.25,
|
||||||
|
"local fixture supplies real video/audio and measured duration")
|
||||||
|
require(model.sliderBoundaries == [0.5, 1.5, 2.25, 3.25] &&
|
||||||
|
model.sliderSegmentTitle(at: 0.75) == "Opening" && model.sliderSegmentTitle(at: 2.5) == "Ending",
|
||||||
|
"provider bands reuse existing slider labels and fractional boundaries")
|
||||||
|
require(model.setPlayback(playing: false), "pause provider playback")
|
||||||
|
try await until("provider pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
|
require(model.seekRelative(0.8 - model.state.position), "save provider bookmark inside opening")
|
||||||
|
try await until("provider bookmark and skip offer acknowledged", model: model) {
|
||||||
|
!model.state.seek_pending && abs(model.state.position - 0.8) < 0.1 && model.segmentToSkip == expected[0]
|
||||||
|
}
|
||||||
|
require(model.skipButtonTitle == "Skip Opening", "provider timing feeds unchanged manual skip policy")
|
||||||
|
model.closeFile()
|
||||||
|
require(model.visibleIntervals.isEmpty && model.sliderBoundaries.isEmpty, "close clears provider bands immediately")
|
||||||
|
try await until("provider playback closed", model: model) { model.state.status == PLAYER_EMPTY }
|
||||||
|
require(model.savedPosition(for: URL(string: "anilist:1:1")!).map { abs($0 - 0.8) < 0.1 } == true,
|
||||||
|
"provider bookmark is saved under stable episode identity")
|
||||||
|
|
||||||
|
let queue = StreamQueue(entries: (1...3).map { episode in
|
||||||
|
.init(url: url(episode), identity: "anilist:1:\(episode)",
|
||||||
|
progressFile: directory.appendingPathComponent("queue-\(episode).jsonl"), label: "Episode \(episode)")
|
||||||
|
})
|
||||||
|
let reopened = PlayerModel(defaults: UserDefaults(suiteName: suite)!, streamQueue: queue,
|
||||||
|
zokoResolver: { _, episode in
|
||||||
|
requests[episode, default: 0] += 1
|
||||||
|
return resolved(episode)
|
||||||
|
}, miruroMetadataResolver: { _, _ in
|
||||||
|
metadataRequests += 1
|
||||||
|
return MiruroMetadata(title: nil, intervals: [])
|
||||||
|
}, skipClock: { clock.now })
|
||||||
|
defer { reopened.shutdown() }
|
||||||
|
reopened.openStreamQueue(queue)
|
||||||
|
try await until("fresh model reopens saved Zoko episode with newly resolved bands", model: reopened) {
|
||||||
|
!reopened.isBusy && reopened.state.status == PLAYER_PLAYING && reopened.state.position >= 0.7 &&
|
||||||
|
reopened.visibleIntervals == expected
|
||||||
|
}
|
||||||
|
require(requests[1] == 2 && requests[2] == nil, "saved queued reopen resolves only selected episode afresh")
|
||||||
|
reopened.advanceQueue(1)
|
||||||
|
require(reopened.visibleIntervals.isEmpty && reopened.segmentToSkip == nil, "queue replacement clears opening offer and bands")
|
||||||
|
try await until("queued episode without timing still decodes", model: reopened) {
|
||||||
|
!reopened.isBusy && reopened.state.status == PLAYER_PLAYING && reopened.fileName == resolved(2).title
|
||||||
|
}
|
||||||
|
require(reopened.visibleIntervals.isEmpty && reopened.sliderBoundaries.isEmpty, "missing timing never inherits previous episode")
|
||||||
|
reopened.advanceQueue(1)
|
||||||
|
try await until("out-of-duration provider range does not block queued playback", model: reopened) {
|
||||||
|
!reopened.isBusy && reopened.state.status == PLAYER_PLAYING && reopened.fileName == resolved(3).title
|
||||||
|
}
|
||||||
|
require(reopened.visibleIntervals == [MiruroInterval(kind: .intro, start: 0.5, end: 1.5, episode: 3)],
|
||||||
|
"actual native duration rejects oversized provider ending without clipping or losing valid opening")
|
||||||
|
reopened.advanceQueue(-2)
|
||||||
|
try await until("queue revisit restores fresh first-episode bands", model: reopened) {
|
||||||
|
!reopened.isBusy && reopened.state.status == PLAYER_PLAYING && reopened.visibleIntervals == expected
|
||||||
|
}
|
||||||
|
require(requests[1] == 3, "queue revisit resolves again instead of retaining stale timing")
|
||||||
|
reopened.closeFile()
|
||||||
|
try await until("queued provider playback closed", model: reopened) { reopened.state.status == PLAYER_EMPTY }
|
||||||
|
|
||||||
|
model.openStream(url(4).absoluteString)
|
||||||
|
try await until("stale provider resolution held", model: model) { held != nil }
|
||||||
|
model.openStream(url(2).absoluteString)
|
||||||
|
held!.resume(returning: resolved(4))
|
||||||
|
held = nil
|
||||||
|
try await until("late canceled provider resolution cannot replace new episode", model: model) {
|
||||||
|
staleCompleted && !model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == resolved(2).title
|
||||||
|
}
|
||||||
|
require(model.visibleIntervals.isEmpty && model.sliderBoundaries.isEmpty, "late source timing cannot paint replacement episode")
|
||||||
|
model.closeFile()
|
||||||
|
try await until("empty player before sidebar provider launch", model: model) { model.state.status == PLAYER_EMPTY }
|
||||||
|
try await model.addStreamToQueue(url(1).absoluteString)
|
||||||
|
try await until("cold sidebar provider launch preserves prepared source timing", model: model) {
|
||||||
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.visibleIntervals == expected
|
||||||
|
}
|
||||||
|
require(metadataRequests == 0, "AniList paths never request Miruro timing metadata")
|
||||||
|
model.openStream("https://barelystarted.miruro.tv/watch/fixture-ID/episode?ep=1")
|
||||||
|
require(model.visibleIntervals.isEmpty, "switching away from provider timing clears bands immediately")
|
||||||
|
try await until("Miruro metadata lookup held", model: model) { heldMetadata != nil }
|
||||||
|
model.openStream(url(1).absoluteString)
|
||||||
|
heldMetadata!.resume(returning: MiruroMetadata(title: "Stale Miruro title",
|
||||||
|
intervals: [.init(kind: .intro, start: 0.1, end: 0.4, episode: 1)]))
|
||||||
|
heldMetadata = nil
|
||||||
|
try await until("replacement Zoko episode keeps its own provider metadata", model: model) {
|
||||||
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.visibleIntervals == expected
|
||||||
|
}
|
||||||
|
for _ in 0..<10 { await Task.yield() }
|
||||||
|
require(metadataRequests == 1 && model.fileName == resolved(1).title && model.visibleIntervals == expected,
|
||||||
|
"canceled Miruro enrichment cannot rename or paint provider replacement")
|
||||||
|
print("PASS: Zoko direct/sidebar launch, fractional bands, manual skip, saved fresh-model reopen, queue/revisit, missing/out-of-duration timing and stale source/metadata rejection")
|
||||||
|
}
|
||||||
|
|
||||||
static func sponsorPreferenceCheck() {
|
static func sponsorPreferenceCheck() {
|
||||||
let suite = "terebi.sponsor-preference." + UUID().uuidString
|
let suite = "terebi.sponsor-preference." + UUID().uuidString
|
||||||
let defaults = UserDefaults(suiteName: suite)!
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
defer { defaults.removePersistentDomain(forName: suite) }
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
func launch(_ configured: Bool = false) -> PlayerModel {
|
func launch(_ configured: Bool = false, autoSkip: Bool = false, openingEnding: Bool = false) -> PlayerModel {
|
||||||
PlayerModel(defaults: UserDefaults(suiteName: suite)!, configuration: .init(sponsorBlock: configured))
|
PlayerModel(defaults: UserDefaults(suiteName: suite)!,
|
||||||
|
configuration: .init(sponsorBlock: configured, sponsorBlockAutoSkip: autoSkip,
|
||||||
|
openingEndingAutoSkip: openingEnding))
|
||||||
}
|
}
|
||||||
let first = launch()
|
let first = launch()
|
||||||
require(!first.sponsorBlockEnabled, "unconfigured SponsorBlock defaults to disabled")
|
require(!first.sponsorBlockEnabled, "unconfigured SponsorBlock defaults to disabled")
|
||||||
|
require(!first.sponsorBlockAutoSkip, "sponsors are not skipped without opt-in")
|
||||||
|
require(!first.openingEndingAutoSkip, "openings and endings are not skipped without opt-in")
|
||||||
|
first.setOpeningEndingAutoSkip(true)
|
||||||
|
first.setSponsorBlockAutoSkip(true)
|
||||||
first.setSponsorBlockEnabled(true)
|
first.setSponsorBlockEnabled(true)
|
||||||
first.shutdown()
|
first.shutdown()
|
||||||
let second = launch()
|
let second = launch()
|
||||||
require(second.sponsorBlockEnabled, "enabled preference survives a fresh model/defaults instance")
|
require(second.sponsorBlockEnabled, "enabled preference survives a fresh model/defaults instance")
|
||||||
|
require(second.sponsorBlockAutoSkip, "autoskip preference survives restart")
|
||||||
|
require(second.openingEndingAutoSkip, "opening/ending autoskip preference survives restart")
|
||||||
|
second.setOpeningEndingAutoSkip(false)
|
||||||
|
second.setSponsorBlockAutoSkip(false)
|
||||||
second.setSponsorBlockEnabled(false)
|
second.setSponsorBlockEnabled(false)
|
||||||
second.shutdown()
|
second.shutdown()
|
||||||
let third = launch(true)
|
let third = launch(true, autoSkip: true, openingEnding: true)
|
||||||
require(!third.sponsorBlockEnabled, "saved disabled preference survives restart and overrides config")
|
require(!third.sponsorBlockEnabled && !third.sponsorBlockAutoSkip && !third.openingEndingAutoSkip,
|
||||||
|
"saved disabled skip preferences override config")
|
||||||
third.shutdown()
|
third.shutdown()
|
||||||
defaults.removePersistentDomain(forName: suite)
|
defaults.removePersistentDomain(forName: suite)
|
||||||
let configured = launch(true)
|
let configured = launch(true, autoSkip: true, openingEnding: true)
|
||||||
require(configured.sponsorBlockEnabled, "config supplies the initial choice without a saved preference")
|
require(configured.sponsorBlockEnabled && configured.sponsorBlockAutoSkip && configured.openingEndingAutoSkip,
|
||||||
|
"config supplies unsaved skip choices")
|
||||||
configured.shutdown()
|
configured.shutdown()
|
||||||
print("PASS: SponsorBlock preference persists enable/disable across launches, with config fallback")
|
print("PASS: sponsor and opening/ending skip preferences persist across launches, with config fallback")
|
||||||
}
|
}
|
||||||
|
|
||||||
static var fixtureSponsors: [SponsorSegment] {
|
static var fixtureSponsors: [SponsorSegment] {
|
||||||
@@ -167,6 +327,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
var extraction: CheckedContinuation<YouTubeVideo, Never>?
|
var extraction: CheckedContinuation<YouTubeVideo, Never>?
|
||||||
var held: [CheckedContinuation<[SponsorSegment], Never>] = []
|
var held: [CheckedContinuation<[SponsorSegment], Never>] = []
|
||||||
var extractionCount = 0
|
var extractionCount = 0
|
||||||
|
let clock = Clock()
|
||||||
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, height in
|
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, height in
|
||||||
extractionCount += 1
|
extractionCount += 1
|
||||||
let video = YouTubeVideo(title: "Sponsor fixture", video: .init(url: source.url, headers: source.headers),
|
let video = YouTubeVideo(title: "Sponsor fixture", video: .init(url: source.url, headers: source.headers),
|
||||||
@@ -177,7 +338,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
}, youtubeTitleResolver: { _ in nil }, sponsorResolver: { id in
|
}, youtubeTitleResolver: { _ in nil }, sponsorResolver: { id in
|
||||||
if id == "BBBBBBBBBBB" { throw URLError(.timedOut) }
|
if id == "BBBBBBBBBBB" { throw URLError(.timedOut) }
|
||||||
return await withCheckedContinuation { held.append($0) }
|
return await withCheckedContinuation { held.append($0) }
|
||||||
})
|
}, skipClock: { clock.now })
|
||||||
defer { model.shutdown() }
|
defer { model.shutdown() }
|
||||||
model.open(url)
|
model.open(url)
|
||||||
try await until("playback extraction held with SponsorBlock disabled", model: model) { extraction != nil }
|
try await until("playback extraction held with SponsorBlock disabled", model: model) { extraction != nil }
|
||||||
@@ -199,7 +360,34 @@ struct ResolvedPlaybackCheck {
|
|||||||
try await until("sponsor seek acknowledged without automatic skipping", model: model) {
|
try await until("sponsor seek acknowledged without automatic skipping", model: model) {
|
||||||
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
|
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
|
||||||
}
|
}
|
||||||
require(model.segmentToSkip == nil, "sponsors never offer Miruro skip actions")
|
clock.now += 60
|
||||||
|
require(model.skipSegment(), "sponsor skip stays available beyond the opening/ending timeout")
|
||||||
|
try await until("manual sponsor skip reaches range end while paused", model: model) {
|
||||||
|
!model.state.seek_pending && abs(model.state.position - 1.5) < 0.1
|
||||||
|
}
|
||||||
|
require(!model.effectivePlaying, "manual sponsor skip preserves paused intent")
|
||||||
|
model.setSponsorBlockAutoSkip(true)
|
||||||
|
require(model.seekRelative(0.75 - model.state.position), "deliberately revisit sponsor with autoskip enabled")
|
||||||
|
try await until("intentional seek into sponsor settles", model: model) {
|
||||||
|
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
|
||||||
|
}
|
||||||
|
require(model.setPlayback(playing: true), "play deliberately selected sponsor")
|
||||||
|
try await until("deliberately selected sponsor plays", model: model) { model.state.position >= 1 }
|
||||||
|
require(model.state.position < 1.5, "autoskip does not fight a seek into the sponsor")
|
||||||
|
require(model.setPlayback(playing: false), "pause before seeking just before sponsor")
|
||||||
|
try await until("sponsor paused again", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
|
model.isSeeking = true
|
||||||
|
model.seekPosition = 0.25
|
||||||
|
model.finishSeeking()
|
||||||
|
try await until("slider seek just before sponsor settles", model: model) {
|
||||||
|
!model.state.seek_pending && abs(model.state.position - 0.25) < 0.1
|
||||||
|
}
|
||||||
|
require(model.setPlayback(playing: true), "play from just before sponsor")
|
||||||
|
try await until("sponsor entered after nearby slider seek", model: model) { model.state.position >= 0.9 }
|
||||||
|
require(model.state.position < 1.5, "autoskip does not fight a nearby slider seek")
|
||||||
|
require(model.skipButtonTitle == "Skip Sponsor", "suppressed autoskip keeps the manual button")
|
||||||
|
require(model.setPlayback(playing: false), "pause before metadata lifecycle checks")
|
||||||
|
try await until("sponsor lifecycle playback paused", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
model.setSponsorBlockEnabled(false)
|
model.setSponsorBlockEnabled(false)
|
||||||
require(model.sponsoredRanges.isEmpty, "disabling immediately clears paint")
|
require(model.sponsoredRanges.isEmpty, "disabling immediately clears paint")
|
||||||
model.setSponsorBlockEnabled(true)
|
model.setSponsorBlockEnabled(true)
|
||||||
@@ -247,7 +435,28 @@ struct ResolvedPlaybackCheck {
|
|||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.has_audio
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.has_audio
|
||||||
}
|
}
|
||||||
require(held.count == 5 && model.sponsoredRanges.isEmpty, "generic sites never send a SponsorBlock lookup")
|
require(held.count == 5 && model.sponsoredRanges.isEmpty, "generic sites never send a SponsorBlock lookup")
|
||||||
print("PASS: SponsorBlock opt-in, independent lookup, crimson-only ranges, quality reuse, same-video cancellation, close, generic sites and timeout isolation")
|
print("PASS: persistent manual sponsor skip, deliberate-seek protection, opt-in, quality reuse, cancellation and failure isolation")
|
||||||
|
try await sponsorAutoSkip(source: source)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func sponsorAutoSkip(source: ResolvedSource) async throws {
|
||||||
|
let suite = "terebi.sponsor-autoskip." + UUID().uuidString
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
|
let model = PlayerModel(defaults: defaults,
|
||||||
|
configuration: .init(sponsorBlock: true, sponsorBlockAutoSkip: true),
|
||||||
|
youtubeResolver: { url, _ in
|
||||||
|
YouTubeVideo(title: "Autoskip fixture", video: .init(url: source.url, headers: source.headers),
|
||||||
|
audio: nil, duration: 0, isLive: false, webpageURL: url, height: 720, availableHeights: [720], chapters: [])
|
||||||
|
}, youtubeTitleResolver: { _ in nil }, sponsorResolver: { _ in fixtureSponsors })
|
||||||
|
defer { model.shutdown() }
|
||||||
|
model.open(URL(string: "https://www.youtube.com/watch?v=CCCCCCCCCCC")!)
|
||||||
|
try await until("natural sponsor entry queues exact-end autoskip", model: model) { model.seekPosition == 1.5 }
|
||||||
|
try await until("native player acknowledges autoskip and keeps playing", model: model) {
|
||||||
|
!model.state.seek_pending && model.state.position >= 1.5 && model.state.status == PLAYER_PLAYING
|
||||||
|
}
|
||||||
|
require(model.effectivePlaying, "autoskip preserves playing intent")
|
||||||
|
print("PASS: natural sponsor entry automatically seeks to range end in the native player")
|
||||||
}
|
}
|
||||||
|
|
||||||
static var fixtureChapters: [YouTubeChapter] {
|
static var fixtureChapters: [YouTubeChapter] {
|
||||||
@@ -370,9 +579,11 @@ struct ResolvedPlaybackCheck {
|
|||||||
var input = ResolvedSource(url: base + "/transport-master.m3u8?token=private", headers:
|
var input = ResolvedSource(url: base + "/transport-master.m3u8?token=private", headers:
|
||||||
"Referer: https://transport.example/\r\nX-Secret: private\r\n",
|
"Referer: https://transport.example/\r\nX-Secret: private\r\n",
|
||||||
identity: source.identity, title: source.title, subtitles: [])
|
identity: source.identity, title: source.title, subtitles: [])
|
||||||
|
input.intervals = [.init(kind: .intro, start: 0.25, end: 1.75)]
|
||||||
input.requirements = .init(proxy: .init(rotate: false, segments: true),
|
input.requirements = .init(proxy: .init(rotate: false, segments: true),
|
||||||
segmentOrigin: base, playlistQuery: ["skip": "fixture"])
|
segmentOrigin: base, playlistQuery: ["skip": "fixture"])
|
||||||
let prepared = try await MiruroTransport.prepare(input, deployment: deployment)
|
let prepared = try await MiruroTransport.prepare(input, deployment: deployment)
|
||||||
|
require(prepared.intervals == input.intervals, "transport source reconstruction preserves provider intervals")
|
||||||
let (master, _) = try await URLSession.shared.data(from: URL(string: prepared.url)!)
|
let (master, _) = try await URLSession.shared.data(from: URL(string: prepared.url)!)
|
||||||
let nested = String(data: master, encoding: .utf8)!.components(separatedBy: .newlines)
|
let nested = String(data: master, encoding: .utf8)!.components(separatedBy: .newlines)
|
||||||
.first { $0.hasPrefix("http://") }!
|
.first { $0.hasPrefix("http://") }!
|
||||||
@@ -1088,4 +1299,56 @@ struct ResolvedPlaybackCheck {
|
|||||||
require(model.effectivePlaying, "opening skip preserves playing intent")
|
require(model.effectivePlaying, "opening skip preserves playing intent")
|
||||||
print("PASS: timed opening/ending offers, dismissal, pending seeks, exact-end skips and reopen reset")
|
print("PASS: timed opening/ending offers, dismissal, pending seeks, exact-end skips and reopen reset")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func automaticSegmentSkips(file: URL) async throws {
|
||||||
|
for seekTarget: Double? in [nil, 0.75, 0.25] {
|
||||||
|
let suite = "terebi.segment-autoskip." + UUID().uuidString
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
|
let model = PlayerModel(defaults: defaults, configuration: .init(openingEndingAutoSkip: true),
|
||||||
|
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) }, skipClock: { 100 })
|
||||||
|
defer { model.shutdown() }
|
||||||
|
model.openResolvedSource(file)
|
||||||
|
try await until("automatic segment fixture plays", model: model) {
|
||||||
|
model.state.status == PLAYER_PLAYING && model.canSeek && !model.isBusy
|
||||||
|
}
|
||||||
|
let opening = MiruroInterval(kind: .intro, start: 0.5, end: 1.5, episode: 1)
|
||||||
|
let ending = MiruroInterval(kind: .outro, start: 2, end: model.duration, episode: 1)
|
||||||
|
if let seekTarget {
|
||||||
|
require(model.setPlayback(playing: false), "pause before intentional opening seek")
|
||||||
|
try await until("automatic segment fixture pauses", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
|
if seekTarget == 0.25 {
|
||||||
|
model.isSeeking = true
|
||||||
|
model.seekPosition = seekTarget
|
||||||
|
model.finishSeeking()
|
||||||
|
} else {
|
||||||
|
require(model.seekRelative(seekTarget - model.state.position), "seek into opening")
|
||||||
|
}
|
||||||
|
try await until("intentional opening seek settles", model: model) {
|
||||||
|
!model.state.seek_pending && abs(model.state.position - seekTarget) < 0.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
model.supplyMetadataForCheck(.init(title: nil, intervals: [opening, ending]), episode: 1)
|
||||||
|
if let seekTarget {
|
||||||
|
require(abs(model.seekPosition - seekTarget) < 0.1 && model.state.status == PLAYER_PAUSED,
|
||||||
|
"late metadata does not autoskip paused playback")
|
||||||
|
require(model.setPlayback(playing: true), "resume deliberately selected opening")
|
||||||
|
try await until("deliberately selected opening plays", model: model) { model.state.position >= 1 }
|
||||||
|
require(model.state.position < opening.end && model.skipButtonTitle == "Skip Opening",
|
||||||
|
"opening autoskip respects seeking into or just before it and keeps the manual button")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try await until("opening autoskip queues its exact end", model: model) { model.seekPosition == opening.end }
|
||||||
|
try await until("native player acknowledges opening autoskip", model: model) {
|
||||||
|
!model.state.seek_pending && model.state.position >= opening.end && model.state.status == PLAYER_PLAYING
|
||||||
|
}
|
||||||
|
try await until("ending autoskip queues exact media end before playing through ending", model: model) {
|
||||||
|
model.seekPosition == ending.end && model.state.position < ending.end - 0.5
|
||||||
|
}
|
||||||
|
try await until("native player acknowledges ending autoskip", model: model) {
|
||||||
|
!model.state.seek_pending && abs(model.state.position - ending.end) < 0.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print("PASS: native opening/ending autoskip, paused/late metadata and deliberate relative/slider seek protection")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
+62
-3
@@ -15,7 +15,10 @@ struct ZokoCheck {
|
|||||||
throw Failure(message: "Incorrect provider error category")
|
throw Failure(message: "Incorrect provider error category")
|
||||||
}
|
}
|
||||||
static func page(_ config: [String: Any]) throws -> String {
|
static func page(_ config: [String: Any]) throws -> String {
|
||||||
var data = Array(try JSONSerialization.data(withJSONObject: config))
|
try page(data: JSONSerialization.data(withJSONObject: config))
|
||||||
|
}
|
||||||
|
static func page(data: Data) -> String {
|
||||||
|
var data = Array(data)
|
||||||
let key = Array("otaku-embed-v1".utf8)
|
let key = Array("otaku-embed-v1".utf8)
|
||||||
for i in data.indices { data[i] ^= key[i % key.count] }
|
for i in data.indices { data[i] ^= key[i % key.count] }
|
||||||
return "<script>window.__P = '\(Data(data).base64EncodedString())';</script>"
|
return "<script>window.__P = '\(Data(data).base64EncodedString())';</script>"
|
||||||
@@ -35,7 +38,60 @@ struct ZokoCheck {
|
|||||||
}
|
}
|
||||||
var config: [String: Any] = ["src": "https://hls.dramahot.top/master.m3u8?token=fixture",
|
var config: [String: Any] = ["src": "https://hls.dramahot.top/master.m3u8?token=fixture",
|
||||||
"subtitles": [["src": "https://hls.dramahot.top/en.vtt", "lang": "en", "label": "English", "default": true]]]
|
"subtitles": [["src": "https://hls.dramahot.top/en.vtt", "lang": "en", "label": "English", "default": true]]]
|
||||||
|
let intro = ProviderInterval(kind: .intro, start: 49.25, end: 144.75)
|
||||||
|
let outro = ProviderInterval(kind: .outro, start: 1357.125, end: 1451.5)
|
||||||
|
config["skip"] = ["intro": ["start": intro.start, "end": intro.end],
|
||||||
|
"outro": ["start": outro.start, "end": outro.end]]
|
||||||
let source = try ZokoResolver.parse(page: page(config), mediaID: "1", episode: 1)
|
let source = try ZokoResolver.parse(page: page(config), mediaID: "1", episode: 1)
|
||||||
|
try require(source.intervals == [intro, outro], "fractional provider boundaries retain seconds and kinds")
|
||||||
|
let replaced = source.replacingSubtitles([])
|
||||||
|
try require(replaced.intervals == source.intervals && replaced.subtitles.isEmpty &&
|
||||||
|
replaced.identity == source.identity && replaced.requirements.zoko,
|
||||||
|
"subtitle replacement preserves provider intervals and playback requirements")
|
||||||
|
let malformedMetadata: [Any] = [NSNull(), true, "bad", [], [:], ["intro": NSNull(), "outro": "bad"]]
|
||||||
|
for metadata in malformedMetadata {
|
||||||
|
var malformed = config
|
||||||
|
malformed["skip"] = metadata
|
||||||
|
let parsed = try ZokoResolver.parse(page: page(malformed), mediaID: "1", episode: 1)
|
||||||
|
try require(parsed.intervals.isEmpty && parsed.url == source.url && parsed.subtitles.count == 1,
|
||||||
|
"missing or malformed skip metadata does not block valid playback")
|
||||||
|
}
|
||||||
|
var absent = config
|
||||||
|
absent.removeValue(forKey: "skip")
|
||||||
|
let withoutTimings = try ZokoResolver.parse(page: page(absent), mediaID: "1", episode: 1)
|
||||||
|
try require(withoutTimings.intervals.isEmpty && withoutTimings.url == source.url,
|
||||||
|
"provider timings are optional")
|
||||||
|
let invalidTimings: [Any] = [
|
||||||
|
NSNull(), true, "bad", [], [:], ["start": 1], ["end": 2],
|
||||||
|
["start": true, "end": 144], ["start": 49, "end": false],
|
||||||
|
["start": "49", "end": 144], ["start": 49, "end": "144"],
|
||||||
|
["start": -1, "end": 144], ["start": 49, "end": -1],
|
||||||
|
["start": 49, "end": 49], ["start": 144, "end": 49]
|
||||||
|
]
|
||||||
|
for invalid in invalidTimings {
|
||||||
|
for badKind in ["intro", "outro"] {
|
||||||
|
var malformed = config
|
||||||
|
var skip = config["skip"] as! [String: Any]
|
||||||
|
skip[badKind] = invalid
|
||||||
|
malformed["skip"] = skip
|
||||||
|
let parsed = try ZokoResolver.parse(page: page(malformed), mediaID: "1", episode: 1)
|
||||||
|
try require(parsed.intervals == (badKind == "intro" ? [outro] : [intro]) && parsed.url == source.url,
|
||||||
|
"each malformed interval is discarded independently without blocking playback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for timing in [#"{"start":1e999,"end":144}"#, #"{"start":49,"end":1e999}"#] {
|
||||||
|
let data = Data("""
|
||||||
|
{"src":"https://hls.dramahot.top/master.m3u8","subtitles":[],
|
||||||
|
"skip":{"intro":\(timing),"outro":{"start":1357.125,"end":1451.5}}}
|
||||||
|
""".utf8)
|
||||||
|
let parsed = try ZokoResolver.parse(page: page(data: data), mediaID: "1", episode: 1)
|
||||||
|
try require(parsed.intervals == [outro], "nonfinite numeric interval never blocks a valid sibling or playback")
|
||||||
|
}
|
||||||
|
var zeroStart = config
|
||||||
|
zeroStart["skip"] = ["intro": ["start": 0, "end": 1.5]]
|
||||||
|
let zeroSource = try ZokoResolver.parse(page: page(zeroStart), mediaID: "1", episode: 1)
|
||||||
|
try require(zeroSource.intervals == [ProviderInterval(kind: .intro, start: 0, end: 1.5)],
|
||||||
|
"zero-based intervals remain valid")
|
||||||
let mapped = try await ZokoResolver.resolve(mediaID: "97940", episode: 1, fetchResponse: { request in
|
let mapped = try await ZokoResolver.resolve(mediaID: "97940", episode: 1, fetchResponse: { request in
|
||||||
let data: Data
|
let data: Data
|
||||||
let mime: String
|
let mime: String
|
||||||
@@ -46,13 +102,16 @@ struct ZokoCheck {
|
|||||||
mime = "application/json"
|
mime = "application/json"
|
||||||
} else {
|
} else {
|
||||||
try require(request.url?.absoluteString == "https://zokoanime.video/stream/mal/34572/1/sub", "provider lookup uses the exact mapped MAL ID, not AniList ID")
|
try require(request.url?.absoluteString == "https://zokoanime.video/stream/mal/34572/1/sub", "provider lookup uses the exact mapped MAL ID, not AniList ID")
|
||||||
data = Data(try page(["src": "https://hls.dramahot.top/master.m3u8", "subtitles": []]).utf8)
|
data = Data(try page(["src": "https://hls.dramahot.top/master.m3u8", "subtitles": [],
|
||||||
|
"skip": ["intro": ["start": 49.25, "end": 144.75]]]).utf8)
|
||||||
mime = "text/html"
|
mime = "text/html"
|
||||||
}
|
}
|
||||||
return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": mime])!)
|
return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": mime])!)
|
||||||
})
|
})
|
||||||
try require(mapped.identity == "anilist:97940:1" && mapped.title == "Black Clover · Episode 1 (sub)",
|
try require(mapped.identity == "anilist:97940:1" && mapped.title == "Black Clover · Episode 1 (sub)",
|
||||||
"mapped playback preserves AniList identity and catalog title")
|
"mapped playback preserves AniList identity and catalog title")
|
||||||
|
try require(mapped.intervals == [intro] && mapped.requirements.zoko,
|
||||||
|
"catalog title enrichment preserves provider intervals and transport requirements")
|
||||||
for bad in [
|
for bad in [
|
||||||
#"{"data":{"Media":{"id":34572,"idMal":97940}}}"#,
|
#"{"data":{"Media":{"id":34572,"idMal":97940}}}"#,
|
||||||
#"{"data":{"Media":{"id":97940,"idMal":0}}}"#,
|
#"{"data":{"Media":{"id":97940,"idMal":0}}}"#,
|
||||||
@@ -116,7 +175,7 @@ struct ZokoCheck {
|
|||||||
progress = PlaybackProgress(defaults: defaults)
|
progress = PlaybackProgress(defaults: defaults)
|
||||||
try require(progress.position(for: identity) == 123 && progress.position(for: URL(string: "miruro:1:1")!) == 456,
|
try require(progress.position(for: identity) == 123 && progress.position(for: URL(string: "miruro:1:1")!) == 456,
|
||||||
"AniList resume persists separately from Miruro")
|
"AniList resume persists separately from Miruro")
|
||||||
print("PASS: AniList URL/identity, static codec, unavailable/protection/malformed categories, bounded resources/subtitles, narrow CDN policy and isolated resume")
|
print("PASS: AniList URL/identity, static codec, optional independent fractional skip metadata, source transformations, unavailable/protection/malformed categories, bounded resources/subtitles, narrow CDN policy and isolated resume")
|
||||||
}
|
}
|
||||||
|
|
||||||
// No NSApplication, window or audio playback. The production player opens and
|
// No NSApplication, window or audio playback. The production player opens and
|
||||||
|
|||||||
@@ -321,6 +321,10 @@ $(BUILD_DIR)/resolved-playback-check: Checks/resolved-playback.swift $(SWIFT_SOU
|
|||||||
check-resolved: $(BUILD_DIR)/resolved-playback-check check-fixtures
|
check-resolved: $(BUILD_DIR)/resolved-playback-check check-fixtures
|
||||||
python3 Checks/resolved-playback.py $(BUILD_DIR)/resolved-playback-check $(BUILD_DIR)/fixtures
|
python3 Checks/resolved-playback.py $(BUILD_DIR)/resolved-playback-check $(BUILD_DIR)/fixtures
|
||||||
|
|
||||||
|
.PHONY: check-zoko-intervals
|
||||||
|
check-zoko-intervals: $(BUILD_DIR)/resolved-playback-check check-fixtures
|
||||||
|
python3 Checks/resolved-playback.py $(BUILD_DIR)/resolved-playback-check $(BUILD_DIR)/fixtures --zoko-intervals
|
||||||
|
|
||||||
|
|
||||||
$(BUILD_DIR)/ffmpeg-runtime-check: Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) Makefile | $(BUILD_DIR)
|
$(BUILD_DIR)/ffmpeg-runtime-check: Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) Makefile | $(BUILD_DIR)
|
||||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) -framework Foundation -o $@
|
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) -framework Foundation -o $@
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self
|
|||||||
- **Stream queues:** integrations can supply an ordered yt-dlp/Miruro queue with `--new-instance --queue-file /absolute/queue.json`, or append without interrupting the running player using `--enqueue --queue-file /absolute/queue.json` (also for a single item). Enqueue starts playback if the player is empty or not running. The same sidebar supports play, reorder, remove, previous/next and automatic advance. Unresolved titles, including the selected row, load serially at lower priority without waiting for playback loading, buffering or quality extraction. YouTube uses a title-only oEmbed request; other sites use yt-dlp metadata and Miruro uses optional episode metadata. Arrival still depends on the site/network; failed lookups retain the supplied label, and full playback metadata takes precedence. Playback sources still resolve fresh when selected or revisited; local-file batches still use natural filename order. yt-dlp items use the original page URL for resume and version-1 progress reports; recognized YouTube video URLs still canonicalize.
|
- **Stream queues:** integrations can supply an ordered yt-dlp/Miruro queue with `--new-instance --queue-file /absolute/queue.json`, or append without interrupting the running player using `--enqueue --queue-file /absolute/queue.json` (also for a single item). Enqueue starts playback if the player is empty or not running. The same sidebar supports play, reorder, remove, previous/next and automatic advance. Unresolved titles, including the selected row, load serially at lower priority without waiting for playback loading, buffering or quality extraction. YouTube uses a title-only oEmbed request; other sites use yt-dlp metadata and Miruro uses optional episode metadata. Arrival still depends on the site/network; failed lookups retain the supplied label, and full playback metadata takes precedence. Playback sources still resolve fresh when selected or revisited; local-file batches still use natural filename order. yt-dlp items use the original page URL for resume and version-1 progress reports; recognized YouTube video URLs still canonicalize.
|
||||||
- **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints. While mouse-seeking, the segment under the thumb smoothly thickens and returns to normal on release. Hovering a named segment fades its title in above the segment's center. Reduce Motion disables these transitions.
|
- **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints. While mouse-seeking, the segment under the thumb smoothly thickens and returns to normal on release. Hovering a named segment fades its title in above the segment's center. Reduce Motion disables these transitions.
|
||||||
- **YouTube chapters:** when yt-dlp provides usable chapters, small rounded gaps mark their boundaries in the playback track. Boundaries use the current player's duration; missing or malformed chapter data leaves ordinary playback unchanged. Chapters do not add opening/ending skip buttons. Availability varies, and description-derived timestamps may differ from YouTube's displayed chapters.
|
- **YouTube chapters:** when yt-dlp provides usable chapters, small rounded gaps mark their boundaries in the playback track. Boundaries use the current player's duration; missing or malformed chapter data leaves ordinary playback unchanged. Chapters do not add opening/ending skip buttons. Availability varies, and description-derived timestamps may differ from YouTube's displayed chapters.
|
||||||
- **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; accessibility also identifies sponsored ranges. This is visual information only: it never seeks or skips.
|
- **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. Nothing skips automatically.
|
- **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
|
||||||
@@ -81,6 +81,8 @@ The canonical handoff still uses `/stream/ani/<AniList ID>/<episode>/sub`, not t
|
|||||||
|
|
||||||
Resolution uses bounded ordinary HTTPS with certificate verification, no cookies/credentials, no redirect following, and no browser or advertising/challenge script execution. AniList's returned catalog ID must match the requested identity; invalid mappings and GraphQL errors are rejected. The same metadata response can supply the playback title, with `AniList <ID> · Episode <number> (sub)` used when the title is absent. Only the provider's static `window.__P` Base64/XOR configuration is decoded with the first-party `otaku-embed-v1` codec. Poster, download, chapter, view/telemetry and player-script fields are ignored and never fetched.
|
Resolution uses bounded ordinary HTTPS with certificate verification, no cookies/credentials, no redirect following, and no browser or advertising/challenge script execution. AniList's returned catalog ID must match the requested identity; invalid mappings and GraphQL errors are rejected. The same metadata response can supply the playback title, with `AniList <ID> · Episode <number> (sub)` used when the title is absent. Only the provider's static `window.__P` Base64/XOR configuration is decoded with the first-party `otaku-embed-v1` codec. Poster, download, chapter, view/telemetry and player-script fields are ignored and never fetched.
|
||||||
|
|
||||||
|
ZokoAnime's optional `skip.intro` / `skip.outro` timestamps feed the existing opening/ending bands and skip controls, including queued episodes and saved-episode reopening. Fractional seconds are preserved. Each interval must have finite, nonnegative, increasing boundaries within the actual player-reported duration; missing or malformed timing is ignored without blocking playback. No Miruro lookup or external timing service is used.
|
||||||
|
|
||||||
The existing process-local loopback HLS relay normalizes playlists for the native decoder. This provider alone permits HTTPS playlists/VTT on `hls.dramahot.top` and HTTPS media resources on that host or the demonstrated `hls1.drama1.cfd` segment origin (default port only). Every manifest URI is checked before registration and fetching, upstream redirects are refused, and only the fixed public `Referer: https://zokoanime.video/` is supplied to those admitted origins. Upstream URLs/tokens remain in memory and are not progress identities. Native global same-origin policy and TLS verification are unchanged; new CDN hosts fail closed until explicitly reviewed. Subtitle redirects retain the existing same-origin-only restriction.
|
The existing process-local loopback HLS relay normalizes playlists for the native decoder. This provider alone permits HTTPS playlists/VTT on `hls.dramahot.top` and HTTPS media resources on that host or the demonstrated `hls1.drama1.cfd` segment origin (default port only). Every manifest URI is checked before registration and fetching, upstream redirects are refused, and only the fixed public `Referer: https://zokoanime.video/` is supplied to those admitted origins. Upstream URLs/tokens remain in memory and are not progress identities. Native global same-origin policy and TLS verification are unchanged; new CDN hosts fail closed until explicitly reviewed. Subtitle redirects retain the existing same-origin-only restriction.
|
||||||
|
|
||||||
Resume, queue deduplication and per-item reports use **`anilist:<ID>:<episode>`**, separately from existing `miruro:` and YouTube identities. Queues use the existing version-1 queue document, with the watch URL above as `url`; progress JSON uses the existing version-2 fields with `media_identity: {"provider":"anilist","media_id":"1","episode":1}`. Old Miruro history is neither migrated nor reinterpreted. Resolved-source files also accept `identity.provider = "anilist"` with canonical decimal IDs; as with Miruro files, the caller owns the explicitly supplied source and headers.
|
Resume, queue deduplication and per-item reports use **`anilist:<ID>:<episode>`**, separately from existing `miruro:` and YouTube identities. Queues use the existing version-1 queue document, with the watch URL above as `url`; progress JSON uses the existing version-2 fields with `media_identity: {"provider":"anilist","media_id":"1","episode":1}`. Old Miruro history is neither migrated nor reinterpreted. Resolved-source files also accept `identity.provider = "anilist"` with canonical decimal IDs; as with Miruro files, the caller owns the explicitly supplied source and headers.
|
||||||
@@ -106,25 +108,39 @@ The optional config file is `~/.config/terebi/config.toml`, or `$XDG_CONFIG_HOME
|
|||||||
terebi --default-config
|
terebi --default-config
|
||||||
```
|
```
|
||||||
|
|
||||||
Copy the settings you want to change into the config file; omitted settings keep their defaults. Settings load at startup, so restart the player after editing. Menu changes are session-only except SponsorBlock, whose saved preference overrides its startup config setting.
|
Copy the settings you want to change into the config file; omitted settings keep their defaults. Settings load at startup, so restart the player after editing. Menu changes are session-only except the SponsorBlock and opening/ending autoskip toggles, whose saved preferences override their startup config settings.
|
||||||
|
|
||||||
The queue shortcut can be rebound with `show_queue` and `hide_queue` under `[keys]`; give both actions the same chord to keep a single toggle.
|
The queue shortcut can be rebound with `show_queue` and `hide_queue` under `[keys]`; give both actions the same chord to keep a single toggle.
|
||||||
|
|
||||||
|
### Opening/ending autoskip
|
||||||
|
|
||||||
|
**Playback → Automatically Skip Openings and Endings**, also available in the player's gear menu, skips valid opening/ending intervals to their exact ends. It is **off by default**, independent of SponsorBlock, and saved across launches. Without a saved preference, this startup setting applies:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[playback]
|
||||||
|
opening_ending_autoskip = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `false` or omit the setting to keep automatic skipping disabled; non-boolean values are rejected. Autoskip runs only during playing, nonbuffering video playback, never while paused or a seek is pending. Seeking into an interval or up to **five seconds before its start** keeps it manual-only for that visit, including when metadata arrives later. Quality changes preserve that intent; saved-position restoration gets the same protection. The existing timed manual buttons remain available when autoskip is suppressed. No interval metadata means no automatic skip.
|
||||||
|
|
||||||
### SponsorBlock
|
### SponsorBlock
|
||||||
|
|
||||||
SponsorBlock is **off by default**. **Playback → SponsorBlock → Show Sponsored Ranges** saves your choice across launches. A saved choice overrides the config setting; without one, the startup setting applies:
|
SponsorBlock and automatic sponsor skipping are **off by default**. **Playback → SponsorBlock → Show Sponsored Ranges** enables lookups, markers and manual skipping; **Automatically Skip Sponsors** separately opts into automatic skipping. Both choices are saved across launches. Saved choices override their respective config settings; without them, the startup settings apply:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[playback]
|
[playback]
|
||||||
sponsorblock = true
|
sponsorblock = true
|
||||||
|
sponsorblock_autoskip = false
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `false` (the generated default) or omit the setting to default to disabled when no preference is saved; non-boolean values are rejected. The optional lookup runs independently of playback, only for canonical YouTube videos. Failure leaves playback unchanged with no sponsored markers.
|
Use `false` (the generated defaults) or omit these settings to default to disabled when no preference is saved; non-boolean values are rejected. Autoskip alone does not enable SponsorBlock lookups. The optional lookup runs independently of playback, only for canonical YouTube videos. Failure leaves playback unchanged with no sponsored markers.
|
||||||
|
|
||||||
Enabling it adds a connection to `sponsor.ajay.app`. Requests use a bucket identified by the first four hexadecimal characters of the video ID's SHA-256 hash, reducing disclosure compared with sending the full ID; this is **not anonymous**. Terebi sends no cookies, account credentials or API key and makes no submissions, votes or view reports.
|
Enabling it adds a connection to `sponsor.ajay.app`. Requests use a bucket identified by the first four hexadecimal characters of the video ID's SHA-256 hash, reducing disclosure compared with sending the full ID; this is **not anonymous**. Terebi sends no cookies, account credentials or API key and makes no submissions, votes or view reports.
|
||||||
|
|
||||||
Ranges require finite, strictly valid bounds inside the actual player duration. A known reference duration that differs from playback by **one second or more** is rejected. Ranges with an unknown reference duration are retained until the player reports an actual finite duration, then validated before display.
|
Ranges require finite, strictly valid bounds inside the actual player duration. A known reference duration that differs from playback by **one second or more** is rejected. Ranges with an unknown reference duration are retained until the player reports an actual finite duration, then validated before display.
|
||||||
|
|
||||||
|
Autoskip seeks to the end of a sponsored range during playing, nonbuffering video playback, not while paused or a seek is pending. Seeking into a range or up to **five seconds before its start** leaves that range manual-only for that visit, including when playback resumes or metadata arrives later. A later seek replaces that intent; quality changes preserve it. Saved-position restoration gets the same protection. The manual button remains available throughout the range.
|
||||||
|
|
||||||
### Miruro providers
|
### Miruro providers
|
||||||
|
|
||||||
Choose **Playback → Miruro → Video Host** or **Caption Provider** in Terebi's menu bar, or use **gear → Miruro**, to change providers for the session. Choices retain both provider and track: `ssub` (soft subtitles), `sub` (hard subtitles), or `dub` (dubbed audio). Video and caption providers are independent; available choices come from the episode response. These menus do not write the config file.
|
Choose **Playback → Miruro → Video Host** or **Caption Provider** in Terebi's menu bar, or use **gear → Miruro**, to change providers for the session. Choices retain both provider and track: `ssub` (soft subtitles), `sub` (hard subtitles), or `dub` (dubbed audio). Video and caption providers are independent; available choices come from the episode response. These menus do not write the config file.
|
||||||
@@ -158,7 +174,7 @@ Install FFmpeg for test fixtures (`brew install ffmpeg`), then run `make check`.
|
|||||||
|
|
||||||
For the focused native compact-queue smoke (transparent, noninteractive app window), run `TEREBI_CHECK_COMPACT_QUEUE_ONLY=1 make check-startup`. `make check-window check-config` covers dropdown geometry, resizing, metadata aspect changes, restoration and configurable queue shortcuts without displaying windows.
|
For the focused native compact-queue smoke (transparent, noninteractive app window), run `TEREBI_CHECK_COMPACT_QUEUE_ONLY=1 make check-startup`. `make check-window check-config` covers dropdown geometry, resizing, metadata aspect changes, restoration and configurable queue shortcuts without displaying windows.
|
||||||
|
|
||||||
`make check-sponsorblock` exercises the bounded native lookup and duration validation with local fixtures; `.build/sponsorblock-check --live` performs read-only public API probes. `make check-resolved` includes SponsorBlock opt-in/failure isolation, same-video cancellation, direct/saved/queue/enqueue launches, quality changes and Miruro separation. The native slider regression (`.build/cast-playback-check --slider-regression`, after building that target) checks crimson painting across chapter gaps, buffering, active emphasis, hover and seeking.
|
`make check-sponsorblock` exercises the bounded native lookup and duration validation with local fixtures; `.build/sponsorblock-check --live` performs read-only public API probes. `make check-resolved` includes persistent manual sponsor skips, native sponsor/opening/ending autoskip, deliberate relative/slider seek protection, paused/late metadata, saved preferences, opt-in/failure isolation, same-video cancellation, direct/saved/queue/enqueue launches, quality changes and Miruro separation. The native slider regression (`.build/cast-playback-check --slider-regression`, after building that target) checks crimson painting across chapter gaps, sponsor-centered hover anchors, buffering, active emphasis and seeking.
|
||||||
`make check-zoko check-reporting check-cli check-stream check-progress` covers provider admission/codec/error categories, bounded resources, scoped CDN policy, isolated resume and report identity without launching a GUI. Optional `make check-zoko-live` resolves current AniList 1 episode 1, loads VTT, and uses the **actual native player** while paused to decode/step video, seek/decode at 600 seconds, restore position and assert version-2 AniList progress. It creates no windows and does not start audio playback, but production Core Audio still initializes an output device. No tokenized addresses are printed or retained after the check. This is not a full-episode, audio-output, pixel/subtitle-rendering or UI smoke test, nor proof of availability for other catalog entries. Full `make check` still launches GUI/audio checks.
|
`make check-zoko check-reporting check-cli check-stream check-progress` covers provider admission/codec/error categories, bounded resources, scoped CDN policy, isolated resume and report identity without launching a GUI. Optional `make check-zoko-live` resolves current AniList 1 episode 1, loads VTT, and uses the **actual native player** while paused to decode/step video, seek/decode at 600 seconds, restore position and assert version-2 AniList progress. It creates no windows and does not start audio playback, but production Core Audio still initializes an output device. No tokenized addresses are printed or retained after the check. This is not a full-episode, audio-output, pixel/subtitle-rendering or UI smoke test, nor proof of availability for other catalog entries. Full `make check` still launches GUI/audio checks.
|
||||||
|
|
||||||
`make check-source` exercises the public parser/result contract, error redaction, shared manifest validation, total deadline/cancellation and actual CLI rejection paths without GUI/audio or native-player linking. Optional `make check-source-live` runs the **actual CLI**, copied outside its app bundle with an intentionally invalid configuration and isolated state directories: Cowboy Bebop, Black Clover episodes 1/170 and Black Clover Season 2 episode 1 must resolve through their exact MAL mappings, while Cowboy Bebop episode 27 remains `unavailable`, with no state writes. These source-only probes fetch no segments and are distinct from `check-zoko-live`'s native playback checks. `.build/zoko-check --live-black-clover` exercises native decoding, ten-minute seek, subtitles, resume and original AniList progress identity for Black Clover.
|
`make check-source` exercises the public parser/result contract, error redaction, shared manifest validation, total deadline/cancellation and actual CLI rejection paths without GUI/audio or native-player linking. Optional `make check-source-live` runs the **actual CLI**, copied outside its app bundle with an intentionally invalid configuration and isolated state directories: Cowboy Bebop, Black Clover episodes 1/170 and Black Clover Season 2 episode 1 must resolve through their exact MAL mappings, while Cowboy Bebop episode 27 remains `unavailable`, with no state writes. These source-only probes fetch no segments and are distinct from `check-zoko-live`'s native playback checks. `.build/zoko-check --live-black-clover` exercises native decoding, ten-minute seek, subtitles, resume and original AniList progress identity for Black Clover.
|
||||||
|
|||||||
+109
-33
@@ -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)
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ final class MiruroTransport: @unchecked Sendable {
|
|||||||
var prepared = ResolvedSource(url: url.absoluteString, headers: "", identity: source.identity,
|
var prepared = ResolvedSource(url: url.absoluteString, headers: "", identity: source.identity,
|
||||||
title: source.title, subtitles: source.subtitles)
|
title: source.title, subtitles: source.subtitles)
|
||||||
prepared.playbackLifetime = owner
|
prepared.playbackLifetime = owner
|
||||||
|
prepared.intervals = source.intervals
|
||||||
return prepared
|
return prepared
|
||||||
} catch {
|
} catch {
|
||||||
if Task.isCancelled || error is CancellationError { throw CancellationError() }
|
if Task.isCancelled || error is CancellationError { throw CancellationError() }
|
||||||
|
|||||||
@@ -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) }
|
||||||
@@ -206,6 +207,17 @@ struct PlayerMiruroMenu: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct PlayerOpeningEndingSkipToggle: View {
|
||||||
|
@ObservedObject var model: PlayerModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Toggle("Automatically Skip Openings and Endings", isOn: Binding(
|
||||||
|
get: { model.openingEndingAutoSkip },
|
||||||
|
set: { model.setOpeningEndingAutoSkip($0) }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct PlayerSponsorBlockMenu: View {
|
struct PlayerSponsorBlockMenu: View {
|
||||||
@ObservedObject var model: PlayerModel
|
@ObservedObject var model: PlayerModel
|
||||||
@@ -215,8 +227,12 @@ struct PlayerSponsorBlockMenu: View {
|
|||||||
Toggle("Show Sponsored Ranges", isOn: Binding(
|
Toggle("Show Sponsored Ranges", isOn: Binding(
|
||||||
get: { model.sponsorBlockEnabled },
|
get: { model.sponsorBlockEnabled },
|
||||||
set: { model.setSponsorBlockEnabled($0) }))
|
set: { model.setSponsorBlockEnabled($0) }))
|
||||||
|
Toggle("Automatically Skip Sponsors", isOn: Binding(
|
||||||
|
get: { model.sponsorBlockAutoSkip },
|
||||||
|
set: { model.setSponsorBlockAutoSkip($0) }))
|
||||||
|
.disabled(!model.sponsorBlockEnabled)
|
||||||
Text("Connects to sponsor.ajay.app using a video-ID hash prefix.")
|
Text("Connects to sponsor.ajay.app using a video-ID hash prefix.")
|
||||||
Text("Reduces disclosure; not anonymous. Display only, never skips.")
|
Text("Reduces disclosure; not anonymous.")
|
||||||
Link("(Using SponsorBlock)", destination: URL(string: "https://sponsor.ajay.app/")!)
|
Link("(Using SponsorBlock)", destination: URL(string: "https://sponsor.ajay.app/")!)
|
||||||
Menu("Acknowledgements") {
|
Menu("Acknowledgements") {
|
||||||
Text("Uses SponsorBlock data under CC BY-NC-SA 4.0")
|
Text("Uses SponsorBlock data under CC BY-NC-SA 4.0")
|
||||||
@@ -230,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
|
||||||
@@ -265,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)
|
||||||
@@ -333,6 +349,7 @@ struct PlayerCommands: Commands {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
PlayerMiruroMenu(model: model).disabled(!hasFocusedScene)
|
PlayerMiruroMenu(model: model).disabled(!hasFocusedScene)
|
||||||
|
PlayerOpeningEndingSkipToggle(model: model)
|
||||||
PlayerSponsorBlockMenu(model: model)
|
PlayerSponsorBlockMenu(model: model)
|
||||||
Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") {
|
Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") {
|
||||||
model.performMenuAction(bufferInfo)
|
model.performMenuAction(bufferInfo)
|
||||||
|
|||||||
@@ -31,18 +31,27 @@ struct ResolvedSubtitle: Decodable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional episode-relative provider timings; playback validates against media duration.
|
||||||
|
struct ProviderInterval: Sendable, Equatable {
|
||||||
|
enum Kind: Sendable { case intro, outro }
|
||||||
|
let kind: Kind
|
||||||
|
let start: Double
|
||||||
|
let end: Double
|
||||||
|
}
|
||||||
|
|
||||||
struct ResolvedSource: Sendable {
|
struct ResolvedSource: Sendable {
|
||||||
let url: String
|
let url: String
|
||||||
let headers: String
|
let headers: String
|
||||||
let identity: String
|
let identity: String
|
||||||
let title: String
|
let title: String
|
||||||
let subtitles: [ResolvedSubtitle]
|
let subtitles: [ResolvedSubtitle]
|
||||||
|
var intervals: [ProviderInterval] = []
|
||||||
var requirements: MiruroPlaybackRequirements = .init()
|
var requirements: MiruroPlaybackRequirements = .init()
|
||||||
var playbackLifetime: (any Sendable)? = nil
|
var playbackLifetime: (any Sendable)? = nil
|
||||||
|
|
||||||
func replacingSubtitles(_ subtitles: [ResolvedSubtitle]) -> ResolvedSource {
|
func replacingSubtitles(_ subtitles: [ResolvedSubtitle]) -> ResolvedSource {
|
||||||
ResolvedSource(url: url, headers: headers, identity: identity, title: title, subtitles: subtitles,
|
ResolvedSource(url: url, headers: headers, identity: identity, title: title, subtitles: subtitles,
|
||||||
requirements: requirements, playbackLifetime: playbackLifetime)
|
intervals: intervals, requirements: requirements, playbackLifetime: playbackLifetime)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Document: Decodable {
|
struct Document: Decodable {
|
||||||
|
|||||||
+174
-44
@@ -76,6 +76,9 @@ final class PlayerModel: ObservableObject {
|
|||||||
private var metadataLifecycle = MetadataLifecycle()
|
private var metadataLifecycle = MetadataLifecycle()
|
||||||
private var metadataTask: Task<Void, Never>?
|
private var metadataTask: Task<Void, Never>?
|
||||||
@Published private(set) var sponsorBlockEnabled: Bool
|
@Published private(set) var sponsorBlockEnabled: Bool
|
||||||
|
@Published private(set) var sponsorBlockAutoSkip: Bool
|
||||||
|
@Published private(set) var openingEndingAutoSkip: Bool
|
||||||
|
private var segmentSeekPosition: Double?
|
||||||
private let preferenceDefaults: UserDefaults
|
private let preferenceDefaults: UserDefaults
|
||||||
private var sponsorVideoID: String?
|
private var sponsorVideoID: String?
|
||||||
private var sponsorGeneration: UInt64 = 0
|
private var sponsorGeneration: UInt64 = 0
|
||||||
@@ -113,11 +116,22 @@ final class PlayerModel: ObservableObject {
|
|||||||
@Published private var skipOffer: (interval: MiruroInterval, deadline: Double)?
|
@Published private var skipOffer: (interval: MiruroInterval, deadline: Double)?
|
||||||
private var offeredSegments: Set<MiruroInterval.Kind> = []
|
private var offeredSegments: Set<MiruroInterval.Kind> = []
|
||||||
private let skipClock: () -> Double
|
private let skipClock: () -> Double
|
||||||
|
private var canSkipSegment: Bool {
|
||||||
|
canSeek && state.has_video && !isSeeking && !transition.seekPending && !state.seek_pending &&
|
||||||
|
(state.status == PLAYER_PLAYING || state.status == PLAYER_PAUSED)
|
||||||
|
}
|
||||||
private var currentSkipInterval: MiruroInterval? {
|
private var currentSkipInterval: MiruroInterval? {
|
||||||
guard canSeek, state.has_video, !isSeeking, !transition.seekPending, !state.seek_pending,
|
guard canSkipSegment else { return nil }
|
||||||
state.status == PLAYER_PLAYING || state.status == PLAYER_PAUSED else { return nil }
|
|
||||||
return visibleIntervals.first { state.position >= $0.start && state.position < $0.end }
|
return visibleIntervals.first { state.position >= $0.start && state.position < $0.end }
|
||||||
}
|
}
|
||||||
|
private var sponsorToSkip: ClosedRange<Double>? {
|
||||||
|
guard canSkipSegment else { return nil }
|
||||||
|
return sponsoredRanges.first { state.position >= $0.lowerBound && state.position < $0.upperBound }
|
||||||
|
}
|
||||||
|
var skipButtonTitle: String? {
|
||||||
|
if sponsorToSkip != nil { return "Skip Sponsor" }
|
||||||
|
return segmentToSkip.map { $0.kind == .intro ? "Skip Opening" : "Skip Ending" }
|
||||||
|
}
|
||||||
var segmentToSkip: MiruroInterval? {
|
var segmentToSkip: MiruroInterval? {
|
||||||
guard let skipOffer, skipClock() < skipOffer.deadline,
|
guard let skipOffer, skipClock() < skipOffer.deadline,
|
||||||
currentSkipInterval == skipOffer.interval else { return nil }
|
currentSkipInterval == skipOffer.interval else { return nil }
|
||||||
@@ -129,6 +143,14 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateSegmentSkip() {
|
private func updateSegmentSkip() {
|
||||||
|
var automaticRange = sponsorBlockAutoSkip ? sponsorToSkip : nil
|
||||||
|
if automaticRange == nil, openingEndingAutoSkip, let interval = currentSkipInterval {
|
||||||
|
automaticRange = interval.start...interval.end
|
||||||
|
}
|
||||||
|
if let range = automaticRange, state.status == PLAYER_PLAYING, effectivePlaying, !state.buffering,
|
||||||
|
segmentSeekPosition.map({ $0 >= range.lowerBound - 5 && $0 < range.upperBound }) != true {
|
||||||
|
_ = seek(to: range.upperBound, userInitiated: false)
|
||||||
|
}
|
||||||
guard let interval = currentSkipInterval else {
|
guard let interval = currentSkipInterval else {
|
||||||
if skipOffer != nil { skipOffer = nil }
|
if skipOffer != nil { skipOffer = nil }
|
||||||
return
|
return
|
||||||
@@ -142,7 +164,8 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func skipSegment() -> Bool {
|
func skipSegment() -> Bool {
|
||||||
guard let interval = segmentToSkip, seekRelative(interval.end - state.position) else { return false }
|
guard let end = sponsorToSkip?.upperBound ?? segmentToSkip?.end,
|
||||||
|
seek(to: end, userInitiated: false) else { return false }
|
||||||
skipOffer = nil
|
skipOffer = nil
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -218,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
|
||||||
@@ -271,6 +296,8 @@ final class PlayerModel: ObservableObject {
|
|||||||
showsBufferInfo = configuration.showBufferInfo
|
showsBufferInfo = configuration.showBufferInfo
|
||||||
preferenceDefaults = defaults
|
preferenceDefaults = defaults
|
||||||
sponsorBlockEnabled = (defaults.object(forKey: "sponsorBlockEnabled") as? Bool) ?? configuration.sponsorBlock
|
sponsorBlockEnabled = (defaults.object(forKey: "sponsorBlockEnabled") as? Bool) ?? configuration.sponsorBlock
|
||||||
|
sponsorBlockAutoSkip = (defaults.object(forKey: "sponsorBlockAutoSkip") as? Bool) ?? configuration.sponsorBlockAutoSkip
|
||||||
|
openingEndingAutoSkip = (defaults.object(forKey: "openingEndingAutoSkip") as? Bool) ?? configuration.openingEndingAutoSkip
|
||||||
miruroVideoProvider = configuration.miruroVideoProvider
|
miruroVideoProvider = configuration.miruroVideoProvider
|
||||||
miruroCaptionProvider = configuration.miruroCaptionProvider
|
miruroCaptionProvider = configuration.miruroCaptionProvider
|
||||||
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
|
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
|
||||||
@@ -304,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
|
||||||
@@ -895,6 +923,16 @@ final class PlayerModel: ObservableObject {
|
|||||||
replaceSponsorMetadata(videoID: sponsorVideoID)
|
replaceSponsorMetadata(videoID: sponsorVideoID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setSponsorBlockAutoSkip(_ enabled: Bool) {
|
||||||
|
sponsorBlockAutoSkip = enabled
|
||||||
|
preferenceDefaults.set(enabled, forKey: "sponsorBlockAutoSkip")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setOpeningEndingAutoSkip(_ enabled: Bool) {
|
||||||
|
openingEndingAutoSkip = enabled
|
||||||
|
preferenceDefaults.set(enabled, forKey: "openingEndingAutoSkip")
|
||||||
|
}
|
||||||
|
|
||||||
private func updateSponsorRanges() {
|
private func updateSponsorRanges() {
|
||||||
sponsorRanges = SponsorSegment.visibleRanges(sponsorSegments, duration: state.duration)
|
sponsorRanges = SponsorSegment.visibleRanges(sponsorSegments, duration: state.duration)
|
||||||
}
|
}
|
||||||
@@ -936,9 +974,16 @@ final class PlayerModel: ObservableObject {
|
|||||||
skipOffer = nil
|
skipOffer = nil
|
||||||
}
|
}
|
||||||
let parts = source?.url.absoluteString.split(separator: ":") ?? []
|
let parts = source?.url.absoluteString.split(separator: ":") ?? []
|
||||||
let episode = parts.count == 3 && parts[0] == "miruro" ? Int(parts[2]) : nil
|
let episode = parts.count == 3 && (parts[0] == "miruro" || parts[0] == "anilist") ? Int(parts[2]) : nil
|
||||||
let generation = metadataLifecycle.replace(identity: source?.url.absoluteString, episode: episode)
|
let providerIntervals: [ProviderInterval]
|
||||||
guard let source, let episode else { return }
|
if case .resolved(let resolved, _) = source, parts.first == "anilist" {
|
||||||
|
providerIntervals = resolved.intervals
|
||||||
|
} else {
|
||||||
|
providerIntervals = []
|
||||||
|
}
|
||||||
|
let generation = metadataLifecycle.replace(identity: source?.url.absoluteString, episode: episode,
|
||||||
|
providerIntervals: providerIntervals)
|
||||||
|
guard let source, let episode, parts[0] == "miruro" else { return }
|
||||||
let identity = source.url.absoluteString
|
let identity = source.url.absoluteString
|
||||||
let mediaID = String(parts[1])
|
let mediaID = String(parts[1])
|
||||||
let resolver = miruroMetadataResolver
|
let resolver = miruroMetadataResolver
|
||||||
@@ -1080,6 +1125,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
window?.title = fileName ?? "Terebi"
|
window?.title = fileName ?? "Terebi"
|
||||||
revealControls()
|
revealControls()
|
||||||
replaceMetadata(for: source)
|
replaceMetadata(for: source)
|
||||||
|
if !change.isQuality { segmentSeekPosition = savedPosition }
|
||||||
systemPlayback?.update()
|
systemPlayback?.update()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1203,14 +1249,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
if transition.resolvingAutomaticQuality { cancelStreamResolution() }
|
if transition.resolvingAutomaticQuality { cancelStreamResolution() }
|
||||||
automaticQuality.resetHealth()
|
automaticQuality.resetHealth()
|
||||||
isSeeking = false
|
isSeeking = false
|
||||||
guard let handle, canSeek else { return }
|
_ = seek(to: min(max(0, seekPosition), duration), userInitiated: true)
|
||||||
if casting.active {
|
|
||||||
seekCast(to: min(max(0, seekPosition), duration))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let position = min(max(0, seekPosition), duration)
|
|
||||||
let accepted = transition.seek(position, handle: handle)
|
|
||||||
commandError = accepted ? nil : "Could not queue the seek. Please try again."
|
|
||||||
revealControls()
|
revealControls()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1257,12 +1296,21 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func seekRelative(_ seconds: Double) -> Bool {
|
func seekRelative(_ seconds: Double) -> Bool {
|
||||||
guard canSeek, let handle, seconds.isFinite else { return false }
|
guard seconds.isFinite else { return false }
|
||||||
|
let target = min(max(0, ((transition.seekPending || state.seek_pending) ? seekPosition : state.position) + seconds), duration)
|
||||||
|
return seek(to: target, userInitiated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func seek(to target: Double, userInitiated: Bool) -> Bool {
|
||||||
|
guard canSeek, let handle, target.isFinite else { return false }
|
||||||
if transition.resolvingAutomaticQuality { cancelStreamResolution() }
|
if transition.resolvingAutomaticQuality { cancelStreamResolution() }
|
||||||
automaticQuality.resetHealth()
|
automaticQuality.resetHealth()
|
||||||
let target = min(max(0, ((transition.seekPending || state.seek_pending) ? seekPosition : state.position) + seconds), duration)
|
let previousSeek = segmentSeekPosition
|
||||||
|
// Record intent before Cast can synchronously publish a new state.
|
||||||
|
if userInitiated { segmentSeekPosition = target }
|
||||||
let accepted = casting.active ? seekCast(to: target) : transition.seek(target, handle: handle)
|
let accepted = casting.active ? seekCast(to: target) : transition.seek(target, handle: handle)
|
||||||
if accepted { seekPosition = target }
|
if accepted { seekPosition = target }
|
||||||
|
else { segmentSeekPosition = previousSeek }
|
||||||
commandError = accepted ? nil : "Could not queue the seek. Please try again."
|
commandError = accepted ? nil : "Could not queue the seek. Please try again."
|
||||||
if !accepted { revealControls() }
|
if !accepted { revealControls() }
|
||||||
return accepted
|
return accepted
|
||||||
@@ -1272,6 +1320,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
func stepFrame(_ direction: Int32) -> Bool {
|
func stepFrame(_ direction: Int32) -> Bool {
|
||||||
guard canStepFrame, let handle else { return false }
|
guard canStepFrame, let handle else { return false }
|
||||||
let accepted = transition.stepFrame(direction, handle: handle)
|
let accepted = transition.stepFrame(direction, handle: handle)
|
||||||
|
if accepted { segmentSeekPosition = state.position }
|
||||||
commandError = accepted ? nil : "Could not queue the frame step. Please try again."
|
commandError = accepted ? nil : "Could not queue the frame step. Please try again."
|
||||||
revealControls()
|
revealControls()
|
||||||
return accepted
|
return accepted
|
||||||
@@ -1384,17 +1433,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()
|
||||||
@@ -2100,14 +2149,20 @@ final class PlayerModel: ObservableObject {
|
|||||||
private struct MetadataLifecycle {
|
private struct MetadataLifecycle {
|
||||||
private(set) var identity: String?
|
private(set) var identity: String?
|
||||||
private(set) var metadata: MiruroMetadata?
|
private(set) var metadata: MiruroMetadata?
|
||||||
|
private var providerIntervals: [MiruroInterval] = []
|
||||||
private var episode: Int?
|
private var episode: Int?
|
||||||
private var generation: UInt64 = 0
|
private var generation: UInt64 = 0
|
||||||
|
|
||||||
mutating func replace(identity: String?, episode: Int?) -> UInt64 {
|
mutating func replace(identity: String?, episode: Int?, providerIntervals: [ProviderInterval] = []) -> UInt64 {
|
||||||
generation &+= 1
|
generation &+= 1
|
||||||
self.identity = identity
|
self.identity = identity
|
||||||
self.episode = episode
|
self.episode = episode
|
||||||
metadata = nil
|
metadata = nil
|
||||||
|
self.providerIntervals = episode.map { episode in
|
||||||
|
providerIntervals.map {
|
||||||
|
MiruroInterval(kind: $0.kind == .intro ? .intro : .outro, start: $0.start, end: $0.end, episode: episode)
|
||||||
|
}
|
||||||
|
} ?? []
|
||||||
return generation
|
return generation
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2118,8 +2173,14 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func visibleIntervals(duration: Double) -> [MiruroInterval] {
|
func visibleIntervals(duration: Double) -> [MiruroInterval] {
|
||||||
guard let episode, let metadata else { return [] }
|
guard let episode else { return [] }
|
||||||
return metadata.visibleIntervals(episode: episode, duration: duration)
|
if !providerIntervals.isEmpty {
|
||||||
|
guard duration.isFinite, duration > 0 else { return [] }
|
||||||
|
return providerIntervals.filter {
|
||||||
|
$0.start.isFinite && $0.end.isFinite && $0.start >= 0 && $0.end > $0.start && $0.end <= duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return metadata?.visibleIntervals(episode: episode, duration: duration) ?? []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2166,6 +2227,32 @@ final class PlayerModel: ObservableObject {
|
|||||||
precondition(!lifecycle.accept(metadata, identity: a, generation: second), "YouTube rejects earlier metadata")
|
precondition(!lifecycle.accept(metadata, identity: a, generation: second), "YouTube rejects earlier metadata")
|
||||||
_ = lifecycle.replace(identity: nil, episode: nil)
|
_ = lifecycle.replace(identity: nil, episode: nil)
|
||||||
precondition(lifecycle.metadata == nil, "Close clears metadata")
|
precondition(lifecycle.metadata == nil, "Close clears metadata")
|
||||||
|
let providerRows: [ProviderInterval] = [
|
||||||
|
.init(kind: .intro, start: 0.25, end: 1.75),
|
||||||
|
.init(kind: .outro, start: 90.5, end: 100),
|
||||||
|
.init(kind: .intro, start: -1, end: 1),
|
||||||
|
.init(kind: .outro, start: 10, end: 10),
|
||||||
|
.init(kind: .intro, start: .nan, end: 3),
|
||||||
|
.init(kind: .outro, start: 2, end: .infinity),
|
||||||
|
]
|
||||||
|
let providerGeneration = lifecycle.replace(identity: "anilist:1:1", episode: 1, providerIntervals: providerRows)
|
||||||
|
let expected = [
|
||||||
|
MiruroInterval(kind: .intro, start: 0.25, end: 1.75, episode: 1),
|
||||||
|
MiruroInterval(kind: .outro, start: 90.5, end: 100, episode: 1),
|
||||||
|
]
|
||||||
|
for duration in [0.0, -1, .nan, .infinity] {
|
||||||
|
precondition(lifecycle.visibleIntervals(duration: duration).isEmpty, "Unknown/nonfinite provider duration hides bands")
|
||||||
|
}
|
||||||
|
precondition(lifecycle.visibleIntervals(duration: 100) == expected, "Provider fractions and exact-duration end survive mapping")
|
||||||
|
precondition(lifecycle.visibleIntervals(duration: 95) == [expected[0]], "Provider ranges outside actual duration are rejected, not clipped")
|
||||||
|
precondition(lifecycle.visibleIntervals(duration: 100) == expected, "Provider raw rows survive duration changes")
|
||||||
|
_ = lifecycle.replace(identity: "anilist:1:2", episode: 2)
|
||||||
|
precondition(lifecycle.visibleIntervals(duration: 100).isEmpty, "Missing provider timing clears preceding episode")
|
||||||
|
_ = lifecycle.replace(identity: "anilist:1:1", episode: 1)
|
||||||
|
precondition(!lifecycle.accept(metadata, identity: "anilist:1:1", generation: providerGeneration),
|
||||||
|
"Provider replacement retains same-identity generation protection")
|
||||||
|
_ = lifecycle.replace(identity: nil, episode: nil)
|
||||||
|
precondition(lifecycle.visibleIntervals(duration: 100).isEmpty, "Close clears provider bands")
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -2582,8 +2669,10 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
let lower = index == 0 ? minValue : cell.boundaries[index - 1]
|
let lower = index == 0 ? minValue : cell.boundaries[index - 1]
|
||||||
let upper = index == cell.boundaries.count ? maxValue : cell.boundaries[index]
|
let upper = index == cell.boundaries.count ? maxValue : cell.boundaries[index]
|
||||||
let middle = (lower + upper) / 2
|
let middle = (lower + upper) / 2
|
||||||
setHover(Hover(time: middle, x: start + (middle - minValue) / (maxValue - minValue) * width,
|
let sponsor = cell.sponsoredRanges.first { $0.lowerBound <= time && time < $0.upperBound }
|
||||||
sponsored: cell.sponsoredRanges.contains { $0.lowerBound <= time && time < $0.upperBound }))
|
let anchor = sponsor.map { ($0.lowerBound + $0.upperBound) / 2 } ?? middle
|
||||||
|
setHover(Hover(time: middle, x: start + (anchor - minValue) / (maxValue - minValue) * width,
|
||||||
|
sponsored: sponsor != nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshHover() {
|
func refreshHover() {
|
||||||
@@ -2813,14 +2902,18 @@ func playbackSliderHitCheck() -> Bool {
|
|||||||
pixel(50).redComponent != pixel(30).redComponent,
|
pixel(50).redComponent != pixel(30).redComponent,
|
||||||
pixel(10).alphaComponent > 0, pixel(40).alphaComponent > 0 else { return false }
|
pixel(10).alphaComponent > 0, pixel(40).alphaComponent > 0 else { return false }
|
||||||
cell.emphasis = []
|
cell.emphasis = []
|
||||||
for (time, middle) in [(0.0, 12.5), (25.0, 50.0), (75.0, 87.5), (100.0, 87.5)] {
|
for (time, middle, anchor, sponsored) in [
|
||||||
|
(0.0, 12.5, 12.5, false), (20.0, 12.5, 25.0, true),
|
||||||
|
(25.0, 50.0, 25.0, true), (30.0, 50.0, 25.0, true),
|
||||||
|
(40.0, 50.0, 50.0, false), (75.0, 87.5, 87.5, false), (100.0, 87.5, 87.5, false)
|
||||||
|
] {
|
||||||
let point = slider.convert(NSPoint(x: left + time / 100 * (right - left), y: 7), to: nil)
|
let point = slider.convert(NSPoint(x: left + time / 100 * (right - left), y: 7), to: nil)
|
||||||
let event = NSEvent.mouseEvent(with: .mouseMoved, location: point, modifierFlags: [],
|
let event = NSEvent.mouseEvent(with: .mouseMoved, location: point, modifierFlags: [],
|
||||||
timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 0, pressure: 0)!
|
timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 0, pressure: 0)!
|
||||||
slider.mouseMoved(with: event)
|
slider.mouseMoved(with: event)
|
||||||
guard hovered?.time == middle,
|
guard hovered?.time == middle,
|
||||||
hovered?.sponsored == (time == 25),
|
hovered?.sponsored == sponsored,
|
||||||
abs(hovered!.x - (left + middle / 100 * (right - left))) < 0.5 else { return false }
|
abs(hovered!.x - (left + anchor / 100 * (right - left))) < 0.5 else { return false }
|
||||||
}
|
}
|
||||||
let exit = NSEvent.enterExitEvent(with: .mouseExited, location: .zero, modifierFlags: [],
|
let exit = NSEvent.enterExitEvent(with: .mouseExited, location: .zero, modifierFlags: [],
|
||||||
timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, trackingNumber: 0, userData: nil)!
|
timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, trackingNumber: 0, userData: nil)!
|
||||||
@@ -3349,6 +3442,7 @@ private struct PlayerContent: View {
|
|||||||
Divider()
|
Divider()
|
||||||
PlayerQualityMenu(model: model)
|
PlayerQualityMenu(model: model)
|
||||||
PlayerMiruroMenu(model: model)
|
PlayerMiruroMenu(model: model)
|
||||||
|
PlayerOpeningEndingSkipToggle(model: model)
|
||||||
PlayerSponsorBlockMenu(model: model)
|
PlayerSponsorBlockMenu(model: model)
|
||||||
Divider()
|
Divider()
|
||||||
if model.isCompact {
|
if model.isCompact {
|
||||||
@@ -4085,12 +4179,11 @@ private struct PlayerContent: View {
|
|||||||
.accessibilityHidden(model.isCompact || !model.controlsVisible)
|
.accessibilityHidden(model.isCompact || !model.controlsVisible)
|
||||||
}
|
}
|
||||||
ZStack(alignment: .topTrailing) {
|
ZStack(alignment: .topTrailing) {
|
||||||
if let interval = model.segmentToSkip {
|
if let title = model.skipButtonTitle {
|
||||||
Button {
|
Button {
|
||||||
model.skipSegment()
|
model.skipSegment()
|
||||||
} label: {
|
} label: {
|
||||||
Label(interval.kind == .intro ? "Skip Opening" : "Skip Ending",
|
Label(title, systemImage: "forward.end.fill")
|
||||||
systemImage: "forward.end.fill")
|
|
||||||
.font(.system(size: model.isCompact ? 11 : 13, weight: .semibold))
|
.font(.system(size: model.isCompact ? 11 : 13, weight: .semibold))
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
@@ -4101,12 +4194,14 @@ private struct PlayerContent: View {
|
|||||||
.background {
|
.background {
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
PlayerGlass(cornerRadius: 8)
|
PlayerGlass(cornerRadius: 8)
|
||||||
TimelineView(.animation(minimumInterval: reduceMotion ? 1 : 1.0 / 30)) { _ in
|
if model.segmentToSkip != nil {
|
||||||
GeometryReader { geometry in
|
TimelineView(.animation(minimumInterval: reduceMotion ? 1 : 1.0 / 30)) { _ in
|
||||||
let elapsed = 1 - model.skipOfferRemainingFraction
|
GeometryReader { geometry in
|
||||||
Color(SunsetPalette.text).opacity(0.25)
|
let elapsed = 1 - model.skipOfferRemainingFraction
|
||||||
.frame(width: geometry.size.width *
|
Color(SunsetPalette.text).opacity(0.25)
|
||||||
(reduceMotion ? floor(elapsed * 10) / 10 : elapsed))
|
.frame(width: geometry.size.width *
|
||||||
|
(reduceMotion ? floor(elapsed * 10) / 10 : elapsed))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4120,8 +4215,8 @@ private struct PlayerContent: View {
|
|||||||
.padding(.trailing, model.isCompact ? 8 : 16)
|
.padding(.trailing, model.isCompact ? 8 : 16)
|
||||||
.padding(.top, 44)
|
.padding(.top, 44)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
|
||||||
.animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: model.segmentToSkip?.kind)
|
.animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: model.skipButtonTitle)
|
||||||
.allowsHitTesting(model.segmentToSkip != nil)
|
.allowsHitTesting(model.skipButtonTitle != nil)
|
||||||
if dropTargeted {
|
if dropTargeted {
|
||||||
RoundedRectangle(cornerRadius: 10)
|
RoundedRectangle(cornerRadius: 10)
|
||||||
.strokeBorder(Color(SunsetPalette.accent), lineWidth: 2)
|
.strokeBorder(Color(SunsetPalette.accent), lineWidth: 2)
|
||||||
@@ -4186,7 +4281,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] {
|
||||||
@@ -4233,8 +4330,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)
|
||||||
}
|
}
|
||||||
@@ -4312,6 +4410,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" }) {
|
||||||
@@ -4321,13 +4427,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
|
||||||
@@ -4335,13 +4464,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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ enum ZokoResolver {
|
|||||||
guard enrichTitle, let name = metadata.name else { return source }
|
guard enrichTitle, let name = metadata.name else { return source }
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
return ResolvedSource(url: source.url, headers: source.headers, identity: source.identity,
|
return ResolvedSource(url: source.url, headers: source.headers, identity: source.identity,
|
||||||
title: "\(name) · Episode \(episode) (sub)", subtitles: source.subtitles, requirements: source.requirements)
|
title: "\(name) · Episode \(episode) (sub)", subtitles: source.subtitles,
|
||||||
|
intervals: source.intervals, requirements: source.requirements, playbackLifetime: source.playbackLifetime)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func parse(page: String, mediaID: String, episode: Int) throws -> ResolvedSource {
|
static func parse(page: String, mediaID: String, episode: Int) throws -> ResolvedSource {
|
||||||
@@ -106,8 +107,37 @@ enum ZokoResolver {
|
|||||||
}
|
}
|
||||||
struct Config: Decodable {
|
struct Config: Decodable {
|
||||||
struct Subtitle: Decodable { let src: String; let lang: String; let label: String; let `default`: Bool? }
|
struct Subtitle: Decodable { let src: String; let lang: String; let label: String; let `default`: Bool? }
|
||||||
|
struct Timing: Decodable {
|
||||||
|
let start: Double
|
||||||
|
let end: Double
|
||||||
|
enum CodingKeys: CodingKey { case start, end }
|
||||||
|
init(from decoder: any Decoder) throws {
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
start = try values.decode(Double.self, forKey: .start)
|
||||||
|
end = try values.decode(Double.self, forKey: .end)
|
||||||
|
guard start.isFinite, end.isFinite, start >= 0, end > start else { throw ZokoError.malformed }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct Skip: Decodable {
|
||||||
|
let intro: Timing?
|
||||||
|
let outro: Timing?
|
||||||
|
enum CodingKeys: CodingKey { case intro, outro }
|
||||||
|
init(from decoder: any Decoder) throws {
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
intro = try? values.decode(Timing.self, forKey: .intro)
|
||||||
|
outro = try? values.decode(Timing.self, forKey: .outro)
|
||||||
|
}
|
||||||
|
}
|
||||||
let src: String
|
let src: String
|
||||||
let subtitles: [Subtitle]
|
let subtitles: [Subtitle]
|
||||||
|
let skip: Skip?
|
||||||
|
enum CodingKeys: CodingKey { case src, subtitles, skip }
|
||||||
|
init(from decoder: any Decoder) throws {
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
src = try values.decode(String.self, forKey: .src)
|
||||||
|
subtitles = try values.decode([Subtitle].self, forKey: .subtitles)
|
||||||
|
skip = try? values.decode(Skip.self, forKey: .skip)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let config: Config
|
let config: Config
|
||||||
do { config = try JSONDecoder().decode(Config.self, from: decoded) }
|
do { config = try JSONDecoder().decode(Config.self, from: decoded) }
|
||||||
@@ -119,9 +149,16 @@ enum ZokoResolver {
|
|||||||
isDefault: row.default ?? false, headers: ["Referer": "https://zokoanime.video/"])
|
isDefault: row.default ?? false, headers: ["Referer": "https://zokoanime.video/"])
|
||||||
}
|
}
|
||||||
guard ResolvedSource.validSubtitles(subtitles) else { throw ZokoError.malformed }
|
guard ResolvedSource.validSubtitles(subtitles) else { throw ZokoError.malformed }
|
||||||
|
var intervals: [ProviderInterval] = []
|
||||||
|
if let intro = config.skip?.intro {
|
||||||
|
intervals.append(ProviderInterval(kind: .intro, start: intro.start, end: intro.end))
|
||||||
|
}
|
||||||
|
if let outro = config.skip?.outro {
|
||||||
|
intervals.append(ProviderInterval(kind: .outro, start: outro.start, end: outro.end))
|
||||||
|
}
|
||||||
return ResolvedSource(url: config.src, headers: referer, identity: "anilist:\(mediaID):\(episode)",
|
return ResolvedSource(url: config.src, headers: referer, identity: "anilist:\(mediaID):\(episode)",
|
||||||
title: "AniList \(mediaID) · Episode \(episode) (sub)", subtitles: subtitles,
|
title: "AniList \(mediaID) · Episode \(episode) (sub)", subtitles: subtitles,
|
||||||
requirements: .init(zoko: true))
|
intervals: intervals, requirements: .init(zoko: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Catalog: Decodable {
|
struct Catalog: Decodable {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
|||||||
public var showBufferInfo: Bool
|
public var showBufferInfo: Bool
|
||||||
public var automaticQuality: Bool
|
public var automaticQuality: Bool
|
||||||
public var sponsorBlock: Bool
|
public var sponsorBlock: Bool
|
||||||
|
public var sponsorBlockAutoSkip: Bool
|
||||||
|
public var openingEndingAutoSkip: Bool
|
||||||
public var snapToEdges: Bool
|
public var snapToEdges: Bool
|
||||||
public var compactTitleProgress: CompactTitleProgress
|
public var compactTitleProgress: CompactTitleProgress
|
||||||
public var miruroVideoProvider: String
|
public var miruroVideoProvider: String
|
||||||
@@ -16,12 +18,14 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
|||||||
public var keys: KeyBindings
|
public var keys: KeyBindings
|
||||||
|
|
||||||
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, sponsorBlock: Bool = false,
|
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, sponsorBlock: Bool = false,
|
||||||
snapToEdges: Bool = true,
|
sponsorBlockAutoSkip: Bool = false, openingEndingAutoSkip: Bool = false, snapToEdges: Bool = true,
|
||||||
compactTitleProgress: CompactTitleProgress = .time, miruroVideoProvider: String = "",
|
compactTitleProgress: CompactTitleProgress = .time, miruroVideoProvider: String = "",
|
||||||
miruroCaptionProvider: String = "", keys: KeyBindings = KeyBindings()) {
|
miruroCaptionProvider: String = "", keys: KeyBindings = KeyBindings()) {
|
||||||
self.showBufferInfo = showBufferInfo
|
self.showBufferInfo = showBufferInfo
|
||||||
self.automaticQuality = automaticQuality
|
self.automaticQuality = automaticQuality
|
||||||
self.sponsorBlock = sponsorBlock
|
self.sponsorBlock = sponsorBlock
|
||||||
|
self.sponsorBlockAutoSkip = sponsorBlockAutoSkip
|
||||||
|
self.openingEndingAutoSkip = openingEndingAutoSkip
|
||||||
self.snapToEdges = snapToEdges
|
self.snapToEdges = snapToEdges
|
||||||
self.compactTitleProgress = compactTitleProgress
|
self.compactTitleProgress = compactTitleProgress
|
||||||
self.miruroVideoProvider = miruroVideoProvider
|
self.miruroVideoProvider = miruroVideoProvider
|
||||||
@@ -37,6 +41,8 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
|||||||
"[playback]",
|
"[playback]",
|
||||||
"automatic_quality = \(defaults.automaticQuality)",
|
"automatic_quality = \(defaults.automaticQuality)",
|
||||||
"sponsorblock = \(defaults.sponsorBlock)",
|
"sponsorblock = \(defaults.sponsorBlock)",
|
||||||
|
"sponsorblock_autoskip = \(defaults.sponsorBlockAutoSkip)",
|
||||||
|
"opening_ending_autoskip = \(defaults.openingEndingAutoSkip)",
|
||||||
"", "[miruro]",
|
"", "[miruro]",
|
||||||
"video_provider = \"\(defaults.miruroVideoProvider)\"",
|
"video_provider = \"\(defaults.miruroVideoProvider)\"",
|
||||||
"caption_provider = \"\(defaults.miruroCaptionProvider)\"",
|
"caption_provider = \"\(defaults.miruroCaptionProvider)\"",
|
||||||
@@ -96,6 +102,8 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
|||||||
return Self(showBufferInfo: values.ui?.bufferInfo ?? false,
|
return Self(showBufferInfo: values.ui?.bufferInfo ?? false,
|
||||||
automaticQuality: values.playback?.automaticQuality ?? false,
|
automaticQuality: values.playback?.automaticQuality ?? false,
|
||||||
sponsorBlock: values.playback?.sponsorBlock ?? false,
|
sponsorBlock: values.playback?.sponsorBlock ?? false,
|
||||||
|
sponsorBlockAutoSkip: values.playback?.sponsorBlockAutoSkip ?? false,
|
||||||
|
openingEndingAutoSkip: values.playback?.openingEndingAutoSkip ?? false,
|
||||||
snapToEdges: values.ui?.compactSnapToEdges ?? true,
|
snapToEdges: values.ui?.compactSnapToEdges ?? true,
|
||||||
compactTitleProgress: values.ui?.compactTitleProgress ?? .time,
|
compactTitleProgress: values.ui?.compactTitleProgress ?? .time,
|
||||||
miruroVideoProvider: values.miruro?.videoProvider ?? "",
|
miruroVideoProvider: values.miruro?.videoProvider ?? "",
|
||||||
@@ -181,11 +189,16 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
|||||||
private struct PlaybackConfiguration {
|
private struct PlaybackConfiguration {
|
||||||
let automaticQuality: Bool?
|
let automaticQuality: Bool?
|
||||||
let sponsorBlock: Bool?
|
let sponsorBlock: Bool?
|
||||||
|
let sponsorBlockAutoSkip: Bool?
|
||||||
|
let openingEndingAutoSkip: Bool?
|
||||||
|
|
||||||
init(container: KeyedDecodingContainer<SettingKey>) throws {
|
init(container: KeyedDecodingContainer<SettingKey>) throws {
|
||||||
try validate(container, allowed: ["automatic_quality", "sponsorblock"])
|
try validate(container, allowed: ["automatic_quality", "sponsorblock", "sponsorblock_autoskip",
|
||||||
|
"opening_ending_autoskip"])
|
||||||
automaticQuality = try container.decodeIfPresent(Bool.self, forKey: SettingKey("automatic_quality"))
|
automaticQuality = try container.decodeIfPresent(Bool.self, forKey: SettingKey("automatic_quality"))
|
||||||
sponsorBlock = try container.decodeIfPresent(Bool.self, forKey: SettingKey("sponsorblock"))
|
sponsorBlock = try container.decodeIfPresent(Bool.self, forKey: SettingKey("sponsorblock"))
|
||||||
|
sponsorBlockAutoSkip = try container.decodeIfPresent(Bool.self, forKey: SettingKey("sponsorblock_autoskip"))
|
||||||
|
openingEndingAutoSkip = try container.decodeIfPresent(Bool.self, forKey: SettingKey("opening_ending_autoskip"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,22 @@
|
|||||||
- [ ] make sponsored blocks skippable:
|
### Terebi handoff: restore opening/ending metadata for ZokoAnime
|
||||||
- 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)
|
**Finding:** ZokoAnime already returns episode-relative timestamps in its decoded configuration:
|
||||||
|
```json
|
||||||
|
"skip": {
|
||||||
|
"intro": {"start": 49, "end": 144},
|
||||||
|
"outro": {"start": 1357, "end": 1451}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Values are seconds. Verified on Cowboy Bebop episode 1—including `/stream/mal/1/1/sub`, the actual resolver path—and Attack on Titan S3 Part 2 episode 1.
|
||||||
|
|
||||||
|
**Status: implemented locally.**
|
||||||
|
- `ZokoResolver.swift` retains optional, independently decoded provider intervals through resolution and source preparation.
|
||||||
|
- `TerebiApp.swift` feeds `anilist:` provider intervals into the existing metadata/display lifecycle without a Miruro lookup.
|
||||||
|
|
||||||
|
**Requested change:** retain optional provider intervals through resolution/source preparation and feed the existing metadata/display lifecycle for `anilist:` identities. No Tubular changes, Miruro lookup, or external timing service needed.
|
||||||
|
|
||||||
|
**Acceptance:** preserve fractional seconds; validate finite, nonnegative, ordered boundaries against actual media duration; missing/malformed intervals must not block playback. Clear stale intervals on media replacement and retain cancellation/generation guards. Cover direct launches, queues, and saved-episode reopening without changing existing display/skip policy.
|
||||||
|
|
||||||
|
**Verification:** `make check-zoko check-zoko-intervals check-source check-miruro-metadata all` and `.build/resolved-playback-check --metadata-lifecycle` passed. Local native playback covers direct/sidebar launches, fractional bands/manual skip, saved fresh-model reopening, queues/revisits, invalid/missing timing and stale source/metadata rejection. Visual rendering and live-provider alignment still need a user check.
|
||||||
|
|
||||||
|
**Evidence:** [research note](docs/miruro-segments.md#current-provider-investigation-2026-10-25). Metadata verified; player rendering and timestamp alignment were not.
|
||||||
@@ -24,6 +24,10 @@ The Miruro contract below remains intact for old collections. New AniList catalo
|
|||||||
|
|
||||||
Terebi resolves bounded static `window.__P` data over ordinary verified HTTPS, then uses the existing scoped native HLS relay with a Zoko-only exact-origin policy. The optional title lookup is bounded AniList GraphQL metadata; unavailable metadata yields an explicit catalog-ID/episode fallback. There is no dub fallback, browser/challenge execution, telemetry fetch or persistent source URL. See [direct playback and security limits](../README.md#anilist--zokoanime-direct-playback).
|
Terebi resolves bounded static `window.__P` data over ordinary verified HTTPS, then uses the existing scoped native HLS relay with a Zoko-only exact-origin policy. The optional title lookup is bounded AniList GraphQL metadata; unavailable metadata yields an explicit catalog-ID/episode fallback. There is no dub fallback, browser/challenge execution, telemetry fetch or persistent source URL. See [direct playback and security limits](../README.md#anilist--zokoanime-direct-playback).
|
||||||
|
|
||||||
|
Optional `skip.intro` and `skip.outro` from that same configuration remain attached to the resolved source through title enrichment, subtitle replacement and relay preparation. For `anilist:` playback they feed the existing metadata lifecycle and opening/ending display/skip policy without a Miruro request or external timing service. Each interval independently requires numeric, finite, nonnegative, increasing boundaries wholly within actual positive player duration; fractional seconds remain unchanged. Missing/malformed timing never blocks playback. Media replacement clears timings and retains generation/cancellation guards.
|
||||||
|
|
||||||
|
`make check-zoko check-zoko-intervals check-source check-miruro-metadata all` and `.build/resolved-playback-check --metadata-lifecycle` passed after this integration. The localhost native playback smoke covers direct/sidebar launches, manual skip, fresh-model saved reopening, queue/revisit, missing/out-of-duration timing and stale source/metadata rejection. Visual rendering and live-provider timestamp alignment remain unverified.
|
||||||
|
|
||||||
Use stable `anilist:<ID>:<episode>` for resume and report registration. Queue/enqueue documents keep their existing version-1 shape and use the watch URL. Version-2 reports keep exactly the existing event/position/duration/eligibility fields, substituting `media_identity: {"provider":"anilist","media_id":"1","episode":1}`. Validate against the exact expected provider/ID/episode; never reinterpret an old `miruro` identity as AniList. Explicit external source documents also accept the `anilist` identity namespace with decimal IDs, retaining the existing strict native same-origin resource policy.
|
Use stable `anilist:<ID>:<episode>` for resume and report registration. Queue/enqueue documents keep their existing version-1 shape and use the watch URL. Version-2 reports keep exactly the existing event/position/duration/eligibility fields, substituting `media_identity: {"provider":"anilist","media_id":"1","episode":1}`. Validate against the exact expected provider/ID/episode; never reinterpret an old `miruro` identity as AniList. Explicit external source documents also accept the `anilist` identity namespace with decimal IDs, retaining the existing strict native same-origin resource policy.
|
||||||
|
|
||||||
Browsing clients may invoke `terebi --check-source URL` through their configured player executable. This isolated command accepts only the canonical AniList sub URL and no other flags. It reads fresh static configuration plus the validated root HLS manifest using the playback resolver/transport, with a 15-second total deadline and cancellation; it skips title enrichment, nested manifests, subtitles and segments. It launches no app/player/audio/relay, reads no config and writes no resume/progress/config state.
|
Browsing clients may invoke `terebi --check-source URL` through their configured player executable. This isolated command accepts only the canonical AniList sub URL and no other flags. It reads fresh static configuration plus the validated root HLS manifest using the playback resolver/transport, with a 15-second total deadline and cancellation; it skips title enrichment, nested manifests, subtitles and segments. It launches no app/player/audio/relay, reads no config and writes no resume/progress/config state.
|
||||||
|
|||||||
Reference in New Issue
Block a user