Files
xiaoxia-saas/apps/web/src/pages/editing-planner/components/BgmSelector.tsx
T
xiaoxia 78d1c88ca1
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1533h11m48s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1533h11m51s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1533h11m51s
CI/CD Pipeline / Build Production API Image (push) Failing after 1533h11m51s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1533h43m28s
feat: 剪辑规划器完整交互 — 7大配置面板 + 时间轴拖拽/缩放/播放头 + 预览播放器 (#284)
2026-07-14 18:15:55 +08:00

293 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* BGM 选择器 — Drawer 形式
* 预设 BGM 列表(按风格分类)、搜索、试听、音量/淡入淡出/人声闪避配置
*/
import React, { useState, useRef, useCallback, useEffect } from "react";
import { Drawer, Slider, Input, Tag, message } from "antd";
import {
getBgmPresets,
type BgmPreset,
type BgmCategory,
type BgmMixConfig,
DEFAULT_BGM_MIX_CONFIG,
} from "@/api/bgm";
const { Search } = Input;
/* ──────────── 分类标签 ──────────── */
const CATEGORY_LIST: {
key: BgmCategory | "all";
label: string;
icon: string;
}[] = [
{ key: "all", label: "全部", icon: "🎶" },
{ key: "轻快", label: "轻快", icon: "🎉" },
{ key: "治愈", label: "治愈", icon: "🌿" },
{ key: "科技", label: "科技", icon: "🔬" },
{ key: "电商", label: "电商", icon: "🛒" },
];
/* ──────────── Props ──────────── */
interface BgmSelectorProps {
open: boolean;
onClose: () => void;
config: BgmMixConfig;
onChange: (config: BgmMixConfig) => void;
}
const BgmSelector: React.FC<BgmSelectorProps> = ({
open,
onClose,
config,
onChange,
}) => {
const [presets, setPresets] = useState<BgmPreset[]>([]);
const [loading, setLoading] = useState(false);
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">(
"all",
);
const [keyword, setKeyword] = useState("");
const [previewingId, setPreviewingId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
/* ── 加载 BGM 列表 ── */
const loadPresets = useCallback(async () => {
setLoading(true);
try {
const params: { category?: string; keyword?: string } = {};
if (activeCategory !== "all") params.category = activeCategory;
if (keyword.trim()) params.keyword = keyword.trim();
const data = await getBgmPresets(params);
setPresets(data);
} catch {
message.error("加载 BGM 列表失败");
} finally {
setLoading(false);
}
}, [activeCategory, keyword]);
useEffect(() => {
if (open) loadPresets();
}, [open, loadPresets]);
/* ── 试听 ── */
const handlePreview = useCallback(
(bgm: BgmPreset) => {
if (previewingId === bgm.id) {
audioRef.current?.pause();
setPreviewingId(null);
return;
}
audioRef.current?.pause();
const audio = new Audio(bgm.url);
audioRef.current = audio;
audio.play().catch(() => {});
audio.onended = () => setPreviewingId(null);
setPreviewingId(bgm.id);
},
[previewingId],
);
/* ── 选中 BGM ── */
const handleSelect = useCallback(
(bgm: BgmPreset) => {
onChange({
...config,
enabled: true,
music_id: bgm.id,
});
},
[config, onChange],
);
/* ── 关闭时停止播放 ── */
const handleClose = useCallback(() => {
audioRef.current?.pause();
setPreviewingId(null);
onClose();
}, [onClose]);
/* ── 移除 BGM ── */
const handleClear = useCallback(() => {
audioRef.current?.pause();
setPreviewingId(null);
onChange({ ...DEFAULT_BGM_MIX_CONFIG });
}, [onChange]);
/* ── 当前选中的 BGM ── */
const selectedBgm = presets.find((p) => p.id === config.music_id);
return (
<Drawer
title="🎵 BGM 音乐选择"
placement="right"
width={420}
open={open}
onClose={handleClose}
className="bgm-selector-drawer"
>
{/* ── 搜索框 ── */}
<div className="bgm-search-row">
<Search
placeholder="搜索 BGM 名称..."
allowClear
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onSearch={() => loadPresets()}
/>
</div>
{/* ── 分类标签 ── */}
<div className="bgm-category-bar">
{CATEGORY_LIST.map((cat) => (
<Tag
key={cat.key}
className={`bgm-category-tag${activeCategory === cat.key ? " active" : ""}`}
onClick={() => setActiveCategory(cat.key)}
>
{cat.icon} {cat.label}
</Tag>
))}
</div>
{/* ── BGM 列表 ── */}
<div className="bgm-list">
{loading && <div className="bgm-loading">加载中...</div>}
{!loading && presets.length === 0 && (
<div className="bgm-empty">暂无 BGM 数据</div>
)}
{presets.map((bgm) => {
const isSelected = config.music_id === bgm.id;
const isPlaying = previewingId === bgm.id;
return (
<div
key={bgm.id}
className={`bgm-item${isSelected ? " selected" : ""}`}
onClick={() => handleSelect(bgm)}
>
<div className="bgm-item-cover">
{bgm.cover_url ? (
<img src={bgm.cover_url} alt={bgm.name} />
) : (
<span className="bgm-item-cover-icon">🎵</span>
)}
</div>
<div className="bgm-item-info">
<div className="bgm-item-name">{bgm.name}</div>
<div className="bgm-item-meta">
<span className="bgm-item-category">{bgm.category}</span>
<span className="bgm-item-duration">
{Math.floor(bgm.duration / 60)}:
{String(Math.floor(bgm.duration % 60)).padStart(2, "0")}
</span>
</div>
{bgm.tags.length > 0 && (
<div className="bgm-item-tags">
{bgm.tags.slice(0, 3).map((t) => (
<span key={t} className="bgm-item-tag">
{t}
</span>
))}
</div>
)}
</div>
<button
className={`bgm-item-preview-btn${isPlaying ? " playing" : ""}`}
onClick={(e) => {
e.stopPropagation();
handlePreview(bgm);
}}
title={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? "⏸" : "▶️"}
</button>
{isSelected && <span className="bgm-item-check">✓</span>}
</div>
);
})}
</div>
{/* ── 混音配置 ── */}
{config.enabled && config.music_id && (
<div className="bgm-mix-config">
<div className="bgm-mix-header">
<span>混音配置</span>
<button className="bgm-mix-clear" onClick={handleClear}>
移除 BGM
</button>
</div>
<div className="bgm-mix-selected">
{selectedBgm
? `当前:${selectedBgm.name}`
: `当前:${config.music_id}`}
</div>
{/* 音量 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
音量 <span className="bgm-mix-value">{config.volume}%</span>
</label>
<Slider
min={0}
max={100}
value={config.volume}
onChange={(v) => onChange({ ...config, volume: v })}
/>
</div>
{/* 淡入 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
淡入{" "}
<span className="bgm-mix-value">
{config.fade_in.toFixed(1)}s
</span>
</label>
<Slider
min={0}
max={3}
step={0.1}
value={config.fade_in}
onChange={(v) => onChange({ ...config, fade_in: v })}
/>
</div>
{/* 淡出 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
淡出{" "}
<span className="bgm-mix-value">
{config.fade_out.toFixed(1)}s
</span>
</label>
<Slider
min={0}
max={3}
step={0.1}
value={config.fade_out}
onChange={(v) => onChange({ ...config, fade_out: v })}
/>
</div>
{/* 人声闪避 */}
<div className="bgm-mix-field bgm-mix-toggle-row">
<label className="bgm-mix-label">人声闪避(sidechain)</label>
<div
className={`ep-toggle${config.voice_dodge ? " active" : ""}`}
onClick={() =>
onChange({ ...config, voice_dodge: !config.voice_dodge })
}
>
<div className="ep-toggle-knob" />
</div>
</div>
</div>
)}
</Drawer>
);
};
export default BgmSelector;