Files
Kokorone/apps/web/components/VoicePlaybackButton.tsx
jiantw83andClaude Sonnet 5 1bea06e3cd feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:10:26 +08:00

81 lines
2.7 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { synthesizeVoice } from "../lib/api";
type PlaybackState = "idle" | "loading" | "playing" | "unavailable";
interface VoicePlaybackButtonProps {
characterId: string;
userId: string;
text: string;
}
// P-6:語音播放介面——呼叫 /voice/:characterId/:userId/synthesize 取得合成結果後播放。
// TTS_PROVIDER=mock 回傳的 audioRef 是 `mock://` 佔位字串(非真實音檔),改用計時器模擬播放時長;
// 之後接上真實供應商、audioRef 變成可播放網址時走真正的 <audio> 播放,呼叫端不用改一行。
// 合成失敗(網路或後端錯誤)時直接隱藏這顆按鈕,退回純文字顯示,不影響訊息本身、不阻斷對話。
export function VoicePlaybackButton({ characterId, userId, text }: VoicePlaybackButtonProps) {
const [state, setState] = useState<PlaybackState>("idle");
const audioRef = useRef<HTMLAudioElement | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
audioRef.current?.pause();
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, []);
function stopPlayback() {
audioRef.current?.pause();
audioRef.current = null;
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
setState("idle");
}
async function startPlayback() {
setState("loading");
try {
const result = await synthesizeVoice(characterId, userId, text);
if (result.audioRef.startsWith("mock://")) {
setState("playing");
timerRef.current = setTimeout(() => setState("idle"), Math.max(result.durationEstimateMs, 300));
return;
}
const audio = new Audio(result.audioRef);
audio.addEventListener("ended", () => setState("idle"));
audio.addEventListener("error", () => setState("unavailable"));
audioRef.current = audio;
await audio.play();
setState("playing");
} catch {
setState("unavailable");
}
}
if (state === "unavailable") {
return null;
}
return (
<button
type="button"
onClick={() => (state === "playing" ? stopPlayback() : void startPlayback())}
disabled={state === "loading"}
aria-label={state === "playing" ? "停止播放語音" : "播放語音"}
className="mt-1 inline-flex w-fit items-center gap-1 rounded-full px-2 py-0.5 text-[11px] disabled:opacity-50"
style={{ color: "var(--accent)", background: "var(--chip-bg)" }}
>
{state === "loading" && "合成中…"}
{state === "playing" && "■ 停止"}
{state === "idle" && "▶ 播放語音"}
</button>
);
}