Files
openless/openless-all/app/src/pages/settings/RecordingInputSection.tsx
T
TRIPandsim 006cb78a93 feat(capsule): 录音胶囊样式可选(流光 Siri / Openless 默认)+ 修复 beta 构建破坏 (#887)
* feat(capsule): 录音胶囊样式可选——流光 Siri 风格 / Openless 默认风格

设置 → 录音与输入新增「胶囊样式」下拉(保留显示开关):
- UserPreferences.capsule_style('siri' 默认 / 'classic')随 capsule:state
  payload 下发,切换后下一次录音即生效
- Capsule.tsx 双样式分支:siri 保留 SiriGL 光效舞台;classic 恢复 1.3.14
  经典毛玻璃药丸(音量条 + 取消/确认按钮 + thinking 扫光 + 翻译徽章),
  取消/确认改走 lib/ipc/dictation.ts,退出动画时长按样式区分
- 预览支持 ?style=classic;i18n 五语言新增胶囊样式文案

* fix(settings): 选区润色行适配重构后的 ShortcutRecorder props

beta HEAD 在快捷键录入交互重构(e12c8dcc)合并后遗留的构建破坏:
SelectionPolishSection 仍传已删除的 alignRecordButton / modifierPresets,
tsc 直接报错。改为新 API(value 可空 + onReset 恢复默认快捷键,
等价于旧「启用」按钮),恢复 beta 可构建状态。

* fix(capsule): 评审修复——经典药丸按钮可点 + 音频线程零偏好锁

- 经典样式(Openless 默认)下胶囊窗口长期 set_ignore_cursor_events(true)
  (纯光效舞台时代遗留),✕/✓ 按钮实际不可点。现在主线程闭包按
  「classic && visible && 非选区润色 && 录音/转写/润色中」关闭鼠标穿透,
  终态 toast / 隐藏 / Siri / 选区润色保持穿透;值不变时不重复调用
  set_ignore_cursor_events
- emit_capsule 在音频回调线程 ~30Hz 被调,原先直接读 prefs(整份克隆 +
  偏好锁被 set() 跨磁盘 I/O 持有)。改为 Inner 上 AtomicU8 样式缓存,
  主线程闭包每帧从 prefs 同步(与 show_capsule 同源读取),音频线程零开销
- 恢复「录音胶囊」行的 desc 文案(beta 原有,重构时被误删)
- Capsule.tsx 退出动画注释同步为按样式区分的常量名

* docs(capsule): 注明入场帧 payload 可能滞后一帧样式(防误改回音频线程读 prefs)

---------

Co-authored-by: sim <sim@local>
2026-08-03 14:17:52 +08:00

511 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 通用 → 录音与输入:录音快捷键 / 方式 / 麦克风 / 胶囊 / 静音,
// 外加「插入与剪贴板」「启动」两个折叠组。流式输入也并到这里(属于插入行为)。
// 自 Settings.tsx 的 RecordingSection 拆出,录音相关逻辑零改动。
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ShortcutRecorder } from '../../components/ShortcutRecorder';
import { playRecordStartCue } from '../../lib/audioCue';
import { defaultDictationHotkey } from '../../lib/hotkey';
import { emitSaved } from '../../lib/savedEvent';
import { isHotkeyModeMigrationNoticeActive } from '../../lib/hotkeyMigration';
import {
isTauri,
listMicrophoneDevices,
setDictationHotkey,
} from '../../lib/ipc';
import { getPlatformCapabilities } from '../../lib/platform';
import type {
CapsuleStyle,
HotkeyMode,
MicrophoneDevice,
PasteShortcut,
PlatformCapabilities,
UserPreferences,
WindowsInsertionMode,
WindowsSendInputNewlineMode,
} from '../../lib/types';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { SelectLite } from '../../components/ui/SelectLite';
import { Card, Collapsible } from '../_atoms';
import { SectionTitle, SettingRow, Toggle, inputStyle, segmentedTrackStyle } from './shared';
import { MicrophoneSelect } from './MicrophoneSelect';
import { detectOS } from '../../components/WindowChrome';
async function autostartIsEnabled(): Promise<boolean> {
const { invoke } = await import('@tauri-apps/api/core');
return invoke<boolean>('plugin:autostart|is_enabled');
}
async function autostartEnable(): Promise<void> {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('plugin:autostart|enable');
}
async function autostartDisable(): Promise<void> {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('plugin:autostart|disable');
}
export function RecordingInputSection() {
const { t } = useTranslation();
const os = detectOS();
const { prefs, capability, updatePrefs: savePrefs, refresh } = useHotkeySettings();
const [platformCaps, setPlatformCaps] = useState<PlatformCapabilities | null>(null);
const [microphoneDevices, setMicrophoneDevices] = useState<MicrophoneDevice[]>([]);
const [microphoneDevicesLoaded, setMicrophoneDevicesLoaded] = useState(false);
const [microphoneDevicesError, setMicrophoneDevicesError] = useState<string | null>(null);
useEffect(() => {
void getPlatformCapabilities().then(setPlatformCaps);
}, []);
// 兼容旧 Windows 配置:Shift+Insert 仍保留在跨平台类型/后端中供 Linux 使用,
// 但 Windows 已不再提供该选项;进入设置时迁移为 Ctrl+V,避免下拉框无匹配值。
useEffect(() => {
if (os !== 'win' || prefs?.pasteShortcut !== 'shiftInsert') return;
void savePrefs(current => {
if (current.pasteShortcut !== 'shiftInsert') return current;
return { ...current, pasteShortcut: 'ctrlV' };
}).catch(error => {
console.warn('[settings] migrate Windows paste shortcut failed', error);
});
}, [os, prefs?.pasteShortcut, savePrefs]);
const loadMicrophoneDevices = useCallback(async (
signal?: { cancelled: boolean },
options: { showLoading?: boolean } = {},
) => {
if (options.showLoading ?? true) {
setMicrophoneDevicesLoaded(false);
}
setMicrophoneDevicesError(null);
try {
const devices = await listMicrophoneDevices();
if (signal?.cancelled) return;
setMicrophoneDevices(devices);
setMicrophoneDevicesLoaded(true);
} catch (err) {
console.error('[settings] list microphone devices failed', err);
if (signal?.cancelled) return;
setMicrophoneDevices([]);
setMicrophoneDevicesError(err instanceof Error ? err.message : String(err));
setMicrophoneDevicesLoaded(true);
}
}, []);
useEffect(() => {
const signal = { cancelled: false };
void loadMicrophoneDevices(signal);
return () => {
signal.cancelled = true;
};
}, [loadMicrophoneDevices]);
useEffect(() => {
if (!isTauri) return;
let cancelled = false;
let unlisten: (() => void) | undefined;
async function listenForDeviceChanges() {
const { listen } = await import('@tauri-apps/api/event');
if (cancelled) return;
const stopListening = await listen('microphone:devices-changed', () => {
void loadMicrophoneDevices(undefined, { showLoading: false });
});
if (cancelled) {
stopListening();
return;
}
unlisten = stopListening;
}
void listenForDeviceChanges();
return () => {
cancelled = true;
unlisten?.();
};
}, [loadMicrophoneDevices]);
const saveKeyboardListAffectingPrefs = useCallback(async (nextPrefs: UserPreferences) => {
try {
await savePrefs(nextPrefs);
} catch (error) {
console.error('[settings] keyboard list visibility pref save failed', error);
emitSaved('failed', t('settings.recording.windowsShowOpenlessInKeyboardListError'));
await refresh();
}
}, [savePrefs, refresh, t]);
if (!prefs || !capability) {
return (
<Card>
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
</Card>
);
}
const isAndroid = platformCaps?.platform === 'android';
const showDesktopHotkey = platformCaps?.supportsDesktopHotkey === true;
const showDesktopInsert = showDesktopHotkey && os !== 'linux';
const showDesktopStartup = showDesktopHotkey;
const effectivePasteShortcut = os === 'win' && prefs.pasteShortcut === 'shiftInsert'
? 'ctrlV'
: prefs.pasteShortcut;
const onModeChange = (mode: HotkeyMode) =>
savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, mode } });
const onShowCapsuleChange = (showCapsule: boolean) =>
savePrefs({ ...prefs, showCapsule });
const onMuteDuringRecordingChange = (muteDuringRecording: boolean) =>
savePrefs({ ...prefs, muteDuringRecording });
const onAudioCueChange = (audioCueOnRecord: boolean) =>
savePrefs({ ...prefs, audioCueOnRecord });
const onMicrophoneDeviceChange = (microphoneDeviceName: string) =>
savePrefs({ ...prefs, microphoneDeviceName });
const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) =>
savePrefs({ ...prefs, restoreClipboardAfterPaste });
const onPasteShortcutChange = (pasteShortcut: PasteShortcut) =>
savePrefs({ ...prefs, pasteShortcut });
const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) =>
savePrefs({ ...prefs, allowNonTsfInsertionFallback });
const onWindowsInsertionModeChange = (windowsInsertionMode: WindowsInsertionMode) =>
void saveKeyboardListAffectingPrefs({
...prefs,
windowsInsertionMode,
windowsSendInputInsertionOnly: windowsInsertionMode === 'sendInput',
});
const onWindowsSendInputNewlineModeChange = (windowsSendInputNewlineMode: WindowsSendInputNewlineMode) =>
savePrefs({ ...prefs, windowsSendInputNewlineMode });
const onWindowsShowOpenlessInKeyboardListChange = (
windowsShowOpenlessInKeyboardList: boolean,
) => void saveKeyboardListAffectingPrefs({ ...prefs, windowsShowOpenlessInKeyboardList });
const onStartMinimizedChange = (startMinimized: boolean) =>
savePrefs({ ...prefs, startMinimized });
const onAutoUpdateCheckChange = (autoUpdateCheck: boolean) =>
savePrefs({ ...prefs, autoUpdateCheck });
const choices: Array<[HotkeyMode, string]> = [
['toggle', t('settings.recording.modeToggle')],
['hold', t('settings.recording.modeHold')],
['auto', t('settings.recording.modeAuto')],
];
const preferredMicrophoneAvailable = Boolean(
prefs.microphoneDeviceName
&& microphoneDevices.some(device => device.name === prefs.microphoneDeviceName),
);
const effectiveMicrophoneDeviceName = prefs.microphoneDeviceName
&& (!microphoneDevicesLoaded || preferredMicrophoneAvailable)
? prefs.microphoneDeviceName
: '';
return (
<>
<Card>
<SectionTitle hint={t('settings.recording.desc')}>
{t('settings.recording.title')}
</SectionTitle>
{isHotkeyModeMigrationNoticeActive() && showDesktopHotkey && (
<div
style={{
marginTop: 4,
marginBottom: 8,
padding: '12px 14px',
borderRadius: 10,
background: 'rgba(37,99,235,0.08)',
border: '0.5px solid rgba(37,99,235,0.18)',
}}
>
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--ol-blue)', marginBottom: 4 }}>
{t('settings.recording.migrationNoticeTitle')}
</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-3)', lineHeight: 1.55 }}>
{t('settings.recording.migrationNoticeDesc')}
</div>
</div>
)}
{showDesktopHotkey && (
<SettingRow label={t('settings.recording.hotkeyLabel')}>
<ShortcutRecorder
value={prefs.dictationHotkey}
sideSpecificModifiers
// 录音快捷键是核心热键,Rust 端不接受 null,不可停用——置灰并提示。
disableDisabled
disableHint={t('settings.recording.comboDisableHint')}
onSave={async binding => {
await setDictationHotkey(binding);
await savePrefs({ ...prefs, dictationHotkey: binding });
}}
onReset={async () => {
const binding = defaultDictationHotkey();
await setDictationHotkey(binding);
await savePrefs({ ...prefs, dictationHotkey: binding });
}}
/>
</SettingRow>
)}
{showDesktopHotkey && (
<SettingRow label={t('settings.recording.modeLabel')} desc={t('settings.recording.modeDesc')}>
<div style={segmentedTrackStyle}>
{choices.map(([v, l]) => (
<button
key={v}
onClick={() => onModeChange(v)}
style={{
padding: '5px 14px', fontSize: 12, fontWeight: 500,
border: 0, borderRadius: 6, fontFamily: 'inherit',
background: prefs.hotkey.mode === v ? 'var(--ol-segmented-active-bg)' : 'transparent',
color: prefs.hotkey.mode === v ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
boxShadow: prefs.hotkey.mode === v ? 'var(--ol-segmented-active-shadow)' : 'none',
cursor: 'default',
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft)',
}}
>
{l}
</button>
))}
</div>
</SettingRow>
)}
<SettingRow label={t('settings.recording.microphoneLabel')} desc={t('settings.recording.microphoneDesc')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<MicrophoneSelect
devices={microphoneDevices}
selectedName={effectiveMicrophoneDeviceName}
onSelect={onMicrophoneDeviceChange}
onOpen={() => { void loadMicrophoneDevices(undefined, { showLoading: false }); }}
/>
{microphoneDevicesError && (
<div style={{ fontSize: 11, color: 'var(--ol-err)', lineHeight: 1.5 }}>
{t('settings.recording.microphoneLoadError', { message: microphoneDevicesError })}
</div>
)}
</div>
</SettingRow>
{os !== 'linux' && !isAndroid && (
<SettingRow label={t('settings.recording.capsuleLabel')} desc={t('settings.recording.capsuleDesc')}>
<Toggle on={prefs.showCapsule} onToggle={onShowCapsuleChange} />
</SettingRow>
)}
{os !== 'linux' && !isAndroid && (
<SettingRow label={t('settings.recording.capsuleStyleLabel')}>
<SelectLite
value={prefs.capsuleStyle ?? 'siri'}
onChange={next => savePrefs({ ...prefs, capsuleStyle: next as CapsuleStyle })}
options={[
{ value: 'siri', label: t('settings.recording.capsuleStyleSiri') },
{ value: 'classic', label: t('settings.recording.capsuleStyleClassic') },
]}
ariaLabel={t('settings.recording.capsuleStyleLabel')}
style={{ ...inputStyle, maxWidth: 220 }}
/>
</SettingRow>
)}
<SettingRow label={t('settings.recording.muteDuringRecordingLabel')} desc={t('settings.recording.muteDuringRecordingDesc')}>
<Toggle on={prefs.muteDuringRecording} onToggle={onMuteDuringRecordingChange} />
</SettingRow>
<SettingRow
label={t('settings.recording.audioCueLabel')}
desc={t('settings.recording.audioCueDesc')}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Toggle on={prefs.audioCueOnRecord} onToggle={onAudioCueChange} />
<button
type="button"
onClick={() => playRecordStartCue()}
style={{
padding: '5px 12px',
fontSize: 12,
fontWeight: 500,
fontFamily: 'inherit',
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 8,
background: 'var(--ol-surface-2)',
color: 'var(--ol-ink-2)',
cursor: 'default',
transition: 'background 0.16s var(--ol-motion-quick)',
}}
>
{t('settings.recording.audioCuePreview')}
</button>
</div>
</SettingRow>
{os === 'linux' && (
<SettingRow label={t('settings.advanced.streamingInsertLabel')}>
<Toggle
on={!!prefs.streamingInsert}
onToggle={(next) => void savePrefs({ ...prefs, streamingInsert: next })}
/>
</SettingRow>
)}
</Card>
{/* ─── 插入与剪贴板(折叠,仅 macOS / Windows) ──────────────── */}
{showDesktopInsert && (
<Collapsible title={t('settings.recording.insertGroupTitle')}>
<SettingRow label={t('settings.recording.restoreClipboardLabel')} desc={t('settings.recording.restoreClipboardDesc')}>
<Toggle on={prefs.restoreClipboardAfterPaste} onToggle={onRestoreClipboardChange} />
</SettingRow>
{capability.adapter !== 'macEventTap' && (
<SettingRow label={t('settings.recording.pasteShortcutLabel')} desc={t('settings.recording.pasteShortcutDesc')}>
<SelectLite
value={effectivePasteShortcut}
onChange={next => onPasteShortcutChange(next as PasteShortcut)}
options={[
{ value: 'ctrlV', label: t('settings.recording.pasteShortcutCtrlV') },
{ value: 'ctrlShiftV', label: t('settings.recording.pasteShortcutCtrlShiftV') },
// 这个「粘贴与剪贴板」组只在 Windows 出现(showDesktopInsert 已排除 Linux,mac 走
// macEventTap 不显示本行)。Shift+Insert 是 xterm/urxvt 等 X11 终端的粘贴组合,
// 放在 Windows 上纯属误导,故不再作为选项(issue #786)。
]}
ariaLabel={t('settings.recording.pasteShortcutLabel')}
style={{ ...inputStyle, maxWidth: 220 }}
/>
</SettingRow>
)}
{capability.adapter === 'windowsLowLevel' && (
<SettingRow
label={t('settings.recording.windowsInsertionModeLabel')}
desc={t('settings.recording.windowsInsertionModeDesc')}
>
<SelectLite
value={prefs.windowsInsertionMode ?? (prefs.windowsSendInputInsertionOnly ? 'sendInput' : 'tsf')}
onChange={next => onWindowsInsertionModeChange(next as WindowsInsertionMode)}
options={[
{ value: 'tsf', label: t('settings.recording.windowsInsertionModeTsf') },
{ value: 'sendInput', label: t('settings.recording.windowsInsertionModeSendInput') },
{ value: 'paste', label: t('settings.recording.windowsInsertionModePaste') },
]}
ariaLabel={t('settings.recording.windowsInsertionModeLabel')}
style={{ ...inputStyle, maxWidth: 260 }}
/>
</SettingRow>
)}
{capability.adapter === 'windowsLowLevel'
&& (prefs.windowsInsertionMode === 'sendInput' || prefs.windowsSendInputInsertionOnly) && (
<SettingRow
label={t('settings.recording.windowsSendInputNewlineModeLabel')}
desc={t('settings.recording.windowsSendInputNewlineModeDesc')}
>
<SelectLite
value={prefs.windowsSendInputNewlineMode ?? 'enter'}
onChange={next => onWindowsSendInputNewlineModeChange(next as WindowsSendInputNewlineMode)}
options={[
{ value: 'enter', label: t('settings.recording.windowsSendInputNewlineModeEnter') },
{ value: 'shiftEnter', label: t('settings.recording.windowsSendInputNewlineModeShiftEnter') },
{ value: 'crlf', label: t('settings.recording.windowsSendInputNewlineModeCrLf') },
]}
ariaLabel={t('settings.recording.windowsSendInputNewlineModeLabel')}
style={{ ...inputStyle, maxWidth: 260 }}
/>
</SettingRow>
)}
{capability.adapter === 'windowsLowLevel'
&& (prefs.windowsInsertionMode === 'sendInput' || prefs.windowsSendInputInsertionOnly) && (
<SettingRow
label={t('settings.recording.windowsShowOpenlessInKeyboardListLabel')}
desc={t('settings.recording.windowsShowOpenlessInKeyboardListDesc')}
>
<Toggle
on={prefs.windowsShowOpenlessInKeyboardList}
onToggle={(next) => void onWindowsShowOpenlessInKeyboardListChange(next)}
/>
</SettingRow>
)}
{capability.adapter === 'windowsLowLevel' && (
<SettingRow label={t('settings.recording.allowNonTsfFallbackLabel')}>
<Toggle
on={prefs.allowNonTsfInsertionFallback}
onToggle={onAllowNonTsfFallbackChange}
/>
</SettingRow>
)}
{/* 流式输入:润色 SSE 一边到达一边模拟键盘逐字落到光标,降低感知延迟。
不满足条件时自动回落一次性插入。属于「插入行为」,故归到本组。 */}
<SettingRow label={t('settings.advanced.streamingInsertLabel')}>
<Toggle
on={!!prefs.streamingInsert}
onToggle={(next) => void savePrefs({ ...prefs, streamingInsert: next })}
/>
</SettingRow>
<SettingRow label={t('settings.advanced.streamingInsertSaveClipboardLabel')}>
<Toggle
on={!!prefs.streamingInsertSaveClipboard}
onToggle={(next) => void savePrefs({ ...prefs, streamingInsertSaveClipboard: next })}
/>
</SettingRow>
</Collapsible>
)}
{/* ─── 启动(折叠) ──────────────────────────────────────────── */}
{showDesktopStartup && (
<Collapsible title={t('settings.recording.startupGroupTitle')}>
<AutostartRow />
<SettingRow label={t('settings.recording.startMinimizedLabel')}>
<Toggle on={prefs.startMinimized} onToggle={onStartMinimizedChange} />
</SettingRow>
<SettingRow label={t('settings.recording.autoUpdateCheckLabel')}>
<Toggle on={prefs.autoUpdateCheck} onToggle={onAutoUpdateCheckChange} />
</SettingRow>
</Collapsible>
)}
</>
);
}
// 不存进 prefs:autostart 状态由 OS 持有(mac LaunchAgent plist / linux .desktop /
// windows HKCU\Run),prefs 缓存反而会与 OS 真相不一致。issue #194。
function AutostartRow() {
const { t } = useTranslation();
const [enabled, setEnabled] = useState(false);
const [loaded, setLoaded] = useState(false);
// 切 plist / 注册表失败时给用户看的错误。null = 没有失败/上次操作已成功。
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isTauri) {
setLoaded(true);
return;
}
let cancelled = false;
autostartIsEnabled()
.then((v: boolean) => {
if (!cancelled) {
setEnabled(v);
setLoaded(true);
}
})
.catch((err: unknown) => {
console.error('[autostart] isEnabled failed', err);
if (!cancelled) setLoaded(true);
});
return () => {
cancelled = true;
};
}, []);
const onToggle = async (next: boolean) => {
setEnabled(next);
setError(null);
try {
if (!isTauri) return;
if (next) await autostartEnable();
else await autostartDisable();
} catch (err) {
console.error('[autostart] toggle failed', err);
setEnabled(!next);
setError(err instanceof Error ? err.message : String(err));
}
};
return (
<SettingRow label={t('settings.recording.startupAtBoot')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{loaded ? <Toggle on={enabled} onToggle={onToggle} /> : null}
{error && (
<div style={{ fontSize: 11, color: 'var(--ol-err)', marginTop: 4, lineHeight: 1.5 }}>
{t('settings.recording.startupAtBootError', { message: error })}
</div>
)}
</div>
</SettingRow>
);
}