<script lang="ts">
|
import { Call, Events } from '@wailsio/runtime'
|
import ToggleSwitch from '../shared/ToggleSwitch.svelte'
|
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 soundFeedbackVal = $state(true)
|
let copyToClipboardVal = $state(true)
|
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 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 unsub3 = deviceName.subscribe(v => { device = v })
|
const unsub4 = engineStatus.subscribe(v => { status = v })
|
const unsub5 = engineHardwareInfo.subscribe(v => { hwInfo = v })
|
const unsub6 = hotkeyName.subscribe(k => { currentKeyName = k })
|
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() {
|
try {
|
const name: any = await Call.ByName('voicesnap/services.AudioService.GetDeviceName')
|
if (name) {
|
device = name
|
deviceName.set(name)
|
}
|
} catch {}
|
}
|
loadDeviceName()
|
|
async function loadSettings() {
|
try {
|
const isStartup: any = await Call.ByName('voicesnap/services.ConfigService.IsStartupEnabled')
|
startAtLogin.set(!!isStartup)
|
startAtLoginVal = !!isStartup
|
} catch {}
|
try {
|
const ah: any = await Call.ByName('voicesnap/services.ConfigService.GetAutoHide')
|
autoHide.set(ah)
|
autoHideVal = ah
|
} catch {}
|
try {
|
const sf: any = await Call.ByName('voicesnap/services.ConfigService.GetSoundFeedback')
|
soundFeedback.set(sf)
|
soundFeedbackVal = sf
|
} catch {}
|
try {
|
const copy: any = await Call.ByName('voicesnap/services.ConfigService.GetCopyToClipboard')
|
copyToClipboard.set(copy)
|
copyToClipboardVal = copy
|
} 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 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')
|
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.SetAutoHide', checked)
|
} catch {}
|
}
|
|
async function onSoundFeedbackChange(checked: boolean) {
|
soundFeedback.set(checked)
|
try {
|
await Call.ByName('voicesnap/services.ConfigService.SetSoundFeedback', checked)
|
} catch {}
|
}
|
|
async function onCopyToClipboardChange(checked: boolean) {
|
copyToClipboard.set(checked)
|
try {
|
await Call.ByName('voicesnap/services.ConfigService.SetCopyToClipboard', checked)
|
} catch {}
|
}
|
|
async function onHotkeyModeChange(mode: 'tap' | 'hold') {
|
hotkeyMode.set(mode)
|
hotkeyModeVal = mode
|
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) {
|
startAtLogin.set(checked)
|
try {
|
await Call.ByName('voicesnap/services.ConfigService.SetStartupEnabled', checked)
|
} catch {}
|
}
|
|
async function onHideDockIconChange(checked: boolean) {
|
hideDockIconVal = checked
|
try {
|
await Call.ByName('voicesnap/services.ConfigService.SetHideDockIcon', checked)
|
} catch {}
|
}
|
|
async function toggleDeviceDropdown() {
|
if (showDeviceDropdown) {
|
showDeviceDropdown = false
|
return
|
}
|
try {
|
const list: any = await Call.ByName('voicesnap/services.AudioService.ListInputDevices')
|
devices = list || []
|
} catch {
|
devices = []
|
}
|
if (devices.length > 0) {
|
showDeviceDropdown = true
|
}
|
}
|
|
async function selectDevice(dev: InputDevice) {
|
showDeviceDropdown = false
|
device = dev.name
|
deviceName.set(dev.name)
|
try {
|
await Call.ByName('voicesnap/services.AudioService.SetDevice', dev.name)
|
} catch {}
|
}
|
|
function closeDropdown(e: MouseEvent) {
|
const target = e.target as HTMLElement
|
if (!target.closest('.device-selector')) {
|
showDeviceDropdown = false
|
}
|
}
|
|
async function startRecordingHotkey() {
|
isRecording = true
|
hintText = t('hotkeys.pressAnyKey')
|
|
try {
|
await Call.ByName('voicesnap/services.HotkeyService.StartRecordingHotkey')
|
} catch {}
|
|
function onKey(e: KeyboardEvent) {
|
e.preventDefault()
|
e.stopPropagation()
|
|
const vk = mapKeyToVK(e)
|
if (vk > 0) {
|
isRecording = false
|
hintText = t('hotkeys.updated')
|
hotkeyVK.set(vk)
|
|
;(async () => {
|
try {
|
await Call.ByName('voicesnap/services.HotkeyService.SetHotkey', vk)
|
const name: any = await Call.ByName('voicesnap/services.HotkeyService.GetKeyName', vk)
|
if (name) {
|
hotkeyName.set(name)
|
currentKeyName = name
|
}
|
} catch {}
|
try {
|
await Call.ByName('voicesnap/services.HotkeyService.StopRecordingHotkey')
|
} catch {}
|
})()
|
|
document.removeEventListener('keydown', onKey)
|
}
|
}
|
|
document.addEventListener('keydown', onKey)
|
}
|
|
function mapKeyToVK(e: KeyboardEvent): number {
|
switch (e.key) {
|
case 'Control': return e.location === 1 ? 0xA2 : e.location === 2 ? 0xA3 : 0x11
|
case 'Alt': return e.location === 1 ? 0xA4 : e.location === 2 ? 0xA5 : 0x12
|
case 'Shift': return e.location === 1 ? 0xA0 : e.location === 2 ? 0xA1 : 0x10
|
case 'CapsLock': return 0x14
|
case ' ': return 0x20
|
case 'Tab': return 0x09
|
case 'Enter': return 0x0D
|
case 'Escape': return 0x1B
|
case 'Meta': return e.location === 1 ? 0x5B : 0x5C
|
default:
|
if (e.key.length === 1) {
|
const code = e.key.toUpperCase().charCodeAt(0)
|
if (code >= 0x41 && code <= 0x5A) return code
|
if (code >= 0x30 && code <= 0x39) return code
|
}
|
if (e.key.startsWith('F') && e.key.length <= 3) {
|
const num = parseInt(e.key.substring(1))
|
if (num >= 1 && num <= 12) return 0x70 + num - 1
|
}
|
return 0
|
}
|
}
|
</script>
|
|
<div class="page">
|
<!-- Header -->
|
<div class="header">
|
<h1 class="tagline">{t('home.tagline')}</h1>
|
<p class="app-desc">{hotkeyModeVal === 'tap' ? t('home.modeHintTap') : t('home.modeHintHold')}</p>
|
</div>
|
|
<!-- Hotkey -->
|
<div class="section">
|
<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>
|
</div>
|
<button class="change-btn" onclick={startRecordingHotkey} disabled={isRecording}>
|
{t('hotkeys.change')}
|
</button>
|
</div>
|
|
{#if hintText}
|
<p class="hint" class:recording={isRecording}>{hintText}</p>
|
{/if}
|
|
<div class="divider"></div>
|
|
<div class="setting-row">
|
<div class="setting-info">
|
<span class="setting-label">{t('settings.hotkeyMode')}</span>
|
<span class="setting-desc">
|
{hotkeyModeVal === 'tap' ? t('settings.hotkeyModeTapDesc') : t('settings.hotkeyModeHoldDesc')}
|
</span>
|
</div>
|
<div class="segmented" role="group" aria-label={t('settings.hotkeyMode')}>
|
<button
|
class:active={hotkeyModeVal === 'tap'}
|
onclick={() => onHotkeyModeChange('tap')}
|
>
|
{t('settings.hotkeyModeTap')}
|
</button>
|
<button
|
class:active={hotkeyModeVal === 'hold'}
|
onclick={() => onHotkeyModeChange('hold')}
|
>
|
{t('settings.hotkeyModeHold')}
|
</button>
|
</div>
|
</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 -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="section" onclick={closeDropdown}>
|
<div class="setting-row">
|
<div class="setting-info">
|
<span class="setting-label">{t('about.engineMode')}</span>
|
<span class="setting-value">
|
<span class="status-dot" class:green={status === 'ready'} class:orange={status === 'loading'} class:red={status === 'error'}></span>
|
{#if status === 'ready'}
|
{hwInfo}
|
{:else if status === 'loading'}
|
{t('status.loading')}
|
{:else}
|
{t('status.engineNotReady')}
|
{/if}
|
</span>
|
</div>
|
</div>
|
|
<div class="divider"></div>
|
|
<div class="setting-row">
|
<div class="setting-info">
|
<span class="setting-label">{t('settings.inputDevice')}</span>
|
</div>
|
<div class="device-selector">
|
<button class="device-btn" onclick={toggleDeviceDropdown}>
|
<span class="device-name">{device}</span>
|
<svg class="chevron" class:open={showDeviceDropdown} width="10" height="6" viewBox="0 0 10 6" fill="none">
|
<path d="M1 1L5 5L9 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
</svg>
|
</button>
|
{#if showDeviceDropdown && devices.length > 0}
|
<div class="device-dropdown">
|
{#each devices as dev}
|
<button
|
class="device-option"
|
class:selected={dev.name === device}
|
onclick={() => selectDevice(dev)}
|
>
|
{dev.name}
|
{#if dev.name === device}
|
<svg width="12" height="9" viewBox="0 0 12 9" fill="none">
|
<path d="M1 4L4.5 7.5L11 1" stroke="var(--color-blue)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
</svg>
|
{/if}
|
</button>
|
{/each}
|
</div>
|
{/if}
|
</div>
|
</div>
|
</div>
|
|
<!-- Toggles -->
|
{#if settingsLoaded}
|
<div class="section">
|
<div class="setting-row">
|
<div class="setting-info">
|
<span class="setting-label">{t('settings.soundFeedback')}</span>
|
<span class="setting-desc">{t('settings.soundFeedbackDesc')}</span>
|
</div>
|
<ToggleSwitch checked={soundFeedbackVal} onchange={onSoundFeedbackChange} />
|
</div>
|
|
<div class="divider"></div>
|
|
<div class="setting-row">
|
<div class="setting-info">
|
<span class="setting-label">{t('settings.copyToClipboard')}</span>
|
<span class="setting-desc">{t('settings.copyToClipboardDesc')}</span>
|
</div>
|
<ToggleSwitch checked={copyToClipboardVal} onchange={onCopyToClipboardChange} />
|
</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>
|
<ToggleSwitch checked={autoHideVal} onchange={onAutoHideChange} />
|
</div>
|
|
<div class="divider"></div>
|
|
<div class="setting-row">
|
<div class="setting-info">
|
<span class="setting-label">{t('settings.startAtLogin')}</span>
|
<span class="setting-desc">{t('settings.startAtLoginDesc')}</span>
|
</div>
|
<ToggleSwitch checked={startAtLoginVal} onchange={onStartAtLoginChange} />
|
</div>
|
|
<div class="divider"></div>
|
|
<div class="setting-row">
|
<div class="setting-info">
|
<span class="setting-label">{t('settings.hideDockIcon')}</span>
|
<span class="setting-desc">{t('settings.hideDockIconDesc')}</span>
|
</div>
|
<ToggleSwitch checked={hideDockIconVal} onchange={onHideDockIconChange} />
|
</div>
|
</div>
|
{/if}
|
</div>
|
|
<style>
|
.header {
|
margin-bottom: var(--spacing-xl);
|
}
|
|
.tagline {
|
font-size: 22px;
|
font-weight: 700;
|
}
|
|
.app-desc {
|
font-size: var(--font-size-sm);
|
color: var(--color-secondary-label);
|
margin-top: 6px;
|
}
|
|
.section {
|
background: var(--color-bg-grouped-secondary);
|
border-radius: var(--radius-md);
|
padding: var(--spacing-lg);
|
margin-bottom: var(--spacing-md);
|
}
|
|
/* Hotkey */
|
.hotkey-display {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
}
|
|
.hotkey-info {
|
display: flex;
|
align-items: center;
|
gap: var(--spacing-md);
|
}
|
|
.hotkey-label {
|
font-size: var(--font-size-base);
|
font-weight: 500;
|
}
|
|
.hotkey-key {
|
display: inline-flex;
|
align-items: center;
|
justify-content: center;
|
min-width: 56px;
|
padding: 5px 14px;
|
background: var(--color-bg-secondary);
|
border-radius: var(--radius-sm);
|
font-family: var(--font-family);
|
font-size: var(--font-size-lg);
|
font-weight: 600;
|
color: var(--color-blue);
|
}
|
|
.hotkey-key.recording {
|
color: var(--color-orange);
|
animation: pulse 1s infinite;
|
}
|
|
@keyframes pulse {
|
0%, 100% { opacity: 1; }
|
50% { opacity: 0.5; }
|
}
|
|
.change-btn {
|
padding: 6px 2px;
|
background: transparent;
|
border: none;
|
font-size: var(--font-size-sm);
|
font-weight: 500;
|
color: var(--color-blue);
|
cursor: pointer;
|
transition: opacity var(--transition-fast);
|
}
|
|
.change-btn:hover { opacity: 0.65; }
|
.change-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
.hint {
|
margin-top: var(--spacing-sm);
|
font-size: var(--font-size-sm);
|
color: var(--color-secondary-label);
|
}
|
|
.hint.recording {
|
color: var(--color-blue);
|
}
|
|
/* Settings rows */
|
.status-dot {
|
display: inline-block;
|
width: 7px;
|
height: 7px;
|
border-radius: 50%;
|
background: var(--color-blue);
|
margin-right: 4px;
|
vertical-align: middle;
|
}
|
|
.status-dot.green { background: var(--color-green); }
|
.status-dot.orange { background: var(--color-orange); }
|
.status-dot.red { background: var(--color-red); }
|
|
.setting-row {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
padding: var(--spacing-xs) 0;
|
}
|
|
.setting-info {
|
display: flex;
|
flex-direction: column;
|
gap: 2px;
|
}
|
|
.setting-label {
|
font-size: var(--font-size-base);
|
font-weight: 500;
|
}
|
|
.setting-value {
|
font-size: var(--font-size-sm);
|
color: var(--color-secondary-label);
|
}
|
|
.setting-desc {
|
font-size: var(--font-size-xs);
|
color: var(--color-tertiary-label);
|
}
|
|
.segmented {
|
display: inline-grid;
|
grid-template-columns: 1fr 1fr;
|
min-width: 148px;
|
padding: 2px;
|
border-radius: var(--radius-sm);
|
background: var(--color-bg-secondary);
|
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;
|
padding: 0 12px;
|
border: none;
|
border-radius: calc(var(--radius-sm) - 2px);
|
background: transparent;
|
color: var(--color-secondary-label);
|
font-size: var(--font-size-sm);
|
font-weight: 500;
|
cursor: pointer;
|
transition: background var(--transition-fast), color var(--transition-fast);
|
}
|
|
.segmented button.active {
|
background: var(--color-blue);
|
color: white;
|
}
|
|
.divider {
|
height: 1px;
|
background: var(--color-separator);
|
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;
|
}
|
|
.device-btn {
|
display: flex;
|
align-items: center;
|
gap: 6px;
|
padding: 4px 0;
|
background: transparent;
|
border: none;
|
font-size: var(--font-size-sm);
|
color: var(--color-secondary-label);
|
cursor: pointer;
|
transition: opacity var(--transition-fast);
|
}
|
|
.device-btn:hover { opacity: 0.65; }
|
|
.device-name {
|
max-width: 200px;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
|
.chevron {
|
color: var(--color-tertiary-label);
|
transition: transform 0.2s ease;
|
flex-shrink: 0;
|
}
|
|
.chevron.open {
|
transform: rotate(180deg);
|
}
|
|
.device-dropdown {
|
position: absolute;
|
right: 0;
|
top: calc(100% + 4px);
|
min-width: 240px;
|
max-width: 320px;
|
background: var(--color-bg-grouped-secondary);
|
border: 1px solid var(--color-separator);
|
border-radius: var(--radius-sm);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
z-index: 100;
|
overflow: hidden;
|
}
|
|
.device-option {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
width: 100%;
|
padding: 8px 12px;
|
background: transparent;
|
border: none;
|
font-size: var(--font-size-sm);
|
color: var(--color-label);
|
cursor: pointer;
|
text-align: left;
|
transition: background 0.15s ease;
|
}
|
|
.device-option:hover {
|
background: var(--color-bg-secondary);
|
}
|
|
.device-option.selected {
|
color: var(--color-blue);
|
font-weight: 500;
|
}
|
|
.device-option + .device-option {
|
border-top: 1px solid var(--color-separator);
|
}
|
</style>
|