diff --git a/src/client/features/shows/cue-list-view-store.ts b/src/client/features/shows/cue-list-view-store.ts index d9119ab..e7f4d60 100644 --- a/src/client/features/shows/cue-list-view-store.ts +++ b/src/client/features/shows/cue-list-view-store.ts @@ -1,6 +1,44 @@ import { createContext, useContext } from "react"; import { proxy } from "valtio"; +const PERSISTED_SETTINGS_KEY = "r2t2-cue-list-settings"; + +interface PersistedSettings { + intercomUrl: string; + isIntercomVisible: boolean; + intercomPanelHeightPx: number; + audioNotificationsEnabled: boolean; + ttsNotificationsEnabled: boolean; +} + +function loadPersistedSettings(): Partial { + try { + const raw = localStorage.getItem(PERSISTED_SETTINGS_KEY); + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return {}; + const obj = parsed as Record; + const result: Partial = {}; + if (typeof obj.intercomUrl === "string") result.intercomUrl = obj.intercomUrl; + if (typeof obj.isIntercomVisible === "boolean") result.isIntercomVisible = obj.isIntercomVisible; + if (typeof obj.intercomPanelHeightPx === "number") result.intercomPanelHeightPx = obj.intercomPanelHeightPx; + if (typeof obj.audioNotificationsEnabled === "boolean") result.audioNotificationsEnabled = obj.audioNotificationsEnabled; + if (typeof obj.ttsNotificationsEnabled === "boolean") result.ttsNotificationsEnabled = obj.ttsNotificationsEnabled; + return result; + } catch { + // ignore malformed data + } + return {}; +} + +export function savePersistedSettings(settings: PersistedSettings): void { + try { + localStorage.setItem(PERSISTED_SETTINGS_KEY, JSON.stringify(settings)); + } catch { + // ignore + } +} + export interface CueListViewState { // Track and value selection for bottom pane filtering selectedTrackId: string | null; @@ -9,13 +47,28 @@ export interface CueListViewState { // Horizontal splitter position as percentage (0-100) // 50 = 50/50 split splitterPositionPercent: number; + + // Intercom integration (persisted to localStorage) + intercomUrl: string; + isIntercomVisible: boolean; + intercomPanelHeightPx: number; + + // Audio notifications on take (persisted to localStorage) + audioNotificationsEnabled: boolean; + ttsNotificationsEnabled: boolean; } export function createCueListViewStore(): CueListViewState { + const persisted = loadPersistedSettings(); return proxy({ selectedTrackId: null, selectedTechnicalIdentifier: null, splitterPositionPercent: 50, + intercomUrl: persisted.intercomUrl ?? "", + isIntercomVisible: persisted.isIntercomVisible ?? false, + intercomPanelHeightPx: persisted.intercomPanelHeightPx ?? 300, + audioNotificationsEnabled: persisted.audioNotificationsEnabled ?? false, + ttsNotificationsEnabled: persisted.ttsNotificationsEnabled ?? false, }); } diff --git a/src/client/features/shows/cue-list-view.tsx b/src/client/features/shows/cue-list-view.tsx index 1f6a29e..9a664b4 100644 --- a/src/client/features/shows/cue-list-view.tsx +++ b/src/client/features/shows/cue-list-view.tsx @@ -5,21 +5,58 @@ import QRCode from "qrcode"; import { trpc } from "@/client/lib/trpc"; import { Button } from "@/client/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/client/components/ui/card"; +import { Input } from "@/client/components/ui/input"; import { cn, getContrastColor } from "@/client/lib/utils"; import { CueListViewStoreContext, getOrCreateCueListViewStore, destroyCueListViewStore, useCueListViewStore, + savePersistedSettings, } from "@/client/features/shows/cue-list-view-store"; import { Cue } from "@/server/db/entities/Cue"; import { Show } from "@/server/db/entities/Show"; -import { QrCodeIcon } from "lucide-react"; +import { HeadphonesIcon, QrCodeIcon, Settings2Icon, XIcon } from "lucide-react"; function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } +/** Play a short beep using the Web Audio API */ +function playBeep(frequency = 880, durationMs = 250, volume = 0.5): void { + try { + const ctx = new AudioContext(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.type = "sine"; + osc.frequency.setValueAtTime(frequency, ctx.currentTime); + gain.gain.setValueAtTime(volume, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + durationMs / 1000); + osc.start(ctx.currentTime); + osc.stop(ctx.currentTime + durationMs / 1000); + osc.onended = () => { ctx.close(); }; + } catch { + // Ignore errors if AudioContext is unavailable + } +} + +/** Speak text using the Web Speech API */ +function speak(text: string): void { + try { + if (typeof window !== "undefined" && "speechSynthesis" in window) { + const utterance = new SpeechSynthesisUtterance(text); + utterance.rate = 1.0; + utterance.pitch = 1.0; + utterance.volume = 1.0; + window.speechSynthesis.speak(utterance); + } + } catch { + // Ignore errors if speech synthesis is unavailable + } +} + interface CueListItemProps { cue: Cue; show: Show; @@ -145,12 +182,28 @@ function CueListViewContent() { const snapshot = useSnapshot(store); const utils = trpc.useUtils(); const splitterRef = useRef(null); + const intercomSplitterRef = useRef(null); + const prevCurrentCueIdRef = useRef(undefined); + const currentCueRef = useRef(undefined); const [isDraggingSplitter, setIsDraggingSplitter] = useState(false); const [nowMs, setNowMs] = useState(() => Date.now()); const [isQrModalOpen, setIsQrModalOpen] = useState(false); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [settingsIntercomUrl, setSettingsIntercomUrl] = useState(""); const [qrCodeDataUrl, setQrCodeDataUrl] = useState(""); const [qrCodeError, setQrCodeError] = useState(null); + /** Persist the current intercom/notification settings to localStorage */ + function persistCurrentSettings(): void { + savePersistedSettings({ + intercomUrl: store.intercomUrl, + isIntercomVisible: store.isIntercomVisible, + intercomPanelHeightPx: store.intercomPanelHeightPx, + audioNotificationsEnabled: store.audioNotificationsEnabled, + ttsNotificationsEnabled: store.ttsNotificationsEnabled, + }); + } + const showQuery = trpc.show.getDetail.useQuery( { showId: showId ?? "" }, { enabled: Boolean(showId) }, @@ -202,6 +255,12 @@ function CueListViewContent() { }, [snapshot.selectedTrackId, orderedCues]); const currentCue = orderedCues.find((c: any) => c.id === show?.currentCueId); + + // Keep a ref to the latest currentCue so the notification effect can access it + // without adding it to the dependency array (which would cause spurious re-runs + // on every show refetch, even when the currentCueId hasn't changed). + currentCueRef.current = currentCue; + const shareUrl = useMemo(() => { if (typeof window === "undefined") { return ""; @@ -275,6 +334,34 @@ function CueListViewContent() { }; }, []); + // Detect take (currentCueId change) and trigger audio/TTS notifications. + // Reading store.audioNotificationsEnabled and store.ttsNotificationsEnabled directly + // (not from snapshot) is intentional: valtio proxies always reflect current values so + // there is no stale-closure risk for those fields. + // currentCueRef.current is used instead of currentCue to avoid triggering this effect + // on every show refetch while keeping access to the freshest cue data. + useEffect(() => { + const currentCueId = show?.currentCueId ?? null; + + // Skip notification on the initial mount + if (prevCurrentCueIdRef.current === undefined) { + prevCurrentCueIdRef.current = currentCueId; + return; + } + + // Only notify when a new cue becomes current + if (currentCueId !== prevCurrentCueIdRef.current && currentCueId !== null) { + if (store.audioNotificationsEnabled) { + playBeep(); + } + if (store.ttsNotificationsEnabled && currentCueRef.current) { + speak(`Cue ${currentCueRef.current.cueId}`); + } + } + + prevCurrentCueIdRef.current = currentCueId; + }, [show?.currentCueId]); // eslint-disable-line react-hooks/exhaustive-deps + // Now safe to have early returns if (!showId) { return ( @@ -356,6 +443,56 @@ function CueListViewContent() { window.addEventListener("pointerup", handlePointerUp); } + function handleIntercomSplitterPointerDown() { + const startY = { value: 0 }; + + function handlePointerMove(event: PointerEvent) { + if (startY.value === 0) { + startY.value = event.clientY; + } + const delta = startY.value - event.clientY; + startY.value = event.clientY; + const minHeight = 150; + const maxHeight = window.innerHeight * 0.8; + store.intercomPanelHeightPx = clamp(store.intercomPanelHeightPx + delta, minHeight, maxHeight); + } + + function handlePointerUp() { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + persistCurrentSettings(); + } + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + } + + function handleToggleIntercom() { + store.isIntercomVisible = !store.isIntercomVisible; + persistCurrentSettings(); + } + + function handleOpenSettings() { + setSettingsIntercomUrl(store.intercomUrl); + setIsSettingsOpen(true); + } + + function handleSaveSettings() { + store.intercomUrl = settingsIntercomUrl.trim(); + persistCurrentSettings(); + setIsSettingsOpen(false); + } + + function handleToggleAudioNotifications() { + store.audioNotificationsEnabled = !store.audioNotificationsEnabled; + persistCurrentSettings(); + } + + function handleToggleTtsNotifications() { + store.ttsNotificationsEnabled = !store.ttsNotificationsEnabled; + persistCurrentSettings(); + } + function handleSelectedTrackChange(selectedTrackId?: string) { store.selectedTrackId = selectedTrackId || null; store.selectedTechnicalIdentifier = null; @@ -384,6 +521,30 @@ function CueListViewContent() { return (
+ {/* Toolbar */} +
+ + +
+ + {/* Split pane area */} +
{/* Top pane */}
@@ -550,6 +711,40 @@ function CueListViewContent() { )}
+
{/* end split pane area */} + + {/* Intercom panel */} + {snapshot.isIntercomVisible && ( + <> +
+
+ {snapshot.intercomUrl ? ( +