| | |
| | | <script lang="ts"> |
| | | import { Call } from '@wailsio/runtime' |
| | | 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 { t, setLocale } from '../../lib/i18n' |
| | | import { autoHide, soundFeedback, copyToClipboard, startAtLogin, hotkeyVK, hotkeyMode, languageMode, languageID, effectiveLanguageID, uiLocale } from '../../lib/stores/config' |
| | | import { deviceName, engineStatus, engineHardwareInfo } from '../../lib/stores/app' |
| | | import { hotkeyName } from '../../lib/stores/indicator' |
| | | |
| | | interface InputDevice { |
| | | name: string |
| | | isDefault: boolean |
| | | } |
| | | |
| | | interface ModelOption { |
| | | modelID: string |
| | | displayName: string |
| | | tier: string |
| | | installed: boolean |
| | | isCurrent: boolean |
| | | isDefault: boolean |
| | | downloadSize?: string |
| | | } |
| | | |
| | | let autoHideVal = $state(true) |
| | |
| | | let hwInfo = $state('') |
| | | let currentKeyName = $state('Ctrl') |
| | | let hotkeyModeVal: 'tap' | 'hold' = $state('hold') |
| | | let languageModeVal: 'auto' | 'manual' = $state('auto') |
| | | let languageIDVal: 'zh-CN' | 'en' = $state('zh-CN') |
| | | let effectiveLanguageIDVal: 'zh-CN' | 'en' = $state('zh-CN') |
| | | let isRecording = $state(false) |
| | | let hintText = $state('') |
| | | let devices = $state<InputDevice[]>([]) |
| | | let showDeviceDropdown = $state(false) |
| | | let modelOptions = $state<ModelOption[]>([]) |
| | | let modelBusyID = $state('') |
| | | let modelProgress = $state(0) |
| | | let modelError = $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 = languageMode.subscribe(v => { languageModeVal = v }) |
| | | const unsub11 = languageID.subscribe(v => { languageIDVal = v }) |
| | | const unsub12 = effectiveLanguageID.subscribe(v => { effectiveLanguageIDVal = v }) |
| | | |
| | | Events.On('model:download-progress', (ev: any) => { |
| | | const data = ev?.data |
| | | if (data?.modelID && data.modelID === modelBusyID && typeof data.percent === 'number') { |
| | | modelProgress = data.percent |
| | | } |
| | | }) |
| | | |
| | | // Load actual device name from backend on mount |
| | | async function loadDeviceName() { |
| | |
| | | const hideDock: any = await Call.ByName('voicesnap/services.ConfigService.GetHideDockIcon') |
| | | hideDockIconVal = !!hideDock |
| | | } catch {} |
| | | try { |
| | | const lang: any = await Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings') |
| | | applyLanguageSettings(lang) |
| | | } catch {} |
| | | await loadModelOptions() |
| | | settingsLoaded = true |
| | | } |
| | | loadSettings() |
| | | |
| | | function applyLanguageSettings(settings: any) { |
| | | const mode = settings?.languageMode === 'manual' ? 'manual' : 'auto' |
| | | const configured = settings?.languageID === 'en' ? 'en' : 'zh-CN' |
| | | const effective = settings?.effectiveLanguageID === 'en' ? 'en' : 'zh-CN' |
| | | const locale = settings?.uiLocale === 'en' ? 'en' : 'zh' |
| | | languageMode.set(mode) |
| | | languageID.set(configured) |
| | | effectiveLanguageID.set(effective) |
| | | uiLocale.set(locale) |
| | | languageModeVal = mode |
| | | languageIDVal = configured |
| | | effectiveLanguageIDVal = effective |
| | | setLocale(locale) |
| | | } |
| | | |
| | | async function syncEngineForCurrentModel() { |
| | | try { |
| | | const current: any = await Call.ByName('voicesnap/services.EngineService.GetCurrentModelStatus') |
| | | if (current?.installed) { |
| | | engineStatus.set('loading') |
| | | } else { |
| | | engineHardwareInfo.set('') |
| | | engineStatus.set('need_model') |
| | | } |
| | | await Call.ByName('voicesnap/services.EngineService.ReloadCurrentModel') |
| | | } catch {} |
| | | } |
| | | |
| | | async function loadModelOptions() { |
| | | try { |
| | | const options: any = await Call.ByName('voicesnap/services.EngineService.ListModelOptions') |
| | | modelOptions = Array.isArray(options) ? options : [] |
| | | } catch { |
| | | modelOptions = [] |
| | | } |
| | | } |
| | | |
| | | function modelDescription(modelID: string): string { |
| | | switch (modelID) { |
| | | case 'sensevoice-zh': return t('models.sensevoiceDesc') |
| | | case 'moonshine-en': return t('models.moonshineDesc') |
| | | case 'parakeet-en': return t('models.parakeetDesc') |
| | | case 'qwen3-asr-0.6b': return t('models.qwen3Desc') |
| | | default: return '' |
| | | } |
| | | } |
| | | |
| | | function modelTierLabel(option: ModelOption): string { |
| | | if (option.isDefault) return t('settings.modelDefault') |
| | | if (option.tier === 'advanced') return t('settings.modelAdvanced') |
| | | return option.tier || '' |
| | | } |
| | | |
| | | function modelActionLabel(option: ModelOption): string { |
| | | if (modelBusyID === option.modelID) { |
| | | if (!option.installed && modelProgress > 0) { |
| | | return `${Math.min(100, Math.max(0, modelProgress)).toFixed(0)}%` |
| | | } |
| | | return t('settings.modelWorking') |
| | | } |
| | | if (option.isCurrent) return t('settings.modelCurrent') |
| | | if (option.installed) return t('settings.modelUse') |
| | | return t('settings.modelDownload') |
| | | } |
| | | |
| | | async function onModelAction(option: ModelOption) { |
| | | if (option.isCurrent || modelBusyID) return |
| | | modelBusyID = option.modelID |
| | | modelProgress = 0 |
| | | modelError = '' |
| | | try { |
| | | if (option.installed) { |
| | | engineStatus.set('loading') |
| | | await Call.ByName('voicesnap/services.EngineService.SelectModel', option.modelID) |
| | | } else { |
| | | await Call.ByName('voicesnap/services.EngineService.DownloadModelByID', option.modelID) |
| | | engineStatus.set('loading') |
| | | } |
| | | await loadModelOptions() |
| | | } catch (err: any) { |
| | | modelError = err?.message || String(err || t('settings.modelActionFailed')) |
| | | await loadModelOptions() |
| | | } finally { |
| | | modelBusyID = '' |
| | | modelProgress = 0 |
| | | } |
| | | } |
| | | |
| | | async function onAutoHideChange(checked: boolean) { |
| | | autoHide.set(checked) |
| | |
| | | try { |
| | | await Call.ByName('voicesnap/services.ConfigService.SetHotkeyMode', mode) |
| | | } catch {} |
| | | } |
| | | |
| | | async function onLanguageChange(selection: 'auto' | 'zh-CN' | 'en') { |
| | | const prevMode = languageModeVal |
| | | const prevID = languageIDVal |
| | | if (selection === 'auto') { |
| | | languageModeVal = 'auto' |
| | | languageMode.set('auto') |
| | | } else { |
| | | languageModeVal = 'manual' |
| | | languageIDVal = selection |
| | | languageMode.set('manual') |
| | | languageID.set(selection) |
| | | } |
| | | try { |
| | | const settings: any = selection === 'auto' |
| | | ? await Call.ByName('voicesnap/services.ConfigService.SetLanguageAuto') |
| | | : await Call.ByName('voicesnap/services.ConfigService.SetLanguageManual', selection) |
| | | applyLanguageSettings(settings) |
| | | await syncEngineForCurrentModel() |
| | | await loadModelOptions() |
| | | } catch { |
| | | languageModeVal = prevMode |
| | | languageIDVal = prevID |
| | | languageMode.set(prevMode) |
| | | languageID.set(prevID) |
| | | } |
| | | } |
| | | |
| | | async function onStartAtLoginChange(checked: boolean) { |
| | |
| | | </div> |
| | | |
| | | </div> |
| | | |
| | | {#if settingsLoaded} |
| | | <div class="section"> |
| | | <div class="setting-row"> |
| | | <div class="setting-info"> |
| | | <span class="setting-label">{t('settings.language')}</span> |
| | | <span class="setting-desc"> |
| | | {languageModeVal === 'auto' |
| | | ? t('settings.languageAutoDesc') |
| | | : effectiveLanguageIDVal === 'en' |
| | | ? t('settings.languageEnglishDesc') |
| | | : t('settings.languageChineseDesc')} |
| | | </span> |
| | | </div> |
| | | <div class="segmented language" role="group" aria-label={t('settings.language')}> |
| | | <button |
| | | class:active={languageModeVal === 'auto'} |
| | | onclick={() => onLanguageChange('auto')} |
| | | > |
| | | {t('settings.languageAuto')} |
| | | </button> |
| | | <button |
| | | class:active={languageModeVal === 'manual' && languageIDVal === 'zh-CN'} |
| | | onclick={() => onLanguageChange('zh-CN')} |
| | | > |
| | | {t('settings.languageChinese')} |
| | | </button> |
| | | <button |
| | | class:active={languageModeVal === 'manual' && languageIDVal === 'en'} |
| | | onclick={() => onLanguageChange('en')} |
| | | > |
| | | {t('settings.languageEnglish')} |
| | | </button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | {/if} |
| | | |
| | | {#if settingsLoaded && modelOptions.length > 0} |
| | | <div class="section"> |
| | | <div class="setting-row models-header"> |
| | | <div class="setting-info"> |
| | | <span class="setting-label">{t('settings.recognitionModel')}</span> |
| | | <span class="setting-desc"> |
| | | {effectiveLanguageIDVal === 'en' ? t('settings.recognitionModelEnglishDesc') : t('settings.recognitionModelChineseDesc')} |
| | | </span> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="model-list"> |
| | | {#each modelOptions as option} |
| | | <div class="model-card" class:current={option.isCurrent}> |
| | | <div class="model-main"> |
| | | <div class="model-title-row"> |
| | | <span class="model-title">{option.displayName}</span> |
| | | <span class="model-pill" class:current={option.isCurrent}> |
| | | {option.isCurrent ? t('settings.modelCurrent') : modelTierLabel(option)} |
| | | </span> |
| | | </div> |
| | | <span class="model-desc">{modelDescription(option.modelID)}</span> |
| | | <span class="model-meta"> |
| | | {option.installed ? t('settings.modelInstalled') : t('settings.modelNotInstalled')} |
| | | {#if option.downloadSize} |
| | | ยท {option.downloadSize} |
| | | {/if} |
| | | </span> |
| | | </div> |
| | | <button |
| | | class="model-action" |
| | | class:primary={!option.isCurrent} |
| | | disabled={option.isCurrent || !!modelBusyID} |
| | | onclick={() => onModelAction(option)} |
| | | > |
| | | {modelActionLabel(option)} |
| | | </button> |
| | | </div> |
| | | {/each} |
| | | </div> |
| | | |
| | | {#if modelError} |
| | | <p class="model-error">{modelError}</p> |
| | | {/if} |
| | | </div> |
| | | {/if} |
| | | |
| | | <!-- Engine + Device --> |
| | | <!-- svelte-ignore a11y_click_events_have_key_events --> |
| | |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .segmented.language { |
| | | grid-template-columns: repeat(3, 1fr); |
| | | min-width: 246px; |
| | | } |
| | | |
| | | .segmented.language button { |
| | | min-width: 76px; |
| | | padding: 0 10px; |
| | | } |
| | | |
| | | .segmented button { |
| | | min-width: 66px; |
| | | height: 30px; |
| | |
| | | margin: var(--spacing-sm) 0; |
| | | } |
| | | |
| | | .models-header { |
| | | align-items: flex-start; |
| | | } |
| | | |
| | | .model-list { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .model-card { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: var(--spacing-md); |
| | | min-height: 78px; |
| | | padding: 10px 12px; |
| | | border: 1px solid var(--color-separator); |
| | | border-radius: var(--radius-sm); |
| | | background: var(--color-bg-secondary); |
| | | } |
| | | |
| | | .model-card.current { |
| | | border-color: rgba(0, 122, 255, 0.38); |
| | | } |
| | | |
| | | .model-main { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 3px; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .model-title-row { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .model-title { |
| | | font-size: var(--font-size-base); |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .model-pill { |
| | | display: inline-flex; |
| | | align-items: center; |
| | | height: 20px; |
| | | padding: 0 7px; |
| | | border-radius: 999px; |
| | | background: var(--color-bg-tertiary); |
| | | color: var(--color-secondary-label); |
| | | font-size: 11px; |
| | | font-weight: 500; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .model-pill.current { |
| | | background: rgba(52, 199, 89, 0.14); |
| | | color: var(--color-green); |
| | | } |
| | | |
| | | .model-desc, |
| | | .model-meta { |
| | | font-size: var(--font-size-xs); |
| | | color: var(--color-tertiary-label); |
| | | } |
| | | |
| | | .model-action { |
| | | min-width: 76px; |
| | | height: 30px; |
| | | padding: 0 12px; |
| | | border: none; |
| | | border-radius: var(--radius-sm); |
| | | background: var(--color-bg-tertiary); |
| | | color: var(--color-secondary-label); |
| | | font-size: var(--font-size-sm); |
| | | font-weight: 500; |
| | | cursor: pointer; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .model-action.primary { |
| | | background: var(--color-blue); |
| | | color: white; |
| | | } |
| | | |
| | | .model-action:disabled { |
| | | cursor: not-allowed; |
| | | opacity: 0.72; |
| | | } |
| | | |
| | | .model-error { |
| | | margin-top: 8px; |
| | | font-size: var(--font-size-xs); |
| | | color: var(--color-red); |
| | | } |
| | | |
| | | /* Device selector */ |
| | | .device-selector { |
| | | position: relative; |