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
293 lines
8.8 KiB
TypeScript
293 lines
8.8 KiB
TypeScript
/**
|
||
* 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;
|