docs(05): create phase plan — 3 plans across 3 waves
This commit is contained in:
368
.planning/phases/05-live-show-execution/05-01-PLAN.md
Normal file
368
.planning/phases/05-live-show-execution/05-01-PLAN.md
Normal 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>
|
||||
499
.planning/phases/05-live-show-execution/05-02-PLAN.md
Normal file
499
.planning/phases/05-live-show-execution/05-02-PLAN.md
Normal 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="">— no shows —</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 [←→] 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>
|
||||
255
.planning/phases/05-live-show-execution/05-03-PLAN.md
Normal file
255
.planning/phases/05-live-show-execution/05-03-PLAN.md
Normal 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>
|
||||
Reference in New Issue
Block a user