256 lines
11 KiB
Markdown
256 lines
11 KiB
Markdown
---
|
|
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>
|