This commit is contained in:
48
src/app/(features)/srt-player/UploadArea.tsx
Normal file
48
src/app/(features)/srt-player/UploadArea.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function UploadArea({
|
||||
setVideoUrl,
|
||||
setSrtUrl,
|
||||
}: {
|
||||
setVideoUrl: (url: string | null) => void;
|
||||
setSrtUrl: (url: string | null) => void;
|
||||
}) {
|
||||
const t = useTranslations("srt-player");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const uploadVideo = () => {
|
||||
const input = inputRef.current;
|
||||
if (input) {
|
||||
input.setAttribute("accept", "video/*");
|
||||
input.click();
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
setVideoUrl(URL.createObjectURL(file));
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
const uploadSRT = () => {
|
||||
const input = inputRef.current;
|
||||
if (input) {
|
||||
input.setAttribute("accept", ".srt");
|
||||
input.click();
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
setSrtUrl(URL.createObjectURL(file));
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2 m-2">
|
||||
<LightButton onClick={uploadVideo}>{t("uploadVideo")}</LightButton>
|
||||
<LightButton onClick={uploadSRT}>{t("uploadSubtitle")}</LightButton>
|
||||
<input type="file" className="hidden" ref={inputRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { inspect } from "@/lib/utils";
|
||||
|
||||
export default function SubtitleDisplay({ subtitle }: { subtitle: string }) {
|
||||
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
|
||||
let i = 0;
|
||||
return (
|
||||
<div className="w-full subtitle overflow-auto h-16 mt-2 break-words bg-black/50 font-sans text-white text-center text-2xl">
|
||||
{words.map((v) => (
|
||||
<span
|
||||
onClick={inspect(v)}
|
||||
key={i++}
|
||||
className="hover:bg-gray-700 hover:underline hover:cursor-pointer"
|
||||
>
|
||||
{v + " "}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
src/app/(features)/srt-player/VideoPlayer/VideoPanel.tsx
Normal file
216
src/app/(features)/srt-player/VideoPlayer/VideoPanel.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
|
||||
import SubtitleDisplay from "./SubtitleDisplay";
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type VideoPanelProps = {
|
||||
videoUrl: string | null;
|
||||
srtUrl: string | null;
|
||||
};
|
||||
|
||||
const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
|
||||
({ videoUrl, srtUrl }, videoRef) => {
|
||||
const t = useTranslations("srt-player");
|
||||
videoRef = videoRef as React.RefObject<HTMLVideoElement>;
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
const [srtLength, setSrtLength] = useState<number>(0);
|
||||
const [progress, setProgress] = useState<number>(-1);
|
||||
const [autoPause, setAutoPause] = useState<boolean>(true);
|
||||
const [spanText, setSpanText] = useState<string>("");
|
||||
const [subtitle, setSubtitle] = useState<string>("");
|
||||
const parsedSrtRef = useRef<
|
||||
{ start: number; end: number; text: string }[] | null
|
||||
>(null);
|
||||
const rafldRef = useRef<number>(0);
|
||||
const ready = useRef({
|
||||
vid: false,
|
||||
sub: false,
|
||||
all: function () {
|
||||
return this.vid && this.sub;
|
||||
},
|
||||
});
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (!videoUrl) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (video.paused || video.currentTime === 0) {
|
||||
video.play();
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
setIsPlaying(!video.paused);
|
||||
}, [videoRef, videoUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDownEvent = (e: globalThis.KeyboardEvent) => {
|
||||
if (e.key === "n") {
|
||||
next();
|
||||
} else if (e.key === "p") {
|
||||
previous();
|
||||
} else if (e.key === " ") {
|
||||
togglePlayPause();
|
||||
} else if (e.key === "r") {
|
||||
restart();
|
||||
} else if (e.key === "a") {
|
||||
handleAutoPauseToggle();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDownEvent);
|
||||
return () => document.removeEventListener("keydown", handleKeyDownEvent);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const cb = () => {
|
||||
if (ready.current.all()) {
|
||||
if (!parsedSrtRef.current) {
|
||||
} else if (isPlaying) {
|
||||
// 这里负责显示当前时间的字幕与自动暂停
|
||||
const srt = parsedSrtRef.current;
|
||||
const ct = videoRef.current?.currentTime as number;
|
||||
const index = getIndex(srt, ct);
|
||||
if (index !== null) {
|
||||
setSubtitle(srt[index].text);
|
||||
if (
|
||||
autoPause &&
|
||||
ct >= srt[index].end - 0.05 &&
|
||||
ct < srt[index].end
|
||||
) {
|
||||
videoRef.current!.currentTime = srt[index].start;
|
||||
togglePlayPause();
|
||||
}
|
||||
} else {
|
||||
setSubtitle("");
|
||||
}
|
||||
} else {
|
||||
}
|
||||
}
|
||||
rafldRef.current = requestAnimationFrame(cb);
|
||||
};
|
||||
rafldRef.current = requestAnimationFrame(cb);
|
||||
return () => {
|
||||
cancelAnimationFrame(rafldRef.current);
|
||||
};
|
||||
}, [autoPause, isPlaying, togglePlayPause, videoRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoUrl && videoRef.current) {
|
||||
videoRef.current.src = videoUrl;
|
||||
videoRef.current.load();
|
||||
setIsPlaying(false);
|
||||
ready.current["vid"] = true;
|
||||
}
|
||||
}, [videoRef, videoUrl]);
|
||||
useEffect(() => {
|
||||
if (srtUrl) {
|
||||
fetch(srtUrl)
|
||||
.then((response) => response.text())
|
||||
.then((data) => {
|
||||
parsedSrtRef.current = parseSrt(data);
|
||||
setSrtLength(parsedSrtRef.current.length);
|
||||
ready.current["sub"] = true;
|
||||
});
|
||||
}
|
||||
}, [srtUrl]);
|
||||
|
||||
const timeUpdate = () => {
|
||||
if (!parsedSrtRef.current || !videoRef.current) return;
|
||||
const index = getIndex(
|
||||
parsedSrtRef.current,
|
||||
videoRef.current.currentTime,
|
||||
);
|
||||
if (!index) return;
|
||||
setSpanText(`${index + 1}/${parsedSrtRef.current.length}`);
|
||||
};
|
||||
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (videoRef.current && parsedSrtRef.current) {
|
||||
const newProgress = parseInt(e.target.value);
|
||||
videoRef.current.currentTime =
|
||||
parsedSrtRef.current[newProgress]?.start || 0;
|
||||
setProgress(newProgress);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoPauseToggle = () => {
|
||||
setAutoPause(!autoPause);
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (!parsedSrtRef.current || !videoRef.current) return;
|
||||
const i = getNearistIndex(
|
||||
parsedSrtRef.current,
|
||||
videoRef.current.currentTime,
|
||||
);
|
||||
if (i != null && i + 1 < parsedSrtRef.current.length) {
|
||||
videoRef.current.currentTime = parsedSrtRef.current[i + 1].start;
|
||||
videoRef.current.play();
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
const previous = () => {
|
||||
if (!parsedSrtRef.current || !videoRef.current) return;
|
||||
const i = getNearistIndex(
|
||||
parsedSrtRef.current,
|
||||
videoRef.current.currentTime,
|
||||
);
|
||||
if (i != null && i - 1 >= 0) {
|
||||
videoRef.current.currentTime = parsedSrtRef.current[i - 1].start;
|
||||
videoRef.current.play();
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
const restart = () => {
|
||||
if (!parsedSrtRef.current || !videoRef.current) return;
|
||||
const i = getNearistIndex(
|
||||
parsedSrtRef.current,
|
||||
videoRef.current.currentTime,
|
||||
);
|
||||
if (i != null && i >= 0) {
|
||||
videoRef.current.currentTime = parsedSrtRef.current[i].start;
|
||||
videoRef.current.play();
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col">
|
||||
<video
|
||||
className="bg-gray-200"
|
||||
ref={videoRef}
|
||||
onTimeUpdate={timeUpdate}
|
||||
></video>
|
||||
<SubtitleDisplay subtitle={subtitle}></SubtitleDisplay>
|
||||
<div className="buttons flex mt-2 gap-2 flex-wrap">
|
||||
<LightButton onClick={togglePlayPause}>
|
||||
{isPlaying ? t("pause") : t("play")}
|
||||
</LightButton>
|
||||
<LightButton onClick={previous}>{t("previous")}</LightButton>
|
||||
<LightButton onClick={next}>{t("next")}</LightButton>
|
||||
<LightButton onClick={restart}>{t("restart")}</LightButton>
|
||||
<LightButton onClick={handleAutoPauseToggle}>
|
||||
{t("autoPause", { enabled: autoPause ? "Yes" : "No" })}
|
||||
</LightButton>
|
||||
</div>
|
||||
<input
|
||||
className="seekbar"
|
||||
type="range"
|
||||
min={0}
|
||||
max={srtLength}
|
||||
onChange={handleSeek}
|
||||
step={1}
|
||||
value={progress}
|
||||
></input>
|
||||
<span>{spanText}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
VideoPanel.displayName = "VideoPanel";
|
||||
|
||||
export default VideoPanel;
|
||||
25
src/app/(features)/srt-player/page.tsx
Normal file
25
src/app/(features)/srt-player/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { KeyboardEvent, useRef, useState } from "react";
|
||||
import UploadArea from "./UploadArea";
|
||||
import VideoPanel from "./VideoPlayer/VideoPanel";
|
||||
|
||||
export default function SrtPlayerPage() {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
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>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
src/app/(features)/srt-player/subtitle.ts
Normal file
74
src/app/(features)/srt-player/subtitle.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export function parseSrt(data: string) {
|
||||
const lines = data.split(/\r?\n/);
|
||||
const result = [];
|
||||
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() });
|
||||
i++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getNearistIndex(
|
||||
srt: { start: number; end: number; text: string }[],
|
||||
ct: number,
|
||||
) {
|
||||
for (let i = 0; i < srt.length; i++) {
|
||||
const s = srt[i];
|
||||
const l = ct - s.start >= 0;
|
||||
const r = ct - s.end >= 0;
|
||||
if (!(l || r)) return i - 1;
|
||||
if (l && !r) return i;
|
||||
}
|
||||
}
|
||||
|
||||
export function getIndex(
|
||||
srt: { start: number; end: number; text: string }[],
|
||||
ct: number,
|
||||
) {
|
||||
for (let i = 0; i < srt.length; i++) {
|
||||
if (ct >= srt[i].start && ct <= srt[i].end) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getSubtitle(
|
||||
srt: { start: number; end: number; text: string }[],
|
||||
currentTime: number,
|
||||
) {
|
||||
return (
|
||||
srt.find((sub) => currentTime >= sub.start && currentTime <= sub.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),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user