The weeb for the next gen discord boat - Wamellow wamellow.com
bot discord

feat: downloadable & deletable tts generations

shi.gg efda0038 ad5ab34a

verified
Changed files
+84 -35
app
(home)
components
public
+41 -17
app/(home)/text-to-speech/history.tsx
··· 5 5 import { getDateString, getTimeAgo } from "@/utils/time"; 6 6 import { actor, getVoices } from "@/utils/tts"; 7 7 import { motion } from "framer-motion"; 8 + import { useEffect, useState } from "react"; 8 9 import { 9 - useEffect, 10 - useRef 11 - } from "react"; 12 - import { HiPencil, HiPlay, HiRefresh, HiSpeakerphone } from "react-icons/hi"; 10 + HiDownload, 11 + HiPencil, HiPlay, HiSpeakerphone 12 + } from "react-icons/hi"; 13 + import { HiTrash } from "react-icons/hi2"; 13 14 14 - import { HistoryItem, State } from "./use-history"; 15 + import { type HistoryItem, State } from "./use-history"; 15 16 16 17 const springAnimation = { 17 18 hidden: { y: 20, opacity: 0 }, ··· 33 34 ensureUrl, 34 35 fallbackVoice, 35 36 onPlay, 36 - onRefill 37 + onRefill, 38 + onDelete 37 39 }: { 38 40 history: HistoryItem[]; 39 41 error: string | null; ··· 42 44 fallbackVoice: string; 43 45 onPlay: (url: string) => void; 44 46 onRefill: (text: string, voice: string) => void; 47 + onDelete: (id: number) => void; 45 48 }) { 46 - const init = useRef(false); 49 + const [hasAnimated, setHasAnimated] = useState(false); 50 + const isLoading = state === State.Loading; 51 + const shouldAnimate = !hasAnimated && !isLoading && history.length > 0; 47 52 48 53 useEffect(() => { 49 - if (state === State.Loading) return; 50 - init.current = true; 51 - 52 - return () => { 53 - init.current = false; 54 - }; 55 - }, [state]); 54 + if (hasAnimated || isLoading) return; 55 + if (history.length) { 56 + queueMicrotask(() => setHasAnimated(true)); 57 + } 58 + }, [hasAnimated, history.length, isLoading]); 56 59 57 60 if (error) { 58 61 return <div className="text-sm text-red-400">{error}</div>; 59 62 } 60 63 61 - if (state === State.Loading) { 64 + if (isLoading) { 62 65 return ( 63 66 <div className="flex flex-col gap-3"> 64 67 {Array.from({ length: 3 }).map((_, i) => ( ··· 90 93 } 91 94 } 92 95 }} 93 - initial={init.current ? "visible" : "hidden"} 94 - animate="visible" 96 + initial={shouldAnimate ? "hidden" : false} 97 + animate={shouldAnimate ? "visible" : false} 95 98 className="flex flex-col gap-3" 96 99 > 97 100 {history.map((item) => ( ··· 101 104 fallbackVoice={fallbackVoice} 102 105 onPlay={onPlay} 103 106 onRefill={onRefill} 107 + onDelete={onDelete} 104 108 ensureUrl={ensureUrl} 105 109 /> 106 110 ))} ··· 114 118 fallbackVoice, 115 119 onPlay, 116 120 onRefill, 121 + onDelete, 117 122 ensureUrl 118 123 }: { 119 124 item: HistoryItem; 120 125 fallbackVoice: string; 121 126 onPlay: (url: string) => void; 122 127 onRefill: (text: string, voice: string) => void; 128 + onDelete: (id: number) => void; 123 129 ensureUrl: (item: HistoryItem) => string; 124 130 }) { 125 131 const createdAt = new Date(item.createdAt); ··· 160 166 }} 161 167 > 162 168 <HiPencil className="mr-1" /> Edit 169 + </Button> 170 + <Button 171 + className="p-4.5" 172 + size="icon" 173 + variant="ghost" 174 + onClick={() => onDelete(item.id)} 175 + > 176 + <HiTrash /> 177 + </Button> 178 + <Button 179 + className="p-4.5" 180 + size="icon" 181 + variant="ghost" 182 + asChild 183 + > 184 + <a href={ensureUrl(item)} download> 185 + <HiDownload /> 186 + </a> 163 187 </Button> 164 188 </div> 165 189 </motion.div>
+16 -7
app/(home)/text-to-speech/page.tsx
··· 15 15 import type { TurnstileInstance } from "@marsidev/react-turnstile"; 16 16 import { Turnstile } from "@marsidev/react-turnstile"; 17 17 import { useRef, useState } from "react"; 18 + import { HiDownload } from "react-icons/hi"; 18 19 19 20 import { getSpeech } from "./api"; 20 21 import { History } from "./history"; ··· 30 31 const [loading, setLoading] = useState(false); 31 32 const [error, setError] = useState<string | null>(null); 32 33 33 - const { history, error: historyError, state, addEntry, ensureUrl } = useHistory(); 34 + const { history, error: historyError, state, addEntry, deleteEntry, ensureUrl } = useHistory(); 34 35 const captcha = useRef<TurnstileInstance>(null); 35 36 36 37 const handleGenerate = async () => { ··· 128 129 <div className="rounded-xl bg-linear-to-br from-violet-300/8 to-violet-200/5 p-5 mt-4"> 129 130 <AudioPlayerProvider> 130 131 <div className="flex items-center gap-4"> 131 - <AudioPlayerButton 132 - item={{ 133 - id: audioUrl, 134 - src: audioUrl 135 - }} 136 - /> 132 + <div className="flex items-center gap-2"> 133 + <Button asChild> 134 + <a href={audioUrl} download> 135 + <HiDownload /> 136 + </a> 137 + </Button> 138 + <AudioPlayerButton 139 + item={{ 140 + id: audioUrl, 141 + src: audioUrl 142 + }} 143 + /> 144 + </div> 137 145 <AudioPlayerProgress className="flex-1" /> 138 146 <AudioPlayerTime /> 139 147 <span>/</span> ··· 161 169 setText(newText); 162 170 setVoice(newVoice); 163 171 }} 172 + onDelete={(id) => deleteEntry(id)} 164 173 /> 165 174 </div> 166 175 </div>
+23 -7
app/(home)/text-to-speech/use-history.ts
··· 24 24 }; 25 25 26 26 export function useHistory() { 27 - const [state, setState] = useState<State>(State.Loading); 27 + const isIndexDbSupported = typeof window !== "undefined" && "indexedDB" in window; 28 + 29 + const [state, setState] = useState<State>(() => isIndexDbSupported ? State.Loading : State.Success); 28 30 const [history, setHistory] = useState<HistoryItem[]>([]); 29 - const [error, setError] = useState<string | null>(null); 31 + const [error, setError] = useState<string | null>(() => isIndexDbSupported ? null : "IndexedDB is not available in this browser."); 30 32 31 33 const dbRef = useRef<IDBDatabase | null>(null); 32 34 const urlCache = useRef<Map<number, string>>(new Map()); ··· 34 36 useEffect(() => { 35 37 let mounted = true; 36 38 37 - if (typeof window === "undefined" || !("indexedDB" in window)) { 38 - setError("IndexedDB is not available in this browser."); 39 - return; 40 - } 39 + if (!isIndexDbSupported) return; 41 40 42 41 const openDb = () => new Promise<IDBDatabase>((resolve, reject) => { 43 42 const request = indexedDB.open(DB_NAME, DB_VERSION); ··· 87 86 dbRef.current?.close(); 88 87 for (const [, url] of urlCache.current) URL.revokeObjectURL(url); 89 88 }; 90 - }, []); 89 + }, [isIndexDbSupported]); 91 90 92 91 const addEntry = async (entry: Omit<HistoryItem, "id">, cachedUrl?: string) => { 93 92 const db = dbRef.current; ··· 113 112 return persistId; 114 113 }; 115 114 115 + const deleteEntry = async (id: number) => { 116 + const db = dbRef.current; 117 + if (!db) return; 118 + 119 + const tx = db.transaction(STORE_NAME, "readwrite"); 120 + const store = tx.objectStore(STORE_NAME); 121 + const request = store.delete(id); 122 + 123 + await new Promise<void>((resolve, reject) => { 124 + request.onsuccess = () => resolve(); 125 + request.onerror = () => reject(request.error); 126 + }); 127 + 128 + setHistory((prev) => prev.filter((item) => item.id !== id)); 129 + }; 130 + 116 131 const ensureUrl = (item: HistoryItem) => { 117 132 if (urlCache.current.has(item.id)) return urlCache.current.get(item.id)!; 118 133 const url = base64ToUrl(item.base64); ··· 125 140 error, 126 141 state, 127 142 addEntry, 143 + deleteEntry, 128 144 ensureUrl 129 145 }; 130 146 }
+3 -3
components/ui/audio-player.tsx
··· 9 9 } from "@/components/ui/dropdown-menu"; 10 10 import { cn } from "@/utils/cn"; 11 11 import * as SliderPrimitive from "@radix-ui/react-slider"; 12 - import { Check, PauseIcon, PlayIcon, Settings } from "lucide-react"; 12 + import { Check, Settings } from "lucide-react"; 13 13 import type { 14 14 ComponentProps, 15 15 HTMLProps, ··· 70 70 isBuffering: boolean; 71 71 playbackRate: number; 72 72 isItemActive: (id: string | number | null) => boolean; 73 - setActiveItem: (item: AudioPlayerItem<TData> | null) => Promise<void>; 73 + setActiveItem: (item: AudioPlayerItem<TData> | null) => void; 74 74 play: (item?: AudioPlayerItem<TData> | null) => Promise<void>; 75 75 pause: () => void; 76 76 seek: (time: number) => void; ··· 121 121 const [playbackRate, setPlaybackRateState] = useState<number>(1); 122 122 123 123 const setActiveItem = useCallback( 124 - async (item: AudioPlayerItem<TData> | null) => { 124 + (item: AudioPlayerItem<TData> | null) => { 125 125 if (!audioRef.current) return; 126 126 127 127 if (item?.id === itemRef.current?.id) {
+1 -1
public/docs/text-to-speech.md
··· 19 19 <br /> 20 20 <br /> 21 21 22 - To get a quick **.mp3 file** of your message, use `/tts file` in any text channel. 22 + To get a quick **.mp3 file** of your message, use `/tts file` in any text channel. You can also [generate Text to Speech files online](/text-to-speech). 23 23 24 24 ### 📑 Usage logs 25 25 Pick a channel where any Text to Speech events from your server should be logged, mainly for moderation purposes.