docs(05): create phase plan — 3 plans across 3 waves

This commit is contained in:
Claude
2026-04-07 09:16:17 +00:00
parent 02826b2c89
commit 6a1ec4e3b6
4 changed files with 1125 additions and 3 deletions

View File

@@ -109,9 +109,9 @@ Plans:
**Plans**: 3 plans **Plans**: 3 plans
Plans: Plans:
- [ ] 05-01: Cue scheduler position-driven scheduling loop, pre-fire offset, seek-safe cue pointer reset - [ ] 05-01-PLAN.md — Cue scheduler: server-side position-driven scheduling, 60ms look-ahead, seek-safe fired_ids reset
- [ ] 05-02: Live preview panel — canvas LED strip visualization synced to playback via WebSocket position ticks - [ ] 05-02-PLAN.md — Live preview panel + show selector: DOM device strips, preview_update handler, show load into timeline
- [ ] 05-03: Show persistence + keyboard shortcuts — complete save/load round-trip, keyboard bindings for all primary operations - [ ] 05-03-PLAN.md — Keyboard shortcuts: Space, arrows, Ctrl+Z/Y/S for mouse-free operation
**UI hint**: yes **UI hint**: yes
### Phase 6: AI Sync ### Phase 6: AI Sync

View File

@@ -0,0 +1,368 @@
---
phase: 05-live-show-execution
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- lightsync/api/ws.py
autonomous: true
requirements:
- SHW-03
must_haves:
truths:
- "Server fires UDP animation commands when audio position reaches a cue timestamp"
- "Seeking resets the cue pointer so past cues are marked fired and future cues re-arm"
- "Loading a show via WebSocket populates per-connection cue list from show_store"
- "Cues within 60ms ahead of tick position fire immediately (never late)"
- "Pausing does not add cues to fired_ids; cues only fire during active playback"
artifacts:
- path: "lightsync/api/ws.py"
provides: "Server-side cue scheduler integrated into tick handler"
contains: "LOOKAHEAD = 0.060"
key_links:
- from: "lightsync/api/ws.py"
to: "lightsync/protocol/animation_cmd.encode_animation_cmd"
via: "import and call in tick branch"
pattern: "encode_animation_cmd"
- from: "lightsync/api/ws.py"
to: "lightsync/protocol/udp_sender.UDPSender.send"
via: "websocket.app.state.udp_sender.send()"
pattern: "udp_sender\\.send"
- from: "lightsync/api/ws.py"
to: "lightsync/main.show_store"
via: "import lightsync.main and call show_store.load()"
pattern: "_main\\.show_store\\.load"
---
<objective>
Server-side cue scheduler: extend the WebSocket tick handler to fire UDP animation commands at correct timestamps during live show playback.
Purpose: This is the core execution engine for SHW-03. Audio position ticks from the browser drive the scheduler, which scans the loaded show's cue list and dispatches UDP commands to devices. Seek-safe reset prevents double-fire or missed cues.
Output: Modified `lightsync/api/ws.py` with cue scheduling logic, show loading per-connection, seek-safe fired_ids rebuild, play/pause gating, and `preview_update` broadcast.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-live-show-execution/05-CONTEXT.md
@.planning/phases/05-live-show-execution/05-RESEARCH.md
<interfaces>
<!-- Key types and contracts the executor needs -->
From lightsync/models/show.py:
```python
class CueModel(BaseModel):
id: UUID
timestamp: float
duration: float = 4.0
mode: Literal["animation", "frame_sequence"] = "animation"
animation: str | None = None
params: dict[str, Any] = Field(default_factory=dict)
class TrackModel(BaseModel):
device_id: UUID
cues: list[CueModel] = Field(default_factory=list)
class ShowModel(BaseModel):
id: UUID
devices: list[DeviceConfig] = Field(default_factory=list)
tracks: list[TrackModel] = Field(default_factory=list)
```
From lightsync/models/device.py:
```python
class DeviceConfig(BaseModel):
id: UUID
name: str
strip_type: str
led_count: int
ip: str = "127.0.0.1"
port: int = 21324
```
From lightsync/protocol/animation_cmd.py:
```python
def encode_animation_cmd(animation: str, params: dict) -> bytes:
# animation must be in ANIMATION_IDS: solid_color, chase, pulse, rainbow, strobe, color_wipe, fire
```
From lightsync/protocol/udp_sender.py:
```python
def send(self, payload: bytes, ip: str, port: int) -> None: # synchronous, fire-and-forget
```
From lightsync/main.py:
```python
# Module-level — NOT on app.state
show_store: ShowStore | None = None
# On app.state:
app.state.udp_sender # UDPSender instance
app.state.beats # dict: filepath -> {"tempo": float, "beats": [float]}
```
From lightsync/store/show_store.py:
```python
async def load(self, show_id: str) -> ShowModel | None:
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add per-connection show state and load handler to ws.py</name>
<files>lightsync/api/ws.py</files>
<read_first>
- lightsync/api/ws.py
- lightsync/models/show.py
- lightsync/store/show_store.py
- lightsync/main.py
</read_first>
<action>
Extend the `websocket_endpoint` function in `lightsync/api/ws.py`:
1. Add imports at top of file:
```python
from lightsync.protocol.animation_cmd import encode_animation_cmd
```
2. Add per-connection state variables after `last_beat_reported`:
```python
show: ShowModel | None = None # loaded show for cue scheduling
fired_ids: set[str] = set() # cues already dispatched this playback pass
is_playing: bool = False # gate cue scheduling on play state
```
Note: Do NOT import ShowModel at module level — use TYPE_CHECKING or import inside the function to avoid circular imports. Check if `from __future__ import annotations` is already present (it is, line 1) — that handles forward refs. Import `ShowModel` at top with other imports.
3. Extend the `load` message handler (the existing `elif msg_type == "load":` block). After the existing beat-loading logic, add show loading per D-04:
```python
# Show cue list loading (Phase 5 — D-04)
show_id = msg.get("show_id")
if show_id:
import lightsync.main as _main
loaded = await _main.show_store.load(show_id)
if loaded:
show = loaded
fired_ids = set()
is_playing = False
logger.info("[ws] loaded show %s with %d tracks", show_id, len(show.tracks))
else:
logger.warning("[ws] show %s not found", show_id)
```
4. Extend the `play` handler to set `is_playing = True`:
```python
elif msg_type == "play":
logger.info("[ws] play event at position=%.3f", float(msg.get("position", 0)))
last_beat_reported = None
is_playing = True # enable cue scheduling
```
5. Extend the `pause` handler to set `is_playing = False` (per Pitfall 2 — do NOT touch fired_ids on pause):
```python
elif msg_type == "pause":
logger.info("[ws] pause event at position=%.3f", float(msg.get("position", 0)))
is_playing = False # disable cue scheduling; do NOT modify fired_ids
```
6. Extend the `seek` handler to rebuild `fired_ids` per D-03. Mark all cues before seek position as already-fired:
```python
elif msg_type == "seek":
position = float(msg.get("position", 0.0))
logger.info("[ws] seek to position=%.3f", position)
last_beat_reported = None
# Rebuild fired_ids: mark everything before seek position as fired (D-03)
if show is not None:
fired_ids = {
str(cue.id)
for track in show.tracks
for cue in track.cues
if cue.timestamp < position - 0.060
}
```
</action>
<verify>
<automated>cd /home/claude/led2 && python -c "
import ast, sys
with open('lightsync/api/ws.py') as f:
src = f.read()
tree = ast.parse(src)
# Check key patterns exist
checks = [
'show_id' in src,
'fired_ids' in src,
'is_playing' in src,
'encode_animation_cmd' in src,
'_main.show_store.load' in src,
'LOOKAHEAD' not in src, # not yet — that's task 2
]
# Verify no syntax errors (ast.parse succeeded)
print('Syntax: OK')
for i, c in enumerate(checks[:5]):
print(f'Check {i+1}: {\"PASS\" if c else \"FAIL\"}')
sys.exit(0 if all(checks[:5]) else 1)
"</automated>
</verify>
<acceptance_criteria>
- lightsync/api/ws.py contains `show: ShowModel | None = None` (or equivalent type annotation)
- lightsync/api/ws.py contains `fired_ids: set` initialization
- lightsync/api/ws.py contains `is_playing: bool = False`
- lightsync/api/ws.py contains `_main.show_store.load(show_id)`
- lightsync/api/ws.py contains seek handler with `fired_ids = {` set comprehension rebuilding fired_ids
- lightsync/api/ws.py play handler sets `is_playing = True`
- lightsync/api/ws.py pause handler sets `is_playing = False` and does NOT modify fired_ids
</acceptance_criteria>
<done>Per-connection show state is initialized, show loads on WebSocket `load` message, seek rebuilds fired_ids, play/pause gate is_playing flag.</done>
</task>
<task type="auto">
<name>Task 2: Add cue scheduling loop and preview_update broadcast to tick handler</name>
<files>lightsync/api/ws.py</files>
<read_first>
- lightsync/api/ws.py (after Task 1 modifications)
- lightsync/protocol/animation_cmd.py
- lightsync/protocol/udp_sender.py
</read_first>
<action>
Add the cue scheduling loop inside the `tick` branch of `websocket_endpoint`, AFTER the existing beat-checking logic. Gate the entire scheduler on `is_playing and show is not None` per Pitfall 3 (prevents cue firing during seek drag / pause).
Insert this code block inside the `if msg_type == "tick":` branch, after the existing beat notification logic:
```python
# ── Cue scheduler (Phase 5 — D-01, D-02) ──────────────────
if is_playing and show is not None:
LOOKAHEAD = 0.060 # 60ms look-ahead window (D-02)
udp = websocket.app.state.udp_sender
preview_updates = [] # batch preview messages
for track in show.tracks:
device = next(
(d for d in show.devices if str(d.id) == str(track.device_id)),
None,
)
if device is None:
continue
active_cue = None # track which cue is active for preview
for cue in track.cues:
cue_id = str(cue.id)
if cue_id in fired_ids:
# Check if this already-fired cue is still active (for preview)
if cue.timestamp <= position < cue.timestamp + cue.duration:
active_cue = cue
continue
if cue.timestamp > position + LOOKAHEAD:
continue # not due yet
if cue.timestamp < position - 0.1:
# Too far in the past — mark fired without sending
fired_ids.add(cue_id)
continue
# Fire this cue (D-01)
fired_ids.add(cue_id)
active_cue = cue
if cue.animation:
try:
payload = encode_animation_cmd(cue.animation, cue.params)
udp.send(payload, device.ip, device.port)
except (ValueError, KeyError):
logger.warning("[ws] unknown animation %r", cue.animation)
# Build preview update for this device (D-07)
if active_cue and active_cue.animation:
color = active_cue.params.get("color", [0, 255, 180])
preview_updates.append({
"type": "preview_update",
"device_id": str(track.device_id),
"animation": active_cue.animation,
"color": color,
})
else:
# No active cue — send clear (D-05: turns dark)
preview_updates.append({
"type": "preview_update",
"device_id": str(track.device_id),
"animation": None,
"color": None,
})
# Broadcast all preview updates
for pu in preview_updates:
await manager.broadcast(pu)
```
Key implementation details:
- `LOOKAHEAD = 0.060` — 60ms window per D-02
- `udp.send()` is synchronous (fire-and-forget UDP) — safe to call without await
- `encode_animation_cmd` imported at top of file (from Task 1)
- `active_cue` tracks which cue is currently playing on each track for preview state, not just newly-fired cues
- Preview updates broadcast to ALL connected browser clients via `manager.broadcast()`
- Both "active" and "inactive/clear" states are broadcast so the preview can dim devices with no active block
</action>
<verify>
<automated>cd /home/claude/led2 && python -c "
import ast
with open('lightsync/api/ws.py') as f:
src = f.read()
checks = {
'LOOKAHEAD = 0.060': 'LOOKAHEAD = 0.060' in src,
'udp.send': 'udp.send(payload, device.ip, device.port)' in src,
'encode_animation_cmd call': 'encode_animation_cmd(cue.animation' in src,
'preview_update broadcast': 'preview_update' in src,
'is_playing gate': 'is_playing and show is not None' in src,
'manager.broadcast': 'await manager.broadcast(pu)' in src or 'await manager.broadcast(' in src,
}
ast.parse(src) # syntax check
print('Syntax: OK')
for name, ok in checks.items():
print(f'{name}: {\"PASS\" if ok else \"FAIL\"}')
import sys; sys.exit(0 if all(checks.values()) else 1)
"</automated>
</verify>
<acceptance_criteria>
- lightsync/api/ws.py contains `LOOKAHEAD = 0.060`
- lightsync/api/ws.py contains `udp.send(payload, device.ip, device.port)`
- lightsync/api/ws.py contains `encode_animation_cmd(cue.animation, cue.params)`
- lightsync/api/ws.py contains `"type": "preview_update"` message construction
- lightsync/api/ws.py contains `is_playing and show is not None` gate condition
- lightsync/api/ws.py contains `await manager.broadcast` call for preview updates
- lightsync/api/ws.py parses without syntax errors
- The scheduler loop iterates `show.tracks` and `track.cues`
- Cues past position by more than 100ms are silently marked fired (no UDP send)
</acceptance_criteria>
<done>Cue scheduler fires UDP animation commands at correct timestamps during playback. Preview update messages are broadcast on each tick. Cue timing is within 60ms look-ahead window. Past cues are silently skipped.</done>
</task>
</tasks>
<verification>
1. `python -c "import ast; ast.parse(open('lightsync/api/ws.py').read()); print('OK')"` — no syntax errors
2. `grep -c 'LOOKAHEAD' lightsync/api/ws.py` returns 1
3. `grep -c 'preview_update' lightsync/api/ws.py` returns at least 2 (message type string + dict construction)
4. `grep -c 'fired_ids' lightsync/api/ws.py` returns at least 4 (init, add, rebuild, check)
5. `grep 'is_playing' lightsync/api/ws.py` shows True/False assignments and gate condition
</verification>
<success_criteria>
- The WebSocket tick handler scans the loaded show's cue list on each tick
- Cues within [position, position+60ms] fire UDP via encode_animation_cmd + UDPSender.send
- Seek rebuilds fired_ids to mark all cues before seek_position as already-fired
- Play sets is_playing=True, pause sets is_playing=False (no fired_ids modification on pause)
- Show loads from show_store on WebSocket "load" message with show_id field
- preview_update messages broadcast to all browser clients on each tick
</success_criteria>
<output>
After completion, create `.planning/phases/05-live-show-execution/05-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,499 @@
---
phase: 05-live-show-execution
plan: 02
type: execute
wave: 2
depends_on:
- 05-01
files_modified:
- lightsync/frontend/index.html
- lightsync/frontend/style.css
- lightsync/frontend/app.js
autonomous: true
requirements:
- UI-04
- SHW-03
must_haves:
truths:
- "Live preview panel shows active animation color and type per device, updated in sync with playback"
- "Devices with no active cue show a dim inactive indicator"
- "A show can be selected from a dropdown and loaded into the timeline + server"
- "Preview panel is visible between timeline canvas and block inspector when a show is loaded"
artifacts:
- path: "lightsync/frontend/index.html"
provides: "Live preview div and show selector UI elements"
contains: "id=\"live-preview\""
- path: "lightsync/frontend/style.css"
provides: "Terminal-aesthetic styles for preview panel and device strips"
contains: ".live-preview"
- path: "lightsync/frontend/app.js"
provides: "preview_update WebSocket handler, show selector logic, preview strip builder"
contains: "preview_update"
key_links:
- from: "lightsync/frontend/app.js"
to: "lightsync/api/ws.py"
via: "WebSocket onmessage handler for preview_update messages"
pattern: "msg\\.type === .preview_update"
- from: "lightsync/frontend/app.js"
to: "/api/shows"
via: "fetch to list and load shows"
pattern: "fetch.*api/shows"
- from: "lightsync/frontend/app.js"
to: "lightsync/frontend/timeline/commands.js"
via: "setCurrentShowId() call on show load"
pattern: "setCurrentShowId"
---
<objective>
Live preview panel and show selector: add the browser-side UI for live show execution feedback and show management.
Purpose: Implements UI-04 (live preview panel) and the frontend half of SHW-03 (show load into timeline + server). The preview panel shows which animation is active on each device during playback. The show selector lets users load saved shows into the timeline editor and connect them to the server-side cue scheduler.
Output: Modified `index.html` (new DOM elements), `style.css` (preview panel styles), and `app.js` (preview handler, show selector, preview strip builder).
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-live-show-execution/05-CONTEXT.md
@.planning/phases/05-live-show-execution/05-RESEARCH.md
@.planning/phases/05-live-show-execution/05-UI-SPEC.md
@.planning/phases/05-live-show-execution/05-01-SUMMARY.md
<interfaces>
<!-- Existing CSS variables from style.css -->
```css
--bg-primary: #0a0a0a;
--bg-panel: #0f0f0f;
--bg-panel-dark: #080808;
--border-dim: #1a4a4a;
--accent: #00ffff;
--text-primary: #cccccc;
--text-dim: #555555;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
```
<!-- WebSocket preview_update message format (from Plan 01) -->
```javascript
// Active cue:
{ type: "preview_update", device_id: "uuid", animation: "chase", color: [0, 255, 180] }
// Inactive (no cue):
{ type: "preview_update", device_id: "uuid", animation: null, color: null }
```
<!-- Existing show API -->
```
GET /api/shows -> [{ id, name, created_at }]
GET /api/shows/{id} -> ShowModel (full, including tracks + devices)
```
<!-- Existing app.js exports/globals -->
```javascript
// client.send(msg) — WebSocket send
// timeline — TimelineCanvas instance (global in app.js)
// timeline.tracks — [{device_id, device_name, strip_type, cues: []}]
// timeline.showId — current show UUID or null
// timeline.loadTracks(devices) — called with device array from /api/devices
```
<!-- From timeline/commands.js -->
```javascript
import { setCurrentShowId } from './timeline/commands.js';
// Already imported in app.js: DeleteBlockCommand
// Need to also import: setCurrentShowId
```
<!-- Existing HTML structure for transport bar selectors -->
```html
<select id="audio-select" class="transport-file-select">...</select>
<button id="btn-load" class="transport-btn">LOAD</button>
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add preview panel HTML and show selector to index.html, add CSS styles</name>
<files>lightsync/frontend/index.html, lightsync/frontend/style.css</files>
<read_first>
- lightsync/frontend/index.html
- lightsync/frontend/style.css
- .planning/phases/05-live-show-execution/05-UI-SPEC.md
</read_first>
<action>
**index.html changes:**
1. Add the live preview panel div between `<canvas id="timeline-canvas">` and `<div id="block-inspector">` inside `<main class="main-area">`:
```html
<canvas id="timeline-canvas"></canvas>
<div id="live-preview" class="live-preview" style="display:none;"></div>
<div id="block-inspector" class="block-inspector" style="display:none;"></div>
```
The `#live-preview` div starts hidden (`display:none` inline). JS sets `display:flex` when a show loads.
Content (device strips) is populated dynamically by JS — the div starts empty.
2. Add show selector controls to the transport bar `<footer class="transport-bar">`. Insert AFTER the existing `<div class="transport-sep"></div>` and BEFORE the upload label. Add a new separator + show select + load button:
```html
<div class="transport-sep"></div>
<select id="show-select" class="transport-file-select">
<option value="">&#8212; no shows &#8212;</option>
</select>
<button id="btn-load-show" class="transport-btn" title="Load saved show">LOAD</button>
<div class="transport-sep"></div>
<label class="transport-btn" title="Upload MP3/WAV/FLAC/OGG" style="cursor:pointer;">
+ UPLOAD
...
```
3. Add a keyboard hint label at the far right of the transport bar, after `<button id="btn-load">`:
```html
<button id="btn-load" class="transport-btn" title="Load selected file">LOAD</button>
<div style="flex:1"></div>
<span class="keyboard-hint">[SPACE] PLAY [&#8592;&#8594;] SEEK [^Z] UNDO [^S] SAVE</span>
```
**style.css changes:**
Append these styles to the end of `lightsync/frontend/style.css`:
```css
/* ── Live Preview Panel (Phase 5) ──────────────────────────────────────────── */
.live-preview {
border-top: 1px solid var(--border-dim);
border-bottom: 1px solid var(--border-dim);
background: var(--bg-panel-dark);
display: flex;
flex-direction: column;
gap: 1px;
padding: 4px 0;
max-height: 60px;
overflow-y: auto;
flex-shrink: 0;
}
.device-strip {
display: flex;
align-items: center;
height: 18px;
padding: 0 4px;
background: var(--bg-panel);
gap: 8px;
font-size: 12px;
font-family: var(--font-mono);
letter-spacing: 0.05em;
transition: background-color 0.1s;
}
.strip-label {
color: var(--text-dim);
text-transform: uppercase;
min-width: 80px;
flex-shrink: 0;
font-size: 10px;
letter-spacing: 0.12em;
}
.strip-color {
width: 40px;
height: 10px;
background: var(--text-dim);
flex-shrink: 0;
transition: background-color 0.15s;
}
.strip-anim {
color: var(--text-dim);
font-size: 10px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.device-strip.active .strip-label {
color: var(--text-primary);
}
.device-strip.active .strip-anim {
color: var(--text-primary);
}
.keyboard-hint {
color: var(--text-dim);
font-size: 10px;
font-family: var(--font-mono);
letter-spacing: 0.05em;
white-space: nowrap;
flex-shrink: 0;
}
```
Do NOT use `--accent` for preview strip colors — the strip color is data-driven from cue params (per UI-SPEC Color section). Do NOT add rounded corners, shadows, or gradients — terminal aesthetic is flat and sharp (per Phase 1 CONTEXT.md).
</action>
<verify>
<automated>cd /home/claude/led2 && python -c "
checks = {}
with open('lightsync/frontend/index.html') as f:
html = f.read()
checks['live-preview div'] = 'id=\"live-preview\"' in html
checks['show-select'] = 'id=\"show-select\"' in html
checks['btn-load-show'] = 'id=\"btn-load-show\"' in html
checks['keyboard-hint'] = 'keyboard-hint' in html
with open('lightsync/frontend/style.css') as f:
css = f.read()
checks['live-preview class'] = '.live-preview' in css
checks['device-strip class'] = '.device-strip' in css
checks['strip-label class'] = '.strip-label' in css
checks['strip-color class'] = '.strip-color' in css
checks['strip-anim class'] = '.strip-anim' in css
checks['max-height 60px'] = 'max-height: 60px' in css
checks['keyboard-hint style'] = '.keyboard-hint' in css
for name, ok in checks.items():
print(f'{name}: {\"PASS\" if ok else \"FAIL\"}')
import sys; sys.exit(0 if all(checks.values()) else 1)
"</automated>
</verify>
<acceptance_criteria>
- index.html contains `<div id="live-preview" class="live-preview"` between timeline-canvas and block-inspector
- index.html contains `<select id="show-select" class="transport-file-select">`
- index.html contains `<button id="btn-load-show" class="transport-btn">`
- index.html contains `class="keyboard-hint"` span in transport bar
- style.css contains `.live-preview` with `max-height: 60px` and `flex-shrink: 0`
- style.css contains `.device-strip` with `height: 18px`
- style.css contains `.strip-label` with `text-transform: uppercase` and `font-size: 10px`
- style.css contains `.strip-color` with `width: 40px` and `height: 10px` and `transition: background-color 0.15s`
- style.css contains `.device-strip.active .strip-label` with `color: var(--text-primary)`
- No `border-radius`, `box-shadow`, or `linear-gradient` in new CSS rules
</acceptance_criteria>
<done>Live preview panel DOM structure exists in index.html. Show selector dropdown + LOAD button exist in transport bar. Keyboard hint visible. All CSS styles follow terminal aesthetic with correct spacing and typography from UI-SPEC.</done>
</task>
<task type="auto">
<name>Task 2: Wire preview_update handler, show selector logic, and preview strip builder in app.js</name>
<files>lightsync/frontend/app.js</files>
<read_first>
- lightsync/frontend/app.js
- lightsync/frontend/timeline/timeline.js (first 50 lines — constructor, tracks, showId, loadTracks)
- lightsync/frontend/timeline/commands.js (first 20 lines — setCurrentShowId export)
- lightsync/api/shows.py (GET /api/shows response format)
</read_first>
<action>
Modify `lightsync/frontend/app.js`:
**1. Update import statement** (line 3) to also import `setCurrentShowId`:
```javascript
import { DeleteBlockCommand, setCurrentShowId } from './timeline/commands.js';
```
**2. Add preview_update handler in `LightSyncClient.ws.onmessage`** (currently only handles `beat`). Extend the handler:
```javascript
this.ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'beat') flashBeat();
else if (msg.type === 'preview_update') updatePreviewStrip(msg.device_id, msg.animation, msg.color);
};
```
**3. Add the `updatePreviewStrip` function** (after `flashBeat`):
```javascript
function updatePreviewStrip(deviceId, animation, color) {
const strip = document.querySelector(`.device-strip[data-device-id="${deviceId}"]`);
if (!strip) return;
const colorEl = strip.querySelector('.strip-color');
const animEl = strip.querySelector('.strip-anim');
if (animation && color) {
const [r, g, b] = color;
colorEl.style.backgroundColor = `rgb(${r},${g},${b})`;
animEl.textContent = animation.toUpperCase().replace('_', ' ');
strip.classList.add('active');
} else {
colorEl.style.backgroundColor = ''; // revert to CSS default (--text-dim)
animEl.textContent = '\u2014'; // em dash
strip.classList.remove('active');
}
}
```
**4. Add `buildPreviewStrips` function** that populates the `#live-preview` div from the device list. Call this AFTER devices load (inside the existing `fetch('/api/devices').then(...)` in `initTimeline`):
```javascript
function buildPreviewStrips(devices) {
const panel = document.getElementById('live-preview');
if (!panel) return;
panel.innerHTML = devices.map(d => `
<div class="device-strip" data-device-id="${d.id}">
<span class="strip-label">${d.name}</span>
<div class="strip-color"></div>
<span class="strip-anim">\u2014</span>
</div>
`).join('');
}
```
**5. Call `buildPreviewStrips` inside `initTimeline`**, in the existing device fetch callback (around line 285-288):
```javascript
fetch('/api/devices').then(r => r.json()).then(devices => {
timeline.loadTracks(devices);
inspector.tracks = timeline.tracks;
buildPreviewStrips(devices); // NEW — populate preview strips
}).catch(e => console.warn('[timeline]', e));
```
**6. Add show selector logic.** After the `initTimeline()` call at the bottom, add:
```javascript
// ── Show Selector ───────────────────────────────────────────────────────────
async function refreshShows() {
const sel = document.getElementById('show-select');
if (!sel) return;
try {
const shows = await (await fetch('/api/shows')).json();
sel.innerHTML = shows.length
? '<option value="">\u2014 select show \u2014</option>'
: '<option value="">\u2014 no shows \u2014</option>';
shows.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.name;
sel.appendChild(opt);
});
} catch (e) { console.error('[shows]', e); }
}
refreshShows();
document.getElementById('btn-load-show')?.addEventListener('click', async () => {
const sel = document.getElementById('show-select');
const showId = sel?.value;
if (!showId) return;
const btn = document.getElementById('btn-load-show');
btn.textContent = '...';
try {
const show = await (await fetch(`/api/shows/${showId}`)).json();
// Populate timeline tracks with saved cues
if (timeline && show.tracks) {
for (const showTrack of show.tracks) {
const existing = timeline.tracks.find(
t => String(t.device_id) === String(showTrack.device_id)
);
if (existing) {
existing.cues = (showTrack.cues || []).map(c => ({...c}));
} else {
console.warn('[show] track for unknown device', showTrack.device_id);
}
}
}
// Set current show ID for auto-save
if (timeline) timeline.showId = showId;
setCurrentShowId(showId);
// Load audio if show has audio path
if (show.audio?.path) {
const filename = show.audio.path.split('/').pop();
const audio = document.getElementById('player');
if (audio && filename) {
audio.src = `/shows/${filename}`;
audio.load();
}
}
// Tell server to load show cue list for scheduling
client.send({ type: 'load', show_id: showId, path: show.audio?.path || '' });
// Show preview panel
const preview = document.getElementById('live-preview');
if (preview) preview.style.display = 'flex';
console.log('[show] loaded', show.name, 'with', show.tracks?.length || 0, 'tracks');
} catch (e) {
console.error('[show] failed to load', showId, e);
} finally {
btn.textContent = 'LOAD';
}
});
```
**7. Clear preview strips on seek** — add a seek handler that dims all strips. In the existing `mouseup` handler on seek bar (around line 141-147), after `client.send({ type: 'seek', ... })`, add:
```javascript
// Clear preview strips on seek (strips re-light when next cue fires)
document.querySelectorAll('.device-strip').forEach(s => {
s.classList.remove('active');
s.querySelector('.strip-color').style.backgroundColor = '';
s.querySelector('.strip-anim').textContent = '\u2014';
});
```
</action>
<verify>
<automated>cd /home/claude/led2 && python -c "
with open('lightsync/frontend/app.js') as f:
js = f.read()
checks = {
'setCurrentShowId import': 'setCurrentShowId' in js and 'import' in js.split('setCurrentShowId')[0][-100:],
'preview_update handler': 'preview_update' in js,
'updatePreviewStrip fn': 'function updatePreviewStrip' in js,
'buildPreviewStrips fn': 'function buildPreviewStrips' in js,
'refreshShows fn': 'async function refreshShows' in js,
'btn-load-show listener': 'btn-load-show' in js,
'show_id in load msg': \"show_id: showId\" in js or \"show_id:\" in js,
'setCurrentShowId call': 'setCurrentShowId(showId)' in js,
'preview display flex': \"display = 'flex'\" in js or 'display = \"flex\"' in js,
}
for name, ok in checks.items():
print(f'{name}: {\"PASS\" if ok else \"FAIL\"}')
import sys; sys.exit(0 if all(checks.values()) else 1)
"</automated>
</verify>
<acceptance_criteria>
- app.js imports `setCurrentShowId` from `./timeline/commands.js`
- app.js ws.onmessage handles `preview_update` message type
- app.js contains `function updatePreviewStrip(deviceId, animation, color)` that updates `.device-strip` DOM
- app.js contains `function buildPreviewStrips(devices)` that creates `div.device-strip[data-device-id]` elements
- app.js calls `buildPreviewStrips(devices)` inside the existing device fetch callback in `initTimeline`
- app.js contains `async function refreshShows()` that fetches `/api/shows` and populates `#show-select`
- app.js `btn-load-show` click handler fetches show, populates timeline tracks, calls `setCurrentShowId(showId)`, sends `{ type: 'load', show_id: showId }` via WebSocket
- app.js sets `#live-preview` `display` to `flex` on show load
- app.js clears preview strips (removes `.active` class) on seek
</acceptance_criteria>
<done>Preview panel updates live during playback via WebSocket preview_update messages. Shows can be selected and loaded into the timeline + server. Preview strips are built from device list. Strips clear on seek and re-activate when cues fire.</done>
</task>
</tasks>
<verification>
1. `grep -c 'preview_update' lightsync/frontend/app.js` returns at least 2
2. `grep -c 'buildPreviewStrips' lightsync/frontend/app.js` returns at least 2 (definition + call)
3. `grep 'live-preview' lightsync/frontend/index.html` finds the div
4. `grep 'show-select' lightsync/frontend/index.html` finds the select element
5. `grep '.device-strip' lightsync/frontend/style.css` finds style rules
6. `grep 'setCurrentShowId' lightsync/frontend/app.js` finds import and call
</verification>
<success_criteria>
- Live preview panel renders one strip per device, colored by active animation's params.color
- Inactive devices show dim state (--text-dim background, em dash text)
- Show selector dropdown lists saved shows from /api/shows
- Loading a show populates timeline tracks, sets showId, sends WebSocket load message with show_id
- Preview panel becomes visible (display:flex) when a show loads
- Preview strips clear on seek and re-activate when cues fire
- All new CSS follows terminal aesthetic (flat, sharp, monospace, no rounded corners)
</success_criteria>
<output>
After completion, create `.planning/phases/05-live-show-execution/05-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,255 @@
---
phase: 05-live-show-execution
plan: 03
type: execute
wave: 3
depends_on:
- 05-02
files_modified:
- lightsync/frontend/app.js
autonomous: true
requirements:
- UI-03
must_haves:
truths:
- "Space bar toggles play/pause without triggering button clicks or scrolling"
- "Arrow keys seek audio forward/backward by 5 seconds and send seek message to server"
- "Ctrl+Z undoes the last timeline mutation"
- "Ctrl+Shift+Z and Ctrl+Y both redo"
- "Ctrl+S prevents browser Save dialog and triggers show save (no-op if no show loaded)"
- "All shortcuts are suppressed when focus is on input/select/textarea elements"
artifacts:
- path: "lightsync/frontend/app.js"
provides: "wireKeyboard() function with all 7 keyboard bindings"
contains: "function wireKeyboard"
key_links:
- from: "lightsync/frontend/app.js wireKeyboard"
to: "HTML5 audio element"
via: "audio.play()/pause()/currentTime manipulation"
pattern: "audio\\.paused \\? audio\\.play"
- from: "lightsync/frontend/app.js wireKeyboard"
to: "timeline.history"
via: "undo()/redo() calls"
pattern: "timeline.*history\\.undo"
---
<objective>
Keyboard shortcuts: wire all primary operations (play/pause, seek, undo/redo, save) to keyboard bindings for mouse-free operation.
Purpose: Implements UI-03 (all primary operations accessible without mouse). Standard DAW keyboard conventions make the app usable during live show execution without reaching for the mouse.
Output: Modified `lightsync/frontend/app.js` with a `wireKeyboard()` function called at init time.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-live-show-execution/05-CONTEXT.md
@.planning/phases/05-live-show-execution/05-UI-SPEC.md
@.planning/phases/05-live-show-execution/05-01-SUMMARY.md
@.planning/phases/05-live-show-execution/05-02-SUMMARY.md
<interfaces>
<!-- Existing app.js globals (after Plan 02 modifications) -->
```javascript
const client = new LightSyncClient(); // WebSocket client
let timeline = null; // TimelineCanvas instance, set in initTimeline()
// Audio element: document.getElementById('player')
// audio.play(), audio.pause(), audio.paused, audio.currentTime, audio.duration
// Timeline history: timeline.history.undo(), timeline.history.redo()
// Show ID: timeline.showId (null if no show loaded)
```
<!-- Keyboard bindings from CONTEXT.md + UI-SPEC.md -->
| Key | Action |
|-----|--------|
| Space | Play/Pause toggle |
| ArrowLeft | Seek -5s |
| ArrowRight | Seek +5s |
| Ctrl+Z | Undo |
| Ctrl+Shift+Z | Redo |
| Ctrl+Y | Redo (alt) |
| Ctrl+S | Explicit save |
<!-- Save endpoint (existing from shows.py) -->
GET /api/shows/{id} returns full ShowModel
POST /api/shows (body: ShowModel) saves/creates
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add wireKeyboard() function and call it at init</name>
<files>lightsync/frontend/app.js</files>
<read_first>
- lightsync/frontend/app.js (current state after Plan 02)
- .planning/phases/05-live-show-execution/05-UI-SPEC.md (Keyboard Shortcut Bindings section)
</read_first>
<action>
Add the `wireKeyboard` function to `lightsync/frontend/app.js`. Place it after the `wireAudio()` function definition (around line 148) and before the file list section.
```javascript
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
function wireKeyboard() {
const audio = document.getElementById('player');
document.addEventListener('keydown', (e) => {
// Skip shortcuts when typing in form elements
const tag = document.activeElement?.tagName?.toLowerCase();
if (['input', 'select', 'textarea'].includes(tag)) return;
// Also skip Space on focused buttons to prevent double-toggle (Pitfall 6)
if (e.code === 'Space' && tag === 'button') return;
if (e.code === 'Space') {
e.preventDefault();
if (!audio) return;
if (audio.paused) {
audio.play();
client.send({ type: 'play', position: audio.currentTime });
} else {
audio.pause();
client.send({ type: 'pause', position: audio.currentTime });
}
} else if (e.code === 'ArrowLeft') {
e.preventDefault();
if (!audio) return;
audio.currentTime = Math.max(0, audio.currentTime - 5);
client.send({ type: 'seek', position: audio.currentTime });
// Clear preview strips on seek
document.querySelectorAll('.device-strip').forEach(s => {
s.classList.remove('active');
const colorEl = s.querySelector('.strip-color');
if (colorEl) colorEl.style.backgroundColor = '';
const animEl = s.querySelector('.strip-anim');
if (animEl) animEl.textContent = '\u2014';
});
} else if (e.code === 'ArrowRight') {
e.preventDefault();
if (!audio) return;
audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + 5);
client.send({ type: 'seek', position: audio.currentTime });
// Clear preview strips on seek
document.querySelectorAll('.device-strip').forEach(s => {
s.classList.remove('active');
const colorEl = s.querySelector('.strip-color');
if (colorEl) colorEl.style.backgroundColor = '';
const animEl = s.querySelector('.strip-anim');
if (animEl) animEl.textContent = '\u2014';
});
} else if (e.ctrlKey && !e.shiftKey && e.code === 'KeyZ') {
e.preventDefault();
timeline?.history.undo();
} else if ((e.ctrlKey && e.shiftKey && e.code === 'KeyZ') || (e.ctrlKey && e.code === 'KeyY')) {
e.preventDefault();
timeline?.history.redo();
} else if (e.ctrlKey && e.code === 'KeyS') {
e.preventDefault(); // Prevent browser Save dialog (Pitfall 7)
if (timeline?.showId) {
fetch(`/api/shows/${timeline.showId}`)
.then(r => r.json())
.then(show => fetch('/api/shows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(show),
}))
.then(() => console.log('[save] show saved via Ctrl+S'))
.catch(e => console.warn('[save]', e));
}
}
});
}
```
Call `wireKeyboard()` at the bottom of the Init section, after `wireAudio()` and before `initTimeline()`:
```javascript
wireAudio();
wireKeyboard(); // NEW — keyboard shortcuts (Phase 5)
```
Key implementation details:
- `e.preventDefault()` is called FIRST in every branch to suppress browser defaults (Space scroll, Ctrl+S save dialog, Arrow key scroll)
- Space on `<button>` elements is explicitly skipped to prevent the double-toggle issue (Pitfall 6)
- Arrow seek sends `{ type: 'seek' }` to the server so the cue scheduler rebuilds `fired_ids`
- Arrow seek also clears preview strips (same pattern as seek bar mouseup from Plan 02)
- Space play/pause sends `{ type: 'play' }` or `{ type: 'pause' }` to the server so the scheduler's `is_playing` flag updates
- Ctrl+S does a GET then POST round-trip (GET current show state → POST to save) — this is a manual trigger, the primary persistence is auto-save on block mutations
- `timeline?.history.undo()` uses optional chaining since timeline might not be initialized yet
</action>
<verify>
<automated>cd /home/claude/led2 && python -c "
with open('lightsync/frontend/app.js') as f:
js = f.read()
checks = {
'wireKeyboard function': 'function wireKeyboard' in js,
'wireKeyboard called': 'wireKeyboard()' in js,
'Space handler': \"e.code === 'Space'\" in js,
'ArrowLeft handler': \"e.code === 'ArrowLeft'\" in js,
'ArrowRight handler': \"e.code === 'ArrowRight'\" in js,
'Ctrl+Z handler': \"e.code === 'KeyZ'\" in js,
'Ctrl+Y handler': \"e.code === 'KeyY'\" in js,
'Ctrl+S handler': \"e.code === 'KeyS'\" in js,
'preventDefault': js.count('e.preventDefault()') >= 6,
'input guard': \"'input', 'select', 'textarea'\" in js or \"input.*select.*textarea\" in js,
'history.undo': 'history.undo()' in js,
'history.redo': 'history.redo()' in js,
'play message': \"type: 'play'\" in js or 'type: \"play\"' in js,
'pause message': \"type: 'pause'\" in js or 'type: \"pause\"' in js,
}
for name, ok in checks.items():
print(f'{name}: {\"PASS\" if ok else \"FAIL\"}')
import sys; sys.exit(0 if all(checks.values()) else 1)
"</automated>
</verify>
<acceptance_criteria>
- app.js contains `function wireKeyboard()` definition
- app.js calls `wireKeyboard()` in the init section (before or after wireAudio)
- app.js Space handler calls `e.preventDefault()` and toggles `audio.play()/pause()`
- app.js Space handler skips when `tag === 'button'`
- app.js ArrowLeft handler sets `audio.currentTime = Math.max(0, audio.currentTime - 5)`
- app.js ArrowRight handler sets `audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + 5)`
- app.js Arrow handlers send `{ type: 'seek', position: audio.currentTime }` via client.send
- app.js Ctrl+Z handler calls `timeline?.history.undo()`
- app.js Ctrl+Shift+Z and Ctrl+Y both call `timeline?.history.redo()`
- app.js Ctrl+S handler calls `e.preventDefault()` and fetches `/api/shows/${timeline.showId}` then POSTs
- app.js input element guard checks for `input`, `select`, `textarea` tag names
- `e.preventDefault()` appears at least 6 times in the keydown handler (one per key combo)
</acceptance_criteria>
<done>All 7 keyboard shortcuts are wired: Space (play/pause), ArrowLeft (seek -5s), ArrowRight (seek +5s), Ctrl+Z (undo), Ctrl+Shift+Z (redo), Ctrl+Y (redo alt), Ctrl+S (save). All shortcuts are suppressed in form elements. Browser defaults are prevented.</done>
</task>
</tasks>
<verification>
1. `grep -c 'wireKeyboard' lightsync/frontend/app.js` returns at least 2 (definition + call)
2. `grep 'preventDefault' lightsync/frontend/app.js | wc -l` returns at least 6
3. `grep -c "e.code === 'Space'" lightsync/frontend/app.js` returns at least 1
4. `grep -c "e.code === 'KeyS'" lightsync/frontend/app.js` returns at least 1
5. `grep -c 'history.undo' lightsync/frontend/app.js` returns at least 1
6. `grep -c 'history.redo' lightsync/frontend/app.js` returns at least 1
</verification>
<success_criteria>
- Space toggles play/pause and sends play/pause message to server
- Arrow keys seek +/- 5 seconds and send seek message to server
- Ctrl+Z undoes last timeline mutation via history.undo()
- Ctrl+Shift+Z and Ctrl+Y both redo via history.redo()
- Ctrl+S saves current show (no-op if no show loaded, prevents browser dialog)
- All shortcuts are disabled when typing in input/select/textarea fields
- Space does not trigger button clicks when a button is focused
</success_criteria>
<output>
After completion, create `.planning/phases/05-live-show-execution/05-03-SUMMARY.md`
</output>