chore: remove old components replaced by redesign
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com
This commit is contained in:
co-authored by
Claude Opus 4.8 (1M context) <noreply@anthropic.com
parent
94f8903b24
commit
106bb249f0
@@ -1,14 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { RecordingList } from "@/components/recordings/recording-list";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
import { useState } from "react";
|
||||
import { RecordingCard } from "@/components/recordings/recording-card";
|
||||
import { RecordingPlayer } from "@/components/recordings/recording-player";
|
||||
import { SubNav } from "@/components/layout/sub-nav";
|
||||
import { ErrorState, LoadingSkeleton } from "@/components/shared";
|
||||
import { useRecordings } from "@/hooks";
|
||||
import type { VoiceRecording } from "@/lib/types";
|
||||
|
||||
type RecordingsTab = "library" | "stats";
|
||||
|
||||
export default function RecordingsPage() {
|
||||
const ws = useWebSocket();
|
||||
const { data: recordings, isLoading, error, refetch } = useRecordings();
|
||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<RecordingsTab>("library");
|
||||
|
||||
const currentTrack = playingId && recordings
|
||||
? recordings.find((r: VoiceRecording) => r.id === playingId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-5 animate-fade-in-up">
|
||||
<RecordingList ws={ws} />
|
||||
<div className="space-y-4 animate-fade-in-up">
|
||||
<SubNav
|
||||
tabs={[
|
||||
{ id: "library", label: "Library", icon: undefined },
|
||||
{ id: "stats", label: "Stats", icon: undefined },
|
||||
]}
|
||||
activeTab={tab}
|
||||
onTabChange={(t) => setTab(t as RecordingsTab)}
|
||||
/>
|
||||
|
||||
{tab === "library" && (
|
||||
<>
|
||||
{error ? (
|
||||
<ErrorState message={error.message} onRetry={refetch} />
|
||||
) : isLoading ? (
|
||||
<LoadingSkeleton count={4} height="h-28" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(recordings ?? []).map((rec: VoiceRecording) => (
|
||||
<RecordingCard
|
||||
key={rec.id}
|
||||
recording={rec}
|
||||
onPlay={(id) => setPlayingId(id === playingId ? null : id)}
|
||||
/>
|
||||
))}
|
||||
{(recordings ?? []).length === 0 && (
|
||||
<div className="py-12 text-center text-sm text-text-secondary/40">No recordings yet</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "stats" && (
|
||||
<div className="py-12 text-center text-sm text-text-secondary/40">Recording stats coming soon</div>
|
||||
)}
|
||||
|
||||
<RecordingPlayer url={currentTrack?.download_url ?? undefined} onClose={() => setPlayingId(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { ActiveSpeakersPanel } from "@/components/voice/active-speakers-panel";
|
||||
import { MicrophoneCard } from "@/components/voice/microphone-card";
|
||||
import { VoiceConnectionCard } from "@/components/voice/voice-connection-card";
|
||||
import { VoiceConnectionCard } from "@/components/voice/connection-card";
|
||||
import { SpeakerWaveform } from "@/components/voice/speaker-waveform";
|
||||
import { MicControl } from "@/components/voice/mic-control";
|
||||
import { VoiceActivityTimeline } from "@/components/voice/activity-timeline";
|
||||
import { SubNav } from "@/components/layout/sub-nav";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
import {
|
||||
useGuilds,
|
||||
useMicTransmit,
|
||||
@@ -14,7 +16,8 @@ import {
|
||||
useVoiceDisconnect,
|
||||
useVoiceStatus,
|
||||
} from "@/hooks";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
type VoiceTab = "connection" | "activity";
|
||||
|
||||
export default function VoicePage() {
|
||||
const ws = useWebSocket();
|
||||
@@ -28,21 +31,14 @@ export default function VoicePage() {
|
||||
const micMut = useMicTransmit();
|
||||
const [selectedChannel, setSelectedChannel] = useState("");
|
||||
const [micActive, setMicActive] = useState(false);
|
||||
const [volume, setVolume] = useState(75);
|
||||
const [tab, setTab] = useState<VoiceTab>("connection");
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = subscribe(ws);
|
||||
return () => unsub();
|
||||
}, [ws, subscribe]);
|
||||
|
||||
const handleGuildChange = useCallback((guildId: string | null) => {
|
||||
if (!guildId) {
|
||||
setSelectedGuild("");
|
||||
setSelectedChannel("");
|
||||
return;
|
||||
}
|
||||
setSelectedGuild(guildId);
|
||||
}, []);
|
||||
|
||||
const handleMicToggle = useCallback(
|
||||
async (checked: boolean) => {
|
||||
setMicActive(checked);
|
||||
@@ -55,29 +51,57 @@ export default function VoicePage() {
|
||||
[micMut],
|
||||
);
|
||||
|
||||
const handleGuildChange = useCallback((guildId: string | null) => {
|
||||
if (!guildId) {
|
||||
setSelectedGuild("");
|
||||
setSelectedChannel("");
|
||||
return;
|
||||
}
|
||||
setSelectedGuild(guildId);
|
||||
}, []);
|
||||
|
||||
const activeSpeakers = speakers.filter((s) => s.speaking);
|
||||
const connected = voiceStatus?.connected ?? false;
|
||||
|
||||
return (
|
||||
<div className="space-y-5 animate-fade-in-up">
|
||||
<div className="space-y-4 animate-fade-in-up">
|
||||
<SubNav
|
||||
tabs={[
|
||||
{ id: "connection", label: "Connection", icon: undefined },
|
||||
{ id: "activity", label: "Activity", icon: undefined },
|
||||
]}
|
||||
activeTab={tab}
|
||||
onTabChange={(t) => setTab(t as VoiceTab)}
|
||||
/>
|
||||
|
||||
<VoiceConnectionCard
|
||||
selectedGuild={selectedGuild}
|
||||
onGuildChange={handleGuildChange}
|
||||
selectedChannel={selectedChannel}
|
||||
onChannelChange={(v) => setSelectedChannel(v)}
|
||||
guilds={guilds}
|
||||
voiceChannels={voiceChannels}
|
||||
connected={connected}
|
||||
activeChannelName={voiceStatus?.activeChannelName}
|
||||
connectMut={connectMut}
|
||||
disconnectMut={disconnectMut}
|
||||
/>
|
||||
<ActiveSpeakersPanel activeSpeakers={activeSpeakers} />
|
||||
<MicrophoneCard
|
||||
connected={connected}
|
||||
micActive={micActive}
|
||||
onMicToggle={handleMicToggle}
|
||||
guilds={guilds}
|
||||
voiceChannels={voiceChannels}
|
||||
selectedGuild={selectedGuild}
|
||||
selectedChannel={selectedChannel}
|
||||
onGuildChange={handleGuildChange}
|
||||
onChannelChange={(v) => setSelectedChannel(v ?? "")}
|
||||
onConnect={() => connectMut.mutate({ guildId: selectedGuild, channelId: selectedChannel })}
|
||||
onDisconnect={() => disconnectMut.mutate(undefined)}
|
||||
connecting={connectMut.isPending}
|
||||
/>
|
||||
|
||||
{tab === "connection" && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<SpeakerWaveform speakers={activeSpeakers} />
|
||||
<MicControl
|
||||
connected={connected}
|
||||
active={micActive}
|
||||
onToggle={handleMicToggle}
|
||||
volume={volume}
|
||||
onVolumeChange={setVolume}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "activity" && <VoiceActivityTimeline />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
--color-accent: var(--color-primary);
|
||||
--color-accent-foreground: var(--color-primary-foreground);
|
||||
|
||||
/* Ring / focus outline for shadcn outline-ring utility */
|
||||
--color-ring: var(--color-primary);
|
||||
|
||||
/* Radius */
|
||||
--radius-card: 16px;
|
||||
--radius-panel: 12px;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { GuildSelector } from "@/components/shared/guild-selector";
|
||||
|
||||
interface HiddenSidebarProps {
|
||||
guildId: string;
|
||||
onGuildChange: (guildId: string) => void;
|
||||
}
|
||||
|
||||
export function HiddenSidebar({ guildId, onGuildChange }: HiddenSidebarProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (hideTimer) clearTimeout(hideTimer);
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
hideTimer = setTimeout(() => setVisible(false), 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hotspot trigger */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: transparent mouse detection zone, not interactive content */}
|
||||
<div
|
||||
className="fixed left-0 top-0 bottom-0 w-1 z-50"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
/>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
role="region"
|
||||
aria-label="Guild selector sidebar"
|
||||
className={`fixed left-0 top-0 bottom-0 z-40 w-56 glass-intense border-r border-glass-border transition-transform duration-150 ease-out ${
|
||||
visible ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className="flex h-11 items-center gap-2 px-4 border-b border-glass-border">
|
||||
<span className="text-xs font-semibold tracking-wider uppercase text-text-secondary">
|
||||
Guilds
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3 space-y-4">
|
||||
<GuildSelector value={guildId} onChange={onGuildChange} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SubNavTab {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface SubNavProps {
|
||||
tabs: SubNavTab[];
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SubNav({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
className,
|
||||
}: SubNavProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-1 py-1 glass rounded-[var(--radius-panel)] w-fit",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-150",
|
||||
activeTab === tab.id
|
||||
? "bg-primary/20 text-text-primary shadow-[0_0_12px] shadow-primary/20"
|
||||
: "text-text-secondary/60 hover:text-text-primary/80",
|
||||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import { Send } from "lucide-react";
|
||||
import { useMascot } from "./mascot-context";
|
||||
|
||||
interface ChatPanelProps {
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
|
||||
const { messages, sendMessage, isTyping } = useMascot();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const internalInputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = externalInputRef ?? internalInputRef;
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, isTyping]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const input = inputRef.current;
|
||||
if (!input || !input.value.trim()) return;
|
||||
sendMessage(input.value);
|
||||
input.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Chat messages */}
|
||||
<div ref={listRef} className="flex-1 overflow-y-auto px-2 py-1 space-y-1">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-[10px] text-text-secondary/40">Ask mascot anything</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.slice(-8).map((msg, i) => (
|
||||
<div
|
||||
key={`${msg.timestamp}-${i}`}
|
||||
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<span
|
||||
className={`text-[10px] px-2 py-1 rounded-lg max-w-[85%] leading-relaxed ${
|
||||
msg.role === "user"
|
||||
? "bg-primary/20 text-text-primary"
|
||||
: "glass text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{isTyping && (
|
||||
<div className="flex justify-start">
|
||||
<div className="glass rounded-lg px-2 py-1">
|
||||
<span className="inline-flex gap-0.5">
|
||||
<span className="size-1 rounded-full bg-text-secondary animate-bounce" style={{ animationDelay: "0ms" }} />
|
||||
<span className="size-1 rounded-full bg-text-secondary animate-bounce" style={{ animationDelay: "150ms" }} />
|
||||
<span className="size-1 rounded-full bg-text-secondary animate-bounce" style={{ animationDelay: "300ms" }} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input bar */}
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-1 px-2 py-1.5 border-t border-glass-border shrink-0">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Ask mascot..."
|
||||
className="flex-1 bg-transparent text-[10px] text-text-primary placeholder-text-secondary/30 outline-none"
|
||||
disabled={isTyping}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="size-5 flex items-center justify-center disabled:opacity-40"
|
||||
disabled={isTyping}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Send className="size-3 text-primary" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { MascotProvider, useMascot } from "./mascot-context";
|
||||
export { MascotContainer } from "./mascot-container";
|
||||
export { MascotCanvas } from "./mascot-canvas";
|
||||
export { ChatPanel } from "./chat-panel";
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useMascot } from "./mascot-context";
|
||||
|
||||
/**
|
||||
* Live2D Cubism WebGL canvas.
|
||||
*
|
||||
* This component renders the Live2D model via the Cubism SDK.
|
||||
* Integration requires:
|
||||
* 1. Live2D Cubism SDK for Web (npm: @live2d/cubism)
|
||||
* 2. Model files: .model3.json, .moc3, .physics3.json, textures
|
||||
* 3. Place model files in public/mascot/
|
||||
*
|
||||
* The current implementation shows a placeholder character.
|
||||
* Replace with actual Cubism SDK integration when model files are available.
|
||||
*/
|
||||
|
||||
export function MascotCanvas() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const { expression } = useMascot();
|
||||
|
||||
// Placeholder: draw a simple avatar face that responds to expression
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Background circle
|
||||
const gradient = ctx.createRadialGradient(w / 2, h / 2 - 10, 10, w / 2, h / 2, 80);
|
||||
gradient.addColorStop(0, "oklch(0.62 0.17 215 / 0.8)");
|
||||
gradient.addColorStop(0.6, "oklch(0.12 0.02 245 / 0.9)");
|
||||
gradient.addColorStop(1, "oklch(0.07 0.015 250 / 1)");
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(w / 2, h / 2, 75, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Eyes
|
||||
const eyeOffsetX = 20;
|
||||
const eyeY = 45;
|
||||
|
||||
// Expression-driven eyes
|
||||
if (expression === "surprise") {
|
||||
// Wide eyes
|
||||
ctx.fillStyle = "oklch(0.93 0.01 245)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(w / 2 - eyeOffsetX, eyeY, 12, 14, 0, 0, Math.PI * 2);
|
||||
ctx.ellipse(w / 2 + eyeOffsetX, eyeY, 12, 14, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "oklch(0.62 0.17 215)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(w / 2 - eyeOffsetX, eyeY, 5, 0, Math.PI * 2);
|
||||
ctx.arc(w / 2 + eyeOffsetX, eyeY, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (expression === "happy") {
|
||||
// Happy closed crescent eyes
|
||||
ctx.strokeStyle = "oklch(0.93 0.01 245)";
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.arc(w / 2 - eyeOffsetX, eyeY, 10, Math.PI * 0.1, Math.PI * 0.9);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.arc(w / 2 + eyeOffsetX, eyeY, 10, Math.PI * 0.1, Math.PI * 0.9);
|
||||
ctx.stroke();
|
||||
} else if (expression === "sad") {
|
||||
// Sad downcast eyes
|
||||
ctx.fillStyle = "oklch(0.93 0.01 245)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(w / 2 - eyeOffsetX, eyeY, 8, 6, 0.2, 0, Math.PI * 2);
|
||||
ctx.ellipse(w / 2 + eyeOffsetX, eyeY, 8, 6, -0.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else {
|
||||
// Normal eyes
|
||||
ctx.fillStyle = "oklch(0.93 0.01 245)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(w / 2 - eyeOffsetX, eyeY, 10, 8, 0, 0, Math.PI * 2);
|
||||
ctx.ellipse(w / 2 + eyeOffsetX, eyeY, 10, 8, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "oklch(0.62 0.17 215)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(w / 2 - eyeOffsetX, eyeY, 4, 0, Math.PI * 2);
|
||||
ctx.arc(w / 2 + eyeOffsetX, eyeY, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Mouth
|
||||
ctx.strokeStyle = "oklch(0.93 0.01 245 / 0.7)";
|
||||
ctx.lineWidth = 2;
|
||||
if (expression === "talking") {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(w / 2, 70, 8, 6, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
} else if (expression === "happy") {
|
||||
ctx.beginPath();
|
||||
ctx.arc(w / 2, 70, 10, 0.1, Math.PI - 0.1);
|
||||
ctx.stroke();
|
||||
} else if (expression === "surprise") {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(w / 2, 70, 6, 8, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = "oklch(0.12 0.02 245)";
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.arc(w / 2, 75, 6, 0.1, Math.PI - 0.1);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Breathing animation — subtle canvas shift
|
||||
const breath = Math.sin(Date.now() / 1000) * 1.5;
|
||||
// Applied via CSS transform on container instead
|
||||
|
||||
}, [expression]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={160}
|
||||
height={180}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback, useEffect } from "react";
|
||||
import { Bot, MessageCircle, Minimize2 } from "lucide-react";
|
||||
import { useMascot } from "./mascot-context";
|
||||
import { MascotCanvas } from "./mascot-canvas";
|
||||
import { ChatPanel } from "./chat-panel";
|
||||
|
||||
export function MascotContainer() {
|
||||
const { minimized, setMinimized, chatOpen, setChatOpen } = useMascot();
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
setDragging(true);
|
||||
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
|
||||
}, [position]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!dragging) return;
|
||||
setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
|
||||
}, [dragging, dragStart]);
|
||||
|
||||
const handleMouseUp = useCallback(() => setDragging(false), []);
|
||||
|
||||
// Focus input when chat opens
|
||||
useEffect(() => {
|
||||
if (chatOpen) {
|
||||
// Small delay for the animation
|
||||
const id = setTimeout(() => inputRef.current?.focus(), 150);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
}, [chatOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-4 right-4 z-40 select-none"
|
||||
style={{ transform: `translate(${position.x}px, ${position.y}px)` }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
{/* Main mascot bubble */}
|
||||
<div
|
||||
className={`glass-intense rounded-2xl overflow-hidden transition-all duration-200 ${
|
||||
minimized ? "w-14 h-14 cursor-pointer" : "w-[220px]"
|
||||
}`}
|
||||
style={{ height: minimized ? 56 : 320 }}
|
||||
>
|
||||
{minimized ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMinimized(false)}
|
||||
className="w-full h-full flex items-center justify-center"
|
||||
onMouseDown={handleMouseDown}
|
||||
aria-label="Open mascot"
|
||||
>
|
||||
<Bot className="size-6 text-primary" />
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{/* Drag handle + controls */}
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-1.5 border-b border-glass-border cursor-grab active:cursor-grabbing"
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<span className="text-[10px] font-semibold text-text-secondary tracking-wide uppercase">
|
||||
Mascot
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChatOpen(!chatOpen)}
|
||||
className="size-5 flex items-center justify-center rounded hover:bg-glass-bg transition-colors"
|
||||
aria-label={chatOpen ? "Close chat" : "Open chat"}
|
||||
>
|
||||
<MessageCircle className="size-3 text-text-secondary/60 hover:text-text-primary" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMinimized(true)}
|
||||
className="size-5 flex items-center justify-center rounded hover:bg-glass-bg transition-colors"
|
||||
aria-label="Minimize mascot"
|
||||
>
|
||||
{minimized ? (
|
||||
<Bot className="size-3 text-text-secondary/60 hover:text-text-primary" />
|
||||
) : (
|
||||
<Minimize2 className="size-3 text-text-secondary/60 hover:text-text-primary" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas area */}
|
||||
<div className="h-[140px] flex items-center justify-center">
|
||||
<MascotCanvas />
|
||||
</div>
|
||||
|
||||
{/* Chat panel (expandable) */}
|
||||
<div
|
||||
className={`transition-all duration-200 overflow-hidden ${
|
||||
chatOpen ? "h-[130px]" : "h-0"
|
||||
}`}
|
||||
>
|
||||
<ChatPanel inputRef={inputRef} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { chatbotApi } from "@/lib/api";
|
||||
import type { ChatHistoryMessage } from "@/lib/types";
|
||||
|
||||
export type MascotExpression = "idle" | "listening" | "surprise" | "happy" | "sad" | "talking";
|
||||
|
||||
interface MascotMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface MascotContextValue {
|
||||
/** Expression the mascot avatar should display */
|
||||
expression: MascotExpression;
|
||||
setExpression: (expr: MascotExpression) => void;
|
||||
|
||||
/** Whether the enlarged bubble is minimized to a small icon */
|
||||
minimized: boolean;
|
||||
setMinimized: (v: boolean) => void;
|
||||
|
||||
/** Whether the chat panel inside the bubble is open */
|
||||
chatOpen: boolean;
|
||||
setChatOpen: (v: boolean) => void;
|
||||
|
||||
/**
|
||||
* @deprecated Use `minimized` / `setMinimized` instead.
|
||||
* Legacy toggle alias kept for compatibility.
|
||||
*/
|
||||
isOpen: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
toggle: () => void;
|
||||
|
||||
/** Chat messages with real API backend */
|
||||
messages: MascotMessage[];
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
clearMessages: () => Promise<void>;
|
||||
isTyping: boolean;
|
||||
}
|
||||
|
||||
const MascotContext = createContext<MascotContextValue | null>(null);
|
||||
|
||||
export function MascotProvider({ children }: { children: ReactNode }) {
|
||||
const [expression, setExpression] = useState<MascotExpression>("idle");
|
||||
const [minimized, setMinimized] = useState(true);
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<MascotMessage[]>([]);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const historyFetched = useRef(false);
|
||||
|
||||
// Derived legacy state
|
||||
const isOpen = !minimized;
|
||||
|
||||
const setOpen = useCallback((open: boolean) => {
|
||||
setMinimized(!open);
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setMinimized((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Load chat history on first mount
|
||||
useEffect(() => {
|
||||
if (historyFetched.current) return;
|
||||
historyFetched.current = true;
|
||||
|
||||
chatbotApi.getHistory().then((history) => {
|
||||
const mapped = (history ?? []).map((msg: ChatHistoryMessage) => ({
|
||||
role: msg.role as "user" | "assistant",
|
||||
content: msg.content,
|
||||
timestamp: msg.timestamp,
|
||||
}));
|
||||
setMessages(mapped);
|
||||
}).catch(() => {
|
||||
// API may not be available yet — silently ignore
|
||||
});
|
||||
}, []);
|
||||
|
||||
const sendMessage = useCallback(async (content: string) => {
|
||||
if (!content.trim()) return;
|
||||
|
||||
const userMsg: MascotMessage = {
|
||||
role: "user",
|
||||
content: content.trim(),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setExpression("listening");
|
||||
setIsTyping(true);
|
||||
|
||||
try {
|
||||
const res = await chatbotApi.send(content.trim());
|
||||
const botMsg: MascotMessage = {
|
||||
role: "assistant",
|
||||
content: res.response,
|
||||
timestamp: res.timestamp ?? new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, botMsg]);
|
||||
setExpression("happy");
|
||||
} catch {
|
||||
const errorMsg: MascotMessage = {
|
||||
role: "assistant",
|
||||
content: "Sorry, I couldn't process that request. Please try again.",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMsg]);
|
||||
setExpression("sad");
|
||||
} finally {
|
||||
setIsTyping(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearMessages = useCallback(async () => {
|
||||
try {
|
||||
await chatbotApi.clearHistory();
|
||||
} catch {
|
||||
// Best-effort clear
|
||||
}
|
||||
setMessages([]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MascotContext.Provider
|
||||
value={{
|
||||
expression,
|
||||
setExpression,
|
||||
minimized,
|
||||
setMinimized,
|
||||
chatOpen,
|
||||
setChatOpen,
|
||||
isOpen,
|
||||
setOpen,
|
||||
toggle,
|
||||
messages,
|
||||
sendMessage,
|
||||
clearMessages,
|
||||
isTyping,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MascotContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMascot(): MascotContextValue {
|
||||
const ctx = useContext(MascotContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useMascot must be used within a MascotProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { Disc3, Music, Play, SkipForward, Square, Volume2 } from "lucide-react";
|
||||
import { useMediaPlayer } from "@/lib/hooks/use-media-player";
|
||||
|
||||
export function MiniPlayer() {
|
||||
const { playing, current, queue, volume, pending, skip, stop, setVolume } =
|
||||
useMediaPlayer();
|
||||
|
||||
// Nothing to show if no track is playing and nothing is queued
|
||||
if (!current && queue.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-40 h-14 glass-intense border-t border-glass-border flex items-center gap-3 px-4 md:px-6">
|
||||
{/* Track info */}
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1 max-w-[280px]">
|
||||
<div className="size-8 rounded-md bg-gradient-to-br from-primary/20 to-primary/5 border border-primary/10 flex items-center justify-center shrink-0">
|
||||
{playing ? (
|
||||
<Disc3 className="size-4 text-primary animate-spin" style={{ animationDuration: "4s" }} />
|
||||
) : (
|
||||
<Music className="size-4 text-text-secondary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-text-primary truncate">
|
||||
{current?.title ?? "Unknown track"}
|
||||
</p>
|
||||
{queue.length > 0 && (
|
||||
<p className="text-[10px] text-text-secondary/60">
|
||||
{queue.length > 1
|
||||
? `${queue.length} in queue`
|
||||
: "1 in queue"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{playing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={stop}
|
||||
disabled={pending}
|
||||
className="size-8 flex items-center justify-center rounded-md text-text-secondary hover:text-destructive hover:bg-glass-bg transition-colors disabled:opacity-40"
|
||||
aria-label="Stop"
|
||||
>
|
||||
<Square className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{current && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={skip}
|
||||
disabled={pending || queue.length === 0}
|
||||
className="size-8 flex items-center justify-center rounded-md text-text-secondary hover:text-text-primary hover:bg-glass-bg transition-colors disabled:opacity-40"
|
||||
aria-label="Skip"
|
||||
>
|
||||
<SkipForward className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||
<Volume2 className="size-3.5 text-text-secondary/60" />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(Number(e.target.value))}
|
||||
className="w-20 h-1 appearance-none rounded-full bg-glass-bg accent-primary cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { Download, Play } from "lucide-react";
|
||||
import { GlassCard } from "@/components/glass/card";
|
||||
import type { VoiceRecording } from "@/lib/types";
|
||||
|
||||
interface RecordingCardProps {
|
||||
recording: VoiceRecording;
|
||||
onPlay: (id: string) => void;
|
||||
}
|
||||
|
||||
export function RecordingCard({ recording, onPlay }: RecordingCardProps) {
|
||||
const durationStr = recording.duration_bytes
|
||||
? `${Math.floor(recording.duration_bytes / 60)}:${String(recording.duration_bytes % 60).padStart(2, "0")}`
|
||||
: "--:--";
|
||||
|
||||
return (
|
||||
<GlassCard variant="interactive" className="p-4" onClick={() => onPlay(recording.id)}>
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onPlay(recording.id); }}
|
||||
className="size-10 flex items-center justify-center rounded-full glass-elevated shrink-0 hover:scale-105 transition-transform"
|
||||
>
|
||||
<Play className="size-4 text-primary ml-0.5" />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-semibold text-text-primary">{recording.username}</span>
|
||||
<span className="text-[10px] text-text-secondary/40 font-mono">{recording.channel_name}</span>
|
||||
</div>
|
||||
|
||||
{/* Mini waveform bar */}
|
||||
<div className="flex items-end gap-0.5 h-8 my-2">
|
||||
{Array.from({ length: 40 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-t-sm bg-primary/60"
|
||||
style={{ height: `${20 + Math.sin(i * 0.5) * 15 + Math.random() * 10}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-mono text-text-secondary/60">{durationStr}</span>
|
||||
<span className="text-[10px] text-text-secondary/40">{new Date(recording.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{recording.download_url && (
|
||||
<a href={recording.download_url} target="_blank" rel="noopener noreferrer" className="size-7 flex items-center justify-center rounded glass hover:glass-elevated transition-all">
|
||||
<Download className="size-3 text-text-secondary/60" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { GlassPanel } from "@/components/glass/panel";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface RecordingPlayerProps {
|
||||
url?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RecordingPlayer({ url, onClose }: RecordingPlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (url && audioRef.current) {
|
||||
audioRef.current?.play().catch(() => {});
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
if (!url) return null;
|
||||
|
||||
return (
|
||||
<GlassPanel dense className="fixed bottom-20 left-4 z-30 w-72 flex items-center gap-3">
|
||||
<audio ref={audioRef} src={url} controls className="flex-1 h-8 [&::-webkit-media-controls-panel]:bg-transparent" autoPlay />
|
||||
<button type="button" onClick={onClose}>
|
||||
<X className="size-3.5 text-text-secondary/60 hover:text-text-primary" />
|
||||
</button>
|
||||
</GlassPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { GlassCard } from "@/components/glass/card";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
interface ActivityTimelineProps {
|
||||
data?: { user: string; duration: number }[];
|
||||
}
|
||||
|
||||
export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) {
|
||||
return (
|
||||
<GlassCard variant="base">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">
|
||||
Voice Activity
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-40">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} layout="vertical">
|
||||
<XAxis
|
||||
type="number"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="user"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
|
||||
width={80}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "oklch(0.11 0.02 245 / 0.9)",
|
||||
border: "1px solid oklch(1 0 0 / 0.08)",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
color: "oklch(0.93 0.01 245)",
|
||||
}}
|
||||
formatter={(value) => [`${(Number(value) / 60).toFixed(1)}m`, "Duration"]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="duration"
|
||||
fill="var(--color-primary)"
|
||||
radius={[0, 4, 4, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { GlassCard } from "@/components/glass/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { Channel, Guild } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ConnectionCardProps {
|
||||
connected: boolean;
|
||||
activeChannelName?: string | null;
|
||||
guilds: Guild[];
|
||||
voiceChannels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
onGuildChange: (guildId: string | null) => void;
|
||||
onChannelChange: (channelId: string | null) => void;
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
connecting?: boolean;
|
||||
}
|
||||
|
||||
export function VoiceConnectionCard({
|
||||
connected,
|
||||
activeChannelName,
|
||||
guilds,
|
||||
voiceChannels,
|
||||
selectedGuild,
|
||||
selectedChannel,
|
||||
onGuildChange,
|
||||
onChannelChange,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
connecting,
|
||||
}: ConnectionCardProps) {
|
||||
return (
|
||||
<GlassCard variant={connected ? "elevated" : "base"}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className={cn("relative flex size-3", connected && "text-emerald-500")}>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inline-flex size-full rounded-full opacity-75",
|
||||
connected ? "bg-emerald-500 animate-pulse-ring" : "bg-destructive",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"relative inline-flex size-3 rounded-full",
|
||||
connected ? "bg-emerald-500" : "bg-destructive",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-text-primary">Voice Connection</span>
|
||||
{activeChannelName && (
|
||||
<span className="text-xs text-text-secondary/60 ml-2 font-mono">{activeChannelName}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{connected ? (
|
||||
<Button size="sm" variant="destructive" onClick={onDisconnect}>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={onConnect} disabled={!selectedGuild || !selectedChannel || connecting}>
|
||||
{connecting ? "Connecting..." : "Connect"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Select
|
||||
value={selectedGuild}
|
||||
onValueChange={(v) => {
|
||||
onGuildChange(v);
|
||||
onChannelChange("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 glass border-glass-border text-xs">
|
||||
<SelectValue placeholder="Select guild" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{guilds.map((g) => (
|
||||
<SelectItem key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={selectedChannel}
|
||||
onValueChange={onChannelChange}
|
||||
disabled={!selectedGuild}
|
||||
>
|
||||
<SelectTrigger className="h-8 glass border-glass-border text-xs">
|
||||
<SelectValue placeholder="Select channel" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{voiceChannels.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { GlassCard } from "@/components/glass/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Mic, MicOff } from "lucide-react";
|
||||
|
||||
interface MicControlProps {
|
||||
connected: boolean;
|
||||
active: boolean;
|
||||
onToggle: (active: boolean) => void;
|
||||
volume: number;
|
||||
onVolumeChange: (v: number) => void;
|
||||
}
|
||||
|
||||
export function MicControl({
|
||||
connected,
|
||||
active,
|
||||
onToggle,
|
||||
volume,
|
||||
onVolumeChange,
|
||||
}: MicControlProps) {
|
||||
return (
|
||||
<GlassCard variant="base">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant={active ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => onToggle(!active)}
|
||||
disabled={!connected}
|
||||
className="h-9"
|
||||
>
|
||||
{active ? <Mic className="size-4 mr-1" /> : <MicOff className="size-4 mr-1" />}
|
||||
{active ? "Live" : "Muted"}
|
||||
</Button>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<span className="text-[10px] text-text-secondary/60 font-mono">Vol</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={volume}
|
||||
onChange={(e) => onVolumeChange(Number(e.target.value))}
|
||||
className="flex-1 h-1 appearance-none bg-glass-border rounded-full accent-primary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:shadow-[0_0_8px] [&::-webkit-slider-thumb]:shadow-primary/60"
|
||||
/>
|
||||
<span className="text-[10px] font-mono text-text-secondary w-8 text-right">{volume}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { GlassPanel } from "@/components/glass/panel";
|
||||
import type { ActiveSpeaker } from "@/lib/types";
|
||||
|
||||
interface SpeakerWaveformProps {
|
||||
speakers: ActiveSpeaker[];
|
||||
}
|
||||
|
||||
export function SpeakerWaveform({ speakers }: SpeakerWaveformProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || speakers.length === 0) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const draw = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const barCount = 40;
|
||||
const barWidth = canvas.width / barCount - 1;
|
||||
|
||||
speakers.forEach((speaker, si) => {
|
||||
const yBase = si * 30 + 10;
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const height = speaker.speaking
|
||||
? Math.random() * 20 + 4
|
||||
: Math.random() * 4 + 2;
|
||||
const x = i * (barWidth + 1);
|
||||
const hue = 185 + si * 30;
|
||||
ctx.fillStyle = `oklch(0.62 ${0.12 + si * 0.02} ${hue} / ${speaker.speaking ? 0.9 : 0.3})`;
|
||||
ctx.fillRect(x, yBase + 20 - height, barWidth, height);
|
||||
}
|
||||
});
|
||||
|
||||
animRef.current = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
draw();
|
||||
return () => cancelAnimationFrame(animRef.current);
|
||||
}, [speakers]);
|
||||
|
||||
if (speakers.length === 0) {
|
||||
return (
|
||||
<GlassPanel dense>
|
||||
<span className="text-xs text-text-secondary/40">No speakers detected</span>
|
||||
</GlassPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GlassPanel dense>
|
||||
<div className="space-y-1">
|
||||
{speakers.map((s) => (
|
||||
<div key={s.userId} className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className={s.speaking ? "text-primary font-medium" : "text-text-secondary/60"}
|
||||
>
|
||||
{s.username}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={400}
|
||||
height={speakers.length * 30}
|
||||
className="w-full h-auto mt-2 rounded"
|
||||
/>
|
||||
</GlassPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
import { mediaApi } from "@/lib/api";
|
||||
import type { MediaState, MediaItem } from "@/lib/types";
|
||||
|
||||
interface MediaPlayerContextValue {
|
||||
/** Current play state */
|
||||
playing: boolean;
|
||||
/** Current track, or null */
|
||||
current: MediaItem | null;
|
||||
/** Upcoming queue */
|
||||
queue: MediaItem[];
|
||||
/** Current volume [0-1] */
|
||||
volume: number;
|
||||
/** True while a mutation is in flight */
|
||||
pending: boolean;
|
||||
|
||||
/** Skip to next track */
|
||||
skip: () => void;
|
||||
/** Stop playback */
|
||||
stop: () => void;
|
||||
/** Set volume [0-1] */
|
||||
setVolume: (vol: number) => void;
|
||||
/** Queue a URL for playback */
|
||||
queueUrl: (url: string) => void;
|
||||
}
|
||||
|
||||
const MediaPlayerContext = createContext<MediaPlayerContextValue | null>(null);
|
||||
|
||||
export function MediaPlayerProvider({ children }: { children: ReactNode }) {
|
||||
const ws = useWebSocket();
|
||||
const [state, setState] = useState<MediaState>({
|
||||
playing: false,
|
||||
musicVolume: 0.5,
|
||||
current: null,
|
||||
queue: [],
|
||||
});
|
||||
const [pending, setPending] = useState(false);
|
||||
const fetched = useRef(false);
|
||||
|
||||
// Fetch initial state
|
||||
useEffect(() => {
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
mediaApi.getStatus().then((data) => {
|
||||
if (data) setState(data as MediaState);
|
||||
}).catch(() => {
|
||||
// API not yet available
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Subscribe to live media_state events via WS
|
||||
useEffect(() => {
|
||||
const unsub = ws.on("media_state", (data) => {
|
||||
setState(data as unknown as MediaState);
|
||||
});
|
||||
return unsub;
|
||||
}, [ws]);
|
||||
|
||||
const skip = useCallback(() => {
|
||||
setPending(true);
|
||||
mediaApi.skip().then((data) => {
|
||||
if (data) setState(data as MediaState);
|
||||
}).catch(() => {
|
||||
// ignore
|
||||
}).finally(() => setPending(false));
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
setPending(true);
|
||||
mediaApi.stop().then((data) => {
|
||||
if (data) setState(data as MediaState);
|
||||
}).catch(() => {
|
||||
// ignore
|
||||
}).finally(() => setPending(false));
|
||||
}, []);
|
||||
|
||||
const setVolume = useCallback((vol: number) => {
|
||||
mediaApi.volume(vol).then((data) => {
|
||||
if (data) setState(data as MediaState);
|
||||
}).catch(() => {
|
||||
// ignore
|
||||
});
|
||||
}, []);
|
||||
|
||||
const queueUrl = useCallback((url: string) => {
|
||||
setPending(true);
|
||||
mediaApi.queue(url, "music").then((data) => {
|
||||
if (data) setState(data as MediaState);
|
||||
}).catch(() => {
|
||||
// ignore
|
||||
}).finally(() => setPending(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MediaPlayerContext.Provider
|
||||
value={{
|
||||
playing: state.playing,
|
||||
current: state.current,
|
||||
queue: state.queue,
|
||||
volume: state.musicVolume,
|
||||
pending,
|
||||
skip,
|
||||
stop,
|
||||
setVolume,
|
||||
queueUrl,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MediaPlayerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMediaPlayer(): MediaPlayerContextValue {
|
||||
const ctx = useContext(MediaPlayerContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useMediaPlayer must be used within a MediaPlayerProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user