Compare commits

..

10 Commits

Author SHA1 Message Date
aaa484ebee ... 2025-10-12 20:47:37 +08:00
a632e5f249 ... 2025-10-12 20:34:05 +08:00
156b5aad34 ... 2025-10-12 20:21:28 +08:00
75f1e529ac ... 2025-10-12 20:06:10 +08:00
84837de999 ... 2025-10-12 19:59:23 +08:00
a9d0247294 ... 2025-10-12 19:48:40 +08:00
4708828972 添加朗读器本地保存功能 2025-10-12 18:42:04 +08:00
85085ba5ff 逐步添加本地保存功能 2025-10-11 20:43:43 +08:00
2edfb0afb4 fix typo 2025-10-09 12:36:35 +08:00
9d4d2c6299 修复了按键监听的问题 2025-10-09 11:51:43 +08:00
14 changed files with 330 additions and 76 deletions

5
package-lock.json generated
View File

@@ -13,7 +13,8 @@
"edge-tts-universal": "^1.3.2", "edge-tts-universal": "^1.3.2",
"next": "15.5.3", "next": "15.5.3",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0" "react-dom": "19.1.0",
"zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
@@ -14938,8 +14939,6 @@
"resolved": "https://mirrors.cloud.tencent.com/npm/zod/-/zod-3.25.76.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -14,7 +14,8 @@
"edge-tts-universal": "^1.3.2", "edge-tts-universal": "^1.3.2",
"next": "15.5.3", "next": "15.5.3",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0" "react-dom": "19.1.0",
"zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",

View File

@@ -1,8 +1,9 @@
2025.09.25 新增记忆字母表功能 2025.10.12 添加朗读器本地保存功能
2025.10.09 新增记忆字母表功能
2025.10.08 加快了TTS的生成速度将IPA生成设置为可选项 2025.10.08 加快了TTS的生成速度将IPA生成设置为可选项
2025.10.07 新增文本朗读器优化了视频播放器UI 2025.10.07 新增文本朗读器优化了视频播放器UI
2025.10.06 更新了主页面UI移除IPA生成与文本朗读功能新增全语言翻译器 2025.10.06 更新了主页面UI移除IPA生成与文本朗读功能新增全语言翻译器
2025.10.05 新增IPA生成与文本朗读功能 2025.10.05 新增IPA生成与文本朗读功能
2025.09.25 优化了主界面UI 2025.09.25 优化了主界面UI
2025.09.19 更新了单词板,单词不再会重叠 2025.09.19 更新了单词板,单词不再会重叠

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160 0h80v-360h-80v360ZM280-720v520-520Z"/></svg>

After

Width:  |  Height:  |  Size: 319 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M840-680v480q0 33-23.5 56.5T760-120H200q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h480l160 160Zm-80 34L646-760H200v560h560v-446ZM480-240q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35ZM240-560h360v-160H240v160Zm-40-86v446-560 114Z"/></svg>

After

Width:  |  Height:  |  Size: 388 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M418-340q24 24 62 23.5t56-27.5l224-336-336 224q-27 18-28.5 55t22.5 61Zm62-460q59 0 113.5 16.5T696-734l-76 48q-33-17-68.5-25.5T480-720q-133 0-226.5 93.5T160-400q0 42 11.5 83t32.5 77h552q23-38 33.5-79t10.5-85q0-36-8.5-70T766-540l48-76q30 47 47.5 100T880-406q1 57-13 109t-41 99q-11 18-30 28t-40 10H204q-21 0-40-10t-30-28q-26-45-40-95.5T80-400q0-83 31.5-155.5t86-127Q252-737 325-768.5T480-800Zm7 313Z"/></svg>

After

Width:  |  Height:  |  Size: 521 B

View File

