Inc 12b — tvOS T1 UX-Iteration (Geräte-Feedback)¶
Date: 2026-06-16
Status: Approved (design), ready for planning
Scope: apple/PhysarumApp/Sources/{Ambient,TV} + Tests. Engine / Renderer / WGSL untouched.
Motivation¶
Johannes' erster Geräte-Test der tvOS-„Physarum Ambient"-App (Inc 12) auf echter Apple TV 4K (AppleTV11,1, tvOS 26.5, Ambilight-TV) ergab drei UX-Lücken im autarken Siri-Remote-Betrieb:
- Play/Pause gibt kein Feedback.
onPlayPauseCommand → togglePlaylistschaltetisCyclinglautlos um — der Nutzer sieht nicht, ob das Signal ankam. - Overlay ist nicht entdeckbar / Geste nicht gespiegelt. Es blendet per
.transition(.opacity)ein (kein Hochschieben passend zur Swipe-up-Geste); Swipe-down schließt es nicht (nur Menu); beim Start gibt es keinen Hinweis, dass unten ein Overlay versteckt ist. - Preset-Wechsel (Swipe ◀/▶) gibt kein Feedback — der Name des neuen Presets wird nicht gezeigt.
Bonus-Beobachtung (out of scope, dokumentiert): oben ist Platz für eine spätere persistente Leiste.
Goals¶
- G1 — Transientes Feedback-HUD (oben-zentriert): zeigt Cycle On/Off (bei Play/Pause) und den Preset-Namen (bei manuellem Wechsel). Blendet nach ~2 s automatisch aus.
- G2 — Overlay schiebt von unten (
.move(edge: .bottom)), Swipe-down schließt es (Menu bleibt zusätzlich; App-Review-2.4.3-Verhalten unverändert: Menu bei geschlossenem Overlay → Exit to Home). - G3 — Startup-Hint „Swipe up for presets" (unten), bei jedem Kaltstart, auto-fade ~5 s, sofort weg bei erster Interaktion.
Non-Goals¶
- Persistente Top-Bar (Zukunft).
- Einfrieren der Simulation (Play/Pause pinnt nur den Auto-Cycle; die Animation lebt bewusst weiter — Lean-back-Ambient).
- HUD beim Auto-Advance (alle 60 s) — bewusst still, damit der Ambient-Fluss ruhig bleibt. Nur manuelle Wechsel zeigen den Namen.
- Änderungen an Engine, Renderer, WGSL, iOS/iPad/macOS-UI.
Architektur-Entscheidung¶
HUD- und Hint-State leben im AmbientController (dem @Observable-Hirn), nicht view-lokal.
- Der Controller ist der einzige
ControlIntent-Sink und weiß als Einziger wann und was angezeigt werden soll (togglePlaylist → Cycle-State; next/previous/jumpTo → Ziel-Preset-Name). - Er besitzt bereits den
tick(dt)-Zeittreiber mit exakt diesem Muster (overlayIdle/overlayTimeout) → HUD/Hint-Ablauf ist ein zweiter/dritter Countdown derselben Form. - Host-testbar auf macOS (wie
PlaylistDirector/ControlIntent/AmbientControllerheute) — die gesamte Feedback-Logik ohne tvOS-View prüfbar. - Future-proof für T2: der Phone-Controller speist später dieselben Intents → bekommt HUD-Feedback geschenkt, ohne zweite Verdrahtung.
Verworfen: view-lokales @State mit eigenen Timern in TVPlayerView — nicht host-testbar, dupliziert
die Timing-Logik, koppelt das Feedback vom Intent-Wissen ab, und würde T2-Intents kein Feedback geben.
Komponenten¶
Ambient/AmbientController.swift (erweitert)¶
Neuer Feedback-Typ (platform-agnostisch, Equatable für Tests):
enum HUDContent: Equatable {
case cycle(Bool) // true = jetzt cyclend, false = pausiert
case preset(String) // Anzeigename des Ziel-Presets
}
Neuer State + Timing-Konstanten:
private(set) var hud: HUDContent?
private var hudElapsed: Double = 0
let hudDuration: Double = 2.0
private(set) var hintVisible: Bool = true // sichtbar ab Kaltstart (Controller wird je Start frisch gebaut)
private var hintElapsed: Double = 0
let hintDuration: Double = 5.0
apply(_:)-Verdrahtung:
.togglePlaylist: nachisCycling.toggle()→setHUD(.cycle(isCycling))..next/.previous: Ziel-Index viaPlaylistDirector.nextIndexberechnen,beginMorph(toIndex:),setHUD(.preset(playlist[targetIndex].name))..jumpTo(id): bei gefundenem IndexbeginMorph+setHUD(.preset(name))..showOverlay/.dismissOverlay: kein HUD.- Jeder Intent:
hintVisible = false(erste Interaktion blendet den Hint aus).
setHUD(_ content:) (privat): hud = content; hudElapsed = 0.
tick(_ dt:)-Erweiterung (zusätzlich zur bestehenden Morph/Cycle/Overlay-Logik):
if hud != nil { hudElapsed += dt; if hudElapsed >= hudDuration { hud = nil } }
if hintVisible { hintElapsed += dt; if hintElapsed >= hintDuration { hintVisible = false } }
Wichtig: Der Auto-Advance in tick() ruft beginMorph direkt (nicht über apply) → setzt
korrekterweise kein HUD (G-Non-Goal). Manuelle Wechsel laufen über apply → HUD.
TV/TVHUDView.swift (neu)¶
Nicht-fokussierbares, transientes Capsule-HUD, oben-zentriert, Safe-Area-Inset:
- Liest
controller.hud; mappt: .cycle(true)→ SF Symbolarrow.triangle.2.circlepath+ „Cycle On"..cycle(false)→ SF Symbolpause.fill+ „Cycle Off"..preset(name)→ nur der Name (groß, fett), kein Icon.- Material-Hintergrund (
.thinMaterialoder.black.opacity(0.55)), weiße Schrift,.title2-Gewicht (10-ft-lesbar)..transition(.opacity)rein/raus. - Eingehängt in den
TVPlayerView-ZStack,VStack { hud; Spacer() },.allowsHitTesting(false).
TV/TVHintView.swift (neu)¶
Dezenter Bottom-Hint: chevron.up + „Swipe up for presets", .thinMaterial-Capsule, gedämpfte Deckkraft,
.transition(.opacity), nicht fokussierbar. Eingehängt bottom-zentriert, sichtbar wenn
controller.hintVisible und !controller.overlayVisible.
TV/TVPlayerView.swift (geändert)¶
- HUD + Hint dem ZStack hinzufügen.
- Overlay-Transition:
.transition(.opacity)→.transition(.move(edge: .bottom).combined(with: .opacity)). Die Show/Dismiss-Pfade sind bereits inwithAnimationgewickelt (onMoveCommand.easeInOut, onExitCommand) → die Slide-Animation greift ohne weitere Änderung.
TV/TVOverlayView.swift (geändert)¶
.onMoveCommand { if $0 == .down { withAnimation { controller.apply(.dismissOverlay) } } }auf dem Wurzel-VStack. Wenn das horizontale Karussell fokussiert ist, wird ein.down-Move nicht von der Fokus-Navigation konsumiert → er propagiert zum Container-Handler und schließt das Overlay. Nur.downwird behandelt;.left/.rightbleiben dem Karussell,.upist no-op.
Verhalten (Zusammenfassung)¶
| Geste | Wirkung | Feedback |
|---|---|---|
| Play/Pause | isCycling toggeln (Sim läuft weiter) |
HUD „Cycle On/Off" ~2 s |
| Swipe ◀ / ▶ | prev / next (manuell) | HUD Preset-Name ~2 s |
| Auto-Advance (60 s) | next | kein HUD |
| Swipe ▲ | Overlay schiebt von unten rein | Hint verschwindet |
| Swipe ▼ / Menu | Overlay schiebt runter raus | — |
| Kaltstart | — | Hint „Swipe up…" ~5 s / bis erste Interaktion |
Tests & Verifikation¶
Host-Unit-Tests (AmbientControllerTests erweitern, macOS-host):
togglePlaylistsetzthud == .cycle(isCycling); nachtick(hudDuration)isthud == nil.next/previous/jumpTosetzenhud == .preset(<erwarteter Name>).- Auto-Advance (genug
tick-Zeit beiisCycling) setzt kein HUD (hud == nil), wechselt aber das Preset. hintVisible == trueinitial; nachtick(hintDuration)false;falsenach beliebigem Intent (z. B.showOverlay).- HUD-Auto-Clear unabhängig von Hint.
View-/Fokus-Verhalten (nicht host-testbar): tvOS-Sim-Build + Screenshot (HUD oben, Hint unten, Overlay-Slide sichtbar) plus Johannes' Geräte-Sign-off mit der Remote (Slide-Animation flüssig, Swipe-down schließt, Fokus sauber zurück zum Player — View-/Fokus-Verhalten ist final nur am Gerät beurteilbar, analog zum 2.4.3-Karussell-Fokus-Fix in Inc 12).
Risiken / offene Verifikationspunkte¶
- Swipe-down-Dismiss via
onMoveCommandhängt am tvOS-Fokus-Bubbling (propagiert ein nicht konsumierter.down-Move vom fokussierten Karussell-Card zum Container?). Empirisch am Sim/Gerät zu bestätigen; Fallback bleibt Menu-Dismiss (bereits vorhanden), also kein Funktionsverlust. - Strings bleiben englisch (konsistent mit bestehender Overlay-UI „Pause/Resume Cycle").
Review-Fixes (adversarialer Multi-Lens-Review, 2026-06-16)¶
Aus dem 4-Linsen-Review (23 Findings, 9 nach Skeptiker-Verifikation bestätigt) umgesetzt:
- Safe-Area / Overscan (medium):
ignoresSafeArea()liegt jetzt nur auf der MetalView (Render bleibt full-bleed); HUD/Hint respektieren die tvOS-Title-Safe-Area (Padding 40/60 → 24, ab Safe-Area gemessen). Vorher lagen sie im Overscan-Band — genau die Klasse Bug, die ein croppender (Ambilight-)TV zeigt. - HUD-Wahrheit (low):
beginMorphliefert jetztBool; das Preset-Name-HUD erscheint nur, wenn ein Morph tatsächlich startet (kein falsches Flash beijumpToaufs aktuelle Preset odernext/previousauf einer Ein-Preset-Playlist). - VoiceOver (low): das HUD postet eine
UIAccessibility-Announcement (Cycle on/off bzw. Preset-Name) — das vorher rein visuelle Feedback ist jetzt auch nicht-visuell verfügbar. - Hint-Hintergrund:
.black.opacity(0.45)-Capsule (statt.thinMaterial) — bewusst solides Schwarz für Lesbarkeit über hellen Szenen, konsistent mit dem HUD. - Tests: +5 Regression-Guards (jumpTo-/Ein-Preset-No-Op → kein HUD, HUD-Timer-Reset/Replace, Overlay-Intents → kein HUD, manuelle Intents + Toggle löschen den Hint).