diff --git a/src/app/(features)/srt-player/VideoPlayer/SubtitleDisplay.tsx b/src/app/(features)/_srt-player/VideoPlayer/SubtitleDisplay.tsx similarity index 100% rename from src/app/(features)/srt-player/VideoPlayer/SubtitleDisplay.tsx rename to src/app/(features)/_srt-player/VideoPlayer/SubtitleDisplay.tsx diff --git a/src/app/(features)/srt-player/VideoPlayer/VideoPanel.tsx b/src/app/(features)/_srt-player/VideoPlayer/VideoPanel.tsx similarity index 100% rename from src/app/(features)/srt-player/VideoPlayer/VideoPanel.tsx rename to src/app/(features)/_srt-player/VideoPlayer/VideoPanel.tsx diff --git a/src/app/(features)/srt-player/components/ControlPanel.tsx b/src/app/(features)/_srt-player/components/ControlPanel.tsx similarity index 100% rename from src/app/(features)/srt-player/components/ControlPanel.tsx rename to src/app/(features)/_srt-player/components/ControlPanel.tsx diff --git a/src/app/(features)/srt-player/components/VideoPlayerPanel.tsx b/src/app/(features)/_srt-player/components/VideoPlayerPanel.tsx similarity index 100% rename from src/app/(features)/srt-player/components/VideoPlayerPanel.tsx rename to src/app/(features)/_srt-player/components/VideoPlayerPanel.tsx diff --git a/src/app/(features)/srt-player/hooks/useFileUpload.ts b/src/app/(features)/_srt-player/hooks/useFileUpload.ts similarity index 93% rename from src/app/(features)/srt-player/hooks/useFileUpload.ts rename to src/app/(features)/_srt-player/hooks/useFileUpload.ts index 5afe508..3c7dca0 100644 --- a/src/app/(features)/srt-player/hooks/useFileUpload.ts +++ b/src/app/(features)/_srt-player/hooks/useFileUpload.ts @@ -9,10 +9,10 @@ export function useFileUpload() { onError?: (error: Error) => void ) => { try { - // 验证文件大小(限制为100MB) - const maxSize = 100 * 1024 * 1024; // 100MB + // 验证文件大小(限制为1000MB) + const maxSize = 1000 * 1024 * 1024; // 1000MB 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); diff --git a/src/app/(features)/srt-player/hooks/useKeyboardShortcuts.ts b/src/app/(features)/_srt-player/hooks/useKeyboardShortcuts.ts similarity index 100% rename from src/app/(features)/srt-player/hooks/useKeyboardShortcuts.ts rename to src/app/(features)/_srt-player/hooks/useKeyboardShortcuts.ts diff --git a/src/app/(features)/srt-player/hooks/useSubtitleSync.ts b/src/app/(features)/_srt-player/hooks/useSubtitleSync.ts similarity index 64% rename from src/app/(features)/srt-player/hooks/useSubtitleSync.ts rename to src/app/(features)/_srt-player/hooks/useSubtitleSync.ts index 239f5f6..d42b65a 100644 --- a/src/app/(features)/srt-player/hooks/useSubtitleSync.ts +++ b/src/app/(features)/_srt-player/hooks/useSubtitleSync.ts @@ -11,11 +11,11 @@ import { useSrtPlayerStore } from "../store"; */ export function useSubtitleSync() { const lastSubtitleRef = useRef(null); + const hasAutoPausedRef = useRef<{ [key: number]: boolean }>({}); // 追踪每个字幕是否已触发自动暂停 const rafIdRef = useRef(0); // 从 store 获取状态 const subtitleData = useSrtPlayerStore((state) => state.subtitle.data); - const currentTime = useSrtPlayerStore((state) => state.video.currentTime); const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying); const autoPause = useSrtPlayerStore((state) => state.controls.autoPause); @@ -27,6 +27,9 @@ export function useSubtitleSync() { // 同步循环 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++) { @@ -38,11 +41,6 @@ export function useSubtitleSync() { 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); // 检查字幕是否发生变化 @@ -57,14 +55,26 @@ export function useSubtitleSync() { } } - // 检查是否需要自动暂停 - const currentSubtitle = currentIndex !== null ? subtitleData[currentIndex] : null; - if (currentSubtitle && shouldAutoPause(currentSubtitle, currentTime)) { - seek(currentSubtitle.start); - pause(); + // 检查是否需要自动暂停(每个字幕只触发一次) + 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); + } } - rafIdRef.current = requestAnimationFrame(syncSubtitles); + // 如果视频正在播放,继续循环 + if (useSrtPlayerStore.getState().video.isPlaying) { + rafIdRef.current = requestAnimationFrame(syncSubtitles); + } }; if (subtitleData.length > 0 && isPlaying) { @@ -76,10 +86,11 @@ export function useSubtitleSync() { cancelAnimationFrame(rafIdRef.current); } }; - }, [subtitleData, currentTime, isPlaying, autoPause, setCurrentSubtitle, seek, pause]); + }, [subtitleData, isPlaying, autoPause, setCurrentSubtitle, seek, pause]); // 重置最后字幕引用 useEffect(() => { lastSubtitleRef.current = null; + hasAutoPausedRef.current = {}; }, [subtitleData]); } diff --git a/src/app/(features)/srt-player/hooks/useVideoSync.ts b/src/app/(features)/_srt-player/hooks/useVideoSync.ts similarity index 100% rename from src/app/(features)/srt-player/hooks/useVideoSync.ts rename to src/app/(features)/_srt-player/hooks/useVideoSync.ts diff --git a/src/app/(features)/_srt-player/page.tsx b/src/app/(features)/_srt-player/page.tsx new file mode 100644 index 0000000..213b0b7 --- /dev/null +++ b/src/app/(features)/_srt-player/page.tsx @@ -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(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 ( + + {/* Title */} +
+

+ {t("srtPlayer.name")} +

+

+ {t("srtPlayer.description")} +

+
+ + {/* Video Player */} + + + {/* Control Panel */} + +
+ ); +} diff --git a/src/app/(features)/srt-player/store.ts b/src/app/(features)/_srt-player/store.ts similarity index 93% rename from src/app/(features)/srt-player/store.ts rename to src/app/(features)/_srt-player/store.ts index c945255..b8ab86c 100644 --- a/src/app/(features)/srt-player/store.ts +++ b/src/app/(features)/_srt-player/store.ts @@ -1,7 +1,7 @@ "use client"; import { create } from 'zustand'; -import { devtools, persist } from 'zustand/middleware'; +import { devtools } from 'zustand/middleware'; import { toast } from 'sonner'; import type { SrtPlayerStore, @@ -11,12 +11,11 @@ import type { SubtitleSettings, SubtitleEntry, } from './types'; -import type { MutableRefObject } from 'react'; +import type { RefObject } from 'react'; -// 声明视频 ref 的全局类型(用于 store 访问 video element) -let videoRef: MutableRefObject | null = null; +let videoRef: RefObject | null; -export function setVideoRef(ref: MutableRefObject) { +export function setVideoRef(ref: RefObject | null) { videoRef = ref; } @@ -112,7 +111,10 @@ export const useSrtPlayerStore = create()( pause: () => { if (videoRef?.current) { - videoRef.current.pause(); + // 只有在视频正在播放时才暂停,避免重复调用 + if (!videoRef.current.paused) { + videoRef.current.pause(); + } set((state) => ({ video: { ...state.video, isPlaying: false } })); } }, diff --git a/src/app/(features)/srt-player/subtitle.ts b/src/app/(features)/_srt-player/subtitle.ts similarity index 100% rename from src/app/(features)/srt-player/subtitle.ts rename to src/app/(features)/_srt-player/subtitle.ts diff --git a/src/app/(features)/_srt-player/types.ts b/src/app/(features)/_srt-player/types.ts new file mode 100644 index 0000000..e0d35be --- /dev/null +++ b/src/app/(features)/_srt-player/types.ts @@ -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) => 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, + }), +}; diff --git a/src/app/(features)/srt-player/utils/subtitleParser.ts b/src/app/(features)/_srt-player/utils/subtitleParser.ts similarity index 100% rename from src/app/(features)/srt-player/utils/subtitleParser.ts rename to src/app/(features)/_srt-player/utils/subtitleParser.ts diff --git a/src/app/(features)/srt-player/page.tsx b/src/app/(features)/srt-player/page.tsx index 213b0b7..5337e8d 100644 --- a/src/app/(features)/srt-player/page.tsx +++ b/src/app/(features)/srt-player/page.tsx @@ -1,63 +1,95 @@ "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"); +import { LightButton, PageLayout } from "@/components/ui"; +import { useVideoStore } from "./stores/videoStore"; +import { useEffect, useRef } from "react"; +import Link from "next/link"; +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"; +export default function SRTPlayerPage() { const videoRef = useRef(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(() => { - if (subtitleUrl) { - loadSubtitle(subtitleUrl) - .then((subtitleData) => { - setSubtitleData(subtitleData); - toast.success(srtT("subtitleLoadSuccess")); - }) - .catch((error) => { - toast.error(srtT("subtitleLoadFailed") + ": " + error.message); - }); - } - }, [srtT, subtitleUrl, setSubtitleData]); + setVideoRef(videoRef); + setOnTimeUpdate((time) => { + setIndex(getCurrentIndex(sub, time)); + }); + return () => { + setVideoRef(); + setOnTimeUpdate(() => { }); + }; + }, [setVideoRef, setOnTimeUpdate, sub, setIndex]); return ( - {/* Title */} -
-

- {t("srtPlayer.name")} -

-

- {t("srtPlayer.description")} -

+ + +
+ { + sub[index] && sub[index].text.split(" ").map((s, i) => + + {s} + + )}
- {/* Video Player */} - + {/* 上传区域 */} +
+
+
+
+ uploadVideo((url) => { + loadVideo(url); + })}>{loaded ? currentSrc?.split("/").pop() : "视频未上传"} +
+
+
+ + {sub.length > 0 ? `字幕已上传 (${sub.length} 条)` : "字幕未上传"} +
+ + uploadSubtitle((sub) => { + setSub(sub); + }) + }>上传字幕 +
+
+ + { + /* 控制面板 */ + sub.length > 0 && loaded && + + play + previous + next + restart + 1x + ap(on) + + } - {/* Control Panel */} - ); } diff --git a/src/app/(features)/srt-player/stores/substitleStore.ts b/src/app/(features)/srt-player/stores/substitleStore.ts new file mode 100644 index 0000000..afda49d --- /dev/null +++ b/src/app/(features)/srt-player/stores/substitleStore.ts @@ -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()( + devtools((set) => ({ + sub: [], + index: 0, + setSub: (sub) => set({ sub, index: 0 }), + setIndex: (index) => set({ index }), + })) +); diff --git a/src/app/(features)/srt-player/stores/videoStore.ts b/src/app/(features)/srt-player/stores/videoStore.ts new file mode 100644 index 0000000..e148f99 --- /dev/null +++ b/src/app/(features)/srt-player/stores/videoStore.ts @@ -0,0 +1,106 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; + +interface VideoStore { + videoRef?: React.RefObject; + currentSrc: string | null; + loaded: boolean; + onTimeUpdate: (time: number) => void; + setOnTimeUpdate: (handler: (time: number) => void) => void; + setVideoRef: (ref?: React.RefObject) => 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()( + 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, + })) +); \ No newline at end of file diff --git a/src/app/(features)/srt-player/subtitleParser.ts b/src/app/(features)/srt-player/subtitleParser.ts new file mode 100644 index 0000000..8a72125 --- /dev/null +++ b/src/app/(features)/srt-player/subtitleParser.ts @@ -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 { + return fetch(url) + .then(response => response.text()) + .then(data => parseSrt(data)) + .catch(error => { + console.error('加载字幕失败', error); + return []; + }); +} \ No newline at end of file diff --git a/src/app/(features)/srt-player/types.ts b/src/app/(features)/srt-player/types.ts index e0d35be..6e397d4 100644 --- a/src/app/(features)/srt-player/types.ts +++ b/src/app/(features)/srt-player/types.ts @@ -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 { 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) => 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, - }), -}; diff --git a/src/app/(features)/srt-player/useFileUpload.ts b/src/app/(features)/srt-player/useFileUpload.ts new file mode 100644 index 0000000..a22ce99 --- /dev/null +++ b/src/app/(features)/srt-player/useFileUpload.ts @@ -0,0 +1,65 @@ +"use client"; + +import { loadSubtitle } from "./subtitleParser"; + +const createUploadHandler = ( + accept: string, + validate: (file: File) => boolean, + errorMessage: string, + processFile: (file: File) => T | Promise +) => { + 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, + }; +} \ No newline at end of file