添加跳过配置功能,包括数据库和API支持,更新播放器以处理跳过片段

This commit is contained in:
katelya
2025-09-02 13:49:46 +08:00
parent d9d50891f2
commit 348494336a
9 changed files with 1049 additions and 3 deletions
+373
View File
@@ -0,0 +1,373 @@
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
deleteSkipConfig,
EpisodeSkipConfig,
getSkipConfig,
saveSkipConfig,
SkipSegment,
} from '@/lib/db.client';
interface SkipControllerProps {
source: string;
id: string;
title: string;
artPlayerRef: React.MutableRefObject<any>;
currentTime?: number;
_duration?: number; // 使用下划线前缀标识未使用的参数
}
export default function SkipController({
source,
id,
title,
artPlayerRef,
currentTime = 0,
_duration = 0,
}: SkipControllerProps) {
const [skipConfig, setSkipConfig] = useState<EpisodeSkipConfig | null>(null);
const [showSkipButton, setShowSkipButton] = useState(false);
const [currentSkipSegment, setCurrentSkipSegment] = useState<SkipSegment | null>(null);
const [isSettingMode, setIsSettingMode] = useState(false);
const [newSegment, setNewSegment] = useState<Partial<SkipSegment>>({});
const lastSkipTimeRef = useRef<number>(0);
const skipTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 加载跳过配置
const loadSkipConfig = useCallback(async () => {
try {
const config = await getSkipConfig(source, id);
setSkipConfig(config);
} catch (err) {
console.error('加载跳过配置失败:', err);
}
}, [source, id]);
// 检查当前播放时间是否在跳过区间内
const checkSkipSegment = useCallback(
(time: number) => {
if (!skipConfig?.segments?.length) return;
const currentSegment = skipConfig.segments.find(
(segment) => time >= segment.start && time <= segment.end
);
if (currentSegment && currentSegment !== currentSkipSegment) {
setCurrentSkipSegment(currentSegment);
setShowSkipButton(true);
// 自动隐藏跳过按钮
if (skipTimeoutRef.current) {
clearTimeout(skipTimeoutRef.current);
}
skipTimeoutRef.current = setTimeout(() => {
setShowSkipButton(false);
setCurrentSkipSegment(null);
}, 8000); // 8秒后自动隐藏
} else if (!currentSegment && currentSkipSegment) {
setCurrentSkipSegment(null);
setShowSkipButton(false);
if (skipTimeoutRef.current) {
clearTimeout(skipTimeoutRef.current);
}
}
},
[skipConfig, currentSkipSegment]
);
// 执行跳过
const handleSkip = useCallback(() => {
if (!currentSkipSegment || !artPlayerRef.current) return;
const targetTime = currentSkipSegment.end + 1; // 跳到片段结束后1秒
artPlayerRef.current.currentTime = targetTime;
lastSkipTimeRef.current = Date.now();
setShowSkipButton(false);
setCurrentSkipSegment(null);
if (skipTimeoutRef.current) {
clearTimeout(skipTimeoutRef.current);
}
// 显示跳过提示
if (artPlayerRef.current.notice) {
const segmentName = currentSkipSegment.type === 'opening' ? '片头' : '片尾';
artPlayerRef.current.notice.show = `已跳过${segmentName}`;
}
}, [currentSkipSegment, artPlayerRef]);
// 保存新的跳过片段
const handleSaveSegment = useCallback(async () => {
if (!newSegment.start || !newSegment.end || !newSegment.type) {
alert('请填写完整的跳过片段信息');
return;
}
if (newSegment.start >= newSegment.end) {
alert('开始时间必须小于结束时间');
return;
}
try {
const segment: SkipSegment = {
start: newSegment.start,
end: newSegment.end,
type: newSegment.type as 'opening' | 'ending',
title: newSegment.title || (newSegment.type === 'opening' ? '片头' : '片尾'),
};
const updatedConfig: EpisodeSkipConfig = {
source,
id,
title,
segments: skipConfig?.segments ? [...skipConfig.segments, segment] : [segment],
updated_time: Date.now(),
};
await saveSkipConfig(source, id, updatedConfig);
setSkipConfig(updatedConfig);
setIsSettingMode(false);
setNewSegment({});
alert('跳过片段已保存');
} catch (err) {
console.error('保存跳过片段失败:', err);
alert('保存失败,请重试');
}
}, [newSegment, skipConfig, source, id, title]);
// 删除跳过片段
const handleDeleteSegment = useCallback(
async (index: number) => {
if (!skipConfig?.segments) return;
try {
const updatedSegments = skipConfig.segments.filter((_, i) => i !== index);
if (updatedSegments.length === 0) {
// 如果没有片段了,删除整个配置
await deleteSkipConfig(source, id);
setSkipConfig(null);
} else {
// 更新配置
const updatedConfig: EpisodeSkipConfig = {
...skipConfig,
segments: updatedSegments,
updated_time: Date.now(),
};
await saveSkipConfig(source, id, updatedConfig);
setSkipConfig(updatedConfig);
}
alert('跳过片段已删除');
} catch (err) {
console.error('删除跳过片段失败:', err);
alert('删除失败,请重试');
}
},
[skipConfig, source, id]
);
// 格式化时间显示
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// 初始化加载配置
useEffect(() => {
loadSkipConfig();
}, [loadSkipConfig]);
// 监听播放时间变化
useEffect(() => {
if (currentTime > 0) {
checkSkipSegment(currentTime);
}
}, [currentTime, checkSkipSegment]);
// 清理定时器
useEffect(() => {
return () => {
if (skipTimeoutRef.current) {
clearTimeout(skipTimeoutRef.current);
}
};
}, []);
return (
<div className="skip-controller">
{/* 跳过按钮 */}
{showSkipButton && currentSkipSegment && (
<div className="fixed top-20 right-4 z-50 bg-black/80 text-white px-4 py-2 rounded-lg backdrop-blur-sm border border-white/20 shadow-lg animate-fade-in">
<div className="flex items-center space-x-3">
<span className="text-sm">
{currentSkipSegment.type === 'opening' ? '检测到片头' : '检测到片尾'}
</span>
<button
onClick={handleSkip}
className="px-3 py-1 bg-green-600 hover:bg-green-700 rounded text-sm font-medium transition-colors"
>
</button>
</div>
</div>
)}
{/* 设置模式面板 */}
{isSettingMode && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
</label>
<select
value={newSegment.type || ''}
onChange={(e) => setNewSegment({ ...newSegment, type: e.target.value as 'opening' | 'ending' })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
>
<option value=""></option>
<option value="opening"></option>
<option value="ending"></option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
()
</label>
<input
type="number"
value={newSegment.start || ''}
onChange={(e) => setNewSegment({ ...newSegment, start: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="例如: 0"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
()
</label>
<input
type="number"
value={newSegment.end || ''}
onChange={(e) => setNewSegment({ ...newSegment, end: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="例如: 90"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
()
</label>
<input
type="text"
value={newSegment.title || ''}
onChange={(e) => setNewSegment({ ...newSegment, title: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="例如: 片头曲"
/>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
: {formatTime(currentTime)}
</div>
</div>
<div className="flex space-x-3 mt-6">
<button
onClick={handleSaveSegment}
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded font-medium transition-colors"
>
</button>
<button
onClick={() => {
setIsSettingMode(false);
setNewSegment({});
}}
className="flex-1 px-4 py-2 bg-gray-500 hover:bg-gray-600 text-white rounded font-medium transition-colors"
>
</button>
</div>
</div>
</div>
)}
{/* 管理已有片段 */}
{skipConfig && skipConfig.segments && skipConfig.segments.length > 0 && !isSettingMode && (
<div className="mt-4 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<h4 className="font-medium mb-2 text-gray-900 dark:text-gray-100">
:
</h4>
<div className="space-y-2">
{skipConfig.segments.map((segment, index) => (
<div
key={index}
className="flex items-center justify-between p-2 bg-white dark:bg-gray-700 rounded text-sm"
>
<span className="text-gray-900 dark:text-gray-100">
{segment.type === 'opening' ? '片头' : '片尾'}: {formatTime(segment.start)} - {formatTime(segment.end)}
{segment.title && ` (${segment.title})`}
</span>
<button
onClick={() => handleDeleteSegment(index)}
className="px-2 py-1 bg-red-500 hover:bg-red-600 text-white rounded text-xs transition-colors"
>
</button>
</div>
))}
</div>
</div>
)}
<style jsx>{`
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
`}</style>
</div>
);
}
// 导出跳过控制器的设置按钮组件
export function SkipSettingsButton({ onClick }: { onClick: () => void }) {
return (
<button
onClick={onClick}
className="flex items-center space-x-1 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded text-sm text-gray-700 dark:text-gray-300 transition-colors"
title="设置跳过片头片尾"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
<span></span>
</button>
);
}