Sun Mar 8 09:25:22 AM CST 2026

This commit is contained in:
2026-03-08 09:25:22 +08:00
parent e2d8e17f62
commit dd1c6a7b52
20 changed files with 494 additions and 1009 deletions

View File

@@ -1,22 +0,0 @@
export function SubtitleDisplay({ subtitle }: { subtitle: string }) {
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
let i = 0;
return (
<div className="w-full subtitle overflow-auto h-16 mt-2 wrap-break-word bg-black/50 font-sans text-white text-center text-2xl">
{words.map((v) => (
<span
onClick={() => {
window.open(
`https://www.youdao.com/result?word=${v}&lang=en`,
"_blank",
);
}}
key={i++}
className="hover:bg-gray-700 hover:underline hover:cursor-pointer"
>
{v + " "}
</span>
))}
</div>
);
}

View File

@@ -1,220 +0,0 @@
import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
import { SubtitleDisplay } from "./SubtitleDisplay";
import { LightButton } from "@/design-system/base/button";
import { RangeInput } from "@/design-system/base/range";
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
import { useTranslations } from "next-intl";
type VideoPanelProps = {
videoUrl: string | null;
srtUrl: string | null;
};
const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
({ videoUrl, srtUrl }, videoRef) => {
const t = useTranslations("srt_player");
videoRef = videoRef as React.RefObject<HTMLVideoElement>;
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [srtLength, setSrtLength] = useState<number>(0);
const [progress, setProgress] = useState<number>(-1);
const [autoPause, setAutoPause] = useState<boolean>(true);
const [spanText, setSpanText] = useState<string>("");
const [subtitle, setSubtitle] = useState<string>("");
const parsedSrtRef = useRef<
{ start: number; end: number; text: string; }[] | null
>(null);
const rafldRef = useRef<number>(0);
const ready = useRef({
vid: false,
sub: false,
all: function () {
return this.vid && this.sub;
},
});
const togglePlayPause = useCallback(() => {
if (!videoUrl) return;
const video = videoRef.current;
if (!video) return;
if (video.paused || video.currentTime === 0) {
video.play();
} else {
video.pause();
}
setIsPlaying(!video.paused);
}, [videoRef, videoUrl]);
useEffect(() => {
const handleKeyDownEvent = (e: globalThis.KeyboardEvent) => {
if (e.key === "n") {
next();
} else if (e.key === "p") {
previous();
} else if (e.key === " ") {
togglePlayPause();
} else if (e.key === "r") {
restart();
} else if (e.key === "a") {
handleAutoPauseToggle();
}
};
document.addEventListener("keydown", handleKeyDownEvent);
return () => document.removeEventListener("keydown", handleKeyDownEvent);
});
useEffect(() => {
const cb = () => {
if (ready.current.all()) {
if (!parsedSrtRef.current) {
} else if (isPlaying) {
// 这里负责显示当前时间的字幕与自动暂停
const srt = parsedSrtRef.current;
const ct = videoRef.current?.currentTime as number;
const index = getIndex(srt, ct);
if (index !== null) {
setSubtitle(srt[index].text);
if (
autoPause &&
ct >= srt[index].end - 0.05 &&
ct < srt[index].end
) {
videoRef.current!.currentTime = srt[index].start;
togglePlayPause();
}
} else {
setSubtitle("");
}
} else {
}
}
rafldRef.current = requestAnimationFrame(cb);
};
rafldRef.current = requestAnimationFrame(cb);
return () => {
cancelAnimationFrame(rafldRef.current);
};
}, [autoPause, isPlaying, togglePlayPause, videoRef]);
useEffect(() => {
if (videoUrl && videoRef.current) {
videoRef.current.src = videoUrl;
videoRef.current.load();
setIsPlaying(false);
ready.current["vid"] = true;
}
}, [videoRef, videoUrl]);
useEffect(() => {
if (srtUrl) {
fetch(srtUrl)
.then((response) => response.text())
.then((data) => {
parsedSrtRef.current = parseSrt(data);
setSrtLength(parsedSrtRef.current.length);
ready.current["sub"] = true;
});
}
}, [srtUrl]);
const timeUpdate = () => {
if (!parsedSrtRef.current || !videoRef.current) return;
const index = getIndex(
parsedSrtRef.current,
videoRef.current.currentTime,
);
if (!index) return;
setSpanText(`${index + 1}/${parsedSrtRef.current.length}`);
};
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
if (videoRef.current && parsedSrtRef.current) {
const newProgress = parseInt(e.target.value);
videoRef.current.currentTime =
parsedSrtRef.current[newProgress]?.start || 0;
setProgress(newProgress);
}
};
const handleAutoPauseToggle = () => {
setAutoPause(!autoPause);
};
const next = () => {
if (!parsedSrtRef.current || !videoRef.current) return;
const i = getNearistIndex(
parsedSrtRef.current,
videoRef.current.currentTime,
);
if (i != null && i + 1 < parsedSrtRef.current.length) {
videoRef.current.currentTime = parsedSrtRef.current[i + 1].start;
videoRef.current.play();
setIsPlaying(true);
}
};
const previous = () => {
if (!parsedSrtRef.current || !videoRef.current) return;
const i = getNearistIndex(
parsedSrtRef.current,
videoRef.current.currentTime,
);
if (i != null && i - 1 >= 0) {
videoRef.current.currentTime = parsedSrtRef.current[i - 1].start;
videoRef.current.play();
setIsPlaying(true);
}
};
const restart = () => {
if (!parsedSrtRef.current || !videoRef.current) return;
const i = getNearistIndex(
parsedSrtRef.current,
videoRef.current.currentTime,
);
if (i != null && i >= 0) {
videoRef.current.currentTime = parsedSrtRef.current[i].start;
videoRef.current.play();
setIsPlaying(true);
}
};
return (
<div className="w-full flex flex-col">
<video
className="bg-gray-200"
ref={videoRef}
onTimeUpdate={timeUpdate}
></video>
<SubtitleDisplay subtitle={subtitle}></SubtitleDisplay>
<div className="buttons flex mt-2 gap-2 flex-wrap">
<LightButton onClick={togglePlayPause}>
{isPlaying ? t("pause") : t("play")}
</LightButton>
<LightButton onClick={previous}>{t("previous")}</LightButton>
<LightButton onClick={next}>{t("next")}</LightButton>
<LightButton onClick={restart}>{t("restart")}</LightButton>
<LightButton onClick={handleAutoPauseToggle}>
{t("autoPause", { enabled: autoPause ? "Yes" : "No" })}
</LightButton>
</div>
<RangeInput
className="seekbar"
min={0}
max={srtLength}
onChange={(value) => {
if (videoRef.current && parsedSrtRef.current) {
videoRef.current.currentTime = parsedSrtRef.current[value]?.start || 0;
setProgress(value);
}
}}
value={progress}
/>
<span>{spanText}</span>
</div>
);
},
);
VideoPanel.displayName = "VideoPanel";
export { VideoPanel };

