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
@@ -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