diff --git a/src/main/auto-start-service.ts b/src/main/auto-start-service.ts new file mode 100644 index 0000000..d4f4b5b --- /dev/null +++ b/src/main/auto-start-service.ts @@ -0,0 +1,40 @@ +import { app } from 'electron'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +function desktopEscape(value: string): string { + return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('`', '\\`').replaceAll('$', '\\$')}"`; +} + +export class AutoStartService { + private readonly filePath = path.join(os.homedir(), '.config', 'autostart', 'meloasr.desktop'); + + apply(enabled: boolean): void { + if (!enabled) { + fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); + fs.writeFileSync(this.filePath, [ + '[Desktop Entry]', + 'Type=Application', + 'Name=MeloASR', + 'Hidden=true', + '' + ].join('\n'), { mode: 0o644 }); + return; + } + const args = app.isPackaged ? [] : [app.getAppPath()]; + const command = [process.execPath, ...args].map(desktopEscape).join(' '); + const contents = [ + '[Desktop Entry]', + 'Type=Application', + 'Name=MeloASR', + `Exec=${command}`, + 'Terminal=false', + 'X-GNOME-Autostart-enabled=true', + 'Comment=MeloASR', + '' + ].join('\n'); + fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); + fs.writeFileSync(this.filePath, contents, { mode: 0o644 }); + } +} diff --git a/src/main/index.ts b/src/main/index.ts new file mode 100644 index 0000000..b4280fb --- /dev/null +++ b/src/main/index.ts @@ -0,0 +1,177 @@ +import { app, ipcMain } from 'electron'; +import { AutoStartService } from './auto-start-service'; +import { BackendManager } from './backend-manager'; +import { FcitxBridge } from './fcitx-bridge'; +import { OverlayController } from './overlay-controller'; +import { SessionController } from './session-controller'; +import { SettingsStore } from './settings-store'; +import { SettingsWindow } from './settings-window'; +import { TrayController } from './tray-controller'; +import type { BackendStatusPayload, Point } from '../shared/contracts'; + +app.setName('MeloASR'); +const singleInstance = app.requestSingleInstanceLock(); + +if (!singleInstance) { + app.quit(); +} else { + void app.whenReady().then(bootstrap).catch((error) => { + console.error('[MeloASR 启动失败]', error); + app.quit(); + }); +} + +async function bootstrap(): Promise { + const settings = new SettingsStore(); + const currentSettings = settings.load(); + const autoStart = new AutoStartService(); + try { autoStart.apply(currentSettings.autoStart); } + catch (error) { console.error('[开机自启动设置失败]', error); } + + const bridge = new FcitxBridge(); + bridge.configure(currentSettings.shortcut); + const backends = new BackendManager(settings); + const overlay = new OverlayController(settings); + await overlay.create(); + const sessions = new SessionController(backends, bridge, overlay); + const repositoryUrl = process.env.MELOASR_REPOSITORY_URL?.trim() || null; + const settingsWindow = new SettingsWindow(settings, backends, overlay, sessions, repositoryUrl); + const settingsWindowReady = settingsWindow.create(); + registerIpc({ settings, autoStart, bridge, backends, overlay, sessions, settingsWindow }); + await settingsWindowReady; + const tray = new TrayController( + backends, + settingsWindow, + () => { app.relaunch(); app.exit(0); }, + () => app.quit() + ); + tray.create(); + + bridge.on('request-start', () => void sessions.start()); + bridge.on('request-stop', () => sessions.stop()); + bridge.on('request-cancel', () => sessions.cancel()); + bridge.on('disconnect', () => sessions.cancel('Fcitx5 插件连接已断开')); + bridge.on('connect', () => settingsWindow.publish()); + bridge.on('protocol-error', (error) => console.error('[Fcitx5 协议错误]', error)); + bridge.on('client-error', (error) => console.error('[Fcitx5 客户端错误]', error)); + bridge.on('server-error', (error) => console.error('[Fcitx5 Socket 错误]', error)); + backends.on('status-changed', () => { tray.refresh(); settingsWindow.publish(); }); + backends.on('active-changed', () => { tray.refresh(); settingsWindow.publish(); }); + sessions.on('state-changed', () => settingsWindow.publish()); + settings.on('changed', () => settingsWindow.publish()); + + await bridge.start(); + bridge.configure(settings.get().shortcut); + await backends.ensure(settings.get().backendId); + tray.refresh(); + + app.on('second-instance', () => settingsWindow.show()); + app.on('before-quit', () => { (app as typeof app & { isQuitting?: boolean }).isQuitting = true; }); + app.on('will-quit', () => { + tray.destroy(); + void bridge.stop(); + }); +} + +interface Services { + settings: SettingsStore; + autoStart: AutoStartService; + bridge: FcitxBridge; + backends: BackendManager; + overlay: OverlayController; + sessions: SessionController; + settingsWindow: SettingsWindow; +} + +function registerIpc(services: Services): void { + const { settings, autoStart, bridge, backends, overlay, sessions, settingsWindow } = services; + const settingsSender = (senderId: number): boolean => + Boolean(settingsWindow.window && !settingsWindow.window.isDestroyed() && settingsWindow.window.webContents.id === senderId); + const overlaySender = (senderId: number): boolean => + Boolean(overlay.window && !overlay.window.isDestroyed() && overlay.window.webContents.id === senderId); + + ipcMain.handle('settings:get-state', (event) => { + if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的设置请求'); + return settingsWindow.state(); + }); + ipcMain.handle('settings:set-backend', async (event, id: unknown) => { + if (!settingsSender(event.sender.id) || typeof id !== 'string') throw new Error('无效的后端设置'); + if (sessions.state !== 'idle') throw new Error('请先结束当前语音输入'); + await backends.select(id); + return settingsWindow.state(); + }); + ipcMain.handle('settings:open-debug', async (event) => { + if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的窗口请求'); + await backends.showDebug(); + }); + ipcMain.handle('settings:set-position', (event, point: Point, displayId: string) => { + if (!settingsSender(event.sender.id) || !isPoint(point)) throw new Error('无效的悬浮球坐标'); + overlay.applyPosition(point, displayId); + return settingsWindow.state(); + }); + ipcMain.handle('settings:set-preset', (event, preset: unknown, displayId: string) => { + if (!settingsSender(event.sender.id) || typeof preset !== 'string') throw new Error('无效的位置预设'); + overlay.applyPreset(preset, displayId); + return settingsWindow.state(); + }); + ipcMain.handle('settings:preview-overlay', (event) => { + if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的预览请求'); + overlay.preview(); + }); + ipcMain.handle('settings:set-shortcut', (event, shortcut: unknown) => { + if (!settingsSender(event.sender.id) || typeof shortcut !== 'string' || !validShortcut(shortcut)) { + throw new Error('快捷键必须包含修饰键和一个主键'); + } + settings.update({ shortcut }); + bridge.configure(shortcut); + return settingsWindow.state(); + }); + ipcMain.handle('settings:set-auto-start', (event, enabled: unknown) => { + if (!settingsSender(event.sender.id) || typeof enabled !== 'boolean') throw new Error('无效的自启动设置'); + autoStart.apply(enabled); + settings.update({ autoStart: enabled }); + return settingsWindow.state(); + }); + ipcMain.handle('settings:set-overlay-persistent', (event, enabled: unknown) => { + if (!settingsSender(event.sender.id) || typeof enabled !== 'boolean') throw new Error('无效的悬浮球设置'); + settings.update({ overlayPersistent: enabled }); + overlay.reconcilePersistent(); + return settingsWindow.state(); + }); + ipcMain.handle('settings:open-repository', async (event) => { + if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的外部链接请求'); + await settingsWindow.openRepository(); + }); + ipcMain.on('overlay:toggle-recording', (event) => { + if (overlaySender(event.sender.id)) sessions.requestStartFromOverlay(); + }); + ipcMain.on('backend-status', (event, payload: BackendStatusPayload) => backends.handleStatus(event, payload)); + ipcMain.on('backend-transcript', (event, payload: { backend?: string; text?: string }) => { + if (typeof payload?.backend === 'string' && typeof payload.text === 'string' && backends.senderIsActive(event, payload.backend)) { + sessions.update(payload.text); + } + }); + ipcMain.on('backend-session-ended', (event, payload: { backend?: string; text?: string }) => { + if (typeof payload?.backend === 'string' && typeof payload.text === 'string' && backends.senderIsActive(event, payload.backend)) { + sessions.finish(payload.text); + } + }); + ipcMain.on('backend-start-error', (event, payload: { backend?: string; message?: string }) => { + if (typeof payload?.backend === 'string' && backends.senderIsActive(event, payload.backend)) { + sessions.backendError(payload.message || '后端启动失败'); + } + }); +} + +function isPoint(value: unknown): value is Point { + return typeof value === 'object' && value !== null && + Number.isFinite((value as Point).x) && Number.isFinite((value as Point).y); +} + +function validShortcut(value: string): boolean { + const parts = value.split('+').filter(Boolean); + return parts.length >= 2 && parts.some((part) => ['Control', 'Alt', 'Shift', 'Super'].includes(part)) && + !['Control', 'Alt', 'Shift', 'Super'].includes(parts.at(-1)!); +} + +app.on('window-all-closed', () => { /* 托盘进程继续常驻。 */ }); diff --git a/src/main/overlay-controller.ts b/src/main/overlay-controller.ts new file mode 100644 index 0000000..d7a9e5b --- /dev/null +++ b/src/main/overlay-controller.ts @@ -0,0 +1,97 @@ +import { BrowserWindow, screen } from 'electron'; +import path from 'node:path'; +import type { Point, SessionState } from '../shared/contracts'; +import { clampPosition, presetPosition } from './overlay-position'; +import type { SettingsStore } from './settings-store'; + +const SIZE = { width: 88, height: 88 }; + +export class OverlayController { + window: BrowserWindow | null = null; + private hideTimer: NodeJS.Timeout | null = null; + + constructor(private readonly settings: SettingsStore) {} + + async create(): Promise { + this.window = new BrowserWindow({ + ...SIZE, + show: false, + frame: false, + transparent: true, + resizable: false, + movable: false, + alwaysOnTop: true, + focusable: false, + skipTaskbar: true, + hasShadow: false, + webPreferences: { + preload: path.join(__dirname, '..', 'preload', 'overlay.js'), + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + backgroundThrottling: false + } + }); + this.window.setAlwaysOnTop(true, 'floating'); + await this.window.loadFile(path.join(__dirname, '..', 'renderer', 'overlay.html')); + this.position(); + this.reconcilePersistent(); + } + + position(point = this.settings.get().overlayPosition): Point { + const initial = point ?? presetPosition(screen.getPrimaryDisplay().workArea, SIZE, 'bottom-center'); + const display = screen.getDisplayNearestPoint(initial); + const result = clampPosition(initial, display.workArea, SIZE); + this.window?.setPosition(result.x, result.y, false); + if (!point) this.settings.update({ overlayPosition: result }); + return result; + } + + applyPosition(point: Point, displayId?: string): Point { + const display = screen.getAllDisplays().find((item) => String(item.id) === String(displayId)) ?? + screen.getDisplayNearestPoint(point); + const result = clampPosition(point, display.workArea, SIZE); + this.settings.update({ overlayPosition: result }); + this.window?.setPosition(result.x, result.y, false); + return result; + } + + applyPreset(preset: string, displayId?: string): Point { + const display = screen.getAllDisplays().find((item) => String(item.id) === String(displayId)) ?? + screen.getPrimaryDisplay(); + return this.applyPosition(presetPosition(display.workArea, SIZE, preset), String(display.id)); + } + + showState(state: SessionState | 'done' | 'error'): void { + if (!this.window || this.window.isDestroyed()) return; + this.clearHideTimer(); + this.window.webContents.send('overlay-state', { state }); + if (!this.window.isVisible()) this.window.showInactive(); + } + + hideLater(delay = 900): void { + this.clearHideTimer(); + if (this.settings.get().overlayPersistent) return; + this.hideTimer = setTimeout(() => this.window?.hide(), delay); + } + + preview(): void { + this.position(); + this.showState('recording'); + this.hideLater(1800); + } + + reconcilePersistent(): void { + if (!this.window || this.window.isDestroyed()) return; + if (this.settings.get().overlayPersistent) { + this.showState('idle'); + } else { + this.window.hide(); + } + } + + private clearHideTimer(): void { + if (this.hideTimer) clearTimeout(this.hideTimer); + this.hideTimer = null; + } +} diff --git a/src/main/overlay-position.ts b/src/main/overlay-position.ts new file mode 100644 index 0000000..ae79721 --- /dev/null +++ b/src/main/overlay-position.ts @@ -0,0 +1,29 @@ +import type { Rectangle } from 'electron'; +import type { Point } from '../shared/contracts'; + +export const PRESETS = new Set([ + 'top-left', 'top-center', 'top-right', + 'middle-left', 'middle-center', 'middle-right', + 'bottom-left', 'bottom-center', 'bottom-right' +]); + +export function presetPosition(workArea: Rectangle, size: { width: number; height: number }, preset: string, margin = 32): Point { + if (!PRESETS.has(preset)) throw new Error('未知的悬浮球位置预设'); + const [vertical, horizontal] = preset.split('-'); + const x = horizontal === 'left' ? workArea.x + margin + : horizontal === 'right' ? workArea.x + workArea.width - size.width - margin + : workArea.x + (workArea.width - size.width) / 2; + const y = vertical === 'top' ? workArea.y + margin + : vertical === 'bottom' ? workArea.y + workArea.height - size.height - margin + : workArea.y + (workArea.height - size.height) / 2; + return { x: Math.round(x), y: Math.round(y) }; +} + +export function clampPosition(position: Point, workArea: Rectangle, size: { width: number; height: number }): Point { + const maxX = workArea.x + Math.max(0, workArea.width - size.width); + const maxY = workArea.y + Math.max(0, workArea.height - size.height); + return { + x: Math.round(Math.min(Math.max(position.x, workArea.x), maxX)), + y: Math.round(Math.min(Math.max(position.y, workArea.y), maxY)) + }; +} diff --git a/src/main/settings-window.ts b/src/main/settings-window.ts new file mode 100644 index 0000000..7257794 --- /dev/null +++ b/src/main/settings-window.ts @@ -0,0 +1,81 @@ +import { app, BrowserWindow, screen, shell } from 'electron'; +import path from 'node:path'; +import type { SettingsViewState } from '../shared/contracts'; +import type { BackendManager } from './backend-manager'; +import type { OverlayController } from './overlay-controller'; +import type { SessionController } from './session-controller'; +import type { SettingsStore } from './settings-store'; + +export class SettingsWindow { + window: BrowserWindow | null = null; + + constructor( + private readonly settings: SettingsStore, + private readonly backends: BackendManager, + private readonly overlay: OverlayController, + private readonly sessions: SessionController, + private readonly repositoryUrl: string | null + ) {} + + async create(): Promise { + this.window = new BrowserWindow({ + width: 720, + height: 820, + minWidth: 640, + minHeight: 680, + show: false, + title: 'MeloASR 设置', + autoHideMenuBar: true, + icon: path.join(app.getAppPath(), 'dist', 'assets', 'logo.png'), + webPreferences: { + preload: path.join(__dirname, '..', 'preload', 'settings.js'), + nodeIntegration: false, + contextIsolation: true, + sandbox: true + } + }); + this.window.on('close', (event) => { + if ((app as typeof app & { isQuitting?: boolean }).isQuitting) return; + event.preventDefault(); + this.window?.hide(); + }); + await this.window.loadFile(path.join(__dirname, '..', 'renderer', 'settings.html')); + } + + show(): void { + this.window?.show(); + this.window?.focus(); + this.publish(); + } + + publish(): void { + if (!this.window || this.window.isDestroyed()) return; + this.window.webContents.send('settings:state', this.state()); + } + + state(): SettingsViewState { + const current = this.settings.get(); + const position = this.overlay.position(current.overlayPosition); + return { + backendId: current.backendId, + backends: this.backends.definitions().map(({ id, label }) => ({ id, label, status: this.backends.status(id) })), + sessionState: this.sessions.state, + shortcut: current.shortcut, + autoStart: current.autoStart, + overlayPersistent: current.overlayPersistent, + overlayPosition: position, + displays: screen.getAllDisplays().map((display, index) => ({ + id: String(display.id), + label: `屏幕 ${index + 1}(${display.workArea.width} × ${display.workArea.height})`, + workArea: display.workArea + })), + version: app.getVersion(), + repositoryUrl: this.repositoryUrl + }; + } + + async openRepository(): Promise { + if (!this.repositoryUrl) throw new Error('尚未配置项目仓库地址'); + await shell.openExternal(this.repositoryUrl); + } +} diff --git a/src/main/tray-controller.ts b/src/main/tray-controller.ts new file mode 100644 index 0000000..e78ed8e --- /dev/null +++ b/src/main/tray-controller.ts @@ -0,0 +1,61 @@ +import { Menu, Tray, nativeImage } from 'electron'; +import type { BackendManager } from './backend-manager'; +import type { SettingsWindow } from './settings-window'; + +type TrayState = 'loading' | 'ready' | 'login-required' | 'error'; + +const COLORS: Record = { + loading: '#e6a23c', + ready: '#22b573', + 'login-required': '#ef6c57', + error: '#d9465f' +}; + +function trayImage(state: TrayState): Electron.NativeImage { + const color = COLORS[state]; + const svg = ` + + + `; + return nativeImage.createFromDataURL(`data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`).resize({ width: 22, height: 22 }); +} + +export class TrayController { + private tray: Tray | null = null; + + constructor( + private readonly backends: BackendManager, + private readonly settingsWindow: SettingsWindow, + private readonly onRestart: () => void, + private readonly onQuit: () => void + ) {} + + create(): void { + this.tray = new Tray(trayImage('loading')); + this.tray.setToolTip('MeloASR · 正在加载'); + this.tray.on('click', () => this.settingsWindow.show()); + this.refresh(); + } + + refresh(): void { + if (!this.tray) return; + const status = this.backends.status(this.backends.activeDefinition().id); + const state: TrayState = status.ready ? 'ready' + : status.login === 'logged-out' ? 'login-required' + : status.detail.startsWith('加载失败') ? 'error' : 'loading'; + this.tray.setImage(trayImage(state)); + this.tray.setToolTip(`MeloASR · ${status.detail}`); + this.tray.setContextMenu(Menu.buildFromTemplate([ + { label: '设置', click: () => this.settingsWindow.show() }, + { label: '打开登录/调试页面', click: () => void this.backends.showDebug() }, + { type: 'separator' }, + { label: '重启', click: this.onRestart }, + { label: '退出', click: this.onQuit } + ])); + } + + destroy(): void { + this.tray?.destroy(); + this.tray = null; + } +} diff --git a/src/preload/overlay.ts b/src/preload/overlay.ts new file mode 100644 index 0000000..b8759da --- /dev/null +++ b/src/preload/overlay.ts @@ -0,0 +1,8 @@ +import { contextBridge, ipcRenderer } from 'electron'; + +contextBridge.exposeInMainWorld('meloOverlay', { + toggleRecording: (): void => ipcRenderer.send('overlay:toggle-recording'), + onState: (listener: (state: string) => void): void => { + ipcRenderer.on('overlay-state', (_event, payload: { state: string }) => listener(payload.state)); + } +}); diff --git a/src/preload/settings.ts b/src/preload/settings.ts new file mode 100644 index 0000000..29e5d9f --- /dev/null +++ b/src/preload/settings.ts @@ -0,0 +1,21 @@ +import { contextBridge, ipcRenderer } from 'electron'; +import type { Point, SettingsViewState } from '../shared/contracts'; + +contextBridge.exposeInMainWorld('meloSettings', { + getState: (): Promise => ipcRenderer.invoke('settings:get-state'), + setBackend: (id: string): Promise => ipcRenderer.invoke('settings:set-backend', id), + openDebug: (): Promise => ipcRenderer.invoke('settings:open-debug'), + setPosition: (position: Point, displayId: string): Promise => + ipcRenderer.invoke('settings:set-position', position, displayId), + setPreset: (preset: string, displayId: string): Promise => + ipcRenderer.invoke('settings:set-preset', preset, displayId), + previewOverlay: (): Promise => ipcRenderer.invoke('settings:preview-overlay'), + setShortcut: (shortcut: string): Promise => ipcRenderer.invoke('settings:set-shortcut', shortcut), + setAutoStart: (enabled: boolean): Promise => ipcRenderer.invoke('settings:set-auto-start', enabled), + setOverlayPersistent: (enabled: boolean): Promise => + ipcRenderer.invoke('settings:set-overlay-persistent', enabled), + openRepository: (): Promise => ipcRenderer.invoke('settings:open-repository'), + onState: (listener: (state: SettingsViewState) => void): void => { + ipcRenderer.on('settings:state', (_event, state: SettingsViewState) => listener(state)); + } +}); diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts new file mode 100644 index 0000000..93241ea --- /dev/null +++ b/src/renderer/global.d.ts @@ -0,0 +1,25 @@ +import type { Point, SettingsViewState } from '../shared/contracts'; + +declare global { + interface Window { + meloSettings: { + getState(): Promise; + setBackend(id: string): Promise; + openDebug(): Promise; + setPosition(position: Point, displayId: string): Promise; + setPreset(preset: string, displayId: string): Promise; + previewOverlay(): Promise; + setShortcut(shortcut: string): Promise; + setAutoStart(enabled: boolean): Promise; + setOverlayPersistent(enabled: boolean): Promise; + openRepository(): Promise; + onState(listener: (state: SettingsViewState) => void): void; + }; + meloOverlay: { + toggleRecording(): void; + onState(listener: (state: string) => void): void; + }; + } +} + +export {}; diff --git a/src/renderer/overlay.css b/src/renderer/overlay.css new file mode 100644 index 0000000..703e463 --- /dev/null +++ b/src/renderer/overlay.css @@ -0,0 +1,17 @@ +html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: transparent; } +body { display: grid; place-items: center; } +.voice-overlay { position: relative; display: grid; place-items: center; width: 68px; height: 68px; padding: 0; border: 0; border-radius: 50%; background: radial-gradient(circle at 35% 28%, #71aaff, #2568de 70%); box-shadow: 0 8px 24px rgba(23,79,177,.35); cursor: pointer; } +.ring { position: absolute; inset: 0; border: 2px solid rgba(69,136,245,.45); border-radius: 50%; opacity: 0; } +.voice-overlay[data-state="recording"] .ring { animation: ripple 1.7s ease-out infinite; } +.voice-overlay[data-state="recording"] .ring.two { animation-delay: .55s; } +.bars { display: flex; align-items: center; gap: 3px; height: 24px; } +.bars i { display: block; width: 4px; height: 10px; border-radius: 3px; background: white; } +.voice-overlay[data-state="recording"] .bars i { animation: wave .75s ease-in-out infinite alternate; } +.bars i:nth-child(2) { height: 20px; animation-delay: .12s !important; } +.bars i:nth-child(3) { height: 15px; animation-delay: .24s !important; } +.bars i:nth-child(4) { animation-delay: .36s !important; } +.voice-overlay[data-state="stopping"] { background: radial-gradient(circle at 35% 28%, #ffd087, #e68a22 70%); } +.voice-overlay[data-state="done"] { background: radial-gradient(circle at 35% 28%, #83ebbd, #22aa72 70%); } +.voice-overlay[data-state="error"] { background: radial-gradient(circle at 35% 28%, #ff9b9b, #dc3f52 70%); } +@keyframes ripple { from { transform: scale(.9); opacity: .7; } to { transform: scale(1.28); opacity: 0; } } +@keyframes wave { to { transform: scaleY(.45); } } diff --git a/src/renderer/overlay.html b/src/renderer/overlay.html new file mode 100644 index 0000000..4479504 --- /dev/null +++ b/src/renderer/overlay.html @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/src/renderer/overlay.ts b/src/renderer/overlay.ts new file mode 100644 index 0000000..f36739f --- /dev/null +++ b/src/renderer/overlay.ts @@ -0,0 +1,7 @@ +const root = document.querySelector('.voice-overlay')!; + +root.addEventListener('click', () => window.meloOverlay.toggleRecording()); +root.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') window.meloOverlay.toggleRecording(); +}); +window.meloOverlay.onState((state) => { root.dataset.state = state; }); diff --git a/src/renderer/settings.css b/src/renderer/settings.css new file mode 100644 index 0000000..84e88f1 --- /dev/null +++ b/src/renderer/settings.css @@ -0,0 +1,34 @@ +:root { color-scheme: light; font: 14px/1.5 system-ui, "Microsoft YaHei", sans-serif; color: #182033; } +* { box-sizing: border-box; } +body { margin: 0; background: #f4f6fa; } +header { display: flex; align-items: center; gap: 12px; padding: 22px 28px 12px; } +header img { width: 44px; height: 44px; border-radius: 11px; } +header h1 { margin: 0; font-size: 22px; } +header span { color: #788398; } +main { display: grid; gap: 14px; padding: 0 28px 30px; } +section { padding: 18px; background: white; border: 1px solid #e0e5ee; border-radius: 13px; box-shadow: 0 5px 20px rgba(33,48,77,.05); } +h2 { margin: 0 0 14px; font-size: 16px; } +.row { display: flex; align-items: center; gap: 12px; min-height: 40px; } +.row > label, .row > span:first-child { min-width: 80px; color: #5f6b80; } +select, input, button { height: 38px; border-radius: 8px; font: inherit; } +select, input { padding: 0 10px; border: 1px solid #cad3e1; background: white; } +select { flex: 1; } +button { padding: 0 14px; border: 0; background: #e9eef7; color: #26334a; cursor: pointer; font-weight: 600; } +button.primary { background: #286df0; color: white; } +button:disabled { opacity: .55; cursor: default; } +.status { padding: 4px 9px; border-radius: 99px; font-size: 12px; background: #edf0f5; } +.status[data-state="ready"] { color: #087b4d; background: #ddf6eb; } +.status[data-state="warning"] { color: #a84332; background: #fde8e4; } +.warning { margin: 8px 0 12px; padding: 9px 11px; border-radius: 8px; color: #a84332; background: #fff0ed; } +.switch { display: flex; align-items: center; gap: 10px; min-height: 40px; } +.switch input { width: 17px; height: 17px; } +.screen { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 12px 0; padding: 15px; aspect-ratio: 16/5; border: 2px solid #b9c4d6; border-radius: 10px; background: #f7f9fc; } +.screen button { align-self: center; justify-self: center; width: 24px; height: 24px; padding: 0; border: 3px solid #3476ed; border-radius: 50%; background: #76a4f8; } +.coordinates { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.coordinates label { display: grid; grid-template-columns: 18px 1fr; align-items: center; color: #5f6b80; } +.coordinates input { width: 100%; } +.buttons { display: flex; gap: 10px; margin-top: 12px; } +.hint { margin-top: 7px; color: #7b8698; font-size: 12px; } +.shortcut { margin-left: auto; min-width: 180px; } +.link { margin-left: auto; color: #286df0; background: transparent; } +#message { min-height: 21px; color: #55708f; } diff --git a/src/renderer/settings.html b/src/renderer/settings.html new file mode 100644 index 0000000..b5a907a --- /dev/null +++ b/src/renderer/settings.html @@ -0,0 +1,51 @@ + + + + + + MeloASR 设置 + + + +

