tvOS „Physarum Ambient" (T1) Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship a standalone, Siri-Remote-operable tvOS Ambiance app that renders the existing Metal engine full-bleed and auto-cycles a curated preset playlist with smooth morph transitions.
Architecture: A single multiplatform target gains a #if os(tvOS) lean-back SwiftUI shell (TV/*) over the unchanged engine + RenderCoordinator + AppState. All ambient logic (playlist scheduling, control-intent state machine, structure-pinned morph) lives in a platform-agnostic Ambient/* layer that is unit-tested on the macOS host. The iOS/macOS UI layer is excluded from the tvOS build via whole-file #if !os(tvOS) guards. A single AmbientController.apply(_ intent: ControlIntent) sink is the seam the later T2 phone controller plugs into.
Tech Stack: Swift 5 mode, SwiftUI, MetalKit, XcodeGen, XCTest. Build via xcodebuild; pure logic tested on macOS, tvOS UI verified by simulator build + launch (animation/thermal = device sign-off).
Reference: build & test commands¶
# Regenerate the Xcode project after any project.yml change
cd apple/PhysarumApp && xcodegen generate
# tvOS Simulator BUILD (compile gate — SDK present, runtime installed)
cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'generic/platform=tvOS Simulator' build 2>&1 | tail -25
# macOS BUILD + pure-logic TESTS (regression gate + where Ambient/* is tested)
cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -15
cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | tail -15
# iOS Simulator BUILD (regression gate)
cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -15
# Run a single test class on macOS
cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/PlaylistDirectorTests 2>&1 | tail -15
# Engine smoke gate (Xcode-free)
cd apple/PhysarumEngine && swift run EngineCheck 2>&1 | tail -5
Commit convention (AGENTS.md): Conventional Commits, no emoji, English. Never
--no-verify. Direct commits tomainare authorized (trunk-based). Do not push (Codeberg→GitHub auto-mirror; push only on Johannes' direct command).
File Structure¶
New — apple/PhysarumApp/Sources/Ambient/ (platform-agnostic, compiled everywhere, unit-tested on macOS):
- ControlIntent.swift — the ControlIntent enum + pure remoteMoveIntent(_:) mapping.
- PlaylistDirector.swift — pure auto-cycle scheduling (dwell + wrap-around index).
- AmbientController.swift — @Observable playback state machine: intent sink, time driver (tick), structure-pinned morph; drives AppState.
New — apple/PhysarumApp/Sources/TV/ (#if os(tvOS) only):
- TVRootView.swift — scene root: owns AppState + AmbientController, idle-timer suppression.
- TVPlayerView.swift — full-bleed MetalView + Siri-Remote gestures + 30 Hz morph tick + scene-phase pause.
- TVOverlayView.swift — summon-able overlay: preset name, focusable preset carousel, pin/resume toggle.
Modified:
- Sources/Model/DeviceIdiom.swift — add .tv idiom.
- Sources/Model/SimFormat.swift — add FieldTier.tv + map .tv idiom.
- Sources/App/PhysarumApp.swift — tvOS scene branch → TVRootView.
- Sources/Audio/AudioInput.swift — compile inert on tvOS (no mic route).
- The iOS/macOS UI layer (RootView, Editor/, Library/, Capture UI + Photos) — whole-file #if !os(tvOS) guards (exact set finalized by the tvOS build loop in Task 4).
- apple/PhysarumApp/project.yml — add tvOS to the test target (Task 7).
New tests — apple/PhysarumApp/Tests/:
- FieldTierTVTests.swift, PlaylistDirectorTests.swift, ControlIntentTests.swift, AmbientControllerTests.swift.
Task 1: Model — .tv device idiom + field tier¶
Files:
- Modify: apple/PhysarumApp/Sources/Model/DeviceIdiom.swift
- Modify: apple/PhysarumApp/Sources/Model/SimFormat.swift
- Test: apple/PhysarumApp/Tests/FieldTierTVTests.swift
- [ ] Step 1: Write the failing test
Create apple/PhysarumApp/Tests/FieldTierTVTests.swift:
import XCTest
@testable import Physarum
final class FieldTierTVTests: XCTestCase {
func testTVTierIsConservativeAndDistinct() {
// Apple TV 4K is mains-powered but fanless — start at the conservative
// phone/tablet workload; the seam (distinct id) lets us raise it on-device.
XCTAssertEqual(FieldTier.tv.base, 512)
XCTAssertEqual(FieldTier.tv.maxLong, 1280)
XCTAssertEqual(FieldTier.tv.agentCap, 250_000)
XCTAssertNotEqual(FieldTier.tv, FieldTier.tablet)
XCTAssertNotEqual(FieldTier.tv, FieldTier.phone)
}
func testFieldTierMapsTVIdiom() {
XCTAssertEqual(fieldTier(for: .tv), .tv)
}
}
- [ ] Step 2: Run test to verify it fails
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/FieldTierTVTests 2>&1 | tail -15
Expected: FAIL — .tv is not a member of DeviceIdiom / FieldTier.
- [ ] Step 3: Add the
.tvidiom
In Sources/Model/DeviceIdiom.swift, extend the enum and the computed var:
/// Device class for live-path workload + capture-ceiling decisions.
enum DeviceIdiom: Equatable { case phone, pad, mac, tv }
/// The running device's class. iPad is its own class (not "desktop").
var currentDeviceIdiom: DeviceIdiom {
#if os(macOS)
return .mac
#elseif os(tvOS)
return .tv
#else
return UIDevice.current.userInterfaceIdiom == .pad ? .pad : .phone
#endif
}
- [ ] Step 4: Add the
.tvfield tier + idiom mapping
In Sources/Model/SimFormat.swift, add the static tier next to the existing ones:
And add the .tv case to the existing fieldTier(for:) switch (it currently maps phone/pad/mac):
func fieldTier(for idiom: DeviceIdiom) -> FieldTier {
switch idiom {
case .phone: return .phone
case .pad: return .tablet
case .mac: return .desktop
case .tv: return .tv
}
}
- [ ] Step 5: Run test to verify it passes
Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/FieldTierTVTests 2>&1 | tail -15
Expected: PASS (2 tests).
- [ ] Step 6: Commit
git add apple/PhysarumApp/Sources/Model/DeviceIdiom.swift apple/PhysarumApp/Sources/Model/SimFormat.swift apple/PhysarumApp/Tests/FieldTierTVTests.swift apple/PhysarumApp/project.yml
git commit -m "feat(tvos): add .tv device idiom and conservative field tier"
Task 2: PlaylistDirector (pure auto-cycle scheduling)¶
Files:
- Create: apple/PhysarumApp/Sources/Ambient/PlaylistDirector.swift
- Test: apple/PhysarumApp/Tests/PlaylistDirectorTests.swift
- [ ] Step 1: Write the failing test
Create apple/PhysarumApp/Tests/PlaylistDirectorTests.swift:
import XCTest
@testable import Physarum
final class PlaylistDirectorTests: XCTestCase {
func testShouldAdvanceAtOrAfterDwell() {
XCTAssertFalse(PlaylistDirector.shouldAdvance(dwellElapsed: 59, dwell: 60))
XCTAssertTrue(PlaylistDirector.shouldAdvance(dwellElapsed: 60, dwell: 60))
XCTAssertTrue(PlaylistDirector.shouldAdvance(dwellElapsed: 61, dwell: 60))
}
func testNextIndexWraps() {
XCTAssertEqual(PlaylistDirector.nextIndex(current: 0, count: 3, step: 1), 1)
XCTAssertEqual(PlaylistDirector.nextIndex(current: 2, count: 3, step: 1), 0)
XCTAssertEqual(PlaylistDirector.nextIndex(current: 0, count: 3, step: -1), 2)
XCTAssertEqual(PlaylistDirector.nextIndex(current: 1, count: 3, step: -1), 0)
}
func testNextIndexEmptyListIsSafe() {
XCTAssertEqual(PlaylistDirector.nextIndex(current: 0, count: 0, step: 1), 0)
}
}
- [ ] Step 2: Run test to verify it fails
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/PlaylistDirectorTests 2>&1 | tail -15
Expected: FAIL — no such type PlaylistDirector.
- [ ] Step 3: Write the implementation
Create apple/PhysarumApp/Sources/Ambient/PlaylistDirector.swift:
import Foundation
/// Pure auto-cycle scheduling for the tvOS ambient playlist. Holds no state itself —
/// callers pass elapsed dwell time and the current index; the director decides when to
/// advance and to which index. Platform-agnostic so it is fully unit-testable on the
/// macOS host (the tvOS view layer just feeds it time).
enum PlaylistDirector {
/// Whether the current preset has been on screen long enough to advance.
static func shouldAdvance(dwellElapsed: Double, dwell: Double) -> Bool {
dwellElapsed >= dwell
}
/// Next index with wrap-around. `step` may be negative (previous) or positive (next).
/// Returns `current` unchanged when the list is empty.
static func nextIndex(current: Int, count: Int, step: Int) -> Int {
guard count > 0 else { return current }
return ((current + step) % count + count) % count
}
}
- [ ] Step 4: Run test to verify it passes
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/PlaylistDirectorTests 2>&1 | tail -15
Expected: PASS (3 tests). (xcodegen generate picks up the new source file.)
- [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/Ambient/PlaylistDirector.swift apple/PhysarumApp/Tests/PlaylistDirectorTests.swift apple/PhysarumApp/project.yml
git commit -m "feat(tvos): add PlaylistDirector auto-cycle scheduling"
Task 3: ControlIntent + remote mapping + AmbientController¶
Files:
- Create: apple/PhysarumApp/Sources/Ambient/ControlIntent.swift
- Create: apple/PhysarumApp/Sources/Ambient/AmbientController.swift
- Test: apple/PhysarumApp/Tests/ControlIntentTests.swift
- Test: apple/PhysarumApp/Tests/AmbientControllerTests.swift
- [ ] Step 1: Write the ControlIntent + mapping test
Create apple/PhysarumApp/Tests/ControlIntentTests.swift:
import XCTest
import SwiftUI
@testable import Physarum
final class ControlIntentTests: XCTestCase {
func testMoveMapping() {
XCTAssertEqual(remoteMoveIntent(.left), .previous)
XCTAssertEqual(remoteMoveIntent(.right), .next)
XCTAssertEqual(remoteMoveIntent(.up), .showOverlay)
XCTAssertEqual(remoteMoveIntent(.down), .dismissOverlay)
}
}
- [ ] Step 2: Write ControlIntent + mapping
Create apple/PhysarumApp/Sources/Ambient/ControlIntent.swift:
import SwiftUI
/// A single command the ambient player understands, regardless of source. Today the
/// Siri Remote (T1) feeds these; the T2 phone controller will decode network messages
/// into the SAME intents, so the player's command surface never forks.
enum ControlIntent: Equatable {
case next
case previous
case togglePlaylist // pin the current preset ↔ resume auto-cycling
case jumpTo(String) // preset id
case showOverlay
case dismissOverlay
}
/// Pure mapping from a SwiftUI move command (Siri Remote swipe / directional click) to
/// an intent. Lives here (not in the view) so it is unit-testable on the macOS host.
/// `MoveCommandDirection` is a struct with static members (not an enum), so compare with `==`.
func remoteMoveIntent(_ direction: MoveCommandDirection) -> ControlIntent? {
if direction == .left { return .previous }
if direction == .right { return .next }
if direction == .up { return .showOverlay }
if direction == .down { return .dismissOverlay }
return nil
}
- [ ] Step 3: Run the mapping test (verify pass)
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/ControlIntentTests 2>&1 | tail -15
Expected: PASS (1 test).
- [ ] Step 4: Write the AmbientController test
Create apple/PhysarumApp/Tests/AmbientControllerTests.swift:
import XCTest
@testable import Physarum
final class AmbientControllerTests: XCTestCase {
private func make() -> (AppState, [Preset], AmbientController) {
let presets = PresetCatalog.loadAll()
let state = AppState()
state.select(id: presets[0].id)
let controller = AmbientController(appState: state, playlist: presets, morphDuration: 1.0, dwell: 2.0)
return (state, presets, controller)
}
func testTogglePlaylistFlipsCycling() {
let (_, _, c) = make()
XCTAssertTrue(c.isCycling)
c.apply(.togglePlaylist)
XCTAssertFalse(c.isCycling)
}
func testPinnedMorphHoldsStructure() {
let (_, presets, c) = make()
let a = presets[0].params, b = presets[1].params
let mid = c.pinnedMorph(from: a, to: b, t: 0.5)
XCTAssertEqual(mid.agentCount, a.agentCount)
XCTAssertEqual(mid.spawnPattern, a.spawnPattern)
for i in 0..<3 { XCTAssertEqual(mid.species[i].populationShare, a.species[i].populationShare) }
}
func testManualNextMorphsThenSelectsTarget() {
let (state, presets, c) = make()
c.apply(.next)
XCTAssertEqual(state.selectedID, presets[0].id) // morph not yet complete
c.tick(0.5)
XCTAssertEqual(state.selectedID, presets[0].id) // mid-morph, still old
c.tick(0.6) // crosses t=1
XCTAssertEqual(state.selectedID, presets[1].id) // adopted target
}
func testAutoCycleAdvancesAfterDwell() {
let (state, presets, c) = make() // dwell 2.0, morphDuration 1.0
c.tick(2.0) // dwell reached → begins morph
c.tick(1.1) // completes morph
XCTAssertEqual(state.selectedID, presets[1].id)
}
func testOverlayAutoDismisses() {
let (_, _, c) = make()
c.apply(.showOverlay)
XCTAssertTrue(c.overlayVisible)
c.tick(7) // > overlayTimeout
XCTAssertFalse(c.overlayVisible)
}
}
- [ ] Step 5: Run the AmbientController test (verify it fails)
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/AmbientControllerTests 2>&1 | tail -15
Expected: FAIL — no such type AmbientController.
- [ ] Step 6: Write AmbientController
Create apple/PhysarumApp/Sources/Ambient/AmbientController.swift:
import SwiftUI
import PhysarumEngine
/// Owns the tvOS ambient playback: which preset is showing, whether the playlist is
/// auto-cycling, and any in-progress morph between presets. It is the single sink for
/// `ControlIntent`s (Siri Remote now, phone controller later) and the single driver of
/// `AppState` (the model SSOT the MetalView renders from). It never touches the
/// RenderCoordinator directly — it mutates `AppState.edited`/selection exactly like the
/// live editor does, so the existing MetalView → RenderCoordinator path applies everything.
///
/// Transitions are a *structure-pinned* param morph: scalar look params (decay, bloom,
/// colours, matrix, …) interpolate via `morphParams`, but agentCount / spawnPattern /
/// populationShare stay pinned to the outgoing preset so the live `setParams` path does
/// NOT thrash its debounced respawn every frame. At t=1 we `select` the target, which
/// re-seeds the sim once into the new structure — the look already matches, so it reads
/// as a soft refresh rather than a cut.
@Observable
final class AmbientController {
private let appState: AppState
private let playlist: [Preset]
let morphDuration: Double
let dwell: Double
private(set) var isCycling: Bool = true
private(set) var overlayVisible: Bool = false
private struct Morph { let from: PresetParams; let to: PresetParams; let toID: String; var elapsed: Double }
private var morph: Morph?
private var dwellElapsed: Double = 0
private var overlayIdle: Double = 0
private let overlayTimeout: Double = 6
init(appState: AppState, playlist: [Preset], morphDuration: Double = 4.0, dwell: Double = 60.0) {
self.appState = appState
self.playlist = playlist
self.morphDuration = morphDuration
self.dwell = dwell
}
private var currentIndex: Int {
playlist.firstIndex { $0.id == appState.selectedID } ?? 0
}
// MARK: Intent sink (Siri Remote now, phone later — same entry point)
func apply(_ intent: ControlIntent) {
switch intent {
case .next:
beginMorph(toIndex: PlaylistDirector.nextIndex(current: currentIndex, count: playlist.count, step: 1))
case .previous:
beginMorph(toIndex: PlaylistDirector.nextIndex(current: currentIndex, count: playlist.count, step: -1))
case .togglePlaylist:
isCycling.toggle()
dwellElapsed = 0
case .jumpTo(let id):
if let i = playlist.firstIndex(where: { $0.id == id }) { beginMorph(toIndex: i) }
case .showOverlay:
overlayVisible = true
overlayIdle = 0
case .dismissOverlay:
overlayVisible = false
}
if overlayVisible { overlayIdle = 0 } // any command keeps the overlay awake
}
// MARK: Time driver — called ~30 Hz by the view
func tick(_ dt: Double) {
if var m = morph {
m.elapsed += dt
let t = min(1, m.elapsed / morphDuration)
appState.edited = pinnedMorph(from: m.from, to: m.to, t: t)
if t >= 1 {
appState.select(id: m.toID) // adopt full structure; one re-seed
morph = nil
dwellElapsed = 0
} else {
morph = m
}
} else if isCycling {
dwellElapsed += dt
if PlaylistDirector.shouldAdvance(dwellElapsed: dwellElapsed, dwell: dwell) {
beginMorph(toIndex: PlaylistDirector.nextIndex(current: currentIndex, count: playlist.count, step: 1))
}
}
if overlayVisible {
overlayIdle += dt
if overlayIdle >= overlayTimeout { overlayVisible = false }
}
}
// MARK: Internals
private func beginMorph(toIndex index: Int) {
guard !playlist.isEmpty else { return }
let target = playlist[index]
guard target.id != appState.selectedID || morph != nil else { return } // already here & idle
morph = Morph(from: appState.edited, to: target.params, toID: target.id, elapsed: 0)
}
/// `morphParams` with structural fields pinned to the outgoing preset so the live
/// `setParams` path never schedules a respawn mid-transition. Internal (not private)
/// for unit testing.
func pinnedMorph(from: PresetParams, to: PresetParams, t: Double) -> PresetParams {
var m = morphParams(from, to, t: t)
m.agentCount = from.agentCount
m.spawnPattern = from.spawnPattern
for i in 0..<3 { m.species[i].populationShare = from.species[i].populationShare }
return m
}
}
- [ ] Step 7: Run the AmbientController test (verify it passes)
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/AmbientControllerTests 2>&1 | tail -15
Expected: PASS (5 tests). If PresetCatalog.loadAll() returns < 2 presets in the test host, the bundle's Resources/Presets folder is not loading — confirm the macOS test host bundles the preset folder (it does in the existing preset-decode tests).
- [ ] Step 8: Commit
git add apple/PhysarumApp/Sources/Ambient/ControlIntent.swift apple/PhysarumApp/Sources/Ambient/AmbientController.swift apple/PhysarumApp/Tests/ControlIntentTests.swift apple/PhysarumApp/Tests/AmbientControllerTests.swift apple/PhysarumApp/project.yml
git commit -m "feat(tvos): add ControlIntent sink and AmbientController playback state machine"
Task 4: tvOS build-enablement (guard iOS/macOS UI, minimal TV shell)¶
This is an iterative compile-fix task. The whole iOS/macOS UI layer is compiled into the multiplatform target and uses APIs absent on tvOS (.inspector, presentationDetents, \.horizontalSizeClass, .fileExporter, Photos). Wrap each offending file whole in #if !os(tvOS) … #endif until the tvOS build is green. macOS/iOS builds keep every guarded file (no regression).
Files:
- Create: apple/PhysarumApp/Sources/TV/TVRootView.swift
- Modify: apple/PhysarumApp/Sources/App/PhysarumApp.swift
- Modify: apple/PhysarumApp/Sources/Audio/AudioInput.swift
- Modify (whole-file #if !os(tvOS) guards, confirm/extend via the build loop): Sources/App/RootView.swift, Sources/App/EditorPresentation.swift, Sources/Editor/EditorView.swift, Sources/Editor/ParamSlider.swift, Sources/Editor/Sections.swift, Sources/Editor/AudioReactiveSection.swift, Sources/Editor/MorphSheet.swift, Sources/Editor/SliderPrecision.swift, Sources/Library/LibraryView.swift, Sources/Library/SaveDialog.swift, Sources/Library/RenameDialog.swift, Sources/Capture/CaptureSheet.swift, Sources/Capture/CaptureSaver.swift, and any UI/* file the build flags.
- [ ] Step 1: Add the minimal tvOS root view
Create apple/PhysarumApp/Sources/TV/TVRootView.swift:
#if os(tvOS)
import SwiftUI
import PhysarumEngine
/// Minimal tvOS entry — renders the engine full-bleed for the selected preset.
/// Auto-cycle, Siri-Remote control, the overlay and lifecycle are layered on in
/// Tasks 5–6. Kept tiny here so the build-enablement task has a compile target.
struct TVRootView: View {
@State private var appState = AppState()
var body: some View {
MetalView(
initialField: FieldSize(width: 1280, height: 720),
selectedPreset: appState.selectedPreset,
editedParams: appState.edited,
cover: true,
format: .auto,
tier: .tv
)
.ignoresSafeArea()
}
}
#endif
- [ ] Step 2: Branch the app scene for tvOS
In Sources/App/PhysarumApp.swift, extend the scene's platform branch:
WindowGroup {
#if os(macOS)
// Three columns at their minimums need ~1000pt before the wider inspector ideal kicks in.
RootView()
.frame(minWidth: 1000, minHeight: 600)
#elseif os(tvOS)
TVRootView()
#else
RootView()
#endif
}
- [ ] Step 3: Make AudioInput compile inert on tvOS
Open Sources/Audio/AudioInput.swift. Apple TV has no microphone. Ensure the file compiles on tvOS with running == false always: wrap the iOS AVAudioSession and macOS AVCaptureDevice permission/start bodies so tvOS hits a no-op branch. The minimal correct shape for each platform-specific block:
#if os(iOS)
// existing iOS AVAudioSession + permission code
#elseif os(macOS)
// existing macOS AVCaptureDevice permission code
#else
// tvOS: no microphone — stay inert (running remains false)
#endif
Do not set RenderCoordinator.audioInput on tvOS (the tvOS shell never constructs an AudioInput), so applyAudio()'s let input = audioInput, input.running guard keeps the engine audio-neutral.
- [ ] Step 4: First tvOS build — collect errors
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'generic/platform=tvOS Simulator' build 2>&1 | tail -40
Expected: FAIL with … is unavailable in tvOS / cannot find … in scope / no such module 'Photos' errors pointing at the iOS/macOS UI + capture files.
- [ ] Step 5: Guard each offending file, loop until green
For every file named in a tvOS build error that is iOS/macOS-only UI or Photos code, wrap its entire contents between #if !os(tvOS) and #endif (one guard pair at the very top and very bottom of the file — these files are only consumed by RootView, which is itself guarded). Start with the candidate list in this task's Files block, then re-run the build and add any stragglers the compiler reports.
Repeat: edit → xcodegen generate → xcodebuild … 'generic/platform=tvOS Simulator' build → fix, until:
Expected: ** BUILD SUCCEEDED ** for the tvOS Simulator.
If the build reports a missing tvOS App Icon / Brand Assets set (ASSETCATALOG_COMPILER_APPICON_NAME), add an "Apple TV App Icon & Top Shelf Image" set named AppIcon to Resources/Assets.xcassets (a flat placeholder layer is fine for the simulator; final parallax art is a device/store follow-up) and rebuild.
- [ ] Step 6: Verify no regression on macOS + iOS
cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -8
cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | tail -12
cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -8
- [ ] Step 7: Commit
git add -A apple/PhysarumApp
git commit -m "feat(tvos): compile-enable the multiplatform target for tvOS
Guard the iOS/macOS UI + Photos layer behind #if !os(tvOS), make AudioInput
inert on tvOS, branch the app scene to a minimal TVRootView. tvOS Simulator
builds; macOS/iOS unchanged."
Task 5: Wire AmbientController + Siri Remote + lifecycle into the tvOS shell¶
Files:
- Modify: apple/PhysarumApp/Sources/TV/TVRootView.swift
- Create: apple/PhysarumApp/Sources/TV/TVPlayerView.swift
- [ ] Step 1: Replace TVRootView with the controller-owning root
Overwrite Sources/TV/TVRootView.swift:
#if os(tvOS)
import SwiftUI
import PhysarumEngine
/// Scene root for tvOS: owns the model (`AppState`) and the playback brain
/// (`AmbientController`), and suppresses the idle/Aerial screensaver so the ambient
/// visual keeps running.
struct TVRootView: View {
@State private var appState: AppState
@State private var controller: AmbientController
init() {
let state = AppState()
_appState = State(initialValue: state)
_controller = State(initialValue: AmbientController(appState: state, playlist: state.presets))
}
var body: some View {
TVPlayerView(appState: appState, controller: controller)
.ignoresSafeArea()
.onAppear { UIApplication.shared.isIdleTimerDisabled = true }
}
}
#endif
- [ ] Step 2: Create the player view (render + remote + tick + pause)
Create apple/PhysarumApp/Sources/TV/TVPlayerView.swift:
#if os(tvOS)
import SwiftUI
import PhysarumEngine
/// Full-bleed render + Siri-Remote control + the 30 Hz morph tick. Pauses the MTKView
/// when the scene is inactive/background (tvOS rejects GPU work in the background).
struct TVPlayerView: View {
let appState: AppState
let controller: AmbientController
@Environment(\.scenePhase) private var scenePhase
@FocusState private var focused: Bool
@State private var lastTick = Date()
private let ticker = Timer.publish(every: 1.0 / 30.0, on: .main, in: .common).autoconnect()
var body: some View {
ZStack {
MetalView(
initialField: FieldSize(width: 1280, height: 720),
selectedPreset: appState.selectedPreset,
editedParams: appState.edited,
cover: true,
format: .auto,
tier: .tv,
paused: scenePhase != .active
)
if controller.overlayVisible {
TVOverlayView(appState: appState, controller: controller)
.transition(.opacity)
}
}
.focusable(true)
.focused($focused)
.onAppear { focused = true; lastTick = Date() }
.onMoveCommand { dir in
if let intent = remoteMoveIntent(dir) { withAnimation(.easeInOut) { controller.apply(intent) } }
}
.onPlayPauseCommand { controller.apply(.togglePlaylist) }
// Only intercept Menu while the overlay is up; otherwise nil lets the system exit to Home (App Review 2.4.3).
.onExitCommand(perform: controller.overlayVisible ? { withAnimation { controller.apply(.dismissOverlay) } } : nil)
.onReceive(ticker) { now in
let dt = now.timeIntervalSince(lastTick)
lastTick = now
controller.tick(min(dt, 0.1)) // clamp scheduling hitches
}
}
}
#endif
Note:
TVOverlayViewis created in Task 6. To build Task 5 in isolation, temporarily replace theif controller.overlayVisible { TVOverlayView(...) }block withEmptyView(), or implement Task 6 before first building. Recommended: implement Task 6 next, then build both together at Task 6 Step 3.
-
[ ] Step 3: Build (with Task 6 in place) — see Task 6 Step 3.
-
[ ] Step 4: Commit (after Task 6 builds green) — combined commit in Task 6.
Task 6: TVOverlayView (preset name + focusable carousel + pin toggle)¶
Files:
- Create: apple/PhysarumApp/Sources/TV/TVOverlayView.swift
- [ ] Step 1: Create the overlay
Create apple/PhysarumApp/Sources/TV/TVOverlayView.swift:
#if os(tvOS)
import SwiftUI
import PhysarumEngine
/// Summon-able lean-back overlay: current preset name, a pin/resume toggle, and a
/// focusable horizontal carousel of presets to jump to. Summoned by swipe-up, dismissed
/// by Menu, auto-dismissed by the controller after idle.
struct TVOverlayView: View {
let appState: AppState
let controller: AmbientController
var body: some View {
VStack {
Spacer()
VStack(alignment: .leading, spacing: 32) {
HStack(alignment: .firstTextBaseline) {
Text(appState.selectedPreset.name)
.font(.largeTitle.bold())
.foregroundStyle(.white)
Spacer()
Button(controller.isCycling ? "Pause Cycle" : "Resume Cycle") {
controller.apply(.togglePlaylist)
}
}
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 32) {
ForEach(appState.presets, id: \.id) { preset in
Button { controller.apply(.jumpTo(preset.id)) } label: {
TVPresetCard(preset: preset, selected: preset.id == appState.selectedID)
}
.buttonStyle(.card)
}
}
.padding(.horizontal, 80)
.padding(.vertical, 40)
}
}
.padding(60)
.background(.black.opacity(0.55))
}
}
}
private struct TVPresetCard: View {
let preset: Preset
let selected: Bool
var body: some View {
VStack(spacing: 12) {
thumbnail
.frame(width: 320, height: 180)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(selected ? .white : .clear, lineWidth: 4))
Text(preset.name).font(.headline).foregroundStyle(.white).lineLimit(1)
}
}
@ViewBuilder private var thumbnail: some View {
if let path = Bundle.main.path(forResource: preset.id, ofType: "png", inDirectory: "Thumbnails"),
let img = UIImage(contentsOfFile: path) {
Image(uiImage: img).resizable().scaledToFill()
} else {
Rectangle().fill(.gray.opacity(0.3))
}
}
}
#endif
- [ ] Step 2: Restore the real overlay call in TVPlayerView
If Task 5 Step 2 used an EmptyView() placeholder, restore the real TVOverlayView(appState: appState, controller: controller) call now.
- [ ] Step 3: Build the tvOS shell
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'generic/platform=tvOS Simulator' build 2>&1 | tail -25
Expected: ** BUILD SUCCEEDED **. Fix any tvOS-API issues (e.g. .buttonStyle(.card) availability, focus modifiers) reported by the compiler.
- [ ] Step 4: Commit
git add -A apple/PhysarumApp
git commit -m "feat(tvos): auto-cycle playback, Siri Remote control, and lean-back overlay"
Task 7: Enable the test target on tvOS¶
Files:
- Modify: apple/PhysarumApp/project.yml
- [ ] Step 1: Add tvOS to the test target
In project.yml, under targets.PhysarumAppTests, add tvOS to destinations and the tvOS host paths (mirroring the iOS flat-bundle pattern):
PhysarumAppTests:
type: bundle.unit-test
supportedDestinations: [macOS, iOS, tvOS]
sources: [Tests]
dependencies:
- target: Physarum
settings:
base:
# … existing keys unchanged …
"TEST_HOST[sdk=appletvos*]": "$(BUILT_PRODUCTS_DIR)/Physarum.app/Physarum"
"TEST_HOST[sdk=appletvsimulator*]": "$(BUILT_PRODUCTS_DIR)/Physarum.app/Physarum"
- [ ] Step 2: Run the Ambient/* tests on the tvOS Simulator
cd apple/PhysarumApp && xcodegen generate
xcodebuild -scheme Physarum -destination 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)' test \
-only-testing:PhysarumAppTests/PlaylistDirectorTests \
-only-testing:PhysarumAppTests/ControlIntentTests \
-only-testing:PhysarumAppTests/AmbientControllerTests \
-only-testing:PhysarumAppTests/FieldTierTVTests 2>&1 | tail -20
xcrun simctl list devices tvOS to confirm the device name; substitute an available one if needed.)
- [ ] Step 3: Commit
git add apple/PhysarumApp/project.yml
git commit -m "test(tvos): run the unit suite on the tvOS Simulator too"
Task 8: Full verification + docs/cockpit¶
Files:
- Modify: docs/superpowers/specs/2026-06-15-tvos-ambient-design.md (mark T1 status)
- (Cockpit is in the Obsidian vault — update its §🔍/▶ via the normal session flow.)
- [ ] Step 1: Run the full build/test matrix
cd apple/PhysarumApp && xcodegen generate
xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | tail -12 # all app tests
xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -6
xcodebuild -scheme Physarum -destination 'generic/platform=tvOS Simulator' build 2>&1 | tail -6
cd ../PhysarumEngine && swift run EngineCheck 2>&1 | tail -5
- [ ] Step 2: Launch in the tvOS Simulator (smoke — no crash, UI appears)
xcrun simctl boot "Apple TV 4K (3rd generation)" 2>/dev/null || true
cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)' build 2>&1 | tail -5
APP=$(find ~/Library/Developer/Xcode/DerivedData -path '*tvOS*' -name 'Physarum.app' -type d 2>/dev/null | head -1)
xcrun simctl install booted "$APP"
xcrun simctl launch booted de.jkaindl.physarum
sleep 4
xcrun simctl io booted screenshot /tmp/physarum-tvos.png
/tmp/physarum-tvos.png. Expected: app launched, no crash. The Metal animation may render a static/black frame headlessly (the sim display link does not advance reliably without a real display) — that is acceptable; the live animation + thermal + remote feel are Johannes' device sign-off. Confirm the process is alive and (if the overlay is summoned via the sim's remote) the overlay UI composits.
- [ ] Step 3: Mark the spec + record the device sign-off list
In docs/superpowers/specs/2026-06-15-tvos-ambient-design.md, change **Status:** Approved … to **Status:** T1 implemented (<commit>); device sign-off pending. Append a short "Device sign-off (Johannes, ATV 4K)" checklist: full-bleed render, preset auto-cycle + morph smoothness, Siri-Remote set (swipe prev/next, Play/Pause pin, swipe-up overlay, Menu dismiss), multi-hour thermal, idle-timer/screensaver suppression, background pause/resume.
- [ ] Step 4: Commit
git add docs/superpowers/specs/2026-06-15-tvos-ambient-design.md
git commit -m "docs(tvos): mark T1 implemented, record device sign-off checklist"
Notes for the implementer¶
- DRY/reuse: Do not duplicate engine, render, preset, or audio logic — the tvOS shell composes the existing
RenderCoordinator/AppState/MetalView/PresetCatalog/morphParams. The only genuinely new behaviour is theAmbient/*state machine and theTV/*views. - No engine changes. If a task seems to need a shader/param-layout/engine edit, stop — that is out of scope (T1 keeps the engine byte-for-byte). The structure-pinned morph exists precisely to avoid an engine change.
#if !os(tvOS)vs#if os(tvOS): guard iOS/macOS-only UI with#if !os(tvOS)(keeps it on those platforms, drops it from tvOS); guard tvOS-only views with#if os(tvOS). TheAmbient/*layer carries no platform guard — it must compile (and be tested) on macOS.- Push: never. Commit to
mainonly; Johannes pushes on a direct command.