feat(stage-tamagotchi): open settings page & open devtools in Developer section

This commit is contained in:
Neko Ayaka
2025-10-12 01:34:03 +08:00
parent 3d453469b6
commit e29a5caaad
18 changed files with 194 additions and 58 deletions
+10 -6
View File
@@ -19,7 +19,7 @@ import { emitAppBeforeQuit, emitAppReady, emitAppWindowAllClosed, onAppBeforeQui
import { setElectronMainDirname } from './libs/electron/location'
import { setupInlayWindow } from './windows/inlay'
import { setupMainWindow } from './windows/main'
import { setupSettingsWindow } from './windows/settings'
import { setupSettingsWindowReusableFunc } from './windows/settings'
import { toggleWindowShow } from './windows/shared/window'
setElectronMainDirname(__dirname)
@@ -32,7 +32,10 @@ const log = useLogg('main').useGlobalConfig()
app.dock?.setIcon(icon)
electronApp.setAppUserModelId('ai.moeru.airi')
function setupTray(params: { mainWindow: BrowserWindow }): void {
function setupTray(params: {
mainWindow: BrowserWindow
settingsWindow: () => Promise<BrowserWindow>
}): void {
once(() => {
const appTray = new Tray(nativeImage.createFromPath(macOSTrayIcon).resize({ width: 16 }))
onAppBeforeQuit(() => appTray.destroy())
@@ -41,7 +44,7 @@ function setupTray(params: { mainWindow: BrowserWindow }): void {
{ label: 'Show', click: () => toggleWindowShow(params.mainWindow) },
{ label: 'Inlay', click: () => setupInlayWindow() },
{ type: 'separator' },
{ label: 'Settings', click: () => setupSettingsWindow() },
{ label: 'Settings', click: () => params.settingsWindow().then(window => toggleWindowShow(window)) },
{ type: 'separator' },
{ label: 'Quit', click: () => app.quit() },
])
@@ -92,9 +95,10 @@ app.whenReady().then(async () => {
await setupProjectAIRIServerRuntime()
injecta.setLogger(createLoggLogger())
injecta.provide('mainWindow', async () => await setupMainWindow())
injecta.provide<{ mainWindow: BrowserWindow }>('tray', { dependsOn: { mainWindow: 'mainWindow' }, build: async ({ dependsOn }) => setupTray({ mainWindow: dependsOn.mainWindow }) })
injecta.invoke({ dependsOn: { mainWindow: 'mainWindow', tray: 'tray' }, callback: noop })
injecta.provide('windows:settings', () => setupSettingsWindowReusableFunc())
injecta.provide<{ settingsWindow: () => Promise<BrowserWindow> }>('windows:main', { dependsOn: { settingsWindow: 'windows:settings' }, build: async ({ dependsOn }) => setupMainWindow(dependsOn) })
injecta.provide<{ mainWindow: BrowserWindow, settingsWindow: () => Promise<BrowserWindow> }>('tray', { dependsOn: { mainWindow: 'windows:main', settingsWindow: 'windows:settings' }, build: async ({ dependsOn }) => setupTray(dependsOn) })
injecta.invoke({ dependsOn: { mainWindow: 'windows:main', tray: 'tray' }, callback: noop })
injecta.start()
// Lifecycle
@@ -0,0 +1 @@
export * from './reusable'
@@ -0,0 +1,20 @@
import type { BrowserWindow } from 'electron'
export function createReusableWindow(setupFn: () => BrowserWindow | Promise<BrowserWindow>): { getWindow: () => Promise<BrowserWindow> } {
let window: BrowserWindow | undefined
return {
getWindow: async () => {
if (!window) {
window = await setupFn()
return window
}
if (window.isDestroyed()) {
window = await setupFn()
return window
}
return window
},
}
}
@@ -1,17 +1,14 @@
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { join, resolve } from 'node:path'
import { BrowserWindow, shell } from 'electron'
import { isMacOS } from 'std-env'
import icon from '../../../../resources/icon.png?asset'
import { baseUrl, load, withHashRoute } from '../../libs/electron/location'
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { currentDisplayBounds, mapForBreakpoints, resolutionBreakpoints, widthFrom } from '../shared/display'
import { spotlightLikeWindowConfig } from '../shared/window'
const __dirname = dirname(fileURLToPath(import.meta.url))
export async function setupInlayWindow() {
const window = new BrowserWindow({
title: 'Inlay',
@@ -20,7 +17,7 @@ export async function setupInlayWindow() {
show: false,
icon,
webPreferences: {
preload: join(dirname(fileURLToPath(import.meta.url)), '../preload/index.mjs'),
preload: join(__dirname, '../preload/index.mjs'),
sandbox: false,
},
...spotlightLikeWindowConfig(),
@@ -63,7 +60,7 @@ export async function setupInlayWindow() {
return { action: 'deny' }
})
await load(window, withHashRoute(baseUrl(resolve(__dirname, '..', '..', '..', 'renderer')), '/inlay'))
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/inlay'))
return window
}
@@ -1,13 +1,17 @@
import type { BrowserWindow } from 'electron'
import { defineInvokeHandler } from '@unbird/eventa'
import { createContext } from '@unbird/eventa/adapters/electron/main'
import { ipcMain } from 'electron'
import { electronOpenMainDevtools, electronOpenSettings } from '../../../../shared/eventa'
import { createFadeOnHoverService } from '../../../services/fade-on-hover'
import { toggleWindowShow } from '../../shared'
export function setupAppInvokeHandlers(window: BrowserWindow) {
const eventaContext = createContext(ipcMain, window)
const { context } = eventaContext
export function setupMainWindowElectronInvokes(params: { window: BrowserWindow, settingsWindow: () => Promise<BrowserWindow> }) {
const { context } = createContext(ipcMain, params.window)
createFadeOnHoverService(context)
defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' }))
defineInvokeHandler(context, electronOpenSettings, async () => toggleWindowShow(await params.settingsWindow()))
}
@@ -5,27 +5,32 @@ import { BrowserWindow, shell } from 'electron'
import icon from '../../../../resources/icon.png?asset'
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { createReusableWindow } from '../../libs/electron/window-manager'
import { setupSettingsWindowInvokes } from './rpc/index.electron'
export function setupSettingsWindowReusableFunc() {
const window = new BrowserWindow({
title: 'Settings',
width: 600.0,
height: 800.0,
show: false,
icon,
webPreferences: {
preload: join(dirname(fileURLToPath(import.meta.url)), '../preload/index.mjs'),
sandbox: false,
},
})
return createReusableWindow(async () => {
const window = new BrowserWindow({
title: 'Settings',
width: 600.0,
height: 800.0,
show: false,
icon,
webPreferences: {
preload: join(__dirname, '../preload/index.mjs'),
sandbox: false,
},
})
window.on('ready-to-show', () => window.show())
window.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
window.on('ready-to-show', () => window.show())
window.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/settings'))
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/settings'))
await setupSettingsWindowInvokes({ settingsWindow: window })
return window
return window
}).getWindow
}
@@ -0,0 +1,13 @@
import type { BrowserWindow } from 'electron'
import { defineInvokeHandler } from '@unbird/eventa'
import { createContext } from '@unbird/eventa/adapters/electron/main'
import { ipcMain } from 'electron'
import { electronOpenSettingsDevtools } from '../../../../shared/eventa'
export async function setupSettingsWindowInvokes(params: { settingsWindow: BrowserWindow }) {
const { context } = createContext(ipcMain, params.settingsWindow)
defineInvokeHandler(context, electronOpenSettingsDevtools, async () => params.settingsWindow.webContents.openDevTools({ mode: 'detach' }))
}
@@ -1,21 +1,31 @@
<script setup lang="ts">
import { HearingConfigDialog } from '@proj-airi/stage-ui/components'
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { defineInvoke } from '@unbird/eventa'
import { createContext } from '@unbird/eventa/adapters/electron/renderer'
import { useDark, useToggle } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ControlButton from './ControlButton.vue'
import { electronOpenSettings } from '../../../../shared/eventa'
const isDark = useDark({ disableTransition: false })
const toggleDark = useToggle(isDark)
const settingsAudioDeviceStore = useSettingsAudioDevice()
const { enabled } = storeToRefs(settingsAudioDeviceStore)
const { context } = createContext(window.electron.ipcRenderer)
const openSettings = defineInvoke(context, electronOpenSettings)
</script>
<template>
<div fixed bottom-2 right-2>
<div flex flex-col gap-1>
<ControlButton @click="() => openSettings()">
<div i-solar:settings-minimalistic-outline size-5 text="neutral-800 dark:neutral-300" />
</ControlButton>
<HearingConfigDialog>
<ControlButton>
<Transition name="fade" mode="out-in">
@@ -1,9 +1,13 @@
<script setup lang="ts">
import { CheckBar, IconItem } from '@proj-airi/stage-ui/components'
import { ButtonBar, CheckBar, IconItem } from '@proj-airi/stage-ui/components'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { defineInvoke } from '@unbird/eventa'
import { createContext } from '@unbird/eventa/adapters/electron/renderer'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { electronOpenMainDevtools } from '../../../../shared/eventa'
const { t } = useI18n()
const settings = useSettings()
@@ -15,9 +19,27 @@ const menu = computed(() => [
to: '/devtools/use-magic-keys',
},
])
const { context } = createContext(window.electron.ipcRenderer)
const openDevTools = defineInvoke(context, electronOpenMainDevtools)
</script>
<template>
<ButtonBar
v-model="settings.disableTransitions"
v-motion
mb-2
icon="i-solar:settings-minimalistic-outline"
text="settings.pages.page.developers.open-devtools.title"
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (19 * 10)"
:delay="1 * 50"
transition="all ease-in-out duration-250"
@click="() => openDevTools()"
>
{{ t('settings.pages.page.developers.open-devtools.button') }}
</ButtonBar>
<CheckBar
v-model="settings.disableTransitions"
v-motion
@@ -28,7 +50,7 @@ const menu = computed(() => [
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (19 * 10)"
:delay="1 * 50"
:delay="2 * 50"
transition="all ease-in-out duration-250"
/>
<CheckBar
@@ -42,7 +64,7 @@ const menu = computed(() => [
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (20 * 10)"
:delay="2 * 50"
:delay="3 * 50"
transition="all ease-in-out duration-250"
/>
@@ -55,7 +77,7 @@ const menu = computed(() => [
:enter="{ opacity: 1, y: 0 }"
:duration="250"
:style="{
transitionDelay: `${index * 50}ms`, // delay between each item, unocss doesn't support dynamic generation of classes now
transitionDelay: `${(4 + index) * 50}ms`, // delay between each item, unocss doesn't support dynamic generation of classes now
}"
:title="item.title"
:description="item.description"
+3 -1
View File
@@ -15,4 +15,6 @@ interface Point {
export const electronCursorPoint = defineEventa<Point>('electron:eventa:event:cursor-point')
export const electronStartTrackingCursorPoint = defineInvokeEventa('electron:eventa:invoke:start-tracking-cursor-point')
export const electronOpenSettings = defineInvokeEventa<void, void>('electron:eventa:invoke:open-settings')
export const electronOpenMainDevtools = defineInvokeEventa<void, void>('electron:eventa:invoke:windows:main:devtools:open')
export const electronOpenSettings = defineInvokeEventa<void, void>('electron:eventa:invoke:windows:settings:open')
export const electronOpenSettingsDevtools = defineInvokeEventa<void, void>('electron:eventa:invoke:windows:settings:devtools:open')
+17 -5
View File
@@ -1,6 +1,5 @@
common:
save: Save
animations:
stage-transitions:
title: Disable Stage Transitions
@@ -186,19 +185,27 @@ pages:
title: Parameters
parameters:
adaptive_threshold:
description: Whether to apply adaptive thresholds based on signal variance over time.
description: >-
Whether to apply adaptive thresholds based on signal variance
over time.
label: Adaptive threshold
buffer_duration:
description: Duration of the internal analysis buffer.
label: Buffer duration
envelope_filter_frequency:
description: Frequency for the envelope filter applied to smooth energy changes.
description: >-
Frequency for the envelope filter applied to smooth energy
changes.
label: Envelope filter frequency
highpass_filter_frequency:
description: Frequency for the highpass filter applied to reduce low frequencies like sub-bass noises.
description: >-
Frequency for the highpass filter applied to reduce low
frequencies like sub-bass noises.
label: Highpass filter frequency
lowpass_filter_frequency:
description: Frequency for the lowpass filter applied to reduce high frequencies like vocals.
description: >-
Frequency for the lowpass filter applied to reduce high
frequencies like vocals.
label: Lowpass filter frequency
min_beat_interval:
description: Maximum BPM or minimum interval between detected beats.
@@ -671,6 +678,11 @@ pages:
title: Chinese Traditional Colors
title: Color Scheme Presets
title: System
page:
developers:
open-devtools:
title: Open Developer Tools
button: Open
sections:
section:
general:
@@ -1,6 +1,5 @@
common:
save: 保存
animations:
stage-transitions:
title: 是否开启舞台动画
@@ -601,6 +600,11 @@ pages:
title: 中国传统颜色
title: 预设
title: 外观
page:
developers:
open-devtools:
button: 打开
title: 打开开发者工具
sections:
section:
general:
@@ -1,6 +1,5 @@
common:
save: 儲存
animations:
stage-transitions:
title: 是否開啟舞台動畫
@@ -507,6 +506,10 @@ pages:
title: 配色
developer:
title: 開發者
sections:
section:
use-magic-keys:
description: 測試快捷鍵
theme-presets:
presets:
- colors:
@@ -0,0 +1,22 @@
<script setup lang="ts">
defineProps<{
text: string
description?: string
}>()
</script>
<template>
<label
class="w-full flex cursor-pointer items-center justify-between rounded-lg px-4 py-3 text-sm outline-none transition-all duration-250 ease-in-out"
bg="neutral-50 dark:neutral-800"
hover="bg-neutral-200 dark:bg-neutral-700"
>
<div>
{{ $t(text) }}
<div v-if="description" text="sm neutral-500">
{{ $t(description) }}
</div>
</div>
<slot />
</label>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import Bar from './Bar.vue'
import { Button } from '../../misc'
defineProps<{
text: string
icon: string
description?: string
}>()
const emits = defineEmits<{
(e: 'click'): void
}>()
</script>
<template>
<Bar :text="text" :description="description">
<Button @click="emits('click')">
<slot />
</Button>
</Bar>
</template>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import Bar from './Bar.vue'
defineProps<{
text: string
iconOn: string
@@ -9,21 +11,11 @@ const model = defineModel<boolean>()
</script>
<template>
<label
class="w-full flex cursor-pointer items-center justify-between rounded-lg px-4 py-3 text-sm outline-none transition-all duration-250 ease-in-out"
bg="neutral-50 dark:neutral-800"
hover="bg-neutral-200 dark:bg-neutral-700"
>
<Bar :text="text" :description="description">
<input v-model="model" :aria-checked="model" type="checkbox" hidden>
<div>
{{ $t(text) }}
<div v-if="description" text="sm neutral-500">
{{ $t(description) }}
</div>
</div>
<Transition name="slide-away" mode="out-in">
<div v-if="model" :class="iconOn" transition="all ease-in-out duration-250" />
<div v-else :class="iconOff" transition="all ease-in-out duration-250" />
</Transition>
</label>
</Bar>
</template>
@@ -1 +1,3 @@
export { default as Bar } from './Bar.vue'
export { default as ButtonBar } from './ButtonBar.vue'
export { default as CheckBar } from './CheckBar.vue'