chore(auto): task completed - unknown
This commit is contained in:
@@ -19,9 +19,11 @@ import {
|
||||
Skeleton,
|
||||
} from "../../../shared/ui";
|
||||
import { useDashboardStats } from "../hooks/useDashboard";
|
||||
import { useUIState } from "../../../shared/hooks/useUIState";
|
||||
|
||||
export function DashboardStatsContent() {
|
||||
const { stats, loading, error, refetch } = useDashboardStats();
|
||||
const { patchUIState } = useUIState();
|
||||
|
||||
if (loading) {
|
||||
return <StatsSkeleton />;
|
||||
@@ -100,6 +102,7 @@ export function DashboardStatsContent() {
|
||||
icon: Mic,
|
||||
color: "text-cyan-500",
|
||||
bg: "bg-cyan-100",
|
||||
onClick: () => patchUIState({ activeTab: "live" }),
|
||||
},
|
||||
{
|
||||
title: "AI Profiles",
|
||||
@@ -123,7 +126,11 @@ export function DashboardStatsContent() {
|
||||
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
{cards.map((card) => (
|
||||
<Card key={card.title} className="overflow-hidden">
|
||||
<Card
|
||||
key={card.title}
|
||||
className={cn("overflow-hidden", card.onClick && "cursor-pointer transition-colors hover:bg-accent/50")}
|
||||
onClick={card.onClick}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -35,7 +35,7 @@ export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
||||
const height = canvas.height / dpr;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const barWidth = width / levels.length;
|
||||
const maxBarHeight = height * 0.85;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// ─── Recordings Sub-Panel ──
|
||||
|
||||
import { Download, Mic, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Download, Mic, Pause, Play, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { VoiceRecording } from "../../../shared/api/client";
|
||||
import { deleteRecording, listRecordings } from "../../../shared/api/client";
|
||||
import { formatBytes, formatDate } from "../../../shared/lib/utils";
|
||||
@@ -10,9 +10,14 @@ import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
|
||||
|
||||
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 [activePlayingId, setActivePlayingId] = useState<string | null>(null);
|
||||
const audioRefs = useRef<Map<string, HTMLAudioElement>>(new Map());
|
||||
|
||||
const loadRecordings = useCallback(async (
|
||||
opts?: { signal?: AbortSignal },
|
||||
@@ -20,8 +25,12 @@ export function RecordingsSubPanel() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await listRecordings();
|
||||
if (!opts?.signal?.aborted) setRecordings(data);
|
||||
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));
|
||||
@@ -30,6 +39,21 @@ export function RecordingsSubPanel() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
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 });
|
||||
@@ -58,6 +82,22 @@ export function RecordingsSubPanel() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTogglePlay = useCallback((id: string) => {
|
||||
setActivePlayingId((prev) => {
|
||||
if (prev === id) {
|
||||
// Pause current
|
||||
audioRefs.current.get(id)?.pause();
|
||||
return null;
|
||||
}
|
||||
// Pause any previously playing
|
||||
if (prev) audioRefs.current.get(prev)?.pause();
|
||||
// Play new
|
||||
const audio = audioRefs.current.get(id);
|
||||
if (audio) audio.play().catch(() => {});
|
||||
return id;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -147,18 +187,50 @@ export function RecordingsSubPanel() {
|
||||
{rec.upload_status}
|
||||
</Badge>
|
||||
{rec.download_url && (
|
||||
<a
|
||||
href={rec.download_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
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>
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={activePlayingId === rec.id ? "default" : "outline"}
|
||||
onClick={() => handleTogglePlay(rec.id)}
|
||||
>
|
||||
{activePlayingId === rec.id
|
||||
? <Pause className="h-4 w-4" />
|
||||
: <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
<audio
|
||||
ref={(el) => {
|
||||
if (el) audioRefs.current.set(rec.id, el);
|
||||
else audioRefs.current.delete(rec.id);
|
||||
}}
|
||||
src={rec.download_url}
|
||||
preload="none"
|
||||
onEnded={() => setActivePlayingId((p) => p === rec.id ? null : p)}
|
||||
className="hidden"
|
||||
/>
|
||||
<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>
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loadingMore}
|
||||
onClick={loadMore}
|
||||
>
|
||||
{loadingMore ? "Loading..." : "Load More"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Headphones, Radio } from "lucide-react";
|
||||
import type { Channel, Guild, VoiceStatus } from "../../../shared/api/client";
|
||||
import { Button, Select } from "../../../shared/ui";
|
||||
import { MicLevelMeter } from "./MicLevelMeter";
|
||||
|
||||
interface VoiceConnectionCardProps {
|
||||
guilds: Guild[];
|
||||
@@ -11,6 +12,7 @@ interface VoiceConnectionCardProps {
|
||||
voiceLoading: boolean;
|
||||
isListening: boolean;
|
||||
isStreaming: boolean;
|
||||
micLevel: number;
|
||||
onGuildChange: (id: string) => void;
|
||||
onChannelChange: (id: string) => void;
|
||||
onJoin: () => void;
|
||||
@@ -28,6 +30,7 @@ export function VoiceConnectionCard({
|
||||
voiceLoading,
|
||||
isListening,
|
||||
isStreaming,
|
||||
micLevel,
|
||||
onGuildChange,
|
||||
onChannelChange,
|
||||
onJoin,
|
||||
@@ -100,6 +103,11 @@ export function VoiceConnectionCard({
|
||||
<Radio className="mr-1.5 h-4 w-4" />{" "}
|
||||
{isStreaming ? "Stop Transmit" : "Transmit"}
|
||||
</Button>
|
||||
{isStreaming && (
|
||||
<div className="flex items-center pl-1">
|
||||
<MicLevelMeter level={micLevel} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,4 +6,5 @@ export { MusicSubPanel } from "./MusicSubPanel";
|
||||
export { NowPlaying } from "./NowPlaying";
|
||||
export { RecordingsSubPanel } from "./RecordingsSubPanel";
|
||||
export { ScreenSubPanel } from "./ScreenSubPanel";
|
||||
export { MicLevelMeter } from "./MicLevelMeter";
|
||||
export { VoiceConnectionCard } from "./VoiceConnectionCard";
|
||||
|
||||
@@ -39,6 +39,7 @@ interface LivePanelProps {
|
||||
levels: number[];
|
||||
isListening: boolean;
|
||||
isStreaming: boolean;
|
||||
micLevel: number;
|
||||
mediaState: MediaState;
|
||||
mediaLoading: boolean;
|
||||
onGuildChange: (id: string) => void;
|
||||
@@ -65,6 +66,7 @@ export function LivePanel({
|
||||
levels,
|
||||
isListening,
|
||||
isStreaming,
|
||||
micLevel,
|
||||
mediaState,
|
||||
mediaLoading,
|
||||
onGuildChange,
|
||||
@@ -96,6 +98,7 @@ export function LivePanel({
|
||||
voiceLoading={voiceLoading}
|
||||
isListening={isListening}
|
||||
isStreaming={isStreaming}
|
||||
micLevel={micLevel}
|
||||
onGuildChange={onGuildChange}
|
||||
onChannelChange={onChannelChange}
|
||||
onJoin={onJoin}
|
||||
|
||||
Reference in New Issue
Block a user