Zum Inhalt

Preset Slots + iCloud Sync — Design

Date: 2026-06-18 Status: Approved (design), ready for planning Scope (Strand ①): Reorganize presets into fixed slots (save-game model), sync them + the pin set across the user's devices via iCloud key-value store, make save = pick a slot, and make pinning select the ATV's lean-back rotation. Plus a Reset all Presets → factory escape hatch. Out of scope (Strand ②, separate): designing a fresh, more varied starter-preset set (content/art). Audio (T2b) remains a later increment. Engine/WGSL untouched.

Motivation

T2a turned the iPhone/iPad into a live editor-remote for the Apple TV. The natural core identity of the peripheral is a preset editor — but today custom presets live only on the device that made them (PresetStore JSON in App-Support; only manual JSON export/import crosses devices), and the built-in vs. custom split is rigid. Johannes wants: presets that sync across all his devices, a curated set he pins to drive the ATV's lean-back rotation, and a save-game-slot model where everything is just a "Preset" he can edit — with the best edits harvested into shipped defaults at update time.

Goal

A fixed array of slots replaces the built-in/custom split. Some slots ship pre-filled (the bundled factory set); all are editable. Saving picks a slot (overwrite or fill an empty one). Slots + the pin set sync via iCloud key-value store, so edits made on the phone appear on the iPad and the ATV. Pinned slots form the ATV's auto-cycle rotation (nothing pinned → cycle all). A factory-reset button restores defaults.

Non-Goals (explicit)

  • Strand ② — the starter-preset content (prettier / more varied factory presets). Separate workstream; this spec ships with the current presets (or a quick first cut) as placeholders in the seeded slots.
  • Audio (T2b) — phone-mic → reactivity. Later increment.
  • CloudKit / Core Data. Deliberately using iCloud KVS: the fixed-slot model bounds the data (each slot ≈ one small key), so KVS's 1 MB / 1024-key budget is comfortable and the implementation stays tiny.
  • Per-device pinned rotations. One shared pin set for now (a future extension if rooms need to differ).

Model

A slot is a position 0…N-1 (working N = 24) that is either empty or holds one Preset (name + PresetParams + tagSignal). Slots replace the built-in/custom collection; AppState.presets becomes "the filled slots, in slot order".

  • Stable slot IDs slot-00 … slot-23. A filled slot's Preset.id is its slot id. So selectedID stays a String, apply(.jumpTo(id)) and the T2a remote protocol (RemoteState.selectedID) are unaffected — selection still travels as a slot-id string.
  • userOwned per slot. False while a slot tracks the shipped default; set true the moment the user saves into it. This is what makes "your edits are never overwritten by an update" work.
  • Pin set = a set of slot ids (the curated rotation), shared/global.
  • Thumbnails are NOT synced (too big for the KVS budget) — each device renders a slot's 256² thumbnail locally from its params via the existing ThumbnailRenderer when the slot's content arrives/changes.

Storage — iCloud key-value store (NSUbiquitousKeyValueStore)

Keys in the default ubiquitous KVS:

  • slot.NN (NN = 00…23) → JSON { name, params, tagSignal, userOwned }, or absent = empty slot.
  • pinned → JSON array of slot ids (the rotation).
  • seedVersion → Int; the factory version last seeded on this account.

A change made on any device fires NSUbiquitousKeyValueStore.didChangeExternallyNotification; the store reloads the changed keys into the local model (on main), which re-renders the library and, on the ATV, re-derives the rotation. synchronize() is called after local writes. The local in-memory model is the working source of truth; KVS is the cross-device mirror (offline edits sync when iCloud reconnects).

Local fallback: if iCloud is unavailable / the user isn't signed in, the same model persists to a local JSON file (so the app is fully usable offline and on accounts without iCloud); KVS mirrors it when available.

Seeding, updates, and reset

  • Bundled factory set: a map slotIndex → Preset for the pre-filled slots, plus a factoryVersion. (Which slots, and how many, is finalized by Strand ②; the mechanism here is content-agnostic.)
  • First launch (empty KVS): seed every factory slot (userOwned = false), seedVersion = factoryVersion.
  • App update with a newer factoryVersion: for each factory slot, write the new default only if the local slot is empty or userOwned == false; leave user-owned slots untouched. Then bump seedVersion. → New factory presets reach untouched/empty slots automatically; your edits are sacrosanct.
  • Save into slot N: write the preset, set userOwned = true.
  • Reset all Presets → factory (destructive, confirmation "Are you sure? This will reset all your presets to factory settings."): set every slot to its factory default (or empty where none), userOwned = false, pin set → factory pins, seedVersion = factoryVersion. This propagates via iCloud (a deliberate factory reset everywhere) — the confirmation covers the cross-device intent. Reset is the explicit way to take the latest factory presets when all slots are already user-owned.

