feat: skip intro & outro

This commit is contained in:
shinya
2025-07-29 18:31:40 +08:00
parent 8fd7451f8a
commit 58ca3ef744
9 changed files with 776 additions and 11 deletions

View File

@@ -0,0 +1,108 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { db } from '@/lib/db';
import { SkipConfig } from '@/lib/types';
export async function GET(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const source = searchParams.get('source');
const id = searchParams.get('id');
if (source && id) {
// 获取单个配置
const config = await db.getSkipConfig(authInfo.username, source, id);
return NextResponse.json(config);
} else {
// 获取所有配置
// 注意:这里需要实现获取所有跳过片头片尾配置的方法
// 由于当前接口设计是按source+id获取单个配置这里返回空对象
return NextResponse.json({});
}
} catch (error) {
console.error('获取跳过片头片尾配置失败:', error);
return NextResponse.json(
{ error: '获取跳过片头片尾配置失败' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const body = await request.json();
const { key, config } = body;
if (!key || !config) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
// 解析key为source和id
const [source, id] = key.split('+');
if (!source || !id) {
return NextResponse.json({ error: '无效的key格式' }, { status: 400 });
}
// 验证配置格式
const skipConfig: SkipConfig = {
enable: Boolean(config.enable),
intro_time: Number(config.intro_time) || 0,
outro_time: Number(config.outro_time) || 0,
};
await db.setSkipConfig(authInfo.username, source, id, skipConfig);
return NextResponse.json({ success: true });
} catch (error) {
console.error('保存跳过片头片尾配置失败:', error);
return NextResponse.json(
{ error: '保存跳过片头片尾配置失败' },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const key = searchParams.get('key');
if (!key) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
// 解析key为source和id
const [source, id] = key.split('+');
if (!source || !id) {
return NextResponse.json({ error: '无效的key格式' }, { status: 400 });
}
await db.deleteSkipConfig(authInfo.username, source, id);
return NextResponse.json({ success: true });
} catch (error) {
console.error('删除跳过片头片尾配置失败:', error);
return NextResponse.json(
{ error: '删除跳过片头片尾配置失败' },
{ status: 500 }
);
}
}

View File

@@ -11,11 +11,14 @@ import { Suspense, useEffect, useRef, useState } from 'react';
import {
deleteFavorite,
deletePlayRecord,
deleteSkipConfig,
generateStorageKey,
getAllPlayRecords,
getSkipConfig,
isFavorited,
saveFavorite,
savePlayRecord,
saveSkipConfig,
subscribeToDataUpdates,
} from '@/lib/db.client';
import { SearchResult } from '@/lib/types';
@@ -49,6 +52,26 @@ function PlayPageClient() {
// 收藏状态
const [favorited, setFavorited] = useState(false);
// 跳过片头片尾配置
const [skipConfig, setSkipConfig] = useState<{
enable: boolean;
intro_time: number;
outro_time: number;
}>({
enable: false,
intro_time: 0,
outro_time: 0,
});
const skipConfigRef = useRef(skipConfig);
useEffect(() => {
skipConfigRef.current = skipConfig;
}, [
skipConfig,
skipConfig.enable,
skipConfig.intro_time,
skipConfig.outro_time,
]);
// 去广告开关(从 localStorage 继承,默认 true
const [blockAdEnabled, setBlockAdEnabled] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
@@ -428,6 +451,37 @@ function PlayPageClient() {
return filteredLines.join('\n');
}
// 跳过片头片尾配置相关函数
const handleSkipConfigChange = async (newConfig: {
enable: boolean;
intro_time: number;
outro_time: number;
}) => {
if (!currentSourceRef.current || !currentIdRef.current) return;
try {
await saveSkipConfig(
currentSourceRef.current,
currentIdRef.current,
newConfig
);
setSkipConfig(newConfig);
console.log('跳过片头片尾配置已保存:', newConfig);
} catch (err) {
console.error('保存跳过片头片尾配置失败:', err);
}
};
const formatTime = (seconds: number): string => {
if (seconds === 0) return '0秒';
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.round(seconds % 60);
if (minutes === 0) {
return `${remainingSeconds}`;
}
return `${minutes}${remainingSeconds.toString().padStart(2, '0')}`;
};
class CustomHlsJsLoader extends Hls.DefaultConfig.loader {
constructor(config: any) {
super(config);
@@ -642,6 +696,25 @@ function PlayPageClient() {
initFromHistory();
}, []);
// 跳过片头片尾配置处理
useEffect(() => {
// 仅在初次挂载时检查跳过片头片尾配置
const initSkipConfig = async () => {
if (!currentSource || !currentId) return;
try {
const config = await getSkipConfig(currentSource, currentId);
if (config) {
setSkipConfig(config);
}
} catch (err) {
console.error('读取跳过片头片尾配置失败:', err);
}
};
initSkipConfig();
}, []);
// 处理换源
const handleSourceChange = async (
newSource: string,
@@ -670,6 +743,19 @@ function PlayPageClient() {
}
}
// 清除并设置下一个跳过片头片尾配置
if (currentSourceRef.current && currentIdRef.current) {
try {
await deleteSkipConfig(
currentSourceRef.current,
currentIdRef.current
);
await saveSkipConfig(newSource, newId, skipConfigRef.current);
} catch (err) {
console.error('清除跳过片头片尾配置失败:', err);
}
}
const newDetail = availableSources.find(
(source) => source.source === newSource && source.id === newId
);
@@ -1182,6 +1268,56 @@ function PlayPageClient() {
return newVal ? '当前开启' : '当前关闭';
},
},
{
html: '跳过片头片尾',
switch: skipConfig.enable,
onSwitch: function (item) {
const newConfig = {
...skipConfigRef.current,
enable: !item.switch,
};
handleSkipConfigChange(newConfig);
return !item.switch;
},
},
{
html: '设置片头',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="5" cy="12" r="2" fill="#ffffff"/><path d="M9 12L17 12" stroke="#ffffff" stroke-width="2"/><path d="M17 6L17 18" stroke="#ffffff" stroke-width="2"/></svg>',
tooltip:
skipConfig.intro_time === 0
? '设置片头时间'
: `${formatTime(skipConfig.intro_time)}`,
onClick: function () {
const currentTime = artPlayerRef.current?.currentTime || 0;
if (currentTime > 0) {
const newConfig = {
...skipConfigRef.current,
intro_time: currentTime,
};
handleSkipConfigChange(newConfig);
return `${formatTime(currentTime)}`;
}
},
},
{
html: '设置片尾',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 6L7 18" stroke="#ffffff" stroke-width="2"/><path d="M7 12L15 12" stroke="#ffffff" stroke-width="2"/><circle cx="19" cy="12" r="2" fill="#ffffff"/></svg>',
tooltip:
skipConfig.outro_time === 0
? '设置片尾时间'
: `${formatTime(skipConfig.outro_time)}`,
onClick: function () {
const currentTime = artPlayerRef.current?.currentTime || 0;
if (currentTime > 0) {
const newConfig = {
...skipConfig,
outro_time: currentTime,
};
handleSkipConfigChange(newConfig);
return `${formatTime(currentTime)}`;
}
},
},
],
// 控制栏配置
controls: [
@@ -1237,6 +1373,37 @@ function PlayPageClient() {
setIsVideoLoading(false);
});
// 监听视频时间更新事件,实现跳过片头片尾
artPlayerRef.current.on('video:timeupdate', () => {
if (!skipConfigRef.current.enable) return;
const currentTime = artPlayerRef.current.currentTime || 0;
const duration = artPlayerRef.current.duration || 0;
// 跳过片头
if (
skipConfigRef.current.intro_time > 0 &&
currentTime < skipConfigRef.current.intro_time
) {
artPlayerRef.current.currentTime = skipConfigRef.current.intro_time;
artPlayerRef.current.notice.show = `已跳过片头 (${formatTime(
skipConfigRef.current.intro_time
)})`;
}
// 跳过片尾
if (
skipConfigRef.current.outro_time > 0 &&
duration > 0 &&
currentTime > skipConfigRef.current.outro_time
) {
handleNextEpisode();
artPlayerRef.current.notice.show = `已跳过片尾 (${formatTime(
skipConfigRef.current.outro_time
)})`;
}
});
artPlayerRef.current.on('error', (err: any) => {
console.error('播放器错误:', err);
if (artPlayerRef.current.currentTime > 0) {