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"
|
||||
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
|
||||
export { default } from "./bgm-selector"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
+264
-3
@@ -1,5 +1,266 @@
|
||||
/**
|
||||
* ClipDetailSection 入口(向后兼容)
|
||||
* 实际实现位于 ./clip-detail-section/ 目录
|
||||
* 片段详情区块
|
||||
*/
|
||||
export { default } from "./clip-detail-section"
|
||||
import React from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { TRANSITION_OPTIONS } from "@/api/template-editor"
|
||||
import type { ClipData, ClipType } from "@/pages/editing-planner/types"
|
||||
import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "@/pages/editing-planner/constants/clipProperties"
|
||||
import { getGenderLabel } from "@/pages/editing-planner/utils/clipProperties"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
|
||||
interface ClipDetailSectionProps {
|
||||
clip: ClipData
|
||||
currentMode: TemplateMode
|
||||
voiceMaterials?: AssetItem[]
|
||||
voiceMaterialsLoading?: boolean
|
||||
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
|
||||
onRefreshVoiceMaterials?: () => void
|
||||
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void
|
||||
onOpenTransitionDrawer?: (clipId: string) => void
|
||||
onOpenSpeedDrawer?: (clipId: string) => void
|
||||
onOpenTtsDrawer?: (clipId: string) => void
|
||||
previewingId: string | null
|
||||
onPreviewVoice: (asset: AssetItem) => void
|
||||
onStopPreview: () => void
|
||||
}
|
||||
|
||||
const ClipDetailSection: React.FC<ClipDetailSectionProps> = ({
|
||||
clip,
|
||||
currentMode,
|
||||
voiceMaterials = [],
|
||||
voiceMaterialsLoading = false,
|
||||
onClipUpdate,
|
||||
onRefreshVoiceMaterials,
|
||||
onClipVoiceSelect,
|
||||
onOpenTransitionDrawer,
|
||||
onOpenSpeedDrawer,
|
||||
onOpenTtsDrawer,
|
||||
previewingId,
|
||||
onPreviewVoice,
|
||||
onStopPreview,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const isTypeDisabled = (t: ClipType) => {
|
||||
if (currentMode === "pip") return t !== "pip"
|
||||
if (currentMode === "voice_over") return t !== "voice"
|
||||
return false
|
||||
}
|
||||
|
||||
const transitionLabel = (() => {
|
||||
const t = clip.transition
|
||||
if (!t || t.type === "none") return "无转场"
|
||||
const opt = TRANSITION_OPTIONS.find((o) => o.value === t.type)
|
||||
return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s`
|
||||
})()
|
||||
|
||||
const speedLabel = clip.speed ? `${clip.speed.rate.toFixed(2)}x` : "1.00x"
|
||||
|
||||
const ttsLabel = (() => {
|
||||
const tts = clip.tts_config
|
||||
if (!tts || tts.mode === "none") return "无配音"
|
||||
if (tts.mode === "upload") return "上传配音"
|
||||
return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}`
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🎞️</span>
|
||||
片段详情
|
||||
</div>
|
||||
|
||||
<div className="ep-clip-detail">
|
||||
{/* 类型选择器 */}
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">类型</div>
|
||||
<div className="ep-clip-type-selector">
|
||||
{(["voice", "pip"] as ClipType[]).map((t) => {
|
||||
const disabled = isTypeDisabled(t)
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
className={`ep-clip-type-btn${clip.type === t ? " active" : ""}${disabled ? " disabled" : ""}`}
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && onClipUpdate(clip.id, { type: t })}
|
||||
>
|
||||
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 时长 */}
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">时长</div>
|
||||
<div className="ep-clip-detail-row">
|
||||
<input
|
||||
className="ep-duration-input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={120}
|
||||
value={clip.duration}
|
||||
onChange={(e) =>
|
||||
onClipUpdate(clip.id, {
|
||||
duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="ep-clip-detail-unit">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 转场效果入口 */}
|
||||
{onOpenTransitionDrawer && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<button
|
||||
className="ep-advanced-btn ep-advanced-btn--transition"
|
||||
onClick={() => onOpenTransitionDrawer(clip.id)}
|
||||
>
|
||||
<span className="ep-advanced-btn-icon">🎬</span>
|
||||
<span className="ep-advanced-btn-label">转场效果</span>
|
||||
<span className="ep-advanced-btn-value">{transitionLabel}</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放速度入口 */}
|
||||
{onOpenSpeedDrawer && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<button
|
||||
className="ep-advanced-btn ep-advanced-btn--speed"
|
||||
onClick={() => onOpenSpeedDrawer(clip.id)}
|
||||
>
|
||||
<span className="ep-advanced-btn-icon">⚡</span>
|
||||
<span className="ep-advanced-btn-label">播放速度</span>
|
||||
<span className="ep-advanced-btn-value">{speedLabel}</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TTS 配音入口 */}
|
||||
{onOpenTtsDrawer && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<button
|
||||
className="ep-advanced-btn ep-advanced-btn--tts"
|
||||
onClick={() => onOpenTtsDrawer(clip.id)}
|
||||
>
|
||||
<span className="ep-advanced-btn-icon">🎙️</span>
|
||||
<span className="ep-advanced-btn-label">TTS 配音</span>
|
||||
<span className="ep-advanced-btn-value">{ttsLabel}</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 素材起始时间 — 仅 voice 类型显示 */}
|
||||
{clip.type === "voice" && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">素材起始时间</div>
|
||||
<div className="ep-clip-detail-row">
|
||||
<input
|
||||
className="ep-duration-input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={9999}
|
||||
step={0.1}
|
||||
value={clip.startOffset}
|
||||
onChange={(e) =>
|
||||
onClipUpdate(clip.id, {
|
||||
startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="ep-clip-detail-unit">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 配音素材选择 — 仅 voice 类型显示 */}
|
||||
{clip.type === "voice" && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">
|
||||
配音素材
|
||||
{onRefreshVoiceMaterials && (
|
||||
<button
|
||||
type="button"
|
||||
className="ep-voice-refresh-btn"
|
||||
title="刷新配音列表"
|
||||
onClick={() => onRefreshVoiceMaterials()}
|
||||
disabled={voiceMaterialsLoading}
|
||||
>
|
||||
{voiceMaterialsLoading ? "⏳" : "🔄"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{voiceMaterialsLoading && voiceMaterials.length === 0 ? (
|
||||
<div className="ep-voice-loading">加载中...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="ep-voice-select-row">
|
||||
<select
|
||||
className="ep-clip-detail-select"
|
||||
value={clip.voice_asset_id ?? ""}
|
||||
onChange={(e) => {
|
||||
const assetId = e.target.value
|
||||
if (!onClipVoiceSelect) return
|
||||
if (!assetId) {
|
||||
onClipVoiceSelect(clip.id, null)
|
||||
} else {
|
||||
const asset = voiceMaterials.find((m) => m.id === assetId)
|
||||
if (asset) onClipVoiceSelect(clip.id, asset)
|
||||
}
|
||||
onStopPreview()
|
||||
}}
|
||||
>
|
||||
<option value="">未选择</option>
|
||||
{voiceMaterials.map((m) => {
|
||||
const gender = getGenderLabel(m)
|
||||
const label = gender ? `${m.name}(${gender})` : m.name
|
||||
return (
|
||||
<option key={m.id} value={m.id}>
|
||||
{label}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
{clip.voice_asset_id && (
|
||||
<button
|
||||
type="button"
|
||||
className="ep-voice-preview-btn"
|
||||
title={previewingId === clip.voice_asset_id ? "暂停" : "试听"}
|
||||
onClick={() => {
|
||||
const asset = voiceMaterials.find((m) => m.id === clip.voice_asset_id)
|
||||
if (asset) onPreviewVoice(asset)
|
||||
}}
|
||||
>
|
||||
{previewingId === clip.voice_asset_id ? "⏸" : "▶️"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{voiceMaterials.length === 0 && (
|
||||
<div className="ep-voice-empty">暂无配音素材,请先上传</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="ep-voice-upload-btn"
|
||||
onClick={() => navigate("/app/voice-materials")}
|
||||
>
|
||||
+ 上传新配音
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClipDetailSection
|
||||
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
import React from "react"
|
||||
import { TRANSITION_OPTIONS } from "@/api/template-editor"
|
||||
import type { ClipData } from "@/pages/editing-planner/types"
|
||||
|
||||
interface AdvancedEntriesProps {
|
||||
clip: ClipData
|
||||
onOpenTransitionDrawer?: (clipId: string) => void
|
||||
onOpenSpeedDrawer?: (clipId: string) => void
|
||||
onOpenTtsDrawer?: (clipId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 高级功能入口按钮(转场/调速/TTS)
|
||||
*/
|
||||
export const AdvancedEntries: React.FC<AdvancedEntriesProps> = ({
|
||||
clip,
|
||||
onOpenTransitionDrawer,
|
||||
onOpenSpeedDrawer,
|
||||
onOpenTtsDrawer,
|
||||
}) => {
|
||||
const transitionLabel = (() => {
|
||||
const t = clip.transition
|
||||
if (!t || t.type === "none") return "无转场"
|
||||
const opt = TRANSITION_OPTIONS.find((o) => o.value === t.type)
|
||||
return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s`
|
||||
})()
|
||||
|
||||
const speedLabel = clip.speed ? `${clip.speed.rate.toFixed(2)}x` : "1.00x"
|
||||
|
||||
const ttsLabel = (() => {
|
||||
const tts = clip.tts_config
|
||||
if (!tts || tts.mode === "none") return "无配音"
|
||||
if (tts.mode === "upload") return "上传配音"
|
||||
return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}`
|
||||
})()
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 转场效果入口 */}
|
||||
{onOpenTransitionDrawer && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<button
|
||||
className="ep-advanced-btn ep-advanced-btn--transition"
|
||||
onClick={() => onOpenTransitionDrawer(clip.id)}
|
||||
>
|
||||
<span className="ep-advanced-btn-icon">🎬</span>
|
||||
<span className="ep-advanced-btn-label">转场效果</span>
|
||||
<span className="ep-advanced-btn-value">{transitionLabel}</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放速度入口 */}
|
||||
{onOpenSpeedDrawer && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<button
|
||||
className="ep-advanced-btn ep-advanced-btn--speed"
|
||||
onClick={() => onOpenSpeedDrawer(clip.id)}
|
||||
>
|
||||
<span className="ep-advanced-btn-icon">⚡</span>
|
||||
<span className="ep-advanced-btn-label">播放速度</span>
|
||||
<span className="ep-advanced-btn-value">{speedLabel}</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TTS 配音入口 */}
|
||||
{onOpenTtsDrawer && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<button
|
||||
className="ep-advanced-btn ep-advanced-btn--tts"
|
||||
onClick={() => onOpenTtsDrawer(clip.id)}
|
||||
>
|
||||
<span className="ep-advanced-btn-icon">🎙️</span>
|
||||
<span className="ep-advanced-btn-label">TTS 配音</span>
|
||||
<span className="ep-advanced-btn-value">{ttsLabel}</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
import React from "react"
|
||||
import type { ClipData, ClipType } from "@/pages/editing-planner/types"
|
||||
import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "@/pages/editing-planner/constants/clipProperties"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
|
||||
interface ClipTypeAndDurationProps {
|
||||
clip: ClipData
|
||||
currentMode: TemplateMode
|
||||
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 片段类型选择 + 时长设置
|
||||
*/
|
||||
export const ClipTypeAndDuration: React.FC<ClipTypeAndDurationProps> = ({
|
||||
clip,
|
||||
currentMode,
|
||||
onClipUpdate,
|
||||
}) => {
|
||||
const isTypeDisabled = (t: ClipType) => {
|
||||
if (currentMode === "pip") return t !== "pip"
|
||||
if (currentMode === "voice_over") return t !== "voice"
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 类型选择器 */}
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">类型</div>
|
||||
<div className="ep-clip-type-selector">
|
||||
{(["voice", "pip"] as ClipType[]).map((t) => {
|
||||
const disabled = isTypeDisabled(t)
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
className={`ep-clip-type-btn${clip.type === t ? " active" : ""}${disabled ? " disabled" : ""}`}
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && onClipUpdate(clip.id, { type: t })}
|
||||
>
|
||||
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 时长 */}
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">时长</div>
|
||||
<div className="ep-clip-detail-row">
|
||||
<input
|
||||
className="ep-duration-input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={120}
|
||||
value={clip.duration}
|
||||
onChange={(e) =>
|
||||
onClipUpdate(clip.id, {
|
||||
duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="ep-clip-detail-unit">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
-132
@@ -1,132 +0,0 @@
|
||||
import React from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { ClipData } from "@/pages/editing-planner/types"
|
||||
import { getGenderLabel } from "@/pages/editing-planner/utils/clipProperties"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface VoiceMaterialSectionProps {
|
||||
clip: ClipData
|
||||
voiceMaterials: AssetItem[]
|
||||
voiceMaterialsLoading: boolean
|
||||
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
|
||||
onRefreshVoiceMaterials?: () => void
|
||||
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void
|
||||
previewingId: string | null
|
||||
onPreviewVoice: (asset: AssetItem) => void
|
||||
onStopPreview: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 配音素材选择区(仅 voice 类型显示)
|
||||
*/
|
||||
export const VoiceMaterialSection: React.FC<VoiceMaterialSectionProps> = ({
|
||||
clip,
|
||||
voiceMaterials,
|
||||
voiceMaterialsLoading,
|
||||
onClipUpdate,
|
||||
onRefreshVoiceMaterials,
|
||||
onClipVoiceSelect,
|
||||
previewingId,
|
||||
onPreviewVoice,
|
||||
onStopPreview,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 素材起始时间 */}
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">素材起始时间</div>
|
||||
<div className="ep-clip-detail-row">
|
||||
<input
|
||||
className="ep-duration-input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={9999}
|
||||
step={0.1}
|
||||
value={clip.startOffset}
|
||||
onChange={(e) =>
|
||||
onClipUpdate(clip.id, {
|
||||
startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="ep-clip-detail-unit">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 配音素材选择 */}
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">
|
||||
配音素材
|
||||
{onRefreshVoiceMaterials && (
|
||||
<button
|
||||
type="button"
|
||||
className="ep-voice-refresh-btn"
|
||||
title="刷新配音列表"
|
||||
onClick={() => onRefreshVoiceMaterials()}
|
||||
disabled={voiceMaterialsLoading}
|
||||
>
|
||||
{voiceMaterialsLoading ? "⏳" : "🔄"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{voiceMaterialsLoading && voiceMaterials.length === 0 ? (
|
||||
<div className="ep-voice-loading">加载中...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="ep-voice-select-row">
|
||||
<select
|
||||
className="ep-clip-detail-select"
|
||||
value={clip.voice_asset_id ?? ""}
|
||||
onChange={(e) => {
|
||||
const assetId = e.target.value
|
||||
if (!onClipVoiceSelect) return
|
||||
if (!assetId) {
|
||||
onClipVoiceSelect(clip.id, null)
|
||||
} else {
|
||||
const asset = voiceMaterials.find((m) => m.id === assetId)
|
||||
if (asset) onClipVoiceSelect(clip.id, asset)
|
||||
}
|
||||
onStopPreview()
|
||||
}}
|
||||
>
|
||||
<option value="">未选择</option>
|
||||
{voiceMaterials.map((m) => {
|
||||
const gender = getGenderLabel(m)
|
||||
const label = gender ? `${m.name}(${gender})` : m.name
|
||||
return (
|
||||
<option key={m.id} value={m.id}>
|
||||
{label}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
{clip.voice_asset_id && (
|
||||
<button
|
||||
type="button"
|
||||
className="ep-voice-preview-btn"
|
||||
title={previewingId === clip.voice_asset_id ? "暂停" : "试听"}
|
||||
onClick={() => {
|
||||
const asset = voiceMaterials.find((m) => m.id === clip.voice_asset_id)
|
||||
if (asset) onPreviewVoice(asset)
|
||||
}}
|
||||
>
|
||||
{previewingId === clip.voice_asset_id ? "⏸" : "▶️"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{voiceMaterials.length === 0 && (
|
||||
<div className="ep-voice-empty">暂无配音素材,请先上传</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button className="ep-voice-upload-btn" onClick={() => navigate("/app/voice-materials")}>
|
||||
+ 上传新配音
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* 片段详情区块
|
||||
*/
|
||||
import React from "react"
|
||||
import type { ClipData } from "@/pages/editing-planner/types"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
import { ClipTypeAndDuration } from "./ClipTypeAndDuration"
|
||||
import { AdvancedEntries } from "./AdvancedEntries"
|
||||
import { VoiceMaterialSection } from "./VoiceMaterialSection"
|
||||
|
||||
interface ClipDetailSectionProps {
|
||||
clip: ClipData
|
||||
currentMode: TemplateMode
|
||||
voiceMaterials?: AssetItem[]
|
||||
voiceMaterialsLoading?: boolean
|
||||
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
|
||||
onRefreshVoiceMaterials?: () => void
|
||||
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void
|
||||
onOpenTransitionDrawer?: (clipId: string) => void
|
||||
onOpenSpeedDrawer?: (clipId: string) => void
|
||||
onOpenTtsDrawer?: (clipId: string) => void
|
||||
previewingId: string | null
|
||||
onPreviewVoice: (asset: AssetItem) => void
|
||||
onStopPreview: () => void
|
||||
}
|
||||
|
||||
const ClipDetailSection: React.FC<ClipDetailSectionProps> = ({
|
||||
clip,
|
||||
currentMode,
|
||||
voiceMaterials = [],
|
||||
voiceMaterialsLoading = false,
|
||||
onClipUpdate,
|
||||
onRefreshVoiceMaterials,
|
||||
onClipVoiceSelect,
|
||||
onOpenTransitionDrawer,
|
||||
onOpenSpeedDrawer,
|
||||
onOpenTtsDrawer,
|
||||
previewingId,
|
||||
onPreviewVoice,
|
||||
onStopPreview,
|
||||
}) => {
|
||||
return (
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🎞️</span>
|
||||
片段详情
|
||||
</div>
|
||||
|
||||
<div className="ep-clip-detail">
|
||||
<ClipTypeAndDuration clip={clip} currentMode={currentMode} onClipUpdate={onClipUpdate} />
|
||||
|
||||
<AdvancedEntries
|
||||
clip={clip}
|
||||
onOpenTransitionDrawer={onOpenTransitionDrawer}
|
||||
onOpenSpeedDrawer={onOpenSpeedDrawer}
|
||||
onOpenTtsDrawer={onOpenTtsDrawer}
|
||||
/>
|
||||
|
||||
{clip.type === "voice" && (
|
||||
<VoiceMaterialSection
|
||||
clip={clip}
|
||||
voiceMaterials={voiceMaterials}
|
||||
voiceMaterialsLoading={voiceMaterialsLoading}
|
||||
onClipUpdate={onClipUpdate}
|
||||
onRefreshVoiceMaterials={onRefreshVoiceMaterials}
|
||||
onClipVoiceSelect={onClipVoiceSelect}
|
||||
previewingId={previewingId}
|
||||
onPreviewVoice={onPreviewVoice}
|
||||
onStopPreview={onStopPreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClipDetailSection
|
||||
Reference in New Issue
Block a user