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:
co-authored by
Claude Opus 4.6
parent
4897335686
commit
dd3cbdae7f
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user