350 lines
14 KiB
TypeScript
350 lines
14 KiB
TypeScript
import { useEffect, useRef, useCallback, useState } from "react";
|
||
import WaveSurfer from "wavesurfer.js";
|
||
import { ChevronDown } from "lucide-react";
|
||
import type { TimelineSegment } from "@/features/home/model/useTimelineEditor";
|
||
import type { Material } from "@/shared/types/material";
|
||
|
||
interface TimelineEditorProps {
|
||
audioDuration: number;
|
||
audioUrl: string;
|
||
segments: TimelineSegment[];
|
||
materials: Material[];
|
||
outputAspectRatio: "9:16" | "16:9";
|
||
onOutputAspectRatioChange: (ratio: "9:16" | "16:9") => void;
|
||
onReorderSegment: (fromIdx: number, toIdx: number) => void;
|
||
onClickSegment: (segment: TimelineSegment) => void;
|
||
}
|
||
|
||
function formatTime(sec: number): string {
|
||
const m = Math.floor(sec / 60);
|
||
const s = sec % 60;
|
||
return `${String(m).padStart(2, "0")}:${s.toFixed(1).padStart(4, "0")}`;
|
||
}
|
||
|
||
export function TimelineEditor({
|
||
audioDuration,
|
||
audioUrl,
|
||
segments,
|
||
materials,
|
||
outputAspectRatio,
|
||
onOutputAspectRatioChange,
|
||
onReorderSegment,
|
||
onClickSegment,
|
||
}: TimelineEditorProps) {
|
||
const waveRef = useRef<HTMLDivElement>(null);
|
||
const wsRef = useRef<WaveSurfer | null>(null);
|
||
const [waveReady, setWaveReady] = useState(false);
|
||
const [isPlaying, setIsPlaying] = useState(false);
|
||
|
||
// Refs for high-frequency DOM updates (avoid 60fps re-renders)
|
||
const playheadRef = useRef<HTMLDivElement>(null);
|
||
const timeRef = useRef<HTMLSpanElement>(null);
|
||
const audioDurationRef = useRef(audioDuration);
|
||
|
||
useEffect(() => {
|
||
audioDurationRef.current = audioDuration;
|
||
}, [audioDuration]);
|
||
|
||
// Drag-to-reorder state
|
||
const [dragFromIdx, setDragFromIdx] = useState<number | null>(null);
|
||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||
|
||
// Aspect ratio dropdown
|
||
const [ratioOpen, setRatioOpen] = useState(false);
|
||
const ratioRef = useRef<HTMLDivElement>(null);
|
||
const ratioOptions = [
|
||
{ value: "9:16" as const, label: "竖屏 9:16" },
|
||
{ value: "16:9" as const, label: "横屏 16:9" },
|
||
];
|
||
const currentRatioLabel =
|
||
ratioOptions.find((opt) => opt.value === outputAspectRatio)?.label ?? "竖屏 9:16";
|
||
|
||
useEffect(() => {
|
||
const handler = (e: MouseEvent) => {
|
||
if (ratioRef.current && !ratioRef.current.contains(e.target as Node)) {
|
||
setRatioOpen(false);
|
||
}
|
||
};
|
||
if (ratioOpen) document.addEventListener("mousedown", handler);
|
||
return () => document.removeEventListener("mousedown", handler);
|
||
}, [ratioOpen]);
|
||
|
||
// Create / recreate wavesurfer when audioUrl changes
|
||
useEffect(() => {
|
||
if (!waveRef.current || !audioUrl) return;
|
||
|
||
const playheadEl = playheadRef.current;
|
||
const timeEl = timeRef.current;
|
||
|
||
// Destroy previous instance
|
||
if (wsRef.current) {
|
||
wsRef.current.destroy();
|
||
wsRef.current = null;
|
||
}
|
||
|
||
const ws = WaveSurfer.create({
|
||
container: waveRef.current,
|
||
height: 56,
|
||
waveColor: "#6d28d9",
|
||
progressColor: "#a855f7",
|
||
barWidth: 2,
|
||
barGap: 1,
|
||
barRadius: 2,
|
||
cursorWidth: 1,
|
||
cursorColor: "#e879f9",
|
||
interact: true,
|
||
normalize: true,
|
||
});
|
||
|
||
// Click waveform → seek + auto-play
|
||
ws.on("interaction", () => ws.play());
|
||
ws.on("play", () => setIsPlaying(true));
|
||
ws.on("pause", () => setIsPlaying(false));
|
||
ws.on("finish", () => {
|
||
setIsPlaying(false);
|
||
if (playheadRef.current) playheadRef.current.style.display = "none";
|
||
});
|
||
// High-frequency: update playhead + time via refs (no React re-render)
|
||
ws.on("timeupdate", (time: number) => {
|
||
const dur = audioDurationRef.current;
|
||
if (playheadRef.current && dur > 0) {
|
||
playheadRef.current.style.left = `${(time / dur) * 100}%`;
|
||
playheadRef.current.style.display = "block";
|
||
}
|
||
if (timeRef.current) {
|
||
timeRef.current.textContent = formatTime(time);
|
||
}
|
||
});
|
||
|
||
ws.load(audioUrl);
|
||
wsRef.current = ws;
|
||
|
||
return () => {
|
||
ws.destroy();
|
||
wsRef.current = null;
|
||
setIsPlaying(false);
|
||
if (playheadEl) playheadEl.style.display = "none";
|
||
if (timeEl) timeEl.textContent = formatTime(0);
|
||
};
|
||
}, [audioUrl, waveReady]);
|
||
|
||
// Callback ref to detect when waveRef div mounts
|
||
const waveCallbackRef = useCallback((node: HTMLDivElement | null) => {
|
||
(waveRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||
setWaveReady(!!node);
|
||
}, []);
|
||
|
||
const handlePlayPause = useCallback(() => {
|
||
wsRef.current?.playPause();
|
||
}, []);
|
||
|
||
// Drag-to-reorder handlers
|
||
const handleDragStart = useCallback((idx: number, e: React.DragEvent) => {
|
||
setDragFromIdx(idx);
|
||
e.dataTransfer.effectAllowed = "move";
|
||
e.dataTransfer.setData("text/plain", String(idx));
|
||
}, []);
|
||
|
||
const handleDragOver = useCallback((idx: number, e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
setDragOverIdx(idx);
|
||
}, []);
|
||
|
||
const handleDragLeave = useCallback(() => {
|
||
setDragOverIdx(null);
|
||
}, []);
|
||
|
||
const handleDrop = useCallback((toIdx: number, e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
const fromIdx = parseInt(e.dataTransfer.getData("text/plain"), 10);
|
||
if (!isNaN(fromIdx) && fromIdx !== toIdx) {
|
||
onReorderSegment(fromIdx, toIdx);
|
||
}
|
||
setDragFromIdx(null);
|
||
setDragOverIdx(null);
|
||
}, [onReorderSegment]);
|
||
|
||
const handleDragEnd = useCallback(() => {
|
||
setDragFromIdx(null);
|
||
setDragOverIdx(null);
|
||
}, []);
|
||
|
||
// Filter visible vs overflow segments
|
||
const visibleSegments = segments.filter((s) => s.start < audioDuration);
|
||
const overflowSegments = segments.filter((s) => s.start >= audioDuration);
|
||
const hasSegments = visibleSegments.length > 0;
|
||
|
||
return (
|
||
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||
🎞️ 时间轴编辑
|
||
</h2>
|
||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||
<div ref={ratioRef} className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => setRatioOpen((v) => !v)}
|
||
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 whitespace-nowrap flex items-center gap-1 transition-all"
|
||
title="设置输出画面比例"
|
||
>
|
||
画面: {currentRatioLabel}
|
||
<ChevronDown className={`h-3 w-3 transition-transform ${ratioOpen ? "rotate-180" : ""}`} />
|
||
</button>
|
||
{ratioOpen && (
|
||
<div className="absolute right-0 top-full mt-1 bg-gray-800 border border-white/20 rounded-lg shadow-xl py-1 z-50 min-w-[106px]">
|
||
{ratioOptions.map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
onClick={() => {
|
||
onOutputAspectRatioChange(opt.value);
|
||
setRatioOpen(false);
|
||
}}
|
||
className={`w-full text-left px-3 py-1.5 text-xs transition-colors ${
|
||
outputAspectRatio === opt.value
|
||
? "bg-purple-600/40 text-purple-200"
|
||
: "text-gray-300 hover:bg-white/10"
|
||
}`}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{audioUrl && (
|
||
<>
|
||
<button
|
||
onClick={handlePlayPause}
|
||
className="w-7 h-7 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||
title={isPlaying ? "暂停" : "播放"}
|
||
>
|
||
{isPlaying ? "⏸" : "▶"}
|
||
</button>
|
||
<span ref={timeRef} className="tabular-nums">00:00.0</span>
|
||
<span className="text-gray-600">/</span>
|
||
<span className="tabular-nums">{formatTime(audioDuration)}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Waveform — always rendered so ref stays mounted */}
|
||
<div className="relative mb-1">
|
||
<div ref={waveCallbackRef} className="rounded-lg overflow-hidden bg-black/20 cursor-pointer" style={{ minHeight: 56 }} />
|
||
</div>
|
||
|
||
{/* Segment blocks or empty placeholder */}
|
||
{hasSegments ? (
|
||
<>
|
||
<div className="relative h-14 flex select-none">
|
||
{/* Playhead — syncs with audio playback */}
|
||
<div
|
||
ref={playheadRef}
|
||
className="absolute top-0 h-full w-0.5 bg-fuchsia-400 z-10 pointer-events-none"
|
||
style={{ display: "none", left: "0%" }}
|
||
/>
|
||
{visibleSegments.map((seg, i) => {
|
||
const left = (seg.start / audioDuration) * 100;
|
||
const width = ((seg.end - seg.start) / audioDuration) * 100;
|
||
const segDur = seg.end - seg.start;
|
||
const isDragTarget = dragOverIdx === i && dragFromIdx !== i;
|
||
|
||
// Compute loop portion for the last visible segment
|
||
const isLastVisible = i === visibleSegments.length - 1;
|
||
let loopPercent = 0;
|
||
if (isLastVisible && audioDuration > 0) {
|
||
const mat = materials.find((m) => m.id === seg.materialId);
|
||
const matDur = mat?.duration_sec ?? 0;
|
||
const effDur = (seg.sourceEnd > seg.sourceStart)
|
||
? (seg.sourceEnd - seg.sourceStart)
|
||
: Math.max(matDur - seg.sourceStart, 0);
|
||
if (effDur > 0 && segDur > effDur + 0.1) {
|
||
loopPercent = ((segDur - effDur) / segDur) * 100;
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div key={seg.id} className="absolute top-0 h-full" style={{ left: `${left}%`, width: `${width}%` }}>
|
||
<button
|
||
draggable
|
||
onDragStart={(e) => handleDragStart(i, e)}
|
||
onDragOver={(e) => handleDragOver(i, e)}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={(e) => handleDrop(i, e)}
|
||
onDragEnd={handleDragEnd}
|
||
onClick={() => onClickSegment(seg)}
|
||
className={`relative w-full h-full rounded-lg flex flex-col items-center justify-center overflow-hidden cursor-grab active:cursor-grabbing transition-all border ${
|
||
isDragTarget
|
||
? "ring-2 ring-purple-400 border-purple-400 scale-[1.02]"
|
||
: dragFromIdx === i
|
||
? "opacity-50 border-white/10"
|
||
: "hover:opacity-90 border-white/10"
|
||
}`}
|
||
style={{ backgroundColor: seg.color + "33", borderColor: isDragTarget ? undefined : seg.color + "66" }}
|
||
title={`拖拽可调换顺序 · 点击设置截取范围\n${seg.materialName}\n${segDur.toFixed(1)}s${loopPercent > 0 ? ` (含循环 ${(segDur * loopPercent / 100).toFixed(1)}s)` : ""}`}
|
||
>
|
||
<span className="text-[11px] text-white/90 truncate max-w-full px-1 leading-tight z-[1]">
|
||
{seg.materialName}
|
||
</span>
|
||
<span className="text-[10px] text-white/60 leading-tight z-[1]">
|
||
{segDur.toFixed(1)}s
|
||
</span>
|
||
{seg.sourceStart > 0 && (
|
||
<span className="text-[9px] text-amber-400/80 leading-tight z-[1]">
|
||
✂ {seg.sourceStart.toFixed(1)}s
|
||
</span>
|
||
)}
|
||
{/* Loop fill stripe overlay */}
|
||
{loopPercent > 0 && (
|
||
<div
|
||
className="absolute top-0 right-0 h-full pointer-events-none flex items-center justify-center"
|
||
style={{
|
||
width: `${loopPercent}%`,
|
||
background: `repeating-linear-gradient(-45deg, transparent, transparent 3px, rgba(255,255,255,0.07) 3px, rgba(255,255,255,0.07) 6px)`,
|
||
borderLeft: "1px dashed rgba(255,255,255,0.25)",
|
||
}}
|
||
>
|
||
<span className="text-[9px] text-white/30">循环</span>
|
||
</div>
|
||
)}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Overflow segments — shown as gray chips */}
|
||
{overflowSegments.length > 0 && (
|
||
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
|
||
<span className="text-[10px] text-gray-500">未使用:</span>
|
||
{overflowSegments.map((seg) => (
|
||
<span
|
||
key={seg.id}
|
||
className="text-[10px] text-gray-500 bg-white/5 border border-white/10 rounded px-1.5 py-0.5"
|
||
>
|
||
{seg.materialName}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<p className="text-[10px] text-gray-500 mt-1.5">
|
||
点击波形定位播放 · 拖拽色块调换顺序 · 点击色块设置截取范围
|
||
</p>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="h-14 bg-white/5 rounded-lg" />
|
||
<p className="text-[10px] text-gray-500 mt-1.5">
|
||
选中配音和素材后可编辑时间轴
|
||
</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|