View File

@@ -1,96 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { useSrtPlayerStore } from "../store";
/**
* useSubtitleSync - 字幕同步 Hook
*
* 自动同步视频播放时间与字幕显示,支持自动暂停功能。
* 使用 Zustand store 获取状态,无需传入参数。
*/
export function useSubtitleSync() {
const lastSubtitleRef = useRef<number | null>(null);
const hasAutoPausedRef = useRef<{ [key: number]: boolean }>({}); // 追踪每个字幕是否已触发自动暂停
const rafIdRef = useRef<number>(0);
// 从 store 获取状态
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
// Store actions
const setCurrentSubtitle = useSrtPlayerStore((state) => state.setCurrentSubtitle);
const seek = useSrtPlayerStore((state) => state.seek);
const pause = useSrtPlayerStore((state) => state.pause);
// 同步循环
useEffect(() => {
const syncSubtitles = () => {
// 从 store 获取最新的 currentTime
const currentTime = useSrtPlayerStore.getState().video.currentTime;
// 获取当前时间对应的字幕索引
const getCurrentSubtitleIndex = (time: number): number | null => {
for (let i = 0; i < subtitleData.length; i++) {
const subtitle = subtitleData[i];
if (time >= subtitle.start && time <= subtitle.end) {
return i;
}
}
return null;
};
const currentIndex = getCurrentSubtitleIndex(currentTime);
// 检查字幕是否发生变化
if (currentIndex !== lastSubtitleRef.current) {
lastSubtitleRef.current = currentIndex;
if (currentIndex !== null) {
const subtitle = subtitleData[currentIndex];
setCurrentSubtitle(subtitle.text, currentIndex);
} else {
setCurrentSubtitle('', null);
}
}
// 检查是否需要自动暂停(每个字幕只触发一次)
if (autoPause && currentIndex !== null) {
const currentSubtitle = subtitleData[currentIndex];
const timeUntilEnd = currentSubtitle.end - currentTime;
// 在字幕结束前 0.2 秒触发自动暂停
if (timeUntilEnd <= 0.2 && timeUntilEnd > 0 && !hasAutoPausedRef.current[currentIndex]) {
hasAutoPausedRef.current[currentIndex] = true;
seek(currentSubtitle.start);
// 使用 setTimeout 确保在 seek 之后暂停
setTimeout(() => {
pause();
}, 0);
}
}
// 如果视频正在播放,继续循环
if (useSrtPlayerStore.getState().video.isPlaying) {
rafIdRef.current = requestAnimationFrame(syncSubtitles);
}
};
if (subtitleData.length > 0 && isPlaying) {
rafIdRef.current = requestAnimationFrame(syncSubtitles);
}
return () => {
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current);
}
};
}, [subtitleData, isPlaying, autoPause, setCurrentSubtitle, seek, pause]);
// 重置最后字幕引用
useEffect(() => {
lastSubtitleRef.current = null;
hasAutoPausedRef.current = {};
}, [subtitleData]);
}

View File

