wai/wai-sink-swift: the native sink for the platforms whose native player is reached from Swift. WaiSink wraps the C interface (every call JSON in, JSON out); WaiRelay is the loopback server the player is given in place of the origin: it fetches each object, gives the sink a playlist with its receipt and the chain's parents, and answers the player only with bytes the sink presents (502 otherwise), every URI of a playlist rewritten to it; WaiPlayerSession drives the platform's player through it, observes it every 200 ms, logs its first frame, stalls and end, ends a session nobody can see, and posts the bundle to the owner's collector; WaiViewAttention reads the platform's signals of who can see the player. scripts/build-xcframework.sh builds WaiFFI.xcframework from wai-rs. Tests: the corpus through the wrapper, the relay over the reference floor, and the player end to end over a presentation made with the platform's own encoder. A native host's player and its requests run on threads of their own, so an object can reach the sink with a time before the log's last entry. delivery-telemetry §7.1 has a log's times never decrease and nothing after end or abandon: the sink logs such an entry at the last entry's time, and takes no entry after end or abandon, while an object received then is still verified and acknowledged (native-sink §5.2). The corpus gains log-order, and the second implementation checks both rules. Co-Authored-By: Claude Opus 5.5 <[email protected]>
158 lines
6.2 KiB
Swift
158 lines
6.2 KiB
Swift
// What a native host observes of whether anyone can see the stream (native-sink §6.2): whether
|
|
// the application is shown, how much of the player's area is in view, and the viewer's input.
|
|
// The platform adapters below read the platform's own state; a host with its own notion of
|
|
// visibility, and a test, gives a `WaiAttention` of its own.
|
|
import Foundation
|
|
|
|
/// The attention state a player session reads at each observation.
|
|
public protocol WaiAttention: AnyObject {
|
|
/// The fraction of the player's area in view, in basis points: 0 while the application is not
|
|
/// shown, its window covered or minimised, or the display asleep.
|
|
func visibleBasisPoints() -> Int
|
|
/// Whether the viewer interacted since the last call (at most once a second is recorded).
|
|
func takeInteraction() -> Bool
|
|
}
|
|
|
|
/// A fixed answer: for hosts that decide visibility themselves, and for tests.
|
|
public final class WaiFixedAttention: WaiAttention, @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var bp: Int
|
|
private var interacted = false
|
|
public init(visibleBasisPoints: Int = 10_000) { bp = visibleBasisPoints }
|
|
public func set(visibleBasisPoints v: Int) { lock.lock(); bp = v; lock.unlock() }
|
|
public func noteInteraction() { lock.lock(); interacted = true; lock.unlock() }
|
|
public func visibleBasisPoints() -> Int { lock.lock(); defer { lock.unlock() }; return bp }
|
|
public func takeInteraction() -> Bool { lock.lock(); defer { lock.unlock() }; let i = interacted; interacted = false; return i }
|
|
}
|
|
|
|
/// The fraction of `rect` that lies inside `bounds`, in basis points.
|
|
func fraction(_ rect: CGRect, in bounds: CGRect) -> Int {
|
|
let area = rect.width * rect.height
|
|
guard area > 0 else { return 0 }
|
|
let i = rect.intersection(bounds)
|
|
guard !i.isNull else { return 0 }
|
|
return Int((i.width * i.height / area * 10_000).rounded(.down))
|
|
}
|
|
|
|
#if canImport(UIKit)
|
|
import UIKit
|
|
import UIKit.UIGestureRecognizerSubclass
|
|
|
|
/// The phone, tablet and television adapter: the application's state, the player view's place in
|
|
/// its window, and a passive recognizer on that window for the viewer's input.
|
|
@MainActor
|
|
public final class WaiViewAttention: WaiAttention {
|
|
private weak var view: UIView?
|
|
private var interacted = false
|
|
private var last: Date = .distantPast
|
|
private var recognizer: Passive?
|
|
|
|
public init(view: UIView) {
|
|
self.view = view
|
|
}
|
|
|
|
nonisolated public func visibleBasisPoints() -> Int {
|
|
MainActor.assumeIsolated { self.visibleNow() }
|
|
}
|
|
|
|
nonisolated public func takeInteraction() -> Bool {
|
|
MainActor.assumeIsolated {
|
|
self.install()
|
|
let i = self.interacted
|
|
self.interacted = false
|
|
return i
|
|
}
|
|
}
|
|
|
|
private func visibleNow() -> Int {
|
|
guard UIApplication.shared.applicationState == .active, let v = view, let w = v.window, !v.isHidden, v.alpha > 0 else { return 0 }
|
|
return fraction(v.convert(v.bounds, to: w), in: w.bounds)
|
|
}
|
|
|
|
private func install() {
|
|
guard recognizer == nil, let w = view?.window else { return }
|
|
let r = Passive { [weak self] in
|
|
guard let self, Date().timeIntervalSince(self.last) >= 1 else { return }
|
|
self.last = Date()
|
|
self.interacted = true
|
|
}
|
|
w.addGestureRecognizer(r)
|
|
recognizer = r
|
|
}
|
|
|
|
/// Sees every touch and press and claims none: it fails at once, so the views under it get
|
|
/// their events as before.
|
|
final class Passive: UIGestureRecognizer {
|
|
let seen: () -> Void
|
|
init(_ seen: @escaping () -> Void) {
|
|
self.seen = seen
|
|
super.init(target: nil, action: nil)
|
|
cancelsTouchesInView = false
|
|
delaysTouchesBegan = false
|
|
delaysTouchesEnded = false
|
|
}
|
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
|
|
seen()
|
|
state = .failed
|
|
}
|
|
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent) {
|
|
seen()
|
|
state = .failed
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
#if canImport(AppKit) && !targetEnvironment(macCatalyst)
|
|
import AppKit
|
|
|
|
/// The desktop adapter: the window's occlusion state and whether it is minimised, the player
|
|
/// view's place in it, the display's sleep, and the application's input events.
|
|
@MainActor
|
|
public final class WaiViewAttention: WaiAttention {
|
|
private weak var view: NSView?
|
|
private var interacted = false
|
|
private var last: Date = .distantPast
|
|
private var asleep = false
|
|
private var monitor: Any?
|
|
private var observers: [NSObjectProtocol] = []
|
|
|
|
public init(view: NSView) {
|
|
self.view = view
|
|
let ws = NSWorkspace.shared.notificationCenter
|
|
observers.append(ws.addObserver(forName: NSWorkspace.screensDidSleepNotification, object: nil, queue: .main) { [weak self] _ in
|
|
MainActor.assumeIsolated { self?.asleep = true }
|
|
})
|
|
observers.append(ws.addObserver(forName: NSWorkspace.screensDidWakeNotification, object: nil, queue: .main) { [weak self] _ in
|
|
MainActor.assumeIsolated { self?.asleep = false }
|
|
})
|
|
monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .leftMouseDown, .rightMouseDown, .otherMouseDown, .scrollWheel]) { [weak self] e in
|
|
MainActor.assumeIsolated {
|
|
if let self, Date().timeIntervalSince(self.last) >= 1 {
|
|
self.last = Date()
|
|
self.interacted = true
|
|
}
|
|
}
|
|
return e
|
|
}
|
|
}
|
|
|
|
nonisolated public func visibleBasisPoints() -> Int {
|
|
MainActor.assumeIsolated { self.visibleNow() }
|
|
}
|
|
|
|
nonisolated public func takeInteraction() -> Bool {
|
|
MainActor.assumeIsolated {
|
|
let i = self.interacted
|
|
self.interacted = false
|
|
return i
|
|
}
|
|
}
|
|
|
|
private func visibleNow() -> Int {
|
|
guard !asleep, let v = view, let w = v.window, w.isVisible, !w.isMiniaturized, w.occlusionState.contains(.visible), !v.isHiddenOrHasHiddenAncestor,
|
|
let content = w.contentView else { return 0 }
|
|
return fraction(v.convert(v.bounds, to: content), in: content.bounds)
|
|
}
|
|
}
|
|
#endif
|