diff --git a/.claude/launch.json b/.claude/launch.json index 20859bb..e6f1b40 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -5,7 +5,7 @@ "name": "static-buffet-dev", "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev"], - "autoPort": true + "port": 5001 } ] } diff --git a/PRD.md b/PRD.md index cb75f2b..e2150fa 100644 --- a/PRD.md +++ b/PRD.md @@ -75,9 +75,10 @@ Ten themes, theme-specific title puns, TTS soundboards, triple-click easter eggs ### Non-goals (v2) - **Not Resolume.** No layer stacks beyond A/B + overlay, no projection mapping, no DMX. - **No user accounts, no cloud saves.** Sets are local JSON files/localStorage. (The dead passport/session stack gets deleted, not finished.) -- **No mobile performance mode.** Desktop Chromium is the performance target; Firefox/Safari get graceful fallback for browsing/preview. +- **No mobile performance mode.** Desktop Chromium is the performance target; Firefox/Safari get graceful fallback for browsing/preview. (iPad arrives later via its own shell — see §5.5.) - **No generative-AI video in the core loop.** AI is a roadmap-tier assistant (curation, set-building), never a dependency for performing. -- **No native NDI/Syphon.** Document the OBS-browser-source bridge instead. +- **No native NDI/Syphon in the browser.** The OBS-browser-source bridge covers the tab; real Syphon/Spout/NDI output arrives with the Electron shell (§5.5, Phase 1.5). +- **No full native rewrite.** Web tech end to end; platforms are shells around one portable engine (§5.5). --- @@ -123,7 +124,21 @@ One WebGL2 compositor canvas; WebGPU later as a fast path, never a requirement. - **Record my set**: `MediaRecorder` on the output stream → downloadable video. Every session becomes shareable content — this is how tools like this spread. - **OBS bridge documented**: add the app as a browser source for streaming/NDI-world interop. -### 5.5 The backend (slim to purpose) +### 5.5 Platform strategy (browser → Electron → iPad) + +**The engine is the portable core; platforms are shells around it.** The WebGL compositor, ISF effects, and beat clock are identical work in a browser tab, an Electron window, or an iPad WebView — so "go native" and "build the engine" are sequential, not competing. The one architectural requirement this imposes *now*: the engine lives in a clean module with no DOM assumptions beyond "give me a canvas." + +- **Phase 1 builds web-first.** Fastest iteration loop, and the zero-install URL stays alive — research shows URL-shareable demos are how tools like this spread (Hydra, LumaDeck). The browser version remains the free front door permanently. +- **Phase 1.5 wraps in Electron — specifically Electron, not Tauri.** Tauri uses the system WebView (WKWebView on macOS = Safari engine), which kills Web MIDI and weakens WebCodecs/`captureStream` — exactly the Chromium-first bets this PRD makes. Electron ships Chromium, so everything Phase 1 builds runs unchanged. The shell earns its existence by delivering only what a browser physically cannot: + - **Syphon/Spout/NDI output** via native Node modules — the table-stakes pro I/O feature. + - **The offline set cache, resurrected**: the Express server runs in-process with a real filesystem, so "download my whole set before the gig, play from local disk" becomes a gig-reliability feature no tab can offer. (The disk-cache/transcode services deleted in Phase 0 return here, deliberately, on a platform that supports them.) + - Multi-display fullscreen without permission prompts, MIDI without permission gates, app icon/dock identity. +- **A full native rewrite (Swift/Metal) is rejected.** It discards the entire codebase for 6–12 months of solo re-implementation before reaching parity — the Resolume-envy trap named in §4. +- **iPad comes through a third shell, not Electron** (Electron does not run on iPadOS): a Capacitor wrapper over the same engine core, App Store distributable, with a native CoreMIDI bridge plugin giving iPad better controller support than Safari alone. Sequenced after desktop, once the touch UI is worth designing on its own terms. + +One engine, three shells: browser (free front door), Electron (the pro instrument), Capacitor (the touch surface). + +### 5.6 The backend (slim to purpose) - **Hot path only**: Archive.org search proxy + streaming proxy with rate limiting. That's the product. - **Delete the Postgres/Drizzle layer.** Its only job is caching Archive.org responses — an in-memory LRU with TTL (or Vercel edge caching) does this without a database, a connection string, or migrations. @@ -176,12 +191,18 @@ The cut list, one theme, fixed layout, slim server. App does *less*, feels drama **Phase 1 — The Engine (3–5 weeks equiv.)** WebGL2 compositor; deck A as texture; ISF rack with intensity contract; feedback pass; beat clock + default routings; crossfader with deck B; quantized triggering. `captureStream` output window + set recording. *This is the moment it stops feeling like 2003.* +**Phase 1.5 — The Shell (1–2 weeks equiv.)** +Electron wrapper around the finished engine: Syphon/Spout (NDI as a follow-on) output, in-process Express with the offline set cache, one-click projector fullscreen, auto-update, signed/notarized builds. The browser version keeps shipping from the same repo — the shell adds, never replaces. Wrapping waits for the engine deliberately: a native-feeling app that still plays CSS-filtered video is lipstick on the 2003. + **Phase 2 — The Instrument (2–4 weeks equiv.)** MIDI learn + controller presets; cue points + chop; hover-scrub; set bundles; scene morphing (lerp between saved effect states over N beats — the one-knob contract makes every state lerp-able); Emergency Mix on the new engine. **Phase 3 — The Amplifiers (ongoing)** Text-to-set AI curation; vibe tagging; WebCodecs frame-accurate loops for short clips; WebGPU fast path; shot-detection chop; optional cloud restyling — each only if the core still holds 60 fps. +**Phase 4 — The Touch Surface (when desktop is stable)** +Capacitor shell for iPad over the same engine core: touch-first trigger UI, CoreMIDI bridge plugin, App Store distribution. + Each phase ends shippable. No long-lived rewrite branch — the compositor lands behind the existing player, then replaces it. --- diff --git a/client/src/App.tsx b/client/src/App.tsx index f61a7db..96416ef 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -3,11 +3,8 @@ import { queryClient } from "./lib/queryClient"; import { QueryClientProvider } from "@tanstack/react-query"; import { Toaster } from "@/components/ui/toaster"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { EasterEgg } from "@/components/EasterEgg"; -import { useEasterEgg } from "@/hooks/use-easter-egg"; import { DragDropProvider } from "@/components/DragDropProvider"; import { KeyboardShortcuts } from "@/components/KeyboardShortcuts"; -import { AsciiModeIndicator } from "@/components/AsciiModeIndicator"; import { CommandPalette } from "@/components/CommandPalette"; import { useCommandPalette } from "@/hooks/use-command-palette"; import Home from "@/pages/home"; @@ -23,7 +20,6 @@ function Router() { } function App() { - const { isEasterEggActive, closeEasterEgg } = useEasterEgg(); const { isOpen: isCommandPaletteOpen, close: closeCommandPalette } = useCommandPalette(); return ( @@ -32,9 +28,7 @@ function App() { - - diff --git a/client/src/components/ActionButtons.tsx b/client/src/components/ActionButtons.tsx index 96eafe7..0018e27 100644 --- a/client/src/components/ActionButtons.tsx +++ b/client/src/components/ActionButtons.tsx @@ -2,56 +2,28 @@ import React from 'react'; import { EmergencyMix } from '@/components/EmergencyMix'; import { LuckyDip } from '@/components/LuckyDip'; import { useStore } from '@/lib/store'; - interface ActionButtonsProps { - onLuckyDipResults?: (results: any) => void; + onLuckyDipResults?: (results: any) => void; } - export function ActionButtons({ onLuckyDipResults }: ActionButtonsProps) { - const { brandSkin } = useStore(); - - const handleLuckyDipResults = (results: any) => { - onLuckyDipResults?.(results); - }; - - const getThemeClasses = () => { - switch (brandSkin) { - case 'testcard': - return 'bg-slate-800/40 border-slate-400/30'; - case 'waffle': - return 'bg-amber-900/40 border-yellow-400/30'; - case 'ebn': - return 'bg-gray-900/60 border-lime-500/30'; - case 'ozzy': - return 'bg-red-900/40 border-red-500/30'; - case 'hogan': - return 'bg-yellow-900/40 border-yellow-400/30'; - case 'dx': - return 'bg-pink-900/40 border-pink-500/30'; - case 'maxheadroom': - return 'bg-gray-900/60 border-green-500/30'; - case 'mario': - return 'bg-red-900/40 border-yellow-400/30'; - case 'dakota': - return 'bg-gray-800/40 border-gray-400/30'; - case 'blondie': - return 'bg-amber-900/40 border-amber-400/30'; - default: - return 'bg-slate-800/40 border-slate-400/30'; - } - }; - - return ( -
+ const { brandSkin } = useStore(); + const handleLuckyDipResults = (results: any) => { + onLuckyDipResults?.(results); + }; + const getThemeClasses = () => { + { + return 'bg-gray-900/60 border-lime-500/30'; + } + }; + return (
Action Zone
-
+
- +
-
- ); -} \ No newline at end of file +
); +} diff --git a/client/src/components/AnimatedTransitions.tsx b/client/src/components/AnimatedTransitions.tsx index 3b2e2cb..09def14 100644 --- a/client/src/components/AnimatedTransitions.tsx +++ b/client/src/components/AnimatedTransitions.tsx @@ -1,254 +1,155 @@ -import React, { ReactNode } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; +import { ReactNode } from 'react'; -// Fade in/out transition -export const FadeTransition = ({ children, className = "", delay = 0 }: { - children: ReactNode; - className?: string; - delay?: number; +// CSS-only transition shims. Same API as the old framer-motion versions, +// but no animation-library dependency and no per-frame JS cost during playback. + +export const FadeTransition = ({ children, className = "", delay = 0 }: { + children: ReactNode; + className?: string; + delay?: number; }) => ( - {children} - +
); -// Slide in from direction -export const SlideTransition = ({ - children, - direction = 'up', - className = "", - delay = 0 -}: { - children: ReactNode; - direction?: 'up' | 'down' | 'left' | 'right'; - className?: string; - delay?: number; +export const SlideTransition = ({ + children, + direction = 'up', + className = "", + delay = 0 +}: { + children: ReactNode; + direction?: 'up' | 'down' | 'left' | 'right'; + className?: string; + delay?: number; }) => { - const getInitialPosition = () => { - switch (direction) { - case 'up': return { y: 20 }; - case 'down': return { y: -20 }; - case 'left': return { x: 20 }; - case 'right': return { x: -20 }; - default: return { y: 20 }; - } - }; + const slideClass = { + up: 'slide-in-from-bottom-4', + down: 'slide-in-from-top-4', + left: 'slide-in-from-right-4', + right: 'slide-in-from-left-4', + }[direction]; return ( - {children} - +
); }; -// Scale animation for buttons and interactive elements -export const ScaleTransition = ({ - children, +export const ScaleTransition = ({ + children, className = "", - hoverScale = 1.05, - tapScale = 0.95 -}: { - children: ReactNode; +}: { + children: ReactNode; className?: string; hoverScale?: number; tapScale?: number; }) => ( - +
{children} - +
); -// Panel collapse/expand animation -export const PanelTransition = ({ - children, - isCollapsed, - className = "" -}: { - children: ReactNode; - isCollapsed: boolean; - className?: string; +export const PanelTransition = ({ + children, + isCollapsed, + className = "" +}: { + children: ReactNode; + isCollapsed: boolean; + className?: string; }) => ( - - - {!isCollapsed && children} - - +
+ {!isCollapsed && children} +
); -// Stagger animation for lists/grids -export const StaggerContainer = ({ - children, +export const StaggerContainer = ({ + children, className = "", - staggerDelay = 0.05 -}: { - children: ReactNode; +}: { + children: ReactNode; className?: string; staggerDelay?: number; }) => ( - +
{children} - +
); -export const StaggerItem = ({ - children, - className = "" -}: { - children: ReactNode; - className?: string; +export const StaggerItem = ({ + children, + className = "" +}: { + children: ReactNode; + className?: string; }) => ( - +
{children} - +
); -// Floating panel animation -export const FloatingPanelTransition = ({ - children, +export const FloatingPanelTransition = ({ + children, className = "", - isDragging = false -}: { - children: ReactNode; + isDragging = false +}: { + children: ReactNode; className?: string; isDragging?: boolean; }) => ( - +
{children} - +
); -// Theme transition wrapper -export const ThemeTransition = ({ - children, +export const ThemeTransition = ({ + children, className = "", - brandSkin -}: { - children: ReactNode; + brandSkin +}: { + children: ReactNode; className?: string; brandSkin: string; }) => ( - +
{children} - +
); -// Pulse animation for active states -export const PulseTransition = ({ - children, +export const PulseTransition = ({ + children, className = "", - isActive = false -}: { - children: ReactNode; + isActive = false +}: { + children: ReactNode; className?: string; isActive?: boolean; }) => ( - +
{children} - +
); -// Loading skeleton animation -export const SkeletonTransition = ({ - width = "100%", - height = "1rem", - className = "" -}: { - width?: string; - height?: string; - className?: string; +export const SkeletonTransition = ({ + width = "100%", + height = "1rem", + className = "" +}: { + width?: string; + height?: string; + className?: string; }) => ( - -); \ No newline at end of file +); diff --git a/client/src/components/AsciiModeIndicator.tsx b/client/src/components/AsciiModeIndicator.tsx deleted file mode 100644 index 32eb0ac..0000000 --- a/client/src/components/AsciiModeIndicator.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useStore } from '@/lib/store'; -import { Terminal, X } from 'lucide-react'; -import { Button } from '@/components/ui/button'; - -export function AsciiModeIndicator() { - const { isAsciiMode, setAsciiMode } = useStore(); - - if (!isAsciiMode) return null; - - return ( -
- - ASCII TERMINAL MODE - -
- ); -} \ No newline at end of file diff --git a/client/src/components/BottomHUD.tsx b/client/src/components/BottomHUD.tsx index d2e23c4..bb6aebb 100644 --- a/client/src/components/BottomHUD.tsx +++ b/client/src/components/BottomHUD.tsx @@ -2,195 +2,143 @@ import { useStore } from '@/lib/store'; import { Monitor, Clock, Maximize2, Wifi, WifiOff, Smartphone, Router } from 'lucide-react'; import { useState, useEffect } from 'react'; import { getThemeClasses } from '@/lib/theme-utils'; - export function BottomHUD() { - const { brandSkin } = useStore(); - const [fps, setFps] = useState(30); - const [resolution, setResolution] = useState('1920x1080'); - const [currentTime, setCurrentTime] = useState(new Date()); - const [connectionType, setConnectionType] = useState('unknown'); - const [isOnline, setIsOnline] = useState(navigator.onLine); - - const themeClasses = getThemeClasses(brandSkin); - - // Real FPS monitoring - useEffect(() => { - let frameCount = 0; - let lastTime = performance.now(); - - const measureFPS = () => { - frameCount++; - const currentTime = performance.now(); - - if (currentTime >= lastTime + 1000) { - setFps(Math.round((frameCount * 1000) / (currentTime - lastTime))); - frameCount = 0; - lastTime = currentTime; - } - - requestAnimationFrame(measureFPS); + const { brandSkin } = useStore(); + const [fps, setFps] = useState(30); + const [resolution, setResolution] = useState('1920x1080'); + const [currentTime, setCurrentTime] = useState(new Date()); + const [connectionType, setConnectionType] = useState('unknown'); + const [isOnline, setIsOnline] = useState(navigator.onLine); + const themeClasses = getThemeClasses(brandSkin); + // Real FPS monitoring + useEffect(() => { + let frameCount = 0; + let lastTime = performance.now(); + const measureFPS = () => { + frameCount++; + const currentTime = performance.now(); + if (currentTime >= lastTime + 1000) { + setFps(Math.round((frameCount * 1000) / (currentTime - lastTime))); + frameCount = 0; + lastTime = currentTime; + } + requestAnimationFrame(measureFPS); + }; + requestAnimationFrame(measureFPS); + }, []); + // Real resolution monitoring + useEffect(() => { + const updateResolution = () => { + const width = window.innerWidth; + const height = window.innerHeight; + setResolution(`${width}x${height}`); + }; + // Initial resolution + updateResolution(); + // Update on window resize + window.addEventListener('resize', updateResolution); + return () => window.removeEventListener('resize', updateResolution); + }, []); + // Real connection monitoring + useEffect(() => { + const updateConnection = () => { + setIsOnline(navigator.onLine); + // Get connection info if available + const connection = (navigator as any).connection || + (navigator as any).mozConnection || + (navigator as any).webkitConnection; + if (connection) { + setConnectionType(connection.effectiveType || connection.type || 'unknown'); + } + }; + // Initial connection check + updateConnection(); + // Listen for online/offline events + window.addEventListener('online', updateConnection); + window.addEventListener('offline', updateConnection); + // Listen for connection changes if supported + const connection = (navigator as any).connection; + if (connection) { + connection.addEventListener('change', updateConnection); + } + return () => { + window.removeEventListener('online', updateConnection); + window.removeEventListener('offline', updateConnection); + if (connection) { + connection.removeEventListener('change', updateConnection); + } + }; + }, []); + // Real-time clock + useEffect(() => { + const interval = setInterval(() => { + setCurrentTime(new Date()); + }, 1000); + return () => clearInterval(interval); + }, []); + // Format time for display + const formatTime = (date: Date) => { + return date.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); }; - - requestAnimationFrame(measureFPS); - }, []); - - // Real resolution monitoring - useEffect(() => { - const updateResolution = () => { - const width = window.innerWidth; - const height = window.innerHeight; - setResolution(`${width}x${height}`); + // Get connection icon based on type and status + const getConnectionIcon = () => { + if (!isOnline) + return WifiOff; + switch (connectionType) { + case 'slow-2g': + case '2g': + case '3g': + return Smartphone; + case '4g': + case '5g': + return Smartphone; + case 'wifi': + return Wifi; + default: + return isOnline ? Wifi : WifiOff; + } }; - - // Initial resolution - updateResolution(); - - // Update on window resize - window.addEventListener('resize', updateResolution); - return () => window.removeEventListener('resize', updateResolution); - }, []); - - // Real connection monitoring - useEffect(() => { - const updateConnection = () => { - setIsOnline(navigator.onLine); - - // Get connection info if available - const connection = (navigator as any).connection || - (navigator as any).mozConnection || - (navigator as any).webkitConnection; - - if (connection) { - setConnectionType(connection.effectiveType || connection.type || 'unknown'); - } + const ConnectionIcon = getConnectionIcon(); + // Get theme-specific darker background for contrast + const getPerformanceBarBg = () => { + { + return 'bg-gray-900'; + } }; - - // Initial connection check - updateConnection(); - - // Listen for online/offline events - window.addEventListener('online', updateConnection); - window.addEventListener('offline', updateConnection); - - // Listen for connection changes if supported - const connection = (navigator as any).connection; - if (connection) { - connection.addEventListener('change', updateConnection); - } - - return () => { - window.removeEventListener('online', updateConnection); - window.removeEventListener('offline', updateConnection); - if (connection) { - connection.removeEventListener('change', updateConnection); - } - }; - }, []); - - // Real-time clock - useEffect(() => { - const interval = setInterval(() => { - setCurrentTime(new Date()); - }, 1000); - - return () => clearInterval(interval); - }, []); - - // Format time for display - const formatTime = (date: Date) => { - return date.toLocaleTimeString('en-US', { - hour12: false, - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }); - }; - - // Get connection icon based on type and status - const getConnectionIcon = () => { - if (!isOnline) return WifiOff; - - switch (connectionType) { - case 'slow-2g': - case '2g': - case '3g': - return Smartphone; - case '4g': - case '5g': - return Smartphone; - case 'wifi': - return Wifi; - default: - return isOnline ? Wifi : WifiOff; - } - }; - - const ConnectionIcon = getConnectionIcon(); - - // Get theme-specific darker background for contrast - const getPerformanceBarBg = () => { - switch (brandSkin) { - case 'ebn': - return 'bg-gray-900'; - case 'waffle': - return 'bg-yellow-900'; - case 'ozzy': - return 'bg-red-900'; - case 'hogan': - return 'bg-gray-900'; - case 'dx': - return 'bg-gray-900'; - case 'mario': - return 'bg-red-900'; - case 'dakota': - return 'bg-gray-900'; - case 'blondie': - return 'bg-amber-900'; - case 'testcard': - return 'bg-slate-900'; - default: - return 'bg-gray-900'; - } - }; - - return ( -
+ return (
{/* Scanlines overlay for EBN theme only */} - {brandSkin === 'ebn' && ( -
-
+
-
- )} + }}/> +
)} {/* Performance indicators */}
{/* FPS */}
- + {fps} FPS
{/* Divider */} -
+
{/* Resolution */}
- + {resolution}
@@ -199,21 +147,20 @@ export function BottomHUD() {
{/* System Clock */}
- + {formatTime(currentTime)}
{/* Divider */} -
+
{/* Connection */}
- +
-
- ); +
); } diff --git a/client/src/components/BrandSkinToggle.tsx b/client/src/components/BrandSkinToggle.tsx deleted file mode 100644 index 349a667..0000000 --- a/client/src/components/BrandSkinToggle.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { useStore } from '@/lib/store'; -import { Button } from '@/components/ui/button'; - -export function BrandSkinToggle() { - const { brandSkin, setBrandSkin } = useStore(); - - const toggleSkin = () => { - setBrandSkin(brandSkin === 'diner' ? 'ebn' : 'diner'); - }; - - return ( - - ); -} diff --git a/client/src/components/CommandPalette.tsx b/client/src/components/CommandPalette.tsx index acf5f0b..e8518cf 100644 --- a/client/src/components/CommandPalette.tsx +++ b/client/src/components/CommandPalette.tsx @@ -8,7 +8,6 @@ import { useToast } from '@/hooks/use-toast'; import { getVideoMetadata } from '@/lib/archive-api'; import { type BrandSkin } from '@/lib/types'; -const BRAND_SKINS: BrandSkin[] = ['testcard', 'waffle', 'ebn', 'ozzy', 'hogan', 'dx', 'maxheadroom', 'mario', 'dakota', 'blondie', 'diner']; export interface CommandPaletteProps { isOpen: boolean; @@ -189,26 +188,6 @@ export function CommandPalette({ isOpen, onClose }: CommandPaletteProps) { group: 'Quick Search', }, - // Theme & UI - { - id: 'toggle-theme', - title: 'Cycle Brand Skin', - description: 'Switch to the next theme', - icon: , - keywords: ['theme', 'skin', 'brand', 'toggle', 'switch', 'cycle'], - action: () => { - const nextSkin = BRAND_SKINS[(BRAND_SKINS.indexOf(brandSkin) + 1) % BRAND_SKINS.length]; - setBrandSkin(nextSkin); - onClose(); - toast({ - title: 'Theme changed', - description: `Switched to ${nextSkin} theme` - }); - }, - group: 'Appearance', - badge: brandSkin, - }, - // Export & Tools { id: 'export-queue', @@ -248,28 +227,6 @@ export function CommandPalette({ isOpen, onClose }: CommandPaletteProps) { group: 'Export', badge: queueItems.length > 0 ? `${queueItems.length} items` : 'Empty', }, - { - id: 'cache-stats', - title: 'View Cache Statistics', - description: 'Check performance and cache hit rates', - icon: , - keywords: ['cache', 'stats', 'performance', 'monitor'], - action: async () => { - try { - const response = await fetch('/api/cache-stats'); - const stats = await response.json(); - console.log('Cache Statistics:', stats); - toast({ - title: 'Cache Statistics', - description: `Search: ${stats.searchCache?.memoryEntries || 0} in memory, Transcode: ${stats.transcode?.totalFiles || 0} files` - }); - } catch (error) { - toast({ title: 'Failed to fetch cache stats', variant: 'destructive' }); - } - onClose(); - }, - group: 'Tools', - }, { id: 'emergency-mix', title: 'Generate Emergency Mix', diff --git a/client/src/components/CoreSoundboards.tsx b/client/src/components/CoreSoundboards.tsx deleted file mode 100644 index df2c75e..0000000 --- a/client/src/components/CoreSoundboards.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { Volume2 } from 'lucide-react'; -import { useStore } from '@/lib/store'; -// Simple TTS implementation -const speakText = (text: string, voiceConfig: any) => { - return new Promise((resolve) => { - if ('speechSynthesis' in window) { - const utterance = new SpeechSynthesisUtterance(text); - utterance.rate = voiceConfig.rate || 1.0; - utterance.pitch = voiceConfig.pitch || 1.0; - utterance.onend = () => resolve(); - speechSynthesis.speak(utterance); - } else { - resolve(); - } - }); -}; -import { useToast } from '@/hooks/use-toast'; - -// Consolidated soundboard data -const soundboards = { - waffle: { - name: 'Waffle House', - sounds: [ - { id: 'eggs', text: "I'll take my eggs scattered, smothered, and covered!", voice: { pitch: 1.2, rate: 0.9 } }, - { id: 'hash', text: "Hash browns all the way!", voice: { pitch: 1.1, rate: 1.0 } }, - { id: 'coffee', text: "Keep that coffee coming!", voice: { pitch: 0.9, rate: 1.1 } }, - { id: 'shift', text: "Third shift crew checking in", voice: { pitch: 1.0, rate: 0.8 } } - ] - }, - ozzy: { - name: 'Heavy Metal', - sounds: [ - { id: 'train', text: "All aboard! Crazy train!", voice: { pitch: 0.7, rate: 1.2 } }, - { id: 'darkness', text: "Hello darkness my old friend", voice: { pitch: 0.6, rate: 0.8 } }, - { id: 'metal', text: "Metal up your life!", voice: { pitch: 0.8, rate: 1.1 } }, - { id: 'rock', text: "Let's rock and roll!", voice: { pitch: 0.7, rate: 1.0 } } - ] - }, - mario: { - name: 'Mario World', - sounds: [ - { id: 'mario', text: "It's-a me, Mario!", voice: { pitch: 1.4, rate: 1.1 } }, - { id: 'jump', text: "Wahoo! Here we go!", voice: { pitch: 1.5, rate: 1.2 } }, - { id: 'power', text: "Power up! Let's-a-go!", voice: { pitch: 1.3, rate: 1.0 } }, - { id: 'coin', text: "Mamma mia! Thank you so much!", voice: { pitch: 1.4, rate: 0.9 } } - ] - }, - max: { - name: 'Max Headroom', - sounds: [ - { id: 'stutter', text: "Th-th-that's all folks!", voice: { pitch: 1.2, rate: 1.3 } }, - { id: 'digital', text: "Welcome to the digital frontier!", voice: { pitch: 1.1, rate: 1.2 } }, - { id: 'glitch', text: "System glitch detected!", voice: { pitch: 1.0, rate: 1.4 } }, - { id: 'future', text: "The future is now!", voice: { pitch: 1.3, rate: 1.1 } } - ] - } -}; - -export function CoreSoundboards() { - const { brandSkin } = useStore(); - const { toast } = useToast(); - const [activeSounds, setActiveSounds] = useState>(new Set()); - - const playSound = async (soundboard: string, sound: any) => { - const soundId = `${soundboard}-${sound.id}`; - - if (activeSounds.has(soundId)) return; - - setActiveSounds(prev => new Set([...Array.from(prev), soundId])); - - try { - await speakText(sound.text, sound.voice); - - toast({ - title: `${soundboards[soundboard as keyof typeof soundboards].name}`, - description: sound.text, - duration: 2000, - }); - } catch (error) { - console.error('TTS Error:', error); - } finally { - setTimeout(() => { - setActiveSounds(prev => { - const newSet = new Set(Array.from(prev)); - newSet.delete(soundId); - return newSet; - }); - }, 3000); - } - }; - - // Only show soundboard for current theme - const getCurrentSoundboard = () => { - switch (brandSkin) { - case 'waffle': return soundboards.waffle; - case 'ozzy': return soundboards.ozzy; - case 'mario': return soundboards.mario; - case 'maxheadroom': return soundboards.max; - default: return null; - } - }; - - const currentSoundboard = getCurrentSoundboard(); - - if (!currentSoundboard) return null; - - return ( -
-
-
- - - {currentSoundboard.name} - - Triple-click theme -
- -
- {currentSoundboard.sounds.map((sound) => { - const soundId = `${brandSkin}-${sound.id}`; - const isActive = activeSounds.has(soundId); - - return ( - - ); - })} -
-
-
- ); -} \ No newline at end of file diff --git a/client/src/components/DevicePrompt.tsx b/client/src/components/DevicePrompt.tsx deleted file mode 100644 index f6a067b..0000000 --- a/client/src/components/DevicePrompt.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Monitor, Tablet, Smartphone, X } from 'lucide-react'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { useStore } from '@/lib/store'; - -export function DevicePrompt() { - const { brandSkin } = useStore(); - const [isOpen, setIsOpen] = useState(false); - const [isMobile, setIsMobile] = useState(false); - - useEffect(() => { - // Check if user is on mobile/small screen - const checkDevice = () => { - const isMobileDevice = window.innerWidth < 768 || /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); - setIsMobile(isMobileDevice); - - // Check if user has seen this prompt before - const hasSeenPrompt = localStorage.getItem('staticBuffet-devicePromptSeen'); - - if (isMobileDevice && !hasSeenPrompt) { - setIsOpen(true); - } - }; - - checkDevice(); - window.addEventListener('resize', checkDevice); - - return () => window.removeEventListener('resize', checkDevice); - }, []); - - const handleClose = () => { - setIsOpen(false); - localStorage.setItem('staticBuffet-devicePromptSeen', 'true'); - }; - - const handleContinueAnyway = () => { - setIsOpen(false); - localStorage.setItem('staticBuffet-devicePromptSeen', 'true'); - }; - - if (!isOpen) return null; - - return ( - - - - - - Optimal Viewing Experience - - - -
-

- Static Buffet is designed for professional VJ mixing and works best on: -

- -
-
- - Laptop or Desktop (Recommended) -
-
- - Tablet in Landscape Mode -
-
- - Mobile Version Coming Soon -
-
- -

- For the best VJ experience with full panel visibility and precise controls, - we recommend using a larger screen device. -

-
- -
- - -
-
-
- ); -} \ No newline at end of file diff --git a/client/src/components/DockingGuides.tsx b/client/src/components/DockingGuides.tsx deleted file mode 100644 index 421e4bb..0000000 --- a/client/src/components/DockingGuides.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { useState, useEffect } from 'react'; -import { useStore } from '@/lib/store'; - -interface DockingGuidesProps { - isDragging: boolean; - dragPosition: { x: number; y: number } | null; -} - -export function DockingGuides({ isDragging, dragPosition }: DockingGuidesProps) { - const { brandSkin } = useStore(); - const [snapZones, setSnapZones] = useState>([]); - - useEffect(() => { - if (isDragging) { - // Calculate snap zones based on current viewport - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - const headerHeight = 120; // Approximate header height - - const zones = [ - // Left third - { x: 0, y: headerHeight, width: viewportWidth * 0.33, height: viewportHeight - headerHeight, type: 'left' }, - // Center third - { x: viewportWidth * 0.33, y: headerHeight, width: viewportWidth * 0.34, height: viewportHeight - headerHeight, type: 'center' }, - // Right third - { x: viewportWidth * 0.67, y: headerHeight, width: viewportWidth * 0.33, height: viewportHeight - headerHeight, type: 'right' }, - // Top half of center - { x: viewportWidth * 0.25, y: headerHeight, width: viewportWidth * 0.5, height: (viewportHeight - headerHeight) * 0.5, type: 'top-center' }, - // Bottom half of center - { x: viewportWidth * 0.25, y: headerHeight + (viewportHeight - headerHeight) * 0.5, width: viewportWidth * 0.5, height: (viewportHeight - headerHeight) * 0.5, type: 'bottom-center' }, - ]; - - setSnapZones(zones); - } else { - setSnapZones([]); - } - }, [isDragging]); - - const getActiveZone = () => { - if (!dragPosition || !isDragging) return null; - - return snapZones.find(zone => - dragPosition.x >= zone.x && - dragPosition.x <= zone.x + zone.width && - dragPosition.y >= zone.y && - dragPosition.y <= zone.y + zone.height - ); - }; - - const activeZone = getActiveZone(); - - const guideColor = brandSkin === 'testcard' ? 'border-blue-400' : - brandSkin === 'waffle' ? 'border-amber-400' : - brandSkin === 'ebn' ? 'border-lime-400' : - brandSkin === 'ozzy' ? 'border-red-400' : - brandSkin === 'mario' ? 'border-red-400' : - 'border-yellow-400'; - - const activeFillColor = brandSkin === 'testcard' ? 'bg-blue-400/20' : - brandSkin === 'waffle' ? 'bg-amber-400/20' : - brandSkin === 'ebn' ? 'bg-lime-400/20' : - brandSkin === 'ozzy' ? 'bg-red-400/20' : - brandSkin === 'mario' ? 'bg-red-400/20' : - 'bg-yellow-400/20'; - - if (!isDragging) return null; - - return ( -
- {snapZones.map((zone, index) => ( -
- {/* Corner indicators */} -
-
-
-
- - {/* Center indicator */} -
-
-
- - {/* Zone type label */} -
- {zone.type.toUpperCase()} -
-
- ))} - - {/* Crosshair cursor indicator */} - {dragPosition && ( -
-
-
-
-
-
- )} -
- ); -} \ No newline at end of file diff --git a/client/src/components/DonationCTA.tsx b/client/src/components/DonationCTA.tsx index aef3a41..7d55405 100644 --- a/client/src/components/DonationCTA.tsx +++ b/client/src/components/DonationCTA.tsx @@ -1,71 +1,47 @@ import React, { useState } from "react"; import { useStore } from "@/lib/store"; - const KO_FI_URL = "https://ko-fi.com/staticbuffet"; - const tips = { - waffle: [ - "Your tip = more loops on the table.", - "Help us keep the hash browns hot.", - "Every dollar buys another weird clip.", - "We work for tips and test patterns.", - "Hot coffee refills included." - ], - ebn: [ - "Boost our broadcast range.", - "Static travels farther with your help.", - "Your contribution just hijacked another channel.", - "Because dead air is boring.", - "More chaos, less silence." - ] + waffle: [ + "Your tip = more loops on the table.", + "Help us keep the hash browns hot.", + "Every dollar buys another weird clip.", + "We work for tips and test patterns.", + "Hot coffee refills included." + ], + ebn: [ + "Boost our broadcast range.", + "Static travels farther with your help.", + "Your contribution just hijacked another channel.", + "Because dead air is boring.", + "More chaos, less silence." + ] }; - -function rand(arr: string[]): string { - return arr[Math.floor(Math.random() * arr.length)]; +function rand(arr: string[]): string { + return arr[Math.floor(Math.random() * arr.length)]; } - interface DonationCTAProps { - href?: string; + href?: string; } - export default function DonationCTA({ href = KO_FI_URL }: DonationCTAProps) { - const { brandSkin } = useStore(); - const mode = brandSkin === 'waffle' ? 'waffle' : 'ebn'; - const [tip, setTip] = useState(rand(tips[mode] || tips.ebn)); - const label = mode === "waffle" ? "Feed the Buffet" : "Send a Signal"; - - const btnBase = "px-4 py-2 rounded-2xl font-semibold transition transform active:scale-[0.98] focus:outline-none focus:ring"; - const btnWaffle = "bg-yellow-300 text-black hover:bg-yellow-200 ring-yellow-400"; - const btnEbn = "bg-black text-[#FFD300] border border-[#FFD300]/60 hover:bg-zinc-900 ring-yellow-400"; - - const openKoFi = () => - window.open(href, "_blank", "noopener,noreferrer"); - - return ( -
- {/* Tooltip */} -
+ bg-zinc-900 text-[#FFD300] border border-[#FFD300]/60`} role="tooltip"> {tip}
-
- ); -} \ No newline at end of file +
); +} diff --git a/client/src/components/EDLRecorder.tsx b/client/src/components/EDLRecorder.tsx index 53af74b..55b9717 100644 --- a/client/src/components/EDLRecorder.tsx +++ b/client/src/components/EDLRecorder.tsx @@ -8,393 +8,290 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { - CircleDot, - Square, - Download, - Clock, - Scissors, - PlayCircle, - PauseCircle, - RotateCcw, - Settings, - FileText, - Timer -} from 'lucide-react'; +import { CircleDot, Square, Download, Clock, Scissors, PlayCircle, PauseCircle, RotateCcw, Settings, FileText, Timer } from 'lucide-react'; import { useStore } from '@/lib/store'; import { useToast } from '@/hooks/use-toast'; import { type EDLEvent, type EDLSession } from '@/lib/types'; - -interface EDLRecorderProps {} - +interface EDLRecorderProps { +} export function EDLRecorder({}: EDLRecorderProps) { - const { - brandSkin, - isPlaying, - queueItems, - currentQueueIndex, - videoEffects, - audioEffects - } = useStore(); - const { toast } = useToast(); - - const [isRecording, setIsRecording] = useState(false); - const [currentSession, setCurrentSession] = useState(null); - const [sessionName, setSessionName] = useState(''); - const [venue, setVenue] = useState(''); - const [description, setDescription] = useState(''); - const [events, setEvents] = useState([]); - const [eventCount, setEventCount] = useState(0); - const [recordingStartTime, setRecordingStartTime] = useState(null); - const [recordingDuration, setRecordingDuration] = useState(0); - - const lastStateRef = useRef({ - isPlaying: false, - currentQueueIndex: 0, - videoEffects: { ...videoEffects }, - audioEffects: { ...audioEffects } - }); - - const timerRef = useRef(); - - const getThemeClasses = () => { - switch (brandSkin) { - case 'testcard': - return { - accent: 'text-blue-400 border-blue-400', - button: 'bg-blue-600 hover:bg-blue-700 text-white', - recording: 'bg-red-600 hover:bg-red-700 text-white animate-pulse', - badge: 'bg-blue-100 text-blue-800' - }; - case 'waffle': - return { - accent: 'text-amber-800 border-amber-800', - button: 'bg-amber-600 hover:bg-amber-700 text-white', - recording: 'bg-red-600 hover:bg-red-700 text-white animate-pulse', - badge: 'bg-amber-100 text-amber-800' - }; - case 'ebn': - return { - accent: 'text-lime-400 border-lime-400', - button: 'bg-lime-600 hover:bg-lime-700 text-white', - recording: 'bg-red-600 hover:bg-red-700 text-white animate-pulse', - badge: 'bg-lime-100 text-lime-800' - }; - case 'ozzy': - return { - accent: 'text-red-300 border-red-300', - button: 'bg-red-600 hover:bg-red-700 text-white', - recording: 'bg-red-800 hover:bg-red-900 text-white animate-pulse', - badge: 'bg-red-100 text-red-800' - }; - case 'hogan': - return { - accent: 'text-yellow-300 border-yellow-300', - button: 'bg-yellow-600 hover:bg-yellow-700 text-white', - recording: 'bg-red-600 hover:bg-red-700 text-white animate-pulse', - badge: 'bg-yellow-100 text-yellow-800' - }; - default: - return { - accent: 'text-blue-400 border-blue-400', - button: 'bg-blue-600 hover:bg-blue-700 text-white', - recording: 'bg-red-600 hover:bg-red-700 text-white animate-pulse', - badge: 'bg-blue-100 text-blue-800' + const { brandSkin, isPlaying, queueItems, currentQueueIndex, videoEffects, audioEffects } = useStore(); + const { toast } = useToast(); + const [isRecording, setIsRecording] = useState(false); + const [currentSession, setCurrentSession] = useState(null); + const [sessionName, setSessionName] = useState(''); + const [venue, setVenue] = useState(''); + const [description, setDescription] = useState(''); + const [events, setEvents] = useState([]); + const [eventCount, setEventCount] = useState(0); + const [recordingStartTime, setRecordingStartTime] = useState(null); + const [recordingDuration, setRecordingDuration] = useState(0); + const lastStateRef = useRef({ + isPlaying: false, + currentQueueIndex: 0, + videoEffects: { ...videoEffects }, + audioEffects: { ...audioEffects } + }); + const timerRef = useRef(); + const getThemeClasses = () => { + { + return { + accent: 'text-lime-400 border-lime-400', + button: 'bg-lime-600 hover:bg-lime-700 text-white', + recording: 'bg-red-600 hover:bg-red-700 text-white animate-pulse', + badge: 'bg-lime-100 text-lime-800' + }; + } + }; + const theme = getThemeClasses(); + // Generate unique session ID + const generateSessionId = () => { + return `edl_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + }; + // Generate unique event ID + const generateEventId = () => { + return `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + }; + // Format duration for display + const formatDuration = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + return `${minutes}:${secs.toString().padStart(2, '0')}`; + }; + // Start recording session + const startRecording = () => { + if (!sessionName.trim()) { + toast({ + title: "Session Name Required", + description: "Please enter a name for your recording session.", + variant: "destructive", + }); + return; + } + const sessionId = generateSessionId(); + const startTime = new Date(); + const newSession: EDLSession = { + id: sessionId, + name: sessionName, + startTime: startTime.toISOString(), + venue: venue || undefined, + description: description || undefined, + events: [], + metadata: { + theme: brandSkin, + audioReactive: false, + totalClips: 0, + totalCuts: 0, + } }; - } - }; - - const theme = getThemeClasses(); - - // Generate unique session ID - const generateSessionId = () => { - return `edl_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - }; - - // Generate unique event ID - const generateEventId = () => { - return `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - }; - - // Format duration for display - const formatDuration = (seconds: number) => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = Math.floor(seconds % 60); - - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; - } - return `${minutes}:${secs.toString().padStart(2, '0')}`; - }; - - // Start recording session - const startRecording = () => { - if (!sessionName.trim()) { - toast({ - title: "Session Name Required", - description: "Please enter a name for your recording session.", - variant: "destructive", - }); - return; - } - - const sessionId = generateSessionId(); - const startTime = new Date(); - - const newSession: EDLSession = { - id: sessionId, - name: sessionName, - startTime: startTime.toISOString(), - venue: venue || undefined, - description: description || undefined, - events: [], - metadata: { - theme: brandSkin, - audioReactive: false, - totalClips: 0, - totalCuts: 0, - } + setCurrentSession(newSession); + setIsRecording(true); + setRecordingStartTime(startTime); + setEvents([]); + setEventCount(0); + // Start the timer + timerRef.current = setInterval(() => { + setRecordingDuration(prev => prev + 1); + }, 1000); + // Log initial state + const currentVideo = queueItems[currentQueueIndex]; + if (currentVideo) { + logEvent('play', { + clipId: currentVideo.id, + clipTitle: currentVideo.title, + timecode: '00:00:00', + trimIn: currentVideo.trimIn, + trimOut: currentVideo.trimOut, + notes: 'Session started' + }); + } + toast({ + title: "EDL Recording Started", + description: `Now recording "${sessionName}" - all cuts and effects will be logged.`, + }); }; - - setCurrentSession(newSession); - setIsRecording(true); - setRecordingStartTime(startTime); - setEvents([]); - setEventCount(0); - - // Start the timer - timerRef.current = setInterval(() => { - setRecordingDuration(prev => prev + 1); - }, 1000); - - // Log initial state - const currentVideo = queueItems[currentQueueIndex]; - if (currentVideo) { - logEvent('play', { - clipId: currentVideo.id, - clipTitle: currentVideo.title, - timecode: '00:00:00', - trimIn: currentVideo.trimIn, - trimOut: currentVideo.trimOut, - notes: 'Session started' - }); - } - - toast({ - title: "EDL Recording Started", - description: `Now recording "${sessionName}" - all cuts and effects will be logged.`, - }); - }; - - // Stop recording session - const stopRecording = () => { - if (!currentSession || !recordingStartTime) return; - - const endTime = new Date(); - const totalDuration = Math.floor((endTime.getTime() - recordingStartTime.getTime()) / 1000); - - const updatedSession: EDLSession = { - ...currentSession, - endTime: endTime.toISOString(), - totalDuration: formatDuration(totalDuration), - events: events, - metadata: { - ...currentSession.metadata, - totalClips: new Set(events.map(e => e.clipId)).size, - totalCuts: events.filter(e => e.eventType === 'cut').length, - } + // Stop recording session + const stopRecording = () => { + if (!currentSession || !recordingStartTime) + return; + const endTime = new Date(); + const totalDuration = Math.floor((endTime.getTime() - recordingStartTime.getTime()) / 1000); + const updatedSession: EDLSession = { + ...currentSession, + endTime: endTime.toISOString(), + totalDuration: formatDuration(totalDuration), + events: events, + metadata: { + ...currentSession.metadata, + totalClips: new Set(events.map(e => e.clipId)).size, + totalCuts: events.filter(e => e.eventType === 'cut').length, + } + }; + setCurrentSession(updatedSession); + setIsRecording(false); + setRecordingDuration(0); + if (timerRef.current) { + clearInterval(timerRef.current); + } + // Save to localStorage + const savedSessions = JSON.parse(localStorage.getItem('staticBuffetEDLSessions') || '[]'); + savedSessions.push(updatedSession); + localStorage.setItem('staticBuffetEDLSessions', JSON.stringify(savedSessions)); + toast({ + title: "EDL Recording Stopped", + description: `Session "${updatedSession.name}" saved with ${events.length} events.`, + }); }; - - setCurrentSession(updatedSession); - setIsRecording(false); - setRecordingDuration(0); - - if (timerRef.current) { - clearInterval(timerRef.current); - } - - // Save to localStorage - const savedSessions = JSON.parse(localStorage.getItem('staticBuffetEDLSessions') || '[]'); - savedSessions.push(updatedSession); - localStorage.setItem('staticBuffetEDLSessions', JSON.stringify(savedSessions)); - - toast({ - title: "EDL Recording Stopped", - description: `Session "${updatedSession.name}" saved with ${events.length} events.`, - }); - }; - - // Log an EDL event - const logEvent = (eventType: EDLEvent['eventType'], params: Partial) => { - if (!isRecording || !currentSession) return; - - const currentVideo = queueItems[currentQueueIndex]; - if (!currentVideo && eventType !== 'pause') return; - - const event: EDLEvent = { - id: generateEventId(), - sessionId: currentSession.id, - timestamp: new Date().toISOString(), - eventType, - clipId: params.clipId || currentVideo?.id || 'unknown', - clipTitle: params.clipTitle || currentVideo?.title || 'Unknown', - timecode: params.timecode || '00:00:00', - trimIn: params.trimIn || currentVideo?.trimIn || '00:00:00', - trimOut: params.trimOut || currentVideo?.trimOut || '00:00:00', - parameters: params.parameters, - notes: params.notes, + // Log an EDL event + const logEvent = (eventType: EDLEvent['eventType'], params: Partial) => { + if (!isRecording || !currentSession) + return; + const currentVideo = queueItems[currentQueueIndex]; + if (!currentVideo && eventType !== 'pause') + return; + const event: EDLEvent = { + id: generateEventId(), + sessionId: currentSession.id, + timestamp: new Date().toISOString(), + eventType, + clipId: params.clipId || currentVideo?.id || 'unknown', + clipTitle: params.clipTitle || currentVideo?.title || 'Unknown', + timecode: params.timecode || '00:00:00', + trimIn: params.trimIn || currentVideo?.trimIn || '00:00:00', + trimOut: params.trimOut || currentVideo?.trimOut || '00:00:00', + parameters: params.parameters, + notes: params.notes, + }; + setEvents(prev => [...prev, event]); + setEventCount(prev => prev + 1); }; - - setEvents(prev => [...prev, event]); - setEventCount(prev => prev + 1); - }; - - // Watch for state changes to log events - useEffect(() => { - const lastState = lastStateRef.current; - - // Play/Pause changes - if (isPlaying !== lastState.isPlaying) { - logEvent(isPlaying ? 'play' : 'pause', { - notes: isPlaying ? 'Playback started' : 'Playback paused' - }); - } - - // Queue index changes (cuts) - if (currentQueueIndex !== lastState.currentQueueIndex) { - const currentVideo = queueItems[currentQueueIndex]; - if (currentVideo) { - logEvent('cut', { - clipId: currentVideo.id, - clipTitle: currentVideo.title, - trimIn: currentVideo.trimIn, - trimOut: currentVideo.trimOut, - notes: `Cut to track ${currentQueueIndex + 1}` + // Watch for state changes to log events + useEffect(() => { + const lastState = lastStateRef.current; + // Play/Pause changes + if (isPlaying !== lastState.isPlaying) { + logEvent(isPlaying ? 'play' : 'pause', { + notes: isPlaying ? 'Playback started' : 'Playback paused' + }); + } + // Queue index changes (cuts) + if (currentQueueIndex !== lastState.currentQueueIndex) { + const currentVideo = queueItems[currentQueueIndex]; + if (currentVideo) { + logEvent('cut', { + clipId: currentVideo.id, + clipTitle: currentVideo.title, + trimIn: currentVideo.trimIn, + trimOut: currentVideo.trimOut, + notes: `Cut to track ${currentQueueIndex + 1}` + }); + } + } + // Update last state + lastStateRef.current = { + isPlaying, + currentQueueIndex, + videoEffects: { ...videoEffects }, + audioEffects: { ...audioEffects } + }; + }, [isPlaying, currentQueueIndex, queueItems, videoEffects, audioEffects]); + // Export EDL as file + const exportEDL = () => { + if (!currentSession) + return; + // Create detailed EDL text format + const edlText = [ + `STATIC BUFFET EDL - ${currentSession.name}`, + `Session ID: ${currentSession.id}`, + `Date: ${new Date(currentSession.startTime).toLocaleString()}`, + `Duration: ${currentSession.totalDuration || 'In Progress'}`, + `Venue: ${currentSession.venue || 'N/A'}`, + `Theme: ${currentSession.metadata?.theme || 'Unknown'}`, + `Total Events: ${events.length}`, + ``, + `EVENT LOG:`, + `========================================`, + ...events.map((event, index) => { + const time = new Date(event.timestamp).toLocaleTimeString(); + return [ + `${(index + 1).toString().padStart(3, '0')}. [${time}] ${event.eventType.toUpperCase()}`, + ` Clip: ${event.clipTitle}`, + ` Timecode: ${event.timecode} (${event.trimIn} - ${event.trimOut})`, + event.notes ? ` Notes: ${event.notes}` : '', + event.parameters ? ` Parameters: ${JSON.stringify(event.parameters)}` : '', + ``, + ].filter(Boolean).join('\n'); + }), + ``, + `END OF EDL`, + ].join('\n'); + // Download as text file + const blob = new Blob([edlText], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${currentSession.name.replace(/[^a-z0-9]/gi, '_')}_EDL.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + toast({ + title: "EDL Exported", + description: `Edit Decision List saved as ${a.download}`, }); - } - } - - // Update last state - lastStateRef.current = { - isPlaying, - currentQueueIndex, - videoEffects: { ...videoEffects }, - audioEffects: { ...audioEffects } }; - }, [isPlaying, currentQueueIndex, queueItems, videoEffects, audioEffects]); - - // Export EDL as file - const exportEDL = () => { - if (!currentSession) return; - - // Create detailed EDL text format - const edlText = [ - `STATIC BUFFET EDL - ${currentSession.name}`, - `Session ID: ${currentSession.id}`, - `Date: ${new Date(currentSession.startTime).toLocaleString()}`, - `Duration: ${currentSession.totalDuration || 'In Progress'}`, - `Venue: ${currentSession.venue || 'N/A'}`, - `Theme: ${currentSession.metadata?.theme || 'Unknown'}`, - `Total Events: ${events.length}`, - ``, - `EVENT LOG:`, - `========================================`, - ...events.map((event, index) => { - const time = new Date(event.timestamp).toLocaleTimeString(); - return [ - `${(index + 1).toString().padStart(3, '0')}. [${time}] ${event.eventType.toUpperCase()}`, - ` Clip: ${event.clipTitle}`, - ` Timecode: ${event.timecode} (${event.trimIn} - ${event.trimOut})`, - event.notes ? ` Notes: ${event.notes}` : '', - event.parameters ? ` Parameters: ${JSON.stringify(event.parameters)}` : '', - ``, - ].filter(Boolean).join('\n'); - }), - ``, - `END OF EDL`, - ].join('\n'); - - // Download as text file - const blob = new Blob([edlText], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${currentSession.name.replace(/[^a-z0-9]/gi, '_')}_EDL.txt`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - toast({ - title: "EDL Exported", - description: `Edit Decision List saved as ${a.download}`, - }); - }; - - // Export EDL as JSON - const exportEDLJSON = () => { - if (!currentSession) return; - - const jsonData = { - ...currentSession, - events: events, - exportedAt: new Date().toISOString(), - format: 'static-buffet-edl-v1' + // Export EDL as JSON + const exportEDLJSON = () => { + if (!currentSession) + return; + const jsonData = { + ...currentSession, + events: events, + exportedAt: new Date().toISOString(), + format: 'static-buffet-edl-v1' + }; + const blob = new Blob([JSON.stringify(jsonData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${currentSession.name.replace(/[^a-z0-9]/gi, '_')}_EDL.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + toast({ + title: "EDL JSON Exported", + description: `Machine-readable EDL saved as ${a.download}`, + }); }; - - const blob = new Blob([JSON.stringify(jsonData, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${currentSession.name.replace(/[^a-z0-9]/gi, '_')}_EDL.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - toast({ - title: "EDL JSON Exported", - description: `Machine-readable EDL saved as ${a.download}`, - }); - }; - - return ( -
+ return (
{/* Recording Status */}
- {isRecording ? ( - - + {isRecording ? ( + REC - + {formatDuration(recordingDuration)} - - ) : ( - - + ) : ( + Standby - - )} + )} - {eventCount > 0 && ( - + {eventCount > 0 && ( {eventCount} events - - )} + )}
- {!isRecording ? ( - + {!isRecording ? ( - @@ -405,95 +302,50 @@ export function EDLRecorder({}: EDLRecorderProps) {
- setSessionName(e.target.value)} - placeholder="e.g., Club Night, Wedding Set, Practice Session" - data-testid="input-session-name" - /> + setSessionName(e.target.value)} placeholder="e.g., Club Night, Wedding Set, Practice Session" data-testid="input-session-name"/>
- setVenue(e.target.value)} - placeholder="e.g., The Underground, Studio A" - data-testid="input-venue" - /> + setVenue(e.target.value)} placeholder="e.g., The Underground, Studio A" data-testid="input-venue"/>
-