@@ -1,63 +0,0 @@
"use client";
import { useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { PageLayout } from "@/components/ui/PageLayout";
import { VideoPlayerPanel } from "./components/VideoPlayerPanel";
import { ControlPanel } from "./components/ControlPanel";
import { useVideoSync } from "./hooks/useVideoSync";
import { useSubtitleSync } from "./hooks/useSubtitleSync";
import { useSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
import { loadSubtitle } from "./utils/subtitleParser";
import { useSrtPlayerStore } from "./store";
export default function SrtPlayerPage() {
const t = useTranslations("home");
const srtT = useTranslations("srt_player");
const videoRef = useRef<HTMLVideoElement>(null);
// Store state
const subtitleUrl = useSrtPlayerStore((state) => state.subtitle.url);
const setSubtitleData = useSrtPlayerStore((state) => state.setSubtitleData);
// Hooks
useVideoSync(videoRef);
useSubtitleSync();
useSrtPlayerShortcuts();
// Load subtitle when URL changes
useEffect(() => {
if (subtitleUrl) {
loadSubtitle(subtitleUrl)
.then((subtitleData) => {
setSubtitleData(subtitleData);
toast.success(srtT("subtitleLoadSuccess"));
})
.catch((error) => {
toast.error(srtT("subtitleLoadFailed") + ": " + error.message);
});
}
}, [srtT, subtitleUrl, setSubtitleData]);
return (
<PageLayout>
{/* Title */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-800 mb-2">
{t("srtPlayer.name")}
</h1>
<p className="text-lg text-gray-600">
{t("srtPlayer.description")}
</p>
</div>
{/* Video Player */}
<VideoPlayerPanel ref={videoRef} />
{/* Control Panel */}
<ControlPanel />
</PageLayout>
);
}

View File

@@ -1,74 +0,0 @@
export function parseSrt(data: string) {
const lines = data.split(/\r?\n/);
const result = [];
const re = new RegExp(
"(\\d{2}:\\d{2}:\\d{2},\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2},\\d{3})",
);
let i = 0;
while (i < lines.length) {
if (!lines[i].trim()) {
i++;
continue;
}
i++;
if (i >= lines.length) break;
const timeMatch = lines[i].match(re);
if (!timeMatch) {
i++;
continue;
}
const start = toSeconds(timeMatch[1]);
const end = toSeconds(timeMatch[2]);
i++;
let text = "";
while (i < lines.length && lines[i].trim()) {
text += lines[i] + "\n";
i++;
}
result.push({ start, end, text: text.trim() });
i++;
}
return result;
}
export function getNearistIndex(
srt: { start: number; end: number; text: string }[],
ct: number,
) {
for (let i = 0; i < srt.length; i++) {
const s = srt[i];
const l = ct - s.start >= 0;
const r = ct - s.end >= 0;
if (!(l || r)) return i - 1;
if (l && !r) return i;
}
}
export function getIndex(
srt: { start: number; end: number; text: string }[],
ct: number,
) {
for (let i = 0; i < srt.length; i++) {
if (ct >= srt[i].start && ct <= srt[i].end) {
return i;
}
}
return null;
}
export function getSubtitle(
srt: { start: number; end: number; text: string }[],
currentTime: number,
) {
return (
srt.find((sub) => currentTime >= sub.start && currentTime <= sub.end) ||
null
);
}
function toSeconds(timeStr: string): number {
const [h, m, s] = timeStr.replace(",", ".").split(":");
return parseFloat(
(parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s)).toFixed(3),
);
}

View File

@@ -1,132 +0,0 @@
// ==================== Video Types ====================
export interface VideoState {
url: string | null;
isPlaying: boolean;
currentTime: number;
duration: number;
playbackRate: number;
volume: number;
}
export interface VideoControls {
play: () => void;
pause: () => void;
togglePlayPause: () => void;
seek: (time: number) => void;
setPlaybackRate: (rate: number) => void;
setVolume: (volume: number) => void;
restart: () => void;
}
// ==================== Subtitle Types ====================
export interface SubtitleEntry {
start: number;
end: number;
text: string;
index: number;
}
export interface SubtitleState {
url: string | null;
data: SubtitleEntry[];
currentText: string;
currentIndex: number | null;
settings: SubtitleSettings;
}
export interface SubtitleSettings {
fontSize: number;
backgroundColor: string;
textColor: string;
position: 'top' | 'center' | 'bottom';
fontFamily: string;
opacity: number;
}
export interface SubtitleControls {
next: () => void;
previous: () => void;
goToIndex: (index: number) => void;
toggleAutoPause: () => void;
}
// ==================== Controls Types ====================
export interface ControlState {
autoPause: boolean;
showShortcuts: boolean;
showSettings: boolean;
}
export interface ControlActions {
toggleAutoPause: () => void;
toggleShortcuts: () => void;
toggleSettings: () => void;
}
export interface KeyboardShortcut {
key: string;
description: string;
action: () => void;
}
// ==================== Store Types ====================
export interface SrtPlayerStore {
// Video state
video: VideoState;
// Subtitle state
subtitle: SubtitleState;
// Controls state
controls: ControlState;
// Video actions
setVideoUrl: (url: string | null) => void;
setPlaying: (playing: boolean) => void;
setCurrentTime: (time: number) => void;
setDuration: (duration: number) => void;
setPlaybackRate: (rate: number) => void;
setVolume: (volume: number) => void;
play: () => void;
pause: () => void;
togglePlayPause: () => void;
seek: (time: number) => void;
restart: () => void;
// Subtitle actions
setSubtitleUrl: (url: string | null) => void;
setSubtitleData: (data: SubtitleEntry[]) => void;
setCurrentSubtitle: (text: string, index: number | null) => void;
updateSettings: (settings: Partial<SubtitleSettings>) => void;
nextSubtitle: () => void;
previousSubtitle: () => void;
restartSubtitle: () => void;
// Controls actions
toggleAutoPause: () => void;
toggleShortcuts: () => void;
toggleSettings: () => void;
}
// ==================== Selectors ====================
export const selectors = {
canPlay: (state: SrtPlayerStore) =>
!!state.video.url &&
!!state.subtitle.url &&
state.subtitle.data.length > 0,
currentSubtitle: (state: SrtPlayerStore) =>
state.subtitle.currentIndex !== null
? state.subtitle.data[state.subtitle.currentIndex]
: null,
progress: (state: SrtPlayerStore) => ({
current: state.subtitle.currentIndex ?? 0,
total: state.subtitle.data.length,
}),
};

View File

@@ -2,11 +2,11 @@
import { useCallback, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Video, FileText, ChevronLeft, ChevronRight, RotateCcw, Pause, Play } from 'lucide-react';
import { Video, FileText, ChevronLeft, ChevronRight, RotateCcw, Pause, Play, Settings, Keyboard } from 'lucide-react';
import { Button, LightButton } from '@/design-system/base/button';
import { Range } from '@/design-system/base/range';
import { HStack, VStack } from '@/design-system/layout/stack';
import { useSrtPlayerStore } from '../store';
import { useSrtPlayerStore } from '../stores/srtPlayerStore';
import { useFileUpload } from '../hooks/useFileUpload';
import { toast } from 'sonner';
@@ -14,7 +14,6 @@ export function ControlPanel() {
const t = useTranslations('srt_player');
const { uploadVideo, uploadSubtitle } = useFileUpload();
// Store state
const videoUrl = useSrtPlayerStore((state) => state.video.url);
const subtitleUrl = useSrtPlayerStore((state) => state.subtitle.url);
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
@@ -22,8 +21,10 @@ export function ControlPanel() {
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
const playbackRate = useSrtPlayerStore((state) => state.video.playbackRate);
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
const showSettings = useSrtPlayerStore((state) => state.controls.showSettings);
const showShortcuts = useSrtPlayerStore((state) => state.controls.showShortcuts);
const settings = useSrtPlayerStore((state) => state.subtitle.settings);
// Store actions
const togglePlayPause = useSrtPlayerStore((state) => state.togglePlayPause);
const nextSubtitle = useSrtPlayerStore((state) => state.nextSubtitle);
const previousSubtitle = useSrtPlayerStore((state) => state.previousSubtitle);
@@ -33,47 +34,45 @@ export function ControlPanel() {
const setVideoUrl = useSrtPlayerStore((state) => state.setVideoUrl);
const setSubtitleUrl = useSrtPlayerStore((state) => state.setSubtitleUrl);
const seek = useSrtPlayerStore((state) => state.seek);
const toggleSettings = useSrtPlayerStore((state) => state.toggleSettings);
const toggleShortcuts = useSrtPlayerStore((state) => state.toggleShortcuts);
const updateSettings = useSrtPlayerStore((state) => state.updateSettings);
// Computed values
const canPlay = useMemo(() => !!videoUrl && !!subtitleUrl && subtitleData.length > 0, [videoUrl, subtitleUrl, subtitleData]);
const currentProgress = currentIndex ?? 0;
const totalProgress = Math.max(0, subtitleData.length - 1);
// Handle video upload
const handleVideoUpload = useCallback(() => {
uploadVideo(setVideoUrl, (error) => {
toast.error(t('videoUploadFailed') + ': ' + error.message);
});
}, [uploadVideo, setVideoUrl, t]);
// Handle subtitle upload
const handleSubtitleUpload = useCallback(() => {
uploadSubtitle(setSubtitleUrl, (error) => {
uploadSubtitle((url) => {
setSubtitleUrl(url);
}, (error) => {
toast.error(t('subtitleUploadFailed') + ': ' + error.message);
});
}, [uploadSubtitle, setSubtitleUrl, t]);
// Handle seek
const handleSeek = useCallback((index: number) => {
if (subtitleData[index]) {
seek(subtitleData[index].start);
}
}, [subtitleData, seek]);
// Handle playback rate change
const handlePlaybackRateChange = useCallback(() => {
const rates = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
const currentIndex = rates.indexOf(playbackRate);
const nextIndex = (currentIndex + 1) % rates.length;
setPlaybackRate(rates[nextIndex]);
const currentIndexRate = rates.indexOf(playbackRate);
const nextIndexRate = (currentIndexRate + 1) % rates.length;
setPlaybackRate(rates[nextIndexRate]);
}, [playbackRate, setPlaybackRate]);
return (
<div className="p-3 bg-gray-50 border-t rounded-b-xl">
<VStack gap={3}>
{/* Upload Status Cards */}
<HStack gap={3}>
{/* Video Upload Card */}
<div
className={`flex-1 p-2 rounded-lg border-2 transition-all ${
videoUrl ? 'border-gray-800 bg-gray-100' : 'border-gray-300 bg-white'
@@ -97,7 +96,6 @@ export function ControlPanel() {
</HStack>
</div>
{/* Subtitle Upload Card */}
<div
className={`flex-1 p-2 rounded-lg border-2 transition-all ${
subtitleUrl ? 'border-gray-800 bg-gray-100' : 'border-gray-300 bg-white'
@@ -122,12 +120,10 @@ export function ControlPanel() {
</div>
</HStack>
{/* Controls Area */}
<VStack
gap={4}
className={!canPlay ? 'opacity-50 pointer-events-none' : ''}
>
{/* Playback Controls */}
<HStack gap={2} justify="center" wrap>
<Button
onClick={togglePlayPause}
@@ -176,9 +172,22 @@ export function ControlPanel() {
>
{t('autoPause', { enabled: autoPause ? t('on') : t('off') })}
</Button>
<LightButton
onClick={toggleSettings}
leftIcon={<Settings className="w-4 h-4" />}
>
{t('settings')}
</LightButton>
<LightButton
onClick={toggleShortcuts}
leftIcon={<Keyboard className="w-4 h-4" />}
>
{t('shortcuts')}
</LightButton>
</HStack>
{/* Seek Bar */}
<VStack gap={2}>
<Range
value={currentProgress}
@@ -188,19 +197,16 @@ export function ControlPanel() {
disabled={!canPlay}
/>
{/* Progress Stats */}
<HStack gap={4} justify="between" className="text-sm text-gray-600 px-2">
<span>
{currentIndex !== null ? `${currentIndex + 1}/${subtitleData.length}` : '0/0'}
</span>
<HStack gap={4}>
{/* Playback Rate Badge */}
<span className="bg-gray-200 px-2 py-1 rounded text-xs">
{playbackRate}x
</span>
{/* Auto Pause Badge */}
<span
className={`px-2 py-1 rounded text-xs ${
autoPause ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600'
@@ -212,6 +218,92 @@ export function ControlPanel() {
</HStack>
</VStack>
</VStack>
{showSettings && (
<div className="p-3 bg-white rounded-lg border border-gray-200">
<h3 className="font-semibold text-gray-800 mb-3">{t('subtitleSettings')}</h3>
<VStack gap={3}>
<HStack gap={2} className="w-full">
<span className="text-sm text-gray-600 w-20">{t('fontSize')}</span>
<Range
value={settings.fontSize}
min={12}
max={48}
onChange={(value) => updateSettings({ fontSize: value })}
/>
<span className="text-sm text-gray-600 w-12">{settings.fontSize}px</span>
</HStack>
<HStack gap={2} className="w-full">
<span className="text-sm text-gray-600 w-20">{t('textColor')}</span>
<input
type="color"
value={settings.textColor}
onChange={(e) => updateSettings({ textColor: e.target.value })}
className="w-8 h-8 rounded cursor-pointer"
/>
</HStack>
<HStack gap={2} className="w-full">
<span className="text-sm text-gray-600 w-20">{t('backgroundColor')}</span>
<input
type="color"
value={settings.backgroundColor.replace(/rgba?\([^)]+\)/, '#000000')}
onChange={(e) => updateSettings({ backgroundColor: e.target.value })}
className="w-8 h-8 rounded cursor-pointer"
/>
</HStack>
<HStack gap={2} className="w-full">
<span className="text-sm text-gray-600 w-20">{t('position')}</span>
<HStack gap={2}>
{(['top', 'center', 'bottom'] as const).map((pos) => (
<Button
key={pos}
size="sm"
variant={settings.position === pos ? 'primary' : 'secondary'}
onClick={() => updateSettings({ position: pos })}
>
{t(pos)}
</Button>
))}
</HStack>
</HStack>
<HStack gap={2} className="w-full">
<span className="text-sm text-gray-600 w-20">{t('opacity')}</span>
<Range
value={settings.opacity}
min={0.1}
max={1}
step={0.1}
onChange={(value) => updateSettings({ opacity: value })}
/>
<span className="text-sm text-gray-600 w-12">{Math.round(settings.opacity * 100)}%</span>
</HStack>
</VStack>
</div>
)}
{showShortcuts && (
<div className="p-3 bg-white rounded-lg border border-gray-200">
<h3 className="font-semibold text-gray-800 mb-3">{t('keyboardShortcuts')}</h3>
<VStack gap={2}>
{[
{ key: 'Space', desc: t('playPause') },
{ key: 'N', desc: t('next') },
{ key: 'P', desc: t('previous') },
{ key: 'R', desc: t('restart') },
{ key: 'A', desc: t('autoPauseToggle') },
].map((shortcut) => (
<HStack key={shortcut.key} gap={2} justify="between" className="w-full">
<kbd className="px-2 py-1 bg-gray-100 rounded text-sm font-mono">{shortcut.key}</kbd>
<span className="text-sm text-gray-600">{shortcut.desc}</span>
</HStack>
))}
</VStack>
</div>
)}
</VStack>
</div>
);

View File

@@ -1,8 +1,8 @@
"use client";
import { useRef, useEffect, forwardRef } from 'react';
import { useSrtPlayerStore } from '../store';
import { setVideoRef } from '../store';
import { useSrtPlayerStore } from '../stores/srtPlayerStore';
import { setVideoRef } from '../stores/srtPlayerStore';
export const VideoPlayerPanel = forwardRef<HTMLVideoElement>((_, ref) => {
const localVideoRef = useRef<HTMLVideoElement>(null);
@@ -14,14 +14,12 @@ export const VideoPlayerPanel = forwardRef<HTMLVideoElement>((_, ref) => {
const currentText = useSrtPlayerStore((state) => state.subtitle.currentText);
const settings = useSrtPlayerStore((state) => state.subtitle.settings);
// 设置 video ref 给 store
useEffect(() => {
setVideoRef(videoRef);
}, [videoRef]);
return (
<div className="aspect-video bg-black relative rounded-md overflow-hidden">
{/* 空状态提示 */}
{(!videoUrl || !subtitleUrl || subtitleData.length === 0) && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-75 z-10">
<div className="text-center text-white">
@@ -41,7 +39,6 @@ export const VideoPlayerPanel = forwardRef<HTMLVideoElement>((_, ref) => {
</div>
)}
{/* 视频元素 */}
{videoUrl && (
<video
ref={videoRef}
@@ -51,7 +48,6 @@ export const VideoPlayerPanel = forwardRef<HTMLVideoElement>((_, ref) => {
/>
)}
{/* 字幕显示覆盖层 */}
{subtitleUrl && subtitleData.length > 0 && currentText && (
<div
className="absolute px-4 py-2 text-center w-full"

View File

@@ -9,8 +9,7 @@ export function useFileUpload() {
onError?: (error: Error) => void
) => {
try {
// 验证文件大小限制为1000MB
const maxSize = 1000 * 1024 * 1024; // 1000MB
const maxSize = 1000 * 1024 * 1024;
if (file.size > maxSize) {
throw new Error(`文件大小超过限制 (${(file.size / 1024 / 1024).toFixed(2)}MB > 1000MB)`);
}
@@ -34,7 +33,6 @@ export function useFileUpload() {
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
// 验证文件类型
if (!file.type.startsWith('video/')) {
onError?.(new Error('请选择有效的视频文件'));
return;
@@ -61,7 +59,6 @@ export function useFileUpload() {
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
// 验证文件扩展名
if (!file.name.toLowerCase().endsWith('.srt')) {
onError?.(new Error('请选择.srt格式的字幕文件'));
return;
@@ -80,6 +77,5 @@ export function useFileUpload() {
return {
uploadVideo,
uploadSubtitle,
uploadFile,
};
}

View File

@@ -1,14 +1,8 @@
"use client";
import { useEffect } from "react";
import { useSrtPlayerStore } from "../store";
import { useSrtPlayerStore } from "../stores/srtPlayerStore";
/**
* useSrtPlayerShortcuts - SRT Hook
*
* SRT
* 使 Zustand store actions
*/
export function useSrtPlayerShortcuts(enabled: boolean = true) {
const togglePlayPause = useSrtPlayerStore((state) => state.togglePlayPause);
const nextSubtitle = useSrtPlayerStore((state) => state.nextSubtitle);
@@ -20,7 +14,6 @@ export function useSrtPlayerShortcuts(enabled: boolean = true) {
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (!enabled) return;
// 防止在输入框中触发快捷键
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
@@ -61,7 +54,6 @@ export function useSrtPlayerShortcuts(enabled: boolean = true) {
}, [enabled, togglePlayPause, nextSubtitle, previousSubtitle, restartSubtitle, toggleAutoPause]);
}
// 保留通用快捷键 Hook 用于其他场景
export function useKeyboardShortcuts(
shortcuts: Array<{ key: string; action: () => void }>,
isEnabled: boolean = true

View File

@@ -0,0 +1,101 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
import { useSrtPlayerStore } from "../stores/srtPlayerStore";
export function useSubtitleSync() {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastIndexRef = useRef<number | null>(null);
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
const playbackRate = useSrtPlayerStore((state) => state.video.playbackRate);
const currentTime = useSrtPlayerStore((state) => state.video.currentTime);
const setCurrentSubtitle = useSrtPlayerStore((state) => state.setCurrentSubtitle);
const pause = useSrtPlayerStore((state) => state.pause);
const scheduleAutoPause = useCallback(() => {
if (!autoPause || !isPlaying) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
return;
}
const currentTimeNow = useSrtPlayerStore.getState().video.currentTime;
const currentIndexNow = useSrtPlayerStore.getState().subtitle.currentIndex;
if (currentIndexNow === null || !subtitleData[currentIndexNow]) {
return;
}
const subtitle = subtitleData[currentIndexNow];
const timeUntilEnd = subtitle.end - currentTimeNow;
if (timeUntilEnd <= 0) {
return;
}
const advanceTime = 0.15;
const realTimeUntilPause = (timeUntilEnd - advanceTime) / playbackRate;
if (realTimeUntilPause > 0) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
pause();
}, realTimeUntilPause * 1000);
}
}, [autoPause, isPlaying, subtitleData, playbackRate, pause]);
useEffect(() => {
if (!subtitleData || subtitleData.length === 0) {
setCurrentSubtitle('', null);
lastIndexRef.current = null;
return;
}
let newIndex: number | null = null;
for (let i = 0; i < subtitleData.length; i++) {
const subtitle = subtitleData[i];
if (currentTime >= subtitle.start && currentTime <= subtitle.end) {
newIndex = i;
break;
}
}
if (newIndex !== lastIndexRef.current) {
lastIndexRef.current = newIndex;
if (newIndex !== null) {
setCurrentSubtitle(subtitleData[newIndex].text, newIndex);
} else {
setCurrentSubtitle('', null);
}
}
}, [subtitleData, currentTime, setCurrentSubtitle]);
useEffect(() => {
scheduleAutoPause();
}, [isPlaying, autoPause]);
useEffect(() => {
if (isPlaying && autoPause) {
scheduleAutoPause();
}
}, [playbackRate, currentTime]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, []);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, type RefObject } from 'react';
import { useSrtPlayerStore } from '../store';
import { useSrtPlayerStore } from '../stores/srtPlayerStore';
export function useVideoSync(videoRef: RefObject<HTMLVideoElement | null>) {
const setCurrentTime = useSrtPlayerStore((state) => state.setCurrentTime);

View File

@@ -1,97 +1,177 @@
"use client";
import { LightButton, PageLayout } from "@/components/ui";
import { useVideoStore } from "./stores/videoStore";
import { useEffect, useRef } from "react";
import Link from "next/link";
import { useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { PageLayout } from "@/components/ui/PageLayout";
import { LightButton } from "@/design-system/base/button";
import { HStack } from "@/design-system/layout/stack";
import { MessageSquareQuote, Video } from "lucide-react";
import { useFileUpload } from "./useFileUpload";
import { useSubtitleStore } from "./stores/substitleStore";
import { getCurrentIndex } from "./subtitleParser";
import { Video, FileText, ChevronLeft, ChevronRight, RotateCcw, Pause, Play } from "lucide-react";
import { useVideoSync } from "./hooks/useVideoSync";
import { useSubtitleSync } from "./hooks/useSubtitleSync";
import { useSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
import { loadSubtitle } from "./utils/subtitleParser";
import { useSrtPlayerStore } from "./stores/srtPlayerStore";
import { useFileUpload } from "./hooks/useFileUpload";
import { setVideoRef } from "./stores/srtPlayerStore";
import Link from "next/link";
export default function SrtPlayerPage() {
const t = useTranslations("home");
const srtT = useTranslations("srt_player");
export default function SRTPlayerPage() {
const videoRef = useRef<HTMLVideoElement>(null);
const { setVideoRef, pause, currentSrc, isPlaying, loadVideo, loaded, getCurrentTime, getDuration, play, setOnTimeUpdate } = useVideoStore();
const {
uploadVideo,
uploadSubtitle,
} = useFileUpload();
const {
sub,
setSub,
index,
setIndex
} = useSubtitleStore();
const { uploadVideo, uploadSubtitle } = useFileUpload();
const subtitleUrl = useSrtPlayerStore((state) => state.subtitle.url);
const setSubtitleData = useSrtPlayerStore((state) => state.setSubtitleData);
const setSubtitleUrl = useSrtPlayerStore((state) => state.setSubtitleUrl);
const setVideoUrl = useSrtPlayerStore((state) => state.setVideoUrl);
const videoUrl = useSrtPlayerStore((state) => state.video.url);
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
const currentIndex = useSrtPlayerStore((state) => state.subtitle.currentIndex);
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
const playbackRate = useSrtPlayerStore((state) => state.video.playbackRate);
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
const togglePlayPause = useSrtPlayerStore((state) => state.togglePlayPause);
const nextSubtitle = useSrtPlayerStore((state) => state.nextSubtitle);
const previousSubtitle = useSrtPlayerStore((state) => state.previousSubtitle);
const restartSubtitle = useSrtPlayerStore((state) => state.restartSubtitle);
const setPlaybackRate = useSrtPlayerStore((state) => state.setPlaybackRate);
const toggleAutoPause = useSrtPlayerStore((state) => state.toggleAutoPause);
const seek = useSrtPlayerStore((state) => state.seek);
useVideoSync(videoRef);
useSubtitleSync();
useSrtPlayerShortcuts();
useEffect(() => {
setVideoRef(videoRef);
setOnTimeUpdate((time) => {
setIndex(getCurrentIndex(sub, time));
}, [videoRef]);
const canPlay = !!videoUrl && !!subtitleUrl && subtitleData.length > 0;
useEffect(() => {
if (subtitleUrl) {
loadSubtitle(subtitleUrl)
.then((subtitleData) => {
setSubtitleData(subtitleData);
toast.success(srtT("subtitleLoadSuccess"));
})
.catch((error) => {
toast.error(srtT("subtitleLoadFailed") + ": " + error.message);
});
}
}, [srtT, subtitleUrl, setSubtitleData]);
const handleVideoUpload = () => {
uploadVideo((url) => {
setVideoUrl(url);
}, (error) => {
toast.error(t('videoUploadFailed') + ': ' + error.message);
});
return () => {
setVideoRef();
setOnTimeUpdate(() => { });
};
}, [setVideoRef, setOnTimeUpdate, sub, setIndex]);
};
const handleSubtitleUpload = () => {
uploadSubtitle((url) => {
setSubtitleUrl(url);
}, (error) => {
toast.error(t('subtitleUploadFailed') + ': ' + error.message);
});
};
const handlePlaybackRateChange = () => {
const rates = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
const currentIndexRate = rates.indexOf(playbackRate);
const nextIndexRate = (currentIndexRate + 1) % rates.length;
setPlaybackRate(rates[nextIndexRate]);
};
const currentSubtitle = currentIndex !== null ? subtitleData[currentIndex] : null;
return (
<PageLayout>
<video ref={videoRef} width="85%" className="mx-auto"></video>
<div className="shadow rounded h-20 w-[85%] mx-auto flex-wrap flex items-begin justify-center">
{
sub[index] && sub[index].text.split(" ").map((s, i) =>
<Link key={i}
href={`/dictionary?q=${s}`}
className="px-1 h-fit hover:bg-gray-200 hover:cursor-pointer">
{s}
</Link>
)}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-800 mb-2">
{t("srtPlayer.name")}
</h1>
<p className="text-lg text-gray-600">
{t("srtPlayer.description")}
</p>
</div>
<video
ref={videoRef}
width="85%"
className="mx-auto"
playsInline
/>
<div className="shadow rounded h-20 w-[85%] mx-auto flex-wrap flex items-begin justify-center">
{currentSubtitle && currentSubtitle.text.split(" ").map((s, i) => (
<Link
key={i}
href={`/dictionary?q=${s}`}
className="px-1 h-fit hover:bg-gray-200 hover:cursor-pointer"
>
{s}
</Link>
))}
</div>
{/* 上传区域 */}
<div className="mx-auto mt-4 flex items-center justify-center flex-wrap gap-2 w-[85%]">
<div className="border-gray-200 border-2 flex items p-2 justify-between items-center rounded gap-8">
<div className="flex items-center flex-col">
<Video size={16} />
<span className="text-sm"></span>
</div>
<LightButton
onClick={() => uploadVideo((url) => {
loadVideo(url);
})}>{loaded ? currentSrc?.split("/").pop() : "视频未上传"}</LightButton>
<LightButton onClick={handleVideoUpload} disabled={!!videoUrl}>
{videoUrl ? '已上传' : '上传视频'}
</LightButton>
</div>
<div className="border-gray-200 border-2 flex items p-2 justify-between items-center rounded gap-8">
<div className="flex items-center flex-col">
<MessageSquareQuote size={16} />
<span className="text-sm"
>{sub.length > 0 ? `字幕已上传 (${sub.length} 条)` : "字幕未上传"}</span>
<FileText size={16} />
<span className="text-sm">
{subtitleData.length > 0 ? `字幕已上传 (${subtitleData.length} 条)` : "字幕未上传"}
</span>
</div>
<LightButton
onClick={() =>
uploadSubtitle((sub) => {
setSub(sub);
})
}></LightButton>
<LightButton onClick={handleSubtitleUpload} disabled={!!subtitleUrl}>
{subtitleUrl ? '已上传' : '上传字幕'}
</LightButton>
</div>
</div>
{
/* 控制面板 */
sub.length > 0 && loaded &&
{canPlay && (
<HStack gap={2} className="mx-auto mt-4 w-[85%]" justify={"center"} wrap>
{isPlaying() ?
LightButton({ children: "pause", onClick: () => pause() }) :
LightButton({ children: "play", onClick: () => play() })}
<LightButton>previous</LightButton>
<LightButton>next</LightButton>
<LightButton>restart</LightButton>
<LightButton>1x</LightButton>
<LightButton>ap(on)</LightButton>
{isPlaying ? (
<LightButton onClick={togglePlayPause} leftIcon={<Pause className="w-4 h-4" />}>
{srtT('pause')}
</LightButton>
) : (
<LightButton onClick={togglePlayPause} leftIcon={<Play className="w-4 h-4" />}>
{srtT('play')}
</LightButton>
)}
<LightButton onClick={previousSubtitle} leftIcon={<ChevronLeft className="w-4 h-4" />}>
{srtT('previous')}
</LightButton>
<LightButton onClick={nextSubtitle} rightIcon={<ChevronRight className="w-4 h-4" />}>
{srtT('next')}
</LightButton>
<LightButton onClick={restartSubtitle} leftIcon={<RotateCcw className="w-4 h-4" />}>
{srtT('restart')}
</LightButton>
<LightButton onClick={handlePlaybackRateChange}>
{playbackRate}x
</LightButton>
<LightButton onClick={toggleAutoPause}>
{srtT('autoPause', { enabled: autoPause ? srtT('on') : srtT('off') })}
</LightButton>
</HStack>
}
)}
</PageLayout>
);
}

View File

@@ -10,7 +10,7 @@ import type {
ControlState,
SubtitleSettings,
SubtitleEntry,
} from './types';
} from '../types';
import type { RefObject } from 'react';
let videoRef: RefObject<HTMLVideoElement | null> | null;
@@ -19,7 +19,6 @@ export function setVideoRef(ref: RefObject<HTMLVideoElement | null> | null) {
videoRef = ref;
}
// 初始状态
const initialVideoState: VideoState = {
url: null,
isPlaying: false,
@@ -55,12 +54,10 @@ const initialControlState: ControlState = {
export const useSrtPlayerStore = create<SrtPlayerStore>()(
devtools(
(set, get) => ({
// ==================== Initial State ====================
video: initialVideoState,
subtitle: initialSubtitleState,
controls: initialControlState,
// ==================== Video Actions ====================
setVideoUrl: (url) =>
set((state) => {
if (videoRef?.current) {
@@ -111,7 +108,6 @@ export const useSrtPlayerStore = create<SrtPlayerStore>()(
pause: () => {
if (videoRef?.current) {
// 只有在视频正在播放时才暂停,避免重复调用
if (!videoRef.current.paused) {
videoRef.current.pause();
}
@@ -146,7 +142,6 @@ export const useSrtPlayerStore = create<SrtPlayerStore>()(
}
},
// ==================== Subtitle Actions ====================
setSubtitleUrl: (url) =>
set((state) => ({ subtitle: { ...state.subtitle, url } })),
@@ -202,7 +197,6 @@ export const useSrtPlayerStore = create<SrtPlayerStore>()(
}
},
// ==================== Controls Actions ====================
toggleAutoPause: () =>
set((state) => ({
controls: { ...state.controls, autoPause: !state.controls.autoPause },

View File

@@ -1,19 +0,0 @@
import { create } from "zustand/react";
import { SubtitleEntry } from "../types";
import { devtools } from "zustand/middleware";
interface SubstitleStore {
sub: SubtitleEntry[];
index: number;
setSub: (sub: SubtitleEntry[]) => void;
setIndex: (index: number) => void;
}
export const useSubtitleStore = create<SubstitleStore>()(
devtools((set) => ({
sub: [],
index: 0,
setSub: (sub) => set({ sub, index: 0 }),
setIndex: (index) => set({ index }),
}))
);

View File

@@ -1,112 +0,0 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface VideoStore {
videoRef?: React.RefObject<HTMLVideoElement | null>;
currentSrc: string | null;
loaded: boolean;
onTimeUpdate: (time: number) => void;
setOnTimeUpdate: (handler: (time: number) => void) => void;
setVideoRef: (ref?: React.RefObject<HTMLVideoElement | null>) => void;
loadVideo: (url: string, options?: { autoplay?: boolean; muted?: boolean; }) => void;
play: () => void;
pause: () => void;
togglePlay: () => void;
seekTo: (time: number) => void;
setVolume: (vol: number) => void;
getCurrentTime: () => number | undefined;
getDuration: () => number | undefined;
isPlaying: () => boolean;
}
export const useVideoStore = create<VideoStore>()(
devtools((set, get) => ({
videoRef: null,
currentSrc: null,
loaded: false,
onTimeUpdate: (time) => { },
setOnTimeUpdate: (handler) => {
set({ onTimeUpdate: handler });
},
setVideoRef: (ref) => {
set({ videoRef: ref });
ref?.current?.addEventListener("timeupdate", () => {
const currentTime = get().videoRef?.current?.currentTime;
if (currentTime !== undefined) {
get().onTimeUpdate(currentTime);
}
});
},
loadVideo: (url: string, options = { autoplay: false, muted: false }) => {
const { videoRef } = get();
const video = videoRef?.current;
if (!url) {
console.warn('loadVideo: empty url provided');
return;
}
if (!video) {
console.debug('loadVideo: video ref not ready yet');
return;
}
try {
video.pause();
video.currentTime = 0;
video.src = url;
if (options.autoplay) {
video
.play()
.then(() => {
console.debug('Auto play succeeded after src change');
})
.catch((err) => {
console.warn('Auto play failed after src change:', err);
});
}
set({ currentSrc: url, loaded: true });
} catch (err) {
console.error('Failed to load video:', err);
set({ loaded: false });
}
},
play: () => {
const video = get().videoRef?.current;
if (video) video.play().catch(() => { });
},
pause: () => {
const video = get().videoRef?.current;
if (video) video.pause();
},
togglePlay: () => {
const video = get().videoRef?.current;
if (!video) return;
if (video.paused) {
video.play().catch(() => { });
} else {
video.pause();
}
},
seekTo: (time: number) => {
const video = get().videoRef?.current;
if (video) video.currentTime = time;
},
setVolume: (vol: number) => {
const video = get().videoRef?.current;
if (video) video.volume = Math.max(0, Math.min(1, vol));
},
getCurrentTime: () => get().videoRef?.current?.currentTime,
getDuration: () => get().videoRef?.current?.duration,
isPlaying: () => {
const video = get().videoRef?.current;
if (!video) return false;
return !video.paused && !video.ended && video.readyState > 2;
}
}))
);

View File

@@ -1,89 +0,0 @@
import { SubtitleEntry } from "./types";
export function parseSrt(data: string): SubtitleEntry[] {
const lines = data.split(/\r?\n/);
const result: SubtitleEntry[] = [];
const re = new RegExp(
"(\\d{2}:\\d{2}:\\d{2},\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2},\\d{3})",
);
let i = 0;
while (i < lines.length) {
if (!lines[i].trim()) {
i++;
continue;
}
i++;
if (i >= lines.length) break;
const timeMatch = lines[i].match(re);
if (!timeMatch) {
i++;
continue;
}
const start = toSeconds(timeMatch[1]);
const end = toSeconds(timeMatch[2]);
i++;
let text = "";
while (i < lines.length && lines[i].trim()) {
text += lines[i] + "\n";
i++;
}
result.push({
start,
end,
text: text.trim(),
index: result.length,
});
i++;
}
return result;
}
export function getNearestIndex(
subtitles: SubtitleEntry[],
currentTime: number,
): number | null {
for (let i = 0; i < subtitles.length; i++) {
const subtitle = subtitles[i];
const isBefore = currentTime - subtitle.start >= 0;
const isAfter = currentTime - subtitle.end >= 0;
if (!isBefore || !isAfter) return i - 1;
if (isBefore && !isAfter) return i;
}
return null;
}
export function getCurrentIndex(
subtitles: SubtitleEntry[],
currentTime: number,
): number {
for (let index = 0; index < subtitles.length; index++) {
if (subtitles[index].start <= currentTime && subtitles[index].end >= currentTime) {
return index;
}
}
return -1;
}
function toSeconds(timeStr: string): number {
const [h, m, s] = timeStr.replace(",", ".").split(":");
return parseFloat(
(parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s)).toFixed(3),
);
}
export function loadSubtitle(url: string): Promise<SubtitleEntry[]> {
return fetch(url)
.then(response => response.text())
.then(data => parseSrt(data))
.catch(error => {
console.error('加载字幕失败', error);
return [];
});
}

View File

@@ -1,6 +1,132 @@
// ==================== Video Types ====================
export interface VideoState {
url: string | null;
isPlaying: boolean;
currentTime: number;
duration: number;
playbackRate: number;
volume: number;
}
export interface VideoControls {
play: () => void;
pause: () => void;
togglePlayPause: () => void;
seek: (time: number) => void;
setPlaybackRate: (rate: number) => void;
setVolume: (volume: number) => void;
restart: () => void;
}
// ==================== Subtitle Types ====================
export interface SubtitleEntry {
start: number;
end: number;
text: string;
index: number;
}
export interface SubtitleState {
url: string | null;
data: SubtitleEntry[];
currentText: string;
currentIndex: number | null;
settings: SubtitleSettings;
}
export interface SubtitleSettings {
fontSize: number;
backgroundColor: string;
textColor: string;
position: 'top' | 'center' | 'bottom';
fontFamily: string;
opacity: number;
}
export interface SubtitleControls {
next: () => void;
previous: () => void;
goToIndex: (index: number) => void;
toggleAutoPause: () => void;
}
// ==================== Controls Types ====================
export interface ControlState {
autoPause: boolean;
showShortcuts: boolean;
showSettings: boolean;
}
export interface ControlActions {
toggleAutoPause: () => void;
toggleShortcuts: () => void;
toggleSettings: () => void;
}
export interface KeyboardShortcut {
key: string;
description: string;
action: () => void;
}
// ==================== Store Types ====================
export interface SrtPlayerStore {
// Video state
video: VideoState;
// Subtitle state
subtitle: SubtitleState;
// Controls state
controls: ControlState;
// Video actions
setVideoUrl: (url: string | null) => void;
setPlaying: (playing: boolean) => void;
setCurrentTime: (time: number) => void;
setDuration: (duration: number) => void;
setPlaybackRate: (rate: number) => void;
setVolume: (volume: number) => void;
play: () => void;
pause: () => void;
togglePlayPause: () => void;
seek: (time: number) => void;
restart: () => void;
// Subtitle actions
setSubtitleUrl: (url: string | null) => void;
setSubtitleData: (data: SubtitleEntry[]) => void;
setCurrentSubtitle: (text: string, index: number | null) => void;
updateSettings: (settings: Partial<SubtitleSettings>) => void;
nextSubtitle: () => void;
previousSubtitle: () => void;
restartSubtitle: () => void;
// Controls actions
toggleAutoPause: () => void;
toggleShortcuts: () => void;
toggleSettings: () => void;
}
// ==================== Selectors ====================
export const selectors = {
canPlay: (state: SrtPlayerStore) =>
!!state.video.url &&
!!state.subtitle.url &&
state.subtitle.data.length > 0,
currentSubtitle: (state: SrtPlayerStore) =>
state.subtitle.currentIndex !== null
? state.subtitle.data[state.subtitle.currentIndex]
: null,
progress: (state: SrtPlayerStore) => ({
current: state.subtitle.currentIndex ?? 0,
total: state.subtitle.data.length,
}),
};

View File

@@ -1,65 +0,0 @@
"use client";
import { loadSubtitle } from "./subtitleParser";
const createUploadHandler = <T,>(
accept: string,
validate: (file: File) => boolean,
errorMessage: string,
processFile: (file: File) => T | Promise<T>
) => {
return ((
onSuccess: (result: T) => void,
onError?: (error: Error) => void
) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = accept;
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
if (!validate(file)) {
onError?.(new Error(errorMessage));
return;
}
try {
const result = await processFile(file);
onSuccess(result);
} catch (error) {
onError?.(error instanceof Error ? error : new Error('文件处理失败'));
}
}
};
input.onerror = () => {
onError?.(new Error('文件选择失败'));
};
input.click();
});
};
export function useFileUpload() {
const uploadVideo = createUploadHandler(
'video/*',
(file) => file.type.startsWith('video/'),
'请选择有效的视频文件',
(file) => URL.createObjectURL(file)
);
const uploadSubtitle = createUploadHandler(
'.srt',
(file) => file.name.toLowerCase().endsWith('.srt'),
'请选择.srt格式的字幕文件',
async (file) => {
const url = URL.createObjectURL(file);
return loadSubtitle(url);
}
);
return {
uploadVideo,
uploadSubtitle,
};
}