349 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|