Build & Deploy / build-and-push (discord-gateway) (push) Failing after 2m22s
Build & Deploy / build-and-push (backend) (push) Failing after 3m22s
Build & Deploy / build-and-push (proxy) (push) Successful in 1m36s
Build & Deploy / deploy (push) Skipped
- Consolidate all DB schema definitions into packages/shared as single source of truth - Migrate backend from raw SQL to Drizzle ORM across all modules - Extract frontend inline UI into separate component files - Refactor discord-gateway circuitBreaker into conversationState + moderationState - Convert messageStore to Proxy singleton pattern - Add validateBody/validateQuery middleware + Zod schemas for API endpoints - Modernize Docker builds with multi-stage + pnpm deploy - Migrate CI/CD from deployment to image-based pipeline - Remove 60+ unused/dead files (~15K lines) - Update color scheme from sky-blue to teal-cyan - Move DB connection management to @bete/shared/database Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
161 lines
5.4 KiB
TypeScript
161 lines
5.4 KiB
TypeScript
"use client";
|
|
|
|
import { Disc3, Music, Play, SkipForward, Square, Volume2 } from "lucide-react";
|
|
import Image from "next/image";
|
|
import { useCallback, useState } from "react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Slider } from "@/components/ui/slider";
|
|
import {
|
|
useMediaQueue,
|
|
useMediaSkip,
|
|
useMediaState,
|
|
useMediaStop,
|
|
useMediaVolume,
|
|
useMediaWsSync,
|
|
} from "@/hooks";
|
|
import type { WsHook } from "@/lib/ws-hook";
|
|
|
|
interface MusicPlayerProps {
|
|
ws: WsHook;
|
|
}
|
|
|
|
export function MusicPlayer({ ws }: MusicPlayerProps) {
|
|
const { data: mediaState } = useMediaState();
|
|
const queueMut = useMediaQueue();
|
|
const skipMut = useMediaSkip();
|
|
const stopMut = useMediaStop();
|
|
const volumeMut = useMediaVolume();
|
|
const [queueUrl, setQueueUrl] = useState("");
|
|
|
|
// Sync WS media_state into the query cache
|
|
useMediaWsSync(ws);
|
|
|
|
const handleQueue = useCallback(() => {
|
|
if (!queueUrl.trim()) return;
|
|
queueMut.mutate(queueUrl.trim());
|
|
setQueueUrl("");
|
|
}, [queueUrl, queueMut]);
|
|
|
|
const handleVolume = useCallback(
|
|
(value: number | readonly number[]) => {
|
|
const vol = Array.isArray(value) ? value[0] : value;
|
|
volumeMut.mutate(vol);
|
|
},
|
|
[volumeMut],
|
|
);
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
|
<Music className="size-4 text-primary" />
|
|
Music Player
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex gap-2">
|
|
<Input
|
|
placeholder="Queue a URL (YouTube, audio file…)"
|
|
value={queueUrl}
|
|
onChange={(e) => setQueueUrl(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && handleQueue()}
|
|
className="flex-1 h-9"
|
|
/>
|
|
<Button
|
|
onClick={handleQueue}
|
|
disabled={!queueUrl.trim() || queueMut.isPending}
|
|
>
|
|
<Play className="size-4 mr-1.5" />
|
|
Queue
|
|
</Button>
|
|
</div>
|
|
|
|
{mediaState?.current ? (
|
|
<div className="rounded-lg bg-gradient-to-br from-primary/5 to-primary/[0.02] border border-primary/10 p-4 space-y-2">
|
|
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider flex items-center gap-1.5">
|
|
<Disc3 className="size-3" />
|
|
Now Playing
|
|
</p>
|
|
<div className="flex items-start gap-3">
|
|
{mediaState.current.thumbnailUrl && (
|
|
<Image
|
|
src={mediaState.current.thumbnailUrl}
|
|
alt=""
|
|
width={56}
|
|
height={56}
|
|
className="size-14 rounded-lg object-cover shadow-sm"
|
|
/>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium truncate">
|
|
{mediaState.current.title ?? mediaState.current.source}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{mediaState.current.durationMs
|
|
? `${Math.floor(mediaState.current.durationMs / 60000)}:${String(Math.floor((mediaState.current.durationMs % 60000) / 1000)).padStart(2, "0")}`
|
|
: "Live"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
!mediaState?.queue?.length && (
|
|
<p className="text-sm text-muted-foreground py-8 text-center">
|
|
No media queued. Paste a URL above to start playing.
|
|
</p>
|
|
)
|
|
)}
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" onClick={() => stopMut.mutate()}>
|
|
<Square className="size-4 mr-1" />
|
|
Stop
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={() => skipMut.mutate()}>
|
|
<SkipForward className="size-4 mr-1" />
|
|
Skip
|
|
</Button>
|
|
<div className="flex items-center gap-2 ml-auto">
|
|
<Volume2 className="size-4 text-muted-foreground" />
|
|
<Slider
|
|
className="w-24"
|
|
defaultValue={[mediaState?.musicVolume ?? 0.5]}
|
|
value={[mediaState?.musicVolume ?? 0.5]}
|
|
onValueChange={handleVolume}
|
|
min={0}
|
|
max={1}
|
|
step={0.05}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{mediaState && mediaState.queue.length > 0 && (
|
|
<div className="space-y-1.5">
|
|
<p className="text-xs text-muted-foreground font-medium">
|
|
Queue ({mediaState.queue.length})
|
|
</p>
|
|
<div className="space-y-1">
|
|
{mediaState.queue.map((item, i) => (
|
|
<div
|
|
key={item.id ?? i}
|
|
className="flex items-center gap-2 rounded-md bg-muted/30 px-3 py-2 text-sm"
|
|
>
|
|
<span className="text-xs text-muted-foreground font-mono w-5 text-right">
|
|
{i + 1}.
|
|
</span>
|
|
<span className="truncate flex-1">
|
|
{item.title ?? item.source}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|