diff --git a/messages/en-US.json b/messages/en-US.json index 56c1a4c..f480218 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -150,7 +150,43 @@ "previous": "Previous", "next": "Next", "restart": "Restart", - "autoPause": "Auto Pause ({enabled})" + "autoPause": "Auto Pause ({enabled})", + "playbackSpeed": "Playback Speed", + "subtitleSettings": "Subtitle Settings", + "fontSize": "Font Size", + "backgroundColor": "Background Color", + "textColor": "Text Color", + "fontFamily": "Font Family", + "opacity": "Opacity", + "position": "Position", + "top": "Top", + "center": "Center", + "bottom": "Bottom", + "keyboardShortcuts": "Keyboard Shortcuts", + "uploadVideoAndSubtitle": "Please upload video and subtitle files", + "uploadVideoFile": "Please upload video file", + "uploadSubtitleFile": "Please upload subtitle file", + "processingSubtitle": "Processing subtitle file...", + "needBothFiles": "Both video and subtitle files are required to start learning", + "videoFile": "Video File", + "subtitleFile": "Subtitle File", + "uploaded": "Uploaded", + "notUploaded": "Not Uploaded", + "upload": "Upload", + "autoPauseStatus": "Auto Pause: {enabled}", + "on": "On", + "off": "Off", + "videoUploadFailed": "Video upload failed", + "subtitleUploadFailed": "Subtitle upload failed", + "subtitleLoadSuccess": "Subtitle file loaded successfully", + "subtitleLoadFailed": "Subtitle file loading failed", + "shortcuts": { + "playPause": "Play/Pause", + "next": "Next", + "previous": "Previous", + "restart": "Restart", + "autoPause": "Toggle Auto Pause" + } }, "text_speaker": { "generateIPA": "Generate IPA", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 85e67bd..87d640e 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -147,6 +147,7 @@ "logout": "退出登录" }, "srt_player": { + "upload": "上传", "uploadVideo": "上传视频", "uploadSubtitle": "上传字幕", "pause": "暂停", @@ -154,7 +155,42 @@ "previous": "上句", "next": "下句", "restart": "句首", - "autoPause": "自动暂停({enabled})" + "autoPause": "自动暂停({enabled})", + "playbackSpeed": "播放速度", + "subtitleSettings": "字幕设置", + "fontSize": "字体大小", + "backgroundColor": "背景颜色", + "textColor": "文字颜色", + "fontFamily": "字体", + "opacity": "透明度", + "position": "位置", + "top": "顶部", + "center": "居中", + "bottom": "底部", + "keyboardShortcuts": "键盘快捷键", + "uploadVideoAndSubtitle": "请上传视频和字幕文件", + "uploadVideoFile": "请上传视频文件", + "uploadSubtitleFile": "请上传字幕文件", + "processingSubtitle": "字幕文件正在处理中...", + "needBothFiles": "需要同时上传视频和字幕文件才能开始学习", + "videoFile": "视频文件", + "subtitleFile": "字幕文件", + "uploaded": "已上传", + "notUploaded": "未上传", + "autoPauseStatus": "自动暂停: {enabled}", + "on": "开", + "off": "关", + "videoUploadFailed": "视频上传失败", + "subtitleUploadFailed": "字幕上传失败", + "subtitleLoadSuccess": "字幕文件加载成功", + "subtitleLoadFailed": "字幕文件加载失败", + "shortcuts": { + "playPause": "播放/暂停", + "next": "下一句", + "previous": "上一句", + "restart": "句首", + "autoPause": "切换自动暂停" + } }, "text_speaker": { "generateIPA": "生成IPA", diff --git a/src/app/(features)/srt-player/components/atoms/FileInput.tsx b/src/app/(features)/srt-player/components/atoms/FileInput.tsx new file mode 100644 index 0000000..10935b5 --- /dev/null +++ b/src/app/(features)/srt-player/components/atoms/FileInput.tsx @@ -0,0 +1,45 @@ +"use client"; + +import React, { useRef } from "react"; +import { FileInputProps } from "../../types/controls"; + +interface FileInputComponentProps extends FileInputProps { + children: React.ReactNode; +} + +export default 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 ( + <> + + + + ); +} \ 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 new file mode 100644 index 0000000..d3d7332 --- /dev/null +++ b/src/app/(features)/srt-player/components/atoms/PlayButton.tsx @@ -0,0 +1,20 @@ +"use client"; + +import React from "react"; +import { useTranslations } from "next-intl"; +import LightButton from "@/components/ui/buttons/LightButton"; +import { PlayButtonProps } from "../../types/player"; + +export default 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 new file mode 100644 index 0000000..68eff7d --- /dev/null +++ b/src/app/(features)/srt-player/components/atoms/SeekBar.tsx @@ -0,0 +1,26 @@ +"use client"; + +import React from "react"; +import { SeekBarProps } from "../../types/player"; + +export default function SeekBar({ value, max, onChange, disabled, className }: SeekBarProps) { + const handleChange = React.useCallback((event: React.ChangeEvent) => { + const newValue = parseInt(event.target.value); + onChange(newValue); + }, [onChange]); + + 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 new file mode 100644 index 0000000..0ef233e --- /dev/null +++ b/src/app/(features)/srt-player/components/atoms/SpeedControl.tsx @@ -0,0 +1,25 @@ +"use client"; + +import React from "react"; +import LightButton from "@/components/ui/buttons/LightButton"; +import { SpeedControlProps } from "../../types/player"; +import { getPlaybackRateOptions, getPlaybackRateLabel } from "../../utils/timeUtils"; + +export default 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 new file mode 100644 index 0000000..ce8065c --- /dev/null +++ b/src/app/(features)/srt-player/components/atoms/SubtitleText.tsx @@ -0,0 +1,44 @@ +"use client"; + +import React from "react"; +import { SubtitleTextProps } from "../../types/subtitle"; + +export default 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 new file mode 100644 index 0000000..4dcbe0c --- /dev/null +++ b/src/app/(features)/srt-player/components/atoms/VideoElement.tsx @@ -0,0 +1,49 @@ +"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 ( +