From 4708828972bfdcaafa437ae1cfad4a99843b7143 Mon Sep 17 00:00:00 2001 From: goddonebianu Date: Sun, 12 Oct 2025 18:42:04 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=9C=97=E8=AF=BB=E5=99=A8?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E4=BF=9D=E5=AD=98=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/changelog.txt | 1 + src/app/text-speaker/SaveList.tsx | 74 +++++++++++++++ src/app/text-speaker/page.tsx | 145 +++++++++++++++++------------- src/interfaces.ts | 7 ++ src/utils.ts | 28 +++++- 5 files changed, 190 insertions(+), 65 deletions(-) create mode 100644 src/app/text-speaker/SaveList.tsx diff --git a/public/changelog.txt b/public/changelog.txt index 0d0cd90..6ac4123 100644 --- a/public/changelog.txt +++ b/public/changelog.txt @@ -1,3 +1,4 @@ +2025.10.12 添加朗读器本地保存功能 2025.10.09 新增记忆字母表功能 2025.10.08 加快了TTS的生成速度,将IPA生成设置为可选项 2025.10.07 新增文本朗读器,优化了视频播放器UI diff --git a/src/app/text-speaker/SaveList.tsx b/src/app/text-speaker/SaveList.tsx new file mode 100644 index 0000000..cd54415 --- /dev/null +++ b/src/app/text-speaker/SaveList.tsx @@ -0,0 +1,74 @@ +'use client'; + +import Button from "@/components/Button"; +import { getTextSpeakerData, setTextSpeakerData } from "@/utils"; +import { useState } from "react"; +import z from "zod"; +import { TextSpeakerItemSchema } from "@/interfaces"; + +interface TextCardProps { + item: z.infer; + handleUse: (item: z.infer) => void; + handleDel: (item: z.infer) => void; +} +function TextCard({ + item, + handleUse, + handleDel +}: TextCardProps) { + const onUseClick = () => { + handleUse(item); + } + const onDelClick = () => { + handleDel(item); + } + return ( +
+
+
{item.text.length > 80 ? item.text.slice(0, 80) + '...' : item.text}
+
{item.ipa ? (item.ipa.length > 160 ? item.ipa.slice(0, 160) + '...' : item.ipa) : ''}
+
+ + +
+ ); +} + +interface SaveListProps { + show?: boolean; + handleUse: (item: z.infer) => void; +} +export default function SaveList({ + show = false, + handleUse +}: SaveListProps) { + const [data, setData] = useState(getTextSpeakerData()); + const handleDel = (item: z.infer) => { + 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 ( +
+ + +
    + {data.map(v => + + )} +
+
+ ); else return (<>); +} \ No newline at end of file diff --git a/src/app/text-speaker/page.tsx b/src/app/text-speaker/page.tsx index 531ac83..63dfbb2 100644 --- a/src/app/text-speaker/page.tsx +++ b/src/app/text-speaker/page.tsx @@ -4,18 +4,22 @@ import Button from "@/components/Button"; import IconClick from "@/components/IconClick"; import IMAGES from "@/config/images"; import { useAudioPlayer } from "@/hooks/useAudioPlayer"; -import { getTTSAudioUrl } from "@/utils"; +import { getTextSpeakerData, getTTSAudioUrl } from "@/utils"; import { ChangeEvent, useEffect, useRef, useState } from "react"; +import SaveList from "./SaveList"; +import { TextSpeakerItemSchema } from "@/interfaces"; import z from "zod"; export default function Home() { + const textareaRef = useRef(null); const [showSpeedAdjust, setShowSpeedAdjust] = useState(false); + const [showSaveList, setShowSaveList] = 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 localeRef = useRef(null); const [locale, setLocale] = useState(null); const [ipa, setIPA] = useState(''); const objurlRef = useRef(null); @@ -87,15 +91,19 @@ export default function Home() { playAudio(objurlRef.current); } else { // 第一次播放 - console.log('downloading text info'); - const params = new URLSearchParams({ - text: textRef.current.slice(0, 30) - }); try { - const textinfo = await (await fetch(`/api/locale?${params}`)).json(); - setLocale(textinfo.locale); + 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 = voicesData.find(v => v.locale.startsWith(textinfo.locale)); + const voice = voicesData.find(v => v.locale.startsWith(theLocale)); if (!voice) throw 'Voice not found.'; objurlRef.current = await getTTSAudioUrl( @@ -150,66 +158,78 @@ export default function Home() { } } - const TextSpeakerItemSchema = z.object({ - text: z.string(), - ipa: z.string().optional(), - locale: z.string() - }); - const TextSpeakerArraySchema = z.array(TextSpeakerItemSchema); + const handleUseItem = (item: z.infer) => { + 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 = () => { - if (!locale) return; + const save = async () => { + if (textRef.current.length === 0) return; - const getTextSpeakerData = () => { - try { - const item = localStorage.getItem('text-speaker'); + 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; + } - if (!item) return []; + 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 rawData = JSON.parse(item); - const result = TextSpeakerArraySchema.safeParse(rawData); - - if (result.success) { - return result.data; + const save = getTextSpeakerData(); + const oldIndex = save.findIndex(v => v.text === textRef.current); + if (oldIndex !== -1) { + const oldItem = save[oldIndex]; + if ((!oldItem.ipa) || (oldItem.ipa !== theIPA)) { + oldItem.ipa = theIPA; + localStorage.setItem('text-speaker', JSON.stringify(save)); + return; } else { - console.error('Invalid data structure in localStorage:', result.error); - return []; + return; } - } catch (e) { - console.error('Failed to parse text-speaker data:', e); - return []; } - } - - const save = getTextSpeakerData(); - const oldIndex = save.findIndex(v => v.text === textRef.current); - if (oldIndex !== -1) { - const oldItem = save[oldIndex]; - if ((ipa && !oldItem.ipa) || (ipa && oldItem.ipa !== ipa)) { - oldItem.ipa = ipa; - localStorage.setItem('text-speaker', JSON.stringify(save)); - return; + if (theIPA.length === 0) { + save.push({ + text: textRef.current, + locale: theLocale + }); + } else { + save.push({ + text: textRef.current, + locale: theLocale, + ipa: theIPA + }); } + localStorage.setItem('text-speaker', JSON.stringify(save)); + } catch (e) { + console.error(e); + setLocale(null); } - if (ipa.length === 0) { - save.push({ - text: textRef.current, - locale: locale - }); - } else { - save.push({ - text: textRef.current, - locale: locale, - ipa: ipa - }); - } - localStorage.setItem('text-speaker', JSON.stringify(save)); } return (<>
{ipa} @@ -224,24 +244,20 @@ export default function Home() { autopause ? IMAGES.autoplay : IMAGES.autopause } alt="autoplayorpause" > - {localStorage.getItem('text-speaker')} setShowSpeedAdjust(!showSpeedAdjust)} src={IMAGES.more_horiz} alt="more"> - {locale ? () : (<>)} + alt="save">
- -
@@ -276,6 +292,7 @@ export default function Home() { }
-
+ + ); } \ No newline at end of file diff --git a/src/interfaces.ts b/src/interfaces.ts index e090190..fc320b7 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,3 +1,4 @@ +import z from "zod"; export interface Word { word: string; @@ -10,4 +11,10 @@ export interface Word { roman_letter?: string; } 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); diff --git a/src/utils.ts b/src/utils.ts index 1a068c3..2f8df04 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,7 @@ import { EdgeTTS, ProsodyOptions } from "edge-tts-universal/browser"; import { env } from "process"; +import { TextSpeakerArraySchema } from "./interfaces"; +import z from "zod"; export function inspect(word: string) { const goto = (url: string) => { @@ -49,4 +51,28 @@ export async function getTTSAudioUrl(text: string, short_name: string, options: } catch (e) { throw e; } -} \ No newline at end of file +} +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) => { + if (!localStorage) return; + localStorage.setItem('text-speaker', JSON.stringify(data)); +}; \ No newline at end of file