feat(stage-tamagotchi): notice before toggling Fade on Hover

This commit is contained in:
Neko Ayaka
2025-11-24 00:22:47 +08:00
parent 22845805fd
commit 521c38a6e8
17 changed files with 438 additions and 12 deletions
+3 -1
View File
@@ -22,6 +22,7 @@ import { setupCaptionWindowManager } from './windows/caption'
import { setupChatWindowReusableFunc } from './windows/chat'
import { setupInlayWindow } from './windows/inlay'
import { setupMainWindow } from './windows/main'
import { setupNoticeWindowManager } from './windows/notice'
import { setupSettingsWindowReusableFunc } from './windows/settings'
import { toggleWindowShow } from './windows/shared/window'
import { setupWidgetsWindowManager } from './windows/widgets'
@@ -103,13 +104,14 @@ app.whenReady().then(async () => {
const channelServerModule = injeca.provide('modules:channel-server', async () => setupChannelServer())
const chatWindow = injeca.provide('windows:chat', { build: () => setupChatWindowReusableFunc() })
const widgetsManager = injeca.provide('windows:widgets', { build: () => setupWidgetsWindowManager() })
const noticeWindow = injeca.provide('windows:notice', { build: () => setupNoticeWindowManager() })
const settingsWindow = injeca.provide('windows:settings', {
dependsOn: { widgetsManager },
build: ({ dependsOn }) => setupSettingsWindowReusableFunc(dependsOn),
})
const mainWindow = injeca.provide('windows:main', {
dependsOn: { settingsWindow, chatWindow, widgetsManager },
dependsOn: { settingsWindow, chatWindow, widgetsManager, noticeWindow },
build: async ({ dependsOn }) => setupMainWindow(dependsOn),
})
const captionWindow = injeca.provide('windows:caption', {
@@ -1,6 +1,7 @@
import type { BrowserWindowConstructorOptions, Rectangle } from 'electron'
import type { WidgetsWindowManager } from '../widgets'
import type { NoticeWindowManager } from '../notice'
import { dirname, join, resolve } from 'node:path'
import { env } from 'node:process'
@@ -31,6 +32,7 @@ export async function setupMainWindow(params: {
settingsWindow: () => Promise<BrowserWindow>
chatWindow: () => Promise<BrowserWindow>
widgetsManager: WidgetsWindowManager
noticeWindow: NoticeWindowManager
}) {
const {
setup: setupConfig,
@@ -131,6 +133,7 @@ export async function setupMainWindow(params: {
settingsWindow: params.settingsWindow,
chatWindow: params.chatWindow,
widgetsManager: params.widgetsManager,
noticeWindow: params.noticeWindow,
})
/**
@@ -1,12 +1,13 @@
import type { BrowserWindow } from 'electron'
import type { NoticeWindowManager } from '../../notice'
import type { WidgetsWindowManager } from '../../widgets'
import { defineInvokeHandler } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/main'
import { ipcMain } from 'electron'
import { electronOpenChat, electronOpenMainDevtools, electronOpenSettings } from '../../../../shared/eventa'
import { electronOpenChat, electronOpenMainDevtools, electronOpenSettings, noticeWindowEventa } from '../../../../shared/eventa'
import { createWidgetsService } from '../../../services/airi/widgets'
import { createScreenService, createWindowService } from '../../../services/electron'
import { toggleWindowShow } from '../../shared'
@@ -16,6 +17,7 @@ export function setupMainWindowElectronInvokes(params: {
settingsWindow: () => Promise<BrowserWindow>
chatWindow: () => Promise<BrowserWindow>
widgetsManager: WidgetsWindowManager
noticeWindow: NoticeWindowManager
}) {
// TODO: once we refactored eventa to support window-namespaced contexts,
// we can remove the setMaxListeners call below since eventa will be able to dispatch and
@@ -31,4 +33,5 @@ export function setupMainWindowElectronInvokes(params: {
defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' }))
defineInvokeHandler(context, electronOpenSettings, async () => toggleWindowShow(await params.settingsWindow()))
defineInvokeHandler(context, electronOpenChat, async () => toggleWindowShow(await params.chatWindow()))
defineInvokeHandler(context, noticeWindowEventa.openWindow, (payload) => params.noticeWindow.open(payload))
}
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import ControlButton from './ControlButton.vue'
import ControlButtonTooltip from './ControlButtonTooltip.vue'
import { noticeWindowEventa } from '../../../../shared/eventa'
import { useElectronEventaInvoke } from '../../../composables/electron-vueuse/use-electron-eventa-context'
import { useControlsIslandStore } from '../../../stores/controls-island'
const uiStore = useControlsIslandStore()
const enabled = computed(() => uiStore.fadeOnHoverEnabled)
const { t } = useI18n()
const requestNotice = useElectronEventaInvoke(noticeWindowEventa.openWindow)
const NOTICE_WINDOW_ID = 'fade-on-hover'
async function handleToggle() {
if (enabled.value) {
uiStore.disableFadeOnHover()
return
}
try {
const acknowledged = await requestNotice({
id: NOTICE_WINDOW_ID,
route: '/notice/fade-on-hover',
type: 'fade-on-hover',
})
if (acknowledged)
uiStore.enableFadeOnHover()
}
catch (error) {
console.error('Failed to open fade-on-hover notice:', error)
}
}
</script>
<template>
<ControlButtonTooltip>
<ControlButton
:class="{ 'border-primary-300/70 shadow-[0_10px_24px_rgba(0,0,0,0.22)]': enabled }"
@click="handleToggle"
>
<Transition name="fade" mode="out-in">
<div v-if="enabled" i-ph:eye size-5 text="primary-700 dark:primary-300" />
<div v-else i-ph:eye-slash size-5 text="neutral-800 dark:neutral-300" />
</Transition>
</ControlButton>
<template #tooltip>
{{ enabled ? t('tamagotchi.stage.controls-island.fade-on-hover.disable') : t('tamagotchi.stage.controls-island.fade-on-hover.enable') }}
</template>
</ControlButtonTooltip>
</template>
@@ -7,6 +7,7 @@ import { ref } from 'vue'
import ControlButton from './ControlButton.vue'
import ControlButtonTooltip from './ControlButtonTooltip.vue'
import ControlsIslandFadeOnHover from './ControlsIslandFadeOnHover.vue'
import ControlsIslandHearingConfig from './ControlsIslandHearingConfig.vue'
import IndicatorMicVolume from './IndicatorMicVolume.vue'
@@ -75,6 +76,8 @@ defineExpose({ hearingDialogOpen })
</template>
</ControlButtonTooltip>
<ControlsIslandFadeOnHover />
<ControlButtonTooltip>
<ControlButton cursor-move :class="{ 'drag-region': isLinux }" @mousedown="startDraggingWindow?.()">
<div i-ph:arrows-out-cardinal size-5 text="neutral-800 dark:neutral-300" />
@@ -13,7 +13,7 @@ import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consci
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { refDebounced, useBroadcastChannel } from '@vueuse/core'
import { refDebounced, useBroadcastChannel, watchPausable } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onUnmounted, ref, toRef, watch } from 'vue'
@@ -28,6 +28,7 @@ import {
useElectronMouseInWindow,
useElectronRelativeMouse,
} from '../composables/electron-vueuse'
import { useControlsIslandStore } from '../stores/controls-island'
import { useWindowStore } from '../stores/window'
const resourceStatusIslandRef = ref<InstanceType<typeof ResourceStatusIsland>>()
@@ -53,16 +54,17 @@ const setIgnoreMouseEvents = useElectronEventaInvoke(electron.window.setIgnoreMo
const { scale, positionInPercentageString } = storeToRefs(useLive2d())
const { live2dLookAtX, live2dLookAtY } = storeToRefs(useWindowStore())
const { fadeOnHoverEnabled } = storeToRefs(useControlsIslandStore())
watch(componentStateStage, () => isLoading.value = componentStateStage.value !== 'mounted', { immediate: true })
const { pause, resume } = watch(isTransparent, (transparent) => {
shouldFadeOnCursorWithin.value = !transparent
const { pause, resume } = watchPausable(isTransparent, (transparent) => {
shouldFadeOnCursorWithin.value = fadeOnHoverEnabled.value && !transparent
}, { immediate: true })
const hearingDialogOpen = computed(() => controlsIslandRef.value?.hearingDialogOpen ?? false)
watch([isOutsideFor250Ms, isAroundWindowBorderFor250Ms, isOutsideWindow, isTransparent, hearingDialogOpen], () => {
watch([isOutsideFor250Ms, isAroundWindowBorderFor250Ms, isOutsideWindow, isTransparent, hearingDialogOpen, fadeOnHoverEnabled], () => {
if (hearingDialogOpen.value) {
// Hearing dialog/drawer is open; keep window interactive
isIgnoringMouseEvents.value = false
@@ -83,13 +85,14 @@ watch([isOutsideFor250Ms, isAroundWindowBorderFor250Ms, isOutsideWindow, isTrans
pause()
}
else {
// Otherwise allow click-through while we fade UI based on transparency
// Otherwise allow click-through while we fade UI based on transparency (when enabled)
isIgnoringMouseEvents.value = true
if (!isOutsideWindow.value && !isTransparent.value) {
shouldFadeOnCursorWithin.value = true
}
shouldFadeOnCursorWithin.value = fadeOnHoverEnabled.value && !isOutsideWindow.value && !isTransparent.value
setIgnoreMouseEvents([true, { forward: true }])
resume()
if (fadeOnHoverEnabled.value)
resume()
else
pause()
}
})
@@ -226,7 +229,9 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
:y-offset="positionInPercentageString.y"
mb="<md:18"
/>
<ControlsIsland ref="controlsIslandRef" />
<ControlsIsland
ref="controlsIslandRef"
/>
</div>
</div>
<div v-show="isLoading" h-full w-full>
@@ -0,0 +1,207 @@
<script setup lang="ts">
import { Button } from '@proj-airi/stage-ui/components'
import { TransitionVertical } from '@proj-airi/ui'
import { refDebounced, useDark, useMouseInElement } from '@vueuse/core'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import VideoTutorialFadeOnHoverDark from '../../assets/videos/tutorial/tutorial-fade-on-hover.dark.mp4'
import VideoTutorialFadeOnHoverLight from '../../assets/videos/tutorial/tutorial-fade-on-hover.light.mp4'
import { noticeWindowEventa } from '../../../shared/eventa'
import { useElectronEventaContext, useElectronEventaInvoke } from '../../composables/electron-vueuse'
const context = useElectronEventaContext()
const sendAction = useElectronEventaInvoke(noticeWindowEventa.windowAction, context.value)
const notifyMounted = useElectronEventaInvoke(noticeWindowEventa.pageMounted, context.value)
const notifyUnmounted = useElectronEventaInvoke(noticeWindowEventa.pageUnmounted, context.value)
const route = useRoute()
const { t } = useI18n()
const descriptionContainerRef = ref<HTMLDivElement>()
const { isOutside } = useMouseInElement(descriptionContainerRef)
const descriptionContainerTitleRef = ref<HTMLDivElement>()
const descriptionOpenImmediate = computed(() => !isOutside.value)
const descriptionOpen = refDebounced(descriptionOpenImmediate, 80)
const isDark = useDark({ disableTransition: false })
const requestId = ref<string | null>(null)
const waitingForRequest = computed(() => !requestId.value)
onMounted(async () => {
try {
const id = typeof route.query.id === 'string'
? route.query.id
: Array.isArray(route.query.id)
? route.query.id[0]
: null
const pending = await notifyMounted({ id: id ?? undefined })
if (pending?.id && pending.type === 'fade-on-hover')
requestId.value = pending.id
}
catch (error) {
console.warn('Failed to notify notice window mounted:', error)
}
})
onBeforeUnmount(async () => {
try {
await notifyUnmounted({ id: undefined })
}
catch {
/* noop */
}
})
async function handleAction(action: 'confirm' | 'cancel' | 'close') {
const id = requestId.value
if (!id) {
window.close()
return
}
try {
await sendAction({ id, action })
}
catch (error) {
console.warn('Failed to notify main process of notice action:', error)
}
finally {
window.close()
}
}
</script>
<template>
<div class="h-100dvh w-100dvw">
<div class="relative h-full w-full flex flex-col gap-4 text-neutral-900 dark:text-neutral-100">
<div class="absolute inset-0 z-0 h-full w-full overflow-hidden text-xs text-neutral-600 dark:text-neutral-400">
<video :src="isDark ? VideoTutorialFadeOnHoverDark : VideoTutorialFadeOnHoverLight" autoplay muted loop class="h-full w-full object-cover" />
</div>
<div class="relative z-1 h-full w-full flex flex-col">
<div class="mb-2 flex items-center justify-between gap-2 px-4 pt-4">
<div class="inline-flex items-center gap-2 rounded-full bg-primary-500 px-3 py-1 text-[11px] text-primary-100 font-semibold tracking-[0.14em] uppercase">
Tutorial
<div class="h-1.5 w-1.5 rounded-full bg-primary-300 shadow-[0_0_12px_rgba(0,0,0,0.35)]" />
</div>
</div>
<div
:class="[
'w-fit',
'pl-4 pr-5 py-4 text-2xl font-semibold leading-tight',
'rounded-r-xl',
'bg-neutral-100/80 dark:bg-neutral-900/80',
'backdrop-blur-sm',
]"
>
{{ t('tamagotchi.stage.notice.fade-on-hover.title') }}
</div>
<div class="flex-1" />
<div class="w-full px-4 pb-4">
<div
ref="descriptionContainerRef"
:class="[
'flex flex-col overflow-hidden',
'bg-neutral-100/90 dark:bg-neutral-900/90',
'backdrop-blur-sm',
'p-3 sm:p-4',
'rounded-lg',
]"
>
<div class="space-y-2">
<div class="flex items-center gap-3">
<div ref="descriptionContainerTitleRef" class="line-clamp-1 min-h-full flex-1 overflow-hidden text-ellipsis text-lg font-semibold space-y-0.5">
<template v-if="!descriptionOpen">
<i18n-t keypath="tamagotchi.stage.notice.fade-on-hover.opacity" tag="div">
<template #value>
<span class="text-primary-800 font-semibold dark:text-primary-100">
{{ t('tamagotchi.stage.notice.fade-on-hover.value') }}
</span>
</template>
<template #targets>
<span class="text-primary-800 font-semibold dark:text-primary-100">
{{ t('tamagotchi.stage.notice.fade-on-hover.targets') }}
</span>
</template>
</i18n-t>
<i18n-t keypath="tamagotchi.stage.notice.fade-on-hover.toggle" tag="div">
<template #controls>
<span class="inline text-nowrap text-primary-800 font-semibold dark:text-primary-100">
{{ t('tamagotchi.stage.notice.fade-on-hover.controls-label') }}
</span>
</template>
<template #icon>
<div
i-ph:eye-slash
class="inline-block align-middle"
:aria-label="t('tamagotchi.stage.notice.fade-on-hover.icon-label')"
/>
</template>
</i18n-t>
</template>
</div>
<Button
v-if="!descriptionOpen"
size="sm"
:label="t('tamagotchi.stage.notice.fade-on-hover.read-more')"
/>
</div>
<TransitionVertical>
<div v-if="descriptionOpen" class="overflow-hidden space-y-2">
<div>
{{ t('tamagotchi.stage.notice.fade-on-hover.intro') }}
</div>
<i18n-t keypath="tamagotchi.stage.notice.fade-on-hover.opacity" tag="div">
<template #value>
<span class="text-primary-800 font-semibold dark:text-primary-100">
{{ t('tamagotchi.stage.notice.fade-on-hover.value') }}
</span>
</template>
<template #targets>
<span class="text-primary-800 font-semibold dark:text-primary-100">
{{ t('tamagotchi.stage.notice.fade-on-hover.targets') }}
</span>
</template>
</i18n-t>
<i18n-t keypath="tamagotchi.stage.notice.fade-on-hover.toggle" tag="div">
<template #controls>
<span class="inline text-nowrap text-primary-800 font-semibold dark:text-primary-100">
{{ t('tamagotchi.stage.notice.fade-on-hover.controls-label') }}
</span>
</template>
<template #icon>
<div
i-ph:eye-slash
class="inline-block align-middle"
:aria-label="t('tamagotchi.stage.notice.fade-on-hover.icon-label')"
/>
</template>
</i18n-t>
<div class="mt-3 flex flex-col gap-2 sm:flex-row">
<Button
variant="primary"
size="md"
block
:label="t('tamagotchi.stage.notice.fade-on-hover.confirm')"
:disabled="waitingForRequest"
:loading="waitingForRequest"
@click="handleAction('confirm')"
/>
</div>
</div>
</TransitionVertical>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<route lang="yaml">
meta:
layout: plain
</route>
@@ -0,0 +1,21 @@
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
export const useControlsIslandStore = defineStore('controls-island', () => {
// Persist fade-on-hover preference per user
const fadeOnHoverEnabled = useLocalStorage<boolean>('controls-island/fade-on-hover-enabled', false)
function enableFadeOnHover() {
fadeOnHoverEnabled.value = true
}
function disableFadeOnHover() {
fadeOnHoverEnabled.value = false
}
return {
fadeOnHoverEnabled,
enableFadeOnHover,
disableFadeOnHover,
}
})
@@ -8,3 +8,21 @@ docs:
navbar:
appearance:
title: Appearance
'controls-island':
'fade-on-hover':
enable: Auto hide
disable: Always show
notice:
'fade-on-hover':
title: Fade on Hover
intro: Automatically fades the character when your cursor roams nearby. It helps reduce distractions while keeping your character visible.
opacity: After turning on, the opacity drops to {value} for both {targets} when your cursor is nearby.
toggle: You can toggle it off by hovering at {controls}, then clicking the {icon} again.
controls-label: Controls Island
icon-label: eye-slash icon
value: '0'
targets: Character and UI
confirm: Got it
preparing: Preparing…
read-more: Read more
preview-title: What is it?
@@ -8,3 +8,21 @@ docs:
navbar:
appearance:
title: Apariencia
'controls-island':
'fade-on-hover':
enable: Ocultar al pasar el cursor
disable: Mostrar siempre
notice:
'fade-on-hover':
title: Desvanecer al pasar el cursor
intro: Difumina el personaje automáticamente cuando el cursor está cerca. Reduce distracciones sin perder visibilidad.
opacity: Al activarlo, la opacidad baja a {value} para {targets} cuando el cursor está cerca.
toggle: Puedes desactivarlo pasando el cursor por {controls} y tocando de nuevo el {icon}.
controls-label: Isla de controles
icon-label: ícono de ojo tachado
value: '0'
targets: Personaje y UI
confirm: Entendido
preparing: Preparando…
read-more: Leer más
preview-title: ¿Qué es esto?
@@ -8,3 +8,21 @@ docs:
navbar:
appearance:
title: Apparence
'controls-island':
'fade-on-hover':
enable: Masquer au survol
disable: Toujours afficher
notice:
'fade-on-hover':
title: Disparaître au survol
intro: Fait disparaître légèrement le personnage quand le curseur sapproche. Moins de distractions tout en restant visible.
opacity: Une fois activé, lopacité descend à {value} pour {targets} quand le curseur est à proximité.
toggle: Vous pouvez le désactiver en survolant l{controls}, puis en cliquant à nouveau sur l{icon}.
controls-label: Îlot de commandes
icon-label: icône œil barré
value: '0'
targets: Personnage et UI
confirm: Compris
preparing: Préparation…
read-more: En savoir plus
preview-title: Cest quoi ?
@@ -8,3 +8,21 @@ docs:
navbar:
appearance:
title: Внешний вид
'controls-island':
'fade-on-hover':
enable: Скрывать при наведении
disable: Всегда показывать
notice:
'fade-on-hover':
title: Исчезать при наведении
intro: Автоматически делает персонажа прозрачнее, когда курсор рядом. Меньше отвлекает, но остаётся видимым.
opacity: После включения непрозрачность падает до {value} для {targets}, когда курсор рядом.
toggle: Можно выключить, наведя на {controls} и снова нажав на {icon}.
controls-label: Остров управления
icon-label: значок перечёркнутого глаза
value: '0'
targets: Персонажа и интерфейса
confirm: Понятно
preparing: Подготавливаем…
read-more: Подробнее
preview-title: Что это?
@@ -8,3 +8,21 @@ docs:
navbar:
appearance:
title: Giao diện
'controls-island':
'fade-on-hover':
enable: Ẩn khi rê chuột
disable: Luôn hiển thị
notice:
'fade-on-hover':
title: Mờ dần khi rê chuột
intro: Tự làm mờ nhân vật khi con trỏ ở gần. Giảm xao nhãng nhưng vẫn nhìn thấy nhân vật.
opacity: Bật lên thì độ mờ giảm xuống {value} cho {targets} khi con trỏ ở gần.
toggle: Tắt đi bằng cách rê chuột qua {controls} rồi bấm lại {icon}.
controls-label: Đảo Điều Khiển
icon-label: biểu tượng mắt gạch
value: '0'
targets: Nhân vật và giao diện
confirm: Đã hiểu
preparing: Đang chuẩn bị…
read-more: Xem thêm
preview-title: Đây là gì?
@@ -8,3 +8,21 @@ docs:
navbar:
appearance:
title: 外观
'controls-island':
'fade-on-hover':
enable: 悬停时隐藏
disable: 总是显示
notice:
'fade-on-hover':
title: 悬停淡出
intro: 当光标靠近时自动让角色变淡,减少干扰又保持可见。
opacity: 开启后,不透明度会降到 {value} ,作用于 {targets} ,当光标靠近时。
toggle: 想关闭,移动到 {controls} 区域,再点击一次 {icon}。
controls-label: 控制岛
icon-label: 隐藏图标
value: '0'
targets: 角色和界面
confirm: 知道了
preparing: 准备中…
read-more: 阅读更多
preview-title: 这是什么?
@@ -8,3 +8,21 @@ docs:
navbar:
appearance:
title: 外貌
'controls-island':
'fade-on-hover':
enable: 懸停時隱藏
disable: 總是顯示
notice:
'fade-on-hover':
title: 懸停淡出
intro: 當游標靠近時自動讓角色變淡,減少干擾同時保持可見。
opacity: 開啟後,不透明度會降到 {value} ,作用於 {targets} ,當游標靠近時。
toggle: 要關閉時,將游標移到 {controls} 區域,再點擊一次 {icon}。
controls-label: 控制島
icon-label: 隱藏圖示
value: '0'
targets: 角色與介面
confirm: 知道了
preparing: 準備中…
read-more: 閱讀更多
preview-title: 這是什麼?