diff --git a/services/frontend/src/app/(dashboard)/recordings/page.tsx b/services/frontend/src/app/(dashboard)/recordings/page.tsx index 11fdee5..874c0b6 100644 --- a/services/frontend/src/app/(dashboard)/recordings/page.tsx +++ b/services/frontend/src/app/(dashboard)/recordings/page.tsx @@ -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(null); + const [tab, setTab] = useState("library"); + + const currentTrack = playingId && recordings + ? recordings.find((r: VoiceRecording) => r.id === playingId) + : null; return ( -
- +
+ setTab(t as RecordingsTab)} + /> + + {tab === "library" && ( + <> + {error ? ( + + ) : isLoading ? ( + + ) : ( +
+ {(recordings ?? []).map((rec: VoiceRecording) => ( + setPlayingId(id === playingId ? null : id)} + /> + ))} + {(recordings ?? []).length === 0 && ( +
No recordings yet
+ )} +
+ )} + + )} + + {tab === "stats" && ( +
Recording stats coming soon
+ )} + + setPlayingId(null)} />
); } diff --git a/services/frontend/src/app/(dashboard)/voice/page.tsx b/services/frontend/src/app/(dashboard)/voice/page.tsx index f0c6313..df72b76 100644 --- a/services/frontend/src/app/(dashboard)/voice/page.tsx +++ b/services/frontend/src/app/(dashboard)/voice/page.tsx @@ -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("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 ( -
+
+ setTab(t as VoiceTab)} + /> + setSelectedChannel(v)} - guilds={guilds} - voiceChannels={voiceChannels} connected={connected} activeChannelName={voiceStatus?.activeChannelName} - connectMut={connectMut} - disconnectMut={disconnectMut} - /> - - setSelectedChannel(v ?? "")} + onConnect={() => connectMut.mutate({ guildId: selectedGuild, channelId: selectedChannel })} + onDisconnect={() => disconnectMut.mutate(undefined)} + connecting={connectMut.isPending} /> + + {tab === "connection" && ( +
+ + +
+ )} + + {tab === "activity" && }
); } diff --git a/services/frontend/src/app/globals.css b/services/frontend/src/app/globals.css index bbf651b..fb03f54 100644 --- a/services/frontend/src/app/globals.css +++ b/services/frontend/src/app/globals.css @@ -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; diff --git a/services/frontend/src/components/layout/hidden-sidebar.tsx b/services/frontend/src/components/layout/hidden-sidebar.tsx new file mode 100644 index 0000000..5e39723 --- /dev/null +++ b/services/frontend/src/components/layout/hidden-sidebar.tsx @@ -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 | 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 */} +
+ + {/* Sidebar */} +
+
+ + Guilds + +
+
+ +
+
+ + ); +} diff --git a/services/frontend/src/components/layout/sub-nav.tsx b/services/frontend/src/components/layout/sub-nav.tsx new file mode 100644 index 0000000..6c8e577 --- /dev/null +++ b/services/frontend/src/components/layout/sub-nav.tsx @@ -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 ( +
+ {tabs.map((tab) => ( + + ))} +
+ ); +} diff --git a/services/frontend/src/components/mascot/chat-panel.tsx b/services/frontend/src/components/mascot/chat-panel.tsx new file mode 100644 index 0000000..0c49b2d --- /dev/null +++ b/services/frontend/src/components/mascot/chat-panel.tsx @@ -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; +} + +export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) { + const { messages, sendMessage, isTyping } = useMascot(); + const listRef = useRef(null); + const internalInputRef = useRef(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 ( +
+ {/* Chat messages */} +
+ {messages.length === 0 && ( +
+

Ask mascot anything

+
+ )} + {messages.slice(-8).map((msg, i) => ( +
+ + {msg.content} + +
+ ))} + {isTyping && ( +
+
+ + + + + +
+
+ )} +
+ + {/* Input bar */} +
+ + +
+
+ ); +} diff --git a/services/frontend/src/components/mascot/index.ts b/services/frontend/src/components/mascot/index.ts new file mode 100644 index 0000000..361e7df --- /dev/null +++ b/services/frontend/src/components/mascot/index.ts @@ -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"; diff --git a/services/frontend/src/components/mascot/mascot-canvas.tsx b/services/frontend/src/components/mascot/mascot-canvas.tsx new file mode 100644 index 0000000..b037c56 --- /dev/null +++ b/services/frontend/src/components/mascot/mascot-canvas.tsx @@ -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(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 ( + + ); +} diff --git a/services/frontend/src/components/mascot/mascot-container.tsx b/services/frontend/src/components/mascot/mascot-container.tsx new file mode 100644 index 0000000..1144bbe --- /dev/null +++ b/services/frontend/src/components/mascot/mascot-container.tsx @@ -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(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 ( +
+ {/* Main mascot bubble */} +
+ {minimized ? ( + + ) : ( + <> + {/* Drag handle + controls */} +
+ + Mascot + +
+ + +
+
+ + {/* Canvas area */} +
+ +
+ + {/* Chat panel (expandable) */} +
+ +
+ + )} +
+
+ ); +} diff --git a/services/frontend/src/components/mascot/mascot-context.tsx b/services/frontend/src/components/mascot/mascot-context.tsx new file mode 100644 index 0000000..7a87938 --- /dev/null +++ b/services/frontend/src/components/mascot/mascot-context.tsx @@ -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; + clearMessages: () => Promise; + isTyping: boolean; +} + +const MascotContext = createContext(null); + +export function MascotProvider({ children }: { children: ReactNode }) { + const [expression, setExpression] = useState("idle"); + const [minimized, setMinimized] = useState(true); + const [chatOpen, setChatOpen] = useState(false); + const [messages, setMessages] = useState([]); + 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 ( + + {children} + + ); +} + +export function useMascot(): MascotContextValue { + const ctx = useContext(MascotContext); + if (!ctx) { + throw new Error("useMascot must be used within a MascotProvider"); + } + return ctx; +} diff --git a/services/frontend/src/components/media/mini-player.tsx b/services/frontend/src/components/media/mini-player.tsx new file mode 100644 index 0000000..0e2a737 --- /dev/null +++ b/services/frontend/src/components/media/mini-player.tsx @@ -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 ( +
+ {/* Track info */} +
+
+ {playing ? ( + + ) : ( + + )} +
+
+

+ {current?.title ?? "Unknown track"} +

+ {queue.length > 0 && ( +

+ {queue.length > 1 + ? `${queue.length} in queue` + : "1 in queue"} +

+ )} +
+
+ + {/* Controls */} +
+ {playing && ( + + )} + {current && ( + + )} +
+ + {/* Volume */} +
+ + 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" + /> +
+
+ ); +} diff --git a/services/frontend/src/components/recordings/recording-card.tsx b/services/frontend/src/components/recordings/recording-card.tsx new file mode 100644 index 0000000..15f493a --- /dev/null +++ b/services/frontend/src/components/recordings/recording-card.tsx @@ -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 ( + onPlay(recording.id)}> +
+ + +
+
+ {recording.username} + {recording.channel_name} +
+ + {/* Mini waveform bar */} +
+ {Array.from({ length: 40 }, (_, i) => ( +
+ ))} +
+ +
+ {durationStr} + {new Date(recording.created_at).toLocaleString()} +
+
+ +
e.stopPropagation()}> + {recording.download_url && ( + + + + )} +
+
+ + ); +} diff --git a/services/frontend/src/components/recordings/recording-player.tsx b/services/frontend/src/components/recordings/recording-player.tsx new file mode 100644 index 0000000..643cc76 --- /dev/null +++ b/services/frontend/src/components/recordings/recording-player.tsx @@ -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(null); + + useEffect(() => { + if (url && audioRef.current) { + audioRef.current?.play().catch(() => {}); + } + }, [url]); + + if (!url) return null; + + return ( + + + ); +} diff --git a/services/frontend/src/components/voice/activity-timeline.tsx b/services/frontend/src/components/voice/activity-timeline.tsx new file mode 100644 index 0000000..c658657 --- /dev/null +++ b/services/frontend/src/components/voice/activity-timeline.tsx @@ -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 ( + +
+ + Voice Activity + +
+
+ + + + + [`${(Number(value) / 60).toFixed(1)}m`, "Duration"]} + /> + + + +
+
+ ); +} diff --git a/services/frontend/src/components/voice/connection-card.tsx b/services/frontend/src/components/voice/connection-card.tsx new file mode 100644 index 0000000..51cf2f8 --- /dev/null +++ b/services/frontend/src/components/voice/connection-card.tsx @@ -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 ( + +
+ + + + +
+ Voice Connection + {activeChannelName && ( + {activeChannelName} + )} +
+
+ {connected ? ( + + ) : ( + + )} +
+
+ +
+ + +
+
+ ); +} diff --git a/services/frontend/src/components/voice/mic-control.tsx b/services/frontend/src/components/voice/mic-control.tsx new file mode 100644 index 0000000..7bbe4d0 --- /dev/null +++ b/services/frontend/src/components/voice/mic-control.tsx @@ -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 ( + +
+ +
+ Vol + 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" + /> + {volume}% +
+
+
+ ); +} diff --git a/services/frontend/src/components/voice/speaker-waveform.tsx b/services/frontend/src/components/voice/speaker-waveform.tsx new file mode 100644 index 0000000..cfee868 --- /dev/null +++ b/services/frontend/src/components/voice/speaker-waveform.tsx @@ -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(null); + const animRef = useRef(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 ( + + No speakers detected + + ); + } + + return ( + +
+ {speakers.map((s) => ( +
+ + {s.username} + +
+ ))} +
+ +
+ ); +} diff --git a/services/frontend/src/lib/hooks/use-media-player.tsx b/services/frontend/src/lib/hooks/use-media-player.tsx new file mode 100644 index 0000000..d89c83b --- /dev/null +++ b/services/frontend/src/lib/hooks/use-media-player.tsx @@ -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(null); + +export function MediaPlayerProvider({ children }: { children: ReactNode }) { + const ws = useWebSocket(); + const [state, setState] = useState({ + 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 ( + + {children} + + ); +} + +export function useMediaPlayer(): MediaPlayerContextValue { + const ctx = useContext(MediaPlayerContext); + if (!ctx) { + throw new Error("useMediaPlayer must be used within a MediaPlayerProvider"); + } + return ctx; +}