` strip, no canvas required |
| Keyboard shortcuts | Browser `keydown` event | native | `document.addEventListener` in `app.js` |
**Installation:** none required.
---
## Architecture Patterns
### Recommended Project Structure (delta from current)
```
lightsync/
├── api/
│ └── ws.py # EXTEND: cue scheduler in tick branch
├── frontend/
│ ├── index.html # EXTEND: add #live-preview div between canvas and transport
│ ├── style.css # EXTEND: .live-preview, .device-strip styles
│ └── app.js # EXTEND: preview_update handler, keyboard shortcuts
```
No new files strictly required. The planner may choose to extract a `preview.js` module if the preview logic grows beyond ~50 lines.
### Pattern 1: Server-side Cue Scheduler
**What:** Inside the `tick` branch of `websocket_endpoint`, maintain per-connection state: the loaded `ShowModel` and a `set` of already-fired cue IDs. On each tick, iterate all tracks and cues; fire any cue whose timestamp is in `[position, position + 0.060]` and is not in `fired_ids`.
**When to use:** Every tick message while playback is active.
**Key state variables to add to `websocket_endpoint`:**
```python
# Inside websocket_endpoint, alongside existing per-connection state:
show: ShowModel | None = None
fired_ids: set[str] = set()
is_playing: bool = False
```
**Scheduler logic inside the tick branch:**
```python
elif msg_type == "tick":
position = float(msg.get("position", 0.0))
is_playing = True # receiving ticks implies playing
if show is not None:
LOOKAHEAD = 0.060 # 60ms window (D-02)
udp = websocket.app.state.udp_sender
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
for cue in track.cues:
cue_id = str(cue.id)
if cue_id in fired_ids:
continue
if cue.timestamp > position + LOOKAHEAD:
continue
if cue.timestamp < position - 0.1:
# Too far in the past — mark fired without sending
fired_ids.add(cue_id)
continue
# Fire this cue
fired_ids.add(cue_id)
if cue.animation:
try:
payload = encode_animation_cmd(cue.animation, cue.params)
udp.send(payload, device.ip, device.port)
except ValueError:
logger.warning("[ws] unknown animation %r", cue.animation)
# Broadcast preview_update to all browser clients (D-07)
color = cue.params.get("color", [0, 255, 180])
await manager.broadcast({
"type": "preview_update",
"device_id": str(track.device_id),
"animation": cue.animation,
"color": color,
})
```
**Seek-safe reset (D-03):** On `seek`, rebuild `fired_ids` to include all cues with `timestamp < seek_position`. This prevents re-fire of past cues and ensures future cues fire correctly.
```python
elif msg_type == "seek":
position = float(msg.get("position", 0.0))
last_beat_reported = None
# Rebuild fired_ids: mark everything before seek position as fired
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
}
```
**Load show on `load` message (D-04):** The browser already sends `{"type": "load", "path": "..."}` when audio loads. Extend this handler to also accept `show_id`:
```python
elif msg_type == "load":
# Existing beat loading...
show_id = msg.get("show_id")
if show_id:
show_store = getattr(websocket.app.state, "show_store", None)
# show_store lives at module level in main.py
import lightsync.main as _main
loaded = await _main.show_store.load(show_id)
if loaded:
show = loaded
fired_ids = set()
logger.info("[ws] loaded show %s with %d tracks", show_id, len(show.tracks))
```
**Anti-pattern to avoid:** Do not load the show on every tick — load once on `load` message, cache per-connection.
### Pattern 2: Live Preview Panel (DOM-based)
**What:** A horizontal strip of `
` elements, one per device. Each device strip has a label and a colored fill rectangle. When a `preview_update` message arrives, update the background-color of the matching device strip. When audio pauses or ends, dim all strips.
**HTML structure:**
```html
```
**JS message handler:**
```javascript
// In LightSyncClient.ws.onmessage:
if (msg.type === 'preview_update') {
updatePreviewStrip(msg.device_id, msg.animation, msg.color);
}
function updatePreviewStrip(deviceId, animation, color) {
const strip = document.querySelector(`[data-device-id="${deviceId}"]`);
if (!strip) return;
const [r, g, b] = color;
strip.style.backgroundColor = `rgb(${r},${g},${b})`;
strip.querySelector('.strip-anim').textContent = animation.toUpperCase().replace('_', ' ');
}
```
**CSS pattern (terminal aesthetic):**
```css
.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: 2px 0;
max-height: 60px;
overflow-y: auto;
}
.device-strip {
display: flex;
align-items: center;
height: 18px;
padding: 0 4px;
background: var(--bg-panel);
gap: 8px;
font-size: 11px;
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; }
.strip-color {
width: 40px; height: 10px;
background: var(--text-dim); /* inactive state */
flex-shrink: 0;
}
.strip-anim { color: var(--text-dim); font-size: 10px; }
```
**Inactive state:** When no block is active on a device, strip background stays `var(--bg-panel)` and `.strip-color` stays `var(--text-dim)` — consistent with the UI's dim inactive visual language.
**Alternative:** The preview could use a `
` element (one per device), but DOM approach is simpler, achieves the same visual result for this fidelity level, and doesn't require coordinate mapping.
### Pattern 3: Keyboard Shortcuts
**What:** `document.addEventListener('keydown', handler)` in `app.js`, added after `wireAudio()` and `initTimeline()` are called.
**Bindings (Claude's discretion per CONTEXT.md):**
| Key | Action | Implementation |
|-----|--------|----------------|
| Space | Play/Pause | `audio.paused ? audio.play() : audio.pause()` |
| ArrowLeft | Seek -5s | `audio.currentTime = Math.max(0, audio.currentTime - 5)` |
| ArrowRight | Seek +5s | `audio.currentTime = Math.min(audio.duration, audio.currentTime + 5)` |
| Ctrl+Z | Undo | `timeline.history.undo()` |
| Ctrl+Shift+Z | Redo | `timeline.history.redo()` |
| Ctrl+Y | Redo (alt) | `timeline.history.redo()` |
| Ctrl+S | Explicit save | `fetch('/api/shows/${timeline.showId}', ...)` or no-op if no show loaded |
**Guard against input elements:** Skip shortcut if `document.activeElement` is an ` `, ``, or `