237 lines
9.4 KiB
JavaScript
237 lines
9.4 KiB
JavaScript
// visuals.jsx - reusable visual elements: thumbnails, waveforms, sparklines, filmstrips
|
|
|
|
const { thumbGrad } = window.ZAMPP_DATA;
|
|
|
|
// AssetThumb — renders a placeholder thumbnail for an asset.
|
|
// For video: gradient background + scan lines + framed window + tiny "preview" content
|
|
// For audio: waveform
|
|
function AssetThumb({ asset, size = "md" }) {
|
|
const aspect = size === "tall" ? "9 / 16" : "16 / 9";
|
|
const seed = asset.seed || 1;
|
|
|
|
if (asset.type === "audio") {
|
|
return (
|
|
<div className="asset-thumb audio" style={{ aspectRatio: aspect }}>
|
|
<Waveform seed={seed} />
|
|
<div className="thumb-overlay">
|
|
<Icon name="audio" size={20} style={{ opacity: 0.9 }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="asset-thumb" style={{ background: thumbGrad(seed), aspectRatio: aspect }}>
|
|
<FauxFrame seed={seed} />
|
|
<div className="scanlines" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// FauxFrame - draws a stylized video frame using shapes/SVG
|
|
function FauxFrame({ seed }) {
|
|
// pick a "shot" type by seed
|
|
const shots = ["stage", "track", "interview", "aerial", "crowd", "trophy"];
|
|
const shot = shots[seed % shots.length];
|
|
|
|
return (
|
|
<svg viewBox="0 0 160 90" preserveAspectRatio="xMidYMid slice" className="thumb-svg">
|
|
<defs>
|
|
<linearGradient id={`sky-${seed}`} x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0" stopColor="hsl(220 30% 18%)" />
|
|
<stop offset="1" stopColor="hsl(280 25% 8%)" />
|
|
</linearGradient>
|
|
<linearGradient id={`ground-${seed}`} x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0" stopColor="hsl(220 15% 12%)" />
|
|
<stop offset="1" stopColor="hsl(220 15% 6%)" />
|
|
</linearGradient>
|
|
</defs>
|
|
{shot === "stage" && (
|
|
<>
|
|
<rect width="160" height="55" fill={`url(#sky-${seed})`} />
|
|
<rect y="55" width="160" height="35" fill={`url(#ground-${seed})`} />
|
|
<circle cx="50" cy="30" r="14" fill="hsl(40 60% 50% / 0.6)" />
|
|
<rect x="20" y="45" width="120" height="20" fill="hsl(220 30% 8% / 0.6)" />
|
|
<rect x="40" y="55" width="6" height="20" fill="hsl(220 20% 40%)" />
|
|
<rect x="55" y="50" width="6" height="25" fill="hsl(220 20% 40%)" />
|
|
<rect x="100" y="52" width="6" height="23" fill="hsl(220 20% 40%)" />
|
|
<rect x="115" y="58" width="6" height="17" fill="hsl(220 20% 40%)" />
|
|
</>
|
|
)}
|
|
{shot === "track" && (
|
|
<>
|
|
<rect width="160" height="50" fill={`url(#sky-${seed})`} />
|
|
<rect y="50" width="160" height="40" fill="hsl(20 15% 12%)" />
|
|
<path d="M0 60 L160 50 L160 90 L0 80 Z" fill="hsl(20 25% 18%)" />
|
|
<path d="M0 70 L160 60" stroke="hsl(40 80% 60% / 0.5)" strokeWidth="0.5" strokeDasharray="4 2" />
|
|
<rect x="60" y="48" width="16" height="10" rx="2" fill="hsl(0 70% 45%)" />
|
|
<rect x="62" y="50" width="12" height="3" fill="hsl(0 0% 80%)" />
|
|
<circle cx="63" cy="59" r="2" fill="hsl(0 0% 8%)" />
|
|
<circle cx="73" cy="59" r="2" fill="hsl(0 0% 8%)" />
|
|
</>
|
|
)}
|
|
{shot === "interview" && (
|
|
<>
|
|
<rect width="160" height="90" fill="hsl(220 20% 14%)" />
|
|
<rect x="0" y="55" width="160" height="35" fill="hsl(220 20% 8%)" />
|
|
<ellipse cx="80" cy="40" rx="14" ry="16" fill="hsl(30 30% 35%)" />
|
|
<ellipse cx="80" cy="32" rx="8" ry="9" fill="hsl(30 40% 55%)" />
|
|
<rect x="68" y="55" width="24" height="35" fill="hsl(220 25% 22%)" />
|
|
<circle cx="80" cy="70" r="2" fill="hsl(0 80% 50%)" />
|
|
</>
|
|
)}
|
|
{shot === "aerial" && (
|
|
<>
|
|
<rect width="160" height="90" fill="hsl(200 30% 20%)" />
|
|
<path d="M0 50 Q40 40 80 55 T160 50 L160 90 L0 90 Z" fill="hsl(140 20% 25%)" />
|
|
<path d="M0 65 Q40 55 80 70 T160 65 L160 90 L0 90 Z" fill="hsl(140 15% 18%)" />
|
|
<circle cx="120" cy="20" r="8" fill="hsl(60 30% 60% / 0.4)" />
|
|
<rect x="40" y="60" width="3" height="2" fill="white" />
|
|
<rect x="80" y="55" width="3" height="2" fill="white" />
|
|
<rect x="110" y="62" width="3" height="2" fill="white" />
|
|
</>
|
|
)}
|
|
{shot === "crowd" && (
|
|
<>
|
|
<rect width="160" height="60" fill={`url(#sky-${seed})`} />
|
|
<rect y="60" width="160" height="30" fill="hsl(220 15% 10%)" />
|
|
{Array.from({ length: 40 }).map((_, i) => {
|
|
const x = (i * 4.5) % 160;
|
|
const y = 50 + Math.sin(i * 1.7) * 4;
|
|
return <circle key={i} cx={x} cy={y} r="2.5" fill={`hsl(${30 + (i * 13) % 60} 30% ${30 + (i % 3) * 8}%)`} />;
|
|
})}
|
|
</>
|
|
)}
|
|
{shot === "trophy" && (
|
|
<>
|
|
<rect width="160" height="90" fill={`url(#sky-${seed})`} />
|
|
<path d="M70 25 L90 25 L88 50 L72 50 Z" fill="hsl(45 80% 55%)" />
|
|
<rect x="74" y="50" width="12" height="6" fill="hsl(45 70% 45%)" />
|
|
<rect x="70" y="56" width="20" height="4" fill="hsl(45 60% 35%)" />
|
|
<path d="M65 35 Q60 30 65 25" fill="none" stroke="hsl(45 80% 55%)" strokeWidth="2" />
|
|
<path d="M95 35 Q100 30 95 25" fill="none" stroke="hsl(45 80% 55%)" strokeWidth="2" />
|
|
</>
|
|
)}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
// Waveform — pseudorandom but deterministic
|
|
function Waveform({ seed = 1, color = "var(--accent)", className = "" }) {
|
|
const bars = 60;
|
|
const pts = React.useMemo(() => {
|
|
return Array.from({ length: bars }).map((_, i) => {
|
|
const x = i / bars;
|
|
const n = Math.sin(i * 0.7 + seed) * 0.5 + Math.sin(i * 2.1 + seed * 1.3) * 0.3 + Math.sin(i * 4.3 + seed * 0.7) * 0.2;
|
|
return Math.max(0.1, Math.min(1, 0.5 + n * 0.5));
|
|
});
|
|
}, [seed]);
|
|
|
|
return (
|
|
<svg viewBox="0 0 60 20" preserveAspectRatio="none" className={`waveform ${className}`}>
|
|
{pts.map((p, i) => (
|
|
<rect key={i} x={i} y={10 - p * 9} width="0.6" height={p * 18} fill={color} rx="0.3" />
|
|
))}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
// Live scrolling thumbnail strip - shows recent frames coming in
|
|
function LiveStrip({ seed = 1, count = 8 }) {
|
|
const [tick, setTick] = React.useState(0);
|
|
React.useEffect(() => {
|
|
const i = setInterval(() => setTick(t => t + 1), 2000);
|
|
return () => clearInterval(i);
|
|
}, []);
|
|
return (
|
|
<div className="live-strip">
|
|
{Array.from({ length: count }).map((_, i) => (
|
|
<div
|
|
key={`${tick}-${i}`}
|
|
className="live-strip-cell"
|
|
style={{ background: thumbGrad((seed + i + tick) % 11) }}
|
|
>
|
|
<FauxFrame seed={(seed + i + tick) % 6} />
|
|
</div>
|
|
))}
|
|
<div className="live-strip-now">
|
|
<span className="live-pulse" />
|
|
NOW
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Sparkline
|
|
function Sparkline({ data, color = "var(--accent)", height = 28, fill = true }) {
|
|
const max = Math.max(...data, 1);
|
|
const min = Math.min(...data, 0);
|
|
const range = max - min || 1;
|
|
const w = 100;
|
|
const pts = data.map((d, i) => {
|
|
const x = (i / (data.length - 1)) * w;
|
|
const y = height - ((d - min) / range) * height;
|
|
return `${x},${y}`;
|
|
}).join(" ");
|
|
const area = `0,${height} ${pts} ${w},${height}`;
|
|
return (
|
|
<svg viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none" style={{ width: "100%", height, display: "block" }}>
|
|
{fill && <polygon points={area} fill={color} opacity="0.15" />}
|
|
<polyline points={pts} fill="none" stroke={color} strokeWidth="1.5" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
// Animated audio meter
|
|
function AudioMeter({ level = 0.7, peak = 0.85, vertical = false }) {
|
|
const segs = 20;
|
|
return (
|
|
<div className={`audio-meter ${vertical ? "v" : "h"}`}>
|
|
{Array.from({ length: segs }).map((_, i) => {
|
|
const v = i / segs;
|
|
const on = v < level;
|
|
const color = v < 0.6 ? "var(--success)" : v < 0.85 ? "var(--warning)" : "var(--danger)";
|
|
return <div key={i} className="audio-seg" style={{ background: on ? color : "var(--bg-3)", opacity: on ? 1 : 0.4 }} />;
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Status dot with pulse
|
|
function StatusDot({ status }) {
|
|
const map = {
|
|
online: { color: "var(--success)", pulse: false },
|
|
recording: { color: "var(--live)", pulse: true },
|
|
armed: { color: "var(--accent)", pulse: false },
|
|
idle: { color: "var(--text-4)", pulse: false },
|
|
error: { color: "var(--danger)", pulse: true },
|
|
offline: { color: "var(--text-4)", pulse: false },
|
|
processing: { color: "var(--warning)", pulse: true },
|
|
ready: { color: "var(--success)", pulse: false },
|
|
live: { color: "var(--live)", pulse: true },
|
|
queued: { color: "var(--text-3)", pulse: false },
|
|
running: { color: "var(--accent)", pulse: true },
|
|
done: { color: "var(--success)", pulse: false },
|
|
failed: { color: "var(--danger)", pulse: false },
|
|
};
|
|
const s = map[status] || { color: "var(--text-3)" };
|
|
return (
|
|
<span className={`status-dot ${s.pulse ? "pulse" : ""}`} style={{ background: s.color, boxShadow: `0 0 0 3px ${s.color}30` }} />
|
|
);
|
|
}
|
|
|
|
// Live-style ticking clock for elapsed times
|
|
function Elapsed({ seconds, live = false }) {
|
|
const [t, setT] = React.useState(seconds);
|
|
React.useEffect(() => {
|
|
if (!live) return;
|
|
const i = setInterval(() => setT(x => x + 1), 1000);
|
|
return () => clearInterval(i);
|
|
}, [live]);
|
|
const h = Math.floor(t / 3600);
|
|
const m = Math.floor((t % 3600) / 60);
|
|
const s = t % 60;
|
|
return <span className="mono">{String(h).padStart(2, "0")}:{String(m).padStart(2, "0")}:{String(s).padStart(2, "0")}</span>;
|
|
}
|
|
|
|
Object.assign(window, { AssetThumb, FauxFrame, Waveform, LiveStrip, Sparkline, AudioMeter, StatusDot, Elapsed });
|