@@ -2,7 +2,7 @@ import Button from "@/components/Button";
import IconClick from "@/components/IconClick"; import IconClick from "@/components/IconClick";
import IMAGES from "@/config/images"; import IMAGES from "@/config/images";
import { Letter, SupportedAlphabets } from "@/interfaces"; import { Letter, SupportedAlphabets } from "@/interfaces";
import { Dispatch, SetStateAction, useRef, useState } from "react"; import { Dispatch, KeyboardEvent, SetStateAction, useEffect, useRef, useState } from "react";
export default function MemoryCard( export default function MemoryCard(
{ {
@@ -19,9 +19,21 @@ export default function MemoryCard(
const [more, setMore] = useState(false); const [more, setMore] = useState(false);
const [ipaDisplay, setIPADisplay] = useState(true); const [ipaDisplay, setIPADisplay] = useState(true);
const [letterDisplay, setLetterDisplay] = 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 letter = alphabet[index];
const refresh = () => {
setIndex(Math.floor(Math.random() * alphabet.length));
}
return ( return (
<div className="w-full flex justify-center items-center"> <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="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"> <div className="w-full flex justify-end items-center">
<IconClick size={32} alt="close" src={IMAGES.close} onClick={() => setChosenAlphabet(null)}></IconClick> <IconClick size={32} alt="close" src={IMAGES.close} onClick={() => setChosenAlphabet(null)}></IconClick>
@@ -31,7 +43,7 @@ export default function MemoryCard(
<span className="text-5xl md:text-7xl text-gray-400">{ipaDisplay ? letter.letter_sound_ipa : ''}</span> <span className="text-5xl md:text-7xl text-gray-400">{ipaDisplay ? letter.letter_sound_ipa : ''}</span>
</div> </div>
<div className="flex flex-row mt-32 items-center justify-center gap-2"> <div className="flex flex-row mt-32 items-center justify-center gap-2">
<IconClick size={48} alt="refresh" src={IMAGES.refresh} onClick={() => setIndex(Math.floor(Math.random() * alphabet.length))}></IconClick> <IconClick size={48} alt="refresh" src={IMAGES.refresh} onClick={refresh}></IconClick>
<IconClick size={48} alt="more" src={IMAGES.more_horiz} onClick={() => setMore(!more)}></IconClick> <IconClick size={48} alt="more" src={IMAGES.more_horiz} onClick={() => setMore(!more)}></IconClick>
{ {
more ? (<> more ? (<>

View File

@@ -39,6 +39,24 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>((
setIsPlaying(!video.paused); setIsPlaying(!video.paused);
}, [videoRef, videoUrl]); }, [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(() => { useEffect(() => {
const cb = () => { const cb = () => {
if (ready.current.all()) { if (ready.current.all()) {
@@ -139,22 +157,8 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>((
} }
} }
const handleKeyDownEvent = (e: KeyboardEvent<HTMLDivElement>) => {
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();
}
}
return ( return (
<div className="w-full flex flex-col" onKeyDown={handleKeyDownEvent}> <div className="w-full flex flex-col">
<video className="bg-gray-200" ref={videoRef} onTimeUpdate={timeUpdate}></video> <video className="bg-gray-200" ref={videoRef} onTimeUpdate={timeUpdate}></video>
<SubtitleDisplay subtitle={subtitle}></SubtitleDisplay> <SubtitleDisplay subtitle={subtitle}></SubtitleDisplay>
<div className="buttons flex mt-2 gap-2 flex-wrap"> <div className="buttons flex mt-2 gap-2 flex-wrap">

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useRef, useState } from "react"; import { KeyboardEvent, useRef, useState } from "react";
import UploadArea from "./UploadArea"; import UploadArea from "./UploadArea";
import VideoPanel from "./VideoPlayer/VideoPanel"; import VideoPanel from "./VideoPlayer/VideoPanel";
@@ -10,7 +10,7 @@ export default function Home() {
const [videoUrl, setVideoUrl] = useState<string | null>(null); const [videoUrl, setVideoUrl] = useState<string | null>(null);
const [srtUrl, setSrtUrl] = useState<string | null>(null); const [srtUrl, setSrtUrl] = useState<string | null>(null);
return ( return (
<div className="flex w-screen pt-8 items-center justify-center"> <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"> <div className="w-[80vw] md:w-[45vw] flex items-center flex-col">
<VideoPanel <VideoPanel
videoUrl={videoUrl} videoUrl={videoUrl}

View File

@@ -0,0 +1,94 @@
'use client';
import { getTextSpeakerData, setTextSpeakerData } from "@/utils";
import { useState } from "react";
import z from "zod";
import { TextSpeakerItemSchema } from "@/interfaces";
import IconClick from "@/components/IconClick";
import IMAGES from "@/config/images";
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="hover:cursor-pointer p-2 border-b-1 border-gray-200 rounded-2xl bg-gray-100 m-2 grid grid-cols-8" onClick={onUseClick}>
<div className="col-span-7">
<div className="max-h-26 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 [data, setData] = useState(getTextSpeakerData());
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
const current_data = getTextSpeakerData();
current_data.splice(
current_data.findIndex(v => v.text === item.text)
);
setTextSpeakerData(current_data);
}
const refresh = () => {
setData(getTextSpeakerData());
}
const handleDeleteAll = () => {
const yesorno = prompt('确定删光吗?(Y/N)')?.trim();
if (yesorno && (yesorno === 'Y' || yesorno === 'y')) {
setTextSpeakerData([]);
refresh();
}
}
if (show) return (
<div className="my-4 p-2 mx-4 md:mx-32 border-1 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 (<></>);
}

View File

@@ -4,20 +4,26 @@ import Button from "@/components/Button";
import IconClick from "@/components/IconClick"; import IconClick from "@/components/IconClick";
import IMAGES from "@/config/images"; import IMAGES from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTTSAudioUrl } from "@/utils"; import { getTextSpeakerData, getTTSAudioUrl, setTextSpeakerData } from "@/utils";
import { ChangeEvent, useEffect, useRef, useState } from "react"; import { ChangeEvent, useEffect, useRef, useState } from "react";
import SaveList from "./SaveList";
import { TextSpeakerItemSchema } from "@/interfaces";
import z from "zod";
export default function Home() { export default function Home() {
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 [ipaEnabled, setIPAEnabled] = useState(false);
const [speed, setSpeed] = useState(1); const [speed, setSpeed] = useState(1);
const [pause, setPause] = useState(true); const [pause, setPause] = useState(true);
const [autopause, setAutopause] = useState(true); const [autopause, setAutopause] = useState(true);
const textRef = useRef(''); const textRef = useRef('');
const localeRef = useRef<string | null>(null); const [locale, setLocale] = useState<string | null>(null);
const [ipa, setIPA] = useState<string>(''); const [ipa, setIPA] = useState<string>('');
const objurlRef = useRef<string | null>(null); const objurlRef = useRef<string | null>(null);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [voicesData, setVoicesData] = useState<{ const [voicesData, setVoicesData] = useState<{
locale: string, locale: string,
short_name: string short_name: string
@@ -84,15 +90,19 @@ export default function Home() {
playAudio(objurlRef.current); playAudio(objurlRef.current);
} else { } else {
// 第一次播放 // 第一次播放
console.log('downloading text info');
const params = new URLSearchParams({
text: textRef.current.slice(0, 30)
});
try { try {
const textinfo = await (await fetch(`/api/locale?${params}`)).json(); let theLocale = locale;
localeRef.current = textinfo.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 = voicesData.find(v => v.locale.startsWith(localeRef.current!)); const voice = voicesData.find(v => v.locale.startsWith(theLocale));
if (!voice) throw 'Voice not found.'; if (!voice) throw 'Voice not found.';
objurlRef.current = await getTTSAudioUrl( objurlRef.current = await getTTSAudioUrl(
@@ -112,7 +122,7 @@ export default function Home() {
console.error(e); console.error(e);
setPause(true); setPause(true);
localeRef.current = null; setLocale(null);
setProcessing(false); setProcessing(false);
} }
@@ -129,7 +139,7 @@ export default function Home() {
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => { const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
textRef.current = e.target.value.trim(); textRef.current = e.target.value.trim();
localeRef.current = null; setLocale(null);
setIPA(''); setIPA('');
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current); if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null; objurlRef.current = null;
@@ -147,51 +157,145 @@ export default function Home() {
} }
} }
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;
stopAudio();
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 = getTextSpeakerData();
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;
setTextSpeakerData(save);
}
}
} else if (theIPA.length === 0) {
save.push({
text: textRef.current,
locale: theLocale
});
} else {
save.push({
text: textRef.current,
locale: theLocale,
ipa: theIPA
});
}
setTextSpeakerData(save);
} catch (e) {
console.error(e);
setLocale(null);
} finally {
setSaving(false);
}
}
return (<> return (<>
<div className="my-4 p-4 mx-4 md:mx-32 border-1 border-gray-200 rounded-2xl"> <div className="my-4 p-4 mx-4 md:mx-32 border-1 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" <textarea className="text-2xl resize-none focus:outline-0 min-h-64 w-full border-gray-200 border-b"
onChange={handleInputChange}> onChange={handleInputChange}
ref={textareaRef}>
</textarea> </textarea>
<div className="overflow-auto text-gray-600 h-18"> {
{ipa} ipa.length !== 0 && (<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b">
</div> {ipa}
<div className="w-full flex flex-row gap-2 justify-center items-center"> </div>) || (<div className="h-18"></div>)
<Button label="generate ipa" selected={ipaEnabled} onClick={() => setIPAEnabled(!ipaEnabled)}></Button> }
<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={() => { <IconClick size={45} onClick={() => {
setAutopause(!autopause); if (objurlRef) { stopAudio(); } setPause(true); setAutopause(!autopause); if (objurlRef) { stopAudio(); } setPause(true);
}} src={ }} src={
autopause ? IMAGES.autoplay : IMAGES.autopause autopause ? IMAGES.autoplay : IMAGES.autopause
} alt="autoplayorpause" } alt="autoplayorpause"
></IconClick> ></IconClick>
<IconClick size={45} onClick={speak} src={ <IconClick size={45} onClick={() => setShowSpeedAdjust(!showSpeedAdjust)}
pause ? IMAGES.play_arrow : IMAGES.pause src={IMAGES.speed}
} alt="playorpause" className={`${processing ? 'bg-gray-200' : ''}`}></IconClick> alt="speed"
<IconClick size={45} onClick={letMeSetSpeed(0.5)} className={`${showSpeedAdjust ? 'bg-gray-200' : ''}`}></IconClick>
src={IMAGES.speed_0_5x} <IconClick size={45} onClick={save}
alt="0.5x" src={IMAGES.save}
className={speed === 0.5 ? 'bg-gray-200' : ''} alt="save"
></IconClick> className={`${saving ? 'bg-gray-200' : ''}`}></IconClick>
<IconClick size={45} onClick={letMeSetSpeed(0.7)} <div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
src={IMAGES.speed_0_7x} <Button label="生成IPA"
alt="0.7x" selected={ipaEnabled}
className={speed === 0.7 ? 'bg-gray-200' : ''} onClick={() => setIPAEnabled(!ipaEnabled)}>
></IconClick> </Button>
<IconClick size={45} onClick={letMeSetSpeed(1)} <Button label="查看保存项"
src={IMAGES.speed_1x} onClick={() => { setShowSaveList(!showSaveList) }}
alt="1x" selected={showSaveList}>
className={speed === 1 ? 'bg-gray-200' : ''} </Button>
></IconClick> </div>
<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> </div>
</div > </div>
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList>
</>); </>);
} }

View File

@@ -12,6 +12,9 @@ const IMAGES = {
close: '/images/close_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', close: '/images/close_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
refresh: '/images/refresh_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', refresh: '/images/refresh_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
more_horiz: '/images/more_horiz_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', more_horiz: '/images/more_horiz_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
save: '/images/save_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
delete: '/images/delete_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
speed: '/images/speed_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
} }
export default IMAGES; export default IMAGES;

View File

@@ -1,3 +1,4 @@
import z from "zod";
export interface Word { export interface Word {
word: string; word: string;
@@ -10,4 +11,10 @@ export interface Word {
roman_letter?: string; roman_letter?: string;
} }
export type SupportedAlphabets = 'japanese' | 'english' | 'esperanto' | 'uyghur'; export type SupportedAlphabets = 'japanese' | 'english' | 'esperanto' | 'uyghur';
export const TextSpeakerItemSchema = z.object({
text: z.string(),
ipa: z.string().optional(),
locale: z.string()
});
export const TextSpeakerArraySchema = z.array(TextSpeakerItemSchema);

View File

@@ -1,5 +1,7 @@
import { EdgeTTS, ProsodyOptions } from "edge-tts-universal/browser"; import { EdgeTTS, ProsodyOptions } from "edge-tts-universal/browser";
import { env } from "process"; import { env } from "process";
import { TextSpeakerArraySchema } from "./interfaces";
import z from "zod";
export function inspect(word: string) { export function inspect(word: string) {
const goto = (url: string) => { const goto = (url: string) => {
@@ -50,3 +52,27 @@ export async function getTTSAudioUrl(text: string, short_name: string, options:
throw e; throw e;
} }
} }
export const getTextSpeakerData = () => {
try {
const item = localStorage.getItem('text-speaker');
if (!item) return [];
const rawData = JSON.parse(item);
const result = TextSpeakerArraySchema.safeParse(rawData);
if (result.success) {
return result.data;
} else {
console.error('Invalid data structure in localStorage:', result.error);
return [];
}
} catch (e) {
console.error('Failed to parse text-speaker data:', e);
return [];
}
};
export const setTextSpeakerData = (data: z.infer<typeof TextSpeakerArraySchema>) => {
if (!localStorage) return;
localStorage.setItem('text-speaker', JSON.stringify(data));
};