mirror of
https://github.com/moeru-ai/airi.git
synced 2026-10-30 19:34:10 +08:00
feat(stage-*): add coordinated splash and loading screens (#2698)
This commit is contained in:
@@ -38,13 +38,74 @@
|
||||
const setting = localStorage.getItem('vueuse-color-scheme') || 'auto'
|
||||
if (setting === 'dark' || (prefersDark && setting !== 'light'))
|
||||
document.documentElement.classList.toggle('dark', true)
|
||||
|
||||
const savedHue = localStorage.getItem('settings/theme/colors/hue')
|
||||
const hue = Number(savedHue)
|
||||
if (savedHue !== null && Number.isFinite(hue))
|
||||
document.documentElement.style.setProperty('--chromatic-hue', hue.toString())
|
||||
})()
|
||||
</script>
|
||||
<script src="/assets/js/CubismSdkForWeb-5-r.3/Core/live2dcubismcore.min.js"></script>
|
||||
<style>
|
||||
#boot-splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: max(calc(env(safe-area-inset-top) + 48px), calc(40vh - 82px)) 24px calc(env(safe-area-inset-bottom) + clamp(48px, 8vh, 96px));
|
||||
background: #fff;
|
||||
color: #262626;
|
||||
font-family: "Comfortaa Variable", "Comfortaa", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
#boot-splash .brand { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; gap: 20px; }
|
||||
#boot-splash img { width: 96px; height: 96px; filter: hue-rotate(calc(var(--chromatic-hue, 220.44) * 1deg)); }
|
||||
#boot-splash strong { font-size: 32px; font-weight: 700; letter-spacing: 0.07em; padding-left: 0.07em; -webkit-text-stroke: 0.5px currentColor; }
|
||||
#boot-splash .status { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; width: min(220px, 60vw); height: 44px; }
|
||||
#boot-splash .track-space { width: 48px; height: 3px; margin-top: 28px; }
|
||||
#boot-splash .boot-error { display: none; position: absolute; bottom: max(calc(env(safe-area-inset-bottom) + 48px), 8vh); flex-direction: column; align-items: center; gap: 12px; max-width: 80vw; text-align: center; font-size: 13px; }
|
||||
#boot-splash .boot-error button { padding: 10px 20px; border: 0; border-radius: 12px; background: oklch(65% 0.16 var(--chromatic-hue, 220.44)); color: #fff; font: inherit; cursor: pointer; }
|
||||
#boot-splash.failed .boot-error { display: flex; }
|
||||
#app:not(:empty) + #boot-splash { display: none; }
|
||||
html.dark #boot-splash { background: #171717; color: #f5f5f5; }
|
||||
</style>
|
||||
<noscript><style>#boot-splash { display: none; }</style></noscript>
|
||||
</head>
|
||||
<body class="font-sans">
|
||||
<div id="app"></div>
|
||||
<div id="boot-splash" role="status" aria-busy="true">
|
||||
<div class="brand">
|
||||
<img src="/favicon.svg" alt="" />
|
||||
<strong>AIRI</strong>
|
||||
</div>
|
||||
<div class="status"><span class="track-space" aria-hidden="true"></span></div>
|
||||
<div class="boot-error" role="alert"><span>AIRI could not start.</span><button type="button" onclick="location.reload()">Retry</button></div>
|
||||
</div>
|
||||
<script src="/assets/js/CubismSdkForWeb-5-r.3/Core/live2dcubismcore.min.js"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script id="boot-splash-locales" type="application/json">__AIRI_STARTUP_LOCALES__</script>
|
||||
<script>
|
||||
;(function () {
|
||||
const data = JSON.parse(document.querySelector('#boot-splash-locales').textContent)
|
||||
const preferred = localStorage.getItem('settings/language') || navigator.language
|
||||
const locale = data.localeRemap[preferred] || preferred
|
||||
const labels = data.labels[locale] || data.labels.en
|
||||
document.querySelector('#boot-splash .boot-error span').textContent = labels.failed
|
||||
document.querySelector('#boot-splash .boot-error button').textContent = labels.retry
|
||||
|
||||
setTimeout(() => {
|
||||
if (document.querySelector('#app:empty')) {
|
||||
const splash = document.querySelector('#boot-splash')
|
||||
splash?.classList.add('failed')
|
||||
splash?.setAttribute('aria-busy', 'false')
|
||||
}
|
||||
}, 15000)
|
||||
})()
|
||||
</script>
|
||||
<noscript> This website requires JavaScript to function properly. Please enable JavaScript to continue. </noscript>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@proj-airi/stage-ui/components'
|
||||
import { OnboardingDialog, OnboardingStepAnalyticsNotice, StartupOverlay, ToasterRoot } from '@proj-airi/stage-ui/components'
|
||||
import { useStartupResourceTimeout } from '@proj-airi/stage-ui/composables/use-startup-resource-timeout'
|
||||
import { usePiniaSynced } from '@proj-airi/stage-ui/libs/pinia'
|
||||
import { initializeAnalytics, isAnalyticsAvailableInBuild } from '@proj-airi/stage-ui/libs/product-signals'
|
||||
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
|
||||
@@ -16,12 +17,13 @@ import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision'
|
||||
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
|
||||
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
|
||||
import { useStartupResourcesStore } from '@proj-airi/stage-ui/stores/startup-resources'
|
||||
import { useTheme } from '@proj-airi/ui'
|
||||
import { StageTransitionGroup } from '@proj-airi/ui-transitions'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { RouterView, useRouter } from 'vue-router'
|
||||
import { toast, Toaster } from 'vue-sonner'
|
||||
|
||||
import OnboardingPermissionsStep from './components/onboarding/step-permissions.vue'
|
||||
@@ -31,6 +33,7 @@ import { getHostWebSocketConnector } from './modules/websocket-bridge'
|
||||
const contextBridgeStore = useContextBridgeStore()
|
||||
const authStore = useAuthStore()
|
||||
const i18n = useI18n()
|
||||
const router = useRouter()
|
||||
const displayModelsStore = useDisplayModelsStore()
|
||||
const settingsStore = useSettings()
|
||||
const settings = storeToRefs(settingsStore)
|
||||
@@ -42,6 +45,15 @@ const settingsAudioDeviceStore = useSettingsAudioDevice()
|
||||
const { showingSetup } = storeToRefs(onboardingStore)
|
||||
const { isDark } = useTheme()
|
||||
const cardStore = useAiriCardStore()
|
||||
const startup = useStartupResourcesStore()
|
||||
startup.reset()
|
||||
startup.register(['auth', 'modelIndex', 'card', 'services', 'modelData', 'modelSelection', 'audio', 'route', 'model'])
|
||||
useStartupResourceTimeout('model', 120_000, () => i18n.t('stage.startup.model-timeout'))
|
||||
const startupOnboarding = ref(false)
|
||||
watch(showingSetup, (visible) => {
|
||||
if (!visible)
|
||||
startupOnboarding.value = false
|
||||
})
|
||||
useArtistryStore()
|
||||
useConsciousnessStore()
|
||||
useHearingStore()
|
||||
@@ -104,30 +116,43 @@ watch(settings.themeColorsHueDynamic, () => {
|
||||
document.documentElement.classList.toggle('dynamic-hue', settings.themeColorsHueDynamic.value)
|
||||
}, { immediate: true })
|
||||
|
||||
// Initialize first-time setup check when app mounts
|
||||
onMounted(async () => {
|
||||
initializeAnalytics()
|
||||
await authStore.initialize()
|
||||
await displayModelsStore.initialize()
|
||||
await cardStore.initialize()
|
||||
registerAuthenticatedSetup()
|
||||
if (!authStore.isAuthenticated)
|
||||
await removeAuthenticationProviderConfiguration()
|
||||
|
||||
if (onboardingStore.needsOnboarding) {
|
||||
onboardingStore.showingSetup = true
|
||||
async function loadStartup() {
|
||||
try {
|
||||
initializeAnalytics()
|
||||
await startup.run('auth', () => authStore.initialize())
|
||||
await startup.run('modelIndex', () => displayModelsStore.initialize())
|
||||
await startup.run('card', async () => {
|
||||
await cardStore.initialize()
|
||||
registerAuthenticatedSetup()
|
||||
if (!authStore.isAuthenticated)
|
||||
await removeAuthenticationProviderConfiguration()
|
||||
})
|
||||
await startup.run('services', () => {
|
||||
void serverChannelStore.initialize({ possibleEvents: ['ui:configure'], connector: getHostWebSocketConnector }).catch(error => console.error('Mods server initialization failed:', error))
|
||||
contextBridgeStore.initialize()
|
||||
characterOrchestratorStore.initialize()
|
||||
})
|
||||
await startup.run('modelData', () => displayModelsStore.loadDisplayModelsFromIndexedDB())
|
||||
await startup.run('modelSelection', () => settingsStore.initializeStageModel())
|
||||
await startup.run('audio', () => settingsAudioDeviceStore.initialize())
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Startup failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
await serverChannelStore.initialize({
|
||||
possibleEvents: ['ui:configure'],
|
||||
connector: getHostWebSocketConnector,
|
||||
}).catch(err => console.error('Failed to initialize Mods Server Channel in App.vue:', err))
|
||||
contextBridgeStore.initialize()
|
||||
characterOrchestratorStore.initialize()
|
||||
onMounted(() => {
|
||||
void startup.run('route', () => router.isReady()).catch(error => console.error('Initial route failed:', error))
|
||||
void loadStartup()
|
||||
})
|
||||
|
||||
await displayModelsStore.loadDisplayModelsFromIndexedDB()
|
||||
await settingsStore.initializeStageModel()
|
||||
await settingsAudioDeviceStore.initialize()
|
||||
watch(() => [startup.resources.find(resource => resource.id === 'modelSelection')?.status, startup.resources.find(resource => resource.id === 'route')?.status], ([selection, route]) => {
|
||||
if (selection !== 'ready' || route !== 'ready' || startup.resources.find(resource => resource.id === 'model')?.status !== 'queued')
|
||||
return
|
||||
if (router.currentRoute.value.name !== 'IndexScenePage' || settings.stageModelRenderer.value === 'disabled' || !settings.stageModelSelectedUrl.value)
|
||||
startup.skip('model')
|
||||
else
|
||||
startup.start('model')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -136,13 +161,16 @@ onUnmounted(() => {
|
||||
contextBridgeStore.dispose()
|
||||
})
|
||||
|
||||
// Handle first-time setup events
|
||||
function handleSetupConfigured() {
|
||||
onboardingStore.markSetupCompleted()
|
||||
function continueWithoutModel() {
|
||||
settingsStore.setStageModelRenderer('disabled')
|
||||
startup.skip('model')
|
||||
}
|
||||
|
||||
function handleSetupSkipped() {
|
||||
onboardingStore.markSetupSkipped()
|
||||
function openOnboardingAfterStartup() {
|
||||
if (onboardingStore.needsOnboarding) {
|
||||
startupOnboarding.value = true
|
||||
onboardingStore.showingSetup = true
|
||||
}
|
||||
}
|
||||
|
||||
const extraSteps = computed(() => [
|
||||
@@ -159,33 +187,37 @@ const extraSteps = computed(() => [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StageTransitionGroup
|
||||
:primary-color="primaryColor"
|
||||
:secondary-color="secondaryColor"
|
||||
:tertiary-color="tertiaryColor"
|
||||
:colors="colors"
|
||||
:z-index="100"
|
||||
:disable-transitions="settings.disableTransitions.value"
|
||||
:use-page-specific-transitions="settings.usePageSpecificTransitions.value"
|
||||
>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<KeepAlive :include="['IndexScenePage', 'StageScenePage']">
|
||||
<component :is="Component" />
|
||||
</KeepAlive>
|
||||
</RouterView>
|
||||
</StageTransitionGroup>
|
||||
<StartupOverlay logo-src="/favicon.svg" @finished="openOnboardingAfterStartup" @skip-model="continueWithoutModel">
|
||||
<StageTransitionGroup
|
||||
:primary-color="primaryColor"
|
||||
:secondary-color="secondaryColor"
|
||||
:tertiary-color="tertiaryColor"
|
||||
:colors="colors"
|
||||
:z-index="100"
|
||||
:disable-transitions="settings.disableTransitions.value"
|
||||
:use-page-specific-transitions="settings.usePageSpecificTransitions.value"
|
||||
>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<KeepAlive :include="['IndexScenePage', 'StageScenePage']">
|
||||
<component :is="Component" />
|
||||
</KeepAlive>
|
||||
</RouterView>
|
||||
</StageTransitionGroup>
|
||||
|
||||
<ToasterRoot @close="id => toast.dismiss(id)">
|
||||
<Toaster rich-colors />
|
||||
</ToasterRoot>
|
||||
<ToasterRoot @close="id => toast.dismiss(id)">
|
||||
<Toaster rich-colors />
|
||||
</ToasterRoot>
|
||||
|
||||
<!-- First Time Setup Dialog -->
|
||||
<OnboardingDialog
|
||||
v-model="showingSetup"
|
||||
:extra-steps="extraSteps"
|
||||
@configured="handleSetupConfigured"
|
||||
@skipped="handleSetupSkipped"
|
||||
/>
|
||||
<!-- First Time Setup Dialog -->
|
||||
<OnboardingDialog
|
||||
v-model="showingSetup"
|
||||
:scale-background="false"
|
||||
:instant-open="startupOnboarding"
|
||||
:extra-steps="extraSteps"
|
||||
@configured="onboardingStore.markSetupCompleted()"
|
||||
@skipped="onboardingStore.markSetupSkipped()"
|
||||
/>
|
||||
</StartupOverlay>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
||||
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
|
||||
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { useStartupResourcesStore } from '@proj-airi/stage-ui/stores/startup-resources'
|
||||
import { breakpointsTailwind, useBreakpoints, useMouse } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
@@ -25,6 +26,22 @@ import { computed, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watc
|
||||
import WebSocketStatusButton from '../components/websocket-status-button.vue'
|
||||
|
||||
const paused = ref(false)
|
||||
const modelRenderState = ref<'pending' | 'loading' | 'mounted'>('pending')
|
||||
const modelRenderError = ref<Error>()
|
||||
const startup = useStartupResourcesStore()
|
||||
|
||||
watch([modelRenderState, modelRenderError, () => startup.resources.find(resource => resource.id === 'model')?.status], ([state, error, status]) => {
|
||||
if (status !== 'loading')
|
||||
return
|
||||
if (error)
|
||||
startup.fail('model', error)
|
||||
else if (state === 'mounted')
|
||||
startup.complete('model')
|
||||
})
|
||||
|
||||
function markModelFailed(error: Error) {
|
||||
modelRenderError.value = error
|
||||
}
|
||||
|
||||
function handleSettingsOpen(open: boolean) {
|
||||
paused.value = open
|
||||
@@ -206,6 +223,7 @@ onUnmounted(() => {
|
||||
<ViewControlSlider />
|
||||
</div>
|
||||
<WidgetStage
|
||||
v-model:state="modelRenderState"
|
||||
h-full w-full
|
||||
:enable-orbit-controls="!isMobile"
|
||||
:paused="paused"
|
||||
@@ -213,6 +231,7 @@ onUnmounted(() => {
|
||||
x: positionCursor.x.value,
|
||||
y: positionCursor.y.value,
|
||||
}"
|
||||
@error="markModelFailed"
|
||||
/>
|
||||
</div>
|
||||
<InteractiveArea v-if="!isMobile" h="85dvh" absolute right-4 flex flex-1 flex-col max-w="500px" min-w="30%" />
|
||||
|
||||
@@ -7,9 +7,7 @@ export default mergeConfigs([
|
||||
{
|
||||
presets: [
|
||||
presetWebFonts({
|
||||
fonts: {
|
||||
...presetWebFontsFonts('fontsource'),
|
||||
},
|
||||
fonts: presetWebFontsFonts('fontsource'),
|
||||
timeouts: {
|
||||
warning: 5000,
|
||||
failure: 10000,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { execSync } from 'node:child_process'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
import VueI18n from '@intlify/unplugin-vue-i18n/vite'
|
||||
import messages from '@proj-airi/i18n/locales'
|
||||
import templateCompilerOptions from '@tresjs/core/template-compiler-options'
|
||||
import Vue from '@vitejs/plugin-vue'
|
||||
import Unocss from 'unocss/vite'
|
||||
@@ -20,6 +21,7 @@ import VueMacros from 'vue-macros/vite'
|
||||
import VueRouter from 'vue-router/vite'
|
||||
|
||||
import { tryCatch } from '@moeru/std'
|
||||
import { localeRemap, serializeStartupFallbackLocales } from '@proj-airi/i18n'
|
||||
import { paraformerBilingualZhEn, xAsrBilingualZhEnInt8, zipformerMultilingual } from '@proj-airi/provider-inference/sherpaw-transcription/models'
|
||||
import { Download } from '@proj-airi/unplugin-fetch/vite'
|
||||
import { DownloadLive2DSDK } from '@proj-airi/unplugin-live2d-sdk/vite'
|
||||
@@ -106,6 +108,10 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
plugins: [
|
||||
{
|
||||
name: 'airi-startup-locales',
|
||||
transformIndexHtml: html => html.replace('__AIRI_STARTUP_LOCALES__', serializeStartupFallbackLocales(messages, localeRemap)),
|
||||
},
|
||||
...isEnvTruthy(process.env.VITE_SKIP_MKCERT ?? '')
|
||||
? []
|
||||
: [mkcert((() => {
|
||||
|
||||
@@ -38,13 +38,74 @@
|
||||
const setting = localStorage.getItem('vueuse-color-scheme') || 'auto'
|
||||
if (setting === 'dark' || (prefersDark && setting !== 'light'))
|
||||
document.documentElement.classList.toggle('dark', true)
|
||||
|
||||
const savedHue = localStorage.getItem('settings/theme/colors/hue')
|
||||
const hue = Number(savedHue)
|
||||
if (savedHue !== null && Number.isFinite(hue))
|
||||
document.documentElement.style.setProperty('--chromatic-hue', hue.toString())
|
||||
})()
|
||||
</script>
|
||||
<script src="/assets/js/CubismSdkForWeb-5-r.3/Core/live2dcubismcore.min.js"></script>
|
||||
<style>
|
||||
#boot-splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: max(calc(env(safe-area-inset-top) + 48px), calc(40vh - 82px)) 24px calc(env(safe-area-inset-bottom) + clamp(48px, 8vh, 96px));
|
||||
background: #fff;
|
||||
color: #262626;
|
||||
font-family: "Comfortaa Variable", "Comfortaa", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
#boot-splash .brand { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; gap: 20px; }
|
||||
#boot-splash img { width: 96px; height: 96px; filter: hue-rotate(calc(var(--chromatic-hue, 220.44) * 1deg)); }
|
||||
#boot-splash strong { font-size: 32px; font-weight: 700; letter-spacing: 0.07em; padding-left: 0.07em; -webkit-text-stroke: 0.5px currentColor; }
|
||||
#boot-splash .status { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; width: min(220px, 60vw); height: 44px; }
|
||||
#boot-splash .track-space { width: 48px; height: 3px; margin-top: 28px; }
|
||||
#boot-splash .boot-error { display: none; position: absolute; bottom: max(calc(env(safe-area-inset-bottom) + 48px), 8vh); flex-direction: column; align-items: center; gap: 12px; max-width: 80vw; text-align: center; font-size: 13px; }
|
||||
#boot-splash .boot-error button { padding: 10px 20px; border: 0; border-radius: 12px; background: oklch(65% 0.16 var(--chromatic-hue, 220.44)); color: #fff; font: inherit; cursor: pointer; }
|
||||
#boot-splash.failed .boot-error { display: flex; }
|
||||
#app:not(:empty) + #boot-splash { display: none; }
|
||||
html.dark #boot-splash { background: #171717; color: #f5f5f5; }
|
||||
</style>
|
||||
<noscript><style>#boot-splash { display: none; }</style></noscript>
|
||||
</head>
|
||||
<body class="font-sans">
|
||||
<div id="app"></div>
|
||||
<div id="boot-splash" role="status" aria-busy="true">
|
||||
<div class="brand">
|
||||
<img src="/favicon.svg" alt="" />
|
||||
<strong>AIRI</strong>
|
||||
</div>
|
||||
<div class="status"><span class="track-space" aria-hidden="true"></span></div>
|
||||
<div class="boot-error" role="alert"><span>AIRI could not start.</span><button type="button" onclick="location.reload()">Retry</button></div>
|
||||
</div>
|
||||
<script src="/assets/js/CubismSdkForWeb-5-r.3/Core/live2dcubismcore.min.js"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script id="boot-splash-locales" type="application/json">__AIRI_STARTUP_LOCALES__</script>
|
||||
<script>
|
||||
;(function () {
|
||||
const data = JSON.parse(document.querySelector('#boot-splash-locales').textContent)
|
||||
const preferred = localStorage.getItem('settings/language') || navigator.language
|
||||
const locale = data.localeRemap[preferred] || preferred
|
||||
const labels = data.labels[locale] || data.labels.en
|
||||
document.querySelector('#boot-splash .boot-error span').textContent = labels.failed
|
||||
document.querySelector('#boot-splash .boot-error button').textContent = labels.retry
|
||||
|
||||
setTimeout(() => {
|
||||
if (document.querySelector('#app:empty')) {
|
||||
const splash = document.querySelector('#boot-splash')
|
||||
splash?.classList.add('failed')
|
||||
splash?.setAttribute('aria-busy', 'false')
|
||||
}
|
||||
}, 15000)
|
||||
})()
|
||||
</script>
|
||||
<noscript> This website requires JavaScript to function properly. Please enable JavaScript to continue. </noscript>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"@proj-airi/stage-ui-three": "workspace:^",
|
||||
"@proj-airi/stream-kit": "workspace:^",
|
||||
"@proj-airi/ui": "workspace:^",
|
||||
"@proj-airi/ui-loading-screens": "workspace:^",
|
||||
"@proj-airi/ui-transitions": "workspace:^",
|
||||
"@tresjs/cientos": "catalog:",
|
||||
"@tresjs/core": "catalog:",
|
||||
|
||||
+92
-59
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@proj-airi/stage-ui/components'
|
||||
import { OnboardingDialog, OnboardingStepAnalyticsNotice, StartupOverlay, ToasterRoot } from '@proj-airi/stage-ui/components'
|
||||
import { useInferencePreload } from '@proj-airi/stage-ui/composables'
|
||||
import { useStartupResourceTimeout } from '@proj-airi/stage-ui/composables/use-startup-resource-timeout'
|
||||
import { usePiniaSynced } from '@proj-airi/stage-ui/libs/pinia'
|
||||
import { initializeAnalytics, isAnalyticsAvailableInBuild } from '@proj-airi/stage-ui/libs/product-signals'
|
||||
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
|
||||
@@ -18,12 +19,13 @@ import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision'
|
||||
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
|
||||
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
|
||||
import { useStartupResourcesStore } from '@proj-airi/stage-ui/stores/startup-resources'
|
||||
import { ErrorBoundary, useTheme } from '@proj-airi/ui'
|
||||
import { StageTransitionGroup } from '@proj-airi/ui-transitions'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { RouterView, useRouter } from 'vue-router'
|
||||
import { toast, Toaster } from 'vue-sonner'
|
||||
|
||||
import PerformanceOverlay from './components/Devtools/PerformanceOverlay.vue'
|
||||
@@ -35,6 +37,7 @@ usePWAStore()
|
||||
const contextBridgeStore = useContextBridgeStore()
|
||||
const authStore = useAuthStore()
|
||||
const i18n = useI18n()
|
||||
const router = useRouter()
|
||||
const displayModelsStore = useDisplayModelsStore()
|
||||
const settingsStore = useSettings()
|
||||
const settings = storeToRefs(settingsStore)
|
||||
@@ -76,6 +79,15 @@ function registerAuthenticatedSetup() {
|
||||
}
|
||||
|
||||
const inferencePreload = useInferencePreload()
|
||||
const startup = useStartupResourcesStore()
|
||||
startup.reset()
|
||||
startup.register(['auth', 'modelIndex', 'card', 'chat', 'services', 'modelData', 'modelSelection', 'audio', 'route', 'model'])
|
||||
useStartupResourceTimeout('model', 120_000, () => i18n.t('stage.startup.model-timeout'))
|
||||
const startupOnboarding = ref(false)
|
||||
watch(showingSetup, (visible) => {
|
||||
if (!visible)
|
||||
startupOnboarding.value = false
|
||||
})
|
||||
|
||||
const primaryColor = computed(() => {
|
||||
return isDark.value
|
||||
@@ -117,31 +129,45 @@ watch(settings.themeColorsHueDynamic, () => {
|
||||
document.documentElement.classList.toggle('dynamic-hue', settings.themeColorsHueDynamic.value)
|
||||
}, { immediate: true })
|
||||
|
||||
// Initialize first-time setup check when app mounts
|
||||
onMounted(async () => {
|
||||
initializeAnalytics()
|
||||
await authStore.initialize()
|
||||
await displayModelsStore.initialize()
|
||||
await cardStore.initialize()
|
||||
registerAuthenticatedSetup()
|
||||
if (!authStore.isAuthenticated)
|
||||
await removeAuthenticationProviderConfiguration()
|
||||
|
||||
if (onboardingStore.needsOnboarding) {
|
||||
onboardingStore.showingSetup = true
|
||||
async function loadStartup() {
|
||||
try {
|
||||
initializeAnalytics()
|
||||
await startup.run('auth', () => authStore.initialize())
|
||||
await startup.run('modelIndex', () => displayModelsStore.initialize())
|
||||
await startup.run('card', async () => {
|
||||
await cardStore.initialize()
|
||||
registerAuthenticatedSetup()
|
||||
if (!authStore.isAuthenticated)
|
||||
await removeAuthenticationProviderConfiguration()
|
||||
})
|
||||
await startup.run('chat', () => chatStore.initialize(syncedPinia))
|
||||
await startup.run('services', () => {
|
||||
void serverChannelStore.initialize({ possibleEvents: ['ui:configure'] }).catch(error => console.error('Mods server initialization failed:', error))
|
||||
contextBridgeStore.initialize()
|
||||
characterOrchestratorStore.initialize()
|
||||
})
|
||||
await startup.run('modelData', () => displayModelsStore.loadDisplayModelsFromIndexedDB())
|
||||
await startup.run('modelSelection', () => settingsStore.initializeStageModel())
|
||||
await startup.run('audio', () => settingsAudioDeviceStore.initialize())
|
||||
inferencePreload.triggerPreload()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Startup failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
await chatStore.initialize(syncedPinia)
|
||||
await serverChannelStore.initialize({ possibleEvents: ['ui:configure'] }).catch(err => console.error('Failed to initialize Mods Server Channel in App.vue:', err))
|
||||
contextBridgeStore.initialize()
|
||||
characterOrchestratorStore.initialize()
|
||||
onMounted(() => {
|
||||
void startup.run('route', () => router.isReady()).catch(error => console.error('Initial route failed:', error))
|
||||
void loadStartup()
|
||||
})
|
||||
|
||||
await displayModelsStore.loadDisplayModelsFromIndexedDB()
|
||||
await settingsStore.initializeStageModel()
|
||||
await settingsAudioDeviceStore.initialize()
|
||||
|
||||
// Preload local inference models (Kokoro TTS, etc.) in background after a delay
|
||||
inferencePreload.triggerPreload()
|
||||
watch(() => [startup.resources.find(resource => resource.id === 'modelSelection')?.status, startup.resources.find(resource => resource.id === 'route')?.status], ([selection, route]) => {
|
||||
if (selection !== 'ready' || route !== 'ready' || startup.resources.find(resource => resource.id === 'model')?.status !== 'queued')
|
||||
return
|
||||
if (router.currentRoute.value.name !== 'IndexScenePage' || settings.stageModelRenderer.value === 'disabled' || !settings.stageModelSelectedUrl.value)
|
||||
startup.skip('model')
|
||||
else
|
||||
startup.start('model')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -151,49 +177,56 @@ onUnmounted(() => {
|
||||
contextBridgeStore.dispose()
|
||||
})
|
||||
|
||||
// Handle first-time setup events
|
||||
function handleSetupConfigured() {
|
||||
onboardingStore.markSetupCompleted()
|
||||
function continueWithoutModel() {
|
||||
settingsStore.setStageModelRenderer('disabled')
|
||||
startup.skip('model')
|
||||
}
|
||||
|
||||
function handleSetupSkipped() {
|
||||
onboardingStore.markSetupSkipped()
|
||||
function openOnboardingAfterStartup() {
|
||||
if (onboardingStore.needsOnboarding) {
|
||||
startupOnboarding.value = true
|
||||
onboardingStore.showingSetup = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StageTransitionGroup
|
||||
:primary-color="primaryColor"
|
||||
:secondary-color="secondaryColor"
|
||||
:tertiary-color="tertiaryColor"
|
||||
:colors="colors"
|
||||
:z-index="100"
|
||||
:disable-transitions="settings.disableTransitions.value"
|
||||
:use-page-specific-transitions="settings.usePageSpecificTransitions.value"
|
||||
>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<ErrorBoundary
|
||||
title="Something went wrong while rendering this page."
|
||||
@error="(err, _, info) => console.error('[ErrorBoundary]', info, err)"
|
||||
>
|
||||
<component :is="Component" />
|
||||
</ErrorBoundary>
|
||||
</RouterView>
|
||||
</StageTransitionGroup>
|
||||
<StartupOverlay logo-src="/favicon.svg" @finished="openOnboardingAfterStartup" @skip-model="continueWithoutModel">
|
||||
<StageTransitionGroup
|
||||
:primary-color="primaryColor"
|
||||
:secondary-color="secondaryColor"
|
||||
:tertiary-color="tertiaryColor"
|
||||
:colors="colors"
|
||||
:z-index="100"
|
||||
:disable-transitions="settings.disableTransitions.value"
|
||||
:use-page-specific-transitions="settings.usePageSpecificTransitions.value"
|
||||
>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<ErrorBoundary
|
||||
title="Something went wrong while rendering this page."
|
||||
@error="(err, _, info) => console.error('[ErrorBoundary]', info, err)"
|
||||
>
|
||||
<component :is="Component" />
|
||||
</ErrorBoundary>
|
||||
</RouterView>
|
||||
</StageTransitionGroup>
|
||||
|
||||
<ToasterRoot @close="id => toast.dismiss(id)">
|
||||
<Toaster />
|
||||
</ToasterRoot>
|
||||
<ToasterRoot @close="id => toast.dismiss(id)">
|
||||
<Toaster />
|
||||
</ToasterRoot>
|
||||
|
||||
<!-- First Time Setup Dialog -->
|
||||
<OnboardingDialog
|
||||
v-model="showingSetup"
|
||||
:extra-steps="onboardingExtraSteps"
|
||||
@configured="handleSetupConfigured"
|
||||
@skipped="handleSetupSkipped"
|
||||
/>
|
||||
<!-- First Time Setup Dialog -->
|
||||
<OnboardingDialog
|
||||
v-model="showingSetup"
|
||||
:scale-background="false"
|
||||
:instant-open="startupOnboarding"
|
||||
:extra-steps="onboardingExtraSteps"
|
||||
@configured="onboardingStore.markSetupCompleted()"
|
||||
@skipped="onboardingStore.markSetupSkipped()"
|
||||
/>
|
||||
|
||||
<PerformanceOverlay />
|
||||
<PerformanceOverlay />
|
||||
</StartupOverlay>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -18,11 +18,28 @@ import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
||||
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
|
||||
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { useStartupResourcesStore } from '@proj-airi/stage-ui/stores/startup-resources'
|
||||
import { breakpointsTailwind, useBreakpoints, useMouse } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
|
||||
const paused = ref(false)
|
||||
const modelRenderState = ref<'pending' | 'loading' | 'mounted'>('pending')
|
||||
const modelRenderError = ref<Error>()
|
||||
const startup = useStartupResourcesStore()
|
||||
|
||||
watch([modelRenderState, modelRenderError, () => startup.resources.find(resource => resource.id === 'model')?.status], ([state, error, status]) => {
|
||||
if (status !== 'loading')
|
||||
return
|
||||
if (error)
|
||||
startup.fail('model', error)
|
||||
else if (state === 'mounted')
|
||||
startup.complete('model')
|
||||
})
|
||||
|
||||
function markModelFailed(error: Error) {
|
||||
modelRenderError.value = error
|
||||
}
|
||||
|
||||
function handleSettingsOpen(open: boolean) {
|
||||
paused.value = open
|
||||
@@ -217,10 +234,12 @@ const cursorPosition = computed(() => ({
|
||||
<ViewControlSlider />
|
||||
</div>
|
||||
<WidgetStage
|
||||
v-model:state="modelRenderState"
|
||||
h-full w-full
|
||||
:cursor-position="cursorPosition"
|
||||
:enable-orbit-controls="!isMobile"
|
||||
:paused="paused"
|
||||
@error="markModelFailed"
|
||||
/>
|
||||
</div>
|
||||
<InteractiveArea v-if="!isMobile" h="85dvh" absolute right-4 flex flex-1 flex-col max-w="500px" min-w="30%" />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { execSync } from 'node:child_process'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
import VueI18n from '@intlify/unplugin-vue-i18n/vite'
|
||||
import messages from '@proj-airi/i18n/locales'
|
||||
import templateCompilerOptions from '@tresjs/core/template-compiler-options'
|
||||
import Vue from '@vitejs/plugin-vue'
|
||||
import Unocss from 'unocss/vite'
|
||||
@@ -17,6 +18,7 @@ import VueMacros from 'vue-macros/vite'
|
||||
import VueRouter from 'vue-router/vite'
|
||||
|
||||
import { tryCatch } from '@moeru/std'
|
||||
import { localeRemap, serializeStartupFallbackLocales } from '@proj-airi/i18n'
|
||||
import { paraformerBilingualZhEn, xAsrBilingualZhEnInt8, zipformerMultilingual } from '@proj-airi/provider-inference/sherpaw-transcription/models'
|
||||
import { Download } from '@proj-airi/unplugin-fetch/vite'
|
||||
import { DownloadLive2DSDK } from '@proj-airi/unplugin-live2d-sdk/vite'
|
||||
@@ -122,6 +124,10 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
plugins: [
|
||||
{
|
||||
name: 'airi-startup-locales',
|
||||
transformIndexHtml: html => html.replace('__AIRI_STARTUP_LOCALES__', serializeStartupFallbackLocales(messages, localeRemap)),
|
||||
},
|
||||
...(
|
||||
hasFlagEnableMkcert()
|
||||
? [Mkcert((() => {
|
||||
|
||||
@@ -47,3 +47,5 @@ export function resolveSupportedLocale(
|
||||
? normalizedLocale
|
||||
: fallbackLocale
|
||||
}
|
||||
|
||||
export { serializeStartupFallbackLocales } from './startup-fallback'
|
||||
|
||||
@@ -79,6 +79,27 @@ render-error:
|
||||
{renderer} failed to render the selected model.
|
||||
Model ID: {modelId}
|
||||
Error: {error}
|
||||
startup:
|
||||
failed: Startup failed
|
||||
failed-resource: Could not load {resource}
|
||||
recover: Try again. If it fails again, open the details below.
|
||||
recover-model: Try again, or continue without a character. You can select another model later.
|
||||
details: What happened?
|
||||
close-details: Close details
|
||||
retry: Retry
|
||||
continue-without-model: Continue Anyway
|
||||
model-timeout: The character model took too long to load. Check your connection and retry.
|
||||
resources:
|
||||
auth: account settings
|
||||
modelIndex: model list
|
||||
card: character settings
|
||||
chat: chat history
|
||||
services: app services
|
||||
modelData: saved models
|
||||
modelSelection: model settings
|
||||
audio: audio settings
|
||||
route: page
|
||||
model: character model
|
||||
message: Say something...
|
||||
send-mode:
|
||||
title: Send key
|
||||
|
||||
@@ -78,6 +78,26 @@ render-error:
|
||||
{renderer} 无法渲染选中的模型。
|
||||
模型 ID:{modelId}
|
||||
错误:{error}
|
||||
startup:
|
||||
failed: 启动失败
|
||||
failed-resource: 无法加载{resource}
|
||||
recover: 请重试。如果仍然失败,请查看下方的错误详情。
|
||||
recover-model: 重新启动 AIRI 再试一次,或暂不加载角色,稍后选择其他模型。
|
||||
details: 发生了什么?
|
||||
close-details: 关闭详情
|
||||
retry: 重新启动 AIRI
|
||||
continue-without-model: 暂不加载角色
|
||||
resources:
|
||||
auth: 账户设置
|
||||
modelIndex: 模型列表
|
||||
card: 角色设置
|
||||
chat: 聊天记录
|
||||
services: 应用服务
|
||||
modelData: 已保存的模型
|
||||
modelSelection: 模型设置
|
||||
audio: 音频设置
|
||||
route: 页面
|
||||
model: 角色模型
|
||||
message: 说点什么...
|
||||
send-mode:
|
||||
title: 发送键值
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
interface StartupFallbackMessages {
|
||||
stage?: { startup?: { failed?: string, retry?: string } }
|
||||
settings?: { dialogs?: { onboarding?: { retry?: string } } }
|
||||
}
|
||||
|
||||
/** Serializes boot error labels for the inline splash that runs before Vue. */
|
||||
export function serializeStartupFallbackLocales(locales: Record<string, StartupFallbackMessages>, localeRemap: Record<string, string>): string {
|
||||
const english = locales.en?.stage?.startup
|
||||
if (!english?.failed || !english.retry)
|
||||
throw new Error('English startup error labels are required')
|
||||
|
||||
const labels = Object.fromEntries(Object.entries(locales).map(([locale, messages]) => [locale, {
|
||||
failed: messages.stage?.startup?.failed ?? english.failed,
|
||||
retry: messages.stage?.startup?.retry ?? messages.settings?.dialogs?.onboarding?.retry ?? english.retry,
|
||||
}]))
|
||||
|
||||
return JSON.stringify({ labels, localeRemap }).replaceAll('<', '\\u003c')
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { ref, watch } from 'vue'
|
||||
import SpineCanvas from './spine/Canvas.vue'
|
||||
import SpineModel from './spine/Model.vue'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Scene painted inside the canvas, behind the model. */
|
||||
backgroundUrl?: string | null
|
||||
modelSrc?: string
|
||||
@@ -27,19 +27,31 @@ withDefaults(defineProps<{
|
||||
renderScale: 1,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ (e: 'error', error: Error): void }>()
|
||||
|
||||
const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' })
|
||||
const componentStateCanvas = defineModel<'pending' | 'loading' | 'mounted'>('canvasState', { default: 'pending' })
|
||||
const componentStateModel = defineModel<'pending' | 'loading' | 'mounted'>('modelState', { default: 'pending' })
|
||||
const modelFailed = ref(false)
|
||||
watch(() => [props.modelSrc, props.modelId], () => {
|
||||
modelFailed.value = false
|
||||
})
|
||||
|
||||
const canvasRef = ref<InstanceType<typeof SpineCanvas>>()
|
||||
const modelRef = ref<InstanceType<typeof SpineModel>>()
|
||||
|
||||
watch([componentStateModel, componentStateCanvas], () => {
|
||||
componentState.value = (componentStateModel.value === 'mounted' && componentStateCanvas.value === 'mounted')
|
||||
componentState.value = (!modelFailed.value && componentStateModel.value === 'mounted' && componentStateCanvas.value === 'mounted')
|
||||
? 'mounted'
|
||||
: 'loading'
|
||||
})
|
||||
|
||||
function reportModelError(error: Error) {
|
||||
modelFailed.value = true
|
||||
componentState.value = 'loading'
|
||||
emit('error', error)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
canvasElement: () => canvasRef.value?.canvasElement(),
|
||||
captureFrame: () => canvasRef.value?.captureFrame(),
|
||||
@@ -74,6 +86,7 @@ defineExpose({
|
||||
:default-mix-duration="defaultMixDuration"
|
||||
:idle-animation-enabled="idleAnimationEnabled"
|
||||
:max-fps="maxFps"
|
||||
@error="reportModelError"
|
||||
/>
|
||||
</SpineCanvas>
|
||||
</Screen>
|
||||
|
||||
@@ -324,7 +324,6 @@ async function loadModel() {
|
||||
}
|
||||
catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
emits('error', error)
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
@@ -388,7 +387,6 @@ async function loadModel() {
|
||||
error: (_sc, errors: Record<string, string>) => {
|
||||
const message = Object.values(errors).join('; ')
|
||||
const error = new Error(message)
|
||||
emits('error', error)
|
||||
reject(error)
|
||||
},
|
||||
}
|
||||
@@ -402,6 +400,7 @@ async function loadModel() {
|
||||
// below has already fired and found nothing to draw with.
|
||||
void syncBackground()
|
||||
})
|
||||
componentState.value = 'mounted'
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[Spine] Failed to load model:', err)
|
||||
@@ -409,7 +408,6 @@ async function loadModel() {
|
||||
}
|
||||
finally {
|
||||
modelLoading.value = false
|
||||
componentState.value = 'mounted'
|
||||
modelLoadMutex.release()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,19 @@
|
||||
|
||||
Shared core for stage
|
||||
|
||||
## Startup progress
|
||||
|
||||
`useStartupResourcesStore` records each resource as queued, loading, ready, failed, or skipped.
|
||||
The apps register the complete resource list before work starts. Their startup flows report each module's result through the store.
|
||||
The app roots reset the store before registration. This also stops an old load from updating a new registration after hot reload.
|
||||
`StartupOverlay` reads the store and shows splash, progress, or an error with a retry action.
|
||||
`useStartupResourceTimeout` fails a resource that stays loading past its deadline. Web and Pocket apply it to character model loading.
|
||||
The optional Mods server connects outside the tracked startup work. Its connection does not block onboarding.
|
||||
Each app's HTML shows the first splash before Vue mounts. CSS hides it when Vue renders into `#app`.
|
||||
The home page reports when its character model is ready or fails. A failed model keeps the overlay visible.
|
||||
If the model fails, the user can retry the app or continue without a character.
|
||||
The overlay emits `finished` when all resources are ready. Apps open onboarding at that point.
|
||||
|
||||
## Chat sampling
|
||||
|
||||
In **Settings → Modules → Consciousness**, custom temperature and Top P are off
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"@proj-airi/stage-ui-tachie": "workspace:^",
|
||||
"@proj-airi/stage-ui-three": "workspace:^",
|
||||
"@proj-airi/ui": "workspace:^",
|
||||
"@proj-airi/ui-loading-screens": "workspace:^",
|
||||
"@proj-airi/vite-plugin-sherpaw": "workspace:^",
|
||||
"@ricky0123/vad-web": "catalog:",
|
||||
"@sherpaw/xsai-transcription": "catalog:",
|
||||
|
||||
+11
-5
@@ -10,9 +10,13 @@ import Onboarding from './onboarding.vue'
|
||||
|
||||
import { useBreakpoints } from '../../../../composables/use-breakpoints'
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
extraSteps?: OnboardingStep[]
|
||||
}>()
|
||||
scaleBackground?: boolean
|
||||
instantOpen?: boolean
|
||||
}>(), {
|
||||
scaleBackground: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'configured'): void
|
||||
@@ -31,8 +35,8 @@ onMounted(() => screenSafeArea.update())
|
||||
<template>
|
||||
<DialogRoot v-if="isDesktop" :open="showDialog" @update:open="value => showDialog = value">
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="fixed inset-0 z-9999 bg-black/50 backdrop-blur-sm data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn" />
|
||||
<DialogContent class="fixed left-1/2 top-1/2 z-9999 h-[min(100dvh,48rem)] max-h-full max-w-2xl w-[92dvw] flex flex-col overflow-hidden rounded-2xl bg-white p-6 shadow-xl outline-none backdrop-blur-md scrollbar-none -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:bg-neutral-900">
|
||||
<DialogOverlay class="fixed inset-0 z-9999 bg-black/50 backdrop-blur-sm data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn" :style="props.instantOpen ? { animation: 'none' } : undefined" />
|
||||
<DialogContent class="fixed left-1/2 top-1/2 z-9999 h-[min(100dvh,48rem)] max-h-full max-w-2xl w-[92dvw] flex flex-col overflow-hidden rounded-2xl bg-white p-6 shadow-xl outline-none backdrop-blur-md scrollbar-none -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:bg-neutral-900" :style="props.instantOpen ? { animation: 'none' } : undefined">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>Onboarding</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
@@ -42,9 +46,10 @@ onMounted(() => screenSafeArea.update())
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
<DrawerRoot v-else :open="showDialog" should-scale-background @update:open="value => showDialog = value">
|
||||
<DrawerRoot v-else :open="showDialog" :should-scale-background="props.scaleBackground" @update:open="value => showDialog = value">
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay
|
||||
:style="props.instantOpen ? { animation: 'none' } : undefined"
|
||||
:class="[
|
||||
'fixed inset-0 z-900',
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0',
|
||||
@@ -52,6 +57,7 @@ onMounted(() => screenSafeArea.update())
|
||||
]"
|
||||
/>
|
||||
<DrawerContent
|
||||
:data-vaul-animate="props.instantOpen ? 'false' : undefined"
|
||||
:class="[
|
||||
'fixed bottom-0 left-0 right-0 z-1000',
|
||||
'mt-20 px-4 pt-4',
|
||||
|
||||
+1
-1
@@ -154,7 +154,7 @@ it('persists configured credentials through the leader and loads the model list'
|
||||
// The dialog saves the config and then the status, each through its own
|
||||
// leader call, so the status can reach the leader after the config.
|
||||
await expect.poll(() => leader.providerConfigStore.providers[providerId]?.status).toBe('configured')
|
||||
expect(leader.providerConfigStore.addedProviders[providerId]).toBe(true)
|
||||
await expect.poll(() => leader.providerConfigStore.addedProviders[providerId]).toBe(true)
|
||||
|
||||
await expect.poll(() => JSON.parse(localStorage.getItem('settings/providers/configured') ?? '{}')).toMatchObject({
|
||||
[providerId]: { config: expectedConfig },
|
||||
|
||||
@@ -5,5 +5,6 @@ export * from './dialogs'
|
||||
export * from './hologram'
|
||||
export * from './providers'
|
||||
export * from './settings'
|
||||
export { default as StartupOverlay } from './startup/startup-overlay.vue'
|
||||
export * from './status'
|
||||
export * from './toasters'
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { createPinia } from 'pinia'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { page } from 'vitest/browser'
|
||||
import { createApp, h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import StartupOverlay from './startup-overlay.vue'
|
||||
|
||||
import { useStartupResourcesStore } from '../../../stores/startup-resources'
|
||||
|
||||
import 'virtual:uno.css'
|
||||
|
||||
const hosts: HTMLElement[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const host of hosts.splice(0))
|
||||
host.remove()
|
||||
})
|
||||
|
||||
function mountOverlay(onFinished: () => void) {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
hosts.push(host)
|
||||
const pinia = createPinia()
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: {
|
||||
en: {
|
||||
stage: {
|
||||
operations: { 'load-models-status': { loading: 'Loading' } },
|
||||
startup: {
|
||||
'failed': 'Startup failed',
|
||||
'failed-resource': 'Could not load {resource}',
|
||||
'recover': 'Try again. If it fails again, open the details below.',
|
||||
'recover-model': 'Try again, or continue without a character. You can select another model later.',
|
||||
'details': 'What happened?',
|
||||
'close-details': 'Close details',
|
||||
'retry': 'Retry',
|
||||
'continue-without-model': 'Continue Anyway',
|
||||
'resources': { auth: 'account settings', model: 'character model' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const app = createApp({ render: () => h(StartupOverlay, { logoSrc: '/favicon.svg', onFinished }, { default: () => h('main', [h('button', { id: 'covered-action' }, 'Covered action')]) }) })
|
||||
app.use(pinia)
|
||||
app.use(i18n)
|
||||
app.mount(host)
|
||||
return { app, startup: useStartupResourcesStore(pinia) }
|
||||
}
|
||||
|
||||
it('keeps the startup screen and retry action visible after a resource fails', async () => {
|
||||
await page.viewport(1440, 900)
|
||||
let finished = false
|
||||
const { app, startup } = mountOverlay(() => {
|
||||
finished = true
|
||||
})
|
||||
try {
|
||||
startup.register(['model'])
|
||||
startup.start('model')
|
||||
await expect.poll(() => {
|
||||
const rect = document.querySelector('.startup-track')?.getBoundingClientRect()
|
||||
return rect ? Math.round(rect.top + rect.height / 2) : 0
|
||||
}).toBe(810)
|
||||
startup.fail('model', new Error('Download failed'))
|
||||
|
||||
await expect.poll(() => document.querySelector('[role="alert"]')?.textContent).toContain('Could not load character model')
|
||||
expect(document.querySelector('.startup-screen')).not.toBeNull()
|
||||
expect(document.querySelector<HTMLElement>('.startup-covered-content')?.inert).toBe(true)
|
||||
expect(document.querySelector('.startup-covered-content')?.getAttribute('aria-hidden')).toBe('true')
|
||||
document.querySelector<HTMLButtonElement>('#covered-action')?.focus()
|
||||
expect(document.activeElement?.id).not.toBe('covered-action')
|
||||
expect(document.querySelector('.startup-screen-error .startup-brand')).not.toBeNull()
|
||||
expect(document.querySelector('.startup-error-hint')?.textContent).toContain('select another model later')
|
||||
expect(document.querySelector('.startup-status-error [role="progressbar"]')).not.toBeNull()
|
||||
expect(document.querySelector('.startup-error-recovery')?.textContent).toContain('Retry')
|
||||
expect(document.querySelector('.startup-error-recovery')?.textContent).toContain('Continue Anyway')
|
||||
expect(document.querySelector('.startup-error-details-tooltip')).toBeNull()
|
||||
const detailsTrigger = document.querySelector<HTMLButtonElement>('.startup-error-details-trigger')
|
||||
expect(detailsTrigger?.textContent).toContain('What happened?')
|
||||
expect(detailsTrigger!.getBoundingClientRect().width).toBeLessThan(200)
|
||||
expect(detailsTrigger!.getBoundingClientRect().top).toBeGreaterThan(document.querySelector('.startup-error-hint')!.getBoundingClientRect().bottom)
|
||||
detailsTrigger?.click()
|
||||
await expect.poll(() => document.querySelector('[role="tooltip"]')?.textContent).toContain('Download failed')
|
||||
expect(detailsTrigger?.getAttribute('aria-describedby')).toBe('startup-error-details-tooltip')
|
||||
expect(document.querySelector('.startup-error-recovery')!.getBoundingClientRect().top).toBeGreaterThan(document.querySelector('[role="tooltip"]')!.getBoundingClientRect().bottom)
|
||||
const recoveryRect = document.querySelector('.startup-error-recovery')!.getBoundingClientRect()
|
||||
await expect.poll(() => {
|
||||
const rect = document.querySelector('.startup-track')!.getBoundingClientRect()
|
||||
return Math.abs(rect.bottom - window.innerHeight + 16) + Math.abs(rect.left - 16) + Math.abs(rect.width - window.innerWidth + 32)
|
||||
}).toBeLessThanOrEqual(1)
|
||||
const progressRect = document.querySelector('.startup-track')!.getBoundingClientRect()
|
||||
const actions = document.querySelectorAll('.startup-error-action')
|
||||
expect(Math.abs(progressRect.left - 16)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(progressRect.width - window.innerWidth + 32)).toBeLessThanOrEqual(1)
|
||||
const infoRect = document.querySelector('.startup-error-info')!.getBoundingClientRect()
|
||||
expect(recoveryRect.top).toBeGreaterThan(infoRect.bottom)
|
||||
expect(recoveryRect.top - infoRect.bottom).toBeLessThan(24)
|
||||
expect(recoveryRect.bottom).toBeLessThan(progressRect.top)
|
||||
expect(progressRect.top - recoveryRect.bottom).toBeGreaterThan(100)
|
||||
expect(document.querySelector('.startup-error-header')).toBeNull()
|
||||
expect(Math.round(recoveryRect.width)).toBe(680)
|
||||
expect(actions).toHaveLength(2)
|
||||
expect(actions[0].getBoundingClientRect().width).toBeGreaterThan(300)
|
||||
expect(document.querySelector('.startup-error-progress-heading')).toBeNull()
|
||||
document.querySelector<HTMLElement>('.startup-error-title')?.click()
|
||||
await expect.poll(() => document.querySelector('[role="tooltip"]')).toBeNull()
|
||||
expect(finished).toBe(false)
|
||||
}
|
||||
finally {
|
||||
app.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps mobile progress near the bottom actions', async () => {
|
||||
await page.viewport(390, 844)
|
||||
const { app, startup } = mountOverlay(() => {})
|
||||
try {
|
||||
startup.register(['model'])
|
||||
startup.start('model')
|
||||
await expect.poll(() => document.querySelector('.startup-track')?.getBoundingClientRect().width).toBeGreaterThan(200)
|
||||
const loadingProgressCenter = document.querySelector('.startup-track')!.getBoundingClientRect().y + 8
|
||||
startup.fail('model', new Error('Download failed'))
|
||||
|
||||
await expect.poll(() => document.querySelector('.startup-error-title')).not.toBeNull()
|
||||
const recoveryRect = document.querySelector('.startup-error-recovery')!.getBoundingClientRect()
|
||||
const actions = document.querySelectorAll('.startup-error-action')
|
||||
expect(Math.round(recoveryRect.width)).toBe(window.innerWidth - 32)
|
||||
expect(document.querySelector('.startup-error-header')).toBeNull()
|
||||
expect(getComputedStyle(document.querySelector('.startup-screen-error .startup-brand')!).display).toBe('none')
|
||||
expect(actions).toHaveLength(2)
|
||||
expect(actions[0].getBoundingClientRect().width).toBe(window.innerWidth - 32)
|
||||
await expect.poll(() => {
|
||||
const rect = document.querySelector('.startup-track')!.getBoundingClientRect()
|
||||
return Math.abs(rect.left - 16) + Math.abs(rect.width - window.innerWidth + 32)
|
||||
}).toBeLessThanOrEqual(1)
|
||||
const errorProgressRect = document.querySelector('.startup-track')!.getBoundingClientRect()
|
||||
expect(loadingProgressCenter - errorProgressRect.y - errorProgressRect.height / 2).toBeGreaterThan(20)
|
||||
expect(Math.abs(errorProgressRect.left - 16)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(errorProgressRect.width - window.innerWidth + 32)).toBeLessThanOrEqual(1)
|
||||
expect(document.querySelector('.startup-error-title')!.getBoundingClientRect().bottom).toBeLessThan(errorProgressRect.top)
|
||||
expect(Math.round(recoveryRect.top - errorProgressRect.bottom)).toBe(12)
|
||||
expect(document.querySelector('.startup-error-progress-heading')).toBeNull()
|
||||
await page.viewport(320, 568)
|
||||
await expect.poll(() => {
|
||||
const rect = document.querySelector('.startup-track')!.getBoundingClientRect()
|
||||
return Math.abs(rect.width - window.innerWidth + 32)
|
||||
}).toBeLessThanOrEqual(1)
|
||||
const progressRect = document.querySelector('.startup-track')!.getBoundingClientRect()
|
||||
const compactRecoveryRect = document.querySelector('.startup-error-recovery')!.getBoundingClientRect()
|
||||
expect(Math.abs(progressRect.left - 16)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(progressRect.width - window.innerWidth + 32)).toBeLessThanOrEqual(1)
|
||||
expect(document.querySelector('.startup-error-details-trigger')!.getBoundingClientRect().bottom).toBeLessThan(progressRect.top)
|
||||
expect(compactRecoveryRect.top).toBeGreaterThan(document.querySelector('.startup-error-details-trigger')!.getBoundingClientRect().bottom)
|
||||
expect(Math.round(compactRecoveryRect.top - progressRect.bottom)).toBe(12)
|
||||
document.querySelector<HTMLButtonElement>('.startup-error-details-trigger')?.click()
|
||||
await expect.poll(() => document.querySelector('.startup-error-details-drawer')?.textContent).toContain('Download failed')
|
||||
const detailsRect = document.querySelector('.startup-error-details-drawer')!.getBoundingClientRect()
|
||||
expect(detailsRect.left).toBe(0)
|
||||
expect(detailsRect.right).toBe(window.innerWidth)
|
||||
await expect.poll(() => Math.round(document.querySelector('.startup-error-details-drawer')!.getBoundingClientRect().bottom)).toBe(window.innerHeight)
|
||||
document.querySelector<HTMLButtonElement>('.startup-error-details-close')?.click()
|
||||
await expect.poll(() => document.querySelector('.startup-error-details-drawer')).toBeNull()
|
||||
}
|
||||
finally {
|
||||
app.unmount()
|
||||
await page.viewport(1440, 900)
|
||||
}
|
||||
})
|
||||
|
||||
it('offers Retry without a continue action when a required resource fails', async () => {
|
||||
await page.viewport(390, 844)
|
||||
const { app, startup } = mountOverlay(() => {})
|
||||
try {
|
||||
startup.register(['auth'])
|
||||
startup.start('auth')
|
||||
startup.fail('auth', new Error('Account setup failed'))
|
||||
|
||||
await expect.poll(() => document.querySelector('[role="alert"]')?.textContent).toContain('Could not load account settings')
|
||||
expect(document.querySelector('.startup-error-recovery')?.textContent).toContain('Retry')
|
||||
expect(document.querySelector('.startup-error-recovery')?.textContent).not.toContain('Continue Anyway')
|
||||
await expect.poll(() => document.querySelector('.startup-error-title')).not.toBeNull()
|
||||
const progressRect = document.querySelector('.startup-track')!.getBoundingClientRect()
|
||||
const recoveryRect = document.querySelector('.startup-error-recovery')!.getBoundingClientRect()
|
||||
expect(progressRect.left).toBe(16)
|
||||
expect(progressRect.width).toBe(window.innerWidth - 32)
|
||||
expect(document.querySelector('.startup-error-header')).toBeNull()
|
||||
expect(Math.round(recoveryRect.top - progressRect.bottom)).toBe(12)
|
||||
}
|
||||
finally {
|
||||
app.unmount()
|
||||
await page.viewport(1440, 900)
|
||||
}
|
||||
})
|
||||
|
||||
it('finishes only after all resources complete', async () => {
|
||||
let finished = false
|
||||
const { app, startup } = mountOverlay(() => {
|
||||
finished = true
|
||||
})
|
||||
try {
|
||||
startup.register(['auth', 'model'])
|
||||
startup.start('auth')
|
||||
startup.complete('auth')
|
||||
await expect.poll(() => document.querySelector('[role="progressbar"]')?.getAttribute('aria-valuenow')).toBe('50')
|
||||
expect(finished).toBe(false)
|
||||
|
||||
startup.start('model')
|
||||
startup.complete('model')
|
||||
await expect.poll(() => finished).toBe(true)
|
||||
expect(document.querySelector<HTMLElement>('.startup-covered-content')?.inert).toBe(false)
|
||||
document.querySelector<HTMLButtonElement>('#covered-action')?.focus()
|
||||
expect(document.activeElement?.id).toBe('covered-action')
|
||||
}
|
||||
finally {
|
||||
app.unmount()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import StartupScreen from '@proj-airi/ui-loading-screens/startup-screen'
|
||||
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useStartupResourcesStore } from '../../../stores/startup-resources'
|
||||
|
||||
defineProps<{ logoSrc: string }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'finished'): void
|
||||
(e: 'skipModel'): void
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
const startup = useStartupResourcesStore()
|
||||
const { progress, failed, ready } = storeToRefs(startup)
|
||||
const errorTitle = computed(() => failed.value
|
||||
? t('stage.startup.failed-resource', { resource: t(`stage.startup.resources.${failed.value.id}`) })
|
||||
: t('stage.startup.failed'))
|
||||
const phase = ref<'splash' | 'loading' | 'done'>('splash')
|
||||
const opened = ref(false)
|
||||
let opening: Promise<void>
|
||||
|
||||
function retry() {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
opening = new Promise(resolve => setTimeout(resolve, 500))
|
||||
phase.value = 'loading'
|
||||
opened.value = true
|
||||
})
|
||||
|
||||
watch([ready, opened], async ([isReady, isOpen]) => {
|
||||
if (!isReady || !isOpen || phase.value === 'done')
|
||||
return
|
||||
await opening
|
||||
await nextTick()
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
|
||||
phase.value = 'done'
|
||||
emit('finished')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="startup-covered-content" :inert="phase !== 'done'" :aria-hidden="phase !== 'done' ? 'true' : undefined">
|
||||
<slot />
|
||||
</div>
|
||||
<StartupScreen
|
||||
:phase="failed ? 'error' : phase"
|
||||
:progress="progress"
|
||||
:logo-src="logoSrc"
|
||||
:label="t('stage.operations.load-models-status.loading')"
|
||||
:error-title="errorTitle"
|
||||
:error-hint="t(failed?.id === 'model' ? 'stage.startup.recover-model' : 'stage.startup.recover')"
|
||||
:error-message="failed?.error"
|
||||
:error-details-label="t('stage.startup.details')"
|
||||
:error-details-close-label="t('stage.startup.close-details')"
|
||||
:retry-label="t('stage.startup.retry')"
|
||||
:alternative-label="failed?.id === 'model' ? t('stage.startup.continue-without-model') : undefined"
|
||||
@retry="retry"
|
||||
@alternative="emit('skipModel')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.startup-covered-content {
|
||||
display: contents;
|
||||
}
|
||||
</style>
|
||||
@@ -10,7 +10,7 @@ import type { EmotionPayload } from '../../constants/emotions'
|
||||
import type { SpeechTransport, StageTtsSession, StreamingSessionSnapshot } from '../../libs/speech/tts-session'
|
||||
|
||||
import { defineInvokeHandler } from '@moeru/eventa'
|
||||
import { sleep } from '@moeru/std'
|
||||
import { errorMessageFrom, sleep } from '@moeru/std'
|
||||
import { createLive2DLipSync } from '@proj-airi/model-driver-lipsync'
|
||||
import { wlipsyncProfile } from '@proj-airi/model-driver-lipsync/shared/wlipsync'
|
||||
import { createPlaybackManager, createSpeechPipeline, normalizeActPayload } from '@proj-airi/pipelines-audio'
|
||||
@@ -64,6 +64,7 @@ const props = withDefaults(defineProps<{
|
||||
paused: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ error: [error: Error] }>()
|
||||
const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' })
|
||||
|
||||
const { getDb } = useDuckDb()
|
||||
@@ -187,6 +188,12 @@ const viewUpdateCleanups: Array<() => void> = []
|
||||
|
||||
function handleStageRenderError(error: Error) {
|
||||
stageRenderError.value = error
|
||||
emit('error', error)
|
||||
}
|
||||
|
||||
function reportStageRenderError(error: unknown) {
|
||||
console.error(error)
|
||||
handleStageRenderError(new Error(errorMessageFrom(error) ?? 'Failed to render stage'))
|
||||
}
|
||||
|
||||
async function retryStageRenderer() {
|
||||
@@ -1083,7 +1090,7 @@ defineExpose({
|
||||
:enable-orbit-controls="props.enableOrbitControls"
|
||||
:audio-context="audioContext"
|
||||
:current-audio-source="currentAudioSource"
|
||||
@error="console.error"
|
||||
@error="reportStageRenderError"
|
||||
@vrm-interact="onVRMInteract"
|
||||
/>
|
||||
<SpineScene
|
||||
@@ -1101,6 +1108,7 @@ defineExpose({
|
||||
:idle-animation-enabled="spineIdleAnimationEnabled"
|
||||
:max-fps="spineMaxFps"
|
||||
:render-scale="spineRenderScale"
|
||||
@error="reportStageRenderError"
|
||||
/>
|
||||
<TachieScene
|
||||
v-if="stageModelRenderer === 'tachie' && showStage"
|
||||
@@ -1114,7 +1122,7 @@ defineExpose({
|
||||
:paused="paused"
|
||||
:theme-colors-hue="themeColorsHue"
|
||||
:theme-colors-hue-dynamic="themeColorsHueDynamic"
|
||||
@error="console.error"
|
||||
@error="reportStageRenderError"
|
||||
/>
|
||||
<MMDScene
|
||||
v-if="stageModelRenderer === 'mmd' && showStage"
|
||||
@@ -1130,7 +1138,7 @@ defineExpose({
|
||||
:enable-orbit-controls="props.enableOrbitControls"
|
||||
:audio-context="audioContext"
|
||||
:current-audio-source="currentAudioSource"
|
||||
@error="console.error"
|
||||
@error="reportStageRenderError"
|
||||
/>
|
||||
<div
|
||||
v-if="stageModelRenderer === 'godot'"
|
||||
@@ -1156,7 +1164,7 @@ defineExpose({
|
||||
<StageRenderError
|
||||
v-if="stageRenderError"
|
||||
:error="stageRenderError"
|
||||
renderer="Live2D"
|
||||
:renderer="stageModelRenderer === 'live2d' ? 'Live2D' : stageModelRenderer ?? 'Model'"
|
||||
:model-id="stageModelSelected"
|
||||
@retry="retryStageRenderer"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { effectScope, nextTick } from 'vue'
|
||||
|
||||
import { useStartupResourcesStore } from '../stores/startup-resources'
|
||||
import { useStartupResourceTimeout } from './use-startup-resource-timeout'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('fails a model resource that stays loading past its deadline', async () => {
|
||||
const startup = useStartupResourcesStore()
|
||||
startup.register(['model'])
|
||||
const scope = effectScope()
|
||||
scope.run(() => useStartupResourceTimeout('model', 1000, () => 'Model load timed out'))
|
||||
|
||||
startup.start('model')
|
||||
await nextTick()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
expect(startup.failed?.error).toBe('Model load timed out')
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('clears the deadline when the model becomes ready', async () => {
|
||||
const startup = useStartupResourcesStore()
|
||||
startup.register(['model'])
|
||||
const scope = effectScope()
|
||||
scope.run(() => useStartupResourceTimeout('model', 1000, () => 'Model load timed out'))
|
||||
|
||||
startup.start('model')
|
||||
await nextTick()
|
||||
startup.complete('model')
|
||||
await nextTick()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
expect(startup.ready).toBe(true)
|
||||
expect(startup.failed).toBeUndefined()
|
||||
scope.stop()
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useStartupResourcesStore } from '../stores/startup-resources'
|
||||
|
||||
/** Fails a startup resource if it stays loading past its deadline. */
|
||||
export function useStartupResourceTimeout(id: string, timeoutMs: number, message: () => string) {
|
||||
const startup = useStartupResourcesStore()
|
||||
|
||||
watch(() => startup.resources.find(resource => resource.id === id)?.status, (status, _, onCleanup) => {
|
||||
if (status !== 'loading')
|
||||
return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (startup.resources.find(resource => resource.id === id)?.status === 'loading')
|
||||
startup.fail(id, new Error(message()))
|
||||
}, timeoutMs)
|
||||
|
||||
onCleanup(() => clearTimeout(timer))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useStartupResourcesStore } from './startup-resources'
|
||||
|
||||
describe('startup resources', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
|
||||
it('counts completed resources and keeps a failed model blocking', () => {
|
||||
const startup = useStartupResourcesStore()
|
||||
startup.register(['config', 'model'])
|
||||
startup.start('config')
|
||||
startup.complete('config')
|
||||
startup.start('model')
|
||||
startup.fail('model', new Error('Model download failed'))
|
||||
|
||||
expect(startup.progress).toBe(50)
|
||||
expect(startup.ready).toBe(false)
|
||||
expect(startup.failed?.error).toBe('Model download failed')
|
||||
})
|
||||
|
||||
it('records an initialization rejection before propagating it', async () => {
|
||||
const startup = useStartupResourcesStore()
|
||||
startup.register(['auth'])
|
||||
|
||||
await expect(startup.run('auth', async () => {
|
||||
throw new Error('Offline')
|
||||
})).rejects.toThrow('Offline')
|
||||
expect(startup.failed?.id).toBe('auth')
|
||||
expect(startup.progress).toBe(0)
|
||||
})
|
||||
|
||||
it('allows a model resource to be skipped when no model is selected', () => {
|
||||
const startup = useStartupResourcesStore()
|
||||
startup.register(['config', 'model'])
|
||||
startup.start('config')
|
||||
startup.complete('config')
|
||||
startup.skip('model')
|
||||
|
||||
expect(startup.ready).toBe(true)
|
||||
expect(startup.progress).toBe(100)
|
||||
})
|
||||
|
||||
it('recovers when the user skips a failed character model', () => {
|
||||
const startup = useStartupResourcesStore()
|
||||
startup.register(['model'])
|
||||
startup.start('model')
|
||||
startup.fail('model', new Error('Corrupt archive'))
|
||||
startup.skip('model')
|
||||
|
||||
expect(startup.failed).toBeUndefined()
|
||||
expect(startup.ready).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects duplicate and unknown resource IDs', () => {
|
||||
const startup = useStartupResourcesStore()
|
||||
expect(() => startup.register(['auth', 'auth'])).toThrow('unique')
|
||||
startup.register(['auth'])
|
||||
expect(() => startup.start('missing')).toThrow('Unknown')
|
||||
})
|
||||
|
||||
it('does not let an earlier startup run update a new registration', async () => {
|
||||
const startup = useStartupResourcesStore()
|
||||
startup.register(['auth'])
|
||||
let finishLoad: (() => void) | undefined
|
||||
const pending = startup.run('auth', () => new Promise<void>((resolve) => {
|
||||
finishLoad = resolve
|
||||
}))
|
||||
|
||||
startup.reset()
|
||||
startup.register(['auth'])
|
||||
startup.start('auth')
|
||||
finishLoad?.()
|
||||
|
||||
await expect(pending).rejects.toThrow('reset')
|
||||
expect(startup.resources[0]?.status).toBe('loading')
|
||||
expect(startup.failed).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export type StartupResourceStatus = 'queued' | 'loading' | 'ready' | 'failed' | 'skipped'
|
||||
|
||||
export interface StartupResource {
|
||||
id: string
|
||||
status: StartupResourceStatus
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Owns one page load's resource states. Register the full list before work starts. */
|
||||
export const useStartupResourcesStore = defineStore('startup-resources', () => {
|
||||
const resources = ref<StartupResource[]>([])
|
||||
let generation = 0
|
||||
const progress = computed(() => resources.value.length === 0
|
||||
? 0
|
||||
: Math.round(resources.value.filter(resource => resource.status === 'ready' || resource.status === 'skipped').length / resources.value.length * 100))
|
||||
const failed = computed(() => resources.value.find(resource => resource.status === 'failed'))
|
||||
const ready = computed(() => resources.value.length > 0 && resources.value.every(resource => resource.status === 'ready' || resource.status === 'skipped'))
|
||||
|
||||
function register(ids: readonly string[]) {
|
||||
if (resources.value.length > 0)
|
||||
throw new Error('Startup resources are already registered')
|
||||
if (new Set(ids).size !== ids.length)
|
||||
throw new Error('Startup resource IDs must be unique')
|
||||
resources.value = ids.map(id => ({ id, status: 'queued' }))
|
||||
}
|
||||
|
||||
function reset() {
|
||||
generation += 1
|
||||
resources.value = []
|
||||
}
|
||||
|
||||
function resource(id: string) {
|
||||
const item = resources.value.find(item => item.id === id)
|
||||
if (!item)
|
||||
throw new Error(`Unknown startup resource: ${id}`)
|
||||
return item
|
||||
}
|
||||
|
||||
function start(id: string) {
|
||||
const item = resource(id)
|
||||
if (item.status !== 'queued')
|
||||
throw new Error(`Cannot start ${id} from ${item.status}`)
|
||||
item.status = 'loading'
|
||||
}
|
||||
|
||||
function complete(id: string) {
|
||||
const item = resource(id)
|
||||
if (item.status !== 'loading')
|
||||
throw new Error(`Cannot complete ${id} from ${item.status}`)
|
||||
item.status = 'ready'
|
||||
}
|
||||
|
||||
function fail(id: string, error: unknown) {
|
||||
const item = resource(id)
|
||||
if (item.status !== 'loading')
|
||||
throw new Error(`Cannot fail ${id} from ${item.status}`)
|
||||
item.status = 'failed'
|
||||
item.error = errorMessageFrom(error)
|
||||
}
|
||||
|
||||
function skip(id: string) {
|
||||
const item = resource(id)
|
||||
if (item.status !== 'queued' && item.status !== 'failed')
|
||||
throw new Error(`Cannot skip ${id} from ${item.status}`)
|
||||
item.status = 'skipped'
|
||||
item.error = undefined
|
||||
}
|
||||
|
||||
/** Runs one resource and records a failure before the caller handles the rejection. */
|
||||
async function run(id: string, load: () => Promise<unknown> | unknown) {
|
||||
const currentGeneration = generation
|
||||
start(id)
|
||||
try {
|
||||
await load()
|
||||
if (currentGeneration !== generation)
|
||||
throw new Error('Startup resources were reset during loading')
|
||||
complete(id)
|
||||
}
|
||||
catch (error) {
|
||||
if (currentGeneration === generation)
|
||||
fail(id, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return { resources, progress, failed, ready, register, reset, start, complete, fail, skip, run }
|
||||
})
|
||||
@@ -1,11 +1,8 @@
|
||||
import { defineConfig, mergeConfigs } from 'unocss'
|
||||
import { mergeConfigs } from 'unocss'
|
||||
|
||||
import { histoireUnoConfig, sharedUnoConfig } from '../../uno.config'
|
||||
|
||||
export default mergeConfigs([
|
||||
sharedUnoConfig(),
|
||||
histoireUnoConfig(),
|
||||
defineConfig({
|
||||
// All font configurations are now inherited from the root uno.config.ts
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -6,6 +6,7 @@ import Info from 'unplugin-info/vite'
|
||||
import VueRouter from 'vue-router/vite'
|
||||
|
||||
import { playwright } from '@vitest/browser-playwright'
|
||||
import { mergeConfigs } from 'unocss'
|
||||
import { loadEnv } from 'vite'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
@@ -23,9 +24,8 @@ export default defineConfig({
|
||||
// Use the app's route-block transform when browser tests mount shared pages.
|
||||
VueRouter({ routesFolder: [], dts: false }),
|
||||
Vue(),
|
||||
UnoCSS({
|
||||
UnoCSS(mergeConfigs([sharedUnoConfig(), {
|
||||
// Browser tests use product styles, not Histoire's hover-preview variants.
|
||||
...sharedUnoConfig(),
|
||||
configFile: false,
|
||||
// Vitest loads components after the stylesheet. Scan their source before
|
||||
// the initial CSS response instead of relying on Vite's HMR updates.
|
||||
@@ -35,7 +35,7 @@ export default defineConfig({
|
||||
`${import.meta.dirname}/../ui/src/**/*.vue`,
|
||||
],
|
||||
},
|
||||
}),
|
||||
}])),
|
||||
],
|
||||
test: {
|
||||
env: loadEnv('test', cwd(), ''),
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
# @proj-airi/ui-loading-screens
|
||||
|
||||
<p align="center">
|
||||
[<a href="https://proj-airi-packages-ui-loading-screens.netlify.app/">Try it</a>]
|
||||
</p>
|
||||
This package provides loading screens for AIRI apps. [View the playground](https://proj-airi-packages-ui-loading-screens.netlify.app/).
|
||||
|
||||
The loading screens to show during long-time application startups.
|
||||
## Use
|
||||
|
||||
Import `StartupScreen` from `@proj-airi/ui-loading-screens/startup-screen`. Give it the phase, progress, logo URL, and translated labels.
|
||||
|
||||
```vue
|
||||
<StartupScreen phase="loading" :progress="50" logo-src="/favicon.svg" :label="t('stage.operations.load-models-status.loading')" :error-title="t('stage.startup.failed')" :error-hint="t('stage.startup.recover')" :error-details-label="t('stage.startup.details')" :error-details-close-label="t('stage.startup.close-details')" :retry-label="t('stage.startup.retry')" />
|
||||
```
|
||||
|
||||
The `splash` phase shows the logo. The `loading` phase shows progress near the lower edge.
|
||||
The `error` phase shows the error title, guidance, progress bar, and recovery actions.
|
||||
Desktop places the actions below the error text and keeps progress at the bottom.
|
||||
Mobile hides the logo and places progress just above the bottom actions. Error progress has no percentage label.
|
||||
The small details button opens a desktop tooltip or a mobile bottom drawer. The `done` phase removes the screen.
|
||||
|
||||
The screen uses Comfortaa for the brand and content.
|
||||
The progress bar uses `Progress` from `@proj-airi/ui`.
|
||||
`StartupOverlay` reads the resource state and supplies the error text.
|
||||
The optional `alternative-label` adds a second action for a recoverable failure.
|
||||
|
||||
## When to use it
|
||||
|
||||
Use `StartupScreen` while an app waits for the startup tasks that it tracks. Report progress from completed tasks.
|
||||
|
||||
## When not to use it
|
||||
|
||||
Do not use startup progress for downloads without completion signals. Show a separate loading state for later model changes.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./components": "./src/components/index.ts",
|
||||
"./startup-screen": "./src/components/startup-screen.vue",
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -27,15 +28,17 @@
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/comfortaa": "catalog:",
|
||||
"@moeru/std": "catalog:",
|
||||
"@proj-airi/font-departure-mono": "workspace:^",
|
||||
"@proj-airi/ui": "workspace:^",
|
||||
"@rive-app/canvas-lite": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"@vueuse/motion": "catalog:",
|
||||
"vaul-vue": "catalog:",
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@proj-airi/ui": "workspace:^",
|
||||
"@unocss/reset": "catalog:",
|
||||
"@vitejs/plugin-vue": "catalog:",
|
||||
"vite": "catalog:",
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as LoadingLogoWithBar } from './LoadingLogoWithBar.vue'
|
||||
export { default as LoadingSciFiCircle } from './LoadingSciFiCircle/index.vue'
|
||||
export { default as StartupScreen } from './startup-screen.vue'
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@proj-airi/ui'
|
||||
import { onClickOutside, useMediaQuery } from '@vueuse/core'
|
||||
import { DrawerContent, DrawerDescription, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTitle } from 'vaul-vue'
|
||||
import { shallowRef, useTemplateRef, watch } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
closeLabel: string
|
||||
message: string
|
||||
}>()
|
||||
|
||||
const isDesktop = useMediaQuery('(min-width: 768px)')
|
||||
const open = shallowRef(false)
|
||||
const rootElement = useTemplateRef('rootElement')
|
||||
|
||||
onClickOutside(rootElement, () => {
|
||||
if (isDesktop.value)
|
||||
open.value = false
|
||||
})
|
||||
watch(isDesktop, () => open.value = false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootElement" class="startup-error-details">
|
||||
<Button
|
||||
type="button"
|
||||
size="unset"
|
||||
color="red"
|
||||
variant="secondary"
|
||||
:outline="false"
|
||||
class="startup-error-details-trigger"
|
||||
:aria-expanded="open"
|
||||
:aria-controls="isDesktop ? 'startup-error-details-tooltip' : 'startup-error-details-drawer'"
|
||||
:aria-describedby="isDesktop && open ? 'startup-error-details-tooltip' : undefined"
|
||||
:aria-haspopup="isDesktop ? undefined : 'dialog'"
|
||||
@click="open = !open"
|
||||
@keydown.esc="open = false"
|
||||
>
|
||||
<span i-solar:question-circle-linear aria-hidden="true" />
|
||||
{{ label }}
|
||||
</Button>
|
||||
<div v-if="isDesktop && open" id="startup-error-details-tooltip" role="tooltip" class="startup-error-details-tooltip">
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DrawerRoot v-if="!isDesktop" v-model:open="open" handle-only>
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay class="startup-error-details-overlay" />
|
||||
<DrawerContent id="startup-error-details-drawer" class="startup-error-details-drawer">
|
||||
<DrawerHandle class="startup-error-details-handle" />
|
||||
<div class="startup-error-details-drawer-heading">
|
||||
<DrawerTitle class="startup-error-details-drawer-title">
|
||||
{{ label }}
|
||||
</DrawerTitle>
|
||||
<button type="button" class="startup-error-details-close" :aria-label="closeLabel" @click="open = false">
|
||||
<span i-solar:close-circle-linear aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<DrawerDescription class="startup-error-details-message">
|
||||
{{ message }}
|
||||
</DrawerDescription>
|
||||
</DrawerContent>
|
||||
</DrawerPortal>
|
||||
</DrawerRoot>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.startup-error-details {
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
margin: 12px 24px 0;
|
||||
}
|
||||
|
||||
.startup-error-details-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #ef44443d;
|
||||
border-radius: 6px;
|
||||
background: #ef44440a;
|
||||
color: #dc2626;
|
||||
cursor: help;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.startup-error-details-trigger:focus-visible,
|
||||
.startup-error-details-close:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.startup-error-details-tooltip {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
max-width: min(320px, calc(100vw - 48px));
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #171717;
|
||||
box-shadow: 0 8px 24px #0003;
|
||||
color: #fafafa;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.startup-error-details-tooltip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 12px;
|
||||
border-right: 6px solid transparent;
|
||||
border-bottom: 6px solid #171717;
|
||||
border-left: 6px solid transparent;
|
||||
}
|
||||
|
||||
.startup-error-details-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10001;
|
||||
background: #0006;
|
||||
}
|
||||
|
||||
.startup-error-details-drawer {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 10002;
|
||||
box-sizing: border-box;
|
||||
max-height: 70dvh;
|
||||
padding: 12px 20px max(24px, env(safe-area-inset-bottom));
|
||||
overflow-y: auto;
|
||||
border-radius: 20px 20px 0 0;
|
||||
background: #fafafa;
|
||||
box-shadow: 0 -8px 24px #0002;
|
||||
color: #262626;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.startup-error-details-handle {
|
||||
margin-bottom: 16px;
|
||||
background: #d4d4d4;
|
||||
}
|
||||
|
||||
.startup-error-details-drawer-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.startup-error-details-drawer-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.startup-error-details-close {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #737373;
|
||||
cursor: pointer;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.startup-error-details-message {
|
||||
margin: 16px 0 0;
|
||||
color: #525252;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
:global(html.dark .startup-error-details-trigger) {
|
||||
border-color: #f8717159;
|
||||
background: #f8717114;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
:global(html.dark .startup-error-details-drawer) {
|
||||
background: #262626;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
:global(html.dark .startup-error-details-handle) {
|
||||
background: #525252;
|
||||
}
|
||||
|
||||
:global(html.dark .startup-error-details-message) {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.startup-error-details {
|
||||
margin-right: 16px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.startup-error-details-trigger {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,393 @@
|
||||
<script setup lang="ts">
|
||||
import { Button, Progress } from '@proj-airi/ui'
|
||||
import { nextTick, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import StartupErrorDetails from './startup-error-details.vue'
|
||||
|
||||
import '@fontsource-variable/comfortaa/wght.css'
|
||||
|
||||
/** Shows startup progress and retains failures until the user retries. */
|
||||
const props = defineProps<{
|
||||
phase: 'splash' | 'loading' | 'error' | 'done'
|
||||
progress: number
|
||||
logoSrc: string
|
||||
label: string
|
||||
errorTitle: string
|
||||
errorHint: string
|
||||
errorMessage?: string
|
||||
errorDetailsLabel: string
|
||||
errorDetailsCloseLabel: string
|
||||
retryLabel: string
|
||||
alternativeLabel?: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'retry'): void
|
||||
(e: 'alternative'): void
|
||||
}>()
|
||||
const trackElement = useTemplateRef<HTMLElement>('trackElement')
|
||||
|
||||
watch(() => props.phase, async (phase, previous, onCleanup) => {
|
||||
if (phase !== 'error' || previous !== 'loading' || window.matchMedia('(prefers-reduced-motion: reduce)').matches)
|
||||
return
|
||||
|
||||
let animation: Animation | undefined
|
||||
let active = true
|
||||
onCleanup(() => {
|
||||
active = false
|
||||
animation?.cancel()
|
||||
})
|
||||
const track = trackElement.value
|
||||
const before = track?.getBoundingClientRect()
|
||||
await nextTick()
|
||||
if (!active || !track || !before)
|
||||
return
|
||||
|
||||
// Move the track between its measured loading and error positions.
|
||||
const after = track.getBoundingClientRect()
|
||||
animation = track.animate([
|
||||
{ transform: `translate(${before.left - after.left}px, ${before.top - after.top}px) scaleX(${before.width / after.width})` },
|
||||
{ transform: 'none' },
|
||||
], { duration: 500, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="startup-exit">
|
||||
<section v-if="phase !== 'done'" class="startup-screen" :class="{ 'startup-screen-error': phase === 'error' }">
|
||||
<div class="startup-brand">
|
||||
<img class="startup-logo" :src="logoSrc" alt="">
|
||||
<strong class="startup-name">AIRI</strong>
|
||||
</div>
|
||||
<div v-if="phase === 'error'" class="startup-error-info" role="alert">
|
||||
<h2 class="startup-error-title">
|
||||
{{ errorTitle }}
|
||||
</h2>
|
||||
<p class="startup-error-hint">
|
||||
{{ errorHint }}
|
||||
</p>
|
||||
<StartupErrorDetails
|
||||
v-if="errorMessage"
|
||||
:label="errorDetailsLabel"
|
||||
:close-label="errorDetailsCloseLabel"
|
||||
:message="errorMessage"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="phase === 'error'" class="startup-error-recovery">
|
||||
<Button class="startup-error-action" color="primary" variant="primary" @click="emit('retry')">
|
||||
{{ retryLabel }}
|
||||
</Button>
|
||||
<Button v-if="alternativeLabel" class="startup-error-action" @click="emit('alternative')">
|
||||
{{ alternativeLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
class="startup-status"
|
||||
:class="{
|
||||
'startup-status-error': phase === 'error',
|
||||
}"
|
||||
>
|
||||
<span v-if="phase === 'loading'" class="startup-label">{{ label }}</span>
|
||||
<div
|
||||
ref="trackElement"
|
||||
class="startup-track"
|
||||
:class="{ 'startup-track-loading': phase === 'loading' || phase === 'error' }"
|
||||
:role="phase === 'loading' || phase === 'error' ? 'progressbar' : undefined"
|
||||
:aria-label="phase === 'error' ? errorTitle : label"
|
||||
:aria-valuemin="phase === 'loading' || phase === 'error' ? 0 : undefined"
|
||||
:aria-valuemax="phase === 'loading' || phase === 'error' ? 100 : undefined"
|
||||
:aria-valuenow="phase === 'loading' || phase === 'error' ? progress : undefined"
|
||||
>
|
||||
<Progress v-if="phase === 'loading' || phase === 'error'" :progress="progress" :bar-class="phase === 'error' ? 'bg-red-500 dark:bg-red-400' : undefined" class="startup-progress" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.startup-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
color: #262626;
|
||||
font-family: "Comfortaa Variable", "Comfortaa", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
:global(html.dark .startup-screen) {
|
||||
background: #171717;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.startup-screen-error {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto auto minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
padding: max(env(safe-area-inset-top), 16px) 16px max(env(safe-area-inset-bottom), 16px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.startup-brand {
|
||||
position: absolute;
|
||||
top: max(calc(env(safe-area-inset-top) + 48px), calc(40vh - 82px));
|
||||
left: 50%;
|
||||
width: 180px;
|
||||
height: 156px;
|
||||
transform: translateX(-50%);
|
||||
transition: top 500ms cubic-bezier(0.22, 1, 0.36, 1), left 500ms cubic-bezier(0.22, 1, 0.36, 1), transform 500ms cubic-bezier(0.22, 1, 0.36, 1), width 500ms ease, height 500ms ease;
|
||||
}
|
||||
|
||||
.startup-logo {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
filter: hue-rotate(calc(var(--chromatic-hue, 220.44) * 1deg));
|
||||
transform: translateX(-50%);
|
||||
transition: left 500ms ease, width 500ms ease, height 500ms ease, transform 500ms ease;
|
||||
}
|
||||
|
||||
.startup-name {
|
||||
position: absolute;
|
||||
top: 116px;
|
||||
left: 50%;
|
||||
padding-left: 0.07em;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
white-space: nowrap;
|
||||
-webkit-text-stroke: 0.5px currentColor;
|
||||
transform: translateX(-50%);
|
||||
transition: top 500ms ease, left 500ms ease, transform 500ms ease, font-size 500ms ease, letter-spacing 500ms ease;
|
||||
}
|
||||
|
||||
.startup-screen-error .startup-brand {
|
||||
top: calc(env(safe-area-inset-top) + 24px);
|
||||
left: 24px;
|
||||
width: 140px;
|
||||
height: 36px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.startup-screen-error .startup-logo {
|
||||
left: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.startup-screen-error .startup-name {
|
||||
top: 7px;
|
||||
left: 46px;
|
||||
font-size: 20px;
|
||||
letter-spacing: 0.04em;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.startup-status {
|
||||
position: absolute;
|
||||
top: calc(90% - 14px);
|
||||
left: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
width: min(220px, 60vw);
|
||||
height: 44px;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: top 500ms cubic-bezier(0.22, 1, 0.36, 1), width 500ms cubic-bezier(0.22, 1, 0.36, 1), transform 500ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.startup-status-error {
|
||||
position: relative;
|
||||
top: auto;
|
||||
left: auto;
|
||||
grid-row: 5;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.startup-error-info {
|
||||
grid-row: 2;
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
width: min(680px, 100%);
|
||||
padding-top: 24px;
|
||||
animation: startup-error-fade-in 400ms 100ms both;
|
||||
}
|
||||
|
||||
.startup-track {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 3px;
|
||||
margin-top: 28px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
opacity: 0;
|
||||
transition: width 500ms cubic-bezier(0.34, 1.36, 0.64, 1), height 500ms cubic-bezier(0.34, 1.36, 0.64, 1), opacity 250ms ease-out;
|
||||
}
|
||||
|
||||
.startup-track-loading {
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.startup-status-error .startup-track {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
transform-origin: top left;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.startup-progress {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.startup-label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
color: #737373;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.startup-error-title {
|
||||
margin: 0 24px;
|
||||
font-size: clamp(24px, 3vw, 32px);
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.startup-error-hint {
|
||||
max-width: 560px;
|
||||
margin: 10px 24px 0;
|
||||
color: #737373;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.startup-error-recovery {
|
||||
grid-row: 3;
|
||||
justify-self: center;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: min(680px, 100%);
|
||||
animation: startup-error-fade-in 400ms 100ms both;
|
||||
}
|
||||
|
||||
.startup-error-action {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:global(html.dark .startup-error-hint) {
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
@keyframes startup-error-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
:global(html.dark .startup-label) {
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.startup-exit-leave-active {
|
||||
transition: opacity 250ms ease-out;
|
||||
}
|
||||
|
||||
.startup-exit-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-height: 650px) {
|
||||
.startup-screen-error .startup-brand {
|
||||
top: calc(env(safe-area-inset-top) + 16px);
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.startup-screen-error .startup-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.startup-screen-error .startup-name {
|
||||
top: 3px;
|
||||
left: 38px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.startup-screen-error .startup-brand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.startup-screen-error {
|
||||
grid-template-rows: minmax(min-content, 1fr) auto auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.startup-error-info {
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.startup-status-error {
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.startup-error-title {
|
||||
margin-right: 16px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.startup-error-hint {
|
||||
margin-right: 16px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.startup-error-recovery {
|
||||
grid-row: 3;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.startup-error-action {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.startup-track {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.startup-brand,
|
||||
.startup-logo,
|
||||
.startup-name,
|
||||
.startup-status {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.startup-error-info,
|
||||
.startup-error-recovery {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.startup-exit-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -8,8 +8,8 @@ defineProps<{
|
||||
<template>
|
||||
<div relative overflow-hidden rounded-md>
|
||||
<div
|
||||
:class="[barClass ? barClass : 'bg-primary-300 dark:bg-primary-300/50']"
|
||||
absolute h-4 min-w-2 rounded-md will-change-width
|
||||
:class="[barClass ? barClass : 'bg-primary-300 dark:bg-primary-300/50', progress > 0 && 'min-w-2']"
|
||||
absolute h-4 rounded-md will-change-width
|
||||
:style="{ width: `${progress}%` }"
|
||||
transition="width duration-500 ease-in-out"
|
||||
>
|
||||
@@ -31,6 +31,13 @@ defineProps<{
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.progress-shine-animation {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes progress-shine {
|
||||
0% {
|
||||
opacity: 0.4;
|
||||
|
||||
Generated
+15
-3
@@ -2522,6 +2522,9 @@ importers:
|
||||
'@proj-airi/ui':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/ui
|
||||
'@proj-airi/ui-loading-screens':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/ui-loading-screens
|
||||
'@proj-airi/ui-transitions':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/ui-transitions
|
||||
@@ -4579,6 +4582,9 @@ importers:
|
||||
'@proj-airi/ui':
|
||||
specifier: workspace:^
|
||||
version: link:../ui
|
||||
'@proj-airi/ui-loading-screens':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-loading-screens
|
||||
'@proj-airi/vite-plugin-sherpaw':
|
||||
specifier: workspace:^
|
||||
version: link:../vite-plugin-sherpaw
|
||||
@@ -5360,12 +5366,18 @@ importers:
|
||||
|
||||
packages/ui-loading-screens:
|
||||
dependencies:
|
||||
'@fontsource-variable/comfortaa':
|
||||
specifier: 'catalog:'
|
||||
version: 5.3.0
|
||||
'@moeru/std':
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.0-beta.17
|
||||
'@proj-airi/font-departure-mono':
|
||||
specifier: workspace:^
|
||||
version: link:../font-departure-mono
|
||||
'@proj-airi/ui':
|
||||
specifier: workspace:^
|
||||
version: link:../ui
|
||||
'@rive-app/canvas-lite':
|
||||
specifier: 'catalog:'
|
||||
version: 2.40.1
|
||||
@@ -5375,13 +5387,13 @@ importers:
|
||||
'@vueuse/motion':
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.3(magicast@0.5.4)(vue@3.5.41(typescript@6.0.3))
|
||||
vaul-vue:
|
||||
specifier: 'catalog:'
|
||||
version: 0.4.1(reka-ui@2.10.3(vue@3.5.41(typescript@6.0.3)))(vue@3.5.41(typescript@6.0.3))
|
||||
vue:
|
||||
specifier: 'catalog:'
|
||||
version: 3.5.41(typescript@6.0.3)
|
||||
devDependencies:
|
||||
'@proj-airi/ui':
|
||||
specifier: workspace:^
|
||||
version: link:../ui
|
||||
'@unocss/reset':
|
||||
specifier: 'catalog:'
|
||||
version: 66.7.5
|
||||
|
||||
Reference in New Issue
Block a user