Files
onyx/Packages/OnyxKit/Sources/ActivityProvider/ActivityPresenter.swift
T
ScarriffleandClaude Opus 5 9b600de9a9 Live Activities unter der Notch, nicht daneben
Neben ihr wären die Arme zwangsläufig gleich breit — die Silhouette zeichnet den
Steg mittig um die Hardware. Ein Symbol links und ein Balken rechts hieße dann
132 Punkte Schwarz für ein Symbol. Darunter hängt die Fläche frei und wird so
breit, wie ihr Inhalt es braucht.

Lautstärke, Strom, Titelwechsel und Helligkeit. Gemeldet wird nur, was sich
**geändert** hat: `PowerWatcher` schweigt beim ersten gelesenen Zustand, sonst
begrüßte Onyx jeden Start mit „am Netz".

Die Helligkeit kostete einen Spike. Von drei Wegen trägt nur `DisplayServices`,
und nur für den eingebauten Bildschirm. `CoreDisplay` — der Weg, den jede
Anleitung empfiehlt — gibt für alle Bildschirme 1.0 zurück, auch für den, der
auf 62 % stand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:05:13 +02:00

125 lines
4.6 KiB
Swift

import SwiftUI
import OnyxDesign
import OnyxNotch
/// Führt die Live Activities und sagt der Notch, was gerade gilt.
///
/// Die Entscheidung selbst trifft `ActivityQueue` — hier steht nur, was mit
/// Zeit und Bildschirm zu tun hat: den Wecker für die nächste ablaufende
/// stellen und den Controllern melden, ob überhaupt etwas anliegt.
@MainActor
@Observable
public final class ActivityPresenter {
/// Die Activity, die gerade gezeichnet wird.
///
/// Bleibt nach dem Ende noch stehen, solange die Fläche einfährt — sonst
/// wäre die View im selben Bild weg, in dem sich die Notch zurückziehen
/// soll, und es gäbe nichts mehr zu sehen. Was **gilt**, sagt
/// `isPresenting`.
public private(set) var current: (any NotchActivity)?
/// Ob die Activity noch gilt oder nur noch ausblendet.
public private(set) var isPresenting = false
/// Wird nach jeder Änderung gerufen — mit dem, was jetzt gilt.
///
/// **Nicht nur bei „etwas / nichts".** Löst ein zweiter Lautstärkeschritt
/// die erste Activity ab, bleibt „etwas liegt an" gleich, aber die Arme
/// werden vielleicht anders breit. Wer nur auf den Wechsel von nichts zu
/// etwas hört, verpasst das.
@ObservationIgnored public var onChange: (((any NotchActivity)?) -> Void)?
@ObservationIgnored private var queue = ActivityQueue()
@ObservationIgnored private var activities: [String: any NotchActivity] = [:]
@ObservationIgnored private var alarm: DispatchWorkItem?
@ObservationIgnored private var retirement: DispatchWorkItem?
@ObservationIgnored private let clock: () -> Date
public init(clock: @escaping () -> Date = Date.init) {
self.clock = clock
}
/// Meldet eine Activity an. Eine bereits stehende mit derselben Kennung
/// wird ersetzt — samt neuer Standzeit.
public func raise(_ activity: any NotchActivity) {
activities[activity.id] = activity
queue.raise(ActivityTicket(id: activity.id,
rank: activity.rank,
raised: clock(),
duration: activity.duration))
settle()
}
public func withdraw(id: String) {
activities[id] = nil
queue.withdraw(id: id)
settle()
}
/// Die Fläche, die die eingeklappte Notch dafür unter sich aufspannt.
///
/// `nil`, sobald die Activity nicht mehr gilt — auch wenn sie noch
/// gezeichnet wird. Genau daran fährt die Fläche ein und schneidet den
/// Inhalt dabei weg.
public var contentSize: CGSize? { isPresenting ? current?.size : nil }
private func settle() {
let now = clock()
queue.prune(now: now)
let next = queue.current(now: now).flatMap { activities[$0.id] }
if let next {
retirement?.cancel()
retirement = nil
current = next
isPresenting = true
} else if isPresenting {
isPresenting = false
scheduleRetirement()
}
onChange?(isPresenting ? current : nil)
scheduleAlarm(after: now)
}
/// Weckt genau dann, wenn die nächste abläuft.
///
/// Nicht abtasten: eine Notch, die zehnmal je Sekunde nachfragt, ob etwas
/// vorbei ist, hält den Rechner wach — und zwar den ganzen Tag, für ein
/// Ereignis alle paar Minuten.
private func scheduleAlarm(after now: Date) {
alarm?.cancel()
alarm = nil
guard let next = queue.nextExpiry(after: now) else { return }
let work = DispatchWorkItem { [weak self] in
MainActor.assumeIsolated { self?.settle() }
}
alarm = work
DispatchQueue.main.asyncAfter(deadline: .now() + max(next.timeIntervalSince(now), 0),
execute: work)
}
}
private extension ActivityPresenter {
/// Räumt die ausgeblendete Activity weg, wenn die Fläche eingefahren ist.
///
/// Nicht sofort: solange die Notch schrumpft, muss noch etwas darin stehen.
/// Die Dauer kommt aus dem Bewegungsprofil, damit sie bei „ruhig" mitwächst
/// — eine feste Zahl schnitte den Inhalt dort mittendrin ab.
func scheduleRetirement() {
retirement?.cancel()
let work = DispatchWorkItem { [weak self] in
MainActor.assumeIsolated {
guard let self, !self.isPresenting else { return }
self.current = nil
}
}
retirement = work
DispatchQueue.main.asyncAfter(deadline: .now() + OnyxTheme.shared.motion.collapseDuration,
execute: work)
}
}