重构逐句视频播放器
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-12-12 13:37:00 +08:00
parent b69e168558
commit 605c57f8bb
25 changed files with 1667 additions and 24 deletions

View File

@@ -150,7 +150,43 @@
"previous": "Previous", "previous": "Previous",
"next": "Next", "next": "Next",
"restart": "Restart", "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": { "text_speaker": {
"generateIPA": "Generate IPA", "generateIPA": "Generate IPA",

View File

@@ -147,6 +147,7 @@
"logout": "退出登录" "logout": "退出登录"
}, },
"srt_player": { "srt_player": {
"upload": "上传",
"uploadVideo": "上传视频", "uploadVideo": "上传视频",
"uploadSubtitle": "上传字幕", "uploadSubtitle": "上传字幕",
"pause": "暂停", "pause": "暂停",
@@ -154,7 +155,42 @@
"previous": "上句", "previous": "上句",
"next": "下句", "next": "下句",
"restart": "句首", "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": { "text_speaker": {
"generateIPA": "生成IPA", "generateIPA": "生成IPA",

View 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>
</>
);
}

View File

@@ -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>
);
}

View 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%)`
}}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View 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,
};
}

View 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,
},
];
}

View 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>;

View 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,
};
}

View File

@@ -1,25 +1,280 @@
"use client"; "use client";
import { KeyboardEvent, useRef, useState } from "react"; import React from "react";
import UploadArea from "./UploadArea"; import { useTranslations } from "next-intl";
import VideoPanel from "./VideoPlayer/VideoPanel"; 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() { 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 ( return (
<> <div className="min-h-screen bg-gray-50">
<div <div className="container mx-auto px-4 py-8">
className="flex w-screen pt-8 items-center justify-center" <div className="max-w-6xl mx-auto">
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()} {/* 标题区域 */}
> <div className="text-center mb-8">
<div className="w-[80vw] md:w-[45vw] flex items-center flex-col"> <h1 className="text-4xl font-bold text-gray-800 mb-2">
<VideoPanel videoUrl={videoUrl} srtUrl={srtUrl} ref={videoRef} /> {t("srtPlayer.name")}
<UploadArea setVideoUrl={setVideoUrl} setSrtUrl={setSrtUrl} /> </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> </div>
</> </div>
); );
} }

View 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;
}

View 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;
}

View 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;
}

View 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 [];
}
}

View 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`;
}

View File

@@ -6,18 +6,21 @@ export default function DarkButton({
selected, selected,
children, children,
type = "button", type = "button",
disabled
}: { }: {
onClick?: () => void; onClick?: (() => void) | undefined;
className?: string; className?: string;
selected?: boolean; selected?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
type?: ButtonType; type?: ButtonType;
disabled?: boolean;
}) { }) {
return ( return (
<PlainButton <PlainButton
onClick={onClick} 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} type={type}
disabled={disabled}
> >
{children} {children}
</PlainButton> </PlainButton>

View File

@@ -6,18 +6,21 @@ export default function LightButton({
selected, selected,
children, children,
type = "button", type = "button",
disabled
}: { }: {
onClick?: () => void; onClick?: (() => void) | undefined;
className?: string; className?: string;
selected?: boolean; selected?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
type?: ButtonType; type?: ButtonType;
disabled?: boolean;
}) { }) {
return ( return (
<PlainButton <PlainButton
onClick={onClick} 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} type={type}
disabled={disabled}
> >
{children} {children}
</PlainButton> </PlainButton>

View File

@@ -1,21 +1,24 @@
export type ButtonType = "button" | "submit" | "reset" | undefined; export type ButtonType = "button" | "submit" | "reset" | undefined;
export default function PlainButton({ export default function PlainButton({
onClick, onClick,
className, className,
children, children,
type = "button", type = "button",
disabled
}: { }: {
onClick?: () => void; onClick?: () => void;
className?: string; className?: string;
children?: React.ReactNode; children?: React.ReactNode;
type?: ButtonType; type?: ButtonType;
disabled?: boolean;
}) { }) {
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className={`px-2 py-1 rounded shadow font-bold hover:cursor-pointer ${className}`} className={`px-2 py-1 rounded shadow font-bold hover:cursor-pointer ${className}`}
type={type} type={type}
disabled={disabled}
> >
{children} {children}
</button> </button>