This commit is contained in:
85
src/app/(features)/srt-player/hooks/useFileUpload.ts
Normal file
85
src/app/(features)/srt-player/hooks/useFileUpload.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
export function useFileUpload() {
|
||||
const uploadFile = useCallback((
|
||||
file: File,
|
||||
onSuccess: (url: string) => void,
|
||||
onError?: (error: Error) => void
|
||||
) => {
|
||||
try {
|
||||
// 验证文件大小(限制为100MB)
|
||||
const maxSize = 100 * 1024 * 1024; // 100MB
|
||||
if (file.size > maxSize) {
|
||||
throw new Error(`文件大小超过限制 (${(file.size / 1024 / 1024).toFixed(2)}MB > 100MB)`);
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
onSuccess(url);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '文件上传失败';
|
||||
onError?.(new Error(errorMessage));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const uploadVideo = useCallback((
|
||||
onVideoUpload: (url: string) => void,
|
||||
onError?: (error: Error) => void
|
||||
) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'video/*';
|
||||
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
// 验证文件类型
|
||||
if (!file.type.startsWith('video/')) {
|
||||
onError?.(new Error('请选择有效的视频文件'));
|
||||
return;
|
||||
}
|
||||
uploadFile(file, onVideoUpload, onError);
|
||||
}
|
||||
};
|
||||
|
||||
input.onerror = () => {
|
||||
onError?.(new Error('文件选择失败'));
|
||||
};
|
||||
|
||||
input.click();
|
||||
}, [uploadFile]);
|
||||
|
||||
const uploadSubtitle = useCallback((
|
||||
onSubtitleUpload: (url: string) => void,
|
||||
onError?: (error: Error) => void
|
||||
) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.srt';
|
||||
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
// 验证文件扩展名
|
||||
if (!file.name.toLowerCase().endsWith('.srt')) {
|
||||
onError?.(new Error('请选择.srt格式的字幕文件'));
|
||||
return;
|
||||
}
|
||||
uploadFile(file, onSubtitleUpload, onError);
|
||||
}
|
||||
};
|
||||
|
||||
input.onerror = () => {
|
||||
onError?.(new Error('文件选择失败'));
|
||||
};
|
||||
|
||||
input.click();
|
||||
}, [uploadFile]);
|
||||
|
||||
return {
|
||||
uploadVideo,
|
||||
uploadSubtitle,
|
||||
uploadFile,
|
||||
};
|
||||
}
|
||||
68
src/app/(features)/srt-player/hooks/useKeyboardShortcuts.ts
Normal file
68
src/app/(features)/srt-player/hooks/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { KeyboardShortcut } from "../types/controls";
|
||||
|
||||
export function useKeyboardShortcuts(
|
||||
shortcuts: KeyboardShortcut[],
|
||||
enabled: boolean = true
|
||||
) {
|
||||
const handleKeyDown = useCallback((event: globalThis.KeyboardEvent) => {
|
||||
if (!enabled) return;
|
||||
|
||||
// 防止在输入框中触发快捷键
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
const shortcut = shortcuts.find(s => s.key === event.key);
|
||||
if (shortcut) {
|
||||
event.preventDefault();
|
||||
shortcut.action();
|
||||
}
|
||||
}, [shortcuts, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
}
|
||||
|
||||
export function createSrtPlayerShortcuts(
|
||||
playPause: () => void,
|
||||
next: () => void,
|
||||
previous: () => void,
|
||||
restart: () => void,
|
||||
toggleAutoPause: () => void
|
||||
): KeyboardShortcut[] {
|
||||
return [
|
||||
{
|
||||
key: ' ',
|
||||
description: '播放/暂停',
|
||||
action: playPause,
|
||||
},
|
||||
{
|
||||
key: 'n',
|
||||
description: '下一句',
|
||||
action: next,
|
||||
},
|
||||
{
|
||||
key: 'p',
|
||||
description: '上一句',
|
||||
action: previous,
|
||||
},
|
||||
{
|
||||
key: 'r',
|
||||
description: '句首',
|
||||
action: restart,
|
||||
},
|
||||
{
|
||||
key: 'a',
|
||||
description: '切换自动暂停',
|
||||
action: toggleAutoPause,
|
||||
},
|
||||
];
|
||||
}
|
||||
306
src/app/(features)/srt-player/hooks/useSrtPlayer.ts
Normal file
306
src/app/(features)/srt-player/hooks/useSrtPlayer.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
import { useReducer, useCallback, useRef, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { VideoState, VideoControls } from "../types/player";
|
||||
import { SubtitleState, SubtitleEntry } from "../types/subtitle";
|
||||
import { ControlState, ControlActions } from "../types/controls";
|
||||
|
||||
export interface SrtPlayerState {
|
||||
video: VideoState;
|
||||
subtitle: SubtitleState;
|
||||
controls: ControlState;
|
||||
}
|
||||
|
||||
export interface SrtPlayerActions extends VideoControls, ControlActions {
|
||||
setVideoUrl: (url: string | null) => void;
|
||||
setSubtitleUrl: (url: string | null) => void;
|
||||
nextSubtitle: () => void;
|
||||
previousSubtitle: () => void;
|
||||
restartSubtitle: () => void;
|
||||
setSubtitleSettings: (settings: Partial<SubtitleState['settings']>) => void;
|
||||
}
|
||||
|
||||
const initialState: SrtPlayerState = {
|
||||
video: {
|
||||
url: null,
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
playbackRate: 1.0,
|
||||
volume: 1.0,
|
||||
},
|
||||
subtitle: {
|
||||
url: null,
|
||||
data: [],
|
||||
currentText: "",
|
||||
currentIndex: null,
|
||||
settings: {
|
||||
fontSize: 24,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
textColor: "#ffffff",
|
||||
position: "bottom",
|
||||
fontFamily: "sans-serif",
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
controls: {
|
||||
autoPause: true,
|
||||
showShortcuts: false,
|
||||
showSettings: false,
|
||||
},
|
||||
};
|
||||
|
||||
type SrtPlayerAction =
|
||||
| { type: "SET_VIDEO_URL"; payload: string | null }
|
||||
| { type: "SET_PLAYING"; payload: boolean }
|
||||
| { type: "SET_CURRENT_TIME"; payload: number }
|
||||
| { type: "SET_DURATION"; payload: number }
|
||||
| { type: "SET_PLAYBACK_RATE"; payload: number }
|
||||
| { type: "SET_VOLUME"; payload: number }
|
||||
| { type: "SET_SUBTITLE_URL"; payload: string | null }
|
||||
| { type: "SET_SUBTITLE_DATA"; payload: SubtitleEntry[] }
|
||||
| { type: "SET_CURRENT_SUBTITLE"; payload: { text: string; index: number | null } }
|
||||
| { type: "SET_SUBTITLE_SETTINGS"; payload: Partial<SubtitleState['settings']> }
|
||||
| { type: "TOGGLE_AUTO_PAUSE" }
|
||||
| { type: "TOGGLE_SHORTCUTS" }
|
||||
| { type: "TOGGLE_SETTINGS" };
|
||||
|
||||
function srtPlayerReducer(state: SrtPlayerState, action: SrtPlayerAction): SrtPlayerState {
|
||||
switch (action.type) {
|
||||
case "SET_VIDEO_URL":
|
||||
return { ...state, video: { ...state.video, url: action.payload } };
|
||||
case "SET_PLAYING":
|
||||
return { ...state, video: { ...state.video, isPlaying: action.payload } };
|
||||
case "SET_CURRENT_TIME":
|
||||
return { ...state, video: { ...state.video, currentTime: action.payload } };
|
||||
case "SET_DURATION":
|
||||
return { ...state, video: { ...state.video, duration: action.payload } };
|
||||
case "SET_PLAYBACK_RATE":
|
||||
return { ...state, video: { ...state.video, playbackRate: action.payload } };
|
||||
case "SET_VOLUME":
|
||||
return { ...state, video: { ...state.video, volume: action.payload } };
|
||||
case "SET_SUBTITLE_URL":
|
||||
return { ...state, subtitle: { ...state.subtitle, url: action.payload } };
|
||||
case "SET_SUBTITLE_DATA":
|
||||
return { ...state, subtitle: { ...state.subtitle, data: action.payload } };
|
||||
case "SET_CURRENT_SUBTITLE":
|
||||
return {
|
||||
...state,
|
||||
subtitle: {
|
||||
...state.subtitle,
|
||||
currentText: action.payload.text,
|
||||
currentIndex: action.payload.index,
|
||||
},
|
||||
};
|
||||
case "SET_SUBTITLE_SETTINGS":
|
||||
return {
|
||||
...state,
|
||||
subtitle: {
|
||||
...state.subtitle,
|
||||
settings: { ...state.subtitle.settings, ...action.payload },
|
||||
},
|
||||
};
|
||||
case "TOGGLE_AUTO_PAUSE":
|
||||
return { ...state, controls: { ...state.controls, autoPause: !state.controls.autoPause } };
|
||||
case "TOGGLE_SHORTCUTS":
|
||||
return { ...state, controls: { ...state.controls, showShortcuts: !state.controls.showShortcuts } };
|
||||
case "TOGGLE_SETTINGS":
|
||||
return { ...state, controls: { ...state.controls, showSettings: !state.controls.showSettings } };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useSrtPlayer() {
|
||||
const [state, dispatch] = useReducer(srtPlayerReducer, initialState);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Video controls
|
||||
const play = useCallback(() => {
|
||||
// 检查是否同时有视频和字幕
|
||||
if (!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) {
|
||||
toast.error("请先上传视频和字幕文件");
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoRef.current) {
|
||||
videoRef.current.play().catch(error => {
|
||||
toast.error("视频播放失败: " + error.message);
|
||||
});
|
||||
dispatch({ type: "SET_PLAYING", payload: true });
|
||||
}
|
||||
}, [state.video.url, state.subtitle.url, state.subtitle.data.length, dispatch]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
dispatch({ type: "SET_PLAYING", payload: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (state.video.isPlaying) {
|
||||
pause();
|
||||
} else {
|
||||
play();
|
||||
}
|
||||
}, [state.video.isPlaying, play, pause]);
|
||||
|
||||
const seek = useCallback((time: number) => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = time;
|
||||
dispatch({ type: "SET_CURRENT_TIME", payload: time });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setPlaybackRate = useCallback((rate: number) => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.playbackRate = rate;
|
||||
dispatch({ type: "SET_PLAYBACK_RATE", payload: rate });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setVolume = useCallback((volume: number) => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.volume = volume;
|
||||
dispatch({ type: "SET_VOLUME", payload: volume });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const restart = useCallback(() => {
|
||||
if (videoRef.current && state.subtitle.currentIndex !== null) {
|
||||
const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
|
||||
if (currentSubtitle) {
|
||||
seek(currentSubtitle.start);
|
||||
play();
|
||||
}
|
||||
}
|
||||
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
|
||||
|
||||
// URL setters
|
||||
const setVideoUrl = useCallback((url: string | null) => {
|
||||
dispatch({ type: "SET_VIDEO_URL", payload: url });
|
||||
if (url && videoRef.current) {
|
||||
videoRef.current.src = url;
|
||||
videoRef.current.load();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setSubtitleUrl = useCallback((url: string | null) => {
|
||||
dispatch({ type: "SET_SUBTITLE_URL", payload: url });
|
||||
}, []);
|
||||
|
||||
// Subtitle controls
|
||||
const nextSubtitle = useCallback(() => {
|
||||
if (state.subtitle.currentIndex !== null &&
|
||||
state.subtitle.currentIndex + 1 < state.subtitle.data.length) {
|
||||
const nextIndex = state.subtitle.currentIndex + 1;
|
||||
const nextSubtitle = state.subtitle.data[nextIndex];
|
||||
seek(nextSubtitle.start);
|
||||
play();
|
||||
}
|
||||
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
|
||||
|
||||
const previousSubtitle = useCallback(() => {
|
||||
if (state.subtitle.currentIndex !== null && state.subtitle.currentIndex > 0) {
|
||||
const prevIndex = state.subtitle.currentIndex - 1;
|
||||
const prevSubtitle = state.subtitle.data[prevIndex];
|
||||
seek(prevSubtitle.start);
|
||||
play();
|
||||
}
|
||||
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
|
||||
|
||||
const restartSubtitle = useCallback(() => {
|
||||
if (state.subtitle.currentIndex !== null) {
|
||||
const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
|
||||
seek(currentSubtitle.start);
|
||||
play();
|
||||
}
|
||||
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
|
||||
|
||||
const setSubtitleSettings = useCallback((settings: Partial<SubtitleState['settings']>) => {
|
||||
dispatch({ type: "SET_SUBTITLE_SETTINGS", payload: settings });
|
||||
}, []);
|
||||
|
||||
// Control actions
|
||||
const toggleAutoPause = useCallback(() => {
|
||||
dispatch({ type: "TOGGLE_AUTO_PAUSE" });
|
||||
}, []);
|
||||
|
||||
const toggleShortcuts = useCallback(() => {
|
||||
dispatch({ type: "TOGGLE_SHORTCUTS" });
|
||||
}, []);
|
||||
|
||||
const toggleSettings = useCallback(() => {
|
||||
dispatch({ type: "TOGGLE_SETTINGS" });
|
||||
}, []);
|
||||
|
||||
// Video event handlers
|
||||
const handleTimeUpdate = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
dispatch({ type: "SET_CURRENT_TIME", payload: videoRef.current.currentTime });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLoadedMetadata = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
dispatch({ type: "SET_DURATION", payload: videoRef.current.duration });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePlay = useCallback(() => {
|
||||
dispatch({ type: "SET_PLAYING", payload: true });
|
||||
}, []);
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
dispatch({ type: "SET_PLAYING", payload: false });
|
||||
}, []);
|
||||
|
||||
// Set subtitle data
|
||||
const setSubtitleData = useCallback((data: SubtitleEntry[]) => {
|
||||
dispatch({ type: "SET_SUBTITLE_DATA", payload: data });
|
||||
}, []);
|
||||
|
||||
// Set current subtitle
|
||||
const setCurrentSubtitle = useCallback((text: string, index: number | null) => {
|
||||
dispatch({ type: "SET_CURRENT_SUBTITLE", payload: { text, index } });
|
||||
}, []);
|
||||
|
||||
const actions: SrtPlayerActions = {
|
||||
play,
|
||||
pause,
|
||||
togglePlayPause,
|
||||
seek,
|
||||
setPlaybackRate,
|
||||
setVolume,
|
||||
restart,
|
||||
setVideoUrl,
|
||||
setSubtitleUrl,
|
||||
nextSubtitle,
|
||||
previousSubtitle,
|
||||
restartSubtitle,
|
||||
setSubtitleSettings,
|
||||
toggleAutoPause,
|
||||
toggleShortcuts,
|
||||
toggleSettings,
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
actions,
|
||||
videoRef,
|
||||
videoEventHandlers: {
|
||||
onTimeUpdate: handleTimeUpdate,
|
||||
onLoadedMetadata: handleLoadedMetadata,
|
||||
onPlay: handlePlay,
|
||||
onPause: handlePause,
|
||||
},
|
||||
subtitleActions: {
|
||||
setSubtitleData,
|
||||
setCurrentSubtitle,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type UseSrtPlayerReturn = ReturnType<typeof useSrtPlayer>;
|
||||
110
src/app/(features)/srt-player/hooks/useSubtitleSync.ts
Normal file
110
src/app/(features)/srt-player/hooks/useSubtitleSync.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { SubtitleEntry } from "../types/subtitle";
|
||||
|
||||
export function useSubtitleSync(
|
||||
subtitles: SubtitleEntry[],
|
||||
currentTime: number,
|
||||
isPlaying: boolean,
|
||||
autoPause: boolean,
|
||||
onSubtitleChange: (subtitle: SubtitleEntry | null) => void,
|
||||
onAutoPauseTrigger?: (subtitle: SubtitleEntry) => void
|
||||
) {
|
||||
const lastSubtitleRef = useRef<SubtitleEntry | null>(null);
|
||||
const rafIdRef = useRef<number>(0);
|
||||
|
||||
// 获取当前时间对应的字幕
|
||||
const getCurrentSubtitle = useCallback((time: number): SubtitleEntry | null => {
|
||||
return subtitles.find(subtitle => time >= subtitle.start && time <= subtitle.end) || null;
|
||||
}, [subtitles]);
|
||||
|
||||
// 获取最近的字幕索引
|
||||
const getNearestIndex = useCallback((time: number): number | null => {
|
||||
if (subtitles.length === 0) return null;
|
||||
|
||||
// 如果时间早于第一个字幕开始时间
|
||||
if (time < subtitles[0].start) return null;
|
||||
|
||||
// 如果时间晚于最后一个字幕结束时间
|
||||
if (time > subtitles[subtitles.length - 1].end) return subtitles.length - 1;
|
||||
|
||||
// 二分查找找到当前时间对应的字幕
|
||||
let left = 0;
|
||||
let right = subtitles.length - 1;
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
const subtitle = subtitles[mid];
|
||||
|
||||
if (time >= subtitle.start && time <= subtitle.end) {
|
||||
return mid;
|
||||
} else if (time < subtitle.start) {
|
||||
right = mid - 1;
|
||||
} else {
|
||||
left = mid + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到完全匹配的字幕,返回最近的字幕索引
|
||||
return right >= 0 ? right : null;
|
||||
}, [subtitles]);
|
||||
|
||||
// 检查是否需要自动暂停
|
||||
const shouldAutoPause = useCallback((subtitle: SubtitleEntry, time: number): boolean => {
|
||||
return autoPause &&
|
||||
time >= subtitle.end - 0.2 && // 增加时间窗口,确保自动暂停更可靠
|
||||
time < subtitle.end;
|
||||
}, [autoPause]);
|
||||
|
||||
// 启动/停止同步循环
|
||||
useEffect(() => {
|
||||
const syncSubtitles = () => {
|
||||
const currentSubtitle = getCurrentSubtitle(currentTime);
|
||||
|
||||
// 检查字幕是否发生变化
|
||||
if (currentSubtitle !== lastSubtitleRef.current) {
|
||||
const previousSubtitle = lastSubtitleRef.current;
|
||||
lastSubtitleRef.current = currentSubtitle;
|
||||
|
||||
// 只有当有当前字幕时才调用onSubtitleChange
|
||||
// 在字幕间隙时保持之前的字幕索引,避免进度条跳到0
|
||||
if (currentSubtitle) {
|
||||
onSubtitleChange(currentSubtitle);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否需要自动暂停
|
||||
// 每次都检查,不只在字幕变化时检查
|
||||
if (currentSubtitle && shouldAutoPause(currentSubtitle, currentTime)) {
|
||||
onAutoPauseTrigger?.(currentSubtitle);
|
||||
} else if (!currentSubtitle && lastSubtitleRef.current && shouldAutoPause(lastSubtitleRef.current, currentTime)) {
|
||||
// 在字幕结束时,如果前一个字幕需要自动暂停,也要触发
|
||||
onAutoPauseTrigger?.(lastSubtitleRef.current);
|
||||
}
|
||||
|
||||
rafIdRef.current = requestAnimationFrame(syncSubtitles);
|
||||
};
|
||||
|
||||
if (subtitles.length > 0) {
|
||||
rafIdRef.current = requestAnimationFrame(syncSubtitles);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (rafIdRef.current) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
}
|
||||
};
|
||||
}, [subtitles.length, currentTime, getCurrentSubtitle, onSubtitleChange, shouldAutoPause, onAutoPauseTrigger]);
|
||||
|
||||
// 重置最后字幕引用
|
||||
useEffect(() => {
|
||||
lastSubtitleRef.current = null;
|
||||
}, [subtitles]);
|
||||
|
||||
return {
|
||||
getCurrentSubtitle,
|
||||
getNearestIndex,
|
||||
shouldAutoPause,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user