Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 994e14585c | |||
| 7f3ca7c029 | |||
| e4fa8492a5 | |||
| 192947b884 |
@@ -1,273 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* BGM 选择器 — Drawer 形式
|
* BGM 选择器入口(向后兼容)
|
||||||
* 预设 BGM 列表(按风格分类)、搜索、试听、音量/淡入淡出/人声闪避配置
|
* 实际实现位于 ./bgm-selector/ 目录
|
||||||
*/
|
*/
|
||||||
import React, { useState, useRef, useCallback, useEffect } from "react"
|
export { default } from "./bgm-selector"
|
||||||
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
|
|
||||||
|
|||||||
@@ -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<BgmItemProps> = ({
|
||||||
|
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 (
|
||||||
|
<div className={`bgm-item${isSelected ? " selected" : ""}`} onClick={onSelect}>
|
||||||
|
<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">{formatDuration(bgm.duration)}</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()
|
||||||
|
onPreview()
|
||||||
|
}}
|
||||||
|
title={isPlaying ? "暂停" : "试听"}
|
||||||
|
>
|
||||||
|
{isPlaying ? "⏸" : "▶️"}
|
||||||
|
</button>
|
||||||
|
{isSelected && <span className="bgm-item-check">✓</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<BgmMixConfigProps> = ({
|
||||||
|
config,
|
||||||
|
selectedBgm,
|
||||||
|
onChange,
|
||||||
|
onClear,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="bgm-mix-config">
|
||||||
|
<div className="bgm-mix-header">
|
||||||
|
<span>混音配置</span>
|
||||||
|
<button className="bgm-mix-clear" onClick={onClear}>
|
||||||
|
移除 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<BgmSelectorProps> = ({ 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 (
|
||||||
|
<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) => (
|
||||||
|
<BgmItem
|
||||||
|
key={bgm.id}
|
||||||
|
bgm={bgm}
|
||||||
|
isSelected={config.music_id === bgm.id}
|
||||||
|
isPlaying={previewingId === bgm.id}
|
||||||
|
onSelect={() => handleSelect(bgm.id)}
|
||||||
|
onPreview={() => handlePreview(bgm)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 混音配置 */}
|
||||||
|
{config.enabled && config.music_id && (
|
||||||
|
<BgmMixConfigPanel
|
||||||
|
config={config}
|
||||||
|
selectedBgm={selectedBgm}
|
||||||
|
onChange={onChange}
|
||||||
|
onClear={handleClear}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BgmSelector
|
||||||
@@ -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<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],
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ── 停止播放(关闭/移除时调用) ── */
|
||||||
|
const stopPreview = useCallback(() => {
|
||||||
|
audioRef.current?.pause()
|
||||||
|
setPreviewingId(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
presets,
|
||||||
|
loading,
|
||||||
|
activeCategory,
|
||||||
|
setActiveCategory,
|
||||||
|
keyword,
|
||||||
|
setKeyword,
|
||||||
|
previewingId,
|
||||||
|
loadPresets,
|
||||||
|
handlePreview,
|
||||||
|
stopPreview,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user