This commit is contained in:
@@ -150,7 +150,43 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"restart": "Restart",
|
||||
"autoPause": "Auto Pause ({enabled})"
|
||||
"autoPause": "Auto Pause ({enabled})",
|
||||
"playbackSpeed": "Playback Speed",
|
||||
"subtitleSettings": "Subtitle Settings",
|
||||
"fontSize": "Font Size",
|
||||
"backgroundColor": "Background Color",
|
||||
"textColor": "Text Color",
|
||||
"fontFamily": "Font Family",
|
||||
"opacity": "Opacity",
|
||||
"position": "Position",
|
||||
"top": "Top",
|
||||
"center": "Center",
|
||||
"bottom": "Bottom",
|
||||
"keyboardShortcuts": "Keyboard Shortcuts",
|
||||
"uploadVideoAndSubtitle": "Please upload video and subtitle files",
|
||||
"uploadVideoFile": "Please upload video file",
|
||||
"uploadSubtitleFile": "Please upload subtitle file",
|
||||
"processingSubtitle": "Processing subtitle file...",
|
||||
"needBothFiles": "Both video and subtitle files are required to start learning",
|
||||
"videoFile": "Video File",
|
||||
"subtitleFile": "Subtitle File",
|
||||
"uploaded": "Uploaded",
|
||||
"notUploaded": "Not Uploaded",
|
||||
"upload": "Upload",
|
||||
"autoPauseStatus": "Auto Pause: {enabled}",
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"videoUploadFailed": "Video upload failed",
|
||||
"subtitleUploadFailed": "Subtitle upload failed",
|
||||
"subtitleLoadSuccess": "Subtitle file loaded successfully",
|
||||
"subtitleLoadFailed": "Subtitle file loading failed",
|
||||
"shortcuts": {
|
||||
"playPause": "Play/Pause",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"restart": "Restart",
|
||||
"autoPause": "Toggle Auto Pause"
|
||||
}
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "Generate IPA",
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
"logout": "退出登录"
|
||||
},
|
||||
"srt_player": {
|
||||
"upload": "上传",
|
||||
"uploadVideo": "上传视频",
|
||||
"uploadSubtitle": "上传字幕",
|
||||
"pause": "暂停",
|
||||
@@ -154,7 +155,42 @@
|
||||
"previous": "上句",
|
||||
"next": "下句",
|
||||
"restart": "句首",
|
||||
"autoPause": "自动暂停({enabled})"
|
||||
"autoPause": "自动暂停({enabled})",
|
||||
"playbackSpeed": "播放速度",
|
||||
"subtitleSettings": "字幕设置",
|
||||
"fontSize": "字体大小",
|
||||
"backgroundColor": "背景颜色",
|
||||
"textColor": "文字颜色",
|
||||
"fontFamily": "字体",
|
||||
"opacity": "透明度",
|
||||
"position": "位置",
|
||||
"top": "顶部",
|
||||
"center": "居中",
|
||||
"bottom": "底部",
|
||||
"keyboardShortcuts": "键盘快捷键",
|
||||
"uploadVideoAndSubtitle": "请上传视频和字幕文件",
|
||||
"uploadVideoFile": "请上传视频文件",
|
||||
"uploadSubtitleFile": "请上传字幕文件",
|
||||
"processingSubtitle": "字幕文件正在处理中...",
|
||||
"needBothFiles": "需要同时上传视频和字幕文件才能开始学习",
|
||||
"videoFile": "视频文件",
|
||||
"subtitleFile": "字幕文件",
|
||||
"uploaded": "已上传",
|
||||
"notUploaded": "未上传",
|
||||
"autoPauseStatus": "自动暂停: {enabled}",
|
||||
"on": "开",
|
||||
"off": "关",
|
||||
"videoUploadFailed": "视频上传失败",
|
||||
"subtitleUploadFailed": "字幕上传失败",
|
||||
"subtitleLoadSuccess": "字幕文件加载成功",
|
||||
"subtitleLoadFailed": "字幕文件加载失败",
|
||||
"shortcuts": {
|
||||
"playPause": "播放/暂停",
|
||||
"next": "下一句",
|
||||
"previous": "上一句",
|
||||
"restart": "句首",
|
||||
"autoPause": "切换自动暂停"
|
||||
}
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "生成IPA",
|
||||
|
||||
45
src/app/(features)/srt-player/components/atoms/FileInput.tsx
Normal file
45
src/app/(features)/srt-player/components/atoms/FileInput.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef } from "react";
|
||||
import { FileInputProps } from "../../types/controls";
|
||||
|
||||
interface FileInputComponentProps extends FileInputProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function FileInput({ accept, onFileSelect, disabled, className, children }: FileInputComponentProps) {
|
||||
const inputRef = useRef<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"
|
||||
/>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
className={`px-2 py-1 rounded shadow font-bold hover:cursor-pointer hover:bg-gray-200 text-gray-800 bg-white ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import { PlayButtonProps } from "../../types/player";
|
||||
|
||||
export default function PlayButton({ isPlaying, onToggle, disabled, className }: PlayButtonProps) {
|
||||
const t = useTranslations("srt_player");
|
||||
|
||||
return (
|
||||
<LightButton
|
||||
onClick={disabled ? undefined : onToggle}
|
||||
disabled={disabled}
|
||||
className={`px-4 py-2 ${className || ''}`}
|
||||
>
|
||||
{isPlaying ? t("pause") : t("play")}
|
||||
</LightButton>
|
||||
);
|
||||
}
|
||||
26
src/app/(features)/srt-player/components/atoms/SeekBar.tsx
Normal file
26
src/app/(features)/srt-player/components/atoms/SeekBar.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { SeekBarProps } from "../../types/player";
|
||||
|
||||
export default function SeekBar({ value, max, onChange, disabled, className }: SeekBarProps) {
|
||||
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = parseInt(event.target.value);
|
||||
onChange(newValue);
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className={`w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
|
||||
style={{
|
||||
background: `linear-gradient(to right, #374151 0%, #374151 ${(value / max) * 100}%, #e5e7eb ${(value / max) * 100}%, #e5e7eb 100%)`
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import { SpeedControlProps } from "../../types/player";
|
||||
import { getPlaybackRateOptions, getPlaybackRateLabel } from "../../utils/timeUtils";
|
||||
|
||||
export default function SpeedControl({ playbackRate, onPlaybackRateChange, disabled, className }: SpeedControlProps) {
|
||||
const speedOptions = getPlaybackRateOptions();
|
||||
|
||||
const handleSpeedChange = React.useCallback(() => {
|
||||
const currentIndex = speedOptions.indexOf(playbackRate);
|
||||
const nextIndex = (currentIndex + 1) % speedOptions.length;
|
||||
onPlaybackRateChange(speedOptions[nextIndex]);
|
||||
}, [playbackRate, onPlaybackRateChange, speedOptions]);
|
||||
|
||||
return (
|
||||
<LightButton
|
||||
onClick={disabled ? undefined : handleSpeedChange}
|
||||
className={`${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
|
||||
>
|
||||
{getPlaybackRateLabel(playbackRate)}
|
||||
</LightButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { SubtitleTextProps } from "../../types/subtitle";
|
||||
|
||||
export default function SubtitleText({ text, onWordClick, style, className }: SubtitleTextProps) {
|
||||
const handleWordClick = React.useCallback((word: string) => {
|
||||
onWordClick?.(word);
|
||||
}, [onWordClick]);
|
||||
|
||||
// 将文本分割成单词,保持标点符号
|
||||
const renderTextWithClickableWords = () => {
|
||||
if (!text) return null;
|
||||
|
||||
// 匹配单词和标点符号
|
||||
const parts = text.match(/[\w']+|[^\w\s]+|\s+/g) || [];
|
||||
|
||||
return parts.map((part, index) => {
|
||||
// 如果是单词(字母和撇号组成)
|
||||
if (/^[\w']+$/.test(part)) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"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 default VideoElement;
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronLeft, ChevronRight, RotateCcw, Pause } from "lucide-react";
|
||||
import DarkButton from "@/components/ui/buttons/DarkButton";
|
||||
import { ControlBarProps } from "../../types/controls";
|
||||
import PlayButton from "../atoms/PlayButton";
|
||||
import SpeedControl from "../atoms/SpeedControl";
|
||||
|
||||
export default 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}
|
||||
/>
|
||||
|
||||
<DarkButton
|
||||
onClick={disabled ? undefined : onPrevious}
|
||||
disabled={disabled}
|
||||
className="flex items-center px-3 py-2"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
{t("previous")}
|
||||
</DarkButton>
|
||||
|
||||
<DarkButton
|
||||
onClick={disabled ? undefined : onNext}
|
||||
disabled={disabled}
|
||||
className="flex items-center px-3 py-2"
|
||||
>
|
||||
{t("next")}
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</DarkButton>
|
||||
|
||||
<DarkButton
|
||||
onClick={disabled ? undefined : onRestart}
|
||||
disabled={disabled}
|
||||
className="flex items-center px-3 py-2"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
{t("restart")}
|
||||
</DarkButton>
|
||||
|
||||
<SpeedControl
|
||||
playbackRate={playbackRate}
|
||||
onPlaybackRateChange={onPlaybackRateChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<DarkButton
|
||||
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") })}
|
||||
</DarkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { SubtitleDisplayProps } from "../../types/subtitle";
|
||||
import SubtitleText from "../atoms/SubtitleText";
|
||||
|
||||
export default 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { Video, FileText } from "lucide-react";
|
||||
import DarkButton from "@/components/ui/buttons/DarkButton";
|
||||
import { FileUploadProps } from "../../types/controls";
|
||||
import { useFileUpload } from "../../hooks/useFileUpload";
|
||||
|
||||
export default 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 || ''}`}>
|
||||
<DarkButton
|
||||
onClick={handleVideoUpload}
|
||||
className="flex-1 py-2 px-3 text-sm"
|
||||
>
|
||||
<Video className="w-4 h-4 mr-2" />
|
||||
{t("uploadVideo")}
|
||||
</DarkButton>
|
||||
|
||||
<DarkButton
|
||||
onClick={handleSubtitleUpload}
|
||||
className="flex-1 py-2 px-3 text-sm"
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
{t("uploadSubtitle")}
|
||||
</DarkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"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 default VideoPlayer;
|
||||
85
src/app/(features)/srt-player/hooks/useFileUpload.ts
Normal file
85
src/app/(features)/srt-player/hooks/useFileUpload.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
export function useFileUpload() {
|
||||
const uploadFile = useCallback((
|
||||
file: File,
|
||||
onSuccess: (url: string) => void,
|
||||
onError?: (error: Error) => void
|
||||
) => {
|
||||
try {
|
||||
// 验证文件大小(限制为100MB)
|
||||
const maxSize = 100 * 1024 * 1024; // 100MB
|
||||
if (file.size > maxSize) {
|
||||
throw new Error(`文件大小超过限制 (${(file.size / 1024 / 1024).toFixed(2)}MB > 100MB)`);
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
onSuccess(url);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '文件上传失败';
|
||||
onError?.(new Error(errorMessage));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const uploadVideo = useCallback((
|
||||
onVideoUpload: (url: string) => void,
|
||||
onError?: (error: Error) => void
|
||||
) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'video/*';
|
||||
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
// 验证文件类型
|
||||
if (!file.type.startsWith('video/')) {
|
||||
onError?.(new Error('请选择有效的视频文件'));
|
||||
return;
|
||||
}
|
||||
uploadFile(file, onVideoUpload, onError);
|
||||
}
|
||||
};
|
||||
|
||||
input.onerror = () => {
|
||||
onError?.(new Error('文件选择失败'));
|
||||
};
|
||||
|
||||
input.click();
|
||||
}, [uploadFile]);
|
||||
|
||||
const uploadSubtitle = useCallback((
|
||||
onSubtitleUpload: (url: string) => void,
|
||||
onError?: (error: Error) => void
|
||||
) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.srt';
|
||||
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
// 验证文件扩展名
|
||||
if (!file.name.toLowerCase().endsWith('.srt')) {
|
||||
onError?.(new Error('请选择.srt格式的字幕文件'));
|
||||
return;
|
||||
}
|
||||
uploadFile(file, onSubtitleUpload, onError);
|
||||
}
|
||||
};
|
||||
|
||||
input.onerror = () => {
|
||||
onError?.(new Error('文件选择失败'));
|
||||
};
|
||||
|
||||
input.click();
|
||||
}, [uploadFile]);
|
||||
|
||||
return {
|
||||
uploadVideo,
|
||||
uploadSubtitle,
|
||||
uploadFile,
|
||||
};
|
||||
}
|
||||
68
src/app/(features)/srt-player/hooks/useKeyboardShortcuts.ts
Normal file
68
src/app/(features)/srt-player/hooks/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { KeyboardShortcut } from "../types/controls";
|
||||
|
||||
export function useKeyboardShortcuts(
|
||||
shortcuts: KeyboardShortcut[],
|
||||
enabled: boolean = true
|
||||
) {
|
||||
const handleKeyDown = useCallback((event: globalThis.KeyboardEvent) => {
|
||||
if (!enabled) return;
|
||||
|
||||
// 防止在输入框中触发快捷键
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
const shortcut = shortcuts.find(s => s.key === event.key);
|
||||
if (shortcut) {
|
||||
event.preventDefault();
|
||||
shortcut.action();
|
||||
}
|
||||
}, [shortcuts, enabled]);
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
}
|
||||
306
src/app/(features)/srt-player/hooks/useSrtPlayer.ts
Normal file
306
src/app/(features)/srt-player/hooks/useSrtPlayer.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
"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>;
|
||||
110
src/app/(features)/srt-player/hooks/useSubtitleSync.ts
Normal file
110
src/app/(features)/srt-player/hooks/useSubtitleSync.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { SubtitleEntry } from "../types/subtitle";
|
||||
|
||||
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);
|
||||
const rafIdRef = useRef<number>(0);
|
||||
|
||||
// 获取当前时间对应的字幕
|
||||
const getCurrentSubtitle = useCallback((time: number): SubtitleEntry | null => {
|
||||
return subtitles.find(subtitle => time >= subtitle.start && time <= subtitle.end) || null;
|
||||
}, [subtitles]);
|
||||
|
||||
// 获取最近的字幕索引
|
||||
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];
|
||||
|
||||
if (time >= subtitle.start && time <= subtitle.end) {
|
||||
return mid;
|
||||
} else if (time < subtitle.start) {
|
||||
right = mid - 1;
|
||||
} else {
|
||||
left = mid + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到完全匹配的字幕,返回最近的字幕索引
|
||||
return right >= 0 ? right : null;
|
||||
}, [subtitles]);
|
||||
|
||||
// 检查是否需要自动暂停
|
||||
const shouldAutoPause = useCallback((subtitle: SubtitleEntry, time: number): boolean => {
|
||||
return autoPause &&
|
||||
time >= subtitle.end - 0.2 && // 增加时间窗口,确保自动暂停更可靠
|
||||
time < subtitle.end;
|
||||
}, [autoPause]);
|
||||
|
||||
// 启动/停止同步循环
|
||||
useEffect(() => {
|
||||
const syncSubtitles = () => {
|
||||
const currentSubtitle = getCurrentSubtitle(currentTime);
|
||||
|
||||
// 检查字幕是否发生变化
|
||||
if (currentSubtitle !== lastSubtitleRef.current) {
|
||||
const previousSubtitle = lastSubtitleRef.current;
|
||||
lastSubtitleRef.current = currentSubtitle;
|
||||
|
||||
// 只有当有当前字幕时才调用onSubtitleChange
|
||||
// 在字幕间隙时保持之前的字幕索引,避免进度条跳到0
|
||||
if (currentSubtitle) {
|
||||
onSubtitleChange(currentSubtitle);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否需要自动暂停
|
||||
// 每次都检查,不只在字幕变化时检查
|
||||
if (currentSubtitle && shouldAutoPause(currentSubtitle, currentTime)) {
|
||||
onAutoPauseTrigger?.(currentSubtitle);
|
||||
} else if (!currentSubtitle && lastSubtitleRef.current && shouldAutoPause(lastSubtitleRef.current, currentTime)) {
|
||||
// 在字幕结束时,如果前一个字幕需要自动暂停,也要触发
|
||||
onAutoPauseTrigger?.(lastSubtitleRef.current);
|
||||
}
|
||||
|
||||
rafIdRef.current = requestAnimationFrame(syncSubtitles);
|
||||
};
|
||||
|
||||
if (subtitles.length > 0) {
|
||||
rafIdRef.current = requestAnimationFrame(syncSubtitles);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (rafIdRef.current) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
}
|
||||
};
|
||||
}, [subtitles.length, currentTime, getCurrentSubtitle, onSubtitleChange, shouldAutoPause, onAutoPauseTrigger]);
|
||||
|
||||
// 重置最后字幕引用
|
||||
useEffect(() => {
|
||||
lastSubtitleRef.current = null;
|
||||
}, [subtitles]);
|
||||
|
||||
return {
|
||||
getCurrentSubtitle,
|
||||
getNearestIndex,
|
||||
shouldAutoPause,
|
||||
};
|
||||
}
|
||||
@@ -1,25 +1,280 @@
|
||||
"use client";
|
||||
|
||||
import { KeyboardEvent, useRef, useState } from "react";
|
||||
import UploadArea from "./UploadArea";
|
||||
import VideoPanel from "./VideoPlayer/VideoPanel";
|
||||
import React from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { Video, FileText } from "lucide-react";
|
||||
import { useSrtPlayer } from "./hooks/useSrtPlayer";
|
||||
import { useSubtitleSync } from "./hooks/useSubtitleSync";
|
||||
import { useKeyboardShortcuts, createSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||
import { useFileUpload } from "./hooks/useFileUpload";
|
||||
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 DarkButton from "@/components/ui/buttons/DarkButton";
|
||||
|
||||
export default function SrtPlayerPage() {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
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 shortcuts = React.useMemo(() =>
|
||||
createSrtPlayerShortcuts(
|
||||
actions.togglePlayPause,
|
||||
actions.nextSubtitle,
|
||||
actions.previousSubtitle,
|
||||
actions.restartSubtitle,
|
||||
actions.toggleAutoPause
|
||||
), [
|
||||
actions.togglePlayPause,
|
||||
actions.nextSubtitle,
|
||||
actions.previousSubtitle,
|
||||
actions.restartSubtitle,
|
||||
actions.toggleAutoPause
|
||||
]
|
||||
);
|
||||
|
||||
useKeyboardShortcuts(shortcuts);
|
||||
|
||||
// 处理字幕文件加载
|
||||
React.useEffect(() => {
|
||||
if (state.subtitle.url) {
|
||||
loadSubtitle(state.subtitle.url)
|
||||
.then(subtitleData => {
|
||||
subtitleActions.setSubtitleData(subtitleData);
|
||||
toast.success(srtT("subtitleLoadSuccess"));
|
||||
})
|
||||
.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;
|
||||
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||
const [srtUrl, setSrtUrl] = useState<string | null>(null);
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex w-screen pt-8 items-center justify-center"
|
||||
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}
|
||||
>
|
||||
<div className="w-[80vw] md:w-[45vw] flex items-center flex-col">
|
||||
<VideoPanel videoUrl={videoUrl} srtUrl={srtUrl} ref={videoRef} />
|
||||
<UploadArea setVideoUrl={setVideoUrl} setSrtUrl={setSrtUrl} />
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* 标题区域 */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-800 mb-2">
|
||||
{t("srtPlayer.name")}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600">
|
||||
{t("srtPlayer.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
{/* 视频播放器区域 */}
|
||||
<div className="aspect-video bg-black relative">
|
||||
{(!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 && (
|
||||
<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">
|
||||
{/* 上传区域和状态指示器 */}
|
||||
<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>
|
||||
<DarkButton
|
||||
onClick={state.video.url ? undefined : handleVideoUpload}
|
||||
disabled={!!state.video.url}
|
||||
className="px-2 py-1 text-xs"
|
||||
>
|
||||
{state.video.url ? srtT("uploaded") : srtT("upload")}
|
||||
</DarkButton>
|
||||
</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>
|
||||
<DarkButton
|
||||
onClick={state.subtitle.url ? undefined : handleSubtitleUpload}
|
||||
disabled={!!state.subtitle.url}
|
||||
className="px-2 py-1 text-xs"
|
||||
>
|
||||
{state.subtitle.url ? srtT("uploaded") : srtT("upload")}
|
||||
</DarkButton>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
65
src/app/(features)/srt-player/types/controls.ts
Normal file
65
src/app/(features)/srt-player/types/controls.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
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;
|
||||
}
|
||||
57
src/app/(features)/srt-player/types/player.ts
Normal file
57
src/app/(features)/srt-player/types/player.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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;
|
||||
}
|
||||
59
src/app/(features)/srt-player/types/subtitle.ts
Normal file
59
src/app/(features)/srt-player/types/subtitle.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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;
|
||||
}
|
||||
99
src/app/(features)/srt-player/utils/subtitleParser.ts
Normal file
99
src/app/(features)/srt-player/utils/subtitleParser.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { SubtitleEntry } from "../types/subtitle";
|
||||
|
||||
export function parseSrt(data: string): SubtitleEntry[] {
|
||||
const lines = data.split(/\r?\n/);
|
||||
const result: SubtitleEntry[] = [];
|
||||
const re = new RegExp(
|
||||
"(\\d{2}:\\d{2}:\\d{2},\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2},\\d{3})",
|
||||
);
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
if (!lines[i].trim()) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
if (i >= lines.length) break;
|
||||
|
||||
const timeMatch = lines[i].match(re);
|
||||
if (!timeMatch) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const start = toSeconds(timeMatch[1]);
|
||||
const end = toSeconds(timeMatch[2]);
|
||||
i++;
|
||||
|
||||
let text = "";
|
||||
while (i < lines.length && lines[i].trim()) {
|
||||
text += lines[i] + "\n";
|
||||
i++;
|
||||
}
|
||||
|
||||
result.push({
|
||||
start,
|
||||
end,
|
||||
text: text.trim(),
|
||||
index: result.length,
|
||||
});
|
||||
i++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getSubtitleIndex(
|
||||
subtitles: SubtitleEntry[],
|
||||
currentTime: number,
|
||||
): number | null {
|
||||
for (let i = 0; i < subtitles.length; i++) {
|
||||
if (currentTime >= subtitles[i].start && currentTime <= subtitles[i].end) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getNearestIndex(
|
||||
subtitles: SubtitleEntry[],
|
||||
currentTime: number,
|
||||
): number | null {
|
||||
for (let i = 0; i < subtitles.length; i++) {
|
||||
const subtitle = subtitles[i];
|
||||
const isBefore = currentTime - subtitle.start >= 0;
|
||||
const isAfter = currentTime - subtitle.end >= 0;
|
||||
|
||||
if (!isBefore || !isAfter) return i - 1;
|
||||
if (isBefore && !isAfter) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getCurrentSubtitle(
|
||||
subtitles: SubtitleEntry[],
|
||||
currentTime: number,
|
||||
): SubtitleEntry | null {
|
||||
return subtitles.find((subtitle) =>
|
||||
currentTime >= subtitle.start && currentTime <= subtitle.end
|
||||
) || null;
|
||||
}
|
||||
|
||||
function toSeconds(timeStr: string): number {
|
||||
const [h, m, s] = timeStr.replace(",", ".").split(":");
|
||||
return parseFloat(
|
||||
(parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s)).toFixed(3),
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadSubtitle(url: string): Promise<SubtitleEntry[]> {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const data = await response.text();
|
||||
return parseSrt(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load subtitle:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
48
src/app/(features)/srt-player/utils/timeUtils.ts
Normal file
48
src/app/(features)/srt-player/utils/timeUtils.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
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`;
|
||||
}
|
||||
@@ -6,18 +6,21 @@ export default function DarkButton({
|
||||
selected,
|
||||
children,
|
||||
type = "button",
|
||||
disabled
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
onClick?: (() => void) | undefined;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
children?: React.ReactNode;
|
||||
type?: ButtonType;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<PlainButton
|
||||
onClick={onClick}
|
||||
className={`hover:bg-gray-600 text-white ${selected ? "bg-gray-600" : "bg-gray-800"} ${className}`}
|
||||
className={`hover:bg-gray-100 text-black ${selected ? "bg-gray-100" : "bg-white"} ${className}`}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</PlainButton>
|
||||
|
||||
@@ -6,18 +6,21 @@ export default function LightButton({
|
||||
selected,
|
||||
children,
|
||||
type = "button",
|
||||
disabled
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
onClick?: (() => void) | undefined;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
children?: React.ReactNode;
|
||||
type?: ButtonType;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<PlainButton
|
||||
onClick={onClick}
|
||||
className={`hover:bg-gray-200 text-gray-800 ${selected ? "bg-gray-200" : "bg-white"} ${className}`}
|
||||
className={`hover:bg-gray-100 text-black ${selected ? "bg-gray-100" : "bg-white"} ${className}`}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</PlainButton>
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
export type ButtonType = "button" | "submit" | "reset" | undefined;
|
||||
export type ButtonType = "button" | "submit" | "reset" | undefined;
|
||||
|
||||
export default function PlainButton({
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
type = "button",
|
||||
disabled
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
type?: ButtonType;
|
||||
type?: ButtonType;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-2 py-1 rounded shadow font-bold hover:cursor-pointer ${className}`}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user