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>
This commit is contained in:
Scarriffle
2026-09-05 21:05:13 +02:00
co-authored by Claude Opus 5
parent c05ccb8ae1
commit 9b600de9a9
11 changed files with 1193 additions and 0 deletions
@@ -0,0 +1,124 @@
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)
}
}
@@ -0,0 +1,96 @@
import Foundation
/// Ein Anspruch auf die eingeklappte Notch.
///
/// Bewusst ohne Darstellung: was eine Live Activity *zeigt*, ist eine View und
/// gehört nicht in die Entscheidung, **welche** gerade gilt. So lässt sich die
/// Regel ohne Bildschirm prüfen — wie `NotchStateMachine` und `LayoutEngine`.
public struct ActivityTicket: Equatable, Sendable, Identifiable {
public let id: String
/// Höher verdrängt niedriger.
public let rank: Int
/// Wann angemeldet.
public let raised: Date
/// Wie lange sie steht. `nil` heißt: bis sie zurückgezogen wird.
public let duration: TimeInterval?
public init(id: String, rank: Int, raised: Date, duration: TimeInterval?) {
self.id = id
self.rank = rank
self.raised = raised
self.duration = duration
}
/// Wann sie von selbst geht. `nil` bei dauerhaften.
public var expiry: Date? {
duration.map { raised.addingTimeInterval($0) }
}
public func hasExpired(at now: Date) -> Bool {
guard let expiry else { return false }
return now >= expiry
}
}
/// Wer die eingeklappte Notch gerade bekommt.
///
/// **Warum überhaupt eine Warteschlange.** Die Anlässe kommen unabhängig
/// voneinander: die Lautstärke ändert sich, während ein Titel wechselt, während
/// das Netzteil angesteckt wird. Ohne Regel gewinnt, wer zuletzt kam — und dann
/// verschluckt ein beiläufiger Lautstärkeschritt die Meldung, dass der Akku
/// fast leer ist.
///
/// **Die Regel ist Rang vor Aktualität.** Bei gleichem Rang gilt die jüngere:
/// zwei Lautstärkeschritte hintereinander sollen den zweiten zeigen, nicht den
/// ersten stehen lassen.
public struct ActivityQueue: Equatable, Sendable {
private var tickets: [ActivityTicket] = []
public init() {}
/// Alles, was gerade anliegt — ungefiltert, in Anmeldereihenfolge.
public var all: [ActivityTicket] { tickets }
/// Meldet einen Anspruch an. Ein bereits vorhandener mit derselben Kennung
/// wird **ersetzt**, nicht ergänzt.
///
/// Das ist der Normalfall und keine Ausnahme: wer die Lautstärke dreimal
/// verstellt, meldet dreimal dieselbe Activity an und will dreimal die
/// volle Standzeit — nicht drei Einträge in einer Schlange.
public mutating func raise(_ ticket: ActivityTicket) {
tickets.removeAll { $0.id == ticket.id }
tickets.append(ticket)
}
public mutating func withdraw(id: String) {
tickets.removeAll { $0.id == id }
}
/// Wirft weg, was abgelaufen ist.
public mutating func prune(now: Date) {
tickets.removeAll { $0.hasExpired(at: now) }
}
/// Welche gerade gilt.
public func current(now: Date) -> ActivityTicket? {
tickets
.filter { !$0.hasExpired(at: now) }
// `last` statt `max(by:)`: bei gleichem Rang gewinnt die zuletzt
// angemeldete, und `tickets` steht in Anmeldereihenfolge.
.reduce(into: ActivityTicket?.none) { best, candidate in
guard let current = best else { best = candidate; return }
if candidate.rank >= current.rank { best = candidate }
}
}
/// Wann das nächste Mal etwas von selbst verschwindet.
///
/// Der Presenter stellt darauf seinen Wecker. Ohne diese Auskunft müsste er
/// abtasten — und eine Notch, die zehnmal je Sekunde nachfragt, ob etwas
/// abgelaufen ist, hält den Rechner wach.
public func nextExpiry(after now: Date) -> Date? {
tickets.compactMap(\.expiry).filter { $0 > now }.min()
}
}
@@ -0,0 +1,50 @@
import SwiftUI
/// Was die eingeklappte Notch zeigt.
///
/// **Unter der Notch, nicht daneben.** Neben ihr wären die Arme zwangsläufig
/// gleich breit — die Silhouette zeichnet den Steg mittig um die Hardware, und
/// verschieden breite Arme schöben die Lücke aus ihr heraus. 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 genau so breit, wie ihr Inhalt es braucht.
///
/// **Die Größe steht im Protokoll, sie wird nicht gemessen.** Eine Notch, die
/// sich an ihren Inhalt anpasst, müsste ihn erst zeichnen, seine Größe
/// zurückmelden und sich dann bewegen — das ist ein Bild zu spät und sieht als
/// Ruckeln aus. Wer eine Activity schreibt, weiß, wie groß sie wird.
@MainActor
public protocol NotchActivity: Sendable {
/// Stabil: eine erneute Anmeldung mit derselben Kennung ersetzt die alte,
/// statt sich anzureihen.
var id: String { get }
/// Höher verdrängt niedriger.
var rank: Int { get }
/// Wie lange sie steht. `nil` heißt: bis sie zurückgezogen wird.
var duration: TimeInterval? { get }
/// Die Fläche **unter** der Notch. Ist sie schmaler als die Notch, gibt
/// diese das Maß vor — sonst hinge unter dem Steg ein schmalerer Zipfel.
var size: CGSize { get }
func makeContent() -> AnyView
}
/// Die Ränge, damit sie an einer Stelle stehen und nicht als Zahlen im Code.
///
/// Die Ordnung ist keine Geschmacksfrage: was der Nutzer **selbst gerade
/// ausgelöst** hat, muss er sehen — sonst wirkt der Regler kaputt. Was ihn
/// warnt, steht darüber, weil er es sonst verpasst. Was nur begleitet, steht
/// ganz unten.
public enum ActivityRank {
/// Läuft im Hintergrund mit, wenn sonst nichts ansteht.
public static let ambient = 10
/// Ein Zustand, der sich von selbst geändert hat — Titelwechsel, Kopplung.
public static let notice = 30
/// Eine Reaktion auf einen Handgriff — Lautstärke, Helligkeit.
public static let response = 50
/// Etwas, das man nicht verpassen darf — Akku fast leer.
public static let alert = 90
}