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 */} +
+ + + + + {videoUrl ? t('uploaded') : t('upload')} + + +
+ + {/* Subtitle Upload Card */} +
+ + + + +

{t('subtitleFile')}

+

{subtitleUrl ? t('uploaded') : t('notUploaded')}

+
+
+ + {subtitleUrl ? t('uploaded') : t('upload')} + +
+
+
+ + {/* Controls Area */} + + {/* Playback Controls */} + + + + + + + + + + + + + + + {/* 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 && ( +
+ ); +}); + +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 ( -