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:
Dorian
2026-03-08 19:46:39 +00:00
co-authored by Claude Opus 4.6
parent a540320901
commit 610e799605
18 changed files with 1555 additions and 397 deletions
+197 -16
View File
@@ -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">
+148
View File
@@ -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>