feat(frontend): perbagus voice & audio playback UX
- Recordings: custom RecordingAudioPlayer (play/pause, buffering spinner, click-to-seek, time label, eq bars, single-playback antar kartu) + highlight kartu now-playing - Media: thumbnail di disc hero + queue row, equalizer saat playing, badge 'up next', label Paused vs Now playing - MiniPlayer global di AppFrame (fixed bottom-right, hidden on /media) menggantikan use-media-player.tsx dead provider (dihapus) - Voice: mic level meter live (AnalyserNode RMS) + slider mic/listen volume
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
# Spec: Perbagus fitur Voice + Audio Playback (GMW frontend)
|
||||||
|
|
||||||
|
Tanggal: 2026-08-22 · Scope: **frontend only** (backend/gateway API sudah cukup)
|
||||||
|
|
||||||
|
## Masalah (audit)
|
||||||
|
1. Recordings: semua kartu pakai `<audio controls>` native — tampilan identik,
|
||||||
|
tidak ada indikasi which-clip-playing / loading / paused, dan N audio bisa
|
||||||
|
play bareng (overlap).
|
||||||
|
2. Media view: `thumbnailUrl` dari gateway tidak dipakai; tidak ada visual
|
||||||
|
"sedang playing" selain disc spin; queue item semua sama tanpa badge up-next.
|
||||||
|
3. Mini-player (`lib/hooks/use-media-player.tsx`) ada tapi TIDAK PERNAH
|
||||||
|
dimount → dead code, user tidak lihat status musik di halaman lain.
|
||||||
|
4. Voice page: `useMicTransmit.setVolume` + `useVoiceListen.setVolume`
|
||||||
|
tersedia tapi tak ada UI-nya; mic live tidak punya level feedback.
|
||||||
|
|
||||||
|
## Desain
|
||||||
|
|
||||||
|
### A. RecordingAudioPlayer (baru, `components/voice/recording-audio-player.tsx`)
|
||||||
|
Custom player menggantikan `<audio controls>`:
|
||||||
|
- Play/pause button (ikon berubah), spinner saat buffering (`waiting` event).
|
||||||
|
- Progress bar seekable (click-to-seek) + time label `m:ss / m:ss`.
|
||||||
|
- Waveform-ish equalizer bars saat playing (CSS animation, reduced-motion safe).
|
||||||
|
- **Single-playback**: module-level registry `activePlayers` — memainkan satu
|
||||||
|
clip otomatis pause yang lain.
|
||||||
|
- Kartu pemilik player aktif dapat highlight border signal + "Now playing" chip.
|
||||||
|
|
||||||
|
### B. Recordings view — pasang player baru
|
||||||
|
- Ganti `<audio>` → `<RecordingAudioPlayer src download_url>`.
|
||||||
|
- Highlight kartu via state lifted: `playingId` di view, callback `onPlay`.
|
||||||
|
|
||||||
|
### C. Media view polish
|
||||||
|
- Hero: thumbnail (jika `current.thumbnailUrl`) sebagai disc center image;
|
||||||
|
fallback ListMusic icon. Equalizer bars animasi CSS saat `playing`.
|
||||||
|
- Queue row pertama: badge "up next"; baris current track diberi ring signal.
|
||||||
|
- Volume read-only tetap.
|
||||||
|
|
||||||
|
### D. MiniPlayer global
|
||||||
|
- Hapus `lib/hooks/use-media-player.tsx` (dead) — ganti dengan komponen
|
||||||
|
`components/media/mini-player.tsx` yang subscribe `useMediaState` +
|
||||||
|
`useMediaWsSync` langsung (SWR cache shared antar route), mounted di
|
||||||
|
`AppFrame` bawah layar (fixed bottom, hidden di route `/media`).
|
||||||
|
- Menampilkan: thumbnail kecil/judul, tombol skip/stop, link ke /media.
|
||||||
|
|
||||||
|
### E. Voice UI
|
||||||
|
- Mic live: level meter (Equalizer bars) — mic-transmitter sudah punya worklet;
|
||||||
|
tambah `getLevel()` via AnalyserNode pada stream (simple RMS) di hook.
|
||||||
|
- Listen: volume slider (input range) wired ke `listen.setVolume`.
|
||||||
|
- Mic volume slider wired ke `mic.setVolume`.
|
||||||
|
|
||||||
|
## File touched
|
||||||
|
| File | Aksi |
|
||||||
|
|---|---|
|
||||||
|
| services/frontend/src/components/voice/recording-audio-player.tsx | new |
|
||||||
|
| services/frontend/src/app/(dashboard)/recordings/view.tsx | edit |
|
||||||
|
| services/frontend/src/app/(dashboard)/media/view.tsx | edit |
|
||||||
|
| services/frontend/src/components/media/mini-player.tsx | new |
|
||||||
|
| services/frontend/src/components/shell/ambient-app.tsx | mount MiniPlayer |
|
||||||
|
| services/frontend/src/lib/hooks/use-media-player.tsx | delete |
|
||||||
|
| services/frontend/src/hooks/use-voice.ts | tambah micLevel |
|
||||||
|
| services/frontend/src/lib/audio/mic-transmit.ts | expose analyser level |
|
||||||
|
| services/frontend/src/app/(dashboard)/voice/view.tsx | sliders + meter |
|
||||||
|
|
||||||
|
## Verifikasi
|
||||||
|
1. `pnpm lint` (biome) + `pnpm build` clean.
|
||||||
|
2. Smoke di port **4024** (BUKAN 4017) → curl 200 semua route.
|
||||||
|
3. Commit (tanpa trailer) → push → `gh run watch` → live check
|
||||||
|
https://imphnen.asepharyana.my.id/{media,recordings,voice}/ = 200.
|
||||||
@@ -96,13 +96,45 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
<div
|
<div
|
||||||
className={`flex size-32 shrink-0 items-center justify-center rounded-full border border-hairline bg-gradient-to-br from-white/10 to-white/[0.02] ${playing ? "animate-spin-disc" : "animate-spin-disc paused"}`}
|
className={`flex size-32 shrink-0 items-center justify-center rounded-full border border-hairline bg-gradient-to-br from-white/10 to-white/[0.02] ${playing ? "animate-spin-disc" : "animate-spin-disc paused"}`}
|
||||||
>
|
>
|
||||||
<div className="flex size-28 items-center justify-center rounded-full bg-canvas/60">
|
<div className="flex size-28 items-center justify-center overflow-hidden rounded-full bg-canvas/60">
|
||||||
<ListMusic className="size-10 text-signal" />
|
{current?.thumbnailUrl ? (
|
||||||
|
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
|
||||||
|
<img
|
||||||
|
src={current.thumbnailUrl}
|
||||||
|
alt=""
|
||||||
|
className="size-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ListMusic className="size-10 text-signal" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="eyebrow mb-1">Now playing</div>
|
<div className="eyebrow mb-1 flex items-center gap-2">
|
||||||
|
{playing ? (
|
||||||
|
<>
|
||||||
|
<span aria-hidden className="flex h-3 items-end gap-[2px]">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<span
|
||||||
|
key={`eq-${i}`}
|
||||||
|
className="w-[3px] animate-eq rounded-full bg-signal"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${i * 160}ms`,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="text-signal">Now playing</span>
|
||||||
|
</>
|
||||||
|
) : current ? (
|
||||||
|
"Paused"
|
||||||
|
) : (
|
||||||
|
"Nothing queued"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<h2 className="display text-balance text-2xl text-ink">
|
<h2 className="display text-balance text-2xl text-ink">
|
||||||
{current?.title ?? "Nothing queued"}
|
{current?.title ?? "Nothing queued"}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -203,27 +235,56 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{queueList.map((item, i) => (
|
{queueList.map((item, i) => {
|
||||||
<div
|
const isNext = i === 0 && playing;
|
||||||
key={`${item.source}-${i}`}
|
return (
|
||||||
className="animate-stagger flex items-center gap-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5"
|
<div
|
||||||
style={staggerDelay(i)}
|
key={`${item.source}-${i}`}
|
||||||
>
|
className={`animate-stagger flex items-center gap-3 rounded-[10px] border px-3 py-2.5 ${
|
||||||
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
isNext
|
||||||
<div className="min-w-0 flex-1">
|
? "border-signal/40 bg-signal/[0.07]"
|
||||||
<div className="truncate text-sm text-ink">{item.title}</div>
|
: "border-hairline bg-white/5"
|
||||||
<div className="mono truncate text-[0.65rem] text-ink-faint">
|
}`}
|
||||||
{item.source}
|
style={staggerDelay(i)}
|
||||||
|
>
|
||||||
|
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||||
|
{item.thumbnailUrl ? (
|
||||||
|
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
|
||||||
|
<img
|
||||||
|
src={item.thumbnailUrl}
|
||||||
|
alt=""
|
||||||
|
className="size-9 shrink-0 rounded-md object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border border-hairline bg-white/5">
|
||||||
|
<ListMusic className="size-4 text-ink-faint" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm text-ink">
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
<div className="mono truncate text-[0.65rem] text-ink-faint">
|
||||||
|
{item.source}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{isNext && (
|
||||||
<span className="pill capitalize">{item.mode ?? "music"}</span>
|
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-signal/40 bg-signal/10 px-2 py-0.5 text-[0.6rem] font-medium text-signal">
|
||||||
{formatDuration(item.durationMs) && (
|
up next
|
||||||
<span className="mono w-10 text-right text-[0.65rem] text-ink-faint">
|
</span>
|
||||||
{formatDuration(item.durationMs)}
|
)}
|
||||||
|
<span className="pill hidden capitalize sm:inline-flex">
|
||||||
|
{item.mode ?? "music"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
{formatDuration(item.durationMs) && (
|
||||||
</div>
|
<span className="mono w-10 text-right text-[0.65rem] text-ink-faint">
|
||||||
))}
|
{formatDuration(item.durationMs)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Download, Hash, Headphones, Loader2, Trash2 } from "lucide-react";
|
import { Download, Hash, Headphones, Loader2, Trash2 } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -13,6 +13,10 @@ import {
|
|||||||
toast,
|
toast,
|
||||||
} from "@/components/primitives";
|
} from "@/components/primitives";
|
||||||
import { EmptyState, ErrorState, SectionHeader } from "@/components/shared";
|
import { EmptyState, ErrorState, SectionHeader } from "@/components/shared";
|
||||||
|
import {
|
||||||
|
NowPlayingChip,
|
||||||
|
RecordingAudioPlayer,
|
||||||
|
} from "@/components/voice/recording-audio-player";
|
||||||
import {
|
import {
|
||||||
useDeleteRecording,
|
useDeleteRecording,
|
||||||
useRecordings,
|
useRecordings,
|
||||||
@@ -33,6 +37,7 @@ export function RecordingsView({
|
|||||||
const del = useDeleteRecording();
|
const del = useDeleteRecording();
|
||||||
useRecordingsWsSync(ws);
|
useRecordingsWsSync(ws);
|
||||||
const ambient = useAmbient();
|
const ambient = useAmbient();
|
||||||
|
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ambient.set("signal", 0.3, "recordings");
|
ambient.set("signal", 0.3, "recordings");
|
||||||
@@ -97,10 +102,15 @@ export function RecordingsView({
|
|||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{(items ?? []).map((r, i) => {
|
{(items ?? []).map((r, i) => {
|
||||||
const up = uploadStatus(r);
|
const up = uploadStatus(r);
|
||||||
|
const isPlaying = playingId === r.id;
|
||||||
return (
|
return (
|
||||||
<GlassCard
|
<GlassCard
|
||||||
key={r.id}
|
key={r.id}
|
||||||
className="animate-stagger flex flex-col gap-3 transition-colors hover:bg-white/[0.06]"
|
className={`animate-stagger flex flex-col gap-3 transition-colors hover:bg-white/[0.06] ${
|
||||||
|
isPlaying
|
||||||
|
? "border-signal/40 shadow-[0_0_36px_-16px_var(--color-signal-glow)]"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
style={staggerDelay(i)}
|
style={staggerDelay(i)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -120,20 +130,24 @@ export function RecordingsView({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{up && <Badge tone={up.tone}>{up.label}</Badge>}
|
{isPlaying && <NowPlayingChip />}
|
||||||
|
{up && !isPlaying && <Badge tone={up.tone}>{up.label}</Badge>}
|
||||||
<span className="mono text-[0.65rem] text-ink-faint">
|
<span className="mono text-[0.65rem] text-ink-faint">
|
||||||
{formatBytes(r.size_bytes)}
|
{formatBytes(r.size_bytes)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{r.download_url ? (
|
{r.download_url ? (
|
||||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
<RecordingAudioPlayer
|
||||||
<audio
|
|
||||||
controls
|
|
||||||
src={r.download_url}
|
src={r.download_url}
|
||||||
className="h-9 w-full"
|
label={`Voice recording by ${r.username}`}
|
||||||
preload="none"
|
onPlayStateChange={(active) =>
|
||||||
aria-label={`Voice recording ${r.id}`}
|
setPlayingId((prev) => {
|
||||||
|
if (active) return r.id;
|
||||||
|
// Only clear if THIS card was the one playing.
|
||||||
|
return prev === r.id ? null : prev;
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-1.5 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-faint">
|
<div className="flex items-center gap-1.5 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-faint">
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export function VoiceView({
|
|||||||
initialStatus?.activeChannelId ?? null,
|
initialStatus?.activeChannelId ?? null,
|
||||||
);
|
);
|
||||||
const [micOn, setMicOn] = useState(false);
|
const [micOn, setMicOn] = useState(false);
|
||||||
|
const [micVol, setMicVol] = useState(100);
|
||||||
|
const [listenVol, setListenVol] = useState(75);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = subscribe(ws);
|
const unsub = subscribe(ws);
|
||||||
@@ -80,6 +82,14 @@ export function VoiceView({
|
|||||||
|
|
||||||
const connected = status?.connected ?? false;
|
const connected = status?.connected ?? false;
|
||||||
const listenBars = Array.from(listen.levels.values()).slice(0, 32);
|
const listenBars = Array.from(listen.levels.values()).slice(0, 32);
|
||||||
|
const micBars = mic.micLevel
|
||||||
|
? Array.from({ length: 12 }, (_, i) =>
|
||||||
|
Math.max(
|
||||||
|
0.08,
|
||||||
|
Math.min(1, mic.micLevel * (1 - i * 0.06) + (i % 3) * 0.05),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
const onConnect = async () => {
|
const onConnect = async () => {
|
||||||
if (!guildId || !channelId) {
|
if (!guildId || !channelId) {
|
||||||
@@ -155,6 +165,16 @@ export function VoiceView({
|
|||||||
{micOn ? <Mic className="size-4" /> : <MicOff className="size-4" />}
|
{micOn ? <Mic className="size-4" /> : <MicOff className="size-4" />}
|
||||||
{micOn ? "Mic live" : "Push-to-talk"}
|
{micOn ? "Mic live" : "Push-to-talk"}
|
||||||
</Button>
|
</Button>
|
||||||
|
{micOn && (
|
||||||
|
<div className="flex items-center gap-2 rounded-[10px] border border-signal/30 bg-signal/[0.06] px-3 py-1.5">
|
||||||
|
<Mic className="size-4 text-signal" />
|
||||||
|
<Equalizer
|
||||||
|
bars={micBars}
|
||||||
|
className="w-28"
|
||||||
|
aria-label="Microphone level"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant={listen.active ? "primary" : "outline"}
|
variant={listen.active ? "primary" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -173,6 +193,42 @@ export function VoiceView({
|
|||||||
<Equalizer bars={listenBars} className="w-40" />
|
<Equalizer bars={listenBars} className="w-40" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-ink-faint">
|
||||||
|
<MicOff className="size-3.5" />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={micVol}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
setMicVol(v);
|
||||||
|
mic.setVolume(v);
|
||||||
|
}}
|
||||||
|
aria-label="Mic transmit volume"
|
||||||
|
className="h-1 w-24 cursor-pointer accent-[var(--color-signal)]"
|
||||||
|
/>
|
||||||
|
<span className="mono w-8 text-right">{micVol}%</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-ink-faint">
|
||||||
|
<Volume2 className="size-3.5" />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={listenVol}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
setListenVol(v);
|
||||||
|
listen.setVolume(v);
|
||||||
|
}}
|
||||||
|
aria-label="Listen volume"
|
||||||
|
className="h-1 w-24 cursor-pointer accent-[var(--color-signal)]"
|
||||||
|
/>
|
||||||
|
<span className="mono w-8 text-right">{listenVol}%</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ListMusic, SkipForward, Square } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
|
import {
|
||||||
|
useMediaLoop,
|
||||||
|
useMediaSkip,
|
||||||
|
useMediaState,
|
||||||
|
useMediaStop,
|
||||||
|
useMediaWsSync,
|
||||||
|
} from "@/hooks";
|
||||||
|
import { formatDuration } from "@/lib/format";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistent now-playing bar, fixed above the mobile dock / bottom of the
|
||||||
|
* viewport. Hidden on the /media route (the full player lives there) and
|
||||||
|
* entirely when nothing is queued. Shares the SWR media-state cache with
|
||||||
|
* every other consumer, so state stays consistent across routes.
|
||||||
|
*/
|
||||||
|
export function MiniPlayer() {
|
||||||
|
const ws = useWebSocket();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { data: media } = useMediaState();
|
||||||
|
useMediaWsSync(ws);
|
||||||
|
const skip = useMediaSkip();
|
||||||
|
const stop = useMediaStop();
|
||||||
|
const loop = useMediaLoop();
|
||||||
|
const ambient = useAmbient();
|
||||||
|
|
||||||
|
const hidden = pathname === "/media";
|
||||||
|
const current = hidden ? null : (media?.current ?? null);
|
||||||
|
const playing = media?.playing ?? false;
|
||||||
|
const queueLen = (media?.queue ?? []).length;
|
||||||
|
|
||||||
|
// Keep the ambient tint in sync while the bar is visible on non-media routes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (hidden || !current) return;
|
||||||
|
ambient.set(
|
||||||
|
playing ? "signal" : "amber",
|
||||||
|
playing ? 0.4 : 0.2,
|
||||||
|
"mini-player",
|
||||||
|
);
|
||||||
|
}, [hidden, current, playing, ambient]);
|
||||||
|
|
||||||
|
if (!current) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto fixed inset-x-3 bottom-[calc(4.5rem+env(safe-area-inset-bottom))] z-40",
|
||||||
|
"md:inset-x-auto md:right-5 md:bottom-5 md:w-[22rem]",
|
||||||
|
"animate-fade-up",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="glass flex items-center gap-3 rounded-[14px] px-3 py-2.5 shadow-[0_12px_40px_-16px_oklch(0_0_0/0.7)]">
|
||||||
|
<Link
|
||||||
|
href="/media"
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-3"
|
||||||
|
aria-label="Open full media player"
|
||||||
|
>
|
||||||
|
<span className="relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full border border-hairline bg-white/5">
|
||||||
|
{current.thumbnailUrl ? (
|
||||||
|
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
|
||||||
|
<img
|
||||||
|
src={current.thumbnailUrl}
|
||||||
|
alt=""
|
||||||
|
className={cn(
|
||||||
|
"size-full object-cover",
|
||||||
|
playing && "animate-spin-disc",
|
||||||
|
)}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ListMusic
|
||||||
|
className={cn(
|
||||||
|
"size-4",
|
||||||
|
playing ? "text-signal" : "text-ink-faint",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{playing && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute -inset-1 rounded-full border border-signal/30 animate-pulse-ring"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="eyebrow block !text-[0.55rem] leading-tight">
|
||||||
|
{playing ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span aria-hidden className="flex h-2 items-end gap-[2px]">
|
||||||
|
{[0, 1].map((i) => (
|
||||||
|
<span
|
||||||
|
key={`eq-${i}`}
|
||||||
|
className="w-[3px] animate-eq rounded-full bg-signal"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${i * 180}ms`,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
now playing
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"paused"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-sm text-ink">
|
||||||
|
{current.title}
|
||||||
|
</span>
|
||||||
|
{current.durationMs != null && (
|
||||||
|
<span className="mono block text-[0.6rem] text-ink-faint">
|
||||||
|
{formatDuration(current.durationMs)}
|
||||||
|
{queueLen > 0 && ` · ${queueLen} in queue`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => skip.mutate()}
|
||||||
|
disabled={skip.isPending}
|
||||||
|
aria-label="Skip to next track"
|
||||||
|
className="flex size-8 items-center justify-center rounded-full text-ink-soft transition-colors hover:bg-white/10 hover:text-signal active:scale-95"
|
||||||
|
>
|
||||||
|
<SkipForward className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => stop.mutate()}
|
||||||
|
disabled={stop.isPending}
|
||||||
|
aria-label="Stop playback"
|
||||||
|
className="flex size-8 items-center justify-center rounded-full text-ink-faint transition-colors hover:bg-vermilion/15 hover:text-vermilion active:scale-95"
|
||||||
|
>
|
||||||
|
<Square className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => loop.mutate(!media?.loop)}
|
||||||
|
aria-pressed={!!media?.loop}
|
||||||
|
aria-label="Toggle loop"
|
||||||
|
className={`hidden size-8 items-center justify-center rounded-full text-xs transition-colors sm:flex ${
|
||||||
|
media?.loop
|
||||||
|
? "bg-signal/15 text-signal"
|
||||||
|
: "text-ink-faint hover:bg-white/10 hover:text-ink"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { MiniPlayer } from "@/components/media/mini-player";
|
||||||
import { MobileNav } from "./mobile-nav";
|
import { MobileNav } from "./mobile-nav";
|
||||||
import { NavRail } from "./nav-rail";
|
import { NavRail } from "./nav-rail";
|
||||||
import { TopBar } from "./topbar";
|
import { TopBar } from "./topbar";
|
||||||
@@ -9,7 +10,8 @@ import { TopBar } from "./topbar";
|
|||||||
*
|
*
|
||||||
* < md the side rail collapses (hidden) and a bottom tab bar (MobileNav)
|
* < md the side rail collapses (hidden) and a bottom tab bar (MobileNav)
|
||||||
* takes over navigation; the content region gains bottom padding so the last
|
* takes over navigation; the content region gains bottom padding so the last
|
||||||
* panel never hides behind the dock.
|
* panel never hides behind the dock. A persistent MiniPlayer floats at the
|
||||||
|
* bottom-right whenever a media track is loaded outside /media.
|
||||||
*/
|
*/
|
||||||
export function AppFrame({ children }: { children: React.ReactNode }) {
|
export function AppFrame({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -22,6 +24,7 @@ export function AppFrame({ children }: { children: React.ReactNode }) {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<MobileNav />
|
<MobileNav />
|
||||||
|
<MiniPlayer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Loader2, Pause, Play, Signal, Volume2 } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-playback registry: playing one clip pauses every other instance.
|
||||||
|
* Module-level so it survives across cards without a context provider.
|
||||||
|
*/
|
||||||
|
const activePlayers = new Set<() => void>();
|
||||||
|
function registerPlayer(pause: () => void): () => void {
|
||||||
|
activePlayers.add(pause);
|
||||||
|
return () => activePlayers.delete(pause);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(sec: number): string {
|
||||||
|
if (!Number.isFinite(sec) || sec < 0) return "0:00";
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = Math.floor(sec % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
src: string;
|
||||||
|
label?: string;
|
||||||
|
/** Lifted state: parent highlights the card that owns the active player. */
|
||||||
|
onPlayStateChange?: (playing: boolean) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom recording player replacing native `<audio controls>`:
|
||||||
|
* play/pause with buffering spinner, click-to-seek progress bar, time label,
|
||||||
|
* animated equalizer bars while playing, and single-playback enforcement
|
||||||
|
* (starting one clip pauses all others).
|
||||||
|
*/
|
||||||
|
export function RecordingAudioPlayer({
|
||||||
|
src,
|
||||||
|
label = "Voice recording",
|
||||||
|
onPlayStateChange,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [buffering, setBuffering] = useState(false);
|
||||||
|
const [current, setCurrent] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = new Audio();
|
||||||
|
audio.preload = "metadata";
|
||||||
|
audio.src = src;
|
||||||
|
audioRef.current = audio;
|
||||||
|
|
||||||
|
const onLoadedMeta = () => setDuration(audio.duration || 0);
|
||||||
|
const onTime = () => setCurrent(audio.currentTime);
|
||||||
|
const onEnd = () => {
|
||||||
|
setPlaying(false);
|
||||||
|
setBuffering(false);
|
||||||
|
setCurrent(0);
|
||||||
|
audio.currentTime = 0;
|
||||||
|
};
|
||||||
|
const onPause = () => {
|
||||||
|
setPlaying(false);
|
||||||
|
setBuffering(false);
|
||||||
|
};
|
||||||
|
const onPlaying = () => {
|
||||||
|
setPlaying(true);
|
||||||
|
setBuffering(false);
|
||||||
|
};
|
||||||
|
const onWaiting = () => setBuffering(true);
|
||||||
|
|
||||||
|
audio.addEventListener("loadedmetadata", onLoadedMeta);
|
||||||
|
audio.addEventListener("durationchange", onLoadedMeta);
|
||||||
|
audio.addEventListener("timeupdate", onTime);
|
||||||
|
audio.addEventListener("ended", onEnd);
|
||||||
|
audio.addEventListener("pause", onPause);
|
||||||
|
audio.addEventListener("playing", onPlaying);
|
||||||
|
audio.addEventListener("play", onWaiting);
|
||||||
|
audio.addEventListener("waiting", onWaiting);
|
||||||
|
|
||||||
|
// Single playback: while this player is active, pause any other that starts.
|
||||||
|
const pauseThis = () => audio.pause();
|
||||||
|
let unregister: (() => void) | null = null;
|
||||||
|
const onPlayEvt = () => {
|
||||||
|
for (const other of activePlayers) {
|
||||||
|
if (other !== pauseThis) other();
|
||||||
|
}
|
||||||
|
unregister?.();
|
||||||
|
unregister = registerPlayer(pauseThis);
|
||||||
|
};
|
||||||
|
audio.addEventListener("play", onPlayEvt);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unregister?.();
|
||||||
|
audio.pause();
|
||||||
|
audio.removeEventListener("loadedmetadata", onLoadedMeta);
|
||||||
|
audio.removeEventListener("durationchange", onLoadedMeta);
|
||||||
|
audio.removeEventListener("timeupdate", onTime);
|
||||||
|
audio.removeEventListener("ended", onEnd);
|
||||||
|
audio.removeEventListener("pause", onPause);
|
||||||
|
audio.removeEventListener("playing", onPlaying);
|
||||||
|
audio.removeEventListener("play", onWaiting);
|
||||||
|
audio.removeEventListener("waiting", onWaiting);
|
||||||
|
audio.removeEventListener("play", onPlayEvt);
|
||||||
|
audio.src = "";
|
||||||
|
audioRef.current = null;
|
||||||
|
};
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onPlayStateChange?.(playing || buffering);
|
||||||
|
}, [playing, buffering, onPlayStateChange]);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
if (audio.paused) {
|
||||||
|
setBuffering(true);
|
||||||
|
void audio.play().catch(() => setBuffering(false));
|
||||||
|
} else {
|
||||||
|
audio.pause();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const seek = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio || !Number.isFinite(audio.duration)) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const ratio = Math.min(
|
||||||
|
1,
|
||||||
|
Math.max(0, (e.clientX - rect.left) / rect.width),
|
||||||
|
);
|
||||||
|
audio.currentTime = ratio * audio.duration;
|
||||||
|
setCurrent(audio.currentTime);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pct = duration > 0 ? (current / duration) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-[10px] border bg-white/[0.04] px-3 py-2.5 transition-colors",
|
||||||
|
playing || buffering
|
||||||
|
? "border-signal/40 shadow-[0_0_24px_-10px_var(--color-signal-glow)]"
|
||||||
|
: "border-hairline",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
role="group"
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
aria-pressed={playing}
|
||||||
|
aria-label={playing ? "Pause" : "Play"}
|
||||||
|
className={cn(
|
||||||
|
"flex size-9 shrink-0 items-center justify-center rounded-full border transition-all active:scale-95",
|
||||||
|
playing || buffering
|
||||||
|
? "border-signal/50 bg-signal/15 text-signal"
|
||||||
|
: "border-hairline bg-white/5 text-ink-soft hover:border-signal/40 hover:text-ink",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{buffering ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : playing ? (
|
||||||
|
<Pause className="size-4" />
|
||||||
|
) : (
|
||||||
|
<Play className="size-4 translate-x-[1px]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* seekable progress */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
role="slider"
|
||||||
|
aria-label="Seek"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={Math.round(duration)}
|
||||||
|
aria-valuenow={Math.round(current)}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={seek}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio || !Number.isFinite(audio.duration)) return;
|
||||||
|
if (e.key === "ArrowRight")
|
||||||
|
audio.currentTime = Math.min(
|
||||||
|
audio.duration,
|
||||||
|
audio.currentTime + 5,
|
||||||
|
);
|
||||||
|
if (e.key === "ArrowLeft")
|
||||||
|
audio.currentTime = Math.max(0, audio.currentTime - 5);
|
||||||
|
}}
|
||||||
|
className="group relative h-4 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 overflow-hidden rounded-full bg-white/10">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full rounded-full transition-[width]",
|
||||||
|
(playing || buffering) && "bg-signal/80",
|
||||||
|
!playing && !buffering && "bg-signal/40",
|
||||||
|
)}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(playing || buffering) && (
|
||||||
|
<span
|
||||||
|
className="absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-signal shadow-[0_0_8px_var(--color-signal-glow)] transition-[left]"
|
||||||
|
style={{ left: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mono mt-1 flex items-center justify-between text-[0.6rem] text-ink-faint">
|
||||||
|
<span>{formatTime(current)}</span>
|
||||||
|
{/* equalizer bars while playing */}
|
||||||
|
{(playing || buffering) && (
|
||||||
|
<span className="flex h-3 items-end gap-[2px]" aria-hidden>
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<span
|
||||||
|
key={`eq-${i}`}
|
||||||
|
className="w-[3px] animate-eq rounded-full bg-signal"
|
||||||
|
style={{ animationDelay: `${i * 140}ms`, height: "100%" }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Volume2 className="size-3" />
|
||||||
|
{formatTime(duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small "now playing" chip used by the card header. */
|
||||||
|
export function NowPlayingChip() {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-signal/40 bg-signal/10 px-2 py-0.5 text-[0.6rem] font-medium text-signal">
|
||||||
|
<Signal className="size-3 animate-pulse" />
|
||||||
|
now playing
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -106,6 +106,7 @@ export function useMicTransmit(ws: {
|
|||||||
sendBinary: (data: ArrayBufferLike) => void;
|
sendBinary: (data: ArrayBufferLike) => void;
|
||||||
}) {
|
}) {
|
||||||
const transmitterRef = useRef<MicTransmitter | null>(null);
|
const transmitterRef = useRef<MicTransmitter | null>(null);
|
||||||
|
const [micLevel, setMicLevel] = useState(0);
|
||||||
|
|
||||||
const action = useAction(async (active: boolean) => {
|
const action = useAction(async (active: boolean) => {
|
||||||
if (active) {
|
if (active) {
|
||||||
@@ -116,6 +117,7 @@ export function useMicTransmit(ws: {
|
|||||||
} else {
|
} else {
|
||||||
transmitterRef.current?.stop();
|
transmitterRef.current?.stop();
|
||||||
transmitterRef.current = null;
|
transmitterRef.current = null;
|
||||||
|
setMicLevel(0);
|
||||||
await voiceApi.sendCommand("voice:transmit:stop");
|
await voiceApi.sendCommand("voice:transmit:stop");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -124,7 +126,15 @@ export function useMicTransmit(ws: {
|
|||||||
transmitterRef.current?.setVolume(volume / 100);
|
transmitterRef.current?.setVolume(volume / 100);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { ...action, setVolume };
|
// Poll the analyser RMS so the UI can render a live input meter.
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setMicLevel(transmitterRef.current?.getLevel() ?? 0);
|
||||||
|
}, 120);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { ...action, setVolume, micLevel };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export class MicTransmitter {
|
|||||||
private ctx: AudioContext | null = null;
|
private ctx: AudioContext | null = null;
|
||||||
private stream: MediaStream | null = null;
|
private stream: MediaStream | null = null;
|
||||||
private node: AudioWorkletNode | null = null;
|
private node: AudioWorkletNode | null = null;
|
||||||
|
private analyser: AnalyserNode | null = null;
|
||||||
|
private levelBuf: Float32Array<ArrayBuffer> | null = null;
|
||||||
private active = false;
|
private active = false;
|
||||||
private volume = 1;
|
private volume = 1;
|
||||||
|
|
||||||
@@ -119,6 +121,14 @@ export class MicTransmitter {
|
|||||||
};
|
};
|
||||||
|
|
||||||
source.connect(this.node);
|
source.connect(this.node);
|
||||||
|
|
||||||
|
// Level metering tap: analyser reads the raw mic (pre-volume) so the UI
|
||||||
|
// shows what the mic actually hears. Silent sink keeps the graph alive.
|
||||||
|
this.analyser = this.ctx.createAnalyser();
|
||||||
|
this.analyser.fftSize = 1024;
|
||||||
|
this.levelBuf = new Float32Array(this.analyser.fftSize);
|
||||||
|
source.connect(this.analyser);
|
||||||
|
|
||||||
// Keep the graph alive with an inaudible tail (silent gain) so the
|
// Keep the graph alive with an inaudible tail (silent gain) so the
|
||||||
// worklet keeps pulling mic data without audible feedback.
|
// worklet keeps pulling mic data without audible feedback.
|
||||||
const silent = this.ctx.createGain();
|
const silent = this.ctx.createGain();
|
||||||
@@ -129,6 +139,15 @@ export class MicTransmitter {
|
|||||||
this.active = true;
|
this.active = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** RMS mic level 0..1 since the last call (drives the live meter UI). */
|
||||||
|
getLevel(): number {
|
||||||
|
if (!this.analyser || !this.levelBuf) return 0;
|
||||||
|
this.analyser.getFloatTimeDomainData(this.levelBuf);
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < this.levelBuf.length; i++) sum += this.levelBuf[i] ** 2;
|
||||||
|
return Math.min(1, Math.sqrt(sum / this.levelBuf.length) * 4);
|
||||||
|
}
|
||||||
|
|
||||||
setVolume(volume: number): void {
|
setVolume(volume: number): void {
|
||||||
this.volume = volume;
|
this.volume = volume;
|
||||||
this.node?.port.postMessage({ type: "volume", value: volume });
|
this.node?.port.postMessage({ type: "volume", value: volume });
|
||||||
@@ -139,6 +158,9 @@ export class MicTransmitter {
|
|||||||
this.node?.port.postMessage({ type: "volume", value: 0 });
|
this.node?.port.postMessage({ type: "volume", value: 0 });
|
||||||
this.node?.disconnect();
|
this.node?.disconnect();
|
||||||
this.node = null;
|
this.node = null;
|
||||||
|
this.analyser?.disconnect();
|
||||||
|
this.analyser = null;
|
||||||
|
this.levelBuf = null;
|
||||||
this.stream?.getTracks().forEach((t) => t.stop());
|
this.stream?.getTracks().forEach((t) => t.stop());
|
||||||
this.stream = null;
|
this.stream = null;
|
||||||
this.ctx?.close().catch(() => {});
|
this.ctx?.close().catch(() => {});
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
type ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { mediaApi } from "@/lib/api";
|
|
||||||
import type { MediaItem, MediaState } from "@/lib/types";
|
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
|
||||||
|
|
||||||
interface MediaPlayerContextValue {
|
|
||||||
/** Current play state */
|
|
||||||
playing: boolean;
|
|
||||||
/** Current track, or null */
|
|
||||||
current: MediaItem | null;
|
|
||||||
/** Upcoming queue */
|
|
||||||
queue: MediaItem[];
|
|
||||||
/** Loop mode (replay current track on natural end) */
|
|
||||||
loop: boolean;
|
|
||||||
/** True while a mutation is in flight */
|
|
||||||
pending: boolean;
|
|
||||||
|
|
||||||
/** Skip to next track */
|
|
||||||
skip: () => void;
|
|
||||||
/** Stop playback */
|
|
||||||
stop: () => void;
|
|
||||||
/** Toggle loop mode */
|
|
||||||
toggleLoop: () => 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.3,
|
|
||||||
loop: false,
|
|
||||||
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 queueUrl = useCallback((url: string) => {
|
|
||||||
setPending(true);
|
|
||||||
mediaApi
|
|
||||||
.queue(url, "music")
|
|
||||||
.then((data) => {
|
|
||||||
if (data) setState(data as MediaState);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// ignore
|
|
||||||
})
|
|
||||||
.finally(() => setPending(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleLoop = useCallback(() => {
|
|
||||||
setPending(true);
|
|
||||||
mediaApi
|
|
||||||
.loop(!state.loop)
|
|
||||||
.then((data) => {
|
|
||||||
if (data) setState(data as MediaState);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// ignore
|
|
||||||
})
|
|
||||||
.finally(() => setPending(false));
|
|
||||||
}, [state.loop]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MediaPlayerContext.Provider
|
|
||||||
value={{
|
|
||||||
playing: state.playing,
|
|
||||||
current: state.current,
|
|
||||||
queue: state.queue,
|
|
||||||
loop: state.loop,
|
|
||||||
pending,
|
|
||||||
skip,
|
|
||||||
stop,
|
|
||||||
toggleLoop,
|
|
||||||
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