| | |
| | | <script lang="ts"> |
| | | import { onDestroy } from 'svelte' |
| | | import { Call } from '@wailsio/runtime' |
| | | import { onDestroy, onMount } from 'svelte' |
| | | import { Call, Events } from '@wailsio/runtime' |
| | | import ToggleSwitch from '../shared/ToggleSwitch.svelte' |
| | | import { t } from '../../lib/i18n' |
| | | import { autoHide, soundFeedback, copyToClipboard, startAtLogin, hotkeyVK, hotkeyMode } from '../../lib/stores/config' |
| | | import { autoHide, soundFeedback, copyToClipboard, autoPasteExperiment, startAtLogin, hotkeyVK, hotkeyMode } from '../../lib/stores/config' |
| | | import { deviceName, engineStatus, engineHardwareInfo } from '../../lib/stores/app' |
| | | import { hotkeyName } from '../../lib/stores/indicator' |
| | | |
| | |
| | | isDefault: boolean |
| | | } |
| | | |
| | | interface TextOutputResult { |
| | | ok?: boolean |
| | | needsPermission?: boolean |
| | | directInserted?: boolean |
| | | fallbackAvailable?: boolean |
| | | createdAt?: string |
| | | } |
| | | |
| | | interface ClipboardFallbackResult { |
| | | ok?: boolean |
| | | prepared?: boolean |
| | | restored?: boolean |
| | | token?: string |
| | | } |
| | | |
| | | let autoHideVal = $state(true) |
| | | let soundFeedbackVal = $state(true) |
| | | let copyToClipboardVal = $state(true) |
| | | let autoPasteExperimentVal = $state(false) |
| | | let startAtLoginVal = $state(false) |
| | | let hideDockIconVal = $state(false) |
| | | let settingsLoaded = $state(false) |
| | | let device = $state('Default') |
| | | let status = $state('loading') |
| | | let hwInfo = $state('') |
| | | let currentKeyName = $state('Ctrl') |
| | | let currentKeyName = $state('') |
| | | let hotkeyModeVal: 'tap' | 'hold' = $state('hold') |
| | | let isRecording = $state(false) |
| | | let hintText = $state('') |
| | | let devices = $state<InputDevice[]>([]) |
| | | let showDeviceDropdown = $state(false) |
| | | let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null |
| | | let textOutput = $state<TextOutputResult | null>(null) |
| | | let outputStatusText = $state('') |
| | | let outputActionText = $state('') |
| | | let fallbackToken = $state('') |
| | | let fallbackPrepared = $state(false) |
| | | let fallbackBusy = $state(false) |
| | | let lastOutputCreatedAt = $state('') |
| | | |
| | | const unsub1 = autoHide.subscribe(v => { autoHideVal = v }) |
| | | const unsub2 = startAtLogin.subscribe(v => { startAtLoginVal = v }) |
| | |
| | | const unsub7 = soundFeedback.subscribe(v => { soundFeedbackVal = v }) |
| | | const unsub8 = copyToClipboard.subscribe(v => { copyToClipboardVal = v }) |
| | | const unsub9 = hotkeyMode.subscribe(v => { hotkeyModeVal = v }) |
| | | const unsub10 = autoPasteExperiment.subscribe(v => { autoPasteExperimentVal = v }) |
| | | |
| | | onDestroy(() => { |
| | | unsub1(); unsub2(); unsub3(); unsub4(); unsub5(); unsub6(); unsub7(); unsub8(); unsub9() |
| | | unsub1(); unsub2(); unsub3(); unsub4(); unsub5(); unsub6(); unsub7(); unsub8(); unsub9(); unsub10() |
| | | clearHotkeyCapture() |
| | | }) |
| | | |
| | |
| | | copyToClipboardVal = copy |
| | | } catch {} |
| | | try { |
| | | const autoPaste: any = await Call.ByName('voicesnap/services.ConfigService.GetAutoPasteExperiment') |
| | | autoPasteExperiment.set(!!autoPaste) |
| | | autoPasteExperimentVal = !!autoPaste |
| | | } catch {} |
| | | try { |
| | | const mode: any = await Call.ByName('voicesnap/services.ConfigService.GetHotkeyMode') |
| | | const normalized = mode === 'tap' ? 'tap' : 'hold' |
| | | hotkeyMode.set(normalized) |
| | | hotkeyModeVal = normalized |
| | | } catch {} |
| | | try { |
| | | const vk: any = await Call.ByName('voicesnap/services.HotkeyService.GetCurrentHotkey') |
| | | if (typeof vk === 'number') { |
| | | hotkeyVK.set(vk) |
| | | } |
| | | const name: any = await Call.ByName('voicesnap/services.HotkeyService.GetHotkeyName') |
| | | if (typeof name === 'string') { |
| | | hotkeyName.set(name) |
| | | currentKeyName = name |
| | | } |
| | | } catch {} |
| | | try { |
| | | const hideDock: any = await Call.ByName('voicesnap/services.ConfigService.GetHideDockIcon') |
| | |
| | | settingsLoaded = true |
| | | } |
| | | loadSettings() |
| | | |
| | | onMount(() => { |
| | | const offTextOutputUpdated = Events.On('text-output:updated', () => { |
| | | void refreshTextOutput() |
| | | }) |
| | | const pollTextOutput = window.setInterval(() => { |
| | | void refreshTextOutput(true) |
| | | }, 1000) |
| | | void refreshTextOutput() |
| | | return () => { |
| | | window.clearInterval(pollTextOutput) |
| | | offTextOutputUpdated() |
| | | } |
| | | }) |
| | | |
| | | async function onAutoHideChange(checked: boolean) { |
| | | autoHide.set(checked) |
| | |
| | | copyToClipboard.set(checked) |
| | | try { |
| | | await Call.ByName('voicesnap/services.ConfigService.SetCopyToClipboard', checked) |
| | | } catch {} |
| | | } |
| | | |
| | | async function onAutoPasteExperimentChange(checked: boolean) { |
| | | autoPasteExperiment.set(checked) |
| | | try { |
| | | await Call.ByName('voicesnap/services.ConfigService.SetAutoPasteExperiment', checked) |
| | | } catch {} |
| | | } |
| | | |
| | |
| | | try { |
| | | await Call.ByName('voicesnap/services.ConfigService.SetHideDockIcon', checked) |
| | | } catch {} |
| | | } |
| | | |
| | | async function refreshTextOutput(preserveActionText = false) { |
| | | try { |
| | | const result: TextOutputResult = await Call.ByName('voicesnap/services.TextOutputService.GetLastResult') |
| | | const nextOutputCreatedAt = outputResultKey(result) |
| | | const isNewOutput = !!nextOutputCreatedAt && !!lastOutputCreatedAt && nextOutputCreatedAt !== lastOutputCreatedAt |
| | | if (isNewOutput) { |
| | | fallbackToken = '' |
| | | fallbackPrepared = false |
| | | outputActionText = '' |
| | | } |
| | | if (nextOutputCreatedAt) { |
| | | lastOutputCreatedAt = nextOutputCreatedAt |
| | | } |
| | | textOutput = result || null |
| | | outputStatusText = textOutputStatus(result) |
| | | if (!isNewOutput && !preserveActionText && !fallbackPrepared) { |
| | | outputActionText = '' |
| | | } |
| | | } catch { |
| | | textOutput = null |
| | | outputStatusText = t('settings.textOutputUnavailable') |
| | | } |
| | | } |
| | | |
| | | async function prepareClipboardFallback() { |
| | | if (fallbackPrepared) return |
| | | fallbackBusy = true |
| | | try { |
| | | const result: ClipboardFallbackResult = await Call.ByName('voicesnap/services.TextOutputService.PrepareLastClipboardFallback') |
| | | if (result?.ok && result?.prepared && result?.token) { |
| | | fallbackToken = result.token |
| | | fallbackPrepared = true |
| | | outputActionText = t('settings.textOutputFallbackPrepared') |
| | | } else { |
| | | outputActionText = t('settings.textOutputFallbackUnavailable') |
| | | } |
| | | } catch { |
| | | outputActionText = t('settings.textOutputFallbackUnavailable') |
| | | } finally { |
| | | fallbackBusy = false |
| | | void refreshTextOutput() |
| | | } |
| | | } |
| | | |
| | | async function restoreClipboardFallback() { |
| | | if (!fallbackToken) return |
| | | fallbackBusy = true |
| | | try { |
| | | const result: ClipboardFallbackResult = await Call.ByName('voicesnap/services.TextOutputService.RestoreClipboardFallback', fallbackToken) |
| | | fallbackToken = '' |
| | | fallbackPrepared = false |
| | | if (result?.ok && result?.restored) { |
| | | outputActionText = t('settings.textOutputClipboardRestored') |
| | | } else { |
| | | outputActionText = t('settings.textOutputClipboardKept') |
| | | } |
| | | } catch { |
| | | outputActionText = t('settings.textOutputClipboardRestoreFailed') |
| | | } finally { |
| | | fallbackBusy = false |
| | | void refreshTextOutput(true) |
| | | } |
| | | } |
| | | |
| | | function textOutputStatus(result: TextOutputResult | null | undefined): string { |
| | | if (!hasOutputResult(result)) { |
| | | return t('settings.textOutputNoResult') |
| | | } |
| | | if (result.ok && result.directInserted) { |
| | | return t('settings.textOutputInserted') |
| | | } |
| | | if (result.ok) { |
| | | return t('settings.textOutputCompleted') |
| | | } |
| | | if (result.needsPermission) { |
| | | return t('settings.textOutputNeedsPermission') |
| | | } |
| | | if (result.fallbackAvailable) { |
| | | return t('settings.textOutputFallbackAvailable') |
| | | } |
| | | return t('settings.textOutputFailed') |
| | | } |
| | | |
| | | function hasOutputResult(result: TextOutputResult | null | undefined): result is TextOutputResult { |
| | | if (!result) return false |
| | | if (result.ok || result.needsPermission || result.directInserted || result.fallbackAvailable) return true |
| | | return !!outputResultKey(result) |
| | | } |
| | | |
| | | function outputResultKey(result: TextOutputResult | null | undefined): string { |
| | | const createdAt = result?.createdAt || '' |
| | | if (!createdAt || createdAt.startsWith('0001-')) return '' |
| | | return createdAt |
| | | } |
| | | |
| | | async function toggleDeviceDropdown() { |
| | |
| | | e.preventDefault() |
| | | e.stopPropagation() |
| | | |
| | | if (isHotkeyCaptureCancel(e)) { |
| | | hintText = t('status.cancelled') |
| | | clearHotkeyCapture() |
| | | return |
| | | } |
| | | |
| | | const vk = mapKeyToVK(e) |
| | | if (vk > 0) { |
| | | isRecording = false |
| | |
| | | try { |
| | | await Call.ByName('voicesnap/services.HotkeyService.SetHotkey', vk) |
| | | const name: any = await Call.ByName('voicesnap/services.HotkeyService.GetKeyName', vk) |
| | | if (name) { |
| | | if (typeof name === 'string') { |
| | | hotkeyName.set(name) |
| | | currentKeyName = name |
| | | } |
| | |
| | | |
| | | activeKeyHandler = onKey |
| | | document.addEventListener('keydown', onKey) |
| | | } |
| | | |
| | | function isHotkeyCaptureCancel(e: KeyboardEvent): boolean { |
| | | return e.key === 'Escape' || e.key === 'Esc' |
| | | } |
| | | |
| | | function clearHotkeyCapture() { |
| | |
| | | <div class="hotkey-display"> |
| | | <div class="hotkey-info"> |
| | | <span class="hotkey-label">{t('home.hotkeyLabel')}</span> |
| | | <div class="hotkey-key" class:recording={isRecording}> |
| | | {isRecording ? '...' : currentKeyName} |
| | | <div class="hotkey-key" class:recording={isRecording} class:unset={!isRecording && !currentKeyName}> |
| | | {isRecording ? '...' : (currentKeyName || t('hotkeys.unset'))} |
| | | </div> |
| | | </div> |
| | | <button class="change-btn" onclick={startRecordingHotkey} disabled={isRecording}> |
| | | {t('hotkeys.change')} |
| | | {currentKeyName ? t('hotkeys.change') : t('hotkeys.set')} |
| | | </button> |
| | | </div> |
| | | |
| | | {#if !currentKeyName && !isRecording} |
| | | <p class="hint">{t('hotkeys.unsetDesc')}</p> |
| | | {/if} |
| | | |
| | | {#if hintText} |
| | | <p class="hint" class:recording={isRecording}>{hintText}</p> |
| | |
| | | |
| | | <div class="setting-row"> |
| | | <div class="setting-info"> |
| | | <span class="setting-label">{t('settings.autoPasteExperiment')}</span> |
| | | <span class="setting-desc">{t('settings.autoPasteExperimentDesc')}</span> |
| | | </div> |
| | | <ToggleSwitch checked={autoPasteExperimentVal} onchange={onAutoPasteExperimentChange} /> |
| | | </div> |
| | | |
| | | <div class="divider"></div> |
| | | |
| | | <div class="text-output-panel"> |
| | | <div class="setting-info"> |
| | | <span class="setting-label">{t('settings.textOutputTitle')}</span> |
| | | <span class="setting-desc">{t('settings.textOutputDesc')}</span> |
| | | </div> |
| | | <div class="text-output-status" class:success={!!textOutput?.ok} class:warn={!!textOutput && !textOutput.ok}> |
| | | {outputStatusText || t('settings.textOutputNoResult')} |
| | | </div> |
| | | {#if outputActionText} |
| | | <div class="fallback-message">{outputActionText}</div> |
| | | {/if} |
| | | <div class="fallback-actions"> |
| | | <button class="secondary-btn" onclick={() => refreshTextOutput()} disabled={fallbackBusy}> |
| | | {t('settings.textOutputRefresh')} |
| | | </button> |
| | | <button |
| | | class="primary-btn" |
| | | onclick={prepareClipboardFallback} |
| | | disabled={fallbackBusy || fallbackPrepared || !textOutput?.fallbackAvailable} |
| | | > |
| | | {t('settings.textOutputUseClipboard')} |
| | | </button> |
| | | {#if fallbackPrepared} |
| | | <button class="secondary-btn" onclick={restoreClipboardFallback} disabled={fallbackBusy || !fallbackToken}> |
| | | {t('settings.textOutputRestoreClipboard')} |
| | | </button> |
| | | {/if} |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="divider"></div> |
| | | |
| | | <div class="setting-row"> |
| | | <div class="setting-info"> |
| | | <span class="setting-label">{t('settings.autoHide')}</span> |
| | | <span class="setting-desc">{t('settings.autoHideDesc')}</span> |
| | | </div> |
| | |
| | | animation: pulse 1s infinite; |
| | | } |
| | | |
| | | .hotkey-key.unset { |
| | | color: var(--color-secondary-label); |
| | | font-size: var(--font-size-base); |
| | | font-weight: 500; |
| | | } |
| | | |
| | | @keyframes pulse { |
| | | 0%, 100% { opacity: 1; } |
| | | 50% { opacity: 0.5; } |
| | |
| | | color: var(--color-tertiary-label); |
| | | } |
| | | |
| | | .text-output-panel { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: var(--spacing-sm); |
| | | padding: var(--spacing-xs) 0; |
| | | } |
| | | |
| | | .text-output-status { |
| | | display: inline-flex; |
| | | align-self: flex-start; |
| | | padding: 4px 10px; |
| | | border-radius: var(--radius-sm); |
| | | background: var(--color-bg-secondary); |
| | | color: var(--color-secondary-label); |
| | | font-size: var(--font-size-sm); |
| | | font-weight: 500; |
| | | } |
| | | |
| | | .text-output-status.success { |
| | | color: var(--color-green); |
| | | } |
| | | |
| | | .text-output-status.warn { |
| | | color: var(--color-orange); |
| | | } |
| | | |
| | | .fallback-message { |
| | | color: var(--color-secondary-label); |
| | | font-size: var(--font-size-sm); |
| | | line-height: 1.4; |
| | | } |
| | | |
| | | .fallback-actions { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: var(--spacing-sm); |
| | | } |
| | | |
| | | .primary-btn, |
| | | .secondary-btn { |
| | | border: none; |
| | | border-radius: var(--radius-sm); |
| | | padding: 7px 12px; |
| | | font-size: var(--font-size-sm); |
| | | font-weight: 600; |
| | | cursor: pointer; |
| | | } |
| | | |
| | | .primary-btn { |
| | | background: var(--color-blue); |
| | | color: white; |
| | | } |
| | | |
| | | .secondary-btn { |
| | | background: var(--color-bg-secondary); |
| | | color: var(--color-label); |
| | | } |
| | | |
| | | .primary-btn:disabled, |
| | | .secondary-btn:disabled { |
| | | opacity: 0.5; |
| | | cursor: not-allowed; |
| | | } |
| | | |
| | | .segmented { |
| | | display: inline-grid; |
| | | grid-template-columns: 1fr 1fr; |