Files
led2/.planning/phases/05-live-show-execution/05-03-PLAN.md

11 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
05-live-show-execution 03 execute 3
05-02
lightsync/frontend/app.js
true
UI-03
truths artifacts key_links
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
path provides contains
lightsync/frontend/app.js wireKeyboard() function with all 7 keyboard bindings function wireKeyboard
from to via pattern
lightsync/frontend/app.js wireKeyboard HTML5 audio element audio.play()/pause()/currentTime manipulation audio.paused ? audio.play
from to via pattern
lightsync/frontend/app.js wireKeyboard timeline.history undo()/redo() calls timeline.*history.undo
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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 ```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():

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 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) " <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> 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.
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

<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>
After completion, create `.planning/phases/05-live-show-execution/05-03-SUMMARY.md`