diff --git a/CLAUDE.md b/CLAUDE.md
index 54e1563..69b60c7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -22,3 +22,4 @@ pnpm run build
- Better-auth 处理会话管理 - 使用 authClient 适配器进行认证操作
- 使用 better-auth username 插件支持用户名登录
- 组件尽量复用/src/design-system里的可复用组件与/src/components里的业务相关组件
+- 不要创建index.ts
diff --git a/src/app/(features)/srt-player/components/ControlPanel.tsx b/src/app/(features)/srt-player/components/ControlPanel.tsx
new file mode 100644
index 0000000..004c245
--- /dev/null
+++ b/src/app/(features)/srt-player/components/ControlPanel.tsx
@@ -0,0 +1,218 @@
+"use client";
+
+import { useCallback, useMemo } from 'react';
+import { useTranslations } from 'next-intl';
+import { Video, FileText, ChevronLeft, ChevronRight, RotateCcw, Pause, Play } 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 { useFileUpload } from '../hooks/useFileUpload';
+import { toast } from 'sonner';
+
+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);
+ 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);
+
+ // Store actions
+ 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 setVideoUrl = useSrtPlayerStore((state) => state.setVideoUrl);
+ const setSubtitleUrl = useSrtPlayerStore((state) => state.setSubtitleUrl);
+ const seek = useSrtPlayerStore((state) => state.seek);
+
+ // 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) => {
+ 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]);
+ }, [playbackRate, setPlaybackRate]);
+
+ return (
+
+
+ {/* Upload Status Cards */}
+
+ {/* Video Upload Card */}
+
+
+
+
+
+ {t('videoFile')}
+ {videoUrl ? t('uploaded') : t('notUploaded')}
+
+
+
+ {videoUrl ? t('uploaded') : t('upload')}
+
+
+
+
+ {/* Subtitle Upload Card */}
+
+
+
+
+
+ {t('subtitleFile')}
+ {subtitleUrl ? t('uploaded') : t('notUploaded')}
+
+
+
+ {subtitleUrl ? t('uploaded') : t('upload')}
+
+
+
+
+
+ {/* Controls Area */}
+
+ {/* Playback Controls */}
+
+ : }
+ >
+ {isPlaying ? t('pause') : t('play')}
+
+
+ }
+ >
+ {t('previous')}
+
+
+ }
+ >
+ {t('next')}
+
+
+ }
+ >
+ {t('restart')}
+
+
+
+
+ }
+ variant={autoPause ? 'primary' : 'secondary'}
+ >
+ {t('autoPause', { enabled: autoPause ? t('on') : t('off') })}
+
+
+
+ {/* Seek Bar */}
+
+
+
+ {/* Progress Stats */}
+
+
+ {currentIndex !== null ? `${currentIndex + 1}/${subtitleData.length}` : '0/0'}
+
+
+
+ {/* Playback Rate Badge */}
+
+ {playbackRate}x
+
+
+ {/* Auto Pause Badge */}
+
+ {t('autoPauseStatus', { enabled: autoPause ? t('on') : t('off') })}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(features)/srt-player/components/VideoPlayerPanel.tsx b/src/app/(features)/srt-player/components/VideoPlayerPanel.tsx
new file mode 100644
index 0000000..05980b4
--- /dev/null
+++ b/src/app/(features)/srt-player/components/VideoPlayerPanel.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { useRef, useEffect, forwardRef } from 'react';
+import { useSrtPlayerStore } from '../store';
+import { setVideoRef } from '../store';
+
+export const VideoPlayerPanel = forwardRef((_, ref) => {
+ const localVideoRef = useRef(null);
+ const videoRef = (ref as React.RefObject) || localVideoRef;
+
+ const videoUrl = useSrtPlayerStore((state) => state.video.url);
+ const subtitleUrl = useSrtPlayerStore((state) => state.subtitle.url);
+ const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
+ const currentText = useSrtPlayerStore((state) => state.subtitle.currentText);
+ const settings = useSrtPlayerStore((state) => state.subtitle.settings);
+
+ // 设置 video ref 给 store
+ useEffect(() => {
+ setVideoRef(videoRef);
+ }, [videoRef]);
+
+ return (
+
+ {/* 空状态提示 */}
+ {(!videoUrl || !subtitleUrl || subtitleData.length === 0) && (
+
+
+
+ {!videoUrl && !subtitleUrl
+ ? '请上传视频和字幕文件'
+ : !videoUrl
+ ? '请上传视频文件'
+ : !subtitleUrl
+ ? '请上传字幕文件'
+ : '正在处理字幕...'}
+
+ {(!videoUrl || !subtitleUrl) && (
+
需要同时上传视频和字幕文件才能播放
+ )}
+
+
+ )}
+
+ {/* 视频元素 */}
+ {videoUrl && (
+
+ )}
+
+ {/* 字幕显示覆盖层 */}
+ {subtitleUrl && subtitleData.length > 0 && currentText && (
+
+ {currentText}
+
+ )}
+
+ );
+});
+
+VideoPlayerPanel.displayName = 'VideoPlayerPanel';
diff --git a/src/app/(features)/srt-player/components/atoms/FileInput.tsx b/src/app/(features)/srt-player/components/atoms/FileInput.tsx
deleted file mode 100644
index 5145137..0000000
--- a/src/app/(features)/srt-player/components/atoms/FileInput.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-"use client";
-
-import React, { useRef } from "react";
-import { LightButton } from "@/design-system/base/button";
-import { FileInputProps } from "../../types/controls";
-
-interface FileInputComponentProps extends FileInputProps {
- children: React.ReactNode;
-}
-
-export function FileInput({ accept, onFileSelect, disabled, className, children }: FileInputComponentProps) {
- const inputRef = useRef(null);
-
- const handleClick = React.useCallback(() => {
- if (!disabled && inputRef.current) {
- inputRef.current.click();
- }
- }, [disabled]);
-
- const handleChange = React.useCallback((event: React.ChangeEvent) => {
- const file = event.target.files?.[0];
- if (file) {
- onFileSelect(file);
- }
- }, [onFileSelect]);
-
- return (
- <>
-
-
- {children}
-
- >
- );
-}
\ No newline at end of file
diff --git a/src/app/(features)/srt-player/components/atoms/PlayButton.tsx b/src/app/(features)/srt-player/components/atoms/PlayButton.tsx
deleted file mode 100644
index 32534ef..0000000
--- a/src/app/(features)/srt-player/components/atoms/PlayButton.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-"use client";
-
-import React from "react";
-import { useTranslations } from "next-intl";
-import { LightButton } from "@/design-system/base/button";
-import { PlayButtonProps } from "../../types/player";
-
-export function PlayButton({ isPlaying, onToggle, disabled, className }: PlayButtonProps) {
- const t = useTranslations("srt_player");
-
- return (
-
- {isPlaying ? t("pause") : t("play")}
-
- );
-}
\ No newline at end of file
diff --git a/src/app/(features)/srt-player/components/atoms/SeekBar.tsx b/src/app/(features)/srt-player/components/atoms/SeekBar.tsx
deleted file mode 100644
index bb0f732..0000000
--- a/src/app/(features)/srt-player/components/atoms/SeekBar.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-"use client";
-
-import React from "react";
-import { SeekBarProps } from "../../types/player";
-import { RangeInput } from "@/design-system/base/range";
-
-export function SeekBar({ value, max, onChange, disabled, className }: SeekBarProps) {
- return (
-
- );
-}
\ No newline at end of file
diff --git a/src/app/(features)/srt-player/components/atoms/SpeedControl.tsx b/src/app/(features)/srt-player/components/atoms/SpeedControl.tsx
deleted file mode 100644
index d266e92..0000000
--- a/src/app/(features)/srt-player/components/atoms/SpeedControl.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-"use client";
-
-import React from "react";
-import { LightButton } from "@/design-system/base/button";
-import { SpeedControlProps } from "../../types/player";
-import { getPlaybackRateOptions, getPlaybackRateLabel } from "../../utils/timeUtils";
-
-export function SpeedControl({ playbackRate, onPlaybackRateChange, disabled, className }: SpeedControlProps) {
- const speedOptions = getPlaybackRateOptions();
-
- const handleSpeedChange = React.useCallback(() => {
- const currentIndex = speedOptions.indexOf(playbackRate);
- const nextIndex = (currentIndex + 1) % speedOptions.length;
- onPlaybackRateChange(speedOptions[nextIndex]);
- }, [playbackRate, onPlaybackRateChange, speedOptions]);
-
- return (
-
- {getPlaybackRateLabel(playbackRate)}
-
- );
-}
\ No newline at end of file
diff --git a/src/app/(features)/srt-player/components/atoms/SubtitleText.tsx b/src/app/(features)/srt-player/components/atoms/SubtitleText.tsx
deleted file mode 100644
index a45f8b4..0000000
--- a/src/app/(features)/srt-player/components/atoms/SubtitleText.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-"use client";
-
-import React from "react";
-import { SubtitleTextProps } from "../../types/subtitle";
-
-export function SubtitleText({ text, onWordClick, style, className }: SubtitleTextProps) {
- const handleWordClick = React.useCallback((word: string) => {
- onWordClick?.(word);
- }, [onWordClick]);
-
- // 将文本分割成单词,保持标点符号
- const renderTextWithClickableWords = () => {
- if (!text) return null;
-
- // 匹配单词和标点符号
- const parts = text.match(/[\w']+|[^\w\s]+|\s+/g) || [];
-
- return parts.map((part, index) => {
- // 如果是单词(字母和撇号组成)
- if (/^[\w']+$/.test(part)) {
- return (
- handleWordClick(part)}
- className="hover:bg-gray-700 hover:underline hover:cursor-pointer rounded px-1 transition-colors"
- >
- {part}
-
- );
- }
- // 如果是空格或其他字符,直接渲染
- return {part};
- });
- };
-
- return (
-
- {renderTextWithClickableWords()}
-
- );
-}
\ No newline at end of file
diff --git a/src/app/(features)/srt-player/components/atoms/VideoElement.tsx b/src/app/(features)/srt-player/components/atoms/VideoElement.tsx
deleted file mode 100644
index 10a000c..0000000
--- a/src/app/(features)/srt-player/components/atoms/VideoElement.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-"use client";
-
-import React, { forwardRef } from "react";
-import { VideoElementProps } from "../../types/player";
-
-const VideoElement = forwardRef(
- ({ src, onTimeUpdate, onLoadedMetadata, onPlay, onPause, onEnded, className }, ref) => {
- const handleTimeUpdate = React.useCallback((event: React.SyntheticEvent) => {
- const video = event.currentTarget;
- onTimeUpdate?.(video.currentTime);
- }, [onTimeUpdate]);
-
- const handleLoadedMetadata = React.useCallback((event: React.SyntheticEvent) => {
- const video = event.currentTarget;
- onLoadedMetadata?.(video.duration);
- }, [onLoadedMetadata]);
-
- const handlePlay = React.useCallback((event: React.SyntheticEvent) => {
- onPlay?.();
- }, [onPlay]);
-
- const handlePause = React.useCallback((event: React.SyntheticEvent) => {
- onPause?.();
- }, [onPause]);
-
- const handleEnded = React.useCallback((event: React.SyntheticEvent) => {
- onEnded?.();
- }, [onEnded]);
-
- return (
-
- );
- }
-);
-
-VideoElement.displayName = "VideoElement";
-
-export { VideoElement };
\ No newline at end of file
diff --git a/src/app/(features)/srt-player/components/compounds/ControlBar.tsx b/src/app/(features)/srt-player/components/compounds/ControlBar.tsx
deleted file mode 100644
index b24470c..0000000
--- a/src/app/(features)/srt-player/components/compounds/ControlBar.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-"use client";
-
-import React from "react";
-import { useTranslations } from "next-intl";
-import { ChevronLeft, ChevronRight, RotateCcw, Pause } from "lucide-react";
-import { LightButton } from "@/design-system/base/button";
-import { ControlBarProps } from "../../types/controls";
-import { PlayButton } from "../atoms/PlayButton";
-import { SpeedControl } from "../atoms/SpeedControl";
-
-export function ControlBar({
- isPlaying,
- onPlayPause,
- onPrevious,
- onNext,
- onRestart,
- playbackRate,
- onPlaybackRateChange,
- autoPause,
- onAutoPauseToggle,
- disabled,
- className
-}: ControlBarProps) {
- const t = useTranslations("srt_player");
-
- return (
-
-
-
-
-
- {t("previous")}
-
-
-
- {t("next")}
-
-
-
-
-
- {t("restart")}
-
-
-
-
-
-
- {t("autoPause", { enabled: autoPause ? t("on") : t("off") })}
-
-
- );
-}
\ No newline at end of file
diff --git a/src/app/(features)/srt-player/components/compounds/SubtitleArea.tsx b/src/app/(features)/srt-player/components/compounds/SubtitleArea.tsx
deleted file mode 100644
index 278c5a0..0000000
--- a/src/app/(features)/srt-player/components/compounds/SubtitleArea.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-"use client";
-
-import React from "react";
-import { SubtitleDisplayProps } from "../../types/subtitle";
-import { SubtitleText } from "../atoms/SubtitleText";
-
-export function SubtitleArea({ subtitle, onWordClick, settings, className }: SubtitleDisplayProps) {
- const handleWordClick = React.useCallback((word: string) => {
- // 打开有道词典页面查询单词
- window.open(
- `https://www.youdao.com/result?word=${encodeURIComponent(word)}&lang=en`,
- "_blank"
- );
- onWordClick?.(word);
- }, [onWordClick]);
-
- const subtitleStyle = React.useMemo(() => {
- if (!settings) return { backgroundColor: 'rgba(0, 0, 0, 0.5)' };
-
- return {
- backgroundColor: settings.backgroundColor,
- color: settings.textColor,
- fontSize: `${settings.fontSize}px`,
- fontFamily: settings.fontFamily,
- opacity: settings.opacity,
- };
- }, [settings]);
-
- return (
-
- );
-}
\ No newline at end of file
diff --git a/src/app/(features)/srt-player/components/compounds/UploadZone.tsx b/src/app/(features)/srt-player/components/compounds/UploadZone.tsx
deleted file mode 100644
index 450850e..0000000
--- a/src/app/(features)/srt-player/components/compounds/UploadZone.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-"use client";
-
-import React from "react";
-import { useTranslations } from "next-intl";
-import { toast } from "sonner";
-import { Video, FileText } from "lucide-react";
-import { LightButton } from "@/design-system/base/button";
-import { FileUploadProps } from "../../types/controls";
-import { useFileUpload } from "../../hooks/useFileUpload";
-
-export function UploadZone({ onVideoUpload, onSubtitleUpload, className }: FileUploadProps) {
- const t = useTranslations("srt_player");
- const { uploadVideo, uploadSubtitle } = useFileUpload();
-
- const handleVideoUpload = React.useCallback(() => {
- uploadVideo(onVideoUpload, (error) => {
- toast.error(t("videoUploadFailed") + ": " + error.message);
- });
- }, [uploadVideo, onVideoUpload, t]);
-
- const handleSubtitleUpload = React.useCallback(() => {
- uploadSubtitle(onSubtitleUpload, (error) => {
- toast.error(t("subtitleUploadFailed") + ": " + error.message);
- });
- }, [uploadSubtitle, onSubtitleUpload, t]);
-
- return (
-
-
-
- {t("uploadVideo")}
-
-
-
-
- {t("uploadSubtitle")}
-
-
- );
-}
\ No newline at end of file
diff --git a/src/app/(features)/srt-player/components/compounds/VideoPlayer.tsx b/src/app/(features)/srt-player/components/compounds/VideoPlayer.tsx
deleted file mode 100644
index d72048a..0000000
--- a/src/app/(features)/srt-player/components/compounds/VideoPlayer.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-"use client";
-
-import React, { forwardRef } from "react";
-import { VideoElementProps } from "../../types/player";
-import { VideoElement } from "../atoms/VideoElement";
-
-interface VideoPlayerComponentProps extends VideoElementProps {
- children?: React.ReactNode;
-}
-
-const VideoPlayer = forwardRef(
- ({
- src,
- onTimeUpdate,
- onLoadedMetadata,
- onPlay,
- onPause,
- onEnded,
- className,
- children
- }, ref) => {
- return (
-
-
- {children}
-
- );
- }
-);
-
-VideoPlayer.displayName = "VideoPlayer";
-
-export { VideoPlayer };
\ No newline at end of file
diff --git a/src/app/(features)/srt-player/hooks/useKeyboardShortcuts.ts b/src/app/(features)/srt-player/hooks/useKeyboardShortcuts.ts
index 6dbb761..42507c8 100644
--- a/src/app/(features)/srt-player/hooks/useKeyboardShortcuts.ts
+++ b/src/app/(features)/srt-player/hooks/useKeyboardShortcuts.ts
@@ -1,68 +1,90 @@
"use client";
-import { useCallback, useEffect } from "react";
-import { KeyboardShortcut } from "../types/controls";
+import { useEffect } from "react";
+import { useSrtPlayerStore } from "../store";
-export function useKeyboardShortcuts(
- shortcuts: KeyboardShortcut[],
- enabled: boolean = true
-) {
- const handleKeyDown = useCallback((event: globalThis.KeyboardEvent) => {
- if (!enabled) return;
-
- // 防止在输入框中触发快捷键
- const target = event.target as HTMLElement;
- if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
- return;
- }
-
- const shortcut = shortcuts.find(s => s.key === event.key);
- if (shortcut) {
- event.preventDefault();
- shortcut.action();
- }
- }, [shortcuts, enabled]);
+/**
+ * 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);
+ const previousSubtitle = useSrtPlayerStore((state) => state.previousSubtitle);
+ const restartSubtitle = useSrtPlayerStore((state) => state.restartSubtitle);
+ const toggleAutoPause = useSrtPlayerStore((state) => state.toggleAutoPause);
useEffect(() => {
+ const handleKeyDown = (event: globalThis.KeyboardEvent) => {
+ if (!enabled) return;
+
+ // 防止在输入框中触发快捷键
+ const target = event.target as HTMLElement;
+ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
+ return;
+ }
+
+ switch (event.key) {
+ case ' ':
+ event.preventDefault();
+ togglePlayPause();
+ break;
+ case 'n':
+ case 'N':
+ event.preventDefault();
+ nextSubtitle();
+ break;
+ case 'p':
+ case 'P':
+ event.preventDefault();
+ previousSubtitle();
+ break;
+ case 'r':
+ case 'R':
+ event.preventDefault();
+ restartSubtitle();
+ break;
+ case 'a':
+ case 'A':
+ event.preventDefault();
+ toggleAutoPause();
+ break;
+ }
+ };
+
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
- }, [handleKeyDown]);
+ }, [enabled, togglePlayPause, nextSubtitle, previousSubtitle, restartSubtitle, toggleAutoPause]);
}
-export function createSrtPlayerShortcuts(
- playPause: () => void,
- next: () => void,
- previous: () => void,
- restart: () => void,
- toggleAutoPause: () => void
-): KeyboardShortcut[] {
- return [
- {
- key: ' ',
- description: '播放/暂停',
- action: playPause,
- },
- {
- key: 'n',
- description: '下一句',
- action: next,
- },
- {
- key: 'p',
- description: '上一句',
- action: previous,
- },
- {
- key: 'r',
- description: '句首',
- action: restart,
- },
- {
- key: 'a',
- description: '切换自动暂停',
- action: toggleAutoPause,
- },
- ];
-}
\ No newline at end of file
+// 保留通用快捷键 Hook 用于其他场景
+export function useKeyboardShortcuts(
+ shortcuts: Array<{ key: string; action: () => void }>,
+ isEnabled: boolean = true
+) {
+ useEffect(() => {
+ const handleKeyDown = (event: globalThis.KeyboardEvent) => {
+ if (!isEnabled) return;
+
+ const target = event.target as HTMLElement;
+ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
+ return;
+ }
+
+ const shortcut = shortcuts.find(s => s.key === event.key);
+ if (shortcut) {
+ event.preventDefault();
+ shortcut.action();
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [shortcuts, isEnabled]);
+}
diff --git a/src/app/(features)/srt-player/hooks/useSrtPlayer.ts b/src/app/(features)/srt-player/hooks/useSrtPlayer.ts
deleted file mode 100644
index 126e980..0000000
--- a/src/app/(features)/srt-player/hooks/useSrtPlayer.ts
+++ /dev/null
@@ -1,306 +0,0 @@
-"use client";
-
-import { useReducer, useCallback, useRef, useEffect } from "react";
-import { toast } from "sonner";
-import { VideoState, VideoControls } from "../types/player";
-import { SubtitleState, SubtitleEntry } from "../types/subtitle";
-import { ControlState, ControlActions } from "../types/controls";
-
-export interface SrtPlayerState {
- video: VideoState;
- subtitle: SubtitleState;
- controls: ControlState;
-}
-
-export interface SrtPlayerActions extends VideoControls, ControlActions {
- setVideoUrl: (url: string | null) => void;
- setSubtitleUrl: (url: string | null) => void;
- nextSubtitle: () => void;
- previousSubtitle: () => void;
- restartSubtitle: () => void;
- setSubtitleSettings: (settings: Partial) => void;
-}
-
-const initialState: SrtPlayerState = {
- video: {
- url: null,
- isPlaying: false,
- currentTime: 0,
- duration: 0,
- playbackRate: 1.0,
- volume: 1.0,
- },
- subtitle: {
- url: null,
- data: [],
- currentText: "",
- currentIndex: null,
- settings: {
- fontSize: 24,
- backgroundColor: "rgba(0, 0, 0, 0.5)",
- textColor: "#ffffff",
- position: "bottom",
- fontFamily: "sans-serif",
- opacity: 1,
- },
- },
- controls: {
- autoPause: true,
- showShortcuts: false,
- showSettings: false,
- },
-};
-
-type SrtPlayerAction =
- | { type: "SET_VIDEO_URL"; payload: string | null }
- | { type: "SET_PLAYING"; payload: boolean }
- | { type: "SET_CURRENT_TIME"; payload: number }
- | { type: "SET_DURATION"; payload: number }
- | { type: "SET_PLAYBACK_RATE"; payload: number }
- | { type: "SET_VOLUME"; payload: number }
- | { type: "SET_SUBTITLE_URL"; payload: string | null }
- | { type: "SET_SUBTITLE_DATA"; payload: SubtitleEntry[] }
- | { type: "SET_CURRENT_SUBTITLE"; payload: { text: string; index: number | null } }
- | { type: "SET_SUBTITLE_SETTINGS"; payload: Partial }
- | { type: "TOGGLE_AUTO_PAUSE" }
- | { type: "TOGGLE_SHORTCUTS" }
- | { type: "TOGGLE_SETTINGS" };
-
-function srtPlayerReducer(state: SrtPlayerState, action: SrtPlayerAction): SrtPlayerState {
- switch (action.type) {
- case "SET_VIDEO_URL":
- return { ...state, video: { ...state.video, url: action.payload } };
- case "SET_PLAYING":
- return { ...state, video: { ...state.video, isPlaying: action.payload } };
- case "SET_CURRENT_TIME":
- return { ...state, video: { ...state.video, currentTime: action.payload } };
- case "SET_DURATION":
- return { ...state, video: { ...state.video, duration: action.payload } };
- case "SET_PLAYBACK_RATE":
- return { ...state, video: { ...state.video, playbackRate: action.payload } };
- case "SET_VOLUME":
- return { ...state, video: { ...state.video, volume: action.payload } };
- case "SET_SUBTITLE_URL":
- return { ...state, subtitle: { ...state.subtitle, url: action.payload } };
- case "SET_SUBTITLE_DATA":
- return { ...state, subtitle: { ...state.subtitle, data: action.payload } };
- case "SET_CURRENT_SUBTITLE":
- return {
- ...state,
- subtitle: {
- ...state.subtitle,
- currentText: action.payload.text,
- currentIndex: action.payload.index,
- },
- };
- case "SET_SUBTITLE_SETTINGS":
- return {
- ...state,
- subtitle: {
- ...state.subtitle,
- settings: { ...state.subtitle.settings, ...action.payload },
- },
- };
- case "TOGGLE_AUTO_PAUSE":
- return { ...state, controls: { ...state.controls, autoPause: !state.controls.autoPause } };
- case "TOGGLE_SHORTCUTS":
- return { ...state, controls: { ...state.controls, showShortcuts: !state.controls.showShortcuts } };
- case "TOGGLE_SETTINGS":
- return { ...state, controls: { ...state.controls, showSettings: !state.controls.showSettings } };
- default:
- return state;
- }
-}
-
-export function useSrtPlayer() {
- const [state, dispatch] = useReducer(srtPlayerReducer, initialState);
- const videoRef = useRef(null);
-
- // Video controls
- const play = useCallback(() => {
- // 检查是否同时有视频和字幕
- if (!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) {
- toast.error("请先上传视频和字幕文件");
- return;
- }
-
- if (videoRef.current) {
- videoRef.current.play().catch(error => {
- toast.error("视频播放失败: " + error.message);
- });
- dispatch({ type: "SET_PLAYING", payload: true });
- }
- }, [state.video.url, state.subtitle.url, state.subtitle.data.length, dispatch]);
-
- const pause = useCallback(() => {
- if (videoRef.current) {
- videoRef.current.pause();
- dispatch({ type: "SET_PLAYING", payload: false });
- }
- }, []);
-
- const togglePlayPause = useCallback(() => {
- if (state.video.isPlaying) {
- pause();
- } else {
- play();
- }
- }, [state.video.isPlaying, play, pause]);
-
- const seek = useCallback((time: number) => {
- if (videoRef.current) {
- videoRef.current.currentTime = time;
- dispatch({ type: "SET_CURRENT_TIME", payload: time });
- }
- }, []);
-
- const setPlaybackRate = useCallback((rate: number) => {
- if (videoRef.current) {
- videoRef.current.playbackRate = rate;
- dispatch({ type: "SET_PLAYBACK_RATE", payload: rate });
- }
- }, []);
-
- const setVolume = useCallback((volume: number) => {
- if (videoRef.current) {
- videoRef.current.volume = volume;
- dispatch({ type: "SET_VOLUME", payload: volume });
- }
- }, []);
-
- const restart = useCallback(() => {
- if (videoRef.current && state.subtitle.currentIndex !== null) {
- const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
- if (currentSubtitle) {
- seek(currentSubtitle.start);
- play();
- }
- }
- }, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
-
- // URL setters
- const setVideoUrl = useCallback((url: string | null) => {
- dispatch({ type: "SET_VIDEO_URL", payload: url });
- if (url && videoRef.current) {
- videoRef.current.src = url;
- videoRef.current.load();
- }
- }, []);
-
- const setSubtitleUrl = useCallback((url: string | null) => {
- dispatch({ type: "SET_SUBTITLE_URL", payload: url });
- }, []);
-
- // Subtitle controls
- const nextSubtitle = useCallback(() => {
- if (state.subtitle.currentIndex !== null &&
- state.subtitle.currentIndex + 1 < state.subtitle.data.length) {
- const nextIndex = state.subtitle.currentIndex + 1;
- const nextSubtitle = state.subtitle.data[nextIndex];
- seek(nextSubtitle.start);
- play();
- }
- }, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
-
- const previousSubtitle = useCallback(() => {
- if (state.subtitle.currentIndex !== null && state.subtitle.currentIndex > 0) {
- const prevIndex = state.subtitle.currentIndex - 1;
- const prevSubtitle = state.subtitle.data[prevIndex];
- seek(prevSubtitle.start);
- play();
- }
- }, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
-
- const restartSubtitle = useCallback(() => {
- if (state.subtitle.currentIndex !== null) {
- const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
- seek(currentSubtitle.start);
- play();
- }
- }, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
-
- const setSubtitleSettings = useCallback((settings: Partial) => {
- dispatch({ type: "SET_SUBTITLE_SETTINGS", payload: settings });
- }, []);
-
- // Control actions
- const toggleAutoPause = useCallback(() => {
- dispatch({ type: "TOGGLE_AUTO_PAUSE" });
- }, []);
-
- const toggleShortcuts = useCallback(() => {
- dispatch({ type: "TOGGLE_SHORTCUTS" });
- }, []);
-
- const toggleSettings = useCallback(() => {
- dispatch({ type: "TOGGLE_SETTINGS" });
- }, []);
-
- // Video event handlers
- const handleTimeUpdate = useCallback(() => {
- if (videoRef.current) {
- dispatch({ type: "SET_CURRENT_TIME", payload: videoRef.current.currentTime });
- }
- }, []);
-
- const handleLoadedMetadata = useCallback(() => {
- if (videoRef.current) {
- dispatch({ type: "SET_DURATION", payload: videoRef.current.duration });
- }
- }, []);
-
- const handlePlay = useCallback(() => {
- dispatch({ type: "SET_PLAYING", payload: true });
- }, []);
-
- const handlePause = useCallback(() => {
- dispatch({ type: "SET_PLAYING", payload: false });
- }, []);
-
- // Set subtitle data
- const setSubtitleData = useCallback((data: SubtitleEntry[]) => {
- dispatch({ type: "SET_SUBTITLE_DATA", payload: data });
- }, []);
-
- // Set current subtitle
- const setCurrentSubtitle = useCallback((text: string, index: number | null) => {
- dispatch({ type: "SET_CURRENT_SUBTITLE", payload: { text, index } });
- }, []);
-
- const actions: SrtPlayerActions = {
- play,
- pause,
- togglePlayPause,
- seek,
- setPlaybackRate,
- setVolume,
- restart,
- setVideoUrl,
- setSubtitleUrl,
- nextSubtitle,
- previousSubtitle,
- restartSubtitle,
- setSubtitleSettings,
- toggleAutoPause,
- toggleShortcuts,
- toggleSettings,
- };
-
- return {
- state,
- actions,
- videoRef,
- videoEventHandlers: {
- onTimeUpdate: handleTimeUpdate,
- onLoadedMetadata: handleLoadedMetadata,
- onPlay: handlePlay,
- onPause: handlePause,
- },
- subtitleActions: {
- setSubtitleData,
- setCurrentSubtitle,
- },
- };
-}
-
-export type UseSrtPlayerReturn = ReturnType;
\ No newline at end of file
diff --git a/src/app/(features)/srt-player/hooks/useSubtitleSync.ts b/src/app/(features)/srt-player/hooks/useSubtitleSync.ts
index c49dccf..07e3280 100644
--- a/src/app/(features)/srt-player/hooks/useSubtitleSync.ts
+++ b/src/app/(features)/srt-player/hooks/useSubtitleSync.ts
@@ -1,110 +1,85 @@
"use client";
-import { useCallback, useEffect, useRef } from "react";
-import { SubtitleEntry } from "../types/subtitle";
+import { useEffect, useRef } from "react";
+import { useSrtPlayerStore } from "../store";
-export function useSubtitleSync(
- subtitles: SubtitleEntry[],
- currentTime: number,
- isPlaying: boolean,
- autoPause: boolean,
- onSubtitleChange: (subtitle: SubtitleEntry | null) => void,
- onAutoPauseTrigger?: (subtitle: SubtitleEntry) => void
-) {
- const lastSubtitleRef = useRef(null);
+/**
+ * useSubtitleSync - 字幕同步 Hook
+ *
+ * 自动同步视频播放时间与字幕显示,支持自动暂停功能。
+ * 使用 Zustand store 获取状态,无需传入参数。
+ */
+export function useSubtitleSync() {
+ const lastSubtitleRef = useRef(null);
const rafIdRef = useRef(0);
- // 获取当前时间对应的字幕
- const getCurrentSubtitle = useCallback((time: number): SubtitleEntry | null => {
- return subtitles.find(subtitle => time >= subtitle.start && time <= subtitle.end) || null;
- }, [subtitles]);
+ // 从 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);
- // 获取最近的字幕索引
- const getNearestIndex = useCallback((time: number): number | null => {
- if (subtitles.length === 0) return null;
-
- // 如果时间早于第一个字幕开始时间
- if (time < subtitles[0].start) return null;
-
- // 如果时间晚于最后一个字幕结束时间
- if (time > subtitles[subtitles.length - 1].end) return subtitles.length - 1;
-
- // 二分查找找到当前时间对应的字幕
- let left = 0;
- let right = subtitles.length - 1;
-
- while (left <= right) {
- const mid = Math.floor((left + right) / 2);
- const subtitle = subtitles[mid];
-
+ // Store actions
+ const setCurrentSubtitle = useSrtPlayerStore((state) => state.setCurrentSubtitle);
+ const seek = useSrtPlayerStore((state) => state.seek);
+ const pause = useSrtPlayerStore((state) => state.pause);
+
+ // 获取当前时间对应的字幕索引
+ 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 mid;
- } else if (time < subtitle.start) {
- right = mid - 1;
- } else {
- left = mid + 1;
+ return i;
}
}
-
- // 如果没有找到完全匹配的字幕,返回最近的字幕索引
- return right >= 0 ? right : null;
- }, [subtitles]);
+ return null;
+ };
// 检查是否需要自动暂停
- const shouldAutoPause = useCallback((subtitle: SubtitleEntry, time: number): boolean => {
- return autoPause &&
- time >= subtitle.end - 0.2 && // 增加时间窗口,确保自动暂停更可靠
- time < subtitle.end;
- }, [autoPause]);
+ const shouldAutoPause = (subtitle: { start: number; end: number }, time: number): boolean => {
+ return autoPause && time >= subtitle.end - 0.2 && time < subtitle.end;
+ };
- // 启动/停止同步循环
+ // 同步循环
useEffect(() => {
const syncSubtitles = () => {
- const currentSubtitle = getCurrentSubtitle(currentTime);
-
+ const currentIndex = getCurrentSubtitleIndex(currentTime);
+
// 检查字幕是否发生变化
- if (currentSubtitle !== lastSubtitleRef.current) {
- const previousSubtitle = lastSubtitleRef.current;
- lastSubtitleRef.current = currentSubtitle;
-
- // 只有当有当前字幕时才调用onSubtitleChange
- // 在字幕间隙时保持之前的字幕索引,避免进度条跳到0
- if (currentSubtitle) {
- onSubtitleChange(currentSubtitle);
+ if (currentIndex !== lastSubtitleRef.current) {
+ lastSubtitleRef.current = currentIndex;
+
+ if (currentIndex !== null) {
+ const subtitle = subtitleData[currentIndex];
+ setCurrentSubtitle(subtitle.text, currentIndex);
+ } else {
+ setCurrentSubtitle('', null);
}
}
-
+
// 检查是否需要自动暂停
- // 每次都检查,不只在字幕变化时检查
+ const currentSubtitle = currentIndex !== null ? subtitleData[currentIndex] : null;
if (currentSubtitle && shouldAutoPause(currentSubtitle, currentTime)) {
- onAutoPauseTrigger?.(currentSubtitle);
- } else if (!currentSubtitle && lastSubtitleRef.current && shouldAutoPause(lastSubtitleRef.current, currentTime)) {
- // 在字幕结束时,如果前一个字幕需要自动暂停,也要触发
- onAutoPauseTrigger?.(lastSubtitleRef.current);
+ seek(currentSubtitle.start);
+ pause();
}
-
+
rafIdRef.current = requestAnimationFrame(syncSubtitles);
};
- if (subtitles.length > 0) {
+ if (subtitleData.length > 0 && isPlaying) {
rafIdRef.current = requestAnimationFrame(syncSubtitles);
}
-
+
return () => {
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current);
}
};
- }, [subtitles.length, currentTime, getCurrentSubtitle, onSubtitleChange, shouldAutoPause, onAutoPauseTrigger]);
+ }, [subtitleData, currentTime, isPlaying, autoPause, setCurrentSubtitle, seek, pause]);
// 重置最后字幕引用
useEffect(() => {
lastSubtitleRef.current = null;
- }, [subtitles]);
-
- return {
- getCurrentSubtitle,
- getNearestIndex,
- shouldAutoPause,
- };
-}
\ No newline at end of file
+ }, [subtitleData]);
+}
diff --git a/src/app/(features)/srt-player/hooks/useVideoSync.ts b/src/app/(features)/srt-player/hooks/useVideoSync.ts
new file mode 100644
index 0000000..9909e69
--- /dev/null
+++ b/src/app/(features)/srt-player/hooks/useVideoSync.ts
@@ -0,0 +1,44 @@
+"use client";
+
+import { useEffect, type RefObject } from 'react';
+import { useSrtPlayerStore } from '../store';
+
+export function useVideoSync(videoRef: RefObject) {
+ const setCurrentTime = useSrtPlayerStore((state) => state.setCurrentTime);
+ const setDuration = useSrtPlayerStore((state) => state.setDuration);
+ const play = useSrtPlayerStore((state) => state.play);
+ const pause = useSrtPlayerStore((state) => state.pause);
+
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ const handleTimeUpdate = () => {
+ setCurrentTime(video.currentTime);
+ };
+
+ const handleLoadedMetadata = () => {
+ setDuration(video.duration);
+ };
+
+ const handlePlay = () => {
+ play();
+ };
+
+ const handlePause = () => {
+ pause();
+ };
+
+ video.addEventListener('timeupdate', handleTimeUpdate);
+ video.addEventListener('loadedmetadata', handleLoadedMetadata);
+ video.addEventListener('play', handlePlay);
+ video.addEventListener('pause', handlePause);
+
+ return () => {
+ video.removeEventListener('timeupdate', handleTimeUpdate);
+ video.removeEventListener('loadedmetadata', handleLoadedMetadata);
+ video.removeEventListener('play', handlePlay);
+ video.removeEventListener('pause', handlePause);
+ };
+ }, [videoRef, setCurrentTime, setDuration, play, pause]);
+}
diff --git a/src/app/(features)/srt-player/page.tsx b/src/app/(features)/srt-player/page.tsx
index 8184861..213b0b7 100644
--- a/src/app/(features)/srt-player/page.tsx
+++ b/src/app/(features)/srt-player/page.tsx
@@ -1,114 +1,49 @@
"use client";
-import React from "react";
+import { useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
-import { Video, FileText } from "lucide-react";
import { PageLayout } from "@/components/ui/PageLayout";
-import { useSrtPlayer } from "./hooks/useSrtPlayer";
+import { VideoPlayerPanel } from "./components/VideoPlayerPanel";
+import { ControlPanel } from "./components/ControlPanel";
+import { useVideoSync } from "./hooks/useVideoSync";
import { useSubtitleSync } from "./hooks/useSubtitleSync";
-import { useKeyboardShortcuts, createSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
-import { useFileUpload } from "./hooks/useFileUpload";
+import { useSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
import { loadSubtitle } from "./utils/subtitleParser";
-import { VideoPlayer } from "./components/compounds/VideoPlayer";
-import { SubtitleArea } from "./components/compounds/SubtitleArea";
-import { ControlBar } from "./components/compounds/ControlBar";
-import { UploadZone } from "./components/compounds/UploadZone";
-import { SeekBar } from "./components/atoms/SeekBar";
-import { LightButton } from "@/design-system/base/button";
+import { useSrtPlayerStore } from "./store";
export default function SrtPlayerPage() {
const t = useTranslations("home");
const srtT = useTranslations("srt_player");
- const { uploadVideo, uploadSubtitle } = useFileUpload();
- const {
- state,
- actions,
- videoRef,
- videoEventHandlers,
- subtitleActions
- } = useSrtPlayer();
- // 字幕同步
- useSubtitleSync(
- state.subtitle.data,
- state.video.currentTime,
- state.video.isPlaying,
- state.controls.autoPause,
- (subtitle) => {
- if (subtitle) {
- subtitleActions.setCurrentSubtitle(subtitle.text, subtitle.index);
- } else {
- subtitleActions.setCurrentSubtitle("", null);
- }
- },
- (subtitle) => {
- // 自动暂停逻辑
- actions.seek(subtitle.start);
- actions.pause();
- }
- );
+ const videoRef = useRef(null);
- // 键盘快捷键
- const shortcuts = React.useMemo(() =>
- createSrtPlayerShortcuts(
- actions.togglePlayPause,
- actions.nextSubtitle,
- actions.previousSubtitle,
- actions.restartSubtitle,
- actions.toggleAutoPause
- ), [
- actions.togglePlayPause,
- actions.nextSubtitle,
- actions.previousSubtitle,
- actions.restartSubtitle,
- actions.toggleAutoPause
- ]
- );
+ // Store state
+ const subtitleUrl = useSrtPlayerStore((state) => state.subtitle.url);
+ const setSubtitleData = useSrtPlayerStore((state) => state.setSubtitleData);
- useKeyboardShortcuts(shortcuts);
+ // Hooks
+ useVideoSync(videoRef);
+ useSubtitleSync();
+ useSrtPlayerShortcuts();
- // 处理字幕文件加载
- React.useEffect(() => {
- if (state.subtitle.url) {
- loadSubtitle(state.subtitle.url)
- .then(subtitleData => {
- subtitleActions.setSubtitleData(subtitleData);
+ // Load subtitle when URL changes
+ useEffect(() => {
+ if (subtitleUrl) {
+ loadSubtitle(subtitleUrl)
+ .then((subtitleData) => {
+ setSubtitleData(subtitleData);
toast.success(srtT("subtitleLoadSuccess"));
})
- .catch(error => {
+ .catch((error) => {
toast.error(srtT("subtitleLoadFailed") + ": " + error.message);
});
}
- }, [srtT, state.subtitle.url, subtitleActions]);
-
- // 处理进度条变化
- const handleSeek = React.useCallback((index: number) => {
- if (state.subtitle.data[index]) {
- actions.seek(state.subtitle.data[index].start);
- }
- }, [state.subtitle.data, actions]);
-
- // 处理视频上传
- const handleVideoUpload = React.useCallback(() => {
- uploadVideo(actions.setVideoUrl, (error) => {
- toast.error(srtT("videoUploadFailed") + ": " + error.message);
- });
- }, [uploadVideo, actions.setVideoUrl, srtT]);
-
- // 处理字幕上传
- const handleSubtitleUpload = React.useCallback(() => {
- uploadSubtitle(actions.setSubtitleUrl, (error) => {
- toast.error(srtT("subtitleUploadFailed") + ": " + error.message);
- });
- }, [uploadSubtitle, actions.setSubtitleUrl, srtT]);
-
- // 检查是否可以播放
- const canPlay = state.video.url && state.subtitle.url && state.subtitle.data.length > 0;
+ }, [srtT, subtitleUrl, setSubtitleData]);
return (
- {/* 标题区域 */}
+ {/* Title */}
{t("srtPlayer.name")}
@@ -118,157 +53,11 @@ export default function SrtPlayerPage() {
- {/* 视频播放器区域 */}
-
- {(!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) && (
-
-
-
- {!state.video.url && !state.subtitle.url
- ? srtT("uploadVideoAndSubtitle")
- : !state.video.url
- ? srtT("uploadVideoFile")
- : !state.subtitle.url
- ? srtT("uploadSubtitleFile")
- : srtT("processingSubtitle")
- }
-
- {(!state.video.url || !state.subtitle.url) && (
-
- {srtT("needBothFiles")}
-
- )}
-
-
- )}
+ {/* Video Player */}
+
- {state.video.url && (
-
- {state.subtitle.url && state.subtitle.data.length > 0 && (
-
- )}
-
- )}
-
-
- {/* 控制面板 */}
-
- {/* 上传区域和状态指示器 */}
-
-
-
-
-
-
-
-
{srtT("videoFile")}
-
- {state.video.url ? srtT("uploaded") : srtT("notUploaded")}
-
-
-
-
- {state.video.url ? srtT("uploaded") : srtT("upload")}
-
-
-
-
-
-
-
-
-
-
{srtT("subtitleFile")}
-
- {state.subtitle.url ? srtT("uploaded") : srtT("notUploaded")}
-
-
-
-
- {state.subtitle.url ? srtT("uploaded") : srtT("upload")}
-
-
-
-
-
-
- {/* 控制按钮和进度条 */}
-
- {/* 控制按钮 */}
-
-
- {/* 进度条 */}
-
-
-
- {/* 字幕进度显示 */}
-
-
- {state.subtitle.currentIndex !== null ?
- `${state.subtitle.currentIndex + 1}/${state.subtitle.data.length}` :
- '0/0'
- }
-
-
-
- {/* 播放速度显示 */}
-
- {state.video.playbackRate}x
-
-
- {/* 自动暂停状态 */}
-
- {srtT("autoPauseStatus", { enabled: state.controls.autoPause ? srtT("on") : srtT("off") })}
-
-
-
-
-
-
+ {/* Control Panel */}
+
);
-}
\ No newline at end of file
+}
diff --git a/src/app/(features)/srt-player/store.ts b/src/app/(features)/srt-player/store.ts
new file mode 100644
index 0000000..c945255
--- /dev/null
+++ b/src/app/(features)/srt-player/store.ts
@@ -0,0 +1,221 @@
+"use client";
+
+import { create } from 'zustand';
+import { devtools, persist } from 'zustand/middleware';
+import { toast } from 'sonner';
+import type {
+ SrtPlayerStore,
+ VideoState,
+ SubtitleState,
+ ControlState,
+ SubtitleSettings,
+ SubtitleEntry,
+} from './types';
+import type { MutableRefObject } from 'react';
+
+// 声明视频 ref 的全局类型(用于 store 访问 video element)
+let videoRef: MutableRefObject | null = null;
+
+export function setVideoRef(ref: MutableRefObject) {
+ videoRef = ref;
+}
+
+// 初始状态
+const initialVideoState: VideoState = {
+ url: null,
+ isPlaying: false,
+ currentTime: 0,
+ duration: 0,
+ playbackRate: 1.0,
+ volume: 1.0,
+};
+
+const initialSubtitleSettings: SubtitleSettings = {
+ fontSize: 24,
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
+ textColor: '#ffffff',
+ position: 'bottom',
+ fontFamily: 'sans-serif',
+ opacity: 1,
+};
+
+const initialSubtitleState: SubtitleState = {
+ url: null,
+ data: [],
+ currentText: '',
+ currentIndex: null,
+ settings: initialSubtitleSettings,
+};
+
+const initialControlState: ControlState = {
+ autoPause: true,
+ showShortcuts: false,
+ showSettings: false,
+};
+
+export const useSrtPlayerStore = create()(
+ devtools(
+ (set, get) => ({
+ // ==================== Initial State ====================
+ video: initialVideoState,
+ subtitle: initialSubtitleState,
+ controls: initialControlState,
+
+ // ==================== Video Actions ====================
+ setVideoUrl: (url) =>
+ set((state) => {
+ if (videoRef?.current) {
+ videoRef.current.src = url || '';
+ videoRef.current.load();
+ }
+ return { video: { ...state.video, url } };
+ }),
+
+ setPlaying: (playing) =>
+ set((state) => ({ video: { ...state.video, isPlaying: playing } })),
+
+ setCurrentTime: (time) =>
+ set((state) => ({ video: { ...state.video, currentTime: time } })),
+
+ setDuration: (duration) =>
+ set((state) => ({ video: { ...state.video, duration } })),
+
+ setPlaybackRate: (rate) =>
+ set((state) => {
+ if (videoRef?.current) {
+ videoRef.current.playbackRate = rate;
+ }
+ return { video: { ...state.video, playbackRate: rate } };
+ }),
+
+ setVolume: (volume) =>
+ set((state) => {
+ if (videoRef?.current) {
+ videoRef.current.volume = volume;
+ }
+ return { video: { ...state.video, volume } };
+ }),
+
+ play: () => {
+ const state = get();
+ if (!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) {
+ toast.error('请先上传视频和字幕文件');
+ return;
+ }
+ if (videoRef?.current) {
+ videoRef.current.play().catch((error) => {
+ toast.error('视频播放失败: ' + error.message);
+ });
+ set({ video: { ...state.video, isPlaying: true } });
+ }
+ },
+
+ pause: () => {
+ if (videoRef?.current) {
+ videoRef.current.pause();
+ set((state) => ({ video: { ...state.video, isPlaying: false } }));
+ }
+ },
+
+ togglePlayPause: () => {
+ const state = get();
+ if (state.video.isPlaying) {
+ get().pause();
+ } else {
+ get().play();
+ }
+ },
+
+ seek: (time) => {
+ if (videoRef?.current) {
+ videoRef.current.currentTime = time;
+ set((state) => ({ video: { ...state.video, currentTime: time } }));
+ }
+ },
+
+ restart: () => {
+ const state = get();
+ if (state.subtitle.currentIndex !== null) {
+ const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
+ if (currentSubtitle) {
+ get().seek(currentSubtitle.start);
+ get().play();
+ }
+ }
+ },
+
+ // ==================== Subtitle Actions ====================
+ setSubtitleUrl: (url) =>
+ set((state) => ({ subtitle: { ...state.subtitle, url } })),
+
+ setSubtitleData: (data) =>
+ set((state) => ({ subtitle: { ...state.subtitle, data } })),
+
+ setCurrentSubtitle: (text, index) =>
+ set((state) => ({
+ subtitle: {
+ ...state.subtitle,
+ currentText: text,
+ currentIndex: index,
+ },
+ })),
+
+ updateSettings: (settings) =>
+ set((state) => ({
+ subtitle: {
+ ...state.subtitle,
+ settings: { ...state.subtitle.settings, ...settings },
+ },
+ })),
+
+ nextSubtitle: () => {
+ const state = get();
+ if (
+ state.subtitle.currentIndex !== null &&
+ state.subtitle.currentIndex + 1 < state.subtitle.data.length
+ ) {
+ const nextIndex = state.subtitle.currentIndex + 1;
+ const nextSubtitle = state.subtitle.data[nextIndex];
+ get().seek(nextSubtitle.start);
+ get().play();
+ }
+ },
+
+ previousSubtitle: () => {
+ const state = get();
+ if (state.subtitle.currentIndex !== null && state.subtitle.currentIndex > 0) {
+ const prevIndex = state.subtitle.currentIndex - 1;
+ const prevSubtitle = state.subtitle.data[prevIndex];
+ get().seek(prevSubtitle.start);
+ get().play();
+ }
+ },
+
+ restartSubtitle: () => {
+ const state = get();
+ if (state.subtitle.currentIndex !== null) {
+ const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
+ get().seek(currentSubtitle.start);
+ get().play();
+ }
+ },
+
+ // ==================== Controls Actions ====================
+ toggleAutoPause: () =>
+ set((state) => ({
+ controls: { ...state.controls, autoPause: !state.controls.autoPause },
+ })),
+
+ toggleShortcuts: () =>
+ set((state) => ({
+ controls: { ...state.controls, showShortcuts: !state.controls.showShortcuts },
+ })),
+
+ toggleSettings: () =>
+ set((state) => ({
+ controls: { ...state.controls, showSettings: !state.controls.showSettings },
+ })),
+ }),
+ { name: 'srt-player-store' }
+ )
+);
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/types/controls.ts b/src/app/(features)/srt-player/types/controls.ts
deleted file mode 100644
index c6b7ba5..0000000
--- a/src/app/(features)/srt-player/types/controls.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-export interface ControlState {
- autoPause: boolean;
- showShortcuts: boolean;
- showSettings: boolean;
-}
-
-export interface ControlActions {
- toggleAutoPause: () => void;
- toggleShortcuts: () => void;
- toggleSettings: () => void;
-}
-
-export interface ControlBarProps {
- isPlaying: boolean;
- onPlayPause: () => void;
- onPrevious: () => void;
- onNext: () => void;
- onRestart: () => void;
- playbackRate: number;
- onPlaybackRateChange: (rate: number) => void;
- autoPause: boolean;
- onAutoPauseToggle: () => void;
- disabled?: boolean;
- className?: string;
-}
-
-export interface NavigationButtonProps {
- onClick: () => void;
- disabled?: boolean;
- className?: string;
- children: React.ReactNode;
-}
-
-export interface AutoPauseToggleProps {
- enabled: boolean;
- onToggle: () => void;
- disabled?: boolean;
- className?: string;
-}
-
-export interface KeyboardShortcut {
- key: string;
- description: string;
- action: () => void;
-}
-
-export interface ShortcutHintProps {
- shortcuts: KeyboardShortcut[];
- visible: boolean;
- onClose: () => void;
- className?: string;
-}
-
-export interface FileUploadProps {
- onVideoUpload: (url: string) => void;
- onSubtitleUpload: (url: string) => void;
- className?: string;
-}
-
-export interface FileInputProps {
- accept: string;
- onFileSelect: (file: File) => void;
- disabled?: boolean;
- className?: string;
-}
\ No newline at end of file
diff --git a/src/app/(features)/srt-player/types/player.ts b/src/app/(features)/srt-player/types/player.ts
deleted file mode 100644
index b21331b..0000000
--- a/src/app/(features)/srt-player/types/player.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-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;
-}
-
-export interface VideoElementProps {
- src?: string;
- onTimeUpdate?: (time: number) => void;
- onLoadedMetadata?: (duration: number) => void;
- onPlay?: () => void;
- onPause?: () => void;
- onEnded?: () => void;
- className?: string;
-}
-
-export interface PlayButtonProps {
- isPlaying: boolean;
- onToggle: () => void;
- disabled?: boolean;
- className?: string;
-}
-
-export interface SeekBarProps {
- value: number;
- max: number;
- onChange: (value: number) => void;
- disabled?: boolean;
- className?: string;
-}
-
-export interface SpeedControlProps {
- playbackRate: number;
- onPlaybackRateChange: (rate: number) => void;
- disabled?: boolean;
- className?: string;
-}
-
-export interface VolumeControlProps {
- volume: number;
- onVolumeChange: (volume: number) => void;
- disabled?: boolean;
- className?: string;
-}
\ No newline at end of file
diff --git a/src/app/(features)/srt-player/types/subtitle.ts b/src/app/(features)/srt-player/types/subtitle.ts
deleted file mode 100644
index e725f93..0000000
--- a/src/app/(features)/srt-player/types/subtitle.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-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 SubtitleDisplayProps {
- subtitle: string;
- onWordClick?: (word: string) => void;
- settings?: SubtitleSettings;
- className?: string;
-}
-
-export interface SubtitleTextProps {
- text: string;
- onWordClick?: (word: string) => void;
- style?: React.CSSProperties;
- className?: string;
-}
-
-export interface SubtitleSettingsProps {
- settings: SubtitleSettings;
- onSettingsChange: (settings: SubtitleSettings) => void;
- className?: string;
-}
-
-export interface SubtitleControls {
- next: () => void;
- previous: () => void;
- goToIndex: (index: number) => void;
- toggleAutoPause: () => void;
-}
-
-export interface SubtitleSyncProps {
- subtitles: SubtitleEntry[];
- currentTime: number;
- isPlaying: boolean;
- autoPause: boolean;
- onSubtitleChange: (subtitle: SubtitleEntry | null) => void;
- onAutoPauseTrigger?: (subtitle: SubtitleEntry) => void;
-}
\ No newline at end of file
diff --git a/src/app/(features)/srt-player/utils/subtitleParser.ts b/src/app/(features)/srt-player/utils/subtitleParser.ts
index 1144554..33be08b 100644
--- a/src/app/(features)/srt-player/utils/subtitleParser.ts
+++ b/src/app/(features)/srt-player/utils/subtitleParser.ts
@@ -1,4 +1,4 @@
-import { SubtitleEntry } from "../types/subtitle";
+import { SubtitleEntry } from "../types";
export function parseSrt(data: string): SubtitleEntry[] {
const lines = data.split(/\r?\n/);
diff --git a/src/app/(features)/srt-player/utils/timeUtils.ts b/src/app/(features)/srt-player/utils/timeUtils.ts
deleted file mode 100644
index c56317a..0000000
--- a/src/app/(features)/srt-player/utils/timeUtils.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-export function formatTime(seconds: number): string {
- const hours = Math.floor(seconds / 3600);
- const minutes = Math.floor((seconds % 3600) / 60);
- const secs = Math.floor(seconds % 60);
-
- if (hours > 0) {
- return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
- }
-
- return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
-}
-
-export function timeToSeconds(timeStr: string): number {
- const parts = timeStr.split(':');
-
- if (parts.length === 3) {
- // HH:MM:SS format
- const [h, m, s] = parts;
- return parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s);
- } else if (parts.length === 2) {
- // MM:SS format
- const [m, s] = parts;
- return parseInt(m) * 60 + parseFloat(s);
- }
-
- return 0;
-}
-
-export function secondsToTime(seconds: number): string {
- const hours = Math.floor(seconds / 3600);
- const minutes = Math.floor((seconds % 3600) / 60);
- const secs = Math.floor(seconds % 60);
- const ms = Math.floor((seconds % 1) * 1000);
-
- return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')},${ms.toString().padStart(3, '0')}`;
-}
-
-export function clampTime(time: number, min: number = 0, max: number = Infinity): number {
- return Math.min(Math.max(time, min), max);
-}
-
-export function getPlaybackRateOptions(): number[] {
- return [0.5, 0.7, 1.0, 1.2, 1.5, 2.0];
-}
-
-export function getPlaybackRateLabel(rate: number): string {
- return `${rate}x`;
-}
\ No newline at end of file
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index d361b50..3085fff 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -4,6 +4,7 @@ import type { Viewport } from "next";
import { NextIntlClientProvider } from "next-intl";
import { Navbar } from "@/components/layout/Navbar";
import { Toaster } from "sonner";
+import { StrictMode } from "react";
export const viewport: Viewport = {
width: "device-width",
@@ -23,11 +24,13 @@ export default async function RootLayout({
return (
-
-
- {children}
-
-
+
+
+
+ {children}
+
+
+
);