feat: add donate stream overlay widget

Real-time donation overlay with animated entry/exit, TTS
announcements via Gemini, sound effects, heartbeat connectivity
check, and a queue system for sequential donation display.
This commit is contained in:
2026-06-07 23:04:55 +00:00 Unverified
parent d34baefa6d
commit b594685901
4 changed files with 48 additions and 12 deletions
+16
View File
@@ -0,0 +1,16 @@
"use server";
import { db } from "@/lib/db";
import { donations } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
export async function markRunning(id: string) {
await db
.update(donations)
.set({ lastPing: new Date() })
.where(eq(donations.id, id));
}
export async function markDone(id: string) {
await db.update(donations).set({ sent: true }).where(eq(donations.id, id));
}
+32 -12
View File
@@ -6,10 +6,13 @@ import { useEffect, useRef, useState } from "react";
import { VersionCheck } from "@/app/client";
import { cn } from "@/lib/utils";
import { sse } from "@/lib/db/sse-endpoints";
import DefaultImage from "#/favicon.webp";
import { markDone, markRunning } from "./api";
type DonateData = {
id: string;
name: string;
image: string;
image?: string;
amount: number;
message: string;
};
@@ -18,13 +21,22 @@ export default function () {
const [current, setCurrent] = useState<DonateData>();
const [mounted, setMounted] = useState(false);
const sfx = useRef<HTMLAudioElement | null>(null);
const onQueue = useRef<string[]>([]);
const queue = useRef<Promise<void>>(Promise.resolve());
function enqueue(data: DonateData) {
if (onQueue.current.includes(data.id)) return;
onQueue.current.push(data.id);
queue.current = queue.current.then(() => ping(data).catch(console.error));
}
async function ping(data: DonateData) {
setCurrent(data);
const { name, amount, message } = data;
markRunning(data.id);
console.log("Requesting TTS");
const tts = new Audio(
`/api/tts?message=${encodeURIComponent(`${name} โดเนทมา ${amount} บาท\n${message}`)}`,
`/api/tts?message=${encodeURIComponent(`"${name} โดเนทมา ${amount} บาท.. ${message}"`)}`,
);
tts.load();
console.log("Waiting for response");
@@ -47,7 +59,12 @@ export default function () {
tts.play();
if (ttsAvailable) await new Promise((r) => (tts.onended = r));
console.log("Done");
setTimeout(() => setMounted(false), 3000);
markDone(data.id);
await new Promise((r) => setTimeout(r, 3000));
setMounted(false);
await new Promise((r) => setTimeout(r, 1200));
console.log("Handing off");
onQueue.current = onQueue.current.filter((q) => q !== data.id);
}
const { name, amount, message, image } = current ?? {};
@@ -56,10 +73,10 @@ export default function () {
const pendingHeartbeat: Record<number, (v?: unknown) => void> = {};
const { clean } = sse.donate.subMany({
heartbeat: (tag) => pendingHeartbeat[tag]?.(),
ping,
ping: enqueue,
});
setInterval(async () => {
const interval = setInterval(async () => {
const tag = Math.floor(Math.random() * 1000);
const promise = new Promise(
(r, j) => ((pendingHeartbeat[tag] = r), setTimeout(j, 30000)),
@@ -74,13 +91,14 @@ export default function () {
console.error("Heartbeat timed out");
}
}, 10000);
return clean;
return () => {
clearInterval(interval);
clean();
};
// ping({
// image: TestImage as unknown as string,
// name: "Gayshin",
// amount: 1,
// message: `สวัสดีครับ วันนี้เรากำลังทดสอบระบบ Text to Speech แบบ mixed language โดยประโยคนี้จะสลับระหว่างภาษาไทยและ English หลายครั้ง เพื่อดูว่า model สามารถ handle pronunciation, pacing, และ sentence transition ได้ดีแค่ไหน ตัวอย่างเช่น "The quick brown fox jumps over the lazy dog" แล้วกลับมาเป็นภาษาไทยอีกครั้ง จากนั้นปิดท้ายด้วยคำว่า thank you for listening และขอให้มีวันที่ดีครับ`,
// // message: "ฮีตดฮีตดฮีตดฮีตดฮีตดฮีตด",
// message: `เสื้อดำเด้าหน่อย`,
// });
}, []);
@@ -94,7 +112,7 @@ export default function () {
className="absolute left-1/2 top-1/2 z-10 -translate-1/2"
initial={{ top: "200%", rotateZ: "600deg" }}
animate={{ top: "50%", rotateZ: "0deg" }}
exit={{ opacity: 0, transition: { delay: 0, duration: 2 } }}
exit={{ opacity: 0, transition: { delay: 0, duration: 1 } }}
transition={{ duration: 1, ease: "circOut" }}
>
<motion.div
@@ -104,7 +122,9 @@ export default function () {
transition={{ delay: 1, duration: 1, ease: "circOut" }}
>
<Image
src={image ?? ""}
src={image ?? DefaultImage}
width={128}
height={128}
alt="User submitted image"
className="aspect-square size-32 shrink-0 rounded-2xl bg-black/50"
/>
@@ -114,7 +134,7 @@ export default function () {
className="rounded-4xl bg-black/50 p-5 flex gap-3 max-w-162.5 h-39 overflow-hidden mx-auto"
initial={{ width: 0, padding: 0 }}
animate={{ width: "100%", padding: "20px" }}
exit={{ opacity: 0, transition: { delay: 0, duration: 2 } }}
exit={{ opacity: 0, transition: { delay: 0, duration: 1 } }}
transition={{ delay: 1, duration: 1, ease: "circOut" }}
>
<div className="aspect-square size-32 shrink-0 rounded-2xl" />
Binary file not shown.
Binary file not shown.