import { useEffect, useRef, useState } from "react"; import { FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View } from "react-native"; import { useLocalSearchParams } from "expo-router"; import type { Character } from "@kokorone/shared"; import { API_BASE_URL, ROOM_USER_SPEAKER_ID, endRoom, fetchExpression, getPossessionStatus, getRoom, getRoomUsage, reportRoomRead, sendRoomMessage, setRoomMuted, speakAsPossessed, subscribeRoomStream, synthesizeVoice, type PossessionStatus, type RoomDetail, type RoomTurnKind, type RoomUsageSummary, type TachieExpressionResponse, } from "../../lib/api"; import { RoomStage, type StageParticipant } from "../../components/RoomStage"; import { isVoiceAutoplayEnabled } from "../../lib/voice-autoplay-settings"; const USER_ID = "seed-user-primary"; const USER_LABEL = "你"; // T20-P3:與 apps/web 的 RoomChatView.tsx 對應——佇列積壓過長時只留最近幾則待播。 const VOICE_QUEUE_MAX_BACKLOG = 3; interface VoiceQueueItem { characterId: string; text: string; } // T20-P3:與 apps/web 一致,ACTION 沿用既有 ChatBubble 的敘述樣式(此處以斜體呈現),其餘三種標籤。 const TURN_KIND_LABEL: Partial> = { DELEGATE: "委託", LEAVE: "離開", SYSTEM: "系統", }; type FeedItem = | { kind: "turn"; seq: number; speakerId: string; speakerLabel: string; content: string; turnKind: RoomTurnKind } | { kind: "member"; seq: number; characterId: string; action: "joined" | "left" } | { kind: "closed"; seq: number; reason: string }; // T20-P2:通用房間畫面——取代 T20-P1 唯讀最小可用版,改為可發言、以 fetch streaming(見 // lib/api.ts 的 subscribeRoomStream,非原生 EventSource)即時收新訊息、並依 unread(T11)畫出 // 第一則未讀分隔線,與 apps/web 的 app/room/[roomId] 對應同一版面與行為。 export default function RoomScreen() { const { roomId } = useLocalSearchParams<{ roomId: string }>(); const [room, setRoom] = useState(null); const [characters, setCharacters] = useState([]); const [feed, setFeed] = useState([]); const [firstUnreadSeq, setFirstUnreadSeq] = useState(null); const [ended, setEnded] = useState(false); const [text, setText] = useState(""); const [pending, setPending] = useState(false); const [error, setError] = useState(null); const listRef = useRef>(null); // T20-P5:套用中時,這個房間的輸入區要換成「以套用角色的身分發話」,不提供以自己身分發話的選項。 const [possession, setPossession] = useState(null); useEffect(() => { void getPossessionStatus(USER_ID).then(setPossession).catch(() => undefined); }, [roomId]); // T20-P3:立繪舞台(目前發話者高亮)與用量顯示。 const [expressions, setExpressions] = useState>({}); const [speakingCharacterId, setSpeakingCharacterId] = useState(null); const [usage, setUsage] = useState(null); // T20-P3:自走語音自動播放——與 apps/web 對應,但行動端無瀏覽器自動播放政策,不需要解鎖手勢; // 佇列、播放中旗標一律用 ref,避免串流事件回呼讀到註冊當下就固定住的舊值(stale closure)。 const [autoplayEnabled, setAutoplayEnabled] = useState(true); const autoplayEnabledRef = useRef(true); const mutedRef = useRef(false); const voiceQueueRef = useRef([]); const voicePlayingRef = useRef(false); const voiceTimerRef = useRef | null>(null); const unmountedRef = useRef(false); useEffect(() => { void isVoiceAutoplayEnabled().then((value) => { autoplayEnabledRef.current = value; setAutoplayEnabled(value); }); return () => { unmountedRef.current = true; if (voiceTimerRef.current) clearTimeout(voiceTimerRef.current); voiceQueueRef.current = []; }; }, []); function refreshExpression(characterId: string) { fetchExpression(characterId, USER_ID) .then((expression) => setExpressions((prev) => ({ ...prev, [characterId]: expression }))) .catch(() => undefined); } function enqueueVoice(item: VoiceQueueItem) { if (!autoplayEnabledRef.current || mutedRef.current) return; voiceQueueRef.current.push(item); if (voiceQueueRef.current.length > VOICE_QUEUE_MAX_BACKLOG) { voiceQueueRef.current.splice(0, voiceQueueRef.current.length - VOICE_QUEUE_MAX_BACKLOG); } void processVoiceQueue(); } async function processVoiceQueue() { if (voicePlayingRef.current || unmountedRef.current) return; const next = voiceQueueRef.current.shift(); if (!next) return; voicePlayingRef.current = true; try { const result = await synthesizeVoice(next.characterId, USER_ID, next.text); if (unmountedRef.current) return; if (result.audioRef.startsWith("mock://")) { await new Promise((resolve) => { voiceTimerRef.current = setTimeout(resolve, Math.max(result.durationEstimateMs, 300)); }); } // T20-P3 已知限制:尚未安裝原生音訊播放套件(expo-audio/expo-av),真實音檔(非 mock://) // 目前僅靜默略過、不會真的播放——見本項 wiki 完成註記;接上真實 TTS Provider 前需另外補上。 } catch { // 合成失敗:安靜跳過這一則,不阻斷佇列後續播放。 } finally { voicePlayingRef.current = false; if (!unmountedRef.current) void processVoiceQueue(); } } useEffect(() => { fetch(`${API_BASE_URL}/characters`) .then((res) => res.json() as Promise) .then(setCharacters) .catch(() => undefined); }, []); function characterLabel(characterId: string): string { return characters.find((c) => c.id === characterId)?.aliases?.[0]?.formalName ?? characterId; } async function handleToggleMute() { if (!room || pending) return; const next = !room.muted; try { await setRoomMuted(room.id, next); mutedRef.current = next; setRoom((prev) => (prev ? { ...prev, muted: next } : prev)); } catch { setError("靜音設定失敗,請確認 api 服務是否已啟動"); } } useEffect(() => { if (!roomId) return; let cancelled = false; let unsubscribe: (() => void) | null = null; async function init() { try { const detail = await getRoom(roomId); if (cancelled) return; setRoom(detail); mutedRef.current = detail.muted; setEnded(detail.status === "ENDED"); setFirstUnreadSeq(detail.unread.firstUnreadSeq); setUsage(detail.usage); setFeed( detail.turns.map((turn) => ({ kind: "turn", seq: turn.seq, speakerId: turn.speakerId, speakerLabel: turn.speakerLabel, content: turn.content, turnKind: turn.kind, })), ); for (const characterId of detail.participants) refreshExpression(characterId); const lastTurn = detail.turns.at(-1); if (lastTurn && lastTurn.speakerId !== ROOM_USER_SPEAKER_ID) setSpeakingCharacterId(lastTurn.speakerId); const lastSeq = detail.turns.length > 0 ? detail.turns[detail.turns.length - 1].seq : -1; unsubscribe = subscribeRoomStream(roomId, lastSeq, (event) => { if (event.type === "turn") { const { kind: turnKind, ...rest } = event.data; setFeed((prev) => prev.some((item) => item.kind === "turn" && item.seq === event.seq) ? prev : [...prev, { kind: "turn", seq: event.seq, turnKind, ...rest }], ); void reportRoomRead(roomId, USER_ID, event.seq).catch(() => undefined); if (event.data.speakerId !== ROOM_USER_SPEAKER_ID) { enqueueVoice({ characterId: event.data.speakerId, text: event.data.content }); setSpeakingCharacterId(event.data.speakerId); refreshExpression(event.data.speakerId); void getRoomUsage(roomId).then(setUsage).catch(() => undefined); } } else if (event.type === "member") { setFeed((prev) => [...prev, { kind: "member", seq: event.seq, ...event.data }]); } else if (event.type === "closed") { setFeed((prev) => [...prev, { kind: "closed", seq: event.seq, reason: event.data.reason }]); setEnded(true); } }); } catch { if (!cancelled) setError("房間查詢失敗,請確認 api 服務是否已啟動"); } } void init(); return () => { cancelled = true; unsubscribe?.(); }; }, [roomId]); // T20-P5:只有「套用中且她本人就在這個房間」才切換成以她的身分發話。 const possessedElsewhere = Boolean(possession?.possessingCharacterId) && !room?.participants.includes(possession!.possessingCharacterId!); const possessedCharacterId = possession?.possessingCharacterId && room?.participants.includes(possession.possessingCharacterId) ? possession.possessingCharacterId : null; async function handleSend() { const trimmed = text.trim(); if (!trimmed || pending || ended || !roomId) return; setPending(true); setError(null); try { if (possessedCharacterId) { await speakAsPossessed(roomId, USER_ID, trimmed); } else { await sendRoomMessage(roomId, USER_ID, USER_LABEL, trimmed); } setText(""); // 新訊息透過串流推送進 feed,這裡不自行 append } catch (err) { setError(err instanceof Error ? err.message : "發言失敗,請確認 api 服務是否已啟動"); } finally { setPending(false); } } async function handleEnd() { if (pending || ended || !roomId) return; setPending(true); setError(null); try { await endRoom(roomId); setEnded(true); } catch { setError("結束房間失敗,請確認 api 服務是否已啟動"); } finally { setPending(false); } } if (error && !room) { return ( {error} ); } if (!room) { return ( 載入中…… ); } return ( {room.mode}.{ended ? "已結束" : "進行中"} {usage && usage.generationCount > 0 ? `.${usage.totalPromptTokens + usage.totalCompletionTokens} tokens` : ""} void handleToggleMute()} disabled={ended}> {room.muted ? "🔇 已靜音" : "🔊 靜音"} 結束房間 ((characterId) => ({ characterId, label: characterLabel(characterId), expression: expressions[characterId] ?? null, }))} speakingCharacterId={speakingCharacterId} /> `${item.kind}-${item.seq}`} onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: true })} ListEmptyComponent={還沒有人說話。} renderItem={({ item }) => ( {firstUnreadSeq !== null && item.seq === firstUnreadSeq && ( 未讀訊息 )} {item.kind === "turn" && ( {TURN_KIND_LABEL[item.turnKind] && {TURN_KIND_LABEL[item.turnKind]}} {item.speakerLabel} {item.content} )} {item.kind === "member" && ( {characterLabel(item.characterId)} {item.action === "joined" ? "進來了" : "離開了"} )} {item.kind === "closed" && 房間已關閉} )} /> {error && {error}} {!ended && possessedCharacterId && ( 你正以「{characterLabel(possessedCharacterId)}」的身分發話 )} {!ended && possessedElsewhere && ( 套用中的角色不在這個房間,無法在此發話——請到她所在的房間,或先退出套用。 )} {!ended && !possessedElsewhere && ( 送出 )} ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#FFF5F7" }, headerRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16, paddingTop: 12, }, title: { fontSize: 14, fontWeight: "bold", color: "#FF7E9D", flexShrink: 1 }, headerButtons: { flexDirection: "row", alignItems: "center", gap: 12 }, endButton: { fontSize: 12, color: "#8a6b6f" }, endButtonDisabled: { opacity: 0.5 }, content: { flex: 1 }, contentInner: { padding: 16, gap: 8 }, feedItem: { gap: 6 }, dividerRow: { flexDirection: "row", alignItems: "center", gap: 8 }, dividerLine: { flex: 1, height: 1, backgroundColor: "#FF7E9D" }, dividerText: { fontSize: 10, color: "#FF7E9D" }, hint: { fontSize: 12, color: "#8a6b6f", padding: 16 }, error: { fontSize: 12, color: "#FF7E9D", paddingHorizontal: 16 }, possessionNotice: { fontSize: 11, fontWeight: "bold", color: "#FF7E9D", textAlign: "center", paddingVertical: 4 }, bubble: { maxWidth: "80%", borderRadius: 16, padding: 10, gap: 2 }, bubbleUser: { alignSelf: "flex-end", backgroundColor: "#FF7E9D" }, bubbleCharacter: { alignSelf: "flex-start", backgroundColor: "#EFEFEF" }, speakerLabel: { fontSize: 10, color: "#8a6b6f" }, bubbleText: { color: "#222" }, actionText: { fontStyle: "italic", color: "#9B8CFF" }, turnKindBadge: { alignSelf: "flex-start", fontSize: 10, color: "#FF7E9D", backgroundColor: "#FFE3EA", borderRadius: 999, paddingHorizontal: 8, paddingVertical: 1, marginBottom: 2, }, systemNote: { alignSelf: "center", fontSize: 10, color: "#8a6b6f" }, inputRow: { flexDirection: "row", alignItems: "center", gap: 8, margin: 12, paddingHorizontal: 16, paddingVertical: 8, borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", }, input: { flex: 1, fontSize: 14 }, sendButton: { color: "#FF7E9D", fontWeight: "bold" }, sendButtonDisabled: { opacity: 0.5 }, });