fix(stage-pocket): guard for beatsync

This commit is contained in:
Neko Ayaka
2026-08-04 04:22:58 +08:00
parent 1226417768
commit 93af669d14
4 changed files with 106 additions and 20 deletions
+1
View File
@@ -140,6 +140,7 @@ export default defineConfig({
exclude: base => [
...base,
'**/settings/connection/index.vue',
'**/settings/modules/beat-sync.vue',
],
},
],
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import {
getBeatSyncInputByteFrequencyData,
getBeatSyncState,
inputAnalyserFFTSize,
isBeatSyncSupported,
listenBeatSyncBeatSignal,
listenBeatSyncStateChange,
toggleBeatSync,
updateBeatSyncParameters,
} from './detector'
describe('capacitor Beat Sync runtime', () => {
beforeAll(() => {
vi.stubEnv('RUNTIME_ENVIRONMENT', 'capacitor')
})
afterAll(() => {
vi.unstubAllEnvs()
})
it('registers and removes inactive listeners without throwing', () => {
const removeStateListener = listenBeatSyncStateChange(vi.fn())
const removeBeatListener = listenBeatSyncBeatSignal(vi.fn())
expect(removeStateListener).toBeTypeOf('function')
expect(removeBeatListener).toBeTypeOf('function')
expect(() => removeStateListener()).not.toThrow()
expect(() => removeBeatListener()).not.toThrow()
})
it('returns a deterministic inactive state', async () => {
expect(isBeatSyncSupported()).toBe(false)
await expect(getBeatSyncState()).resolves.toEqual({ isActive: false })
})
it('allows inactive configuration calls to settle', async () => {
await expect(toggleBeatSync(false)).resolves.toBeUndefined()
await expect(updateBeatSyncParameters({})).resolves.toBeUndefined()
})
it('rejects attempts to enable unsupported screen capture', async () => {
await expect(toggleBeatSync(true)).rejects.toThrow('Beat Sync is not available in Stage Pocket')
})
it('returns silent frequency data', async () => {
const data = await getBeatSyncInputByteFrequencyData()
expect(data).toBeInstanceOf(Uint8Array)
expect(data).toHaveLength(inputAnalyserFFTSize / 2)
expect(data.every(value => value === 0)).toBe(true)
})
})
+33 -10
View File
@@ -1,4 +1,3 @@
import type { EventContext } from '@moeru/eventa'
import type { Analyser, AnalyserBeatEvent, AnalyserWorkletParameters } from '@nekopaw/tempora'
import type { SerializableDesktopCapturerSource } from '@proj-airi/electron-screen-capture'
@@ -10,7 +9,7 @@ import { defineInvoke, defineInvokeHandler } from '@moeru/eventa'
import { startAnalyser as startTemporaAnalyser } from '@nekopaw/tempora'
import { setupElectronScreenCapture } from '@proj-airi/electron-screen-capture/renderer'
import { isStageTamagotchi, isStageWeb, StageEnvironment } from '../environment'
import { isStageCapacitor, isStageTamagotchi, isStageWeb, StageEnvironment } from '../environment'
import { isElectronWindow } from '../window'
import {
beatSyncBeatSignaledInvokeEventa,
@@ -249,14 +248,38 @@ function getDetector() {
return detector
}
let context: EventContext<any, any> | undefined
let context: ReturnType<typeof createContext> | undefined
function installCapacitorHandlers(context: ReturnType<typeof createContext>) {
defineInvokeHandler(context, beatSyncToggleInvokeEventa, async (enabled) => {
if (enabled)
throw new Error('Beat Sync is not available in Stage Pocket')
})
defineInvokeHandler(context, beatSyncGetStateInvokeEventa, async () => ({ isActive: false }))
defineInvokeHandler(context, beatSyncUpdateParametersInvokeEventa, async () => {})
defineInvokeHandler(context, beatSyncGetInputByteFrequencyDataInvokeEventa, async () => {
return new Uint8Array(inputAnalyserFFTSize / 2)
})
}
function getContext() {
if (!context)
if (!context) {
context = createContext()
// Capacitor cannot capture system audio. Register every request handler so
// callers receive deterministic inactive results instead of pending forever.
if (isStageCapacitor())
installCapacitorHandlers(context)
}
return context
}
/** Returns whether the current Stage runtime can capture audio for Beat Sync. */
export function isBeatSyncSupported() {
return isStageWeb() || isStageTamagotchi()
}
export function toggleBeatSync(enabled: boolean) {
if (isStageWeb()) {
if (enabled) {
@@ -267,7 +290,7 @@ export function toggleBeatSync(enabled: boolean) {
}
}
if (isStageTamagotchi()) {
if (isStageTamagotchi() || isStageCapacitor()) {
const toggleFn = defineInvoke(getContext(), beatSyncToggleInvokeEventa)
return toggleFn(enabled)
}
@@ -280,7 +303,7 @@ export async function getBeatSyncState() {
return getDetector().state
}
if (isStageTamagotchi()) {
if (isStageTamagotchi() || isStageCapacitor()) {
return defineInvoke(getContext(), beatSyncGetStateInvokeEventa)()
}
@@ -292,7 +315,7 @@ export function updateBeatSyncParameters(params: Partial<AnalyserWorkletParamete
return getDetector().updateParameters(params)
}
if (isStageTamagotchi()) {
if (isStageTamagotchi() || isStageCapacitor()) {
return defineInvoke(getContext(), beatSyncUpdateParametersInvokeEventa)(params)
}
@@ -304,7 +327,7 @@ export function listenBeatSyncStateChange(listener: (state: BeatSyncDetectorStat
return getDetector().on('stateChange', listener)
}
if (isStageTamagotchi()) {
if (isStageTamagotchi() || isStageCapacitor()) {
return defineInvokeHandler(getContext(), beatSyncStateChangedInvokeEventa, listener)
}
@@ -316,7 +339,7 @@ export function listenBeatSyncBeatSignal(listener: (e: AnalyserBeatEvent) => voi
return getDetector().on('beat', listener)
}
if (isStageTamagotchi()) {
if (isStageTamagotchi() || isStageCapacitor()) {
return defineInvokeHandler(getContext(), beatSyncBeatSignaledInvokeEventa, listener)
}
@@ -328,7 +351,7 @@ export async function getBeatSyncInputByteFrequencyData() {
return getDetector().getInputByteFrequencyData()
}
if (isStageTamagotchi()) {
if (isStageTamagotchi() || isStageCapacitor()) {
return defineInvoke(getContext(), beatSyncGetInputByteFrequencyDataInvokeEventa)()
}
@@ -1,6 +1,6 @@
import type { BeatSyncDetectorState } from '@proj-airi/stage-shared/beat-sync'
import { getBeatSyncState, listenBeatSyncStateChange } from '@proj-airi/stage-shared/beat-sync'
import { getBeatSyncState, isBeatSyncSupported, listenBeatSyncStateChange } from '@proj-airi/stage-shared/beat-sync'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -44,6 +44,7 @@ export function useModulesList() {
const factorioStore = useFactorioStore()
const artistryStore = useArtistryStore()
const beatSyncState = ref<BeatSyncDetectorState>()
const beatSyncSupported = isBeatSyncSupported()
minecraftStore.initialize()
@@ -165,15 +166,17 @@ export function useModulesList() {
configured: false,
category: 'essential',
},
{
id: 'beat-sync',
name: t('settings.pages.modules.beat_sync.title'),
description: t('settings.pages.modules.beat_sync.description'),
icon: 'i-solar:music-notes-bold-duotone',
to: '/settings/modules/beat-sync',
configured: beatSyncState.value?.isActive ?? false,
category: 'essential',
},
...(beatSyncSupported
? [{
id: 'beat-sync',
name: t('settings.pages.modules.beat_sync.title'),
description: t('settings.pages.modules.beat_sync.description'),
icon: 'i-solar:music-notes-bold-duotone',
to: '/settings/modules/beat-sync',
configured: beatSyncState.value?.isActive ?? false,
category: 'essential',
}]
: []),
])
const categorizedModules = computed(() => {
@@ -196,6 +199,9 @@ export function useModulesList() {
// TODO(Makito): We can make this a reactive value from a synthetic store.
onMounted(() => {
if (!beatSyncSupported)
return
getBeatSyncState().then(initialState => beatSyncState.value = initialState)
const removeListener = listenBeatSyncStateChange(newState => beatSyncState.value = { ...newState })
onUnmounted(() => removeListener())