feat(stage-layouts): add mobile view adjustment mode (#2475)

This commit is contained in:
RainbowBird
2026-09-07 12:37:45 +08:00
committed by GitHub
parent 05ac66edfa
commit 0fd71bc1ab
6 changed files with 348 additions and 43 deletions
+1
View File
@@ -17,6 +17,7 @@ mobile-tools:
settings: All settings
about: About
view: View
close-view: Save and close view controls
hearing: Hearing
chat:
actions:
+2 -1
View File
@@ -16,7 +16,8 @@ mobile-tools:
background: 背景
settings: 全部设置
about: 关于
view:
view:
close-view: 保存并关闭视图调节
hearing: 听觉
chat:
actions:
@@ -2,38 +2,79 @@
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, GhostButton } from '@proj-airi/ui'
import { Button } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const props = withDefaults(defineProps<{
variant?: 'desktop' | 'mobile-stage'
}>(), {
variant: 'desktop',
})
const { stageModelRenderer } = storeToRefs(useSettingsStageModel())
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, conf: l2dCtrlConf, reset: l2dSet }
return { enabled: l2dViewCtrlEnabled, mode: l2dCtrlMode }
if (stageModelRenderer.value === 'vrm')
return { enabled: threeSliderCtrlEnabled, mode: threeCtrlMode, supported: threeSupportedControl, conf: threeCtrlConf, reset: threeSet }
return { enabled: threeSliderCtrlEnabled, mode: threeCtrlMode }
return null
})
const visibleControls = computed(() => {
if (!controlEnabled.value)
return []
if (stageModelRenderer.value === 'live2d')
return l2dSupportedCtrl
return threeSupportedControl
})
function isL2dControl(targetMode: string): targetMode is typeof l2dSupportedCtrl[number] {
return l2dSupportedCtrl.includes(targetMode as typeof l2dSupportedCtrl[number])
}
function isThreeControl(targetMode: string): targetMode is typeof threeSupportedControl[number] {
return threeSupportedControl.includes(targetMode as typeof threeSupportedControl[number])
}
function controlLabel(control: string) {
if (stageModelRenderer.value === 'live2d' && isL2dControl(control))
return l2dCtrlConf[control].buttonText
if (stageModelRenderer.value === 'vrm' && isThreeControl(control))
return threeCtrlConf[control].buttonText
return control
}
function handleViewControlsToggle(targetMode: string) {
if (!controlEnabled.value || !controlEnabled.value.supported.includes(targetMode as any))
return
if (controlEnabled.value.mode.value === targetMode) {
controlEnabled.value.reset(controlEnabled.value.mode.value as any)
if (stageModelRenderer.value === 'live2d' && isL2dControl(targetMode)) {
if (l2dCtrlMode.value === targetMode)
l2dSet(targetMode)
else
l2dCtrlMode.value = targetMode
return
}
controlEnabled.value.mode.value = targetMode as any
if (stageModelRenderer.value === 'vrm' && isThreeControl(targetMode)) {
if (threeCtrlMode.value === targetMode)
threeSet(targetMode)
else
threeCtrlMode.value = targetMode
}
}
</script>
<template>
<div :class="['w-full flex items-center self-end justify-end gap-2', $slots.default ? 'flex-col' : 'flex-1']">
<div :class="['w-full flex items-center self-end justify-end gap-2', props.variant === 'desktop' && 'flex-1']">
<Transition name="fade">
<div v-if="controlEnabled?.enabled.value" :class="['w-full flex justify-between gap-2', $slots.default && 'px-4 pb-4']">
<div v-if="controlEnabled?.enabled.value" :class="['w-full flex justify-between gap-2']">
<Button
v-for="control in controlEnabled.supported"
v-for="control in visibleControls"
:key="control"
:aria-pressed="controlEnabled.mode.value === control"
:color="controlEnabled.mode.value === control ? 'primary' : 'neutral'"
@@ -41,24 +82,12 @@ function handleViewControlsToggle(targetMode: string) {
block
@click="handleViewControlsToggle(control)"
>
{{ (controlEnabled.conf as any)[control].buttonText }}
{{ controlLabel(control) }}
</Button>
</div>
</Transition>
<GhostButton
v-if="$slots.default"
block size="unset"
:disabled="!controlEnabled"
:aria-expanded="controlEnabled?.enabled.value ?? false"
:class="['mobile-tool-row order-first min-h-15 rounded-none px-4 py-3']"
@click="controlEnabled && (controlEnabled.enabled.value = !controlEnabled.enabled.value)"
>
<span aria-hidden="true" :class="['i-solar:tuning-outline size-5 shrink-0 text-neutral-400']" />
<span :class="['flex-1 text-left text-sm']"><slot /></span>
<span aria-hidden="true" :class="['size-4 text-neutral-400', controlEnabled?.enabled.value ? 'i-solar:alt-arrow-up-outline' : 'i-solar:alt-arrow-down-outline']" />
</GhostButton>
<button
v-else
v-if="props.variant === 'desktop'"
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"
text="neutral-500 dark:neutral-400"
@@ -73,11 +102,6 @@ function handleViewControlsToggle(targetMode: string) {
</template>
<style scoped>
.mobile-tool-row :deep(.basic-button-content) {
width: 100%;
gap: 0.75rem;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease-in-out;
@@ -14,6 +14,7 @@ import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store
import { useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
import { BasicButton, BasicTextarea } from '@proj-airi/ui'
import { onLongPress, useEventListener, usePointerSwipe } from '@vueuse/core'
import { animate, spring } from 'animejs'
@@ -21,6 +22,7 @@ import { storeToRefs } from 'pinia'
import { computed, nextTick, onMounted, onUnmounted, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ViewControls from './InteractiveArea/Actions/ViewControls.vue'
import MobileSettingsDrawer from './mobile-settings-drawer.vue'
import MobileHeader from './MobileHeader.vue'
@@ -107,7 +109,7 @@ const mobileInteractiveAreaClass = [
]
const chatHistoryClass = [
'pointer-events-auto relative z-20',
'max-w-[calc(100%_-_3.5rem)] w-full self-start pb-3 pl-3',
'w-full self-start px-3 pb-3',
]
const controlsIslandClass = computed(() => [
'absolute right-0 translate-y-[-100%]',
@@ -120,8 +122,17 @@ const controlsIslandClass = computed(() => [
],
])
const { themeColorsHueDynamic } = storeToRefs(useSettings())
const { viewControlsEnabled: l2dViewCtrlEnabled } = useL2dViewControl()
const { viewControlsEnabled: threeViewCtrlEnabled } = useThreeViewControl()
const { stageModelRenderer } = storeToRefs(useSettingsStageModel())
const l2dViewControl = useL2dViewControl()
const threeViewControl = useThreeViewControl()
const viewControlsAvailable = computed(() => stageModelRenderer.value === 'live2d' || stageModelRenderer.value === 'vrm')
const viewControlsEnabled = computed(() => {
if (stageModelRenderer.value === 'live2d')
return l2dViewControl.viewControlsEnabled.value
if (stageModelRenderer.value === 'vrm')
return threeViewControl.viewControlsEnabled.value
return false
})
const settingsAudioDevice = useSettingsAudioDevice()
const { enabled, stream } = storeToRefs(settingsAudioDevice)
const { t } = useI18n()
@@ -129,6 +140,55 @@ const { audioContext } = useAudioContext()
const { startAnalyzer, stopAnalyzer } = useAudioAnalyzer()
let analyzerSource: MediaStreamAudioSourceNode | undefined
async function openViewControls() {
closeViewControls()
if (stageModelRenderer.value === 'live2d')
l2dViewControl.viewControlsEnabled.value = true
else if (stageModelRenderer.value === 'vrm')
threeViewControl.viewControlsEnabled.value = true
await nextTick()
mobileInteractiveArea.value
?.querySelector<HTMLButtonElement>('[data-testid="view-controls-close-button"]')
?.focus()
}
function closeViewControls() {
l2dViewControl.viewControlsEnabled.value = false
threeViewControl.viewControlsEnabled.value = false
}
async function exitViewControls() {
closeViewControls()
await nextTick()
mobileInteractiveArea.value
?.querySelector<HTMLButtonElement>('[data-testid="mobile-settings-button"]')
?.focus()
}
watch(stageModelRenderer, (renderer) => {
const exitsViewControls = (renderer !== 'live2d' && l2dViewControl.viewControlsEnabled.value)
|| (renderer !== 'vrm' && threeViewControl.viewControlsEnabled.value)
if (exitsViewControls) {
void exitViewControls()
return
}
if (renderer !== 'live2d')
l2dViewControl.viewControlsEnabled.value = false
if (renderer !== 'vrm')
threeViewControl.viewControlsEnabled.value = false
}, { immediate: true })
function handleViewControlsKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || !viewControlsEnabled.value)
return
event.preventDefault()
void exitViewControls()
}
function isMobileDevice() {
return /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
}
@@ -388,8 +448,9 @@ onUnmounted(() => {
data-testid="mobile-interactive-area"
:class="mobileInteractiveAreaClass"
:style="mobileInteractiveAreaStyle"
@keydown="handleViewControlsKeydown"
>
<MobileHeader>
<MobileHeader v-if="!viewControlsEnabled">
<BasicButton
size="unset"
data-testid="conversation-selector-button"
@@ -407,7 +468,27 @@ onUnmounted(() => {
<span aria-hidden="true" :class="['i-solar:dialog-2-outline size-6']" />
</BasicButton>
<CharacterSwitcherDrawer />
<MobileSettingsDrawer v-model:character-voice-enabled="characterVoiceEnabled" />
<MobileSettingsDrawer
v-model:character-voice-enabled="characterVoiceEnabled"
:view-controls-available="viewControlsAvailable"
@open-view-controls="openViewControls"
/>
</MobileHeader>
<MobileHeader v-else>
<BasicButton
size="unset"
data-testid="view-controls-close-button"
:title="t('stage.mobile-tools.close-view')"
:aria-label="t('stage.mobile-tools.close-view')"
:class="[
'pointer-events-auto ml-auto size-11 rounded-full backdrop-blur-md',
'bg-neutral-50/70 text-neutral-600 dark:bg-neutral-900/70 dark:text-neutral-300',
'focus-visible:outline-2 focus-visible:outline-primary-500',
]"
@click="exitViewControls"
>
<span aria-hidden="true" :class="['i-solar:close-circle-outline size-6']" />
</BasicButton>
</MobileHeader>
<div
:class="[
@@ -417,7 +498,7 @@ onUnmounted(() => {
<KeepAlive>
<Transition name="fade">
<ChatHistory
v-if="!threeViewCtrlEnabled && !l2dViewCtrlEnabled"
v-if="!viewControlsEnabled"
variant="mobile"
:messages="historyMessages"
:sending="isActiveSessionSending"
@@ -432,6 +513,7 @@ onUnmounted(() => {
</KeepAlive>
</div>
<div
v-show="!viewControlsEnabled"
ref="interactionControls"
data-testid="mobile-interaction-controls"
:class="[
@@ -552,5 +634,16 @@ onUnmounted(() => {
</button>
</div>
</div>
<div
v-show="viewControlsEnabled"
data-testid="view-controls-toolbar"
:class="[
'pointer-events-auto fixed inset-x-0 z-30',
'pl-[max(0.75rem,env(safe-area-inset-left))] pr-[max(0.75rem,env(safe-area-inset-right))]',
'bottom-[max(1rem,env(safe-area-inset-bottom))]',
]"
>
<ViewControls variant="mobile-stage" />
</div>
</div>
</template>
@@ -9,10 +9,15 @@ import { useI18n } from 'vue-i18n'
import { RouterLink, useRouter } from 'vue-router'
import ActionAbout from './InteractiveArea/Actions/About.vue'
import ViewControls from './InteractiveArea/Actions/ViewControls.vue'
import { BackgroundDialogPicker } from '../Backgrounds'
const props = defineProps<{
viewControlsAvailable: boolean
}>()
const emit = defineEmits<{
openViewControls: []
}>()
const characterVoiceEnabled = defineModel<boolean>('characterVoiceEnabled', { required: true })
const { t } = useI18n()
const { isDark } = useTheme()
@@ -25,9 +30,9 @@ const backgroundDialogOpen = shallowRef(false)
const settingsOpen = shallowRef(false)
const aboutOpen = shallowRef(false)
// Finish closing settings before opening a sibling modal, so focus and scroll locks have one owner.
const nextPanel = shallowRef<'background' | 'about' | 'account' | 'hearing'>()
const nextPanel = shallowRef<'background' | 'about' | 'account' | 'hearing' | 'view'>()
function openPanel(panel: 'background' | 'about' | 'account' | 'hearing') {
function openPanel(panel: 'background' | 'about' | 'account' | 'hearing' | 'view') {
nextPanel.value = panel
settingsOpen.value = false
}
@@ -48,6 +53,9 @@ function finishSettingsClose() {
else
authStore.needsLogin = true
}
else if (nextPanel.value === 'view') {
emit('openViewControls')
}
nextPanel.value = undefined
}
@@ -112,9 +120,16 @@ watch(hearingOpen, async (open) => {
<span aria-hidden="true" :class="['i-solar:alt-arrow-right-outline size-4 text-neutral-400']" />
</GhostButton>
<div :class="['mx-4 border-t border-neutral-100 dark:border-neutral-700/50']" />
<ViewControls>
{{ t('stage.mobile-tools.view') }}
</ViewControls>
<GhostButton
block size="unset"
:disabled="!props.viewControlsAvailable"
:class="['mobile-tool-row min-h-13 rounded-none px-4 py-3']"
@click="openPanel('view')"
>
<span aria-hidden="true" :class="['i-solar:tuning-outline size-5 shrink-0 text-neutral-400']" />
<span :class="['flex-1 text-left text-sm']">{{ t('stage.mobile-tools.view') }}</span>
<span aria-hidden="true" :class="['i-solar:alt-arrow-right-outline size-4 text-neutral-400']" />
</GhostButton>
</div>
</section>
<section :class="['mb-4']">