This commit is contained in:
102
src/app/(features)/alphabet/MemoryCard.tsx
Normal file
102
src/app/(features)/alphabet/MemoryCard.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import IconClick from "@/components/IconClick";
|
||||
import IMAGES from "@/config/images";
|
||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||
import {
|
||||
Dispatch,
|
||||
KeyboardEvent,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function MemoryCard({
|
||||
alphabet,
|
||||
setChosenAlphabet,
|
||||
}: {
|
||||
alphabet: Letter[];
|
||||
setChosenAlphabet: Dispatch<SetStateAction<SupportedAlphabets | null>>;
|
||||
}) {
|
||||
const t = useTranslations("alphabet");
|
||||
const [index, setIndex] = useState(
|
||||
Math.floor(Math.random() * alphabet.length),
|
||||
);
|
||||
const [more, setMore] = useState(false);
|
||||
const [ipaDisplay, setIPADisplay] = useState(true);
|
||||
const [letterDisplay, setLetterDisplay] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeydown = (e: globalThis.KeyboardEvent) => {
|
||||
if (e.key === " ") refresh();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
return () => document.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
const letter = alphabet[index];
|
||||
const refresh = () => {
|
||||
setIndex(Math.floor(Math.random() * alphabet.length));
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className="w-full flex justify-center items-center"
|
||||
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}
|
||||
>
|
||||
<div className="m-4 p-4 w-full md:w-[60dvw] flex-col rounded-2xl shadow border-gray-200 border flex justify-center items-center">
|
||||
<div className="w-full flex justify-end items-center">
|
||||
<IconClick
|
||||
size={32}
|
||||
alt="close"
|
||||
src={IMAGES.close}
|
||||
onClick={() => setChosenAlphabet(null)}
|
||||
></IconClick>
|
||||
</div>
|
||||
<div className="flex flex-col gap-12 justify-center items-center">
|
||||
<span className="text-7xl md:text-9xl">
|
||||
{letterDisplay ? letter.letter : ""}
|
||||
</span>
|
||||
<span className="text-5xl md:text-7xl text-gray-400">
|
||||
{ipaDisplay ? letter.letter_sound_ipa : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-row mt-32 items-center justify-center gap-2">
|
||||
<IconClick
|
||||
size={48}
|
||||
alt="refresh"
|
||||
src={IMAGES.refresh}
|
||||
onClick={refresh}
|
||||
></IconClick>
|
||||
<IconClick
|
||||
size={48}
|
||||
alt="more"
|
||||
src={IMAGES.more_horiz}
|
||||
onClick={() => setMore(!more)}
|
||||
></IconClick>
|
||||
{more ? (
|
||||
<>
|
||||
<LightButton
|
||||
className="w-20"
|
||||
onClick={() => {
|
||||
setLetterDisplay(!letterDisplay);
|
||||
}}
|
||||
>
|
||||
{letterDisplay ? t("hideLetter") : t("showLetter")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
className="w-20"
|
||||
onClick={() => {
|
||||
setIPADisplay(!ipaDisplay);
|
||||
}}
|
||||
>
|
||||
{ipaDisplay ? t("hideIPA") : t("showIPA")}
|
||||
</LightButton>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
src/app/(features)/alphabet/page.tsx
Normal file
97
src/app/(features)/alphabet/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||
import { useEffect, useState } from "react";
|
||||
import MemoryCard from "./MemoryCard";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Alphabet() {
|
||||
const t = useTranslations("alphabet");
|
||||
const [chosenAlphabet, setChosenAlphabet] =
|
||||
useState<SupportedAlphabets | null>(null);
|
||||
const [alphabetData, setAlphabetData] = useState<
|
||||
Record<SupportedAlphabets, Letter[] | null>
|
||||
>({
|
||||
japanese: null,
|
||||
english: null,
|
||||
esperanto: null,
|
||||
uyghur: null,
|
||||
});
|
||||
const [loadingState, setLoadingState] = useState<
|
||||
"idle" | "loading" | "success" | "error"
|
||||
>("idle");
|
||||
|
||||
useEffect(() => {
|
||||
if (chosenAlphabet && !alphabetData[chosenAlphabet]) {
|
||||
setLoadingState("loading");
|
||||
|
||||
fetch("/alphabets/" + chosenAlphabet + ".json")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Network response was not ok");
|
||||
return res.json();
|
||||
})
|
||||
.then((obj) => {
|
||||
setAlphabetData((prev) => ({
|
||||
...prev,
|
||||
[chosenAlphabet]: obj as Letter[],
|
||||
}));
|
||||
setLoadingState("success");
|
||||
})
|
||||
.catch(() => {
|
||||
setLoadingState("error");
|
||||
});
|
||||
}
|
||||
}, [chosenAlphabet, alphabetData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadingState === "error") {
|
||||
const timer = setTimeout(() => {
|
||||
setLoadingState("idle");
|
||||
setChosenAlphabet(null);
|
||||
}, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [loadingState]);
|
||||
|
||||
if (!chosenAlphabet)
|
||||
return (
|
||||
<>
|
||||
<div className="border border-gray-200 m-4 mt-4 flex flex-col justify-center items-center p-4 rounded-2xl gap-2">
|
||||
<span className="text-2xl md:text-3xl">{t("chooseCharacters")}</span>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<LightButton onClick={() => setChosenAlphabet("japanese")}>
|
||||
{t("japanese")}
|
||||
</LightButton>
|
||||
<LightButton onClick={() => setChosenAlphabet("english")}>
|
||||
{t("english")}
|
||||
</LightButton>
|
||||
<LightButton onClick={() => setChosenAlphabet("uyghur")}>
|
||||
{t("uyghur")}
|
||||
</LightButton>
|
||||
<LightButton onClick={() => setChosenAlphabet("esperanto")}>
|
||||
{t("esperanto")}
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
if (loadingState === "loading") {
|
||||
return t("loading");
|
||||
}
|
||||
if (loadingState === "error") {
|
||||
return t("loadFailed");
|
||||
}
|
||||
if (loadingState === "success" && alphabetData[chosenAlphabet]) {
|
||||
return (
|
||||
<>
|
||||
<MemoryCard
|
||||
alphabet={alphabetData[chosenAlphabet]}
|
||||
setChosenAlphabet={setChosenAlphabet}
|
||||
></MemoryCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
53
src/app/(features)/memorize/FolderSelector.tsx
Normal file
53
src/app/(features)/memorize/FolderSelector.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/cards/Container";
|
||||
import { folder } from "../../../../generated/prisma/client";
|
||||
import { Folder } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Center } from "@/components/Center";
|
||||
|
||||
interface FolderSelectorProps {
|
||||
folders: (folder & { total_pairs: number })[];
|
||||
}
|
||||
|
||||
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Center>
|
||||
<Container className="p-6 gap-4 flex flex-col">
|
||||
{(folders.length === 0 && (
|
||||
<h1 className="text-2xl text-gray-900 font-light">
|
||||
No folders found.
|
||||
</h1>
|
||||
)) || (
|
||||
<>
|
||||
<h1 className="text-2xl text-gray-900 font-light">
|
||||
Select a folder:
|
||||
</h1>
|
||||
<div className="text-gray-900 border border-gray-200 rounded-2xl max-h-96 overflow-y-auto">
|
||||
{folders.map((folder) => (
|
||||
<div
|
||||
key={folder.id}
|
||||
onClick={() =>
|
||||
router.push(`/memorize?folder_id=${folder.id}`)
|
||||
}
|
||||
className="flex flex-row justify-center items-center group p-2 gap-2 hover:cursor-pointer hover:bg-gray-50"
|
||||
>
|
||||
<Folder />
|
||||
<div className="flex-1 flex gap-2">
|
||||
<span className="group-hover:text-blue-500">
|
||||
{folder.name}
|
||||
</span>
|
||||
<span>({folder.total_pairs})</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
export default FolderSelector;
|
||||
115
src/app/(features)/memorize/Memorize.tsx
Normal file
115
src/app/(features)/memorize/Memorize.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { Center } from "@/components/Center";
|
||||
import { text_pair } from "../../../../generated/prisma/browser";
|
||||
import Container from "@/components/cards/Container";
|
||||
import { useState } from "react";
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import { getTTSAudioUrl } from "@/lib/tts";
|
||||
import { VOICES } from "@/config/locales";
|
||||
|
||||
interface MemorizeProps {
|
||||
textPairs: text_pair[];
|
||||
}
|
||||
|
||||
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
||||
const [reverse, setReverse] = useState(false);
|
||||
const [dictation, setDictation] = useState(false);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [show, setShow] = useState<"question" | "answer">("question");
|
||||
const { load, play } = useAudioPlayer();
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<Container className="p-6 flex flex-col gap-8 h-96 justify-center items-center">
|
||||
{(textPairs.length > 0 && (
|
||||
<>
|
||||
<div className="h-36 flex flex-col gap-2 justify-start items-center font-serif text-3xl">
|
||||
<div className="text-sm text-gray-500">
|
||||
{index + 1}/{textPairs.length}
|
||||
</div>
|
||||
{dictation ? (
|
||||
show === "question" ? (
|
||||
""
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
{reverse
|
||||
? textPairs[index].text2
|
||||
: textPairs[index].text1}
|
||||
</div>
|
||||
<div>
|
||||
{reverse
|
||||
? textPairs[index].text1
|
||||
: textPairs[index].text2}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
{reverse ? textPairs[index].text2 : textPairs[index].text1}
|
||||
</div>
|
||||
<div>
|
||||
{show === "answer"
|
||||
? reverse
|
||||
? textPairs[index].text1
|
||||
: textPairs[index].text2
|
||||
: ""}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 items-center justify-center">
|
||||
<LightButton
|
||||
className="w-32"
|
||||
onClick={async () => {
|
||||
if (show === "answer") {
|
||||
const newIndex = (index + 1) % textPairs.length;
|
||||
setIndex(newIndex);
|
||||
if (dictation)
|
||||
getTTSAudioUrl(
|
||||
textPairs[newIndex][reverse ? "text2" : "text1"],
|
||||
VOICES.find(
|
||||
(v) =>
|
||||
v.locale ===
|
||||
textPairs[newIndex][
|
||||
reverse ? "locale2" : "locale1"
|
||||
],
|
||||
)!.short_name,
|
||||
).then((url) => {
|
||||
load(url);
|
||||
play();
|
||||
});
|
||||
}
|
||||
setShow(show === "question" ? "answer" : "question");
|
||||
}}
|
||||
>
|
||||
{show === "question" ? "Show Answer" : "Next"}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
onClick={() => {
|
||||
setReverse(!reverse);
|
||||
}}
|
||||
selected={reverse}
|
||||
>
|
||||
Reverse
|
||||
</LightButton>
|
||||
<LightButton
|
||||
onClick={() => {
|
||||
setDictation(!dictation);
|
||||
}}
|
||||
selected={dictation}
|
||||
>
|
||||
Dictation
|
||||
</LightButton>
|
||||
</div>
|
||||
</>
|
||||
)) || <p>No text pairs available</p>}
|
||||
</Container>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
export default Memorize;
|
||||
45
src/app/(features)/memorize/page.tsx
Normal file
45
src/app/(features)/memorize/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import {
|
||||
getFoldersByOwner,
|
||||
getFoldersWithTotalPairsByOwner,
|
||||
getOwnerByFolderId,
|
||||
} from "@/lib/services/folderService";
|
||||
import { isNonNegativeInteger } from "@/lib/utils";
|
||||
import FolderSelector from "./FolderSelector";
|
||||
import Memorize from "./Memorize";
|
||||
import { getTextPairsByFolderId } from "@/lib/services/textPairService";
|
||||
|
||||
export default async function MemorizePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ folder_id?: string }>;
|
||||
}) {
|
||||
const session = await getServerSession();
|
||||
const username = session?.user?.name;
|
||||
|
||||
const t = (await searchParams).folder_id;
|
||||
const folder_id = t ? (isNonNegativeInteger(t) ? parseInt(t) : null) : null;
|
||||
|
||||
if (!username)
|
||||
redirect(
|
||||
`/login?redirect=/memorize${folder_id ? `?folder_id=${folder_id}` : ""}`,
|
||||
);
|
||||
|
||||
if (!folder_id) {
|
||||
return (
|
||||
<FolderSelector
|
||||
folders={await getFoldersWithTotalPairsByOwner(username)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const owner = await getOwnerByFolderId(folder_id);
|
||||
if (owner !== username) {
|
||||
return <p>无权访问该文件夹</p>;
|
||||
}
|
||||
|
||||
return <Memorize textPairs={await getTextPairsByFolderId(folder_id)} />;
|
||||
}
|
||||
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),
|
||||
);
|
||||
}
|
||||
116
src/app/(features)/text-speaker/SaveList.tsx
Normal file
116
src/app/(features)/text-speaker/SaveList.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { getLocalStorageOperator } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import z from "zod";
|
||||
import {
|
||||
TextSpeakerArraySchema,
|
||||
TextSpeakerItemSchema,
|
||||
} from "@/lib/interfaces";
|
||||
import IconClick from "@/components/IconClick";
|
||||
import IMAGES from "@/config/images";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface TextCardProps {
|
||||
item: z.infer<typeof TextSpeakerItemSchema>;
|
||||
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
|
||||
handleDel: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
|
||||
}
|
||||
function TextCard({ item, handleUse, handleDel }: TextCardProps) {
|
||||
const onUseClick = () => {
|
||||
handleUse(item);
|
||||
};
|
||||
const onDelClick = () => {
|
||||
handleDel(item);
|
||||
};
|
||||
return (
|
||||
<div className="p-2 border-b border-gray-200 rounded-2xl bg-gray-100 m-2 grid grid-cols-8">
|
||||
<div className="col-span-7" onClick={onUseClick}>
|
||||
<div className="max-h-26 hover:cursor-pointer text-3xl overflow-y-auto">
|
||||
{item.text}
|
||||
</div>
|
||||
<div className="max-h-16 overflow-y-auto text-xl text-gray-600 whitespace-nowrap overflow-x-auto">
|
||||
{item.ipa}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center items-center border-gray-300 border-l-2 m-2">
|
||||
<IconClick
|
||||
src={IMAGES.delete}
|
||||
alt="delete"
|
||||
onClick={onDelClick}
|
||||
className="place-self-center"
|
||||
size={42}
|
||||
></IconClick>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SaveListProps {
|
||||
show?: boolean;
|
||||
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
|
||||
}
|
||||
export default function SaveList({ show = false, handleUse }: SaveListProps) {
|
||||
const t = useTranslations("text-speaker");
|
||||
const { get: getFromLocalStorage, set: setIntoLocalStorage } =
|
||||
getLocalStorageOperator<typeof TextSpeakerArraySchema>(
|
||||
"text-speaker",
|
||||
TextSpeakerArraySchema,
|
||||
);
|
||||
const [data, setData] = useState(getFromLocalStorage());
|
||||
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
||||
const current_data = getFromLocalStorage();
|
||||
|
||||
current_data.splice(
|
||||
current_data.findIndex((v) => v.text === item.text),
|
||||
1,
|
||||
);
|
||||
setIntoLocalStorage(current_data);
|
||||
refresh();
|
||||
};
|
||||
const refresh = () => {
|
||||
setData(getFromLocalStorage());
|
||||
};
|
||||
const handleDeleteAll = () => {
|
||||
const yesorno = prompt(t("confirmDeleteAll"))?.trim();
|
||||
if (yesorno && (yesorno === "Y" || yesorno === "y")) {
|
||||
setIntoLocalStorage([]);
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
if (show)
|
||||
return (
|
||||
<div
|
||||
className="my-4 p-2 mx-4 md:mx-32 border border-gray-200 rounded-2xl"
|
||||
style={{ fontFamily: "Times New Roman, serif" }}
|
||||
>
|
||||
<div className="flex flex-row justify-center gap-8 items-center">
|
||||
<IconClick
|
||||
src={IMAGES.refresh}
|
||||
alt="refresh"
|
||||
onClick={refresh}
|
||||
size={48}
|
||||
className=""
|
||||
></IconClick>
|
||||
<IconClick
|
||||
src={IMAGES.delete}
|
||||
alt="delete"
|
||||
onClick={handleDeleteAll}
|
||||
size={48}
|
||||
className=""
|
||||
></IconClick>
|
||||
</div>
|
||||
<ul>
|
||||
{data.map((v) => (
|
||||
<TextCard
|
||||
item={v}
|
||||
key={crypto.randomUUID()}
|
||||
handleUse={handleUse}
|
||||
handleDel={handleDel}
|
||||
></TextCard>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
else return <></>;
|
||||
}
|
||||
341
src/app/(features)/text-speaker/page.tsx
Normal file
341
src/app/(features)/text-speaker/page.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
"use client";
|
||||
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import IconClick from "@/components/IconClick";
|
||||
import IMAGES from "@/config/images";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import { TextSpeakerArraySchema, TextSpeakerItemSchema } from "@/lib/interfaces";
|
||||
import { getLocalStorageOperator, getTTSAudioUrl } from "@/lib/utils";
|
||||
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
||||
import z from "zod";
|
||||
import SaveList from "./SaveList";
|
||||
|
||||
import { VOICES } from "@/config/locales";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function TextSpeakerPage() {
|
||||
const t = useTranslations("text-speaker");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [showSpeedAdjust, setShowSpeedAdjust] = useState(false);
|
||||
const [showSaveList, setShowSaveList] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [ipaEnabled, setIPAEnabled] = useState(false);
|
||||
const [speed, setSpeed] = useState(1);
|
||||
const [pause, setPause] = useState(true);
|
||||
const [autopause, setAutopause] = useState(true);
|
||||
const textRef = useRef("");
|
||||
const [locale, setLocale] = useState<string | null>(null);
|
||||
const [ipa, setIPA] = useState<string>("");
|
||||
const objurlRef = useRef<string | null>(null);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const { play, stop, load, audioRef } = useAudioPlayer();
|
||||
|
||||
const { get: getFromLocalStorage, set: setIntoLocalStorage } = getLocalStorageOperator<
|
||||
typeof TextSpeakerArraySchema
|
||||
>("text-speaker", TextSpeakerArraySchema);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const handleEnded = () => {
|
||||
if (autopause) {
|
||||
setPause(true);
|
||||
} else {
|
||||
load(objurlRef.current!);
|
||||
play();
|
||||
}
|
||||
};
|
||||
audio.addEventListener("ended", handleEnded);
|
||||
return () => {
|
||||
audio.removeEventListener("ended", handleEnded);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [audioRef, autopause]);
|
||||
|
||||
const speak = async () => {
|
||||
if (processing) return;
|
||||
setProcessing(true);
|
||||
|
||||
if (ipa.length === 0 && ipaEnabled && textRef.current.length !== 0) {
|
||||
const params = new URLSearchParams({
|
||||
text: textRef.current,
|
||||
});
|
||||
fetch(`/api/ipa?${params}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setIPA(data.ipa);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setIPA("");
|
||||
});
|
||||
}
|
||||
|
||||
if (pause) {
|
||||
// 如果没在读
|
||||
if (textRef.current.length === 0) {
|
||||
// 没文本咋读
|
||||
} else {
|
||||
setPause(false);
|
||||
|
||||
if (objurlRef.current) {
|
||||
// 之前有播放
|
||||
load(objurlRef.current);
|
||||
play();
|
||||
} else {
|
||||
// 第一次播放
|
||||
try {
|
||||
let theLocale = locale;
|
||||
if (!theLocale) {
|
||||
console.log("downloading text info");
|
||||
const params = new URLSearchParams({
|
||||
text: textRef.current.slice(0, 30),
|
||||
});
|
||||
const textinfo = await (
|
||||
await fetch(`/api/locale?${params}`)
|
||||
).json();
|
||||
setLocale(textinfo.locale);
|
||||
theLocale = textinfo.locale as string;
|
||||
}
|
||||
|
||||
const voice = VOICES.find((v) => v.locale.startsWith(theLocale));
|
||||
if (!voice) throw "Voice not found.";
|
||||
|
||||
objurlRef.current = await getTTSAudioUrl(
|
||||
textRef.current,
|
||||
voice.short_name,
|
||||
(() => {
|
||||
if (speed === 1) return {};
|
||||
else if (speed < 1)
|
||||
return {
|
||||
rate: `-${100 - speed * 100}%`,
|
||||
};
|
||||
else
|
||||
return {
|
||||
rate: `+${speed * 100 - 100}%`,
|
||||
};
|
||||
})(),
|
||||
);
|
||||
load(objurlRef.current);
|
||||
play();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
setPause(true);
|
||||
setLocale(null);
|
||||
|
||||
setProcessing(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 如果在读就暂停
|
||||
setPause(true);
|
||||
stop();
|
||||
}
|
||||
|
||||
setProcessing(false);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
textRef.current = e.target.value.trim();
|
||||
setLocale(null);
|
||||
setIPA("");
|
||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||
objurlRef.current = null;
|
||||
stop();
|
||||
setPause(true);
|
||||
};
|
||||
|
||||
const letMeSetSpeed = (new_speed: number) => {
|
||||
return () => {
|
||||
setSpeed(new_speed);
|
||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||
objurlRef.current = null;
|
||||
stop();
|
||||
setPause(true);
|
||||
};
|
||||
};
|
||||
|
||||
const handleUseItem = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
||||
if (textareaRef.current) textareaRef.current.value = item.text;
|
||||
textRef.current = item.text;
|
||||
setLocale(item.locale);
|
||||
setIPA(item.ipa || "");
|
||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||
objurlRef.current = null;
|
||||
stop();
|
||||
setPause(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (saving) return;
|
||||
if (textRef.current.length === 0) return;
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
let theLocale = locale;
|
||||
if (!theLocale) {
|
||||
console.log("downloading text info");
|
||||
const params = new URLSearchParams({
|
||||
text: textRef.current.slice(0, 30),
|
||||
});
|
||||
const textinfo = await (await fetch(`/api/locale?${params}`)).json();
|
||||
setLocale(textinfo.locale);
|
||||
theLocale = textinfo.locale as string;
|
||||
}
|
||||
|
||||
let theIPA = ipa;
|
||||
if (ipa.length === 0 && ipaEnabled) {
|
||||
const params = new URLSearchParams({
|
||||
text: textRef.current,
|
||||
});
|
||||
const tmp = await (await fetch(`/api/ipa?${params}`)).json();
|
||||
setIPA(tmp.ipa);
|
||||
theIPA = tmp.ipa;
|
||||
}
|
||||
|
||||
const save = getFromLocalStorage();
|
||||
const oldIndex = save.findIndex((v) => v.text === textRef.current);
|
||||
if (oldIndex !== -1) {
|
||||
const oldItem = save[oldIndex];
|
||||
if (theIPA) {
|
||||
if (!oldItem.ipa || oldItem.ipa !== theIPA) {
|
||||
oldItem.ipa = theIPA;
|
||||
setIntoLocalStorage(save);
|
||||
}
|
||||
}
|
||||
} else if (theIPA.length === 0) {
|
||||
save.push({
|
||||
text: textRef.current,
|
||||
locale: theLocale,
|
||||
});
|
||||
} else {
|
||||
save.push({
|
||||
text: textRef.current,
|
||||
locale: theLocale,
|
||||
ipa: theIPA,
|
||||
});
|
||||
}
|
||||
setIntoLocalStorage(save);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setLocale(null);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="my-4 p-4 mx-4 md:mx-32 border border-gray-200 rounded-2xl"
|
||||
style={{ fontFamily: "Times New Roman, serif" }}
|
||||
>
|
||||
<textarea
|
||||
className="text-2xl resize-none focus:outline-0 min-h-64 w-full border-gray-200 border-b"
|
||||
onChange={handleInputChange}
|
||||
ref={textareaRef}
|
||||
></textarea>
|
||||
{(ipa.length !== 0 && (
|
||||
<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b">
|
||||
{ipa}
|
||||
</div>
|
||||
)) || <div className="h-18"></div>}
|
||||
<div className="mt-8 relative w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
||||
{showSpeedAdjust && (
|
||||
<div className="bg-white p-6 rounded-2xl border-gray-200 border-2 shadow-2xl absolute left-1/2 -translate-x-1/2 -translate-y-full -top-4 flex flex-row flex-wrap gap-2 justify-center items-center">
|
||||
<IconClick
|
||||
size={45}
|
||||
onClick={letMeSetSpeed(0.5)}
|
||||
src={IMAGES.speed_0_5x}
|
||||
alt="0.5x"
|
||||
className={speed === 0.5 ? "bg-gray-200" : ""}
|
||||
></IconClick>
|
||||
<IconClick
|
||||
size={45}
|
||||
onClick={letMeSetSpeed(0.7)}
|
||||
src={IMAGES.speed_0_7x}
|
||||
alt="0.7x"
|
||||
className={speed === 0.7 ? "bg-gray-200" : ""}
|
||||
></IconClick>
|
||||
<IconClick
|
||||
size={45}
|
||||
onClick={letMeSetSpeed(1)}
|
||||
src={IMAGES.speed_1x}
|
||||
alt="1x"
|
||||
className={speed === 1 ? "bg-gray-200" : ""}
|
||||
></IconClick>
|
||||
<IconClick
|
||||
size={45}
|
||||
onClick={letMeSetSpeed(1.2)}
|
||||
src={IMAGES.speed_1_2_x}
|
||||
alt="1.2x"
|
||||
className={speed === 1.2 ? "bg-gray-200" : ""}
|
||||
></IconClick>
|
||||
<IconClick
|
||||
size={45}
|
||||
onClick={letMeSetSpeed(1.5)}
|
||||
src={IMAGES.speed_1_5x}
|
||||
alt="1.5x"
|
||||
className={speed === 1.5 ? "bg-gray-200" : ""}
|
||||
></IconClick>
|
||||
</div>
|
||||
)}
|
||||
<IconClick
|
||||
size={45}
|
||||
onClick={speak}
|
||||
src={pause ? IMAGES.play_arrow : IMAGES.pause}
|
||||
alt="playorpause"
|
||||
className={`${processing ? "bg-gray-200" : ""}`}
|
||||
></IconClick>
|
||||
<IconClick
|
||||
size={45}
|
||||
onClick={() => {
|
||||
setAutopause(!autopause);
|
||||
if (objurlRef) {
|
||||
stop();
|
||||
}
|
||||
setPause(true);
|
||||
}}
|
||||
src={autopause ? IMAGES.autoplay : IMAGES.autopause}
|
||||
alt="autoplayorpause"
|
||||
></IconClick>
|
||||
<IconClick
|
||||
size={45}
|
||||
onClick={() => setShowSpeedAdjust(!showSpeedAdjust)}
|
||||
src={IMAGES.speed}
|
||||
alt="speed"
|
||||
className={`${showSpeedAdjust ? "bg-gray-200" : ""}`}
|
||||
></IconClick>
|
||||
<IconClick
|
||||
size={45}
|
||||
onClick={save}
|
||||
src={IMAGES.save}
|
||||
alt="save"
|
||||
className={`${saving ? "bg-gray-200" : ""}`}
|
||||
></IconClick>
|
||||
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
||||
<LightButton
|
||||
selected={ipaEnabled}
|
||||
onClick={() => setIPAEnabled(!ipaEnabled)}
|
||||
>
|
||||
{t("generateIPA")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
onClick={() => {
|
||||
setShowSaveList(!showSaveList);
|
||||
}}
|
||||
selected={showSaveList}
|
||||
>
|
||||
{t("viewSavedItems")}
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList>
|
||||
</>
|
||||
);
|
||||
}
|
||||
272
src/app/(features)/translator/page.tsx
Normal file
272
src/app/(features)/translator/page.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
"use client";
|
||||
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import Container from "@/components/cards/Container";
|
||||
import IconClick from "@/components/IconClick";
|
||||
import IMAGES from "@/config/images";
|
||||
import { VOICES } from "@/config/locales";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import { TranslationHistorySchema } from "@/lib/interfaces";
|
||||
import { tlsoPush, tlso } from "@/lib/localStorageOperators";
|
||||
import { getTTSAudioUrl } from "@/lib/tts";
|
||||
import { letsFetch } from "@/lib/utils";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRef, useState } from "react";
|
||||
import z from "zod";
|
||||
|
||||
export default function TranslatorPage() {
|
||||
const t = useTranslations("translator");
|
||||
|
||||
const taref = useRef<HTMLTextAreaElement>(null);
|
||||
const [lang, setLang] = useState<string>("chinese");
|
||||
const [tresult, setTresult] = useState<string>("");
|
||||
const [genIpa, setGenIpa] = useState(true);
|
||||
const [ipaTexts, setIpaTexts] = useState(["", ""]);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const { load, play } = useAudioPlayer();
|
||||
const [history, setHistory] = useState<
|
||||
z.infer<typeof TranslationHistorySchema>[]
|
||||
>(tlso.get());
|
||||
|
||||
const lastTTS = useRef({
|
||||
text: "",
|
||||
url: "",
|
||||
});
|
||||
|
||||
const tts = async (text: string, locale: string) => {
|
||||
if (lastTTS.current.text !== text) {
|
||||
const url = await getTTSAudioUrl(
|
||||
text,
|
||||
VOICES.find((v) => v.locale === locale)!.short_name,
|
||||
);
|
||||
await load(url);
|
||||
lastTTS.current.text = text;
|
||||
lastTTS.current.url = url;
|
||||
}
|
||||
play();
|
||||
};
|
||||
|
||||
const translate = async () => {
|
||||
if (processing) return;
|
||||
setProcessing(true);
|
||||
|
||||
if (!taref.current) return;
|
||||
const text = taref.current.value;
|
||||
|
||||
const newItem: {
|
||||
text1: string | null;
|
||||
text2: string | null;
|
||||
locale1: string | null;
|
||||
locale2: string | null;
|
||||
} = {
|
||||
text1: text,
|
||||
text2: null,
|
||||
locale1: null,
|
||||
locale2: null,
|
||||
};
|
||||
|
||||
const checkUpdateLocalStorage = (item: typeof newItem) => {
|
||||
if (item.text1 && item.text2 && item.locale1 && item.locale2) {
|
||||
tlsoPush(item as z.infer<typeof TranslationHistorySchema>);
|
||||
setHistory(tlso.get());
|
||||
}
|
||||
};
|
||||
const innerStates = {
|
||||
text2: false,
|
||||
ipa1: !genIpa,
|
||||
ipa2: !genIpa,
|
||||
};
|
||||
const checkUpdateProcessStates = () => {
|
||||
if (innerStates.ipa1 && innerStates.ipa2 && innerStates.text2)
|
||||
setProcessing(false);
|
||||
};
|
||||
const updateState = (stateName: keyof typeof innerStates) => () => {
|
||||
innerStates[stateName] = true;
|
||||
checkUpdateLocalStorage(newItem);
|
||||
checkUpdateProcessStates();
|
||||
};
|
||||
|
||||
// Fetch locale for text1
|
||||
letsFetch(
|
||||
`/api/v1/locale?text=${encodeURIComponent(text)}`,
|
||||
(locale: string) => {
|
||||
newItem.locale1 = locale;
|
||||
},
|
||||
console.log,
|
||||
() => {},
|
||||
);
|
||||
|
||||
if (genIpa)
|
||||
// Fetch IPA for text1
|
||||
letsFetch(
|
||||
`/api/v1/ipa?text=${encodeURIComponent(text)}`,
|
||||
(ipa: string) => setIpaTexts((prev) => [ipa, prev[1]]),
|
||||
console.log,
|
||||
updateState("ipa1"),
|
||||
);
|
||||
// Fetch translation for text2
|
||||
letsFetch(
|
||||
`/api/v1/translate?text=${encodeURIComponent(text)}&lang=${encodeURIComponent(lang)}`,
|
||||
(text2) => {
|
||||
setTresult(text2);
|
||||
newItem.text2 = text2;
|
||||
if (genIpa)
|
||||
// Fetch IPA for text2
|
||||
letsFetch(
|
||||
`/api/v1/ipa?text=${encodeURIComponent(text2)}`,
|
||||
(ipa: string) => setIpaTexts((prev) => [prev[0], ipa]),
|
||||
console.log,
|
||||
updateState("ipa2"),
|
||||
);
|
||||
// Fetch locale for text2
|
||||
letsFetch(
|
||||
`/api/v1/locale?text=${encodeURIComponent(text2)}`,
|
||||
(locale: string) => {
|
||||
newItem.locale2 = locale;
|
||||
},
|
||||
console.log,
|
||||
() => {},
|
||||
);
|
||||
},
|
||||
console.log,
|
||||
updateState("text2"),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* TCard Component */}
|
||||
<div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2">
|
||||
{/* Card Component - Left Side */}
|
||||
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
||||
{/* ICard1 Component */}
|
||||
<div className="border border-gray-200 rounded-2xl w-full h-64 p-2">
|
||||
<textarea
|
||||
className="resize-none h-8/12 w-full focus:outline-0"
|
||||
ref={taref}
|
||||
onKeyDown={(e) => {
|
||||
if (e.ctrlKey && e.key === "Enter") translate();
|
||||
}}
|
||||
></textarea>
|
||||
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
||||
{ipaTexts[0]}
|
||||
</div>
|
||||
<div className="h-2/12 w-full flex justify-end items-center">
|
||||
<IconClick
|
||||
src={IMAGES.copy_all}
|
||||
alt="copy"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
taref.current?.value || "",
|
||||
);
|
||||
}}
|
||||
></IconClick>
|
||||
<IconClick
|
||||
src={IMAGES.play_arrow}
|
||||
alt="play"
|
||||
onClick={() => {
|
||||
const t = taref.current?.value;
|
||||
if (!t) return;
|
||||
tts(t, tlso.get().find((v) => v.text1 === t)?.locale1 || "");
|
||||
}}
|
||||
></IconClick>
|
||||
</div>
|
||||
</div>
|
||||
<div className="option1 w-full flex flex-row justify-between items-center">
|
||||
<span>{t("detectLanguage")}</span>
|
||||
<LightButton
|
||||
selected={genIpa}
|
||||
onClick={() => setGenIpa((prev) => !prev)}
|
||||
>
|
||||
{t("generateIPA")}
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Component - Right Side */}
|
||||
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
||||
{/* ICard2 Component */}
|
||||
<div className="bg-gray-100 rounded-2xl w-full h-64 p-2">
|
||||
<div className="h-8/12 w-full">{tresult}</div>
|
||||
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
||||
{ipaTexts[1]}
|
||||
</div>
|
||||
<div className="h-2/12 w-full flex justify-end items-center">
|
||||
<IconClick
|
||||
src={IMAGES.copy_all}
|
||||
alt="copy"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(tresult);
|
||||
}}
|
||||
></IconClick>
|
||||
<IconClick
|
||||
src={IMAGES.play_arrow}
|
||||
alt="play"
|
||||
onClick={() => {
|
||||
tts(
|
||||
tresult,
|
||||
tlso.get().find((v) => v.text2 === tresult)?.locale2 || "",
|
||||
);
|
||||
}}
|
||||
></IconClick>
|
||||
</div>
|
||||
</div>
|
||||
<div className="option2 w-full flex gap-1 items-center flex-wrap">
|
||||
<span>{t("translateInto")}</span>
|
||||
<LightButton
|
||||
selected={lang === "chinese"}
|
||||
onClick={() => setLang("chinese")}
|
||||
>
|
||||
{t("chinese")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
selected={lang === "english"}
|
||||
onClick={() => setLang("english")}
|
||||
>
|
||||
{t("english")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
selected={lang === "italian"}
|
||||
onClick={() => setLang("italian")}
|
||||
>
|
||||
{t("italian")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
selected={!["chinese", "english", "italian"].includes(lang)}
|
||||
onClick={() => {
|
||||
const newLang = prompt("Enter language");
|
||||
if (newLang) {
|
||||
setLang(newLang);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("other")}
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TranslateButton Component */}
|
||||
<div className="w-screen flex justify-center items-center">
|
||||
<button
|
||||
className={`duration-150 ease-in text-xl font-extrabold border rounded-4xl p-3 border-gray-200 h-16 ${processing ? "bg-gray-200" : "bg-white hover:bg-gray-200 hover:cursor-pointer"}`}
|
||||
onClick={translate}
|
||||
>
|
||||
{t("translate")}
|
||||
</button>
|
||||
</div>
|
||||
{history.length > 0 && (
|
||||
<Container className="m-6 flex flex-col p-6">
|
||||
<h1 className="text-2xl font-light">History</h1>
|
||||
<ul className="list-disc list-inside">
|
||||
{history.map((item, index) => (
|
||||
<li key={index}>
|
||||
<span className="font-bold">{item.text1}</span> - {item.text2}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Container>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
66
src/app/(features)/word-board/TheBoard.tsx
Normal file
66
src/app/(features)/word-board/TheBoard.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BOARD_WIDTH,
|
||||
TEXT_WIDTH,
|
||||
BOARD_HEIGHT,
|
||||
TEXT_SIZE,
|
||||
} from "@/config/word-board-config";
|
||||
import { Word } from "@/lib/interfaces";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
|
||||
export default function TheBoard({
|
||||
words,
|
||||
selectWord,
|
||||
}: {
|
||||
words: [
|
||||
{
|
||||
word: string;
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
];
|
||||
setWords: Dispatch<SetStateAction<Word[]>>;
|
||||
selectWord: (word: string) => void;
|
||||
}) {
|
||||
function DraggableWord({ word }: { word: Word }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
left: `${Math.floor(word.x * (BOARD_WIDTH - TEXT_WIDTH * word.word.length))}px`,
|
||||
top: `${Math.floor(word.y * (BOARD_HEIGHT - TEXT_SIZE))}px`,
|
||||
fontSize: `${TEXT_SIZE}px`,
|
||||
}}
|
||||
className="select-none cursor-pointer absolute code-block border-amber-100 border-1"
|
||||
// onClick={inspect(word.word)}>{word.word}</span>))
|
||||
onClick={() => {
|
||||
selectWord(word.word);
|
||||
}}
|
||||
>
|
||||
{word.word}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: `${BOARD_WIDTH}px`,
|
||||
height: `${BOARD_HEIGHT}px`,
|
||||
}}
|
||||
className="relative rounded bg-white"
|
||||
>
|
||||
{words.map(
|
||||
(
|
||||
v: {
|
||||
word: string;
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
i: number,
|
||||
) => {
|
||||
return <DraggableWord word={v} key={i}></DraggableWord>;
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
src/app/(features)/word-board/page.tsx
Normal file
185
src/app/(features)/word-board/page.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
import TheBoard from "@/app/(features)/word-board/TheBoard";
|
||||
import LightButton from "../../../components/buttons/LightButton";
|
||||
import { KeyboardEvent, useRef, useState } from "react";
|
||||
import { Word } from "@/lib/interfaces";
|
||||
import {
|
||||
BOARD_WIDTH,
|
||||
TEXT_WIDTH,
|
||||
BOARD_HEIGHT,
|
||||
TEXT_SIZE,
|
||||
} from "@/config/word-board-config";
|
||||
import { inspect } from "@/lib/utils";
|
||||
|
||||
export default function WordBoardPage() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const inputFileRef = useRef<HTMLInputElement>(null);
|
||||
const initialWords = [
|
||||
// 'apple',
|
||||
// 'banana',
|
||||
// 'cannon',
|
||||
// 'desktop',
|
||||
// 'kernel',
|
||||
// 'system',
|
||||
// 'programming',
|
||||
// 'owe'
|
||||
] as Array<string>;
|
||||
const [words, setWords] = useState(
|
||||
initialWords.map((v: string) => ({
|
||||
word: v,
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
})),
|
||||
);
|
||||
const generateNewWord = (word: string) => {
|
||||
const isOK = (w: Word) => {
|
||||
if (words.length === 0) return true;
|
||||
const tf = (ww: Word) =>
|
||||
({
|
||||
word: ww.word,
|
||||
x: Math.floor(ww.x * (BOARD_WIDTH - TEXT_WIDTH * ww.word.length)),
|
||||
y: Math.floor(ww.y * (BOARD_HEIGHT - TEXT_SIZE)),
|
||||
}) as Word;
|
||||
const tfd_words = words.map(tf);
|
||||
const tfd_w = tf(w);
|
||||
for (const www of tfd_words) {
|
||||
const p1 = {
|
||||
x: (www.x + www.x + TEXT_WIDTH * www.word.length) / 2,
|
||||
y: (www.y + www.y + TEXT_SIZE) / 2,
|
||||
};
|
||||
const p2 = {
|
||||
x: (tfd_w.x + tfd_w.x + TEXT_WIDTH * tfd_w.word.length) / 2,
|
||||
y: (tfd_w.y + tfd_w.y + TEXT_SIZE) / 2,
|
||||
};
|
||||
if (
|
||||
Math.abs(p1.x - p2.x) <
|
||||
(TEXT_WIDTH * (www.word.length + tfd_w.word.length)) / 2 &&
|
||||
Math.abs(p1.y - p2.y) < TEXT_SIZE
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
let new_word;
|
||||
let count = 0;
|
||||
do {
|
||||
new_word = {
|
||||
word: word,
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
};
|
||||
if (++count > 1000) return null;
|
||||
} while (!isOK(new_word));
|
||||
return new_word as Word;
|
||||
};
|
||||
const insertWord = () => {
|
||||
if (!inputRef.current) return;
|
||||
const word = inputRef.current.value.trim();
|
||||
if (word === "") return;
|
||||
const new_word = generateNewWord(word);
|
||||
if (!new_word) return;
|
||||
setWords([...words, new_word]);
|
||||
inputRef.current.value = "";
|
||||
};
|
||||
const deleteWord = () => {
|
||||
if (!inputRef.current) return;
|
||||
const word = inputRef.current.value.trim();
|
||||
if (word === "") return;
|
||||
setWords(words.filter((v) => v.word !== word));
|
||||
inputRef.current.value = "";
|
||||
};
|
||||
const importWords = () => {
|
||||
inputFileRef.current?.click();
|
||||
};
|
||||
const exportWords = () => {
|
||||
const blob = new Blob([JSON.stringify(words)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${Date.now()}.json`;
|
||||
a.style.display = "none";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
const handleFileChange = () => {
|
||||
const files = inputFileRef.current?.files;
|
||||
if (files && files.length > 0) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (reader.result && typeof reader.result === "string")
|
||||
setWords(JSON.parse(reader.result) as [Word]);
|
||||
};
|
||||
reader.readAsText(files[0]);
|
||||
}
|
||||
};
|
||||
const deleteAll = () => {
|
||||
setWords([] as Array<Word>);
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
// e.preventDefault();
|
||||
if (e.key === "Enter") {
|
||||
insertWord();
|
||||
}
|
||||
};
|
||||
const selectWord = (word: string) => {
|
||||
if (!inputRef.current) return;
|
||||
inputRef.current.value = word;
|
||||
};
|
||||
const searchWord = () => {
|
||||
if (!inputRef.current) return;
|
||||
const word = inputRef.current.value.trim();
|
||||
if (word === "") return;
|
||||
inspect(word)();
|
||||
inputRef.current.value = "";
|
||||
};
|
||||
// const readWordAloud = () => {
|
||||
// playFromUrl('https://fanyi.baidu.com/gettts?lan=uk&text=disclose&spd=3')
|
||||
// return;
|
||||
// if (!inputRef.current) return;
|
||||
// const word = inputRef.current.value.trim();
|
||||
// if (word === '') return;
|
||||
// inspect(word)();
|
||||
// inputRef.current.value = '';
|
||||
// }
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-screen h-screen justify-center items-center">
|
||||
<div
|
||||
onKeyDown={handleKeyDown}
|
||||
className="p-5 bg-gray-200 rounded shadow-2xl"
|
||||
>
|
||||
<TheBoard
|
||||
selectWord={selectWord}
|
||||
words={words as [Word]}
|
||||
setWords={setWords}
|
||||
/>
|
||||
<div className="flex justify-center rounded mt-3 gap-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
placeholder="word to operate"
|
||||
type="text"
|
||||
className="focus:outline-none border-b-2 border-black"
|
||||
/>
|
||||
<LightButton onClick={insertWord}>插入</LightButton>
|
||||
<LightButton onClick={deleteWord}>删除</LightButton>
|
||||
<LightButton onClick={searchWord}>搜索</LightButton>
|
||||
<LightButton onClick={importWords}>导入</LightButton>
|
||||
<LightButton onClick={exportWords}>导出</LightButton>
|
||||
<LightButton onClick={deleteAll}>删光</LightButton>
|
||||
{/* <Button label="朗读" onClick={readWordAloud}></Button> */}
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
ref={inputFileRef}
|
||||
className="hidden"
|
||||
accept="application/json"
|
||||
onChange={handleFileChange}
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user