diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index 3f912f7..7bb0cf7 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -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() diff --git a/frontend/src/composables/useNostr.ts b/frontend/src/composables/useNostr.ts index 98adbd8..83b309b 100644 --- a/frontend/src/composables/useNostr.ts +++ b/frontend/src/composables/useNostr.ts @@ -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 { + 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, diff --git a/frontend/src/game/audio/index.ts b/frontend/src/game/audio/index.ts index 9a4ff46..de4c908 100644 --- a/frontend/src/game/audio/index.ts +++ b/frontend/src/game/audio/index.ts @@ -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 diff --git a/frontend/src/pages/FightPage.vue b/frontend/src/pages/FightPage.vue index 4348c78..4013b25 100644 --- a/frontend/src/pages/FightPage.vue +++ b/frontend/src/pages/FightPage.vue @@ -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 diff --git a/frontend/src/pages/JoinBoutPage.vue b/frontend/src/pages/JoinBoutPage.vue index 3dbbff9..1c6a2a8 100644 --- a/frontend/src/pages/JoinBoutPage.vue +++ b/frontend/src/pages/JoinBoutPage.vue @@ -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() {