"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 && (
)}
中文翻译
{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 (
)
}