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:
asepharyana
2026-08-22 12:53:18 +07:00
parent df69b3f05d
commit 4e0c21d86c
10 changed files with 677 additions and 184 deletions
@@ -66,6 +66,8 @@ export class MicTransmitter {
private ctx: AudioContext | null = null;
private stream: MediaStream | null = null;
private node: AudioWorkletNode | null = null;
private analyser: AnalyserNode | null = null;
private levelBuf: Float32Array<ArrayBuffer> | null = null;
private active = false;
private volume = 1;
@@ -119,6 +121,14 @@ export class MicTransmitter {
};
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
// worklet keeps pulling mic data without audible feedback.
const silent = this.ctx.createGain();
@@ -129,6 +139,15 @@ export class MicTransmitter {
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 {
this.volume = 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?.disconnect();
this.node = null;
this.analyser?.disconnect();
this.analyser = null;
this.levelBuf = null;
this.stream?.getTracks().forEach((t) => t.stop());
this.stream = null;
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;
}