fix: audio bars and upload process
This commit is contained in:
@@ -3,12 +3,21 @@ import type { MediaParticleData, StreamParticle } from "@/api/types";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
|
||||
import { useAudioSource } from "@/components/audio/use-audio-source";
|
||||
|
||||
interface MediaParticleViewProps {
|
||||
particle: StreamParticle;
|
||||
onEnded: () => void;
|
||||
}
|
||||
|
||||
function formatTime(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function MediaParticleView({
|
||||
particle,
|
||||
onEnded,
|
||||
@@ -23,6 +32,10 @@ export function MediaParticleView({
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
||||
const [currentTimeMs, setCurrentTimeMs] = useState(0);
|
||||
|
||||
const audioSource = useAudioSource(audioEl);
|
||||
|
||||
useEffect(() => {
|
||||
if (cachedUrl) {
|
||||
@@ -75,8 +88,29 @@ export function MediaParticleView({
|
||||
|
||||
if (isAudio) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<audio ref={audioRef} src={url} autoPlay onEnded={onEnded} controls />
|
||||
<div className="relative flex h-full w-full items-center justify-center bg-black/90">
|
||||
<audio
|
||||
ref={(el) => {
|
||||
audioRef.current = el;
|
||||
setAudioEl(el);
|
||||
}}
|
||||
src={url}
|
||||
autoPlay
|
||||
onEnded={onEnded}
|
||||
onTimeUpdate={(e) => {
|
||||
setCurrentTimeMs(e.currentTarget.currentTime * 1000);
|
||||
}}
|
||||
/>
|
||||
|
||||
{audioSource && (
|
||||
<AudioLevelBars sourceNode={audioSource.sourceNode} />
|
||||
)}
|
||||
|
||||
<div className="absolute top-3 right-3 rounded-full bg-black/30 px-2.5 py-1 backdrop-blur-sm">
|
||||
<span className="font-mono text-xs text-white/80">
|
||||
{formatTime(currentTimeMs)} / {formatTime(data.duration_ms)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,69 +1,12 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRecordingStore } from "@/stores/recording-store";
|
||||
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
|
||||
import { useAudioSource } from "@/components/audio/use-audio-source";
|
||||
|
||||
interface RecordingOverlayProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function AudioLevelBars({ mediaStream }: { mediaStream: MediaStream }) {
|
||||
const audioRef = useRef<{ analyser: AnalyserNode; ctx: AudioContext } | null>(
|
||||
null,
|
||||
);
|
||||
const [levels, setLevels] = useState([0, 0, 0]);
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const ctx = new AudioContext();
|
||||
ctx.resume();
|
||||
const source = ctx.createMediaStreamSource(mediaStream);
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
audioRef.current = { analyser, ctx };
|
||||
|
||||
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||||
|
||||
function tick() {
|
||||
analyser.getByteTimeDomainData(dataArray);
|
||||
|
||||
// Compute RMS of waveform (128 = silence baseline)
|
||||
let sumSquares = 0;
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
const normalized = (dataArray[i] - 128) / 128;
|
||||
sumSquares += normalized * normalized;
|
||||
}
|
||||
const rms = Math.sqrt(sumSquares / dataArray.length);
|
||||
|
||||
// VU meter: 3 bars with staggered thresholds
|
||||
const bar0 = Math.min(1, rms * 3);
|
||||
const bar1 = Math.max(0, Math.min(1, (rms - 0.1) * 3));
|
||||
const bar2 = Math.max(0, Math.min(1, (rms - 0.25) * 3));
|
||||
setLevels([bar0, bar1, bar2]);
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
ctx.close();
|
||||
};
|
||||
}, [mediaStream]);
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-1.5">
|
||||
{levels.map((level, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1.5 rounded-full bg-green-400 transition-all duration-75"
|
||||
style={{ height: `${Math.max(6, level * 48)}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordingTimer() {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
@@ -95,6 +38,9 @@ function ReviewPlayback({
|
||||
}) {
|
||||
const urlRef = useRef<string | null>(null);
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const audioElRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
||||
const audioSource = useAudioSource(isVideo ? null : audioEl);
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -123,8 +69,20 @@ function ReviewPlayback({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<audio src={objectUrl} autoPlay loop />
|
||||
<span className="text-sm text-white/60">Playing back audio...</span>
|
||||
<audio
|
||||
ref={(el) => {
|
||||
audioElRef.current = el;
|
||||
setAudioEl(el);
|
||||
}}
|
||||
src={objectUrl}
|
||||
autoPlay
|
||||
loop
|
||||
/>
|
||||
{audioSource ? (
|
||||
<AudioLevelBars sourceNode={audioSource.sourceNode} />
|
||||
) : (
|
||||
<span className="text-sm text-white/60">Playing back audio...</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -136,6 +94,7 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
|
||||
const reviewBlob = useRecordingStore((s) => s.reviewBlob);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hasBeenActiveRef = useRef(false);
|
||||
const recordingAudioSource = useAudioSource(mediaStream ?? null);
|
||||
|
||||
// Track whether we've entered an active state at least once
|
||||
if (
|
||||
@@ -227,9 +186,9 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
|
||||
</div>
|
||||
|
||||
{/* Center: audio level bars (recording with active stream) */}
|
||||
{isRecording && mediaStream && (
|
||||
{isRecording && recordingAudioSource && (
|
||||
<div className="z-10">
|
||||
<AudioLevelBars mediaStream={mediaStream} />
|
||||
<AudioLevelBars sourceNode={recordingAudioSource.sourceNode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ function getMediaMime(mode: "video" | "audio"): string {
|
||||
return VIDEO_FALLBACK_MIME;
|
||||
}
|
||||
|
||||
export function useRecorder(streamId: string | null) {
|
||||
export function useRecorder(
|
||||
streamId: string | null,
|
||||
networkId: string | null,
|
||||
) {
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
@@ -44,7 +47,7 @@ export function useRecorder(streamId: string | null) {
|
||||
}, [setMediaStream]);
|
||||
|
||||
const confirmSend = useCallback(async () => {
|
||||
if (!streamId) return;
|
||||
if (!streamId || !networkId) return;
|
||||
|
||||
const { reviewBlob, reviewDurationMs } = useRecordingStore.getState();
|
||||
if (!reviewBlob) return;
|
||||
@@ -55,10 +58,11 @@ export function useRecorder(streamId: string | null) {
|
||||
const mimeType = reviewBlob.type || VIDEO_FALLBACK_MIME;
|
||||
const fileName = `recording-${Date.now()}.webm`;
|
||||
|
||||
const { object, upload_url } = await apiClient.prepareUpload({
|
||||
file_name: fileName,
|
||||
const { object_id, upload_url } = await apiClient.prepareUpload({
|
||||
network_id: networkId,
|
||||
name: fileName,
|
||||
content_type: mimeType,
|
||||
size_bytes: reviewBlob.size,
|
||||
content_length: reviewBlob.size,
|
||||
});
|
||||
|
||||
await fetch(upload_url, {
|
||||
@@ -67,12 +71,12 @@ export function useRecorder(streamId: string | null) {
|
||||
body: reviewBlob,
|
||||
});
|
||||
|
||||
await apiClient.confirmUpload(object.id);
|
||||
await apiClient.confirmUpload(object_id);
|
||||
|
||||
const particle = await apiClient.createStreamParticle(streamId, {
|
||||
type: "media",
|
||||
data: {
|
||||
object_id: object.id,
|
||||
object_id,
|
||||
duration_ms: reviewDurationMs,
|
||||
mime_type: mimeType,
|
||||
},
|
||||
@@ -91,7 +95,7 @@ export function useRecorder(streamId: string | null) {
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Upload failed");
|
||||
}
|
||||
}, [streamId, setStatus, setError, resetRecording, addParticleToStream]);
|
||||
}, [streamId, networkId, setStatus, setError, resetRecording, addParticleToStream]);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
const currentStatus = useRecordingStore.getState().status;
|
||||
|
||||
Reference in New Issue
Block a user