Pinning → ATV rotation

The pin set selects the ATV's auto-cycle playlist:

  • Rotation = the pinned filled slots, in slot order.
  • Nothing pinned → cycle all filled slots (the natural default).
  • All pinned → cycles all (same as nothing pinned).

PlaylistDirector is already index-based; AmbientController derives its playlist from the pinned subset (or all filled when empty). The phone toggles a slot's pin (synced via pinned), and the ATV's rotation updates on the next KVS change.

Save flow & UI

  • Save → Slot picker: a grid of all slots (filled = thumbnail + name, empty = "+"). Pick a slot → overwrite (confirm if filled) or fill an empty one. Replaces the current Save-as-new / overwrite dialog.
  • Library = slot grid (no built-in/custom lists). Tapping a filled slot loads it; a pin toggle (star) sits on each slot. The built-in/custom distinction disappears from the UI.
  • Reset all Presets lives as a destructive action in the library/settings overflow.
  • The live editor is largely unchanged: select slot → load into edited; edit; Save → pick slot.

Components (small, focused)

  • SlotStore (shared, host-testable): the fixed [Slot] model — fill / overwrite / clear / pin / unpin / reset, the seeding+update rule (userOwned/seedVersion), and the migration of existing customs. Pure logic over an injectable persistence backend; no iCloud dependency in the core.
  • KVSPresetBackend (device-empirical): wraps NSUbiquitousKeyValueStore — read/write the slot.NN / pinned / seedVersion keys, observe external changes, hop to main. Plus the local-JSON fallback backend.
  • PresetMapping (pure, host-testable): Preset ⇄ KVS value JSON encode/decode (reuses PresetParams Codable), and the factory-set decode.
  • Rotation source in AmbientController/PlaylistDirector: derive the playlist from the pinned subset.
  • AppState integration: presets ← filled slots; select(id:) by slot id; save targets a slot; pin/unpin; reset. The edited/baseline working state is unchanged.

Migration

On first launch of the slot model: seed factory slots, then place any existing custom presets into the remaining empty slots (userOwned = true), up to capacity. Overflow (more customs than free slots — unlikely for a personal collection) is logged and the extras are kept in a one-off legacy export file rather than lost.

Concurrency

KVS notifications and all model mutation hop to main (the model is main-only, like the tick/editor path). Last-writer-wins per key is acceptable for personal slots (simultaneous edits of the same slot on two devices are rare; different slots are independent keys).

Testing & Verification

  • Host-unit (macOS, TDD): SlotStore (fill/overwrite/clear; seeding on empty vs. update with a newer factoryVersion respecting userOwned; reset; migration of customs into empty slots; overflow); pin logic (pinned subset, empty → all-filled fallback, all-pinned); rotation derivation; PresetMapping round-trip; factory-set decode. These carry the coverage, like T2a's protocol/apply/throttle core.
  • Device-empirical (sign-off, real iCloud account + ≥ 2 devices): edit/save a slot on the phone → it appears on the iPad and the ATV; pin a slot → the ATV rotation changes; Reset all → factory everywhere; works offline then syncs. KVS propagation can't be unit-tested.

Risks

  • iCloud on the Developer-ID / notarized macOS app (not App Store): iCloud KVS needs an embedded provisioning profile carrying the iCloud (ubiquity-kvstore) entitlement — automatic on iOS/tvOS, an extra signing step on macOS. If it fights the notarized build, ship iPhone/iPad/ATV sync first and land macOS sync as a fast-follow. (Same constraint would apply to CloudKit; not a reason to change course.)
  • KVS budget: bounded by design (24 small slots), but the spec keeps a guard — refuse to write a slot whose JSON would blow a per-key cap, surfacing it rather than silently truncating.
  • Reset is destructive across devices — mitigated by the explicit confirmation; intent is documented.
  • Migration overflow if a user has many customs — logged + exported, not silently dropped.
  • Content placeholder: seeded slots ship with current/quick presets until Strand ② lands; flagged so the factory set isn't mistaken for final.

Self-Review

Spec coverage: slot model + IDs (Model); KVS keys + change handling + offline fallback (Storage); seed/update/reset rules (Seeding); pinning→rotation (Pinning); save/library/reset UI (Save flow & UI); component split (Components); migration; host-vs-device test split (Testing). Placeholders: N = 24 and the count of pre-filled slots are deliberate, user-confirmed working values (24) / Strand-②-owned (pre-fill count) — not vague TODOs. Consistency: selectedID stays a String slot-id, so the T2a remote is untouched — stated in Model and consistent with Components. Scope: focused on the slot/sync foundation; the content redesign (②) and audio (T2b) are explicitly separate.