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"
|
||||
|
||||
@@ -1,5 +1,286 @@
|
||||
/**
|
||||
* 封面选择器入口(向后兼容)
|
||||
* 实际实现位于 ./cover-selector/ 目录
|
||||
* 封面选择器
|
||||
* 抽帧选封面 + 上传自定义封面 + 智能封面推荐
|
||||
*/
|
||||
export { default } from "./cover-selector"
|
||||
import React, { useCallback, useRef, useState } from "react"
|
||||
import { Drawer } from "antd"
|
||||
import type { CoverConfig, CoverMode } from "../types"
|
||||
import { DEFAULT_COVER_CONFIG } from "../types"
|
||||
|
||||
interface CoverSelectorProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: CoverConfig
|
||||
onChange: (config: CoverConfig) => void
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
/** 封面模式标签 */
|
||||
const MODE_LABELS: Record<CoverMode, string> = {
|
||||
auto: "智能封面",
|
||||
frame: "抽帧选封面",
|
||||
upload: "上传封面",
|
||||
}
|
||||
|
||||
/** 封面模式图标 */
|
||||
const MODE_ICONS: Record<CoverMode, string> = {
|
||||
auto: "🤖",
|
||||
frame: "🎞️",
|
||||
upload: "📤",
|
||||
}
|
||||
|
||||
const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
totalDuration,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const update = useCallback(
|
||||
(partial: Partial<CoverConfig>) => {
|
||||
onChange({ ...config, ...partial })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled })
|
||||
}, [config.enabled, onChange])
|
||||
|
||||
/** 切换模式 */
|
||||
const handleModeChange = useCallback(
|
||||
(mode: CoverMode) => {
|
||||
update({ mode })
|
||||
},
|
||||
[update],
|
||||
)
|
||||
|
||||
/** 处理文件上传 */
|
||||
const handleFileUpload = useCallback(
|
||||
(file: File) => {
|
||||
if (!file.type.startsWith("image/")) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const url = e.target?.result as string
|
||||
update({ upload_url: url, thumbnail_url: url, mode: "upload" })
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
},
|
||||
[update],
|
||||
)
|
||||
|
||||
/** 拖拽上传 */
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleFileUpload(file)
|
||||
},
|
||||
[handleFileUpload],
|
||||
)
|
||||
|
||||
/** 使用 AI 推荐时间 */
|
||||
const handleUseAiSuggestion = useCallback(() => {
|
||||
if (config.ai_suggested_time !== null) {
|
||||
update({ frame_time: config.ai_suggested_time, mode: "frame" })
|
||||
}
|
||||
}, [config.ai_suggested_time, update])
|
||||
|
||||
/** 格式化时间 */
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
const ms = Math.floor((seconds % 1) * 10)
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="封面选择"
|
||||
placement="right"
|
||||
width={440}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="cover-selector-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="cover-header">
|
||||
<span className="cover-header-label">启用自定义封面</span>
|
||||
<label className="cover-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enabled}
|
||||
onChange={(e) => update({ enabled: e.target.checked })}
|
||||
/>
|
||||
<span className="cover-switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 模式选择 */}
|
||||
<div className="cover-mode-section">
|
||||
<div className="cover-section-title">封面来源</div>
|
||||
<div className="cover-mode-tabs">
|
||||
{(["auto", "frame", "upload"] as CoverMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`cover-mode-tab${config.mode === m ? " active" : ""}`}
|
||||
onClick={() => handleModeChange(m)}
|
||||
>
|
||||
<span className="cover-mode-icon">{MODE_ICONS[m]}</span>
|
||||
<span className="cover-mode-label">{MODE_LABELS[m]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模式内容区 */}
|
||||
<div className="cover-mode-content">
|
||||
{/* 智能封面 */}
|
||||
{config.mode === "auto" && (
|
||||
<div className="cover-auto-section">
|
||||
<div className="cover-auto-desc">
|
||||
AI 将分析视频内容,自动选择最具吸引力的画面作为封面。
|
||||
</div>
|
||||
{config.ai_suggested_time !== null ? (
|
||||
<div className="cover-auto-suggestion">
|
||||
<div className="cover-auto-badge">AI 推荐</div>
|
||||
<div className="cover-auto-time">
|
||||
推荐时间点:{formatTime(config.ai_suggested_time)}
|
||||
</div>
|
||||
<button className="cover-auto-use-btn" onClick={handleUseAiSuggestion}>
|
||||
使用此时间点
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cover-auto-pending">
|
||||
<div className="cover-auto-spinner" />
|
||||
<span>AI 分析中...(生成视频后自动推荐)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 抽帧选封面 */}
|
||||
{config.mode === "frame" && (
|
||||
<div className="cover-frame-section">
|
||||
<div className="cover-frame-preview">
|
||||
<div className="cover-frame-placeholder">
|
||||
<span className="cover-frame-icon">🎞️</span>
|
||||
<span className="cover-frame-time">{formatTime(config.frame_time)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cover-frame-timeline">
|
||||
<div className="cover-frame-slider-header">
|
||||
<span className="cover-frame-slider-label">拖动选择封面帧</span>
|
||||
<span className="cover-frame-slider-value">{formatTime(config.frame_time)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="cover-frame-slider"
|
||||
min={0}
|
||||
max={Math.max(totalDuration, 1)}
|
||||
step={0.1}
|
||||
value={config.frame_time}
|
||||
onChange={(e) => update({ frame_time: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="cover-frame-range">
|
||||
<span>00:00</span>
|
||||
<span>{formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 快捷时间点 */}
|
||||
<div className="cover-frame-quick">
|
||||
<span className="cover-quick-label">快捷选帧:</span>
|
||||
{[0, 0.25, 0.5, 0.75].map((ratio) => {
|
||||
const t = totalDuration * ratio
|
||||
return (
|
||||
<button
|
||||
key={ratio}
|
||||
className="cover-quick-btn"
|
||||
onClick={() => update({ frame_time: t })}
|
||||
>
|
||||
{formatTime(t)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传封面 */}
|
||||
{config.mode === "upload" && (
|
||||
<div className="cover-upload-section">
|
||||
<div
|
||||
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{config.upload_url ? (
|
||||
<div className="cover-upload-preview">
|
||||
<img src={config.upload_url} alt="封面预览" />
|
||||
<div className="cover-upload-overlay">点击更换</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cover-upload-placeholder">
|
||||
<span className="cover-upload-icon">📤</span>
|
||||
<span className="cover-upload-text">点击或拖拽上传封面图片</span>
|
||||
<span className="cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFileUpload(file)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 封面预览 */}
|
||||
<div className="cover-preview-section">
|
||||
<div className="cover-section-title">封面预览</div>
|
||||
<div className="cover-preview-box">
|
||||
{config.upload_url ? (
|
||||
<img src={config.upload_url} alt="封面预览" className="cover-preview-img" />
|
||||
) : (
|
||||
<div className="cover-preview-placeholder">
|
||||
<span className="cover-preview-icon">🖼️</span>
|
||||
<span className="cover-preview-text">
|
||||
{config.mode === "auto"
|
||||
? "AI 智能选择"
|
||||
: config.mode === "frame"
|
||||
? `帧 ${formatTime(config.frame_time)}`
|
||||
: "未上传封面"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="cover-preview-ratio">16:9</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="cover-footer">
|
||||
<button className="cover-reset-btn" onClick={handleReset}>
|
||||
重置封面
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoverSelector
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import React from "react"
|
||||
import type { CoverConfig } from "../../types"
|
||||
|
||||
interface CoverAutoModeProps {
|
||||
config: CoverConfig
|
||||
formatTime: (s: number) => string
|
||||
onUseAiSuggestion: () => void
|
||||
}
|
||||
|
||||
/** 智能封面模式面板 */
|
||||
export const CoverAutoMode: React.FC<CoverAutoModeProps> = ({
|
||||
config,
|
||||
formatTime,
|
||||
onUseAiSuggestion,
|
||||
}) => (
|
||||
<div className="cover-auto-section">
|
||||
<div className="cover-auto-desc">AI 将分析视频内容,自动选择最具吸引力的画面作为封面。</div>
|
||||
{config.ai_suggested_time !== null ? (
|
||||
<div className="cover-auto-suggestion">
|
||||
<div className="cover-auto-badge">AI 推荐</div>
|
||||
<div className="cover-auto-time">推荐时间点:{formatTime(config.ai_suggested_time)}</div>
|
||||
<button className="cover-auto-use-btn" onClick={onUseAiSuggestion}>
|
||||
使用此时间点
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cover-auto-pending">
|
||||
<div className="cover-auto-spinner" />
|
||||
<span>AI 分析中...(生成视频后自动推荐)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface CoverFrameModeProps {
|
||||
config: CoverConfig
|
||||
totalDuration: number
|
||||
formatTime: (s: number) => string
|
||||
onFrameTimeChange: (time: number) => void
|
||||
}
|
||||
|
||||
/** 抽帧选封面模式面板 */
|
||||
export const CoverFrameMode: React.FC<CoverFrameModeProps> = ({
|
||||
config,
|
||||
totalDuration,
|
||||
formatTime,
|
||||
onFrameTimeChange,
|
||||
}) => (
|
||||
<div className="cover-frame-section">
|
||||
<div className="cover-frame-preview">
|
||||
<div className="cover-frame-placeholder">
|
||||
<span className="cover-frame-icon">🎞️</span>
|
||||
<span className="cover-frame-time">{formatTime(config.frame_time)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cover-frame-timeline">
|
||||
<div className="cover-frame-slider-header">
|
||||
<span className="cover-frame-slider-label">拖动选择封面帧</span>
|
||||
<span className="cover-frame-slider-value">{formatTime(config.frame_time)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="cover-frame-slider"
|
||||
min={0}
|
||||
max={Math.max(totalDuration, 1)}
|
||||
step={0.1}
|
||||
value={config.frame_time}
|
||||
onChange={(e) => onFrameTimeChange(Number(e.target.value))}
|
||||
/>
|
||||
<div className="cover-frame-range">
|
||||
<span>00:00</span>
|
||||
<span>{formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cover-frame-quick">
|
||||
<span className="cover-quick-label">快捷选帧:</span>
|
||||
{[0, 0.25, 0.5, 0.75].map((ratio) => {
|
||||
const t = totalDuration * ratio
|
||||
return (
|
||||
<button key={ratio} className="cover-quick-btn" onClick={() => onFrameTimeChange(t)}>
|
||||
{formatTime(t)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface CoverUploadModeProps {
|
||||
config: CoverConfig
|
||||
isDragging: boolean
|
||||
fileInputRef: React.RefObject<HTMLInputElement>
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
onAreaClick: () => void
|
||||
onFileChange: (file: File) => void
|
||||
}
|
||||
|
||||
/** 上传封面模式面板 */
|
||||
export const CoverUploadMode: React.FC<CoverUploadModeProps> = ({
|
||||
config,
|
||||
isDragging,
|
||||
fileInputRef,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onAreaClick,
|
||||
onFileChange,
|
||||
}) => (
|
||||
<div className="cover-upload-section">
|
||||
<div
|
||||
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={onAreaClick}
|
||||
>
|
||||
{config.upload_url ? (
|
||||
<div className="cover-upload-preview">
|
||||
<img src={config.upload_url} alt="封面预览" />
|
||||
<div className="cover-upload-overlay">点击更换</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cover-upload-placeholder">
|
||||
<span className="cover-upload-icon">📤</span>
|
||||
<span className="cover-upload-text">点击或拖拽上传封面图片</span>
|
||||
<span className="cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) onFileChange(file)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* 封面选择器
|
||||
* 抽帧选封面 + 上传自定义封面 + 智能封面推荐
|
||||
*/
|
||||
import React from "react"
|
||||
import { Drawer } from "antd"
|
||||
import type { CoverConfig, CoverMode } from "../../types"
|
||||
import { useCoverSelector, MODE_LABELS, MODE_ICONS } from "./useCoverSelector"
|
||||
import { CoverAutoMode, CoverFrameMode, CoverUploadMode } from "./CoverModePanels"
|
||||
|
||||
interface CoverSelectorProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: CoverConfig
|
||||
onChange: (config: CoverConfig) => void
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
totalDuration,
|
||||
}) => {
|
||||
const {
|
||||
fileInputRef,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
update,
|
||||
handleReset,
|
||||
handleModeChange,
|
||||
handleFileUpload,
|
||||
handleDrop,
|
||||
handleUseAiSuggestion,
|
||||
formatTime,
|
||||
} = useCoverSelector({ config, onChange })
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="封面选择"
|
||||
placement="right"
|
||||
width={440}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="cover-selector-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="cover-header">
|
||||
<span className="cover-header-label">启用自定义封面</span>
|
||||
<label className="cover-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enabled}
|
||||
onChange={(e) => update({ enabled: e.target.checked })}
|
||||
/>
|
||||
<span className="cover-switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 模式选择 */}
|
||||
<div className="cover-mode-section">
|
||||
<div className="cover-section-title">封面来源</div>
|
||||
<div className="cover-mode-tabs">
|
||||
{(["auto", "frame", "upload"] as CoverMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`cover-mode-tab${config.mode === m ? " active" : ""}`}
|
||||
onClick={() => handleModeChange(m)}
|
||||
>
|
||||
<span className="cover-mode-icon">{MODE_ICONS[m]}</span>
|
||||
<span className="cover-mode-label">{MODE_LABELS[m]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模式内容区 */}
|
||||
<div className="cover-mode-content">
|
||||
{config.mode === "auto" && (
|
||||
<CoverAutoMode
|
||||
config={config}
|
||||
formatTime={formatTime}
|
||||
onUseAiSuggestion={handleUseAiSuggestion}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.mode === "frame" && (
|
||||
<CoverFrameMode
|
||||
config={config}
|
||||
totalDuration={totalDuration}
|
||||
formatTime={formatTime}
|
||||
onFrameTimeChange={(t) => update({ frame_time: t })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.mode === "upload" && (
|
||||
<CoverUploadMode
|
||||
config={config}
|
||||
isDragging={isDragging}
|
||||
fileInputRef={fileInputRef}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onAreaClick={() => fileInputRef.current?.click()}
|
||||
onFileChange={handleFileUpload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 封面预览 */}
|
||||
<div className="cover-preview-section">
|
||||
<div className="cover-section-title">封面预览</div>
|
||||
<div className="cover-preview-box">
|
||||
{config.upload_url ? (
|
||||
<img src={config.upload_url} alt="封面预览" className="cover-preview-img" />
|
||||
) : (
|
||||
<div className="cover-preview-placeholder">
|
||||
<span className="cover-preview-icon">🖼️</span>
|
||||
<span className="cover-preview-text">
|
||||
{config.mode === "auto"
|
||||
? "AI 智能选择"
|
||||
: config.mode === "frame"
|
||||
? `帧 ${formatTime(config.frame_time)}`
|
||||
: "未上传封面"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="cover-preview-ratio">16:9</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="cover-footer">
|
||||
<button className="cover-reset-btn" onClick={handleReset}>
|
||||
重置封面
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoverSelector
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useCallback, useRef, useState } from "react"
|
||||
import type { CoverConfig, CoverMode } from "../../types"
|
||||
import { DEFAULT_COVER_CONFIG } from "../../types"
|
||||
|
||||
/** 封面模式标签 */
|
||||
export const MODE_LABELS: Record<CoverMode, string> = {
|
||||
auto: "智能封面",
|
||||
frame: "抽帧选封面",
|
||||
upload: "上传封面",
|
||||
}
|
||||
|
||||
/** 封面模式图标 */
|
||||
export const MODE_ICONS: Record<CoverMode, string> = {
|
||||
auto: "🤖",
|
||||
frame: "🎞️",
|
||||
upload: "📤",
|
||||
}
|
||||
|
||||
interface UseCoverSelectorOptions {
|
||||
config: CoverConfig
|
||||
onChange: (config: CoverConfig) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 封面选择器 Hook
|
||||
* 封装状态管理、文件上传、模式切换等逻辑
|
||||
*/
|
||||
export function useCoverSelector({ config, onChange }: UseCoverSelectorOptions) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const update = useCallback(
|
||||
(partial: Partial<CoverConfig>) => {
|
||||
onChange({ ...config, ...partial })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled })
|
||||
}, [config.enabled, onChange])
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(mode: CoverMode) => {
|
||||
update({ mode })
|
||||
},
|
||||
[update],
|
||||
)
|
||||
|
||||
const handleFileUpload = useCallback(
|
||||
(file: File) => {
|
||||
if (!file.type.startsWith("image/")) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const url = e.target?.result as string
|
||||
update({ upload_url: url, thumbnail_url: url, mode: "upload" })
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
},
|
||||
[update],
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleFileUpload(file)
|
||||
},
|
||||
[handleFileUpload],
|
||||
)
|
||||
|
||||
const handleUseAiSuggestion = useCallback(() => {
|
||||
if (config.ai_suggested_time !== null) {
|
||||
update({ frame_time: config.ai_suggested_time, mode: "frame" })
|
||||
}
|
||||
}, [config.ai_suggested_time, update])
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
const ms = Math.floor((seconds % 1) * 10)
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
|
||||
}
|
||||
|
||||
return {
|
||||
fileInputRef,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
update,
|
||||
handleReset,
|
||||
handleModeChange,
|
||||
handleFileUpload,
|
||||
handleDrop,
|
||||
handleUseAiSuggestion,
|
||||
formatTime,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user