feat(frontend): add tactical HUD header and GSAP speaking-pulse loop to voice route

This commit is contained in:
asepharyana
2026-08-25 23:23:19 +07:00
parent 2aa12b9ec5
commit 9c25e0887c
2 changed files with 78 additions and 7 deletions
@@ -29,6 +29,7 @@ import {
useVoiceListen, useVoiceListen,
useVoiceStatus, useVoiceStatus,
} from "@/hooks"; } from "@/hooks";
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
import type { Guild, VoiceStatus } from "@/lib/types"; import type { Guild, VoiceStatus } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context"; import { useWebSocket } from "@/lib/ws/context";
@@ -63,6 +64,12 @@ export function VoiceView({
const [micVol, setMicVol] = useState(100); const [micVol, setMicVol] = useState(100);
const [listenVol, setListenVol] = useState(75); const [listenVol, setListenVol] = useState(75);
const hudRef = useStaggerReveal<HTMLDivElement>(".hud-tile", {
stagger: 0.06,
y: 14,
dependencies: [status],
});
useEffect(() => { useEffect(() => {
const unsub = subscribe(ws); const unsub = subscribe(ws);
return unsub; return unsub;
@@ -124,8 +131,37 @@ export function VoiceView({
}; };
return ( return (
<div className="space-y-5"> <div ref={hudRef} className="space-y-4">
<GlassPanel> {/* Tactical HUD Header Bar */}
<div className="hud-tile flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
<div className="flex items-center gap-3">
<div className="relative flex size-3 items-center justify-center">
<span
className={`absolute inline-flex size-full rounded-full opacity-75 ${
connected ? "animate-ping bg-signal" : "bg-vermilion"
}`}
/>
<span
className={`relative inline-flex size-2 rounded-full ${
connected ? "bg-signal" : "bg-vermilion"
}`}
/>
</div>
<h1 className="font-mono text-xs font-semibold tracking-widest text-ink uppercase">
VOICE MATRIX · LIVE_LINK
</h1>
</div>
<div className="inline-flex items-center gap-1.5 rounded-sm bg-surface px-2 py-0.5 font-mono text-[11px] text-ink-soft">
<span className="text-ink-faint">LINK:</span>
<span
className={`font-bold ${connected ? "text-signal" : "text-vermilion"}`}
>
{connected ? "ESTABLISHED" : "STANDBY"}
</span>
</div>
</div>
<GlassPanel className="hud-tile">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<GuildChannelPicker <GuildChannelPicker
mode="voice" mode="voice"
@@ -238,10 +274,10 @@ export function VoiceView({
</div> </div>
</GlassPanel> </GlassPanel>
<div className="grid gap-5 lg:grid-cols-3"> <div className="grid gap-4 lg:grid-cols-3">
<GlassPanel className="lg:col-span-2"> <GlassPanel className="hud-tile lg:col-span-2">
<SectionHeader <SectionHeader
eyebrow="stage" eyebrow="stage · radar"
title="Live speakers" title="Live speakers"
action={ action={
<span className="mono text-xs text-ink-faint"> <span className="mono text-xs text-ink-faint">
@@ -283,7 +319,7 @@ export function VoiceView({
)} )}
</GlassPanel> </GlassPanel>
<GlassPanel> <GlassPanel className="hud-tile">
<SectionHeader eyebrow="links" title="Connections" /> <SectionHeader eyebrow="links" title="Connections" />
<div className="space-y-2"> <div className="space-y-2">
{(status?.connections ?? []).map((c) => ( {(status?.connections ?? []).map((c) => (
@@ -16,6 +16,10 @@ export function VoiceStage({ speakers }: { speakers: ActiveSpeaker[] }) {
const n = speakers.length; const n = speakers.length;
const speaking = speakers.filter((s) => s.speaking).length; const speaking = speakers.filter((s) => s.speaking).length;
const live = speaking > 0; const live = speaking > 0;
const speakingIds = speakers
.filter((s) => s.speaking)
.map((s) => s.userId)
.join(",");
useGSAP( useGSAP(
() => { () => {
@@ -39,6 +43,37 @@ export function VoiceStage({ speakers }: { speakers: ActiveSpeaker[] }) {
{ scope: containerRef, dependencies: [speakers.length] }, { scope: containerRef, dependencies: [speakers.length] },
); );
// Reactive pulse ring loop keyed to active speaking set — cleared on stop.
useGSAP(
() => {
if (!containerRef.current) return;
const prefersReduced = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
if (prefersReduced) return;
const ringNodes = containerRef.current.querySelectorAll(
".speaker-pulse-ring",
);
if (ringNodes.length === 0) return;
const tl = gsap.timeline({ repeat: -1, yoyo: true });
tl.to(ringNodes, {
scale: 1.18,
opacity: 0.35,
duration: 0.55,
ease: "sine.inOut",
stagger: { each: 0.08, from: "random" },
});
return () => {
tl.kill();
gsap.set(ringNodes, { clearProps: "scale,opacity" });
};
},
{ scope: containerRef, dependencies: [speakingIds] },
);
return ( return (
<div <div
ref={containerRef} ref={containerRef}
@@ -91,7 +126,7 @@ export function VoiceStage({ speakers }: { speakers: ActiveSpeaker[] }) {
ring={s.speaking} ring={s.speaking}
/> />
{s.speaking && ( {s.speaking && (
<span className="absolute -inset-1 rounded-full ring-2 ring-signal ring-offset-2 ring-offset-canvas animate-pulse-ring" /> <span className="speaker-pulse-ring absolute -inset-1 rounded-full ring-2 ring-signal ring-offset-2 ring-offset-canvas" />
)} )}
</span> </span>
<span className="font-mono max-w-[100px] truncate rounded-md border border-hairline bg-canvas-2/90 px-2 py-0.5 text-[10px] font-semibold text-ink shadow-md backdrop-blur-md"> <span className="font-mono max-w-[100px] truncate rounded-md border border-hairline bg-canvas-2/90 px-2 py-0.5 text-[10px] font-semibold text-ink shadow-md backdrop-blur-md">