Zum Inhalt

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:

  1. Play/Pause gibt kein Feedback. onPlayPauseCommand → togglePlaylist schaltet isCycling lautlos um — der Nutzer sieht nicht, ob das Signal ankam.
  2. 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.
  3. 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/AmbientController heute) — 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: nach isCycling.toggle() → setHUD(.cycle(isCycling)).
  • .next / .previous: Ziel-Index via PlaylistDirector.nextIndex berechnen, beginMorph(toIndex:), setHUD(.preset(playlist[targetIndex].name)).
  • .jumpTo(id): bei gefundenem Index beginMorph + 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 Symbol arrow.triangle.2.circlepath + „Cycle On".
  • .cycle(false) → SF Symbol pause.fill + „Cycle Off".
  • .preset(name) → nur der Name (groß, fett), kein Icon.
  • Material-Hintergrund (.thinMaterial oder .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 in withAnimation gewickelt (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 .down wird behandelt; .left/.right bleiben dem Karussell, .up ist 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):

  • togglePlaylist setzt hud == .cycle(isCycling); nach tick(hudDuration) ist hud == nil.
  • next / previous / jumpTo setzen hud == .preset(<erwarteter Name>).
  • Auto-Advance (genug tick-Zeit bei isCycling) setzt kein HUD (hud == nil), wechselt aber das Preset.
  • hintVisible == true initial; nach tick(hintDuration) false; false nach 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 onMoveCommand hä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): beginMorph liefert jetzt Bool; das Preset-Name-HUD erscheint nur, wenn ein Morph tatsächlich startet (kein falsches Flash bei jumpTo aufs aktuelle Preset oder next/previous auf 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).