srt
This commit is contained in:
@@ -22,3 +22,4 @@ pnpm run build
|
||||
- Better-auth 处理会话管理 - 使用 authClient 适配器进行认证操作
|
||||
- 使用 better-auth username 插件支持用户名登录
|
||||
- 组件尽量复用/src/design-system里的可复用组件与/src/components里的业务相关组件
|
||||
- 不要创建index.ts
|
||||
|
||||
218
src/app/(features)/srt-player/components/ControlPanel.tsx
Normal file
218
src/app/(features)/srt-player/components/ControlPanel.tsx
Normal file
@@ -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 (
|
||||
<div className="p-3 bg-gray-50 border-t rounded-b-xl">
|
||||
<VStack gap={3}>
|
||||
{/* Upload Status Cards */}
|
||||
<HStack gap={3}>
|
||||
{/* Video Upload Card */}
|
||||
<div
|
||||
className={`flex-1 p-2 rounded-lg border-2 transition-all ${
|
||||
videoUrl ? 'border-gray-800 bg-gray-100' : 'border-gray-300 bg-white'
|
||||
}`}
|
||||
>
|
||||
<HStack gap={2} justify="between">
|
||||
<HStack gap={2}>
|
||||
<Video className="w-5 h-5 text-gray-600" />
|
||||
<VStack gap={0}>
|
||||
<h3 className="font-semibold text-gray-800 text-sm">{t('videoFile')}</h3>
|
||||
<p className="text-xs text-gray-600">{videoUrl ? t('uploaded') : t('notUploaded')}</p>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<LightButton
|
||||
onClick={videoUrl ? undefined : handleVideoUpload}
|
||||
disabled={!!videoUrl}
|
||||
size="sm"
|
||||
>
|
||||
{videoUrl ? t('uploaded') : t('upload')}
|
||||
</LightButton>
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
{/* Subtitle Upload Card */}
|
||||
<div
|
||||
className={`flex-1 p-2 rounded-lg border-2 transition-all ${
|
||||
subtitleUrl ? 'border-gray-800 bg-gray-100' : 'border-gray-300 bg-white'
|
||||
}`}
|
||||
>
|
||||
<HStack gap={2} justify="between">
|
||||
<HStack gap={2}>
|
||||
<FileText className="w-5 h-5 text-gray-600" />
|
||||
<VStack gap={0}>
|
||||
<h3 className="font-semibold text-gray-800 text-sm">{t('subtitleFile')}</h3>
|
||||
<p className="text-xs text-gray-600">{subtitleUrl ? t('uploaded') : t('notUploaded')}</p>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<LightButton
|
||||
onClick={subtitleUrl ? undefined : handleSubtitleUpload}
|
||||
disabled={!!subtitleUrl}
|
||||
size="sm"
|
||||
>
|
||||
{subtitleUrl ? t('uploaded') : t('upload')}
|
||||
</LightButton>
|
||||
</HStack>
|
||||
</div>
|
||||
</HStack>
|
||||
|
||||
{/* Controls Area */}
|
||||
<VStack
|
||||
gap={4}
|
||||
className={!canPlay ? 'opacity-50 pointer-events-none' : ''}
|
||||
>
|
||||
{/* Playback Controls */}
|
||||
<HStack gap={2} justify="center" wrap>
|
||||
<Button
|
||||
onClick={togglePlayPause}
|
||||
disabled={!canPlay}
|
||||
leftIcon={isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
>
|
||||
{isPlaying ? t('pause') : t('play')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={previousSubtitle}
|
||||
disabled={!canPlay}
|
||||
leftIcon={<ChevronLeft className="w-4 h-4" />}
|
||||
>
|
||||
{t('previous')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={nextSubtitle}
|
||||
disabled={!canPlay}
|
||||
rightIcon={<ChevronRight className="w-4 h-4" />}
|
||||
>
|
||||
{t('next')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={restartSubtitle}
|
||||
disabled={!canPlay}
|
||||
leftIcon={<RotateCcw className="w-4 h-4" />}
|
||||
>
|
||||
{t('restart')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handlePlaybackRateChange}
|
||||
disabled={!canPlay}
|
||||
>
|
||||
{playbackRate}x
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={toggleAutoPause}
|
||||
disabled={!canPlay}
|
||||
leftIcon={<Pause className="w-4 h-4" />}
|
||||
variant={autoPause ? 'primary' : 'secondary'}
|
||||
>
|
||||
{t('autoPause', { enabled: autoPause ? t('on') : t('off') })}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{/* Seek Bar */}
|
||||
<VStack gap={2}>
|
||||
<Range
|
||||
value={currentProgress}
|
||||
min={0}
|
||||
max={totalProgress}
|
||||
onChange={handleSeek}
|
||||
disabled={!canPlay}
|
||||
/>
|
||||
|
||||
{/* Progress Stats */}
|
||||
<HStack gap={4} justify="between" className="text-sm text-gray-600 px-2">
|
||||
<span>
|
||||
{currentIndex !== null ? `${currentIndex + 1}/${subtitleData.length}` : '0/0'}
|
||||
</span>
|
||||
|
||||
<HStack gap={4}>
|
||||
{/* Playback Rate Badge */}
|
||||
<span className="bg-gray-200 px-2 py-1 rounded text-xs">
|
||||
{playbackRate}x
|
||||
</span>
|
||||
|
||||
{/* Auto Pause Badge */}
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs ${
|
||||
autoPause ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{t('autoPauseStatus', { enabled: autoPause ? t('on') : t('off') })}
|
||||
</span>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, forwardRef } from 'react';
|
||||
import { useSrtPlayerStore } from '../store';
|
||||
import { setVideoRef } from '../store';
|
||||
|
||||
export const VideoPlayerPanel = forwardRef<HTMLVideoElement>((_, ref) => {
|
||||
const localVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const videoRef = (ref as React.RefObject<HTMLVideoElement>) || 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 (
|
||||
<div className="aspect-video bg-black relative rounded-md overflow-hidden">
|
||||
{/* 空状态提示 */}
|
||||
{(!videoUrl || !subtitleUrl || subtitleData.length === 0) && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-75 z-10">
|
||||
<div className="text-center text-white">
|
||||
<p className="text-lg mb-2">
|
||||
{!videoUrl && !subtitleUrl
|
||||
? '请上传视频和字幕文件'
|
||||
: !videoUrl
|
||||
? '请上传视频文件'
|
||||
: !subtitleUrl
|
||||
? '请上传字幕文件'
|
||||
: '正在处理字幕...'}
|
||||
</p>
|
||||
{(!videoUrl || !subtitleUrl) && (
|
||||
<p className="text-sm text-gray-300">需要同时上传视频和字幕文件才能播放</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频元素 */}
|
||||
{videoUrl && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
className="w-full h-full"
|
||||
playsInline
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 字幕显示覆盖层 */}
|
||||
{subtitleUrl && subtitleData.length > 0 && currentText && (
|
||||
<div
|
||||
className="absolute px-4 py-2 text-center w-full"
|
||||
style={{
|
||||
bottom: settings.position === 'top' ? 'auto' : settings.position === 'center' ? '50%' : '0',
|
||||
top: settings.position === 'top' ? '0' : 'auto',
|
||||
transform: settings.position === 'center' ? 'translateY(-50%)' : 'none',
|
||||
backgroundColor: settings.backgroundColor,
|
||||
color: settings.textColor,
|
||||
fontSize: `${settings.fontSize}px`,
|
||||
fontFamily: settings.fontFamily,
|
||||
opacity: settings.opacity,
|
||||
}}
|
||||
>
|
||||
{currentText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
VideoPlayerPanel.displayName = 'VideoPlayerPanel';
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
if (!disabled && inputRef.current) {
|
||||
inputRef.current.click();
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
onFileSelect(file);
|
||||
}
|
||||
}, [onFileSelect]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
/>
|
||||
<LightButton
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</LightButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<LightButton
|
||||
onClick={disabled ? undefined : onToggle}
|
||||
disabled={disabled}
|
||||
className={`px-4 py-2 ${className || ''}`}
|
||||
>
|
||||
{isPlaying ? t("pause") : t("play")}
|
||||
</LightButton>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<RangeInput
|
||||
value={value}
|
||||
max={max}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<LightButton
|
||||
onClick={disabled ? undefined : handleSpeedChange}
|
||||
className={`${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
|
||||
>
|
||||
{getPlaybackRateLabel(playbackRate)}
|
||||
</LightButton>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<span
|
||||
key={index}
|
||||
onClick={() => handleWordClick(part)}
|
||||
className="hover:bg-gray-700 hover:underline hover:cursor-pointer rounded px-1 transition-colors"
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// 如果是空格或其他字符,直接渲染
|
||||
return <span key={index}>{part}</span>;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`overflow-auto h-16 mt-2 wrap-break-words font-sans text-white text-center text-2xl ${className || ''}`}
|
||||
style={style}
|
||||
>
|
||||
{renderTextWithClickableWords()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { forwardRef } from "react";
|
||||
import { VideoElementProps } from "../../types/player";
|
||||
|
||||
const VideoElement = forwardRef<HTMLVideoElement, VideoElementProps>(
|
||||
({ src, onTimeUpdate, onLoadedMetadata, onPlay, onPause, onEnded, className }, ref) => {
|
||||
const handleTimeUpdate = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||
const video = event.currentTarget;
|
||||
onTimeUpdate?.(video.currentTime);
|
||||
}, [onTimeUpdate]);
|
||||
|
||||
const handleLoadedMetadata = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||
const video = event.currentTarget;
|
||||
onLoadedMetadata?.(video.duration);
|
||||
}, [onLoadedMetadata]);
|
||||
|
||||
const handlePlay = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||
onPlay?.();
|
||||
}, [onPlay]);
|
||||
|
||||
const handlePause = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||
onPause?.();
|
||||
}, [onPause]);
|
||||
|
||||
const handleEnded = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||
onEnded?.();
|
||||
}, [onEnded]);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={ref}
|
||||
src={src}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onEnded={handleEnded}
|
||||
className={`bg-gray-200 w-full ${className || ""}`}
|
||||
playsInline
|
||||
controls={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
VideoElement.displayName = "VideoElement";
|
||||
|
||||
export { VideoElement };
|
||||
@@ -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 (
|
||||
<div className={`flex flex-wrap gap-2 justify-center ${className || ''}`}>
|
||||
<PlayButton
|
||||
isPlaying={isPlaying}
|
||||
onToggle={onPlayPause}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<LightButton
|
||||
onClick={disabled ? undefined : onPrevious}
|
||||
disabled={disabled}
|
||||
className="flex items-center px-3 py-2"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
{t("previous")}
|
||||
</LightButton>
|
||||
|
||||
<LightButton
|
||||
onClick={disabled ? undefined : onNext}
|
||||
disabled={disabled}
|
||||
className="flex items-center px-3 py-2"
|
||||
>
|
||||
{t("next")}
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</LightButton>
|
||||
|
||||
<LightButton
|
||||
onClick={disabled ? undefined : onRestart}
|
||||
disabled={disabled}
|
||||
className="flex items-center px-3 py-2"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
{t("restart")}
|
||||
</LightButton>
|
||||
|
||||
<SpeedControl
|
||||
playbackRate={playbackRate}
|
||||
onPlaybackRateChange={onPlaybackRateChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<LightButton
|
||||
onClick={disabled ? undefined : onAutoPauseToggle}
|
||||
disabled={disabled}
|
||||
className="flex items-center px-3 py-2"
|
||||
>
|
||||
<Pause className="w-4 h-4 mr-2" />
|
||||
{t("autoPause", { enabled: autoPause ? t("on") : t("off") })}
|
||||
</LightButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<SubtitleText
|
||||
text={subtitle}
|
||||
onWordClick={handleWordClick}
|
||||
style={subtitleStyle}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={`flex gap-3 ${className || ''}`}>
|
||||
<LightButton
|
||||
onClick={handleVideoUpload}
|
||||
className="flex-1 py-2 px-3 text-sm"
|
||||
>
|
||||
<Video className="w-4 h-4 mr-2" />
|
||||
{t("uploadVideo")}
|
||||
</LightButton>
|
||||
|
||||
<LightButton
|
||||
onClick={handleSubtitleUpload}
|
||||
className="flex-1 py-2 px-3 text-sm"
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
{t("uploadSubtitle")}
|
||||
</LightButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLVideoElement, VideoPlayerComponentProps>(
|
||||
({
|
||||
src,
|
||||
onTimeUpdate,
|
||||
onLoadedMetadata,
|
||||
onPlay,
|
||||
onPause,
|
||||
onEnded,
|
||||
className,
|
||||
children
|
||||
}, ref) => {
|
||||
return (
|
||||
<div className={`w-full flex flex-col ${className || ''}`}>
|
||||
<VideoElement
|
||||
ref={ref}
|
||||
src={src}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onLoadedMetadata={onLoadedMetadata}
|
||||
onPlay={onPlay}
|
||||
onPause={onPause}
|
||||
onEnded={onEnded}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
VideoPlayer.displayName = "VideoPlayer";
|
||||
|
||||
export { VideoPlayer };
|
||||
@@ -1,13 +1,23 @@
|
||||
"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) => {
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// 防止在输入框中触发快捷键
|
||||
@@ -16,53 +26,65 @@ export function useKeyboardShortcuts(
|
||||
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);
|
||||
};
|
||||
}, [enabled, togglePlayPause, nextSubtitle, previousSubtitle, restartSubtitle, toggleAutoPause]);
|
||||
}
|
||||
|
||||
// 保留通用快捷键 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();
|
||||
}
|
||||
}, [shortcuts, enabled]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
}, [shortcuts, isEnabled]);
|
||||
}
|
||||
@@ -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<SubtitleState['settings']>) => 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<SubtitleState['settings']> }
|
||||
| { 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<HTMLVideoElement>(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<SubtitleState['settings']>) => {
|
||||
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<typeof useSrtPlayer>;
|
||||
@@ -1,92 +1,73 @@
|
||||
"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<SubtitleEntry | null>(null);
|
||||
/**
|
||||
* useSubtitleSync - 字幕同步 Hook
|
||||
*
|
||||
* 自动同步视频播放时间与字幕显示,支持自动暂停功能。
|
||||
* 使用 Zustand store 获取状态,无需传入参数。
|
||||
*/
|
||||
export function useSubtitleSync() {
|
||||
const lastSubtitleRef = useRef<number | null>(null);
|
||||
const rafIdRef = useRef<number>(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;
|
||||
if (currentIndex !== lastSubtitleRef.current) {
|
||||
lastSubtitleRef.current = currentIndex;
|
||||
|
||||
// 只有当有当前字幕时才调用onSubtitleChange
|
||||
// 在字幕间隙时保持之前的字幕索引,避免进度条跳到0
|
||||
if (currentSubtitle) {
|
||||
onSubtitleChange(currentSubtitle);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -95,16 +76,10 @@ export function useSubtitleSync(
|
||||
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,
|
||||
};
|
||||
}, [subtitleData]);
|
||||
}
|
||||
44
src/app/(features)/srt-player/hooks/useVideoSync.ts
Normal file
44
src/app/(features)/srt-player/hooks/useVideoSync.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type RefObject } from 'react';
|
||||
import { useSrtPlayerStore } from '../store';
|
||||
|
||||
export function useVideoSync(videoRef: RefObject<HTMLVideoElement | null>) {
|
||||
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]);
|
||||
}
|
||||
@@ -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<HTMLVideoElement>(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 (
|
||||
<PageLayout>
|
||||
{/* 标题区域 */}
|
||||
{/* Title */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-800 mb-2">
|
||||
{t("srtPlayer.name")}
|
||||
@@ -118,157 +53,11 @@ export default function SrtPlayerPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 视频播放器区域 */}
|
||||
<div className="aspect-video bg-black relative rounded-md overflow-hidden">
|
||||
{(!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-75 z-10">
|
||||
<div className="text-center text-white">
|
||||
<p className="text-lg mb-2">
|
||||
{!state.video.url && !state.subtitle.url
|
||||
? srtT("uploadVideoAndSubtitle")
|
||||
: !state.video.url
|
||||
? srtT("uploadVideoFile")
|
||||
: !state.subtitle.url
|
||||
? srtT("uploadSubtitleFile")
|
||||
: srtT("processingSubtitle")
|
||||
}
|
||||
</p>
|
||||
{(!state.video.url || !state.subtitle.url) && (
|
||||
<p className="text-sm text-gray-300">
|
||||
{srtT("needBothFiles")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Video Player */}
|
||||
<VideoPlayerPanel ref={videoRef} />
|
||||
|
||||
{state.video.url && (
|
||||
<VideoPlayer
|
||||
ref={videoRef}
|
||||
src={state.video.url}
|
||||
{...videoEventHandlers}
|
||||
className="w-full h-full"
|
||||
>
|
||||
{state.subtitle.url && state.subtitle.data.length > 0 && (
|
||||
<SubtitleArea
|
||||
subtitle={state.subtitle.currentText}
|
||||
settings={state.subtitle.settings}
|
||||
className="absolute bottom-0 left-0 right-0 px-4 py-2"
|
||||
/>
|
||||
)}
|
||||
</VideoPlayer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 控制面板 */}
|
||||
<div className="p-3 bg-gray-50 border-t rounded-b-xl">
|
||||
{/* 上传区域和状态指示器 */}
|
||||
<div className="mb-3">
|
||||
<div className="flex gap-3">
|
||||
<div className={`flex-1 p-2 rounded-lg border-2 transition-all ${state.video.url
|
||||
? 'border-gray-800 bg-gray-100'
|
||||
: 'border-gray-300 bg-white'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Video className="w-5 h-5 text-gray-600" />
|
||||
<div className="text-left">
|
||||
<h3 className="font-semibold text-gray-800 text-sm">{srtT("videoFile")}</h3>
|
||||
<p className="text-xs text-gray-600">
|
||||
{state.video.url ? srtT("uploaded") : srtT("notUploaded")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<LightButton
|
||||
onClick={state.video.url ? undefined : handleVideoUpload}
|
||||
disabled={!!state.video.url}
|
||||
className="px-2 py-1 text-xs"
|
||||
>
|
||||
{state.video.url ? srtT("uploaded") : srtT("upload")}
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex-1 p-2 rounded-lg border-2 transition-all ${state.subtitle.url
|
||||
? 'border-gray-800 bg-gray-100'
|
||||
: 'border-gray-300 bg-white'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-gray-600" />
|
||||
<div className="text-left">
|
||||
<h3 className="font-semibold text-gray-800 text-sm">{srtT("subtitleFile")}</h3>
|
||||
<p className="text-xs text-gray-600">
|
||||
{state.subtitle.url ? srtT("uploaded") : srtT("notUploaded")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<LightButton
|
||||
onClick={state.subtitle.url ? undefined : handleSubtitleUpload}
|
||||
disabled={!!state.subtitle.url}
|
||||
className="px-2 py-1 text-xs"
|
||||
>
|
||||
{state.subtitle.url ? srtT("uploaded") : srtT("upload")}
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 控制按钮和进度条 */}
|
||||
<div className={`space-y-4 ${canPlay ? '' : 'opacity-50 pointer-events-none'}`}>
|
||||
{/* 控制按钮 */}
|
||||
<ControlBar
|
||||
isPlaying={state.video.isPlaying}
|
||||
onPlayPause={actions.togglePlayPause}
|
||||
onPrevious={actions.previousSubtitle}
|
||||
onNext={actions.nextSubtitle}
|
||||
onRestart={actions.restartSubtitle}
|
||||
playbackRate={state.video.playbackRate}
|
||||
onPlaybackRateChange={actions.setPlaybackRate}
|
||||
autoPause={state.controls.autoPause}
|
||||
onAutoPauseToggle={actions.toggleAutoPause}
|
||||
disabled={!canPlay}
|
||||
className="justify-center"
|
||||
/>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="space-y-2">
|
||||
<SeekBar
|
||||
value={state.subtitle.currentIndex ?? 0}
|
||||
max={Math.max(0, state.subtitle.data.length - 1)}
|
||||
onChange={handleSeek}
|
||||
disabled={!canPlay}
|
||||
className="h-3"
|
||||
/>
|
||||
|
||||
{/* 字幕进度显示 */}
|
||||
<div className="flex justify-between items-center text-sm text-gray-600 px-2">
|
||||
<span>
|
||||
{state.subtitle.currentIndex !== null ?
|
||||
`${state.subtitle.currentIndex + 1}/${state.subtitle.data.length}` :
|
||||
'0/0'
|
||||
}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 播放速度显示 */}
|
||||
<span className="bg-gray-200 px-2 py-1 rounded text-xs">
|
||||
{state.video.playbackRate}x
|
||||
</span>
|
||||
|
||||
{/* 自动暂停状态 */}
|
||||
<span className={`px-2 py-1 rounded text-xs ${state.controls.autoPause
|
||||
? 'bg-gray-800 text-white'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{srtT("autoPauseStatus", { enabled: state.controls.autoPause ? srtT("on") : srtT("off") })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Control Panel */}
|
||||
<ControlPanel />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
221
src/app/(features)/srt-player/store.ts
Normal file
221
src/app/(features)/srt-player/store.ts
Normal file
@@ -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<HTMLVideoElement | null> | null = null;
|
||||
|
||||
export function setVideoRef(ref: MutableRefObject<HTMLVideoElement | null>) {
|
||||
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<SrtPlayerStore>()(
|
||||
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' }
|
||||
)
|
||||
);
|
||||
132
src/app/(features)/srt-player/types.ts
Normal file
132
src/app/(features)/srt-player/types.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// ==================== Video Types ====================
|
||||
|
||||
export interface VideoState {
|
||||
url: string | null;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
playbackRate: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface VideoControls {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
togglePlayPause: () => void;
|
||||
seek: (time: number) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
restart: () => void;
|
||||
}
|
||||
|
||||
// ==================== Subtitle Types ====================
|
||||
|
||||
export interface SubtitleEntry {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface SubtitleState {
|
||||
url: string | null;
|
||||
data: SubtitleEntry[];
|
||||
currentText: string;
|
||||
currentIndex: number | null;
|
||||
settings: SubtitleSettings;
|
||||
}
|
||||
|
||||
export interface SubtitleSettings {
|
||||
fontSize: number;
|
||||
backgroundColor: string;
|
||||
textColor: string;
|
||||
position: 'top' | 'center' | 'bottom';
|
||||
fontFamily: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface SubtitleControls {
|
||||
next: () => void;
|
||||
previous: () => void;
|
||||
goToIndex: (index: number) => void;
|
||||
toggleAutoPause: () => void;
|
||||
}
|
||||
|
||||
// ==================== Controls Types ====================
|
||||
|
||||
export interface ControlState {
|
||||
autoPause: boolean;
|
||||
showShortcuts: boolean;
|
||||
showSettings: boolean;
|
||||
}
|
||||
|
||||
export interface ControlActions {
|
||||
toggleAutoPause: () => void;
|
||||
toggleShortcuts: () => void;
|
||||
toggleSettings: () => void;
|
||||
}
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
key: string;
|
||||
description: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
// ==================== Store Types ====================
|
||||
|
||||
export interface SrtPlayerStore {
|
||||
// Video state
|
||||
video: VideoState;
|
||||
|
||||
// Subtitle state
|
||||
subtitle: SubtitleState;
|
||||
|
||||
// Controls state
|
||||
controls: ControlState;
|
||||
|
||||
// Video actions
|
||||
setVideoUrl: (url: string | null) => void;
|
||||
setPlaying: (playing: boolean) => void;
|
||||
setCurrentTime: (time: number) => void;
|
||||
setDuration: (duration: number) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
togglePlayPause: () => void;
|
||||
seek: (time: number) => void;
|
||||
restart: () => void;
|
||||
|
||||
// Subtitle actions
|
||||
setSubtitleUrl: (url: string | null) => void;
|
||||
setSubtitleData: (data: SubtitleEntry[]) => void;
|
||||
setCurrentSubtitle: (text: string, index: number | null) => void;
|
||||
updateSettings: (settings: Partial<SubtitleSettings>) => void;
|
||||
nextSubtitle: () => void;
|
||||
previousSubtitle: () => void;
|
||||
restartSubtitle: () => void;
|
||||
|
||||
// Controls actions
|
||||
toggleAutoPause: () => void;
|
||||
toggleShortcuts: () => void;
|
||||
toggleSettings: () => void;
|
||||
}
|
||||
|
||||
// ==================== Selectors ====================
|
||||
|
||||
export const selectors = {
|
||||
canPlay: (state: SrtPlayerStore) =>
|
||||
!!state.video.url &&
|
||||
!!state.subtitle.url &&
|
||||
state.subtitle.data.length > 0,
|
||||
|
||||
currentSubtitle: (state: SrtPlayerStore) =>
|
||||
state.subtitle.currentIndex !== null
|
||||
? state.subtitle.data[state.subtitle.currentIndex]
|
||||
: null,
|
||||
|
||||
progress: (state: SrtPlayerStore) => ({
|
||||
current: state.subtitle.currentIndex ?? 0,
|
||||
total: state.subtitle.data.length,
|
||||
}),
|
||||
};
|
||||
@@ -1,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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/);
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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 (
|
||||
<html lang="en">
|
||||
<body className={`antialiased`}>
|
||||
<StrictMode>
|
||||
<NextIntlClientProvider>
|
||||
<Navbar></Navbar>
|
||||
{children}
|
||||
<Toaster />
|
||||
</NextIntlClientProvider>
|
||||
</StrictMode>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user