MeloASR

设置
+
+
+

语音后端

+
+ + +
+ +
+

悬浮球

+ +
+
+ + + +
+
+ + +
+
方向键微调 1 像素,Shift + 方向键微调 10 像素
+
+
+ +
+

常规

+
按住说话快捷键
+ +
+ +
+

关于

+
版本
+
项目仓库
+
+
+
+ + + diff --git a/src/renderer/settings.ts b/src/renderer/settings.ts new file mode 100644 index 0000000..fab87a7 --- /dev/null +++ b/src/renderer/settings.ts @@ -0,0 +1,148 @@ +interface BackendRuntimeStatus { + ready: boolean; + login: 'unknown' | 'logged-in' | 'logged-out'; + detail: string; +} + +interface SettingsViewState { + backendId: string; + backends: Array<{ id: string; label: string; status: BackendRuntimeStatus }>; + sessionState: 'idle' | 'starting' | 'recording' | 'stopping'; + shortcut: string; + autoStart: boolean; + overlayPersistent: boolean; + overlayPosition: { x: number; y: number }; + displays: Array<{ id: string; label: string; workArea: { x: number; y: number; width: number; height: number } }>; + version: string; + repositoryUrl: string | null; +} + +const backend = document.querySelector('#backend')!; +const backendStatus = document.querySelector('#backend-status')!; +const loginWarning = document.querySelector('#login-warning')!; +const display = document.querySelector('#display')!; +const x = document.querySelector('#position-x')!; +const y = document.querySelector('#position-y')!; +const shortcut = document.querySelector('#shortcut')!; +const autoStart = document.querySelector('#auto-start')!; +const overlayPersistent = document.querySelector('#overlay-persistent')!; +const version = document.querySelector('#version')!; +const repository = document.querySelector('#repository')!; +const message = document.querySelector('#message')!; +let state: SettingsViewState; +let capturingShortcut = false; + +function statusText(status: BackendRuntimeStatus): string { + if (status.ready) return '已就绪'; + if (status.login === 'logged-out') return '需要登录'; + return status.detail || '正在加载'; +} + +function render(next: SettingsViewState): void { + state = next; + backend.replaceChildren(...next.backends.map((item) => new Option(item.label, item.id))); + backend.value = next.backendId; + const active = next.backends.find((item) => item.id === next.backendId)!; + backendStatus.textContent = statusText(active.status); + backendStatus.dataset.state = active.status.ready ? 'ready' + : active.status.login === 'logged-out' ? 'warning' : 'loading'; + loginWarning.hidden = active.status.login !== 'logged-out'; + + const previousDisplay = display.value; + display.replaceChildren(...next.displays.map((item) => new Option(item.label, item.id))); + const containing = next.displays.find((item) => { + const area = item.workArea; + return next.overlayPosition.x >= area.x && next.overlayPosition.x < area.x + area.width && + next.overlayPosition.y >= area.y && next.overlayPosition.y < area.y + area.height; + }); + display.value = next.displays.some((item) => item.id === previousDisplay) + ? previousDisplay : (containing?.id ?? next.displays[0]?.id ?? ''); + x.value = String(next.overlayPosition.x); + y.value = String(next.overlayPosition.y); + shortcut.textContent = capturingShortcut ? '请按下新快捷键…' : displayShortcut(next.shortcut); + autoStart.checked = next.autoStart; + overlayPersistent.checked = next.overlayPersistent; + version.textContent = next.version; + repository.textContent = next.repositoryUrl ?? '尚未配置'; + repository.disabled = !next.repositoryUrl; +} + +function displayShortcut(value: string): string { + return value.replaceAll('Control', 'Ctrl').replaceAll('Super', 'Meta').replace(/\+space$/i, '+Space'); +} + +function formatShortcut(event: KeyboardEvent): string | null { + const modifiers = [ + event.ctrlKey ? 'Control' : '', + event.altKey ? 'Alt' : '', + event.shiftKey ? 'Shift' : '', + event.metaKey ? 'Super' : '' + ].filter(Boolean); + if (['Control', 'Alt', 'Shift', 'Meta'].includes(event.key) || modifiers.length === 0) return null; + const key = event.code === 'Space' ? 'space' + : event.code.startsWith('Key') ? event.code.slice(3).toLowerCase() + : event.code.startsWith('Digit') ? event.code.slice(5) + : event.key.length === 1 ? event.key.toLowerCase() : event.key; + return [...modifiers, key].join('+'); +} + +async function apply(action: () => Promise, confirmation = '已保存'): Promise { + try { + render(await action()); + message.textContent = confirmation; + } catch (error) { + message.textContent = (error as Error).message; + } +} + +async function applyPosition(): Promise { + await apply(() => window.meloSettings.setPosition({ x: Number(x.value), y: Number(y.value) }, display.value)); +} + +backend.addEventListener('change', () => void apply(() => window.meloSettings.setBackend(backend.value))); +document.querySelector('#open-debug')!.addEventListener('click', () => void window.meloSettings.openDebug()); +document.querySelectorAll('[data-preset]').forEach((button) => { + button.addEventListener('click', () => void apply( + () => window.meloSettings.setPreset(button.dataset.preset!, display.value), '位置已保存' + )); +}); +document.querySelector('#apply-position')!.addEventListener('click', () => void applyPosition()); +document.querySelector('#preview-overlay')!.addEventListener('click', () => void window.meloSettings.previewOverlay()); + +document.addEventListener('keydown', (event) => { + if (capturingShortcut) { + event.preventDefault(); + if (event.key === 'Escape') { + capturingShortcut = false; + render(state); + return; + } + const value = formatShortcut(event); + if (!value) return; + capturingShortcut = false; + void apply(() => window.meloSettings.setShortcut(value), '快捷键已保存'); + return; + } + if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return; + if ((event.target as HTMLElement).matches('select, button, input[type="checkbox"]')) return; + event.preventDefault(); + const step = event.shiftKey ? 10 : 1; + if (event.key === 'ArrowLeft') x.value = String(Number(x.value) - step); + if (event.key === 'ArrowRight') x.value = String(Number(x.value) + step); + if (event.key === 'ArrowUp') y.value = String(Number(y.value) - step); + if (event.key === 'ArrowDown') y.value = String(Number(y.value) + step); + void applyPosition(); +}); + +shortcut.addEventListener('click', () => { + capturingShortcut = true; + shortcut.textContent = '请按下新快捷键…'; + shortcut.focus(); +}); +autoStart.addEventListener('change', () => void apply(() => window.meloSettings.setAutoStart(autoStart.checked))); +overlayPersistent.addEventListener('change', () => void apply( + () => window.meloSettings.setOverlayPersistent(overlayPersistent.checked) +)); +repository.addEventListener('click', () => void window.meloSettings.openRepository()); +window.meloSettings.onState(render); +void window.meloSettings.getState().then(render).catch((error: Error) => { message.textContent = error.message; }); diff --git a/test/overlay-position.test.ts b/test/overlay-position.test.ts new file mode 100644 index 0000000..4e03de0 --- /dev/null +++ b/test/overlay-position.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { clampPosition, presetPosition } from '../src/main/overlay-position'; + +const workArea = { x: 100, y: 50, width: 1200, height: 800 }; +const size = { width: 88, height: 88 }; + +test('九宫格预设基于屏幕工作区计算', () => { + assert.deepEqual(presetPosition(workArea, size, 'top-left'), { x: 132, y: 82 }); + assert.deepEqual(presetPosition(workArea, size, 'middle-center'), { x: 656, y: 406 }); + assert.deepEqual(presetPosition(workArea, size, 'bottom-right'), { x: 1180, y: 730 }); +}); + +test('精确坐标被限制在工作区内', () => { + assert.deepEqual(clampPosition({ x: -500, y: 2000 }, workArea, size), { x: 100, y: 762 }); +});