Revert "feat: migrate frontend to Astro SSG with design system"
This reverts commit 8ad888da28.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import type { ActiveSpeaker } from "../../../entities/voice/types.js";
|
||||
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
|
||||
|
||||
interface ActiveSpeakersProps {
|
||||
speakers: ActiveSpeaker[];
|
||||
}
|
||||
|
||||
export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
|
||||
if (speakers.length === 0) {
|
||||
return <EmptyStateMascot />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{speakers.map((s) => {
|
||||
const key = s.userId ?? s.id ?? `speaker-${s.username}`;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-3 rounded-xl border border-border bg-card p-3"
|
||||
>
|
||||
<img
|
||||
src={s.avatar}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded-full object-cover ring-2 ring-primary/30"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{s.username}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${
|
||||
s.speaking ? "bg-emerald-500" : "bg-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
s.speaking ? "text-emerald-600" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{s.speaking ? "Speaking" : "Silent"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface AudioVisualizerProps {
|
||||
levels: number[];
|
||||
}
|
||||
|
||||
export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = 128 * dpr;
|
||||
canvas.style.height = "128px";
|
||||
});
|
||||
ro.observe(container);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = canvas.width / dpr;
|
||||
const height = canvas.height / dpr;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const barWidth = width / levels.length;
|
||||
const maxBarHeight = height * 0.85;
|
||||
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||
gradient.addColorStop(0, "#23a1eb");
|
||||
gradient.addColorStop(1, "#3eb0f2");
|
||||
|
||||
for (let i = 0; i < levels.length; i++) {
|
||||
const level = levels[i];
|
||||
const barHeight = Math.min(maxBarHeight, level * maxBarHeight);
|
||||
const x = i * barWidth;
|
||||
const y = height - barHeight;
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
|
||||
const radius = barWidth * 0.4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + barWidth - radius, y);
|
||||
ctx.quadraticCurveTo(x + barWidth, y, x + barWidth, y + radius);
|
||||
ctx.lineTo(x + barWidth, height);
|
||||
ctx.lineTo(x, height);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.fill();
|
||||
}
|
||||
}, [levels]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={0}
|
||||
height={0}
|
||||
className="w-full rounded-lg bg-primary/5"
|
||||
style={{ height: "128px" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// ─── Mic level meter — vertical bar showing outgoing audio RMS level ─────────
|
||||
|
||||
interface MicLevelMeterProps {
|
||||
level: number; // 0-1
|
||||
}
|
||||
|
||||
export function MicLevelMeter({ level }: MicLevelMeterProps) {
|
||||
const pct = Math.round(level * 100);
|
||||
|
||||
// Color gradient: green <-> yellow <-> red
|
||||
const hue = 120 - level * 120; // 120 (green) -> 0 (red)
|
||||
const bg = `hsl(${hue}, 80%, 45%)`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex h-6 w-24 overflow-hidden rounded-full bg-muted"
|
||||
role="meter"
|
||||
aria-valuenow={pct}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label="Microphone level"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full transition-[width,background-color] duration-75 ease-linear"
|
||||
style={{ width: `${pct}%`, backgroundColor: bg }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Music2, SkipForward, Square, Volume2, VolumeX } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button, Input } from "../../../shared/ui";
|
||||
|
||||
interface MusicSubPanelProps {
|
||||
volume: number;
|
||||
onVolumeChange: (v: number) => void;
|
||||
onQueue: (source: string) => void;
|
||||
onSkip: () => void;
|
||||
onStop: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function MusicSubPanel({
|
||||
volume,
|
||||
onVolumeChange,
|
||||
onQueue,
|
||||
onSkip,
|
||||
onStop,
|
||||
loading,
|
||||
}: MusicSubPanelProps) {
|
||||
const [source, setSource] = useState("");
|
||||
const safeVolume = Number.isFinite(volume)
|
||||
? Math.max(0, Math.min(1, volume))
|
||||
: 1;
|
||||
const [draftVolume, setDraftVolume] = useState(Math.round(safeVolume * 100));
|
||||
const [muted, setMuted] = useState(false);
|
||||
const prevVolumeRef = useRef(safeVolume);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Proper debounce: setTimeout instead of setInterval polling
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const normalized = draftVolume / 100;
|
||||
if (Math.abs(normalized - safeVolume) >= 0.001)
|
||||
onVolumeChange(normalized);
|
||||
}, 200);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [draftVolume, safeVolume, onVolumeChange]);
|
||||
|
||||
const handleMute = useCallback(() => {
|
||||
if (muted) {
|
||||
// Unmute: restore previous volume
|
||||
const restore = prevVolumeRef.current;
|
||||
setDraftVolume(Math.round(restore * 100));
|
||||
onVolumeChange(restore);
|
||||
setMuted(false);
|
||||
} else {
|
||||
// Mute: save current, set to 0
|
||||
prevVolumeRef.current = safeVolume;
|
||||
setDraftVolume(0);
|
||||
onVolumeChange(0);
|
||||
setMuted(true);
|
||||
}
|
||||
}, [muted, safeVolume, onVolumeChange]);
|
||||
|
||||
const submit = () => {
|
||||
const t = source.trim();
|
||||
if (!t) return;
|
||||
onQueue(t);
|
||||
setSource("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4 shadow-sm space-y-4">
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||
placeholder="YouTube URL, Spotify track, or search terms"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMute}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{muted ? (
|
||||
<VolumeX className="h-4 w-4" />
|
||||
) : (
|
||||
<Volume2 className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={draftVolume}
|
||||
onChange={(e) => {
|
||||
setDraftVolume(Number(e.target.value));
|
||||
if (muted) setMuted(false);
|
||||
}}
|
||||
className="h-2 w-full cursor-pointer accent-primary"
|
||||
/>
|
||||
<span className="w-10 shrink-0 text-right text-sm tabular-nums text-muted-foreground">
|
||||
{draftVolume}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={loading || !source.trim()} onClick={submit}>
|
||||
<Music2 className="mr-1.5 h-4 w-4" /> Queue
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={loading} onClick={onSkip}>
|
||||
<SkipForward className="mr-1.5 h-4 w-4" /> Skip
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={loading} onClick={onStop}>
|
||||
<Square className="mr-1.5 h-4 w-4" /> Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { MonitorUp, Music2 } from "lucide-react";
|
||||
import type { MediaItem } from "../../../entities/media/types.js";
|
||||
import { Badge } from "../../../shared/ui";
|
||||
|
||||
interface NowPlayingProps {
|
||||
current: MediaItem | null;
|
||||
queue: MediaItem[];
|
||||
}
|
||||
|
||||
export function NowPlaying({ current, queue }: NowPlayingProps) {
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
{current.mode === "screen" ? (
|
||||
<MonitorUp className="h-5 w-5" />
|
||||
) : (
|
||||
<Music2 className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{current.title}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{current.source}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={current.mode === "screen" ? "warning" : "success"}>
|
||||
{current.mode ?? "music"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{queue.length > 0 && (
|
||||
<div className="border-t border-border p-4">
|
||||
<div className="mb-2 text-sm font-medium">Queue ({queue.length})</div>
|
||||
<div className="space-y-1.5">
|
||||
{queue.map((item, i) => (
|
||||
<div
|
||||
key={`${item.source}-${i}`}
|
||||
className="flex items-center gap-3 rounded-lg border-l-2 border-l-primary border-border bg-card p-2.5 text-sm"
|
||||
>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{item.title}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{item.source}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// ─── Recordings Sub-Panel ──
|
||||
|
||||
import { Download, Mic, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { VoiceRecording } from "../../../entities/recording/types.js";
|
||||
import { deleteRecording, listRecordings } from "../../../shared/api/client";
|
||||
import { formatBytes, formatDate } from "../../../shared/lib/utils";
|
||||
import { Badge, Button, Skeleton } from "../../../shared/ui";
|
||||
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
|
||||
import { WaveformPlayer } from "./WaveformPlayer";
|
||||
|
||||
export function RecordingsSubPanel() {
|
||||
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const loadRecordings = useCallback(
|
||||
async (opts?: { signal?: AbortSignal }) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await listRecordings({ limit: 50 });
|
||||
if (!opts?.signal?.aborted) {
|
||||
setRecordings(data.items);
|
||||
setNextCursor(data.nextCursor);
|
||||
setHasMore(data.hasMore);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!opts?.signal?.aborted)
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (!opts?.signal?.aborted) setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!nextCursor || loadingMore) return;
|
||||
try {
|
||||
setLoadingMore(true);
|
||||
const data = await listRecordings({ limit: 50, cursor: nextCursor });
|
||||
setRecordings((prev) => [...prev, ...data.items]);
|
||||
setNextCursor(data.nextCursor);
|
||||
setHasMore(data.hasMore);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [nextCursor, loadingMore]);
|
||||
|
||||
useEffect(() => {
|
||||
const ab = new AbortController();
|
||||
loadRecordings({ signal: ab.signal });
|
||||
const handler = () => loadRecordings();
|
||||
window.addEventListener("voice_recording_uploaded", handler);
|
||||
return () => {
|
||||
ab.abort();
|
||||
window.removeEventListener("voice_recording_uploaded", handler);
|
||||
};
|
||||
}, [loadRecordings]);
|
||||
|
||||
const handleDelete = useCallback(async (id: string) => {
|
||||
if (!confirm("Delete this recording?")) return;
|
||||
setDeletingIds((prev) => new Set(prev).add(id));
|
||||
try {
|
||||
await deleteRecording(id);
|
||||
setRecordings((prev) => prev.filter((r) => r.id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setDeletingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-4 rounded-xl border border-border bg-card p-4"
|
||||
>
|
||||
<Skeleton className="h-10 w-10 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-64" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-destructive p-6 text-center text-sm text-destructive">
|
||||
{error}
|
||||
<div className="mt-2">
|
||||
<Button size="sm" variant="outline" onClick={() => loadRecordings()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (recordings.length === 0) {
|
||||
return <EmptyStateMascot />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{recordings.map((rec) => (
|
||||
<div key={rec.id} className="rounded-xl border border-border bg-card">
|
||||
<div className="flex items-center gap-4 p-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Mic className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{rec.filename}</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
|
||||
<span>{rec.username}</span>
|
||||
<span>·</span>
|
||||
<span>{rec.channel_name ?? rec.channel_id ?? "unknown"}</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(rec.created_at)}</span>
|
||||
<span>·</span>
|
||||
<span>{formatBytes(rec.size_bytes)}</span>
|
||||
</div>
|
||||
{rec.upload_error && (
|
||||
<div className="mt-1 text-xs text-destructive">
|
||||
{rec.upload_error}
|
||||
</div>
|
||||
)}
|
||||
{rec.transcription && (
|
||||
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground italic">
|
||||
{rec.transcription}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={deletingIds.has(rec.id)}
|
||||
onClick={() => handleDelete(rec.id)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Badge
|
||||
variant={
|
||||
rec.upload_status === "uploaded"
|
||||
? "success"
|
||||
: rec.upload_status === "failed"
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{rec.upload_status}
|
||||
</Badge>
|
||||
{rec.download_url && (
|
||||
<a
|
||||
href={rec.download_url}
|
||||
download={rec.filename}
|
||||
className="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{rec.download_url && (
|
||||
<div className="-mt-2 px-4 pb-4">
|
||||
<WaveformPlayer
|
||||
downloadUrl={rec.download_url}
|
||||
filename={rec.filename}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loadingMore}
|
||||
onClick={loadMore}
|
||||
>
|
||||
{loadingMore ? "Loading..." : "Load More"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MonitorUp, SkipForward, Square } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button, Input } from "../../../shared/ui";
|
||||
|
||||
interface ScreenSubPanelProps {
|
||||
onStart: (source: string) => void;
|
||||
onSkip: () => void;
|
||||
onStop: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function ScreenSubPanel({
|
||||
onStart,
|
||||
onSkip,
|
||||
onStop,
|
||||
loading,
|
||||
}: ScreenSubPanelProps) {
|
||||
const [source, setSource] = useState("");
|
||||
const submit = () => {
|
||||
const t = source.trim();
|
||||
if (!t) return;
|
||||
onStart(t);
|
||||
setSource("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4 shadow-sm space-y-4">
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||
placeholder="Screen share URL or local file path"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={loading || !source.trim()} onClick={submit}>
|
||||
<MonitorUp className="mr-1.5 h-4 w-4" /> Start
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={loading} onClick={onSkip}>
|
||||
<SkipForward className="mr-1.5 h-4 w-4" /> Skip
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={loading} onClick={onStop}>
|
||||
<Square className="mr-1.5 h-4 w-4" /> Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Headphones, Radio } from "lucide-react";
|
||||
import type { Channel, Guild } from "../../../entities/guild/types.js";
|
||||
import type { VoiceStatus } from "../../../entities/voice/types.js";
|
||||
import { Button, Select } from "../../../shared/ui";
|
||||
import { MicLevelMeter } from "./MicLevelMeter";
|
||||
|
||||
interface VoiceConnectionCardProps {
|
||||
guilds: Guild[];
|
||||
voiceChannels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
status: VoiceStatus;
|
||||
voiceLoading: boolean;
|
||||
isListening: boolean;
|
||||
isStreaming: boolean;
|
||||
micLevel: number;
|
||||
onGuildChange: (id: string) => void;
|
||||
onChannelChange: (id: string) => void;
|
||||
onJoin: () => void;
|
||||
onDisconnect: () => void;
|
||||
onListenToggle: () => void;
|
||||
onStreamingToggle: () => void;
|
||||
}
|
||||
|
||||
export function VoiceConnectionCard({
|
||||
guilds,
|
||||
voiceChannels,
|
||||
selectedGuild,
|
||||
selectedChannel,
|
||||
status,
|
||||
voiceLoading,
|
||||
isListening,
|
||||
isStreaming,
|
||||
micLevel,
|
||||
onGuildChange,
|
||||
onChannelChange,
|
||||
onJoin,
|
||||
onDisconnect,
|
||||
onListenToggle,
|
||||
onStreamingToggle,
|
||||
}: VoiceConnectionCardProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm">
|
||||
<div className="p-6">
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold tracking-tight">
|
||||
<Radio className="h-5 w-5 text-primary" /> Voice Bridge
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Join a Discord voice channel, listen, and transmit audio.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Guild</label>
|
||||
<Select
|
||||
value={selectedGuild}
|
||||
onChange={(e) => onGuildChange(e.target.value)}
|
||||
placeholder="Select guild"
|
||||
options={guilds.map((g) => ({ value: g.id, label: g.name }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Voice Channel
|
||||
</label>
|
||||
<Select
|
||||
value={selectedChannel}
|
||||
onChange={(e) => onChannelChange(e.target.value)}
|
||||
placeholder="Select voice channel"
|
||||
options={voiceChannels.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.name,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
disabled={!selectedGuild || !selectedChannel || voiceLoading}
|
||||
onClick={onJoin}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{status.connected ? "Reconnect" : "Join Voice"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!status.connected || voiceLoading}
|
||||
onClick={onDisconnect}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
<Button
|
||||
variant={isListening ? "secondary" : "outline"}
|
||||
onClick={onListenToggle}
|
||||
>
|
||||
<Headphones className="mr-1.5 h-4 w-4" />{" "}
|
||||
{isListening ? "Stop Listening" : "Listen"}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isStreaming ? "secondary" : "outline"}
|
||||
onClick={onStreamingToggle}
|
||||
>
|
||||
<Radio className="mr-1.5 h-4 w-4" />{" "}
|
||||
{isStreaming ? "Stop Transmit" : "Transmit"}
|
||||
</Button>
|
||||
{isStreaming && (
|
||||
<div className="flex items-center gap-2 pl-1">
|
||||
<span className="animate-pulse rounded-full bg-emerald-500 px-2 py-0.5 text-xs font-medium text-white">
|
||||
Hold Space
|
||||
</span>
|
||||
<MicLevelMeter level={micLevel} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// ─── Waveform Player — audio visualizer with seekable waveform bars ──────────
|
||||
|
||||
import { Pause, Play } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createLogger } from "../../../shared/lib/logger";
|
||||
|
||||
const logger = createLogger("waveform-player");
|
||||
|
||||
const BAR_COUNT = 64;
|
||||
const SAMPLE_RATE = 24000;
|
||||
|
||||
interface WaveformPlayerProps {
|
||||
downloadUrl: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export function WaveformPlayer({ downloadUrl, filename }: WaveformPlayerProps) {
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [peaks, setPeaks] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const sourceRef = useRef<AudioBufferSourceNode | null>(null);
|
||||
const startTimeRef = useRef(0);
|
||||
const startOffsetRef = useRef(0);
|
||||
const rafRef = useRef<number>(0);
|
||||
const decodedRef = useRef<AudioBuffer | null>(null);
|
||||
const durationRef = useRef(0);
|
||||
|
||||
// Decode audio on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const ctx = new AudioContext();
|
||||
audioContextRef.current = ctx;
|
||||
|
||||
fetch(downloadUrl)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.arrayBuffer();
|
||||
})
|
||||
.then((buf) => ctx.decodeAudioData(buf))
|
||||
.then((audioBuffer) => {
|
||||
if (cancelled) return;
|
||||
decodedRef.current = audioBuffer;
|
||||
durationRef.current = audioBuffer.duration;
|
||||
|
||||
// Compute waveform peaks
|
||||
const channel = audioBuffer.getChannelData(0);
|
||||
const samplesPerBar = Math.floor(channel.length / BAR_COUNT);
|
||||
const peakValues: number[] = [];
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
let max = 0;
|
||||
const start = i * samplesPerBar;
|
||||
const end = Math.min(start + samplesPerBar, channel.length);
|
||||
for (let j = start; j < end; j++) {
|
||||
const abs = Math.abs(channel[j]);
|
||||
if (abs > max) max = abs;
|
||||
}
|
||||
// Clamp so silent sections still show a tiny bar
|
||||
peakValues.push(Math.max(0.01, max));
|
||||
}
|
||||
setPeaks(peakValues);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.error("Failed to decode audio", { error: msg });
|
||||
setError(msg);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
ctx.close();
|
||||
};
|
||||
}, [downloadUrl]);
|
||||
|
||||
// Draw waveform on canvas whenever peaks change or while playing
|
||||
const drawWaveform = useCallback(
|
||||
(progress = 0) => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = container.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = 64 * dpr;
|
||||
canvas.style.height = "64px";
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, rect.width, 64);
|
||||
|
||||
if (peaks.length === 0) return;
|
||||
|
||||
const barWidth = rect.width / peaks.length;
|
||||
const barGap = Math.max(1, barWidth * 0.15);
|
||||
const barActualWidth = barWidth - barGap;
|
||||
const progressPixel = rect.width * progress;
|
||||
|
||||
for (let i = 0; i < peaks.length; i++) {
|
||||
const x = i * barWidth;
|
||||
const height = Math.max(2, peaks[i] * 50);
|
||||
const y = 32 - height / 2;
|
||||
|
||||
// Color: played vs unplayed
|
||||
const isPlayed = x + barWidth <= progressPixel;
|
||||
ctx.fillStyle = isPlayed ? "#23a1eb" : "#334155";
|
||||
ctx.fillRect(x, y, barActualWidth, height);
|
||||
}
|
||||
},
|
||||
[peaks],
|
||||
);
|
||||
|
||||
// Initial draw when peaks change
|
||||
useEffect(() => {
|
||||
drawWaveform();
|
||||
}, [drawWaveform]);
|
||||
|
||||
// Animation loop while playing
|
||||
useEffect(() => {
|
||||
if (!playing || !decodedRef.current) return;
|
||||
|
||||
const tick = () => {
|
||||
if (!audioContextRef.current) return;
|
||||
const elapsed =
|
||||
audioContextRef.current.currentTime - startTimeRef.current;
|
||||
const progress = (elapsed + startOffsetRef.current) / durationRef.current;
|
||||
drawWaveform(Math.min(1, Math.max(0, progress)));
|
||||
|
||||
if (progress >= 1) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, [playing, drawWaveform]);
|
||||
|
||||
const handleTogglePlay = useCallback(() => {
|
||||
const ctx = audioContextRef.current;
|
||||
const buffer = decodedRef.current;
|
||||
if (!ctx || !buffer) return;
|
||||
|
||||
if (playing) {
|
||||
// Pause
|
||||
if (sourceRef.current) {
|
||||
startOffsetRef.current += ctx.currentTime - startTimeRef.current;
|
||||
sourceRef.current.stop();
|
||||
sourceRef.current.disconnect();
|
||||
sourceRef.current = null;
|
||||
}
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resume / start
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(ctx.destination);
|
||||
source.start(0, startOffsetRef.current);
|
||||
startTimeRef.current = ctx.currentTime;
|
||||
sourceRef.current = source;
|
||||
setPlaying(true);
|
||||
|
||||
source.onended = () => {
|
||||
if (sourceRef.current === source) {
|
||||
setPlaying(false);
|
||||
sourceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [playing]);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!decodedRef.current) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const progress = Math.max(0, Math.min(1, x / rect.width));
|
||||
const offset = progress * durationRef.current;
|
||||
|
||||
const ctx = audioContextRef.current;
|
||||
if (ctx && sourceRef.current) {
|
||||
sourceRef.current.stop();
|
||||
sourceRef.current.disconnect();
|
||||
}
|
||||
|
||||
startOffsetRef.current = offset;
|
||||
startTimeRef.current = ctx?.currentTime ?? 0;
|
||||
drawWaveform(progress);
|
||||
|
||||
if (playing && ctx) {
|
||||
const buffer = decodedRef.current;
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(ctx.destination);
|
||||
source.start(0, offset);
|
||||
startTimeRef.current = ctx.currentTime;
|
||||
sourceRef.current = source;
|
||||
source.onended = () => {
|
||||
if (sourceRef.current === source) {
|
||||
setPlaying(false);
|
||||
sourceRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
[playing, drawWaveform],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <div className="h-16 w-full animate-pulse rounded-md bg-muted" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-16 w-full rounded-md bg-destructive/10 flex items-center justify-center text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (peaks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTogglePlay}
|
||||
className="shrink-0 rounded-full bg-primary p-1.5 text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{playing ? (
|
||||
<Pause className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex-1 cursor-pointer"
|
||||
onClick={handleSeek}
|
||||
role="slider"
|
||||
aria-label={`Playback seek for ${filename}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<canvas ref={canvasRef} className="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user