| |
|
|
| let state = { |
| show_name: "", |
| num_lights: 8, |
| cues: [], |
| current_cue_index: -1, |
| }; |
|
|
| let myLightId = window.INITIAL_LIGHT_ID; |
| let ws = null; |
| let reconnectTimer = null; |
|
|
| function connectWS() { |
| const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; |
| ws = new WebSocket(`${protocol}//${window.location.host}/ws`); |
|
|
| ws.onopen = () => { |
| setStatus(true); |
| if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } |
| }; |
| ws.onclose = () => { setStatus(false); reconnectTimer = setTimeout(connectWS, 2000); }; |
| ws.onerror = () => { setStatus(false); }; |
| ws.onmessage = (e) => { |
| const msg = JSON.parse(e.data); |
| if (msg.type === "state_update") { |
| const prevIndex = state.current_cue_index; |
| state = msg.data; |
| render(); |
| if (state.current_cue_index !== prevIndex && hasMyAction(state.current_cue_index)) { |
| triggerAlert(); |
| } |
| } |
| }; |
| } |
|
|
| function setStatus(connected) { |
| const dot = document.getElementById("status-dot"); |
| if (!dot) return; |
| dot.className = connected ? "status-dot connected" : "status-dot disconnected"; |
| } |
|
|
| function hasMyAction(cueIdx) { |
| if (cueIdx < 0 || cueIdx >= state.cues.length) return false; |
| const cue = state.cues[cueIdx]; |
| if (!cue.actions) return false; |
| const action = cue.actions[myLightId - 1]; |
| return action && action.enabled; |
| } |
|
|
| function getMyAction(cueIdx) { |
| if (cueIdx < 0 || cueIdx >= state.cues.length) return null; |
| const cue = state.cues[cueIdx]; |
| if (!cue.actions) return null; |
| return cue.actions[myLightId - 1]; |
| } |
|
|
| function findMyLastActionBefore(cueIdx) { |
| for (let i = cueIdx - 1; i >= 0; i--) { |
| if (hasMyAction(i)) return { cue: state.cues[i], index: i }; |
| } |
| return null; |
| } |
|
|
| function render() { |
| if (myLightId === null || myLightId === undefined) { |
| renderPicker(); |
| return; |
| } |
|
|
| document.getElementById("light-picker").hidden = true; |
| document.getElementById("operator-view").hidden = false; |
|
|
| if (myLightId < 1 || myLightId > state.num_lights) { |
| renderPicker(); |
| return; |
| } |
|
|
| document.getElementById("my-light-id").textContent = myLightId; |
| document.getElementById("footer-show").textContent = state.show_name || "—"; |
|
|
| const curr = state.current_cue_index; |
| const total = state.cues.length; |
|
|
| if (curr < 0) { |
| document.getElementById("footer-cue").textContent = total > 0 ? `— / ${total}` : "—"; |
| } else { |
| document.getElementById("footer-cue").textContent = `${curr + 1} / ${total}`; |
| } |
|
|
| const standby = document.getElementById("state-standby"); |
| const action = document.getElementById("state-action"); |
| const idle = document.getElementById("state-idle"); |
|
|
| standby.hidden = true; |
| action.hidden = true; |
| idle.hidden = true; |
|
|
| if (total === 0) { |
| idle.hidden = false; |
| document.getElementById("idle-label").textContent = "No cues programmed"; |
| return; |
| } |
|
|
| if (curr < 0) { |
| idle.hidden = false; |
| document.getElementById("idle-label").textContent = "Show not started"; |
| return; |
| } |
|
|
| if (hasMyAction(curr)) { |
| action.hidden = false; |
| renderAction(); |
| } else { |
| standby.hidden = false; |
| renderStandby(); |
| } |
| } |
|
|
| function renderStandby() { |
| const lastAction = findMyLastActionBefore(state.current_cue_index); |
| const lastEl = document.getElementById("last-action"); |
| if (lastAction) { |
| const a = lastAction.cue.actions[myLightId - 1]; |
| lastEl.hidden = false; |
| document.getElementById("last-action-detail").textContent = |
| `Cue ${lastAction.index + 1}: ${formatActionSummary(a)}`; |
| } else { |
| lastEl.hidden = true; |
| } |
| } |
|
|
| function renderAction() { |
| const curr = state.current_cue_index; |
| const cue = state.cues[curr]; |
| const a = cue.actions[myLightId - 1]; |
|
|
| document.getElementById("action-cue-num").textContent = |
| `CUE ${curr + 1} — ${cue.name || "Untitled"}`; |
|
|
| const modeCard = document.getElementById("mode-card"); |
| const modeText = { |
| "snap": "⚡ Snap (instant)", |
| "fade": "🌫 Fade", |
| "off": "⚫ Off", |
| }[a.mode || "snap"] || "Snap"; |
| modeCard.hidden = false; |
| document.getElementById("detail-mode").textContent = modeText; |
|
|
| const colorCard = document.getElementById("color-card"); |
| if (a.mode === "off") { |
| colorCard.hidden = true; |
| } else { |
| colorCard.hidden = false; |
| const colorName = a.color_name || colorHexToName(a.color) || "Custom"; |
| document.getElementById("detail-color").textContent = colorName; |
| const swatch = document.getElementById("color-swatch"); |
| swatch.style.backgroundColor = a.color || "#ffffff"; |
| swatch.style.color = a.color || "#ffffff"; |
| } |
|
|
| const brightnessCard = document.getElementById("brightness-card"); |
| if (a.mode === "off") { |
| brightnessCard.hidden = true; |
| } else { |
| brightnessCard.hidden = false; |
| const b = a.brightness ?? 100; |
| document.getElementById("detail-brightness").textContent = `${b}%`; |
| document.getElementById("brightness-fill").style.width = b + "%"; |
| } |
|
|
| const fadeCard = document.getElementById("fade-card"); |
| if (a.mode === "fade" && (a.fade_seconds || 0) > 0) { |
| fadeCard.hidden = false; |
| document.getElementById("detail-fade").textContent = `${a.fade_seconds}s`; |
| } else { |
| fadeCard.hidden = true; |
| } |
|
|
| const notesCard = document.getElementById("notes-card"); |
| if (a.notes && a.notes.trim()) { |
| notesCard.hidden = false; |
| document.getElementById("detail-notes").textContent = a.notes; |
| } else { |
| notesCard.hidden = true; |
| } |
| } |
|
|
| function formatActionSummary(a) { |
| if (!a) return "—"; |
| if (a.mode === "off") return "Off"; |
| const parts = []; |
| if (a.color_name) parts.push(a.color_name); |
| if (a.brightness != null) parts.push(`${a.brightness}%`); |
| if (a.mode === "fade" && a.fade_seconds > 0) parts.push(`fade ${a.fade_seconds}s`); |
| return parts.join(" · ") || "Active"; |
| } |
|
|
| function colorHexToName(hex) { |
| if (!hex) return null; |
| const h = hex.toLowerCase(); |
| const map = { |
| "#ffffff": "White", "#000000": "Black", |
| "#ff0000": "Red", "#00ff00": "Green", "#0000ff": "Blue", |
| "#ffff00": "Yellow", "#ff00ff": "Magenta", "#00ffff": "Cyan", |
| "#ffa500": "Orange", "#ffc0cb": "Pink", "#800080": "Purple", |
| }; |
| return map[h] || null; |
| } |
|
|
| function triggerAlert() { |
| if (navigator.vibrate) navigator.vibrate([200, 100, 200]); |
| playBeep(); |
| } |
|
|
| function playBeep() { |
| try { |
| const ctx = new (window.AudioContext || window.webkitAudioContext)(); |
| const osc = ctx.createOscillator(); |
| const gain = ctx.createGain(); |
| osc.connect(gain); |
| gain.connect(ctx.destination); |
| osc.frequency.value = 880; |
| osc.type = "sine"; |
| gain.gain.setValueAtTime(0.0001, ctx.currentTime); |
| gain.gain.exponentialRampToValueAtTime(0.3, ctx.currentTime + 0.02); |
| gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.35); |
| osc.start(); |
| osc.stop(ctx.currentTime + 0.4); |
| } catch (e) {} |
| } |
|
|
| function renderPicker() { |
| document.getElementById("light-picker").hidden = false; |
| document.getElementById("operator-view").hidden = true; |
| const btns = document.getElementById("light-buttons"); |
| btns.innerHTML = ""; |
| const n = state.num_lights || 8; |
| for (let i = 1; i <= n; i++) { |
| const a = document.createElement("a"); |
| a.className = "light-btn"; |
| a.href = `/operator/${i}`; |
| a.textContent = i; |
| btns.appendChild(a); |
| } |
| } |
|
|
| document.addEventListener("DOMContentLoaded", () => { |
| const doneBtn = document.getElementById("btn-done"); |
| if (doneBtn) { |
| doneBtn.addEventListener("click", () => { |
| const action = document.getElementById("state-action"); |
| const standby = document.getElementById("state-standby"); |
| action.hidden = true; |
| standby.hidden = false; |
| renderStandby(); |
| }); |
| } |
|
|
| document.body.addEventListener("touchstart", primeAudio, { once: true }); |
| document.body.addEventListener("click", primeAudio, { once: true }); |
|
|
| connectWS(); |
| }); |
|
|
| function primeAudio() { |
| try { |
| const ctx = new (window.AudioContext || window.webkitAudioContext)(); |
| const osc = ctx.createOscillator(); |
| const gain = ctx.createGain(); |
| osc.connect(gain); |
| gain.connect(ctx.destination); |
| gain.gain.value = 0; |
| osc.start(); |
| osc.stop(ctx.currentTime + 0.01); |
| } catch (e) {} |
| } |
|
|