srt
This commit is contained in:
@@ -22,3 +22,4 @@ pnpm run build
|
|||||||
- Better-auth 处理会话管理 - 使用 authClient 适配器进行认证操作
|
- Better-auth 处理会话管理 - 使用 authClient 适配器进行认证操作
|
||||||
- 使用 better-auth username 插件支持用户名登录
|
- 使用 better-auth username 插件支持用户名登录
|
||||||
- 组件尽量复用/src/design-system里的可复用组件与/src/components里的业务相关组件
|
- 组件尽量复用/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,68 +1,90 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { KeyboardShortcut } from "../types/controls";
|
import { useSrtPlayerStore } from "../store";
|
||||||
|
|
||||||
export function useKeyboardShortcuts(
|
/**
|
||||||
shortcuts: KeyboardShortcut[],
|
* useSrtPlayerShortcuts - SRT 播放器快捷键 Hook
|
||||||
enabled: boolean = true
|
*
|
||||||
) {
|
* 自动为 SRT 播放器设置键盘快捷键,无需传入参数。
|
||||||
const handleKeyDown = useCallback((event: globalThis.KeyboardEvent) => {
|
* 直接使用 Zustand store 中的 actions。
|
||||||
if (!enabled) return;
|
*/
|
||||||
|
export function useSrtPlayerShortcuts(enabled: boolean = true) {
|
||||||
// 防止在输入框中触发快捷键
|
const togglePlayPause = useSrtPlayerStore((state) => state.togglePlayPause);
|
||||||
const target = event.target as HTMLElement;
|
const nextSubtitle = useSrtPlayerStore((state) => state.nextSubtitle);
|
||||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
const previousSubtitle = useSrtPlayerStore((state) => state.previousSubtitle);
|
||||||
return;
|
const restartSubtitle = useSrtPlayerStore((state) => state.restartSubtitle);
|
||||||
}
|
const toggleAutoPause = useSrtPlayerStore((state) => state.toggleAutoPause);
|
||||||
|
|
||||||
const shortcut = shortcuts.find(s => s.key === event.key);
|
|
||||||
if (shortcut) {
|
|
||||||
event.preventDefault();
|
|
||||||
shortcut.action();
|
|
||||||
}
|
|
||||||
}, [shortcuts, enabled]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
// 防止在输入框中触发快捷键
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case ' ':
|
||||||
|
event.preventDefault();
|
||||||
|
togglePlayPause();
|
||||||
|
break;
|
||||||
|
case 'n':
|
||||||
|
case 'N':
|
||||||
|
event.preventDefault();
|
||||||
|
nextSubtitle();
|
||||||
|
break;
|
||||||
|
case 'p':
|
||||||
|
case 'P':
|
||||||
|
event.preventDefault();
|
||||||
|
previousSubtitle();
|
||||||
|
break;
|
||||||
|
case 'r':
|
||||||
|
case 'R':
|
||||||
|
event.preventDefault();
|
||||||
|
restartSubtitle();
|
||||||
|
break;
|
||||||
|
case 'a':
|
||||||
|
case 'A':
|
||||||
|
event.preventDefault();
|
||||||
|
toggleAutoPause();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [handleKeyDown]);
|
}, [enabled, togglePlayPause, nextSubtitle, previousSubtitle, restartSubtitle, toggleAutoPause]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSrtPlayerShortcuts(
|
// 保留通用快捷键 Hook 用于其他场景
|
||||||
playPause: () => void,
|
export function useKeyboardShortcuts(
|
||||||
next: () => void,
|
shortcuts: Array<{ key: string; action: () => void }>,
|
||||||
previous: () => void,
|
isEnabled: boolean = true
|
||||||
restart: () => void,
|
) {
|
||||||
toggleAutoPause: () => void
|
useEffect(() => {
|
||||||
): KeyboardShortcut[] {
|
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||||
return [
|
if (!isEnabled) return;
|
||||||
{
|
|
||||||
key: ' ',
|
const target = event.target as HTMLElement;
|
||||||
description: '播放/暂停',
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||||
action: playPause,
|
return;
|
||||||
},
|
}
|
||||||
{
|
|
||||||
key: 'n',
|
const shortcut = shortcuts.find(s => s.key === event.key);
|
||||||
description: '下一句',
|
if (shortcut) {
|
||||||
action: next,
|
event.preventDefault();
|
||||||
},
|
shortcut.action();
|
||||||
{
|
}
|
||||||
key: 'p',
|
};
|
||||||
description: '上一句',
|
|
||||||
action: previous,
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
},
|
return () => {
|
||||||
{
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
key: 'r',
|
};
|
||||||
description: '句首',
|
}, [shortcuts, isEnabled]);
|
||||||
action: restart,
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'a',
|
|
||||||
description: '切换自动暂停',
|
|
||||||
action: toggleAutoPause,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,110 +1,85 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { SubtitleEntry } from "../types/subtitle";
|
import { useSrtPlayerStore } from "../store";
|
||||||
|
|
||||||
export function useSubtitleSync(
|
/**
|
||||||
subtitles: SubtitleEntry[],
|
* useSubtitleSync - 字幕同步 Hook
|
||||||
currentTime: number,
|
*
|
||||||
isPlaying: boolean,
|
* 自动同步视频播放时间与字幕显示,支持自动暂停功能。
|
||||||
autoPause: boolean,
|
* 使用 Zustand store 获取状态,无需传入参数。
|
||||||
onSubtitleChange: (subtitle: SubtitleEntry | null) => void,
|
*/
|
||||||
onAutoPauseTrigger?: (subtitle: SubtitleEntry) => void
|
export function useSubtitleSync() {
|
||||||
) {
|
const lastSubtitleRef = useRef<number | null>(null);
|
||||||
const lastSubtitleRef = useRef<SubtitleEntry | null>(null);
|
|
||||||
const rafIdRef = useRef<number>(0);
|
const rafIdRef = useRef<number>(0);
|
||||||
|
|
||||||
// 获取当前时间对应的字幕
|
// 从 store 获取状态
|
||||||
const getCurrentSubtitle = useCallback((time: number): SubtitleEntry | null => {
|
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
|
||||||
return subtitles.find(subtitle => time >= subtitle.start && time <= subtitle.end) || null;
|
const currentTime = useSrtPlayerStore((state) => state.video.currentTime);
|
||||||
}, [subtitles]);
|
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
|
||||||
|
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
|
||||||
|
|
||||||
// 获取最近的字幕索引
|
// Store actions
|
||||||
const getNearestIndex = useCallback((time: number): number | null => {
|
const setCurrentSubtitle = useSrtPlayerStore((state) => state.setCurrentSubtitle);
|
||||||
if (subtitles.length === 0) return null;
|
const seek = useSrtPlayerStore((state) => state.seek);
|
||||||
|
const pause = useSrtPlayerStore((state) => state.pause);
|
||||||
// 如果时间早于第一个字幕开始时间
|
|
||||||
if (time < subtitles[0].start) return null;
|
// 获取当前时间对应的字幕索引
|
||||||
|
const getCurrentSubtitleIndex = (time: number): number | null => {
|
||||||
// 如果时间晚于最后一个字幕结束时间
|
for (let i = 0; i < subtitleData.length; i++) {
|
||||||
if (time > subtitles[subtitles.length - 1].end) return subtitles.length - 1;
|
const subtitle = subtitleData[i];
|
||||||
|
|
||||||
// 二分查找找到当前时间对应的字幕
|
|
||||||
let left = 0;
|
|
||||||
let right = subtitles.length - 1;
|
|
||||||
|
|
||||||
while (left <= right) {
|
|
||||||
const mid = Math.floor((left + right) / 2);
|
|
||||||
const subtitle = subtitles[mid];
|
|
||||||
|
|
||||||
if (time >= subtitle.start && time <= subtitle.end) {
|
if (time >= subtitle.start && time <= subtitle.end) {
|
||||||
return mid;
|
return i;
|
||||||
} else if (time < subtitle.start) {
|
|
||||||
right = mid - 1;
|
|
||||||
} else {
|
|
||||||
left = mid + 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
// 如果没有找到完全匹配的字幕,返回最近的字幕索引
|
};
|
||||||
return right >= 0 ? right : null;
|
|
||||||
}, [subtitles]);
|
|
||||||
|
|
||||||
// 检查是否需要自动暂停
|
// 检查是否需要自动暂停
|
||||||
const shouldAutoPause = useCallback((subtitle: SubtitleEntry, time: number): boolean => {
|
const shouldAutoPause = (subtitle: { start: number; end: number }, time: number): boolean => {
|
||||||
return autoPause &&
|
return autoPause && time >= subtitle.end - 0.2 && time < subtitle.end;
|
||||||
time >= subtitle.end - 0.2 && // 增加时间窗口,确保自动暂停更可靠
|
};
|
||||||
time < subtitle.end;
|
|
||||||
}, [autoPause]);
|
|
||||||
|
|
||||||
// 启动/停止同步循环
|
// 同步循环
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncSubtitles = () => {
|
const syncSubtitles = () => {
|
||||||
const currentSubtitle = getCurrentSubtitle(currentTime);
|
const currentIndex = getCurrentSubtitleIndex(currentTime);
|
||||||
|
|
||||||
// 检查字幕是否发生变化
|
// 检查字幕是否发生变化
|
||||||
if (currentSubtitle !== lastSubtitleRef.current) {
|
if (currentIndex !== lastSubtitleRef.current) {
|
||||||
const previousSubtitle = lastSubtitleRef.current;
|
lastSubtitleRef.current = currentIndex;
|
||||||
lastSubtitleRef.current = currentSubtitle;
|
|
||||||
|
if (currentIndex !== null) {
|
||||||
// 只有当有当前字幕时才调用onSubtitleChange
|
const subtitle = subtitleData[currentIndex];
|
||||||
// 在字幕间隙时保持之前的字幕索引,避免进度条跳到0
|
setCurrentSubtitle(subtitle.text, currentIndex);
|
||||||
if (currentSubtitle) {
|
} else {
|
||||||
onSubtitleChange(currentSubtitle);
|
setCurrentSubtitle('', null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否需要自动暂停
|
// 检查是否需要自动暂停
|
||||||
// 每次都检查,不只在字幕变化时检查
|
const currentSubtitle = currentIndex !== null ? subtitleData[currentIndex] : null;
|
||||||
if (currentSubtitle && shouldAutoPause(currentSubtitle, currentTime)) {
|
if (currentSubtitle && shouldAutoPause(currentSubtitle, currentTime)) {
|
||||||
onAutoPauseTrigger?.(currentSubtitle);
|
seek(currentSubtitle.start);
|
||||||
} else if (!currentSubtitle && lastSubtitleRef.current && shouldAutoPause(lastSubtitleRef.current, currentTime)) {
|
pause();
|
||||||
// 在字幕结束时,如果前一个字幕需要自动暂停,也要触发
|
|
||||||
onAutoPauseTrigger?.(lastSubtitleRef.current);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rafIdRef.current = requestAnimationFrame(syncSubtitles);
|
rafIdRef.current = requestAnimationFrame(syncSubtitles);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (subtitles.length > 0) {
|
if (subtitleData.length > 0 && isPlaying) {
|
||||||
rafIdRef.current = requestAnimationFrame(syncSubtitles);
|
rafIdRef.current = requestAnimationFrame(syncSubtitles);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (rafIdRef.current) {
|
if (rafIdRef.current) {
|
||||||
cancelAnimationFrame(rafIdRef.current);
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [subtitles.length, currentTime, getCurrentSubtitle, onSubtitleChange, shouldAutoPause, onAutoPauseTrigger]);
|
}, [subtitleData, currentTime, isPlaying, autoPause, setCurrentSubtitle, seek, pause]);
|
||||||
|
|
||||||
// 重置最后字幕引用
|
// 重置最后字幕引用
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
lastSubtitleRef.current = null;
|
lastSubtitleRef.current = null;
|
||||||
}, [subtitles]);
|
}, [subtitleData]);
|
||||||
|
}
|
||||||
return {
|
|
||||||
getCurrentSubtitle,
|
|
||||||
getNearestIndex,
|
|
||||||
shouldAutoPause,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
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";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import { useRef, useEffect } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Video, FileText } from "lucide-react";
|
|
||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
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 { useSubtitleSync } from "./hooks/useSubtitleSync";
|
||||||
import { useKeyboardShortcuts, createSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
|
import { useSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||||
import { useFileUpload } from "./hooks/useFileUpload";
|
|
||||||
import { loadSubtitle } from "./utils/subtitleParser";
|
import { loadSubtitle } from "./utils/subtitleParser";
|
||||||
import { VideoPlayer } from "./components/compounds/VideoPlayer";
|
import { useSrtPlayerStore } from "./store";
|
||||||
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";
|
|
||||||
|
|
||||||
export default function SrtPlayerPage() {
|
export default function SrtPlayerPage() {
|
||||||
const t = useTranslations("home");
|
const t = useTranslations("home");
|
||||||
const srtT = useTranslations("srt_player");
|
const srtT = useTranslations("srt_player");
|
||||||
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
|
||||||
const {
|
|
||||||
state,
|
|
||||||
actions,
|
|
||||||
videoRef,
|
|
||||||
videoEventHandlers,
|
|
||||||
subtitleActions
|
|
||||||
} = useSrtPlayer();
|
|
||||||
|
|
||||||
// 字幕同步
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
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();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 键盘快捷键
|
// Store state
|
||||||
const shortcuts = React.useMemo(() =>
|
const subtitleUrl = useSrtPlayerStore((state) => state.subtitle.url);
|
||||||
createSrtPlayerShortcuts(
|
const setSubtitleData = useSrtPlayerStore((state) => state.setSubtitleData);
|
||||||
actions.togglePlayPause,
|
|
||||||
actions.nextSubtitle,
|
|
||||||
actions.previousSubtitle,
|
|
||||||
actions.restartSubtitle,
|
|
||||||
actions.toggleAutoPause
|
|
||||||
), [
|
|
||||||
actions.togglePlayPause,
|
|
||||||
actions.nextSubtitle,
|
|
||||||
actions.previousSubtitle,
|
|
||||||
actions.restartSubtitle,
|
|
||||||
actions.toggleAutoPause
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
useKeyboardShortcuts(shortcuts);
|
// Hooks
|
||||||
|
useVideoSync(videoRef);
|
||||||
|
useSubtitleSync();
|
||||||
|
useSrtPlayerShortcuts();
|
||||||
|
|
||||||
// 处理字幕文件加载
|
// Load subtitle when URL changes
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.subtitle.url) {
|
if (subtitleUrl) {
|
||||||
loadSubtitle(state.subtitle.url)
|
loadSubtitle(subtitleUrl)
|
||||||
.then(subtitleData => {
|
.then((subtitleData) => {
|
||||||
subtitleActions.setSubtitleData(subtitleData);
|
setSubtitleData(subtitleData);
|
||||||
toast.success(srtT("subtitleLoadSuccess"));
|
toast.success(srtT("subtitleLoadSuccess"));
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch((error) => {
|
||||||
toast.error(srtT("subtitleLoadFailed") + ": " + error.message);
|
toast.error(srtT("subtitleLoadFailed") + ": " + error.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [srtT, state.subtitle.url, subtitleActions]);
|
}, [srtT, subtitleUrl, setSubtitleData]);
|
||||||
|
|
||||||
// 处理进度条变化
|
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
{/* 标题区域 */}
|
{/* Title */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-4xl font-bold text-gray-800 mb-2">
|
<h1 className="text-4xl font-bold text-gray-800 mb-2">
|
||||||
{t("srtPlayer.name")}
|
{t("srtPlayer.name")}
|
||||||
@@ -118,157 +53,11 @@ export default function SrtPlayerPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 视频播放器区域 */}
|
{/* Video Player */}
|
||||||
<div className="aspect-video bg-black relative rounded-md overflow-hidden">
|
<VideoPlayerPanel ref={videoRef} />
|
||||||
{(!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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{state.video.url && (
|
{/* Control Panel */}
|
||||||
<VideoPlayer
|
<ControlPanel />
|
||||||
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>
|
|
||||||
</PageLayout>
|
</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[] {
|
export function parseSrt(data: string): SubtitleEntry[] {
|
||||||
const lines = data.split(/\r?\n/);
|
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 { NextIntlClientProvider } from "next-intl";
|
||||||
import { Navbar } from "@/components/layout/Navbar";
|
import { Navbar } from "@/components/layout/Navbar";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
width: "device-width",
|
width: "device-width",
|
||||||
@@ -23,11 +24,13 @@ export default async function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`antialiased`}>
|
<body className={`antialiased`}>
|
||||||
<NextIntlClientProvider>
|
<StrictMode>
|
||||||
<Navbar></Navbar>
|
<NextIntlClientProvider>
|
||||||
{children}
|
<Navbar></Navbar>
|
||||||
<Toaster />
|
{children}
|
||||||
</NextIntlClientProvider>
|
<Toaster />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</StrictMode>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user