diff --git a/apps/web/src/pages/editing-planner/components/BgmSelector.tsx b/apps/web/src/pages/editing-planner/components/BgmSelector.tsx index 785524e9e..1d0206adc 100644 --- a/apps/web/src/pages/editing-planner/components/BgmSelector.tsx +++ b/apps/web/src/pages/editing-planner/components/BgmSelector.tsx @@ -1,273 +1,5 @@ /** - * BGM 选择器 — Drawer 形式 - * 预设 BGM 列表(按风格分类)、搜索、试听、音量/淡入淡出/人声闪避配置 + * BGM 选择器入口(向后兼容) + * 实际实现位于 ./bgm-selector/ 目录 */ -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 = ({ open, onClose, config, onChange }) => { - const [presets, setPresets] = useState([]) - const [loading, setLoading] = useState(false) - const [activeCategory, setActiveCategory] = useState("all") - const [keyword, setKeyword] = useState("") - const [previewingId, setPreviewingId] = useState(null) - - const audioRef = useRef(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 ( - - {/* ── 搜索框 ── */} -
- setKeyword(e.target.value)} - onSearch={() => loadPresets()} - /> -
- - {/* ── 分类标签 ── */} -
- {CATEGORY_LIST.map((cat) => ( - setActiveCategory(cat.key)} - > - {cat.icon} {cat.label} - - ))} -
- - {/* ── BGM 列表 ── */} -
- {loading &&
加载中...
} - {!loading && presets.length === 0 &&
暂无 BGM 数据
} - {presets.map((bgm) => { - const isSelected = config.music_id === bgm.id - const isPlaying = previewingId === bgm.id - return ( -
handleSelect(bgm)} - > -
- {bgm.cover_url ? ( - {bgm.name} - ) : ( - 🎵 - )} -
-
-
{bgm.name}
-
- {bgm.category} - - {Math.floor(bgm.duration / 60)}: - {String(Math.floor(bgm.duration % 60)).padStart(2, "0")} - -
- {bgm.tags.length > 0 && ( -
- {bgm.tags.slice(0, 3).map((t) => ( - - {t} - - ))} -
- )} -
- - {isSelected && } -
- ) - })} -
- - {/* ── 混音配置 ── */} - {config.enabled && config.music_id && ( -
-
- 混音配置 - -
- -
- {selectedBgm ? `当前:${selectedBgm.name}` : `当前:${config.music_id}`} -
- - {/* 音量 */} -
- - onChange({ ...config, volume: v })} - /> -
- - {/* 淡入 */} -
- - onChange({ ...config, fade_in: v })} - /> -
- - {/* 淡出 */} -
- - onChange({ ...config, fade_out: v })} - /> -
- - {/* 人声闪避 */} -
- -
onChange({ ...config, voice_dodge: !config.voice_dodge })} - > -
-
-
-
- )} - - ) -} - -export default BgmSelector +export { default } from "./bgm-selector" diff --git a/apps/web/src/pages/editing-planner/components/bgm-selector/BgmItem.tsx b/apps/web/src/pages/editing-planner/components/bgm-selector/BgmItem.tsx new file mode 100644 index 000000000..19cf747e6 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/bgm-selector/BgmItem.tsx @@ -0,0 +1,66 @@ +import React from "react" +import type { BgmPreset } from "@/api/bgm" + +interface BgmItemProps { + bgm: BgmPreset + isSelected: boolean + isPlaying: boolean + onSelect: () => void + onPreview: () => void +} + +/** + * 单个 BGM 列表项组件 + */ +export const BgmItem: React.FC = ({ + bgm, + isSelected, + isPlaying, + onSelect, + onPreview, +}) => { + const formatDuration = (seconds: number) => { + const mins = Math.floor(seconds / 60) + const secs = String(Math.floor(seconds % 60)).padStart(2, "0") + return `${mins}:${secs}` + } + + return ( +
+
+ {bgm.cover_url ? ( + {bgm.name} + ) : ( + 🎵 + )} +
+
+
{bgm.name}
+
+ {bgm.category} + {formatDuration(bgm.duration)} +
+ {bgm.tags.length > 0 && ( +
+ {bgm.tags.slice(0, 3).map((t) => ( + + {t} + + ))} +
+ )} +
+ + {isSelected && } +
+ ) +} diff --git a/apps/web/src/pages/editing-planner/components/bgm-selector/BgmMixConfig.tsx b/apps/web/src/pages/editing-planner/components/bgm-selector/BgmMixConfig.tsx new file mode 100644 index 000000000..ff89561e1 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/bgm-selector/BgmMixConfig.tsx @@ -0,0 +1,88 @@ +import React from "react" +import { Slider } from "antd" +import type { BgmMixConfig as BgmMixConfigType, BgmPreset } from "@/api/bgm" + +interface BgmMixConfigProps { + config: BgmMixConfigType + selectedBgm: BgmPreset | undefined + onChange: (config: BgmMixConfigType) => void + onClear: () => void +} + +/** + * BGM 混音配置面板 + * 音量、淡入淡出、人声闪避等设置 + */ +export const BgmMixConfig: React.FC = ({ + config, + selectedBgm, + onChange, + onClear, +}) => { + return ( +
+
+ 混音配置 + +
+ +
+ {selectedBgm ? `当前:${selectedBgm.name}` : `当前:${config.music_id}`} +
+ + {/* 音量 */} +
+ + onChange({ ...config, volume: v })} + /> +
+ + {/* 淡入 */} +
+ + onChange({ ...config, fade_in: v })} + /> +
+ + {/* 淡出 */} +
+ + onChange({ ...config, fade_out: v })} + /> +
+ + {/* 人声闪避 */} +
+ +
onChange({ ...config, voice_dodge: !config.voice_dodge })} + > +
+
+
+
+ ) +} diff --git a/apps/web/src/pages/editing-planner/components/bgm-selector/index.tsx b/apps/web/src/pages/editing-planner/components/bgm-selector/index.tsx new file mode 100644 index 000000000..51aa19e31 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/bgm-selector/index.tsx @@ -0,0 +1,124 @@ +/** + * BGM 选择器 — Drawer 形式 + * 预设 BGM 列表(按风格分类)、搜索、试听、音量/淡入淡出/人声闪避配置 + */ +import React, { useCallback } from "react" +import { Drawer, Input, Tag } from "antd" +import { type BgmMixConfig, DEFAULT_BGM_MIX_CONFIG } from "@/api/bgm" +import { useBgmSelector, CATEGORY_LIST } from "./useBgmSelector" +import { BgmItem } from "./BgmItem" +import { BgmMixConfig as BgmMixConfigPanel } from "./BgmMixConfig" + +const { Search } = Input + +interface BgmSelectorProps { + open: boolean + onClose: () => void + config: BgmMixConfig + onChange: (config: BgmMixConfig) => void +} + +const BgmSelector: React.FC = ({ open, onClose, config, onChange }) => { + const { + presets, + loading, + activeCategory, + setActiveCategory, + keyword, + setKeyword, + previewingId, + loadPresets, + handlePreview, + stopPreview, + } = useBgmSelector(open) + + /* ── 选中 BGM ── */ + const handleSelect = useCallback( + (bgmId: string) => { + onChange({ + ...config, + enabled: true, + music_id: bgmId, + }) + }, + [config, onChange], + ) + + /* ── 关闭时停止播放 ── */ + const handleClose = useCallback(() => { + stopPreview() + onClose() + }, [stopPreview, onClose]) + + /* ── 移除 BGM ── */ + const handleClear = useCallback(() => { + stopPreview() + onChange({ ...DEFAULT_BGM_MIX_CONFIG }) + }, [stopPreview, onChange]) + + /* ── 当前选中的 BGM ── */ + const selectedBgm = presets.find((p) => p.id === config.music_id) + + return ( + + {/* 搜索框 */} +
+ setKeyword(e.target.value)} + onSearch={() => loadPresets()} + /> +
+ + {/* 分类标签 */} +
+ {CATEGORY_LIST.map((cat) => ( + setActiveCategory(cat.key)} + > + {cat.icon} {cat.label} + + ))} +
+ + {/* BGM 列表 */} +
+ {loading &&
加载中...
} + {!loading && presets.length === 0 &&
暂无 BGM 数据
} + {presets.map((bgm) => ( + handleSelect(bgm.id)} + onPreview={() => handlePreview(bgm)} + /> + ))} +
+ + {/* 混音配置 */} + {config.enabled && config.music_id && ( + + )} +
+ ) +} + +export default BgmSelector diff --git a/apps/web/src/pages/editing-planner/components/bgm-selector/useBgmSelector.ts b/apps/web/src/pages/editing-planner/components/bgm-selector/useBgmSelector.ts new file mode 100644 index 000000000..8a47b6a6a --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/bgm-selector/useBgmSelector.ts @@ -0,0 +1,87 @@ +import { useState, useRef, useCallback, useEffect } from "react" +import { message } from "antd" +import { getBgmPresets, type BgmPreset, type BgmCategory } from "@/api/bgm" + +/* ──────────── 分类标签 ──────────── */ +export 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: "🛒" }, +] + +/** + * BGM 选择器数据与交互 Hook + * 封装列表加载、搜索、分类筛选、试听播放逻辑 + */ +export function useBgmSelector(open: boolean) { + const [presets, setPresets] = useState([]) + const [loading, setLoading] = useState(false) + const [activeCategory, setActiveCategory] = useState("all") + const [keyword, setKeyword] = useState("") + const [previewingId, setPreviewingId] = useState(null) + + const audioRef = useRef(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], + ) + + /* ── 停止播放(关闭/移除时调用) ── */ + const stopPreview = useCallback(() => { + audioRef.current?.pause() + setPreviewingId(null) + }, []) + + return { + presets, + loading, + activeCategory, + setActiveCategory, + keyword, + setKeyword, + previewingId, + loadPresets, + handlePreview, + stopPreview, + } +}