"use client" import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react" import { ChevronDown, ChevronUp, GripHorizontal, Mic2, X } from "lucide-react" import { apiAssetUrl, sourceAudioUrl, type Job, type TranscriptSegment } from "@/lib/api" const STORAGE_KEY = "skg.audio-strip.height" const MIN_HEIGHT = 132 const MAX_HEIGHT = 420 const DEFAULT_HEIGHT = 236 function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)) } function fallbackPeaks(count: number, seedText: string) { let seed = 0 for (let i = 0; i < seedText.length; i++) seed = (seed * 31 + seedText.charCodeAt(i)) % 9973 return Array.from({ length: count }, (_, i) => { const wave = Math.sin((i + seed) * 0.43) * 0.35 + Math.sin((i + seed) * 0.11) * 0.25 const pulse = ((i + seed) % 9) / 18 return clamp(0.22 + Math.abs(wave) + pulse, 0.18, 1) }) } function slicePeaks(peaks: number[], start: number, end: number, duration: number, count = 56) { if (peaks.length === 0 || duration <= 0 || end <= start) return fallbackPeaks(count, `${start}-${end}`) const from = clamp(Math.floor((start / duration) * peaks.length), 0, peaks.length - 1) const to = clamp(Math.ceil((end / duration) * peaks.length), from + 1, peaks.length) const source = peaks.slice(from, to) return Array.from({ length: count }, (_, i) => { const a = Math.floor((i / count) * source.length) const b = Math.max(a + 1, Math.floor(((i + 1) / count) * source.length)) return Math.max(...source.slice(a, b), 0.12) }) } function Waveform({ peaks, active = false }: { peaks: number[]; active?: boolean }) { return (
{peaks.map((p, i) => (
))}
) } function SegmentCard({ segment, peaks, duration, currentTime, }: { segment: TranscriptSegment peaks: number[] duration: number currentTime: number }) { const segDuration = Math.max(1.2, segment.end - segment.start) const width = clamp(180 + segDuration * 42, 220, 520) const segPeaks = slicePeaks(peaks, segment.start, segment.end, duration) const active = currentTime >= segment.start && currentTime <= Math.max(segment.end, segment.start + 0.2) const pointerPct = active ? clamp(((currentTime - segment.start) / Math.max(0.2, segment.end - segment.start)) * 100, 0, 100) : 0 return (
{active && (
)}
{segment.start.toFixed(1)}s to {segment.end.toFixed(1)}s #{segment.index + 1}
{segment.en && (
English

{segment.en}

)}
中文翻译

{segment.zh || 翻译中...}

) } async function decodeWaveform(url: string, targetPeaks = 1800) { const res = await fetch(url) if (!res.ok) throw new Error(`audio ${res.status}`) const arrayBuffer = await res.arrayBuffer() const AudioContextClass = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext if (!AudioContextClass) throw new Error("AudioContext unavailable") const ctx = new AudioContextClass() try { const buffer = await ctx.decodeAudioData(arrayBuffer.slice(0)) const data = buffer.getChannelData(0) const bucket = Math.max(1, Math.floor(data.length / targetPeaks)) let maxPeak = 0.01 const raw: number[] = [] for (let i = 0; i < targetPeaks; i++) { const start = i * bucket const end = Math.min(data.length, start + bucket) let peak = 0 for (let j = start; j < end; j++) peak = Math.max(peak, Math.abs(data[j] || 0)) raw.push(peak) maxPeak = Math.max(maxPeak, peak) } return raw.map((p) => clamp(p / maxPeak, 0.08, 1)) } finally { void ctx.close().catch(() => {}) } } export function AudioStrip({ job, open, onClose }: { job: Job | null; open: boolean; onClose?: () => void }) { const [collapsed, setCollapsed] = useState(false) const [height, setHeight] = useState(DEFAULT_HEIGHT) const [peaks, setPeaks] = useState([]) const [sourceReady, setSourceReady] = useState(false) const [audioKey, setAudioKey] = useState(0) const [currentTime, setCurrentTime] = useState(0) const dragRef = useRef<{ startY: number; startHeight: number } | null>(null) const audioRef = useRef(null) const transcript = job?.transcript ?? [] const audioScript = job?.audio_script const sourceUrl = job ? apiAssetUrl(job.source_audio_url || sourceAudioUrl(job.id)) : "" const processing = !!job && (job.status === "transcribing" || audioScript?.status === "rewriting") const activeSegment = transcript.find((segment) => currentTime >= segment.start && currentTime <= Math.max(segment.end, segment.start + 0.2)) const duration = useMemo(() => { const lastTranscriptEnd = transcript.reduce((max, s) => Math.max(max, s.end || 0), 0) const audioDuration = audioRef.current?.duration return Math.max( Number.isFinite(audioDuration) ? Number(audioDuration) : 0, job?.duration ?? 0, lastTranscriptEnd, 1, ) }, [job?.duration, transcript]) const timelinePeaks = useMemo(() => slicePeaks(peaks, 0, duration, duration, 160), [duration, peaks]) const pointerPct = clamp((currentTime / duration) * 100, 0, 100) useEffect(() => { if (typeof window === "undefined") return const stored = Number(window.localStorage.getItem(STORAGE_KEY) || "") if (Number.isFinite(stored) && stored > 0) setHeight(clamp(stored, MIN_HEIGHT, MAX_HEIGHT)) }, []) useEffect(() => { let cancelled = false let timer: ReturnType | null = null let attempts = 0 setPeaks([]) setSourceReady(false) setCurrentTime(0) if (!job?.id || !open) return setPeaks(fallbackPeaks(1800, `${job.id}-loading`)) const load = () => { attempts += 1 decodeWaveform(sourceUrl) .then((next) => { if (cancelled) return setPeaks(next) setSourceReady(true) setAudioKey((key) => key + 1) }) .catch(() => { if (cancelled) return setSourceReady(false) if (attempts < (processing ? 45 : 6)) { timer = setTimeout(load, 1000) } }) } load() return () => { cancelled = true if (timer) clearTimeout(timer) } }, [job?.id, open, processing, sourceUrl, transcript.length]) if (!open || !job) return null const startDrag = (e: ReactPointerEvent) => { e.preventDefault() dragRef.current = { startY: e.clientY, startHeight: height } const onMove = (ev: PointerEvent) => { if (!dragRef.current) return const next = clamp(dragRef.current.startHeight + (dragRef.current.startY - ev.clientY), MIN_HEIGHT, MAX_HEIGHT) setHeight(next) } const onUp = () => { if (dragRef.current) { try { window.localStorage.setItem(STORAGE_KEY, String(height)) } catch {} } dragRef.current = null window.removeEventListener("pointermove", onMove) window.removeEventListener("pointerup", onUp) } window.addEventListener("pointermove", onMove) window.addEventListener("pointerup", onUp) } return ( ) }