feat: SSE live fight spectating with spectator count
Enable real-time fight spectating for all live fights (not just human fights). Multiple spectators can watch simultaneously via SSE. Spectator count is tracked per-fight and broadcast with every SSE event. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a540320901
commit
610e799605
@@ -67,9 +67,23 @@ watch(() => route.params.fightId, async (newId) => {
|
||||
}
|
||||
if (isLive.value) {
|
||||
startPolling()
|
||||
connectSSE()
|
||||
if (isHumanFight.value) {
|
||||
startHumanPolling()
|
||||
connectSSE()
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
if (liveFightData.value) await initLiveScene()
|
||||
} else {
|
||||
// Bot-vs-bot spectating: init live scene for real-time viewing
|
||||
if (!liveFightData.value) await loadFight()
|
||||
if (!liveFightData.value) {
|
||||
// loadFight sets liveFightData only for human fights; set it for spectating too
|
||||
const res = await fetch(`/api/fights/${fightId.value}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.botA && data.botB) liveFightData.value = data
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
if (liveFightData.value) await initLiveScene()
|
||||
@@ -112,6 +126,7 @@ const liveSoundOn = ref(true)
|
||||
const liveAnnouncement = ref('')
|
||||
const liveAnnouncementColor = ref('#ffffff')
|
||||
const liveAnnouncementVisible = ref(false)
|
||||
const spectatorCount = ref(0)
|
||||
const currentChallengeInfo = ref<{ type: string; label: string } | null>(null)
|
||||
const pendingChallengeData = ref<{ data: any; receivedAt: number } | null>(null)
|
||||
const pendingSSEEvents = ref<{ type: string; data: any }[]>([])
|
||||
@@ -212,9 +227,11 @@ function startPolling() {
|
||||
pollCount++
|
||||
const s = await loadFight()
|
||||
if (s === 'finished') {
|
||||
// Human fights handle end via SSE fight_end event — don't transition here
|
||||
if (!isHumanFight.value) {
|
||||
// SSE fight_end handles transition for all live fights with SSE connected
|
||||
if (!eventSource) {
|
||||
// Fallback: no SSE connected, transition directly
|
||||
isLive.value = false
|
||||
disconnectSSE()
|
||||
stopHumanPolling()
|
||||
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
|
||||
}
|
||||
@@ -361,6 +378,22 @@ async function initLiveScene() {
|
||||
function connectSSE() {
|
||||
eventSource = new EventSource(`/api/fights/${fightId.value}/stream`)
|
||||
|
||||
eventSource.addEventListener('spectator_count', (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data)
|
||||
spectatorCount.value = data.count || 0
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
|
||||
eventSource.addEventListener('ping', (e) => {
|
||||
try {
|
||||
if (e.data) {
|
||||
const data = JSON.parse(e.data)
|
||||
if (data.spectators !== undefined) spectatorCount.value = data.spectators
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
|
||||
eventSource.addEventListener('round_start', (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data)
|
||||
@@ -409,7 +442,9 @@ function connectSSE() {
|
||||
|
||||
eventSource.addEventListener('round_end', (e) => {
|
||||
try {
|
||||
handleRoundEnd(JSON.parse(e.data)).catch(() => {})
|
||||
const data = JSON.parse(e.data)
|
||||
if (data.spectators !== undefined) spectatorCount.value = data.spectators
|
||||
handleRoundEnd(data).catch(() => {})
|
||||
} catch (err) {
|
||||
console.warn('[FightPage] SSE round_end failed:', err)
|
||||
}
|
||||
@@ -417,7 +452,9 @@ function connectSSE() {
|
||||
|
||||
eventSource.addEventListener('fight_end', (e) => {
|
||||
try {
|
||||
handleFightEnd(JSON.parse(e.data)).catch(() => {})
|
||||
const data = JSON.parse(e.data)
|
||||
if (data.spectators !== undefined) spectatorCount.value = data.spectators
|
||||
handleFightEnd(data).catch(() => {})
|
||||
} catch (err) {
|
||||
console.warn('[FightPage] SSE fight_end failed:', err)
|
||||
}
|
||||
@@ -441,6 +478,7 @@ function connectSSE() {
|
||||
|
||||
function disconnectSSE() {
|
||||
if (eventSource) { eventSource.close(); eventSource = null }
|
||||
spectatorCount.value = 0
|
||||
}
|
||||
|
||||
async function showLiveOverlay(text: string, color: string, duration: number) {
|
||||
@@ -816,6 +854,12 @@ function stopAutoBattle() {
|
||||
<span class="w-2 h-2 lg:w-2.5 lg:h-2.5 rounded-full bg-neon-yellow" />
|
||||
<span class="w-2 h-2 lg:w-2.5 lg:h-2.5 rounded-full bg-neon-green" />
|
||||
<span class="font-pixel text-[10px] text-text-muted ml-2 tracking-wider">BATTLE LOG</span>
|
||||
<span v-if="spectatorCount > 0" class="ml-auto font-pixel text-[10px] text-neon-cyan tracking-wider flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3 h-3">
|
||||
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
|
||||
</svg>
|
||||
{{ spectatorCount }}
|
||||
</span>
|
||||
</div>
|
||||
<div ref="liveLogEl" class="flex-1 overflow-y-auto p-2 sm:p-4 font-mono text-xs sm:text-sm space-y-1.5 leading-relaxed">
|
||||
<div v-for="(item, idx) in liveLogItems" :key="idx">
|
||||
@@ -969,6 +1013,7 @@ function stopAutoBattle() {
|
||||
<span class="font-pixel text-[9px] text-text-muted">
|
||||
{{ liveFightData.arenaInfo?.name }} | R{{ liveCurrentRound }}
|
||||
<span v-if="liveFightData.mode === 'ranked'" class="text-neon-cyan"> | ⚡{{ liveFightData.potSats || 42 }} SATS</span>
|
||||
<span v-if="spectatorCount > 0" class="text-neon-cyan"> | {{ spectatorCount }} watching</span>
|
||||
</span>
|
||||
<span class="font-pixel text-[9px]" :class="tierClass(liveFightData.botB.tier || 0)">{{ Math.round(liveFightData.botB.eloRating || 0) }}</span>
|
||||
</div>
|
||||
@@ -1003,17 +1048,153 @@ function stopAutoBattle() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LIVE BOT FIGHT: spinner -->
|
||||
<div v-else-if="isLive && !fight" class="flex-1 flex flex-col items-center justify-center gap-4">
|
||||
<div class="w-16 h-16 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
|
||||
<p class="font-display text-neon-pink text-xl tracking-widest animate-pulse glow-pink">FIGHT IN PROGRESS</p>
|
||||
<p class="font-mono text-text-muted text-xs">
|
||||
Round {{ liveRounds }} — webhooks being called...
|
||||
</p>
|
||||
<p v-if="autoBattle" class="font-pixel text-[10px] text-neon-yellow tracking-wider">
|
||||
AUTO BATTLE #{{ autoBattleCount + 1 }}
|
||||
<button class="ml-2 text-ko hover:text-text-primary transition-colors" @click="stopAutoBattle">STOP</button>
|
||||
</p>
|
||||
<!-- LIVE BOT FIGHT: spectator view with live scene -->
|
||||
<div v-else-if="isLive && !isHumanFight" class="flex-1 flex flex-col lg:flex-row gap-1 sm:gap-2 min-h-0 overflow-hidden">
|
||||
|
||||
<!-- Battle Log — mobile: bottom 40%, desktop: left 35% -->
|
||||
<div class="flex flex-col min-h-0 border border-border rounded-lg bg-black/90 overflow-hidden
|
||||
h-[40%] lg:h-auto lg:w-[35%] order-2 lg:order-1">
|
||||
<div class="bg-surface-raised border-b border-border px-3 py-1 lg:py-1.5 flex items-center gap-2 flex-shrink-0">
|
||||
<span class="w-2 h-2 lg:w-2.5 lg:h-2.5 rounded-full bg-ko" />
|
||||
<span class="w-2 h-2 lg:w-2.5 lg:h-2.5 rounded-full bg-neon-yellow" />
|
||||
<span class="w-2 h-2 lg:w-2.5 lg:h-2.5 rounded-full bg-neon-green" />
|
||||
<span class="font-pixel text-[10px] text-text-muted ml-2 tracking-wider">BATTLE LOG</span>
|
||||
<span v-if="spectatorCount > 0" class="ml-auto font-pixel text-[10px] text-neon-cyan tracking-wider flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3 h-3">
|
||||
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
|
||||
</svg>
|
||||
{{ spectatorCount }}
|
||||
</span>
|
||||
</div>
|
||||
<div ref="liveLogEl" class="flex-1 overflow-y-auto p-2 sm:p-4 font-mono text-xs sm:text-sm space-y-1.5 leading-relaxed">
|
||||
<div v-for="(item, idx) in liveLogItems" :key="idx">
|
||||
<div v-if="item.type === 'divider'" class="py-1.5"><div class="border-t border-white/5" /></div>
|
||||
<p v-else-if="item.type === 'header'" class="text-neon-purple font-bold text-base tracking-wide pt-3 pb-1 uppercase">{{ item.text }}</p>
|
||||
<div v-else-if="item.type === 'challenge'" class="bg-neon-green/[0.06] border border-neon-green/20 rounded-md px-3 py-1.5 my-1">
|
||||
<p class="text-neon-green text-xs font-mono leading-snug">{{ item.text }}</p>
|
||||
</div>
|
||||
<div v-else-if="item.type === 'responseA'" class="bg-neon-cyan/[0.04] border-l-2 border-neon-cyan/30 rounded-r-md px-3 py-1.5 my-1">
|
||||
<p class="text-neon-cyan text-sm leading-snug">{{ item.text }}</p>
|
||||
</div>
|
||||
<div v-else-if="item.type === 'responseB'" class="bg-neon-pink/[0.04] border-l-2 border-neon-pink/30 rounded-r-md px-3 py-1.5 my-1">
|
||||
<p class="text-neon-pink text-sm leading-snug">{{ item.text }}</p>
|
||||
</div>
|
||||
<div v-else-if="item.type === 'narration'" class="bg-neon-yellow/[0.06] border border-neon-yellow/20 rounded-md px-3 py-1.5 my-1">
|
||||
<p class="text-neon-yellow font-bold text-sm">{{ item.text }}</p>
|
||||
</div>
|
||||
<p v-else-if="item.type === 'result'" :class="['font-bold text-sm pl-2 py-0.5', item.color === 'neon-cyan' ? 'text-neon-cyan' : item.color === 'neon-pink' ? 'text-neon-pink' : 'text-text-secondary']">{{ item.text }}</p>
|
||||
<p v-else-if="item.type === 'system'" :class="['text-sm', item.color === 'neon-purple' ? 'text-neon-purple font-bold tracking-wider' : 'text-text-muted']">{{ item.text }}</p>
|
||||
</div>
|
||||
<div v-if="liveLogItems.length === 0" class="text-neon-purple italic pt-8 text-center text-sm">Waiting for fight to begin...</div>
|
||||
</div>
|
||||
|
||||
<!-- Spectator footer -->
|
||||
<div class="px-3 py-2 border-t border-border bg-surface-raised/80 flex-shrink-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-mono text-text-muted text-xs">
|
||||
<span v-if="liveCurrentRound > 0">Round {{ liveCurrentRound }}</span>
|
||||
<span v-else>Waiting for fight...</span>
|
||||
</p>
|
||||
<button
|
||||
class="w-7 h-7 flex items-center justify-center border border-border/50 text-text-muted
|
||||
hover:text-neon-cyan hover:border-neon-cyan/50 transition-all"
|
||||
:title="liveSoundOn ? 'Mute' : 'Unmute'"
|
||||
@click="toggleLiveSound"
|
||||
>
|
||||
<svg v-if="liveSoundOn" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3.5 h-3.5">
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07"/>
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3.5 h-3.5">
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5z"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="autoBattle" class="font-pixel text-[10px] text-neon-yellow tracking-wider mt-1">
|
||||
AUTO BATTLE #{{ autoBattleCount + 1 }}
|
||||
<button class="ml-2 text-ko hover:text-text-primary transition-colors" @click="stopAutoBattle">STOP</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Game Canvas — mobile: top 60%, desktop: right 65% -->
|
||||
<div class="h-[60%] lg:h-auto lg:flex-1 lg:w-[65%] flex flex-col min-h-0 border border-border rounded-lg bg-black overflow-hidden order-1 lg:order-2">
|
||||
|
||||
<!-- Health bars -->
|
||||
<div v-if="liveFightData?.botA" class="px-2 sm:px-3 py-1 sm:py-2 bg-surface-raised/80 border-b border-border flex-shrink-0">
|
||||
<!-- Mobile: compact single-row names + HP -->
|
||||
<div class="sm:hidden">
|
||||
<div class="flex items-center gap-1">
|
||||
<p class="font-marker text-[10px] tracking-wider truncate text-neon-cyan flex-1 min-w-0">{{ liveFightData.botA.name }}</p>
|
||||
<span class="font-mono font-bold text-[10px] w-5 text-right tabular-nums" :class="liveHpA > 50 ? 'text-neon-cyan' : liveHpA > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpA }}</span>
|
||||
<div class="w-6 h-2.5 bg-black/50 rounded-sm overflow-hidden border border-neon-cyan/30">
|
||||
<div class="h-full bg-neon-cyan transition-all duration-500" :style="{ width: `${liveHpA}%` }" />
|
||||
</div>
|
||||
<span class="font-funky text-neon-purple text-[10px] px-0.5 flex-shrink-0">VS</span>
|
||||
<div class="w-6 h-2.5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30">
|
||||
<div class="h-full bg-neon-pink transition-all duration-500 ml-auto" :style="{ width: `${liveHpB}%` }" />
|
||||
</div>
|
||||
<span class="font-mono font-bold text-[10px] w-5 text-left tabular-nums" :class="liveHpB > 50 ? 'text-neon-pink' : liveHpB > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpB }}</span>
|
||||
<p class="font-marker text-[10px] tracking-wider truncate text-right text-neon-pink flex-1 min-w-0">{{ liveFightData.botB.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Desktop: single-row layout -->
|
||||
<div class="hidden sm:block">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-marker text-sm tracking-wider truncate text-neon-cyan flex-shrink-0 max-w-[20%]">{{ liveFightData.botA.name }}</p>
|
||||
<div class="flex-1 h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-cyan/30">
|
||||
<div class="h-full bg-gradient-to-r from-neon-cyan to-neon-purple transition-all duration-500" :style="{ width: `${liveHpA}%` }" />
|
||||
</div>
|
||||
<span class="font-mono font-bold text-sm w-8 text-right tabular-nums" :class="liveHpA > 50 ? 'text-neon-cyan' : liveHpA > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpA }}</span>
|
||||
<span class="font-funky text-neon-purple text-xl px-1">VS</span>
|
||||
<span class="font-mono font-bold text-sm w-8 text-left tabular-nums" :class="liveHpB > 50 ? 'text-neon-pink' : liveHpB > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpB }}</span>
|
||||
<div class="flex-1 h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30">
|
||||
<div class="h-full bg-gradient-to-l from-neon-pink to-neon-purple transition-all duration-500 ml-auto" :style="{ width: `${liveHpB}%` }" />
|
||||
</div>
|
||||
<p class="font-marker text-sm tracking-wider truncate text-right text-neon-pink flex-shrink-0 max-w-[20%]">{{ liveFightData.botB.name }}</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-0.5">
|
||||
<span class="font-pixel text-[9px]" :class="tierClass(liveFightData.botA.tier || 0)">{{ Math.round(liveFightData.botA.eloRating || 0) }}</span>
|
||||
<span class="font-pixel text-[9px] text-text-muted">
|
||||
{{ liveFightData.arenaInfo?.name }} | R{{ liveCurrentRound }}
|
||||
<span v-if="spectatorCount > 0" class="text-neon-cyan ml-1">| {{ spectatorCount }} watching</span>
|
||||
</span>
|
||||
<span class="font-pixel text-[9px]" :class="tierClass(liveFightData.botB.tier || 0)">{{ Math.round(liveFightData.botB.eloRating || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canvas area -->
|
||||
<div class="flex-1 relative min-h-0">
|
||||
<canvas ref="liveCanvas" class="w-full h-full block" />
|
||||
|
||||
<!-- Floating announcement -->
|
||||
<Transition name="announce">
|
||||
<div v-if="liveAnnouncementVisible"
|
||||
class="absolute inset-0 flex items-center justify-center pointer-events-none z-20">
|
||||
<p class="font-funky text-2xl sm:text-5xl lg:text-7xl tracking-widest uppercase announce-text"
|
||||
:style="{ color: liveAnnouncementColor, textShadow: `0 0 20px ${liveAnnouncementColor}, 0 0 40px ${liveAnnouncementColor}` }">
|
||||
{{ liveAnnouncement }}
|
||||
</p>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Loading scene overlay -->
|
||||
<div v-if="!liveSceneReady && liveFightData" class="absolute inset-0 flex items-center justify-center bg-black/80 z-10">
|
||||
<div class="text-center">
|
||||
<div class="w-12 h-12 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin mx-auto mb-3" />
|
||||
<p class="font-display text-neon-pink tracking-widest animate-pulse">LOADING ARENA...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No fight data yet -->
|
||||
<div v-if="!liveFightData" class="absolute inset-0 flex flex-col items-center justify-center bg-black z-10 gap-4">
|
||||
<div class="w-16 h-16 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
|
||||
<p class="font-display text-neon-pink text-xl tracking-widest animate-pulse glow-pink">FIGHT IN PROGRESS</p>
|
||||
<p class="font-mono text-text-muted text-xs">Connecting to live fight...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="fightError" class="flex-1 flex flex-col items-center justify-center gap-3">
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getVoiceMap, isKokoroReady, isKokoroLoading } from '../game/tts'
|
||||
import { speak, ensureAudioContext } from '../game/sounds'
|
||||
|
||||
const voiceMap = getVoiceMap()
|
||||
const allProfiles = Object.entries(voiceMap).map(([name, { voice, speed }]) => ({
|
||||
name,
|
||||
kokoroVoice: voice,
|
||||
speed,
|
||||
}))
|
||||
|
||||
const customText = ref('Devastating blow! That had to hurt!')
|
||||
const filter = ref('')
|
||||
const audioReady = ref(false)
|
||||
const playing = ref<string | null>(null)
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (!filter.value) return allProfiles
|
||||
const q = filter.value.toLowerCase()
|
||||
return allProfiles.filter(p =>
|
||||
p.name.includes(q) || p.kokoroVoice.includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
// Group by category based on voice name prefix
|
||||
const categories = computed(() => {
|
||||
const groups: Record<string, typeof allProfiles> = {}
|
||||
for (const p of filtered.value) {
|
||||
let cat = 'Other'
|
||||
if (['announcer', 'deep', 'smooth', 'question_reader', 'news'].includes(p.name)) cat = 'Authoritative'
|
||||
else if (['hype', 'screamer', 'sportscaster', 'auctioneer', 'hyper', 'punk', 'drill', 'karen', 'terrified', 'power_up', 'wrestler_v'].includes(p.name)) cat = 'High Energy'
|
||||
else if (['boomer', 'movie', 'demon_v', 'final_boss', 'boss_taunt', 'game_over', 'giant', 'mainframe'].includes(p.name)) cat = 'Deep / Menacing'
|
||||
else if (['preacher', 'wizard_v', 'sensei', 'professor', 'ancient', 'opera'].includes(p.name)) cat = 'Calm / Wise'
|
||||
else if (['chipmunk', 'baby', 'fairy', 'angel', 'tutorial', 'valley'].includes(p.name)) cat = 'Cute / High'
|
||||
else if (['robot', 'ai_core', 'mech', 'android_v', 'siri', 'hal', 'dial_up', 'glitch', 'glitchbot'].includes(p.name)) cat = 'Robots'
|
||||
else if (['posh', 'aussie', 'scottish', 'french', 'texan'].includes(p.name)) cat = 'Accents'
|
||||
else if (['whisper', 'surfer', 'pirate_v', 'cowboy_v', 'ninja_v', 'alien_v', 'echo_v'].includes(p.name)) cat = 'Characters'
|
||||
else if (['grandpa', 'grandma', 'crotchety'].includes(p.name)) cat = 'Old People'
|
||||
else if (['drunk', 'sleepy', 'stoner', 'npc', 'conspiracy'].includes(p.name)) cat = 'Misc Characters'
|
||||
if (!groups[cat]) groups[cat] = []
|
||||
groups[cat].push(p)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
async function initAudio() {
|
||||
await ensureAudioContext()
|
||||
audioReady.value = true
|
||||
}
|
||||
|
||||
function playVoice(profileName: string) {
|
||||
if (!audioReady.value) return
|
||||
playing.value = profileName
|
||||
speak(customText.value || 'Devastating blow! That had to hurt!', profileName, true)
|
||||
setTimeout(() => { if (playing.value === profileName) playing.value = null }, 3000)
|
||||
}
|
||||
|
||||
const samplePhrases = [
|
||||
'Devastating blow! That had to hurt!',
|
||||
'Round one! Fight!',
|
||||
'K. O.! And the winner is...',
|
||||
'What an incredible combo!',
|
||||
'The crowd goes wild!',
|
||||
'Is that all you got?',
|
||||
'Satoshi would be proud!',
|
||||
'Lightning fast attack!',
|
||||
'Not your keys, not your coins!',
|
||||
'Stack sats and throw hands!',
|
||||
]
|
||||
|
||||
function randomPhrase() {
|
||||
customText.value = samplePhrases[Math.floor(Math.random() * samplePhrases.length)]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-black text-green-400 p-4 sm:p-8 font-mono">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-cyan-400 mb-2">VOICE SOUNDBOARD</h1>
|
||||
<p class="text-zinc-500 text-sm mb-6">{{ allProfiles.length }} voice profiles. Click to preview.</p>
|
||||
|
||||
<!-- Audio init banner -->
|
||||
<div v-if="!audioReady" class="mb-6">
|
||||
<button
|
||||
@click="initAudio"
|
||||
class="px-6 py-3 bg-cyan-600 hover:bg-cyan-500 text-black font-bold rounded text-lg transition-colors"
|
||||
>
|
||||
CLICK TO ENABLE AUDIO
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div v-if="audioReady" class="mb-4 flex items-center gap-3 text-sm">
|
||||
<span v-if="isKokoroReady()" class="text-green-400">Kokoro TTS: READY</span>
|
||||
<span v-else-if="isKokoroLoading()" class="text-yellow-400">Kokoro TTS: Loading model...</span>
|
||||
<span v-else class="text-zinc-500">Kokoro TTS: Not loaded (using Web Speech fallback)</span>
|
||||
</div>
|
||||
|
||||
<!-- Custom text + filter -->
|
||||
<div class="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<div class="flex-1 flex gap-2">
|
||||
<input
|
||||
v-model="customText"
|
||||
class="flex-1 bg-zinc-900 border border-zinc-700 rounded px-3 py-2 text-green-400 text-sm focus:border-cyan-500 focus:outline-none"
|
||||
placeholder="Type custom text to speak..."
|
||||
/>
|
||||
<button
|
||||
@click="randomPhrase"
|
||||
class="px-3 py-2 bg-zinc-800 hover:bg-zinc-700 border border-zinc-600 rounded text-xs text-zinc-400 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Random
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="filter"
|
||||
class="sm:w-48 bg-zinc-900 border border-zinc-700 rounded px-3 py-2 text-green-400 text-sm focus:border-cyan-500 focus:outline-none"
|
||||
placeholder="Filter voices..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Voice grid by category -->
|
||||
<div v-for="(profiles, category) in categories" :key="category" class="mb-8">
|
||||
<h2 class="text-lg font-bold text-yellow-400 mb-3 border-b border-zinc-800 pb-1">{{ category }}</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
|
||||
<button
|
||||
v-for="p in profiles"
|
||||
:key="p.name"
|
||||
:disabled="!audioReady"
|
||||
@click="playVoice(p.name)"
|
||||
class="text-left px-3 py-2 rounded border transition-all"
|
||||
:class="[
|
||||
playing === p.name
|
||||
? 'bg-cyan-900/40 border-cyan-500 text-cyan-300'
|
||||
: 'bg-zinc-900/60 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/60',
|
||||
!audioReady && 'opacity-40 cursor-not-allowed'
|
||||
]"
|
||||
>
|
||||
<div class="font-bold text-sm" :class="playing === p.name ? 'text-cyan-300' : 'text-green-400'">
|
||||
{{ p.name }}
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 mt-0.5">
|
||||
{{ p.kokoroVoice }} @ {{ p.speed }}x
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user