fix(stage-*): follow-up fix for #1696 (#1809)

This commit is contained in:
DrHuangMHT
2026-05-19 13:54:41 +08:00
committed by GitHub
parent 82b0d6a80c
commit c2bf73c95d
27 changed files with 459 additions and 228 deletions
@@ -1,19 +1,19 @@
<script lang="ts" setup>
import { supportedControl as threeSupportedControl, useThreeViewControl } from '@proj-airi/stage-ui-three'
import { supportedControl as l2dSupportedCtrl, useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
import { defaultControlConfig as threeCtrlConf, supportedControl as threeSupportedControl, useThreeViewControl } from '@proj-airi/stage-ui-three'
import { defaultControlConfig as l2dCtrlConf, supportedControl as l2dSupportedCtrl, useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
import { Button } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const { stageModelRenderer } = storeToRefs(useSettingsStageModel())
const { viewControlsEnabled: l2dViewCtrlEnabled, viewControlMode: l2dCtrlMode, reset: l2dReset } = useL2dViewControl()
const { viewControlsEnabled: threePlainCtrlEnabled, viewControlMode: threeCtrlMode, reset: threeReset } = useThreeViewControl()
const { viewControlsEnabled: l2dViewCtrlEnabled, viewControlMode: l2dCtrlMode, set: l2dSet } = useL2dViewControl()
const { viewControlsEnabled: threeSliderCtrlEnabled, viewControlMode: threeCtrlMode, set: threeSet } = useThreeViewControl()
const controlEnabled = computed(() => {
if (stageModelRenderer.value === 'live2d')
return { enabled: l2dViewCtrlEnabled, mode: l2dCtrlMode, supported: l2dSupportedCtrl, reset: l2dReset }
return { enabled: l2dViewCtrlEnabled, mode: l2dCtrlMode, supported: l2dSupportedCtrl, conf: l2dCtrlConf, reset: l2dSet }
if (stageModelRenderer.value === 'vrm')
return { enabled: threePlainCtrlEnabled, mode: threeCtrlMode, supported: threeSupportedControl, reset: threeReset }
return { enabled: threeSliderCtrlEnabled, mode: threeCtrlMode, supported: threeSupportedControl, conf: threeCtrlConf, reset: threeSet }
return null
})
@@ -26,39 +26,23 @@ function handleViewControlsToggle(targetMode: string) {
}
controlEnabled.value.mode.value = targetMode as any
}
// watch()
</script>
<template>
<div w-full flex flex-1 items-center self-end justify-end gap-2>
<Transition name="fade">
<div v-if="controlEnabled?.enabled.value" w-full flex justify-between gap-2>
<Button variant="secondary-muted" :toggled="controlEnabled.mode.value === 'x'" w-full @click="handleViewControlsToggle('x')">
X
</Button>
<Button variant="secondary-muted" :toggled="controlEnabled.mode.value === 'y'" w-full @click="handleViewControlsToggle('y')">
Y
</Button>
<Button v-if="controlEnabled.supported.includes('z' as any)" variant="secondary-muted" :toggled="controlEnabled.mode.value === 'z'" w-full @click="handleViewControlsToggle('z')">
Z
</Button>
<Button v-if="controlEnabled.supported.includes('cameraFOV' as any)" variant="secondary-muted" :toggled="controlEnabled.mode.value === 'cameraFOV'" w-full @click="handleViewControlsToggle('cameraFOV')">
FOV
</Button>
<Button v-if="controlEnabled.supported.includes('scale' as any)" variant="secondary-muted" :toggled="controlEnabled.mode.value === 'scale'" w-full @click="handleViewControlsToggle('scale')">
Scale
</Button>
<Button v-if="controlEnabled.supported.includes('cameraDistance' as any)" variant="secondary-muted" :toggled="controlEnabled.mode.value === 'cameraDistance'" w-full @click="handleViewControlsToggle('cameraDistance')">
Dis
<Button
v-for="control in controlEnabled.supported" :key="control" variant="secondary-muted"
:toggled="controlEnabled.mode.value === control" w-full @click="handleViewControlsToggle(control)"
>
{{ (controlEnabled.conf as any)[control].buttonText }}
</Button>
</div>
</Transition>
<button
w-fit flex items-center self-end justify-center justify-self-end rounded-xl p-2 backdrop-blur-md
border="2 solid neutral-100/60 dark:neutral-800/30"
bg="neutral-50/70 dark:neutral-800/70"
title="View"
border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" title="View"
text="neutral-500 dark:neutral-400"
@click="controlEnabled && (controlEnabled.enabled.value = !controlEnabled.enabled.value)"
>
@@ -31,7 +31,6 @@ import { useTranscriptions } from '../../composables/use-transcriptions'
import { BackgroundDialogPicker } from '../Backgrounds'
const { isDark, toggleDark } = useTheme()
const hearingDialogOpen = ref(false)
const chatOrchestrator = useChatOrchestratorStore()
const chatSession = useChatSessionStore()
const chatStream = useChatStreamStore()
@@ -59,24 +58,25 @@ const { themeColorsHueDynamic } = storeToRefs(useSettings())
const { viewControlsEnabled: l2dViewCtrlEnabled } = useL2dViewControl()
const { viewControlsEnabled: threeViewCtrlEnabled } = useThreeViewControl()
const settingsAudioDevice = useSettingsAudioDevice()
const { enabled, selectedAudioInput, stream, audioInputs } = storeToRefs(settingsAudioDevice)
const { enabled, stream } = storeToRefs(settingsAudioDevice)
const { ingest, onAfterMessageComposed } = chatOrchestrator
const { t } = useI18n()
const { audioContext } = useAudioContext()
const { startAnalyzer, stopAnalyzer, volumeLevel } = useAudioAnalyzer()
const { startAnalyzer, stopAnalyzer } = useAudioAnalyzer()
let analyzerSource: MediaStreamAudioSourceNode | undefined
function isMobileDevice() {
return /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
}
const { isListening } = useTranscriptions(
const { isListening, startStreamingTranscription, stopStreamingTranscription } = useTranscriptions(
{
messageInputRef: messageInput,
sendMessage: handleSend,
isStageTamagotchi,
},
)
const toggleTranscription = () => isListening.value ? stopStreamingTranscription() : startStreamingTranscription()
async function handleSubmit() {
if (!isMobileDevice()) {
@@ -115,14 +115,14 @@ function teardownAnalyzer() {
try {
analyzerSource?.disconnect()
}
catch {}
catch { }
analyzerSource = undefined
stopAnalyzer()
}
async function setupAnalyzer() {
teardownAnalyzer()
if (!hearingDialogOpen.value || !enabled.value || !stream.value)
if (!enabled.value || !stream.value)
return
if (audioContext.state === 'suspended')
await audioContext.resume()
@@ -137,12 +137,6 @@ watch([enabled, stream], () => {
setupAnalyzer()
}, { immediate: true })
watch(hearingDialogOpen, (value) => {
if (value) {
settingsAudioDevice.askPermission()
}
})
onAfterMessageComposed(async () => {
})
@@ -196,11 +190,9 @@ onMounted(() => {
</button>
<ChatSessionsDrawer v-model="sessionsDrawerOpen" />
<HearingConfigDialog
v-model:show="hearingDialogOpen"
v-model:enabled="enabled"
v-model:selected-audio-input="selectedAudioInput"
:audio-inputs="audioInputs"
:volume-level="volumeLevel"
:transcription="isListening"
:toggle-transcription="toggleTranscription"
:granted="true"
>
<button
@@ -139,8 +139,7 @@ watch(hearingPopoverOpen, async (value) => {
onAfterMessageComposed(async () => {
})
const { startAnalyzer, stopAnalyzer, volumeLevel } = useAudioAnalyzer()
const normalizedVolume = computed(() => Math.min(1, Math.max(0, (volumeLevel.value ?? 0) / 100)))
const { startAnalyzer, stopAnalyzer } = useAudioAnalyzer()
let analyzerSource: MediaStreamAudioSourceNode | undefined
function teardownAnalyzer() {
@@ -294,7 +293,6 @@ watch(sendMode, () => {
v-model:auto-send="autoSendEnabled"
:transcription="isListening"
:granted="true"
:volume-level="normalizedVolume"
@toggle-transcription="() => isListening ? stopStreamingTranscription() : startStreamingTranscription()"
/>
</PopoverContent>
@@ -17,13 +17,14 @@ export function useTranscriptions(options: TranscriptionOptions) {
const { messageInputRef: messageInput, sendMessage, isStageTamagotchi } = options
const hearingStore = useHearingStore()
const audioDeviceSettingsStore = useSettingsAudioDevice()
const hearingPipeline = useHearingSpeechInputPipeline()
const { transcribeForMediaStream, stopStreamingTranscription } = hearingPipeline
const { supportsStreamInput } = storeToRefs(hearingPipeline)
const { configured: hearingConfigured, autoSendEnabled, autoSendDelay } = storeToRefs(hearingStore)
const { enabled: hearingEnabled, stream } = storeToRefs(useSettingsAudioDevice())
const { enabled: hearingEnabled, stream } = storeToRefs(audioDeviceSettingsStore)
const providersStore = useProvidersStore()
const { askPermission, startStream } = useSettingsAudioDevice()
const { askPermission, startStream } = audioDeviceSettingsStore
const isListening = ref(false)
+1
View File
@@ -69,6 +69,7 @@
"devDependencies": {
"@types/culori": "^4.0.1",
"@types/three": "^0.184.0",
"vitest": "catalog:",
"vue-tsc": "^3.2.6"
}
}
@@ -1,10 +1,31 @@
<script setup lang="ts">
import { RoundRange } from '@proj-airi/ui'
import { onUnmounted } from 'vue'
import { computed, onUnmounted } from 'vue'
import { controlConfig, useL2dViewControl } from '../../stores'
import { defaultControlConfig as conf, formatter, useL2dViewControl } from '../../stores'
const { scale, position, viewControlsEnabled, viewControlMode } = useL2dViewControl()
const { scale, position, viewControlsEnabled, viewControlMode, set: setValue } = useL2dViewControl()
const controlledValue = computed({
get() {
switch (viewControlMode.value) {
case 'x':
return position.value.x
case 'y':
return position.value.y
case 'scale':
return scale.value
default: throw new Error(`Unexpected control key: ${viewControlMode.value}`)
}
},
set(value) {
setValue(viewControlMode.value, value)
},
})
const formattedValue = computed(() => {
return formatter[viewControlMode.value](controlledValue.value)
})
onUnmounted(() => {
viewControlsEnabled.value = false
@@ -15,31 +36,13 @@ onUnmounted(() => {
<Transition name="fade-side-pops-in">
<div v-if="viewControlsEnabled">
<Transition name="fade-side-pops-in" mode="out-in">
<div v-if="viewControlMode === 'x'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<div :key="viewControlMode" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange
v-model="position.x" :min="controlConfig.x.min" :max="controlConfig.x.max" :step="controlConfig.x.step" handle-wheel
v-model="controlledValue" :min="conf[viewControlMode].min" :max="conf[viewControlMode].max" :step="conf[viewControlMode].step" handle-wheel
data-direction="vertical" h="50%" write-vertical-left
/>
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ controlConfig.x.format(position.x) }}
</div>
</div>
<div v-else-if="viewControlMode === 'y'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange
v-model="position.y" :min="controlConfig.y.min" :max="controlConfig.y.max" :step="controlConfig.y.step" handle-wheel
data-direction="vertical" h="50%" write-vertical-left
/>
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ controlConfig.y.format(position.y) }}
</div>
</div>
<div v-else-if="viewControlMode === 'scale'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange
v-model="scale" :min="controlConfig.scale.min" :max="controlConfig.scale.max" :step="controlConfig.scale.step" handle-wheel
data-direction="vertical" h="50%" write-vertical-left
/>
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ controlConfig.scale.format(scale) }}
{{ formattedValue }}
</div>
</div>
</Transition>
@@ -62,13 +62,13 @@ export const useLive2d = defineStore('live2d', () => {
const currentMotion = useLocalStorageManualReset<{ group: string, index?: number }>('settings/live2d/current-motion', () => ({ group: 'Idle', index: 0 }))
const availableMotions = useLocalStorageManualReset<{ motionName: string, motionIndex: number, fileName: string }[]>('settings/live2d/available-motions', () => [])
const motionMap = useLocalStorageManualReset<Record<string, string>>('settings/live2d/motion-map', {})
const { position, scale, reset: resetViewControl } = useL2dViewControl()
const { position, scale, set: setViewControl } = useL2dViewControl()
// Live2D model parameters
const modelParameters = useLocalStorageManualReset<Record<string, number>>('settings/live2d/parameters', defaultModelParameters)
function resetState() {
supportedControl.forEach(c => resetViewControl(c))
supportedControl.forEach(c => setViewControl(c))
currentMotion.reset()
availableMotions.reset()
motionMap.reset()
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defaultControlConfig, formatter, supportedControl, useL2dViewControl } from './view-control'
vi.mock('@vueuse/core', async () => {
const vue = await import('vue')
return {
useLocalStorage: vi.fn((_key, initialValue) => vue.ref(initialValue)),
}
})
describe('useL2dViewControl', () => {
let composable: ReturnType<typeof useL2dViewControl>
beforeEach(() => {
composable = useL2dViewControl()
})
it('should initialize with defaults', () => {
expect(composable.viewControlsEnabled.value).toBe(false)
expect(composable.viewControlMode.value).toBe('scale')
expect(composable.position.value).toEqual({ x: 0, y: 0 })
expect(composable.scale.value).toBe(1)
})
describe('set function', () => {
it('should set model position x', () => {
composable.set('x', 5)
expect(composable.position.value.x).toBe(5)
})
it('should set scale', () => {
composable.set('scale', 2)
expect(composable.scale.value).toBe(2)
})
it('should clamp values to max', () => {
supportedControl.forEach(key => composable.set(key, Number.MAX_VALUE))
expect(composable.position.value.x).toBe(defaultControlConfig.x.max)
expect(composable.position.value.y).toBe(defaultControlConfig.y.max)
expect(composable.scale.value).toBe(defaultControlConfig.scale.max)
})
it('should clamp values to min', () => {
supportedControl.forEach(key => composable.set(key, -Number.MAX_VALUE))
expect(composable.position.value.x).toBe(defaultControlConfig.x.min)
expect(composable.position.value.y).toBe(defaultControlConfig.y.min)
expect(composable.scale.value).toBe(defaultControlConfig.scale.min)
})
it('should reset to default when value is omitted', () => {
supportedControl.forEach(k => composable.set(k, defaultControlConfig[k].default + 1))
supportedControl.forEach(k => composable.set(k))
expect(composable.position.value.x).toBe(defaultControlConfig.x.default)
expect(composable.position.value.y).toBe(defaultControlConfig.y.default)
expect(composable.scale.value).toBe(defaultControlConfig.scale.default)
})
})
describe('formatter', () => {
it('should format percentages with 1 decimals', () => {
expect(formatter.x(123.44)).toBe('123.4%')
expect(formatter.x(123.56)).toBe('123.6%')
expect(formatter.y(11.22)).toBe('11.2%')
})
it('should format to percentage with 0 decimal', () => {
expect(formatter.scale(1.234)).toBe('123%')
expect(formatter.scale(1.236)).toBe('124%')
expect(formatter.scale(0.11)).toBe('11%')
})
})
})
@@ -3,60 +3,68 @@ import { ref } from 'vue'
export const supportedControl = ['x', 'y', 'scale'] as const
type SupportedControl = typeof supportedControl[number]
interface ControlConfig { min: number, max: number, step: number, default: number, format: (val: number) => string }
interface ControlConfig { min: number, max: number, step: number, default: number, buttonText: string }
/** show or hide the control element(slider) on stage */
const viewControlsEnabled = ref(false)
/** what value to control for the control element */
const viewControlMode = ref<SupportedControl>('scale')
/** model position relative to the center of the screen, in pixels */
/** model position relative to the center of the screen, in percentages */
const position = useLocalStorage<{ x: number, y: number }>('settings/live2d/position', { x: 0, y: 0 })
/** model scaling. `1` means no scaling. */
/** model scaling factor. `1` means no scaling. */
const scale = useLocalStorage('settings/live2d/scale', 1)
const formatPercentD1 = (val: number) => `${val.toFixed(1)}%`
const formatToPercent = (val: number) => `${(val * 100).toFixed(0)}%`
export const controlConfig: Record<SupportedControl, ControlConfig> = {
// TODO: calculate the min and max value dynamically according to window height/width, or allow user to set it
export const defaultControlConfig: Record<SupportedControl, ControlConfig> = {
// TODO: allow user to set preferred default
x: {
min: -500,
max: 500,
step: 0.1,
default: 0,
format: formatPercentD1,
buttonText: 'X',
},
y: {
min: -500,
max: 500,
step: 0.1,
default: 0,
format: formatPercentD1,
buttonText: 'Y',
},
scale: {
min: 0.01,
max: 3,
step: 0.01,
default: 1,
format: formatToPercent,
buttonText: 'Scale',
},
}
export const formatter: Record<SupportedControl, (val: number) => string> = {
x: formatPercentD1,
y: formatPercentD1,
scale: formatToPercent,
}
const clampMinMax = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
export function useL2dViewControl() {
/**
* reset the given control to its default value.
* @param key the control to reset
* @param value optional, will reset the value to its default if not provided
*/
function reset(key: SupportedControl) {
function set(key: SupportedControl, value?: number) {
const clamped = value !== undefined ? clampMinMax(value, defaultControlConfig[key].min, defaultControlConfig[key].max) : undefined
switch (key) {
case 'x':
position.value.x = controlConfig.x.default
position.value.x = clamped ?? defaultControlConfig.x.default
break
case 'y':
position.value.y = controlConfig.y.default
position.value.y = clamped ?? defaultControlConfig.y.default
break
case 'scale':
scale.value = controlConfig.scale.default
scale.value = clamped ?? defaultControlConfig.scale.default
break
}
}
@@ -67,7 +75,7 @@ export function useL2dViewControl() {
/** model scaling in percentages. `100` means no scaling. */
scale,
/** reset the given control to its default value. */
reset,
set,
/** show or hide the control element(slider) on stage */
viewControlsEnabled,
/** what value to control for the control element */
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
})
+1
View File
@@ -46,6 +46,7 @@
"devDependencies": {
"@types/culori": "^4.0.1",
"@types/three": "^0.184.0",
"vitest": "catalog:",
"vue-tsc": "^3.2.6"
}
}
@@ -2,7 +2,6 @@
/*
* - Extend OrbitControls from three
* - Define camera behavior
* - TODO: implement the control elements and replace the <slot/>
*/
import type { Vec3 } from '../../stores/model-store'
@@ -17,23 +16,20 @@ import {
} from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
// From stage-ui-three package
import { onMounted, onUnmounted, shallowRef, toRefs, watch } from 'vue'
import { inject, onMounted, onUnmounted, shallowRef, toRefs, watch } from 'vue'
import { useThreeCamera } from '../../stores/camera'
import { useThreeViewControl } from '../../stores/view-control'
/*
* Props:
* - model size
* - camera position
* - camera target: camera looking at target
* - camera fov angle
* - camera distance: camera position - camera target
*/
const props = defineProps<{
controlEnable: boolean
modelSize: Vec3
cameraPosition: Vec3
cameraTarget: Vec3
cameraFOV: number
cameraDistance: number
}>()
/*
* Emits:
@@ -51,10 +47,7 @@ const emit = defineEmits<{
const {
controlEnable,
modelSize,
cameraPosition,
cameraTarget,
cameraFOV,
cameraDistance,
} = toRefs(props)
extend({ OrbitControls })
@@ -64,6 +57,10 @@ const controls = shallowRef<OrbitControls>()
const camera = shallowRef<PerspectiveCamera | null>(null)
let disposeControlsChange: (() => void) | undefined
const { cameraPosition, cameraFOV, cameraDistance } = useThreeCamera()
const { viewControlsEnabled } = useThreeViewControl()
const isPreviewStage = inject<boolean>('previewStage')
// Initialisation on onMounted
function registerInfoFlow() {
/*
@@ -121,11 +118,11 @@ function registerInfoFlow() {
camera.value.updateProjectionMatrix()
controls.value.update()
})
watch(controlEnable, (newEnable) => {
watch([controlEnable, viewControlsEnabled], (newEnable) => {
if (!camera.value || !controls.value)
return
controls.value.enableRotate = newEnable
controls.value.enableZoom = newEnable
controls.value.enableRotate = isPreviewStage || newEnable.every(Boolean)
controls.value.enableZoom = isPreviewStage || newEnable.every(Boolean)
}, { immediate: true })
/*
@@ -1,10 +1,40 @@
<script setup lang="ts">
import { RoundRange } from '@proj-airi/ui'
import { onUnmounted } from 'vue'
import { storeToRefs } from 'pinia'
import { computed, onUnmounted } from 'vue'
import { controlConfig as conf, useThreeViewControl } from '../../stores/view-control'
import { useModelStore } from '../../stores/model-store'
import { defaultControlConfig as conf, formatter, useThreeViewControl } from '../../stores/view-control'
const { cameraDistance, cameraFOV, modelOffset, viewControlsEnabled, viewControlMode } = useThreeViewControl()
const { cameraDistance, cameraFOV, modelOffset, viewControlsEnabled, viewControlMode, set: setValue } = useThreeViewControl()
const { sceneMutationLocked } = storeToRefs(useModelStore())
const controlledValue = computed({
get() {
switch (viewControlMode.value) {
case 'x':
return modelOffset.value.x
case 'y':
return modelOffset.value.y
case 'z':
return modelOffset.value.z
case 'cameraDistance':
return cameraDistance.value
case 'cameraFOV':
return cameraFOV.value
default: throw new Error(`Unexpected control key: ${viewControlMode.value}`)
}
},
set(value) {
if (sceneMutationLocked.value)
return
setValue(viewControlMode.value, value)
},
})
const formattedValue = computed(() => {
return formatter[viewControlMode.value](controlledValue.value)
})
onUnmounted(() => {
viewControlsEnabled.value = false
@@ -13,56 +43,23 @@ onUnmounted(() => {
<template>
<Transition name="fade-side-pops-in">
<div v-if="viewControlsEnabled">
<fieldset v-if="viewControlsEnabled">
<Transition name="fade-side-pops-in" mode="out-in">
<!-- TODO: generate the controls programmatically, while preserving the transition -->
<div v-if="viewControlMode === 'x'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<div :key="viewControlMode" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange
v-model="modelOffset.x" :min="conf.x.min" :max="conf.x.max" :step="conf.x.step" handle-wheel
data-direction="vertical" h="50%" write-vertical-left
v-model="controlledValue" :min="conf[viewControlMode].min" :max="conf[viewControlMode].max"
:disabled="sceneMutationLocked" :step="conf[viewControlMode].step" handle-wheel data-direction="vertical"
h="50%" write-vertical-left
/>
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ conf.x.format(modelOffset.x) }}
</div>
</div>
<div v-else-if="viewControlMode === 'y'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange
v-model="modelOffset.y" :min="conf.y.min" :max="conf.y.max" :step="conf.y.step" handle-wheel
data-direction="vertical" h="50%" write-vertical-left
/>
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ conf.y.format(modelOffset.y) }}
</div>
</div>
<div v-else-if="viewControlMode === 'z'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange
v-model="modelOffset.z" :min="conf.z.min" :max="conf.z.max" :step="conf.z.step" handle-wheel
data-direction="vertical" h="50%" write-vertical-left
/>
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ conf.z.format(modelOffset.z) }}
</div>
</div>
<div v-else-if="viewControlMode === 'cameraDistance'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange
v-model="cameraDistance" :min="conf.cameraDistance.min" :max="conf.cameraDistance.max" :step="conf.cameraDistance.step" handle-wheel
data-direction="vertical" h="50%" write-vertical-left
/>
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ conf.cameraDistance.format(cameraDistance) }}
</div>
</div>
<div v-else-if="viewControlMode === 'cameraFOV'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange
v-model="cameraFOV" :min="conf.cameraFOV.min" :max="conf.cameraFOV.max" :step="conf.cameraFOV.step" handle-wheel
data-direction="vertical" h="50%" write-vertical-left
/>
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ conf.cameraFOV.format(cameraFOV) }}
<div
class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0
transition="all duration-200 ease-in-out"
>
{{ formattedValue }}
</div>
</div>
</Transition>
</div>
</fieldset>
</Transition>
</template>
@@ -100,7 +100,6 @@ const {
modelOffset,
modelRotationY,
cameraFOV,
cameraPosition,
cameraDistance,
@@ -436,8 +435,7 @@ function onOrbitControlsReady() {
const controlEnable = computed(() => {
return controlsReady.value
&& modelPhase.value === 'ready'
&& scenePhase.value === 'mounted'
&& sceneTransactionDepth.value === 0
&& !sceneMutationLocked.value
})
function onVRMModelLoadStart(reason: VrmLifecycleReason) {
modelPhase.value = 'loading'
@@ -728,10 +726,7 @@ defineExpose({
ref="controlsRef"
:control-enable="controlEnable"
:model-size="modelSize"
:camera-position="cameraPosition"
:camera-target="modelOrigin"
:camera-f-o-v="cameraFOV"
:camera-distance="cameraDistance"
@orbit-controls-camera-changed="onOrbitControlsCameraChanged"
@orbit-controls-ready="onOrbitControlsReady"
/>
@@ -0,0 +1,25 @@
import { useLocalStorage } from '@vueuse/core'
export const DEFAULT_CAMERA_FOV = 40
export const DEFAULT_CAMERA_DISTANCE = 1
export const DEFAULT_CAMERA_POSITION = { x: 0, y: 0, z: -1 }
/** camera field of view, in degrees. */
const cameraFOV = useLocalStorage('settings/stage-ui-three/cameraFOV', DEFAULT_CAMERA_FOV)
/**
* euclidean distance between the model center and the camera center, in meters.
* setting this value will move the camera along the axis.
*/
const cameraDistance = useLocalStorage('settings/stage-ui-three/cameraDistance', DEFAULT_CAMERA_DISTANCE)
/**
* Internal state of the camera. Users should not be able to set this directly, use `cameraDistance` instead.
*/
const cameraPosition = useLocalStorage('settings/stage-ui-three/camera-position', { ...DEFAULT_CAMERA_POSITION })
export function useThreeCamera() {
return {
cameraFOV,
cameraDistance,
cameraPosition,
}
}
@@ -6,6 +6,7 @@ import { computed, ref, watch } from 'vue'
import defaultSkyBoxSrc from '../components/Environment/assets/sky_linekotsi_23_HDRI.hdr?url'
import { DEFAULT_CAMERA_POSITION, useThreeCamera } from './camera'
import { supportedControl, useThreeViewControl } from './view-control'
// TODO: this is for future type injection features
@@ -79,8 +80,8 @@ interface BroadcastChannelEventShouldUpdateView {
const vrmViewUpdateRuntimeInstanceId = Math.random().toString(36).slice(2, 10)
let vrmViewUpdateMessageSequence = 0
const { cameraFOV, cameraDistance, modelOffset, reset: resetViewControl } = useThreeViewControl()
const { modelOffset, set: setViewControl } = useThreeViewControl()
const { cameraDistance, cameraFOV, cameraPosition } = useThreeCamera()
export const useModelStore = defineStore('modelStore', () => {
const { post, data } = useBroadcastChannel<BroadcastChannelEvents, BroadcastChannelEvents>({ name: 'airi-stores-stage-ui-three-vrm' })
const shouldUpdateViewHooks = ref(new Set<() => void>())
@@ -149,10 +150,7 @@ export const useModelStore = defineStore('modelStore', () => {
const trackingMode = useLocalStorage('settings/stage-ui-three/trackingMode', 'none' as 'camera' | 'mouse' | 'none')
// === View state ===
// `cameraDistance` is the user-facing distance control. `cameraPosition` and
// `lookAtTarget` represent the current runtime pose and may be recalculated when
// a new model bootstrap is applied.
const cameraPosition = useLocalStorage('settings/stage-ui-three/camera-position', { x: 0, y: 0, z: -1 })
/** current runtime pose. may be recalculated when a new model bootstrap is applied. */
const lookAtTarget = useLocalStorage('settings/stage-ui-three/lookAtTarget', { x: 0, y: 0, z: 0 })
function resetModelStore() {
@@ -164,8 +162,8 @@ export const useModelStore = defineStore('modelStore', () => {
modelOrigin.value = { x: 0, y: 0, z: 0 }
modelRotationY.value = 0
cameraPosition.value = { x: 0, y: 0, z: 0 }
supportedControl.forEach(c => resetViewControl(c))
cameraPosition.value = { ...DEFAULT_CAMERA_POSITION }
supportedControl.forEach(c => setViewControl(c))
lookAtTarget.value = { x: 0, y: 0, z: 0 }
trackingMode.value = 'none'
@@ -0,0 +1,97 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defaultControlConfig, formatter, supportedControl, useThreeViewControl } from './view-control'
vi.mock('./camera', async () => {
const vue = await import('vue')
return {
DEFAULT_CAMERA_DISTANCE: 20,
DEFAULT_CAMERA_FOV: 40,
useThreeCamera: vi.fn(() => ({
cameraDistance: vue.ref(20),
cameraFOV: vue.ref(40),
})),
}
})
vi.mock('@vueuse/core', async () => {
const vue = await import('vue')
return {
useLocalStorage: vi.fn((_key, initialValue) => vue.ref(initialValue)),
}
})
describe('useThreeViewControl', () => {
let composable: ReturnType<typeof useThreeViewControl>
beforeEach(() => {
composable = useThreeViewControl()
})
it('should initialize with defaults', () => {
expect(composable.viewControlsEnabled.value).toBe(false)
expect(composable.viewControlMode.value).toBe('cameraDistance')
expect(composable.modelOffset.value).toEqual({ x: 0, y: 0, z: 0 })
expect(composable.cameraDistance.value).toBe(20)
expect(composable.cameraFOV.value).toBe(40)
})
describe('set function', () => {
it('should set model offset x', () => {
composable.set('x', 5)
expect(composable.modelOffset.value.x).toBe(5)
})
it('should set camera distance', () => {
composable.set('cameraDistance', 8)
expect(composable.cameraDistance.value).toBe(8)
})
it('should set camera FOV', () => {
composable.set('cameraFOV', 60)
expect(composable.cameraFOV.value).toBe(60)
})
it('should clamp values to max', () => {
supportedControl.forEach(key => composable.set(key, Number.MAX_VALUE))
expect(composable.modelOffset.value.x).toBe(defaultControlConfig.x.max)
expect(composable.modelOffset.value.y).toBe(defaultControlConfig.y.max)
expect(composable.modelOffset.value.z).toBe(defaultControlConfig.z.max)
expect(composable.cameraDistance.value).toBe(defaultControlConfig.cameraDistance.max)
expect(composable.cameraFOV.value).toBe(defaultControlConfig.cameraFOV.max)
})
it('should clamp values to min', () => {
supportedControl.forEach(key => composable.set(key, -Number.MAX_VALUE))
expect(composable.modelOffset.value.x).toBe(defaultControlConfig.x.min)
expect(composable.modelOffset.value.y).toBe(defaultControlConfig.y.min)
expect(composable.modelOffset.value.z).toBe(defaultControlConfig.z.min)
expect(composable.cameraDistance.value).toBe(defaultControlConfig.cameraDistance.min)
expect(composable.cameraFOV.value).toBe(defaultControlConfig.cameraFOV.min)
})
it('should reset to default when value is omitted', () => {
supportedControl.forEach(k => composable.set(k, defaultControlConfig[k].default + 1))
supportedControl.forEach(k => composable.set(k))
expect(composable.modelOffset.value.x).toBe(defaultControlConfig.x.default)
expect(composable.modelOffset.value.y).toBe(defaultControlConfig.y.default)
expect(composable.modelOffset.value.z).toBe(defaultControlConfig.z.default)
expect(composable.cameraDistance.value).toBe(defaultControlConfig.cameraDistance.default)
expect(composable.cameraFOV.value).toBe(defaultControlConfig.cameraFOV.default)
})
})
describe('formatter', () => {
it('should format meters with 2 decimals', () => {
expect(formatter.x(1.234)).toBe('1.23m')
expect(formatter.x(1.236)).toBe('1.24m')
expect(formatter.y(10)).toBe('10.00m')
})
it('should format camera FOV with 0 decimal', () => {
expect(formatter.cameraFOV(45.4)).toBe('45°')
expect(formatter.cameraFOV(45.5)).toBe('46°')
expect(formatter.cameraFOV(90)).toBe('90°')
})
})
})
@@ -1,85 +1,96 @@
import { useLocalStorage } from '@vueuse/core'
import { ref } from 'vue'
import { DEFAULT_CAMERA_DISTANCE, DEFAULT_CAMERA_FOV, useThreeCamera } from './camera'
export const supportedControl = ['x', 'y', 'z', 'cameraDistance', 'cameraFOV'] as const
type SupportedControl = typeof supportedControl[number]
interface ControlConfig { min: number, max: number, step: number, default: number, format: (val: number) => string }
export type SupportedControl = typeof supportedControl[number]
interface ControlConfig { min: number, max: number, step: number, default: number, buttonText: string }
const formatDecimal2Meters = (val: number) => `${val.toFixed(2)}m`
const formatMetersD2 = (val: number) => `${val.toFixed(2)}m`
export const controlConfig: Record<SupportedControl, ControlConfig> = {
export const defaultControlConfig: Record<SupportedControl, ControlConfig> = {
// TODO: allow user to set the min/max value
x: {
min: -10,
max: 10,
step: 0.01,
default: 0,
format: formatDecimal2Meters,
buttonText: 'X',
},
y: {
min: -10,
max: 10,
step: 0.01,
default: 0,
format: formatDecimal2Meters,
buttonText: 'Y',
},
z: {
min: -10,
max: 10,
step: 0.01,
default: 0,
format: formatDecimal2Meters,
buttonText: 'Z',
},
cameraDistance: {
min: 0,
max: 10,
step: 0.01,
default: 1,
format: formatDecimal2Meters,
default: DEFAULT_CAMERA_DISTANCE,
buttonText: 'Dis',
},
cameraFOV: {
min: 10,
max: 120,
step: 1,
default: 40,
format: (val: number) => `${val.toFixed(0)}°`,
default: DEFAULT_CAMERA_FOV,
buttonText: 'FOV',
},
}
/** camera field of view, in degrees. */
const cameraFOV = useLocalStorage('settings/stage-ui-three/cameraFOV', 40)
/**
* euclidean distance between the model center and the camera center, in meters.
* setting this value will move the camera along the axis.
*/
const cameraDistance = useLocalStorage('settings/stage-ui-three/cameraDistance', 1)
export const formatter: Record<SupportedControl, (val: number) => string> = {
x: formatMetersD2,
y: formatMetersD2,
z: formatMetersD2,
cameraDistance: formatMetersD2,
cameraFOV: (val: number) => `${val.toFixed(0)}°`,
}
const clampMinMax = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
const { cameraDistance, cameraFOV } = useThreeCamera()
const controlConfig = ref(defaultControlConfig)
/** model position from the scene origin, in meters. */
const modelOffset = useLocalStorage('settings/stage-ui-three/modelOffset', { x: 0, y: 0, z: 0 })
/** show or hide the control element(slider) on HUD. */
const modelOffset = useLocalStorage('settings/stage-ui-three/modelOffset', { x: defaultControlConfig.x.default, y: defaultControlConfig.y.default, z: defaultControlConfig.z.default })
/**
* show or hide the control element(slider) on HUD.
* also enable/disable camera panning.
*/
const viewControlsEnabled = ref(false)
/** what value to control for the control element */
const viewControlMode = ref<SupportedControl>('cameraDistance')
/**
* reset the given control to its default value.
* @param key the control to reset
* set the given control to the given value.
* @param key the control to set
* @param value optional, will reset the value to its default if not provided
*/
function reset(key: SupportedControl) {
function set(key: SupportedControl, value?: number) {
const clamped = value !== undefined ? clampMinMax(value, defaultControlConfig[key].min, defaultControlConfig[key].max) : undefined
switch (key) {
case 'x':
modelOffset.value.x = controlConfig.x.default
modelOffset.value.x = clamped ?? defaultControlConfig.x.default
break
case 'y':
modelOffset.value.y = controlConfig.y.default
modelOffset.value.y = clamped ?? defaultControlConfig.y.default
break
case 'z':
modelOffset.value.z = controlConfig.z.default
modelOffset.value.z = clamped ?? defaultControlConfig.z.default
break
case 'cameraDistance':
cameraDistance.value = controlConfig.cameraDistance.default
cameraDistance.value = clamped ?? defaultControlConfig.cameraDistance.default
break
case 'cameraFOV':
cameraFOV.value = controlConfig.cameraFOV.default
cameraFOV.value = clamped ?? defaultControlConfig.cameraFOV.default
break
}
}
@@ -96,8 +107,8 @@ export function useThreeViewControl() {
viewControlsEnabled,
/** what value to control for the control element */
viewControlMode,
controlConfig,
/** reset the given control to its default value. */
reset,
set,
}
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
})
@@ -2,25 +2,32 @@
import { useResizeObserver, useScreenSafeArea } from '@vueuse/core'
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger, VisuallyHidden } from 'reka-ui'
import { DrawerContent, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTrigger } from 'vaul-vue'
import { onMounted } from 'vue'
import { onMounted, watch } from 'vue'
import HearingConfig from './hearing-config.vue'
import { useAudioDevice } from '../../../../composables'
import { useBreakpoints } from '../../../../composables/use-breakpoints'
const props = defineProps<{
overlayDim?: boolean
overlayBlur?: boolean
granted?: boolean
volumeLevel?: number
transcription?: boolean
toggleTranscription?: () => void
}>()
const showDialog = defineModel('show', { type: Boolean, default: false, required: false })
const { isDesktop } = useBreakpoints()
const { askPermission } = useAudioDevice()
const screenSafeArea = useScreenSafeArea()
useResizeObserver(document.documentElement, () => screenSafeArea.update())
watch(showDialog, (show) => {
if (show)
askPermission()
})
onMounted(() => screenSafeArea.update())
</script>
@@ -42,8 +49,8 @@ onMounted(() => screenSafeArea.update())
<DialogTitle>Hearing Input</DialogTitle>
</VisuallyHidden>
<HearingConfig
:granted="props.granted"
:volume-level="props.volumeLevel"
:granted="props.granted" :transcription="props.transcription"
@toggle-transcription="() => toggleTranscription?.()"
/>
<slot name="extra" />
</DialogContent>
@@ -73,7 +80,8 @@ onMounted(() => screenSafeArea.update())
/>
<HearingConfig
:granted="props.granted"
:volume-level="props.volumeLevel"
:transcription="props.transcription"
@toggle-transcription="() => toggleTranscription?.()"
/>
<slot name="extra" />
</DrawerContent>
@@ -3,20 +3,21 @@ import { Button, Callout, FieldCombobox } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useAudioAnalyzer, useAudioDevice } from '../../../../composables'
import { useSettingsAudioDevice } from '../../../../stores'
const props = withDefaults(defineProps<{
granted?: boolean
volumeLevel?: number
granted?: boolean // permission status on OS level
transcription?: boolean
}>(), {
granted: false,
volumeLevel: 0,
})
const emit = defineEmits(['toggleTranscription'])
const { enabled, selectedAudioInput, audioInputs } = storeToRefs(useSettingsAudioDevice())
const deviceStore = useSettingsAudioDevice()
const { enabled, selectedAudioInput } = storeToRefs(deviceStore)
const { audioInputs, permissionGranted, askPermission } = useAudioDevice()
const { volumeLevel } = useAudioAnalyzer()
const autoSend = defineModel<boolean>('autoSend')
const ringEnabledClass = computed(() => enabled.value
@@ -27,8 +28,10 @@ const ringEnabledClass = computed(() => enabled.value
function toggleHearingEnabled() {
if (enabled.value)
return enabled.value = false
if (!enabled.value && selectedAudioInput.value !== '')
enabled.value = true
if (selectedAudioInput.value !== '' && permissionGranted.value)
return enabled.value = true
if (!permissionGranted.value)
return askPermission().then(() => { enabled.value = permissionGranted.value })
}
</script>
@@ -40,17 +43,17 @@ function toggleHearingEnabled() {
<!-- Rings (scale + opacity follow volume) -->
<div
class="absolute left-1/2 top-1/2 h-20 w-20 rounded-full transition-all duration-150 -translate-x-1/2 -translate-y-1/2"
:style="{ transform: `translate(-50%, -50%) scale(${1 + (props.volumeLevel / 100) * 0.35})`, opacity: String(0.25 + (props.volumeLevel / 100) * 0.25) }"
:style="{ transform: `translate(-50%, -50%) scale(${1 + (volumeLevel / 100) * 0.35})`, opacity: String(0.25 + (volumeLevel / 100) * 0.25) }"
:class="ringEnabledClass"
/>
<div
class="absolute left-1/2 top-1/2 h-24 w-24 rounded-full transition-all duration-200 -translate-x-1/2 -translate-y-1/2"
:style="{ transform: `translate(-50%, -50%) scale(${1.2 + (props.volumeLevel / 100) * 0.55})`, opacity: String(0.15 + (props.volumeLevel / 100) * 0.2) }"
:style="{ transform: `translate(-50%, -50%) scale(${1.2 + (volumeLevel / 100) * 0.55})`, opacity: String(0.15 + (volumeLevel / 100) * 0.2) }"
:class="enabled ? 'bg-primary-500/10 dark:bg-primary-600/15' : 'bg-neutral-300/10 dark:bg-neutral-700/10'"
/>
<div
class="absolute left-1/2 top-1/2 h-28 w-28 rounded-full transition-all duration-300 -translate-x-1/2 -translate-y-1/2"
:style="{ transform: `translate(-50%, -50%) scale(${1.5 + (props.volumeLevel / 100) * 0.8})`, opacity: String(0.08 + (props.volumeLevel / 100) * 0.15) }"
:style="{ transform: `translate(-50%, -50%) scale(${1.5 + (volumeLevel / 100) * 0.8})`, opacity: String(0.08 + (volumeLevel / 100) * 0.15) }"
:class="enabled ? 'bg-primary-500/5 dark:bg-primary-600/10' : 'bg-neutral-300/5 dark:bg-neutral-700/5'"
/>
@@ -5,7 +5,7 @@ import { Live2DScene } from '@proj-airi/stage-ui-live2d'
import { ThreeScene, useModelStore } from '@proj-airi/stage-ui-three'
import { useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { computed, provide, ref, watch } from 'vue'
import { useSettings } from '../../../../stores/settings'
import {
@@ -30,6 +30,8 @@ const vrmSceneRef = ref<{ canvasElement: () => HTMLCanvasElement | undefined }>(
const live2dComponentState = ref<'pending' | 'loading' | 'mounted'>('pending')
const vrmPreviewStageInstanceId = `model-settings-preview-stage:${Math.random().toString(36).slice(2, 10)}`
provide('previewStage', true)
const {
live2dDisableFocus,
stageModelSelected,
@@ -1,5 +1,10 @@
import { onUnmounted, ref } from 'vue'
const amplification = 3 // Amplification factor for volume visualization
const volumeLevel = ref(0) // 0-100
const error = ref<string>()
export function useAudioAnalyzer() {
const analyzer = ref<AnalyserNode>()
const dataArray = ref<Uint8Array<ArrayBuffer>>()
@@ -7,11 +12,6 @@ export function useAudioAnalyzer() {
const onAnalyzerUpdateHooks = ref<Array<(volumeLevel: number) => void | Promise<void>>>([])
const volumeLevel = ref(0) // 0-100
const error = ref<string>()
const amplification = 3 // Amplification factor for volume visualization
function onAnalyzerUpdate(callback: (volumeLevel: number) => void | Promise<void>) {
onAnalyzerUpdateHooks.value.push(callback)
return () => {
@@ -2,9 +2,8 @@ import { useDevicesList, useUserMedia } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
export function useAudioDevice(requestPermission: boolean = false) {
const devices = useDevicesList({ constraints: { audio: true }, requestPermissions: requestPermission })
const audioInputs = computed(() => devices.audioInputs.value)
const selectedAudioInput = ref<string>(devices.audioInputs.value.find(device => device.deviceId === 'default')?.deviceId || '')
const { audioInputs, permissionGranted, ensurePermissions } = useDevicesList({ constraints: { audio: true }, requestPermissions: requestPermission })
const selectedAudioInput = ref<string>(audioInputs.value.find(device => device.deviceId === 'default')?.deviceId || '')
const deviceConstraints = computed<MediaStreamConstraints>(() => ({
audio: {
deviceId: { exact: selectedAudioInput.value },
@@ -16,13 +15,13 @@ export function useAudioDevice(requestPermission: boolean = false) {
const { stream, stop: stopStream, start: startStream } = useUserMedia({ constraints: deviceConstraints, enabled: false, autoSwitch: true })
watch(audioInputs, () => {
if (!selectedAudioInput.value && audioInputs.value.length > 0) {
if (selectedAudioInput.value === '' && audioInputs.value.length > 0) {
selectedAudioInput.value = audioInputs.value.find(input => input.deviceId === 'default')?.deviceId || audioInputs.value[0].deviceId
}
})
function askPermission() {
return devices.ensurePermissions()
return ensurePermissions()
.then(() => nextTick())
.then(() => {
if (audioInputs.value.length > 0 && !selectedAudioInput.value) {
@@ -40,6 +39,7 @@ export function useAudioDevice(requestPermission: boolean = false) {
selectedAudioInput,
stream,
deviceConstraints,
permissionGranted,
askPermission,
startStream,
@@ -4,17 +4,19 @@ import { watch } from 'vue'
import { useAudioDevice } from '../../composables/audio'
let microphonePermissionStatus: PermissionStatus
export const useSettingsAudioDevice = defineStore('settings-audio-devices', () => {
const { audioInputs, deviceConstraints, selectedAudioInput: selectedAudioInputNonPersist, startStream, stopStream, stream, askPermission } = useAudioDevice()
const selectedAudioInputPersist = useLocalStorageManualReset<string>('settings/audio/input', selectedAudioInputNonPersist.value)
const selectedAudioInputEnabledPersist = useLocalStorageManualReset<boolean>('settings/audio/input/enabled', false)
const audioInputEnabled = useLocalStorageManualReset<boolean>('settings/audio/input/enabled', false)
watch(selectedAudioInputPersist, (newValue) => {
selectedAudioInputNonPersist.value = newValue
})
watch(selectedAudioInputEnabledPersist, (val) => {
watch(audioInputEnabled, (val) => {
if (val) {
startStream()
}
@@ -23,14 +25,27 @@ export const useSettingsAudioDevice = defineStore('settings-audio-devices', () =
}
})
// permissionGranted from vueuse does not track revocation yet.
// implement it manually.
try {
navigator?.permissions?.query({ name: 'microphone' }).then((status) => {
microphonePermissionStatus = status // existing one cleaned up by GC
status.onchange = () => {
if (status.state === 'denied' || status.state === 'prompt')
audioInputEnabled.value = false
}
})
}
catch (e) { console.info(`Unable to track microphone permission: ${e}`) }
void microphonePermissionStatus // suppress unused variable lint
function initialize() {
const hasSelectedInput = selectedAudioInputPersist.value
&& audioInputs.value.some(device => device.deviceId === selectedAudioInputPersist.value)
if (selectedAudioInputEnabledPersist.value && hasSelectedInput) {
if (audioInputEnabled.value && hasSelectedInput) {
startStream()
}
if (selectedAudioInputNonPersist.value && !selectedAudioInputEnabledPersist.value) {
if (selectedAudioInputNonPersist.value && !audioInputEnabled.value) {
selectedAudioInputPersist.value = selectedAudioInputNonPersist.value
}
}
@@ -38,7 +53,7 @@ export const useSettingsAudioDevice = defineStore('settings-audio-devices', () =
function resetState() {
selectedAudioInputPersist.reset()
selectedAudioInputNonPersist.value = ''
selectedAudioInputEnabledPersist.reset()
audioInputEnabled.reset()
stopStream()
}
@@ -46,7 +61,7 @@ export const useSettingsAudioDevice = defineStore('settings-audio-devices', () =
audioInputs,
deviceConstraints,
selectedAudioInput: selectedAudioInputPersist,
enabled: selectedAudioInputEnabledPersist,
enabled: audioInputEnabled,
stream,
@@ -64,11 +64,13 @@ function handleInput(e: Event) {
}
function onWheelInput(ev: WheelEvent) {
let valueAfter = 0
if (ev.deltaY === 0)
return
let valueAfter = modelValue.value
if (ev.deltaY < 0)
valueAfter = modelValue.value += props.step * (shiftPressed.value ? 50 : 1)
valueAfter += props.step * (shiftPressed.value ? 50 : 1)
if (ev.deltaY > 0)
valueAfter = modelValue.value -= props.step * (shiftPressed.value ? 50 : 1)
valueAfter -= props.step * (shiftPressed.value ? 50 : 1)
modelValue.value = Math.min(Math.max(valueAfter, props.min), props.max)
}
</script>
+6
View File
@@ -3661,6 +3661,9 @@ importers:
'@types/three':
specifier: ^0.184.0
version: 0.184.0
vitest:
specifier: 'catalog:'
version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vue-tsc:
specifier: ^3.2.6
version: 3.2.6(typescript@5.9.3)
@@ -3725,6 +3728,9 @@ importers:
'@types/three':
specifier: ^0.184.0
version: 0.184.0
vitest:
specifier: 'catalog:'
version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vue-tsc:
specifier: ^3.2.6
version: 3.2.6(typescript@5.9.3)