// 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, with event: UIEvent) { seen() state = .failed } override func pressesBegan(_ presses: Set, 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