Wed Mar 4 09:32:00 AM CST 2026
This commit is contained in:
@@ -9,10 +9,10 @@ export function useFileUpload() {
|
|||||||
onError?: (error: Error) => void
|
onError?: (error: Error) => void
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
// 验证文件大小(限制为100MB)
|
// 验证文件大小(限制为1000MB)
|
||||||
const maxSize = 100 * 1024 * 1024; // 100MB
|
const maxSize = 1000 * 1024 * 1024; // 1000MB
|
||||||
if (file.size > maxSize) {
|
if (file.size > maxSize) {
|
||||||
throw new Error(`文件大小超过限制 (${(file.size / 1024 / 1024).toFixed(2)}MB > 100MB)`);
|
throw new Error(`文件大小超过限制 (${(file.size / 1024 / 1024).toFixed(2)}MB > 1000MB)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(file);
|
||||||
@@ -11,11 +11,11 @@ import { useSrtPlayerStore } from "../store";
|
|||||||
*/
|
*/
|
||||||
export function useSubtitleSync() {
|
export function useSubtitleSync() {
|
||||||
const lastSubtitleRef = useRef<number | null>(null);
|
const lastSubtitleRef = useRef<number | null>(null);
|
||||||
|
const hasAutoPausedRef = useRef<{ [key: number]: boolean }>({}); // 追踪每个字幕是否已触发自动暂停
|
||||||
const rafIdRef = useRef<number>(0);
|
const rafIdRef = useRef<number>(0);
|
||||||
|
|
||||||
// 从 store 获取状态
|
// 从 store 获取状态
|
||||||
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
|
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
|
||||||
const currentTime = useSrtPlayerStore((state) => state.video.currentTime);
|
|
||||||
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
|
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
|
||||||
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
|
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
|
||||||
|
|
||||||
@@ -27,6 +27,9 @@ export function useSubtitleSync() {
|
|||||||
// 同步循环
|
// 同步循环
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncSubtitles = () => {
|
const syncSubtitles = () => {
|
||||||
|
// 从 store 获取最新的 currentTime
|
||||||
|
const currentTime = useSrtPlayerStore.getState().video.currentTime;
|
||||||
|
|
||||||
// 获取当前时间对应的字幕索引
|
// 获取当前时间对应的字幕索引
|
||||||
const getCurrentSubtitleIndex = (time: number): number | null => {
|
const getCurrentSubtitleIndex = (time: number): number | null => {
|
||||||
for (let i = 0; i < subtitleData.length; i++) {
|
for (let i = 0; i < subtitleData.length; i++) {
|
||||||
@@ -38,11 +41,6 @@ export function useSubtitleSync() {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 检查是否需要自动暂停
|
|
||||||
const shouldAutoPause = (subtitle: { start: number; end: number }, time: number): boolean => {
|
|
||||||
return autoPause && time >= subtitle.end - 0.2 && time < subtitle.end;
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentIndex = getCurrentSubtitleIndex(currentTime);
|
const currentIndex = getCurrentSubtitleIndex(currentTime);
|
||||||
|
|
||||||
// 检查字幕是否发生变化
|
// 检查字幕是否发生变化
|
||||||
@@ -57,14 +55,26 @@ export function useSubtitleSync() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否需要自动暂停
|
// 检查是否需要自动暂停(每个字幕只触发一次)
|
||||||
const currentSubtitle = currentIndex !== null ? subtitleData[currentIndex] : null;
|
if (autoPause && currentIndex !== null) {
|
||||||
if (currentSubtitle && shouldAutoPause(currentSubtitle, currentTime)) {
|
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);
|
seek(currentSubtitle.start);
|
||||||
|
// 使用 setTimeout 确保在 seek 之后暂停
|
||||||
|
setTimeout(() => {
|
||||||
pause();
|
pause();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果视频正在播放,继续循环
|
||||||
|
if (useSrtPlayerStore.getState().video.isPlaying) {
|
||||||
rafIdRef.current = requestAnimationFrame(syncSubtitles);
|
rafIdRef.current = requestAnimationFrame(syncSubtitles);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (subtitleData.length > 0 && isPlaying) {
|
if (subtitleData.length > 0 && isPlaying) {
|
||||||
@@ -76,10 +86,11 @@ export function useSubtitleSync() {
|
|||||||
cancelAnimationFrame(rafIdRef.current);
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [subtitleData, currentTime, isPlaying, autoPause, setCurrentSubtitle, seek, pause]);
|
}, [subtitleData, isPlaying, autoPause, setCurrentSubtitle, seek, pause]);
|
||||||
|
|
||||||
// 重置最后字幕引用
|
// 重置最后字幕引用
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
lastSubtitleRef.current = null;
|
lastSubtitleRef.current = null;
|
||||||
|
hasAutoPausedRef.current = {};
|
||||||
}, [subtitleData]);
|
}, [subtitleData]);
|
||||||
}
|
}
|
||||||
63
src/app/(features)/_srt-player/page.tsx
Normal file
63
src/app/(features)/_srt-player/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { devtools, persist } from 'zustand/middleware';
|
import { devtools } from 'zustand/middleware';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type {
|
import type {
|
||||||
SrtPlayerStore,
|
SrtPlayerStore,
|
||||||
@@ -11,12 +11,11 @@ import type {
|
|||||||
SubtitleSettings,
|
SubtitleSettings,
|
||||||
SubtitleEntry,
|
SubtitleEntry,
|
||||||
} from './types';
|
} from './types';
|
||||||
import type { MutableRefObject } from 'react';
|
import type { RefObject } from 'react';
|
||||||
|
|
||||||
// 声明视频 ref 的全局类型(用于 store 访问 video element)
|
let videoRef: RefObject<HTMLVideoElement | null> | null;
|
||||||
let videoRef: MutableRefObject<HTMLVideoElement | null> | null = null;
|
|
||||||
|
|
||||||
export function setVideoRef(ref: MutableRefObject<HTMLVideoElement | null>) {
|
export function setVideoRef(ref: RefObject<HTMLVideoElement | null> | null) {
|
||||||
videoRef = ref;
|
videoRef = ref;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +111,10 @@ export const useSrtPlayerStore = create<SrtPlayerStore>()(
|
|||||||
|
|
||||||
pause: () => {
|
pause: () => {
|
||||||
if (videoRef?.current) {
|
if (videoRef?.current) {
|
||||||
|
// 只有在视频正在播放时才暂停,避免重复调用
|
||||||
|
if (!videoRef.current.paused) {
|
||||||
videoRef.current.pause();
|
videoRef.current.pause();
|
||||||
|
}
|
||||||
set((state) => ({ video: { ...state.video, isPlaying: false } }));
|
set((state) => ({ video: { ...state.video, isPlaying: false } }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
132
src/app/(features)/_srt-player/types.ts
Normal file
132
src/app/(features)/_srt-player/types.ts
Normal file
@@ -0,0 +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,
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -1,63 +1,95 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useEffect } from "react";
|
import { LightButton, PageLayout } from "@/components/ui";
|
||||||
import { useTranslations } from "next-intl";
|
import { useVideoStore } from "./stores/videoStore";
|
||||||
import { toast } from "sonner";
|
import { useEffect, useRef } from "react";
|
||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
import Link from "next/link";
|
||||||
import { VideoPlayerPanel } from "./components/VideoPlayerPanel";
|
import { HStack } from "@/design-system/layout/stack";
|
||||||
import { ControlPanel } from "./components/ControlPanel";
|
import { MessageSquareQuote, Video } from "lucide-react";
|
||||||
import { useVideoSync } from "./hooks/useVideoSync";
|
import { useFileUpload } from "./useFileUpload";
|
||||||
import { useSubtitleSync } from "./hooks/useSubtitleSync";
|
import { useSubtitleStore } from "./stores/substitleStore";
|
||||||
import { useSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
|
import { getCurrentIndex } from "./subtitleParser";
|
||||||
import { loadSubtitle } from "./utils/subtitleParser";
|
|
||||||
import { useSrtPlayerStore } from "./store";
|
|
||||||
|
|
||||||
export default function SrtPlayerPage() {
|
|
||||||
const t = useTranslations("home");
|
|
||||||
const srtT = useTranslations("srt_player");
|
|
||||||
|
|
||||||
|
export default function SRTPlayerPage() {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const { setVideoRef, currentSrc, loadVideo, loaded, getCurrentTime, getDuration, play, setOnTimeUpdate } = useVideoStore();
|
||||||
|
const {
|
||||||
|
uploadVideo,
|
||||||
|
uploadSubtitle,
|
||||||
|
} = useFileUpload();
|
||||||
|
const {
|
||||||
|
sub,
|
||||||
|
setSub,
|
||||||
|
index,
|
||||||
|
setIndex
|
||||||
|
} = useSubtitleStore();
|
||||||
|
|
||||||
// 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(() => {
|
useEffect(() => {
|
||||||
if (subtitleUrl) {
|
setVideoRef(videoRef);
|
||||||
loadSubtitle(subtitleUrl)
|
setOnTimeUpdate((time) => {
|
||||||
.then((subtitleData) => {
|
setIndex(getCurrentIndex(sub, time));
|
||||||
setSubtitleData(subtitleData);
|
|
||||||
toast.success(srtT("subtitleLoadSuccess"));
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
toast.error(srtT("subtitleLoadFailed") + ": " + error.message);
|
|
||||||
});
|
});
|
||||||
}
|
return () => {
|
||||||
}, [srtT, subtitleUrl, setSubtitleData]);
|
setVideoRef();
|
||||||
|
setOnTimeUpdate(() => { });
|
||||||
|
};
|
||||||
|
}, [setVideoRef, setOnTimeUpdate, sub, setIndex]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
{/* Title */}
|
<video ref={videoRef} width="85%" className="mx-auto"></video>
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h1 className="text-4xl font-bold text-gray-800 mb-2">
|
<div className="shadow rounded h-20 w-[85%] mx-auto flex-wrap flex items-begin justify-center">
|
||||||
{t("srtPlayer.name")}
|
{
|
||||||
</h1>
|
sub[index] && sub[index].text.split(" ").map((s, i) =>
|
||||||
<p className="text-lg text-gray-600">
|
<Link key={i}
|
||||||
{t("srtPlayer.description")}
|
href={`/dictionary?q=${s}`}
|
||||||
</p>
|
className="px-1 h-fit hover:bg-gray-200 hover:cursor-pointer">
|
||||||
|
{s}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Video Player */}
|
{/* 上传区域 */}
|
||||||
<VideoPlayerPanel ref={videoRef} />
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
<LightButton
|
||||||
|
onClick={() =>
|
||||||
|
uploadSubtitle((sub) => {
|
||||||
|
setSub(sub);
|
||||||
|
})
|
||||||
|
}>上传字幕</LightButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* 控制面板 */
|
||||||
|
sub.length > 0 && loaded &&
|
||||||
|
<HStack gap={2} className="mx-auto mt-4 w-[85%]" justify={"center"} wrap>
|
||||||
|
<LightButton onClick={play}>play</LightButton>
|
||||||
|
<LightButton>previous</LightButton>
|
||||||
|
<LightButton>next</LightButton>
|
||||||
|
<LightButton>restart</LightButton>
|
||||||
|
<LightButton>1x</LightButton>
|
||||||
|
<LightButton>ap(on)</LightButton>
|
||||||
|
</HStack>
|
||||||
|
}
|
||||||
|
|
||||||
{/* Control Panel */}
|
|
||||||
<ControlPanel />
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/app/(features)/srt-player/stores/substitleStore.ts
Normal file
19
src/app/(features)/srt-player/stores/substitleStore.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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 }),
|
||||||
|
}))
|
||||||
|
);
|
||||||
106
src/app/(features)/srt-player/stores/videoStore.ts
Normal file
106
src/app/(features)/srt-player/stores/videoStore.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
);
|
||||||
89
src/app/(features)/srt-player/subtitleParser.ts
Normal file
89
src/app/(features)/srt-player/subtitleParser.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
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 [];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,132 +1,6 @@
|
|||||||
// ==================== 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 {
|
export interface SubtitleEntry {
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
text: string;
|
text: string;
|
||||||
index: number;
|
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,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|||||||
65
src/app/(features)/srt-player/useFileUpload.ts
Normal file
65
src/app/(features)/srt-player/useFileUpload.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user