Files
learn-languages/src/app/(features)/text-speaker/page.tsx
2026-01-13 23:02:07 +08:00

349 lines
11 KiB
TypeScript

"use client";
import { LightButton } from "@/components/ui/buttons";
import { IconClick } from "@/components/ui/buttons";
import IMAGES from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import {
TextSpeakerArraySchema,
TextSpeakerItemSchema,
} from "@/lib/interfaces";
import { ChangeEvent, useEffect, useRef, useState } from "react";
import z from "zod";
import SaveList from "./SaveList";
import { useTranslations } from "next-intl";
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
import { genIPA, genLanguage } from "@/modules/translator/translator-action";
import { logger } from "@/lib/logger";
import PageLayout from "@/components/ui/PageLayout";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
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 [language, setLanguage] = 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) => {
logger.error("生成 IPA 失败", e);
setIPA("");
});
}
if (pause) {
// 如果没在读
if (textRef.current.length === 0) {
// 没文本咋读
} else {
setPause(false);
if (objurlRef.current) {
// 之前有播放
load(objurlRef.current);
play();
} else {
// 第一次播放
try {
let theLanguage = language;
if (!theLanguage) {
const tmp_language = await genLanguage(textRef.current.slice(0, 30));
setLanguage(tmp_language);
theLanguage = tmp_language;
}
theLanguage = theLanguage.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
// 检查语言是否在 TTS 支持列表中
const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
"Auto", "Chinese", "English", "German", "Italian", "Portuguese",
"Spanish", "Japanese", "Korean", "French", "Russian"
];
if (!supportedLanguages.includes(theLanguage as TTS_SUPPORTED_LANGUAGES)) {
theLanguage = "Auto";
}
objurlRef.current = await getTTSUrl(
textRef.current,
theLanguage as TTS_SUPPORTED_LANGUAGES
);
load(objurlRef.current);
play();
} catch (e) {
logger.error("播放音频失败", e);
setPause(true);
setLanguage(null);
setProcessing(false);
}
}
}
} else {
// 如果在读就暂停
setPause(true);
stop();
}
setProcessing(false);
};
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
textRef.current = e.target.value.trim();
setLanguage(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;
setLanguage(item.language);
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 theLanguage = language;
if (!theLanguage) {
const tmp_language = await genLanguage(textRef.current.slice(0, 30));
setLanguage(tmp_language);
theLanguage = tmp_language;
}
let theIPA = ipa;
if (ipa.length === 0 && ipaEnabled) {
const tmp_ipa = await genIPA(textRef.current);
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,
language: theLanguage as string,
});
} else {
save.push({
text: textRef.current,
language: theLanguage as string,
ipa: theIPA,
});
}
setIntoLocalStorage(save);
} catch (e) {
logger.error("保存到本地存储失败", e);
setLanguage(null);
} finally {
setSaving(false);
}
};
return (
<PageLayout className="items-start py-4">
{/* 文本输入区域 */}
<div
className="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 p-4"
onChange={handleInputChange}
ref={textareaRef}
></textarea>
{/* IPA 显示区域 */}
{(ipa.length !== 0 && (
<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b px-4">
{ipa}
</div>
)) || <div className="h-18"></div>}
{/* 控制按钮区域 */}
<div className="p-4 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 z-10">
<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>
{/* 保存列表 */}
{showSaveList && (
<div className="mt-4 border border-gray-200 rounded-2xl overflow-hidden">
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList>
</div>
)}
</PageLayout>
);
}