fix: mobile nostr signer detection, mobile TTS auto-unlock, button loaders

- Nostr signer: poll for window.nostr up to 3s on mobile (Amber injects
  late). Both login() and handleSignerLogin() now wait before failing.
- Mobile TTS: install global one-time click/touch/keydown handler to
  auto-unlock AudioContext when fight pages mount. Previously only
  triggered by explicit sound toggle, so mobile TTS silently failed.
- Add loading spinners to "I BUILD BOTS" and "I FIGHT MYSELF" buttons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-09 14:44:38 +00:00
co-authored by Claude Opus 4.6
parent 4897335686
commit dd3cbdae7f
5 changed files with 80 additions and 18 deletions
+4 -1
View File
@@ -7,7 +7,7 @@ import {
announceFinishHim, announceFlawlessVictory,
sfxCrowdCheer, sfxCrowdGasp, sfxCrowdOoh, sfxApplause, sfxDrumRoll,
setMusicIntensity, stopAllAudio,
setMasterMute, isMasterMuted, ensureAudioContext,
setMasterMute, isMasterMuted, ensureAudioContext, installAutoUnlock,
speakQuestion, speakAnswer, speakNarration,
prefetchQuestion, prefetchAnswer, prefetchNarration,
awaitQuestionReady, awaitAnswerReady,
@@ -144,6 +144,9 @@ function togglePerfMode() {
const logItems = ref<{ type: string; round: number; text: string; color: string }[]>([])
onMounted(async () => {
// Auto-unlock AudioContext on first user interaction (critical for mobile TTS)
if (soundOn.value) installAutoUnlock()
if (props.autoplay) {
// Fresh fight — replay() will call initScene(), no need to double-init
await nextTick()
+22 -6
View File
@@ -112,6 +112,17 @@ export function useNostr() {
const isLoggedIn = computed(() => !!pubkey.value && !!bot.value)
const hasExtension = computed(() => !!window.nostr)
/** Wait for window.nostr to appear (mobile signers inject late) */
async function waitForSigner(timeoutMs = 3000): Promise<boolean> {
if (window.nostr) return true
const start = Date.now()
while (Date.now() - start < timeoutMs) {
await new Promise(r => setTimeout(r, 200))
if (window.nostr) return true
}
return false
}
/**
* Authenticate with the server using NIP-98 signed request.
* Works with NIP-07 extension, Amber signer, or local nsec.
@@ -159,17 +170,21 @@ export function useNostr() {
*/
async function login(): Promise<{ pubkey: string; bot: BotData | null }> {
if (!window.nostr) {
// Fall back to stored nsec if available
const storedNsec = localStorage.getItem('bf_nsec')
if (storedNsec) {
return loginWithNsec(storedNsec)
// Mobile signers (Amber) inject window.nostr late — poll for up to 3s
const found = await waitForSigner(3000)
if (!found) {
// Fall back to stored nsec if available
const storedNsec = localStorage.getItem('bf_nsec')
if (storedNsec) {
return loginWithNsec(storedNsec)
}
throw new Error('No Nostr signer found. Install a browser extension or use "Generate New Identity".')
}
throw new Error('No Nostr signer found. Install a browser extension or use "Generate New Identity".')
}
// Verify extension is responsive
try {
await window.nostr.getPublicKey()
await window.nostr!.getPublicKey()
} catch {
throw new Error('Nostr signer denied access. Approve the request and try again.')
}
@@ -419,6 +434,7 @@ export function useNostr() {
hasExtension,
hasStoredKey,
login,
waitForSigner,
generateLogin,
loginWithNsec,
registerBot,
+21
View File
@@ -16,6 +16,27 @@ export function stopAllAudio() {
resetGainNodes()
}
// === CROSS-CUTTING: auto-unlock AudioContext on first user gesture ===
// Mobile browsers require a user gesture to unlock AudioContext. Instead of
// relying solely on the sound toggle button, install a one-time listener on
// the first click/tap/keydown anywhere on the page.
let _autoUnlockInstalled = false
export function installAutoUnlock() {
if (_autoUnlockInstalled) return
_autoUnlockInstalled = true
const handler = () => {
ensureAudioContext()
document.removeEventListener('click', handler, true)
document.removeEventListener('touchstart', handler, true)
document.removeEventListener('keydown', handler, true)
}
document.addEventListener('click', handler, true)
document.addEventListener('touchstart', handler, true)
document.addEventListener('keydown', handler, true)
}
// === CROSS-CUTTING: ensureAudioContext ===
// Must be called from user gesture (click/tap) to unlock audio on mobile browsers
+4 -1
View File
@@ -11,7 +11,7 @@ import {
fanfareRound,
sfxCrowdCheer, sfxApplause, sfxDrumRoll,
announceFinishHim, announceFlawlessVictory, announceDeepIntro,
setMusicIntensity, stopAllAudio, ensureAudioContext, setMasterMute,
setMusicIntensity, stopAllAudio, ensureAudioContext, installAutoUnlock, setMasterMute,
} from '../game/audio'
const route = useRoute()
@@ -444,6 +444,9 @@ watch(liveFightData, async (val) => {
// --- Mount / Unmount ---
onMounted(async () => {
// Auto-unlock AudioContext on first user interaction (critical for mobile TTS)
if (liveSoundOn.value) installAutoUnlock()
let status = await loadFight()
// If fetch failed (offline), try IndexedDB cache
+29 -10
View File
@@ -10,7 +10,7 @@ import WalletConnect from '../components/WalletConnect.vue'
import { authFetch } from '../lib/nostr-auth'
const router = useRouter()
const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, hasStoredKey, isLoading, login, generateLogin, loginWithNsec, registerBot, registerHuman, getStoredNsec, logout } = useNostr()
const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, hasStoredKey, isLoading, login, waitForSigner, generateLogin, loginWithNsec, registerBot, registerHuman, getStoredNsec, logout } = useNostr()
const showNsecBackup = ref(false)
const generatedNsec = ref('')
const nsecInput = ref('')
@@ -185,10 +185,16 @@ async function handleLogin() {
}
}
function handleSignerLogin() {
async function handleSignerLogin() {
if (!window.nostr) {
error.value = 'No Nostr signer detected. Install a NIP-07 extension (Nos2x, Alby) or use Amber on Android.'
return
// Mobile signers inject late — wait briefly
error.value = 'Looking for signer...'
const found = await waitForSigner(3000)
if (!found) {
error.value = 'No Nostr signer detected. Install a NIP-07 extension (Nos2x, Alby) or use Amber on Android.'
return
}
error.value = ''
}
handleLogin()
}
@@ -295,14 +301,21 @@ async function confirmName() {
step.value = 'bot-setup'
}
function chooseBot() {
const isChoosingMode = ref(false)
async function chooseBot() {
isChoosingMode.value = true
isHumanMode.value = false
await new Promise(r => setTimeout(r, 0)) // Let spinner render
step.value = 'pick-character'
isChoosingMode.value = false
}
function chooseHuman() {
async function chooseHuman() {
isChoosingMode.value = true
isHumanMode.value = true
await new Promise(r => setTimeout(r, 0))
step.value = 'pick-human-avatar'
isChoosingMode.value = false
}
const humanAvatarList = [
@@ -640,10 +653,13 @@ function handleSignOut() {
<div class="space-y-4">
<button
class="w-full py-5 px-4 bg-neon-cyan/5 border-2 border-neon-cyan/40 text-left
hover:bg-neon-cyan/10 hover:border-neon-cyan/70 transition-all group"
hover:bg-neon-cyan/10 hover:border-neon-cyan/70 transition-all group
disabled:opacity-50 disabled:cursor-wait"
:disabled="isChoosingMode"
@click="chooseBot"
>
<span class="font-display font-black text-lg tracking-wider text-neon-cyan block mb-1">
<span class="font-display font-black text-lg tracking-wider text-neon-cyan block mb-1 flex items-center gap-2">
<span v-if="isChoosingMode && !isHumanMode" class="w-4 h-4 border-2 border-neon-cyan/30 border-t-neon-cyan rounded-full animate-spin" />
I BUILD BOTS
</span>
<span class="font-mono text-[10px] text-text-muted leading-relaxed block">
@@ -654,10 +670,13 @@ function handleSignOut() {
<button
class="w-full py-5 px-4 bg-neon-pink/5 border-2 border-neon-pink/40 text-left
hover:bg-neon-pink/10 hover:border-neon-pink/70 transition-all group"
hover:bg-neon-pink/10 hover:border-neon-pink/70 transition-all group
disabled:opacity-50 disabled:cursor-wait"
:disabled="isChoosingMode"
@click="chooseHuman"
>
<span class="font-display font-black text-lg tracking-wider text-neon-pink block mb-1">
<span class="font-display font-black text-lg tracking-wider text-neon-pink block mb-1 flex items-center gap-2">
<span v-if="isChoosingMode && isHumanMode" class="w-4 h-4 border-2 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
I FIGHT MYSELF
</span>
<span class="font-mono text-[10px] text-text-muted leading-relaxed block">