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>
This commit is contained in:
TRIPandsim authored and GitHub committed 2026-08-03 14:17:52 +08:00
1 parent 8a2febf59d
commit 006cb78a93
14 files changed
+599 -50

No files matched your search

+16 -3
View File
@@ -9,7 +9,7 @@
//! insertion, persists history, emits `capsule:state` events to the capsule
//! window.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use std::time::Instant;
@@ -55,8 +55,9 @@ use crate::recorder::{Recorder, RecorderError};
#[cfg(target_os = "windows")]
use crate::types::PasteShortcut;
use crate::types::{
CapsulePayload, CapsuleState, ChineseScriptPreference, DictationSession, HotkeyCapability,
HotkeyStatus, HotkeyStatusState, InsertStatus, OutputLanguagePreference, PolishMode,
CapsulePayload, CapsuleState, CapsuleStyle, ChineseScriptPreference, DictationSession,
HotkeyCapability, HotkeyStatus, HotkeyStatusState, InsertStatus, OutputLanguagePreference,
PolishMode,
};
#[cfg(target_os = "windows")]
use crate::windows_ime_ipc::ImeSubmitTarget;
@@ -554,6 +555,14 @@ struct Inner {
/// `warming` 打成 true(前端渲染"待命"光效);`level_handler` 首次触发(PCM 真的
/// 流入)后置 false,光条"点亮"进入正式录音。begin_session 每次入场重置为 true。
capsule_warming: AtomicBool,
/// 用户选择的胶囊样式缓存(0=Siri,1=Classic)。emit_capsule 在音频回调线程
/// ~30Hz 读它下发 payload.capsuleStyle;主线程闭包每帧从 prefs 同步该值——
/// 读偏好锁的代价只落在主线程(与 show_capsule 同源),音频线程零开销。
capsule_style: AtomicU8,
/// 胶囊窗口当前是否鼠标穿透(true=穿透)。经典药丸需要接收 ✕/✓ 点击时,主线程
/// 闭包把它翻 false;离开可交互状态立即恢复 true。初始 true 与 lib.rs 启动时
/// set_ignore_cursor_events(true) 保持一致。
capsule_cursor_passthrough: AtomicBool,
/// QA 用的 ASR 句柄。必须跟 active_asr_provider 保持一致,避免浮窗走不同入口。
qa_asr: Mutex<Option<SessionResource<ActiveAsr>>>,
/// QA 用的 Recorder 句柄。
@@ -749,6 +758,8 @@ impl Coordinator {
qa_state: Mutex::new(QaSessionState::default()),
capsule_layout: Mutex::new(None),
capsule_warming: AtomicBool::new(false),
capsule_style: AtomicU8::new(0),
capsule_cursor_passthrough: AtomicBool::new(true),
qa_asr: Mutex::new(None),
qa_recorder: Mutex::new(None),
qa_stream_cancelled: Arc::new(AtomicBool::new(false)),
@@ -863,6 +874,8 @@ impl Coordinator {
qa_state: Mutex::new(QaSessionState::default()),
capsule_layout: Mutex::new(None),
capsule_warming: AtomicBool::new(false),
capsule_style: AtomicU8::new(0),
capsule_cursor_passthrough: AtomicBool::new(true),
qa_asr: Mutex::new(None),
qa_recorder: Mutex::new(None),
qa_stream_cancelled: Arc::new(AtomicBool::new(false)),
@@ -534,6 +534,12 @@ fn emit_capsule_with_context_locked(
operating,
warming,
selection_polish,
// 用户选择的胶囊样式:读 Inner 上的原子缓存(主线程闭包每帧从 prefs 同步),
// 不在音频回调线程碰偏好锁。设置里切换后下一次录音即生效。
capsule_style: match inner.capsule_style.load(Ordering::Relaxed) {
1 => CapsuleStyle::Classic,
_ => CapsuleStyle::Siri,
},
};
#[cfg(target_os = "android")]
@@ -652,6 +658,9 @@ fn emit_capsule_with_context_locked(
let app_for_main = app.clone();
// 入场帧要在 window.show 之后、闭包内部把 state 回发给前端,需要 payload 的独立副本
// move 进闭包;非入场帧走闭包外的即时同步 emit(下方),这里就是 None。
// 注意:入场帧的 payload 在闭包同步 capsule_style 原子之前克隆,最多带一帧旧样式
//(设置里刚切换后的首次录音,第 2 帧 ~33ms 即纠正)。这是刻意取舍——不要在音频
// 线程改回直接读 prefs。前端第 1 帧处于 capsule-in 动画期间(380ms),无感知。
let payload_for_deferred_emit = if defer_capsule_emit {
Some(payload.clone())
} else {
@@ -671,7 +680,16 @@ fn emit_capsule_with_context_locked(
};
// `show_capsule` 是原有“录音胶囊”偏好;Selection Polish 没有独立开关,且它的
// 无选区/失败提示是这条无界面工作流的唯一反馈,所以始终展示轻量提示。
let show_capsule = selection_polish || inner_for_main.prefs.get().show_capsule;
let prefs_snapshot = inner_for_main.prefs.get();
let show_capsule = selection_polish || prefs_snapshot.show_capsule;
// 把胶囊样式同步进 Inner 原子缓存:emit_capsule(音频回调线程)从这里读
// payload.capsuleStyle,避免在音频线程碰偏好锁。主线程每帧克隆 prefs 本就有
//(show_capsule 同源),多读一个字段零额外代价。
let classic_style = matches!(prefs_snapshot.capsule_style, CapsuleStyle::Classic);
inner_for_main.capsule_style.store(
if classic_style { 1 } else { 0 },
Ordering::Relaxed,
);
// Linux: 不操作胶囊窗口(不 show/hide,不 reposition)。
// 文字通过 fcitx5 插件直接 commit,用户始终在目标 app 中。
#[cfg(target_os = "linux")]
@@ -687,6 +705,27 @@ fn emit_capsule_with_context_locked(
// `hide_capsule_window_if_present()` Win32 hard-hide 在 visible=false 分支
// 处理,不依赖把 Done/Cancelled/Error 打成 invisible。详见 PR #140 评论。
maybe_position_capsule_bottom_center(&inner_for_main, &window, translation);
// 经典药丸(Openless 默认风格)的 ✕/✓ 按钮需要接收点击:录音/转写/润色期间
// 关掉鼠标穿透(按钮可点,代价是窗口底部 460×180 区域在这几秒内拦截点击——
// 与 1.3.x 经典胶囊同款取舍);终态 toast、隐藏、Siri 光效、选区润色提示一律
// 保持穿透,不遮挡底层 app。set_ignore_cursor_events 只在值变化时调用一次。
let interactive = classic_style
&& visible
&& !selection_polish
&& matches!(
state,
CapsuleState::Recording | CapsuleState::Transcribing | CapsuleState::Polishing
);
let want_passthrough = !interactive;
if inner_for_main
.capsule_cursor_passthrough
.swap(want_passthrough, Ordering::SeqCst)
!= want_passthrough
{
if let Err(e) = window.set_ignore_cursor_events(want_passthrough) {
log::warn!("[capsule] set_ignore_cursor_events failed: {e}");
}
}
if show_capsule && visible {
// 用户报"看不到胶囊"时第一时间能在 log 里确认:胶囊路径有跑、show_capsule
// 开关是 true、当前进入 visible 帧 —— 排除 prefs 没存住 / emit_capsule 没触
+25
View File
@@ -673,6 +673,10 @@ pub struct UserPreferences {
pub custom_style_prompts: CustomStylePrompts,
pub launch_at_login: bool,
pub show_capsule: bool,
/// 录音胶囊样式:'siri' = 流光 Siri 光效版(默认);'classic' = Openless 经典药丸版。
/// 由 capsule:state 事件的 capsuleStyle 字段下发到胶囊 webview,下次录音即生效。
#[serde(default)]
pub capsule_style: CapsuleStyle,
/// 录音期间临时静音系统输出,停止/取消/出错后恢复原静音状态。
#[serde(default)]
pub mute_during_recording: bool,
@@ -1035,6 +1039,8 @@ struct UserPreferencesWire {
launch_at_login: bool,
show_capsule: bool,
#[serde(default)]
capsule_style: CapsuleStyle,
#[serde(default)]
mute_during_recording: bool,
#[serde(default = "default_true")]
audio_cue_on_record: bool,
@@ -1200,6 +1206,7 @@ impl Default for UserPreferencesWire {
custom_style_prompts: prefs.custom_style_prompts,
launch_at_login: prefs.launch_at_login,
show_capsule: prefs.show_capsule,
capsule_style: prefs.capsule_style,
mute_during_recording: prefs.mute_during_recording,
audio_cue_on_record: prefs.audio_cue_on_record,
microphone_device_name: prefs.microphone_device_name,
@@ -1323,6 +1330,7 @@ impl<'de> Deserialize<'de> for UserPreferences {
custom_style_prompts: wire.custom_style_prompts,
launch_at_login: wire.launch_at_login,
show_capsule: wire.show_capsule,
capsule_style: wire.capsule_style,
mute_during_recording: wire.mute_during_recording,
audio_cue_on_record: wire.audio_cue_on_record,
microphone_device_name: wire.microphone_device_name,
@@ -2140,6 +2148,7 @@ impl Default for UserPreferences {
custom_style_prompts: CustomStylePrompts::default(),
launch_at_login: false,
show_capsule: true,
capsule_style: CapsuleStyle::Siri,
mute_during_recording: false,
audio_cue_on_record: true,
microphone_device_name: String::new(),
@@ -2855,6 +2864,18 @@ pub enum CapsuleState {
Error,
}
/// 录音胶囊样式。由 UserPreferences.capsule_style 透传到 capsule:state payload,
/// 胶囊 webview 据此选择渲染流光 Siri 光效舞台还是经典药丸。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum CapsuleStyle {
/// 流光 Siri 风格:SiriGL 光效舞台(默认)。
#[default]
Siri,
/// Openless 默认风格:经典毛玻璃药丸(音量条 + 取消/确认按钮)。
Classic,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CapsulePayload {
@@ -2876,6 +2897,10 @@ pub struct CapsulePayload {
/// false,光条"点亮"进入正式录音态。只对 Recording 状态有意义。详见胶囊出现时序改造。
#[serde(default)]
pub warming: bool,
/// 用户选择的胶囊样式(siri / classic)。随每次状态事件下发,设置里切换后下一次
/// 录音即生效,胶囊 webview 无需额外请求。
#[serde(default)]
pub capsule_style: CapsuleStyle,
/// 选区润色专用的轻量反馈。它与原有语音/QA 会话共用同一扇不抢焦点的 capsule
/// 窗口,但前端据此切换为一行状态提示,避免改变既有语音光效与文案。
#[serde(default)]
+458 -13
View File
@@ -1,13 +1,24 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from 'react';
import { useTranslation } from 'react-i18next';
import { detectOS, type OS } from './WindowChrome';
import { SiriGL, warmUpSiriShaders } from './SiriGL';
import { cancelDictation, stopDictation } from '../lib/ipc/dictation';
import {
getCapsuleHostMetrics,
getCapsuleMessageLayout,
getCapsulePillMetrics,
} from '../lib/capsuleLayout';
import { isTauri } from '../lib/ipc';
import type { CapsulePayload, CapsuleState } from '../lib/types';
import type { CapsulePayload, CapsuleState, CapsuleStyle } from '../lib/types';
// 胶囊 keyframes 注入一次到 document.head,而不是放在组件 JSX 里。否则录音时音量
// 每帧(~60Hz)setLevel 都会让 React 重新创建/reconcile 这个 <style> 元素 —— 纯属
@@ -31,6 +42,17 @@ const CAPSULE_KEYFRAMES = `
@keyframes selection-polish-spinner {
to { transform: rotate(360deg); }
}
/* 经典药丸(Openless 默认风格)专属:
- cap-shine:转写/润色中"thinking" 文字的蓝光扫过;
- cap-state-enter:状态内容切换时的淡入。 */
@keyframes cap-shine {
0% { background-position: 200% center; }
100% { background-position: -200% center; }
}
@keyframes cap-state-enter {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
`;
if (typeof document !== 'undefined' && !document.getElementById('capsule-keyframes')) {
@@ -256,9 +278,401 @@ function SelectionPolishNotice({ state, message }: SelectionPolishNoticeProps) {
);
}
// 与 @keyframes capsule-out 的 0.52s 时长一致——必须同步,否则定时器先于
// 动画结束就 unmount → 用户看到半截动画被截断。
const EXIT_ANIM_MS = 520;
// ───────── 经典药丸(Openless 默认风格)───────────────────────────────
// 从 1.3.14 的胶囊实现恢复:毛玻璃药丸 + 音量条 + 取消/确认按钮。尺寸与当时
// capsuleLayout 的常量保持一致(Siri 光效舞台是 460×180 全幅,经典版是居中小药丸)。
interface ClassicPillMetrics {
width: number;
height: number;
textWidth: number;
}
const CLASSIC_PILL_METRICS: ClassicPillMetrics = {
width: 176,
height: 42,
textWidth: 84,
};
const CLASSIC_PILL_METRICS_WIN: ClassicPillMetrics = {
width: 196,
height: 52,
textWidth: 104,
};
function classicPillMetrics(os: OS): ClassicPillMetrics {
return os === 'win' ? CLASSIC_PILL_METRICS_WIN : CLASSIC_PILL_METRICS;
}
function AudioBars({ level }: { level: number }) {
const envelope = [0.55, 0.85, 1.0, 0.85, 0.55];
const base = 2;
const max = 24;
const voice = Math.min(1, Math.max(0, level));
const silenceGate = 0.012;
const responseCeiling = 0.34;
const gatedVoice = Math.min(1, Math.max(0, (voice - silenceGate) / (responseCeiling - silenceGate)));
const easedVoice = gatedVoice * gatedVoice * (3 - 2 * gatedVoice);
const visualVoice = Math.pow(easedVoice, 0.42);
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 3,
width: 42,
height: max,
}}
>
{envelope.map((env, i) => (
<span
key={i}
style={{
display: 'inline-block',
width: 3,
height: base + (max - base) * visualVoice * env,
borderRadius: 999,
background: 'var(--ol-blue)',
opacity: 0.82,
transformOrigin: 'center',
// 0.08s 在 60Hz audio-level 更新下太快,每次 re-render 都重启 transition,
// 视觉上是阶梯式跳变。延长到 0.18s 让多次 update 在曲线内平滑混合,
// easeOutExpo-like 缓动让圆点→长条的形变自然顺滑(用户原话"圆形跳成矩形")。
transition: 'height 0.18s cubic-bezier(0.22, 1, 0.36, 1)',
}}
/>
))}
</div>
);
}
interface CenterTextProps {
os: OS;
kind: 'default' | 'processing' | 'error';
text: string;
color?: string;
}
function CenterText({ os, kind, text, color = 'var(--ol-capsule-center-ink)' }: CenterTextProps) {
const metrics = classicPillMetrics(os);
const layout = getCapsuleMessageLayout(os, kind);
return (
<span
style={{
fontSize: 11,
fontWeight: 500,
color,
width: '100%',
maxWidth: metrics.textWidth,
minWidth: 0,
textAlign: 'center',
lineHeight: layout.allowWrap ? 1.2 : 1,
whiteSpace: layout.allowWrap ? 'normal' : 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: layout.lineClamp,
}}
>
{text}
</span>
);
}
interface CircleButtonProps {
variant: 'cancel' | 'confirm';
enabled: boolean;
onClick: () => void;
}
// memo:录音时 level 每帧(~60Hz)变化会重渲 Pill;cancel/confirm 两个 SVG 按钮跟
// level 无关,memo + 稳定的 onClick 让它们在录音期间跳过重渲(只剩音量条真正更新)。
const CircleButton = memo(function CircleButton({ variant, enabled, onClick }: CircleButtonProps) {
const { t } = useTranslation();
const isCancel = variant === 'cancel';
return (
<button
onClick={enabled ? onClick : undefined}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
aria-label={isCancel ? t('common.cancel') : t('settings.shortcuts.confirm')}
disabled={!enabled}
style={{
width: 28,
height: 28,
borderRadius: 999,
background: isCancel ? 'var(--ol-capsule-btn-bg)' : 'var(--ol-capsule-btn-bg-confirm)',
color: 'var(--ol-capsule-btn-ink)',
border: '0.8px solid var(--ol-capsule-btn-border)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
cursor: enabled ? 'default' : 'not-allowed',
opacity: enabled ? 1 : 0.42,
visibility: 'visible',
flexShrink: 0,
padding: 0,
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.06)',
transition: 'opacity 0.18s var(--ol-motion-soft), background 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick)',
}}
>
{isCancel ? (
<svg width="11" height="11" viewBox="0 0 11 11">
<path d="M1.5 1.5l8 8M9.5 1.5l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
) : (
<svg width="13" height="13" viewBox="0 0 13 13">
<path d="M2 6.5l3.2 3.5L11 3.5" stroke="currentColor" strokeWidth="1.7" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</button>
);
});
interface ClassicPillProps {
os: OS;
state: CapsuleState;
level: number;
insertedChars: number;
message?: string;
operating?: boolean;
onCancel: () => void;
onConfirm: () => void;
}
function ClassicPill({ os, state, level, insertedChars, message, operating, onCancel, onConfirm }: ClassicPillProps) {
const { t } = useTranslation();
const metrics = classicPillMetrics(os);
const processingLayout = useMemo(() => getCapsuleMessageLayout(os, 'processing'), [os]);
const cancelEnabled = state === 'recording' || state === 'transcribing' || state === 'polishing';
const confirmEnabled = state === 'recording';
// "thinking" 扫光速度:进入 transcribing/polishing 的头 2 秒走快速(0.9s/cycle,提示
// 「流式刚开始」),之后切回慢速(2.4s)作为稳态。切回 idle / done / 其他 state 也复位
// 为 fast,下次进入时从头开始 burst。
const [shineFast, setShineFast] = useState(true);
useEffect(() => {
if (state === 'transcribing' || state === 'polishing') {
setShineFast(true);
const t = setTimeout(() => setShineFast(false), 2000);
return () => clearTimeout(t);
}
setShineFast(true);
return undefined;
}, [state]);
let center: ReactNode;
switch (state) {
case 'recording':
center = <AudioBars level={level} />;
break;
case 'transcribing':
case 'polishing':
center = (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
// 左右 4px 内边距 + 外层 gap 已经让 "thinking" ↔ ✗/✓ 视觉间距落在 ~4-5px。
padding: '0 4px',
width: '100%',
maxWidth: metrics.textWidth,
minWidth: 0,
justifyContent: 'center',
// state 进入动画 —— 用户从 recording 切到 polishing 时多一道淡入提示,
// 比纯切换 center 内容更容易被感知。
animation: 'cap-state-enter 220ms var(--ol-motion-soft) both',
}}
>
<span
style={{
// v1.3.1-7 用户拍板:黑色底字 + 蓝色扫光(亮黄太显眼,黑底更稳)。
// 字号保持 17,字重 700 → 600 稍细一些。
fontSize: 17,
fontWeight: 600,
letterSpacing: 0.3,
// line-height: 1 下 g/y/p 等下伸字符会被 clip,给 padding 留 descender 空间。
paddingBlock: 1,
color: 'var(--ol-ink-2)',
backgroundImage:
'linear-gradient(100deg, var(--ol-ink) 0%, var(--ol-ink) 35%, var(--ol-blue) 50%, var(--ol-ink) 65%, var(--ol-ink) 100%)',
backgroundSize: '220% auto',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
WebkitTextFillColor: 'transparent',
// 进入流式的头 ~2 秒用 0.9s 高速扫光(视觉提示「刚开始」),之后 React 副作用
// 切到 2.4s 慢速。duration 变化时浏览器不重启动画,会平滑减速。
animation: `cap-shine ${shineFast ? '0.9s' : '2.4s'} linear infinite`,
minWidth: 0,
textAlign: 'center',
lineHeight: processingLayout.allowWrap ? 1.3 : 1.25,
whiteSpace: processingLayout.allowWrap ? 'normal' : 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: processingLayout.lineClamp,
}}
>
{t(operating ? 'capsule.using' : 'capsule.thinking')}
</span>
</div>
);
break;
case 'done':
center = <CenterText os={os} kind="default" text={message || t('capsule.inserted', { count: insertedChars })} />;
break;
case 'cancelled':
center = <CenterText os={os} kind="default" text={t('capsule.cancelled')} />;
break;
case 'error':
center = <CenterText os={os} kind="error" text={message || t('capsule.error')} color="var(--ol-err)" />;
break;
default:
center = <AudioBars level={0} />;
}
const ambient = state === 'recording' ? Math.min(1, Math.max(0, level)) : 0;
const scale = os === 'win' ? 1 : 1 + ambient * 0.018;
const shadowAlpha = 0.20 + ambient * 0.10;
return (
// 非 Linux 走假毛玻璃;Linux 禁用透明窗口后由 .ol-frost 平台规则退成不透明面。
// 不写 backdrop-filter —— webview 模糊不了透明窗口背后的桌面(Tauri 上游限制)。
<div
className="ol-frost ol-capsule-pill"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 4,
padding: '0 8px',
width: metrics.width,
height: metrics.height,
boxSizing: 'border-box',
borderRadius: 999,
border: '1px solid var(--ol-capsule-pill-border)',
boxShadow: `${os === 'win' ? `0 10px 24px -14px rgba(0, 0, 0, ${(0.24 + ambient * 0.06).toFixed(3)})` : `0 18px 50px -10px rgba(0, 0, 0, ${shadowAlpha.toFixed(3)})`}, 0 0 0 0.5px rgba(0, 0, 0, 0.24), var(--ol-capsule-pill-inset)`,
color: 'var(--ol-capsule-center-ink)',
fontFamily: 'var(--ol-font-sans)',
transform: `scale(${scale.toFixed(4)})`,
transformOrigin: 'center',
transition: 'transform 0.08s var(--ol-motion-quick), box-shadow 0.08s var(--ol-motion-quick)',
willChange: 'transform, box-shadow',
}}
>
<CircleButton variant="cancel" enabled={cancelEnabled} onClick={onCancel} />
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{center}
</div>
<CircleButton variant="confirm" enabled={confirmEnabled} onClick={onConfirm} />
</div>
);
}
interface ClassicCapsuleProps {
os: OS;
state: CapsuleState;
level: number;
insertedChars: number;
message?: string;
operating?: boolean;
translation: boolean;
}
/**
* 经典药丸 + 「正在翻译」徽章(与 1.3.14 一致)。取消/确认按钮直接调 dictation
* 命令(胶囊窗口本身不抢焦点,按钮交互只出现在录音进行中)。
*/
function ClassicCapsule({
os,
state,
level,
insertedChars,
message,
operating,
translation,
}: ClassicCapsuleProps) {
const { t } = useTranslation();
const metrics = classicPillMetrics(os);
const hostMetrics = getCapsuleHostMetrics(os, false);
const onCancel = useCallback(() => {
void cancelDictation();
}, []);
const onConfirm = useCallback(() => {
void stopDictation();
}, []);
return (
<>
{/* "正在翻译" 徽章 — 嵌套两层:
外层只负责"绝对定位 + 水平居中(translateX(-50%))",不参与动画;
内层只负责"垂直位移 + 渐变透明度"——这样不会跟 translateX(-50%) 冲突,
也不存在 keyframe 与 inline transform 互相覆盖导致的视觉跳变。 */}
<div
style={{
position: 'absolute',
left: '50%',
// macOS / Linux:pill 居中在 460×180 host,badge 锚到 pill 中线上方 21+8。
// Windows:pill 更高(52),badge 锚到 pill 上沿(bottomInset + height + gap)。
bottom: os === 'win'
? `${hostMetrics.bottomInset + metrics.height + hostMetrics.badgeGap}px`
: 'calc(50% + 21px + 8px)',
transform: 'translateX(-50%)',
pointerEvents: 'none',
}}
>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 5,
padding: '3px 10px',
borderRadius: 999,
fontSize: 10.5,
fontWeight: 600,
color: 'var(--ol-blue)',
background: 'var(--ol-capsule-badge-bg)',
// issue #470:去掉无效的 backdrop-filter —— webview 模糊不了透明窗口背后的桌面
// (Tauri 上游限制,同 pill 注释),纯空耗合成,删除零视觉变化。
border: '0.5px solid var(--ol-capsule-badge-border)',
boxShadow: '0 4px 12px -4px rgba(37, 99, 235, 0.25), 0 0 0 0.5px rgba(0,0,0,0.04)',
letterSpacing: '0.02em',
whiteSpace: 'nowrap',
// 隐藏:从 pill 中线偏下出发;显示:归位到 pill 上方。
opacity: translation ? 1 : 0,
transform: translation ? 'translateY(0) scale(1)' : 'translateY(40px) scale(.88)',
transformOrigin: 'center bottom',
transition: 'opacity .24s ease-out, transform .34s cubic-bezier(.2,.9,.3,1.1)',
willChange: 'opacity, transform',
}}
>
<span style={{ width: 5, height: 5, borderRadius: 999, background: 'var(--ol-blue)' }} />
{t('capsule.translating')}
</div>
</div>
<ClassicPill
os={os}
state={state}
level={level}
insertedChars={insertedChars}
message={message}
operating={operating}
onCancel={onCancel}
onConfirm={onConfirm}
/>
</>
);
}
// 与 @keyframes capsule-out 的时长一致——必须同步,否则定时器先于动画结束就 unmount →
// 用户看到半截动画被截断。两种样式沿用同一组 capsule-in/out keyframes,只是时长不同:
// Siri 光效 520ms(现状),经典药丸 360ms(与 1.3.14 一致)。
const EXIT_ANIM_MS_SIRI = 520;
const EXIT_ANIM_MS_CLASSIC = 360;
// 胶囊「预备→就绪」的平均耗时(ms),驱动入场展开动画的预测节奏(见 SiriGL warmProgress):
// 每次录音就绪后按 EMA 更新并存 localStorage 跨会话学习。默认 150ms,clamp [60,600]。
@@ -291,6 +705,7 @@ function getPreviewCapsulePayload() {
translation: false,
warming: false,
selectionPolish: false,
style: 'siri' as CapsuleStyle,
};
}
@@ -307,6 +722,8 @@ function getPreviewCapsulePayload() {
translation: params.get('translation') === '1',
warming: params.get('warming') === '1',
selectionPolish: params.get('selectionPolish') === '1',
// 浏览器预览:?style=classic 直接看经典药丸。
style: params.get('style') === 'classic' ? ('classic' as CapsuleStyle) : ('siri' as CapsuleStyle),
};
}
@@ -324,14 +741,21 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
const [message, setMessage] = useState<string | undefined>(preview.message);
const [translation, setTranslation] = useState<boolean>(preview.translation);
const [selectionPolish, setSelectionPolish] = useState<boolean>(preview.selectionPolish);
// 胶囊样式(siri / classic):随 capsule:state payload 下发,设置里切换后下一次录音生效。
const [capsuleStyle, setCapsuleStyle] = useState<CapsuleStyle>(preview.style);
const isClassic = capsuleStyle === 'classic';
// 预备态:麦克风尚未吐第一帧 PCM。true 时录音光条走「待命」呼吸形态(见 SiriGL warming)。
const [warming, setWarming] = useState<boolean>(preview.warming);
// 预备→就绪耗时的移动平均,驱动光条展开动画的预测节奏(见 SiriGL warmProgress)。
const [warmupMs, setWarmupMs] = useState<number>(() => readWarmupMs());
const warmStartRef = useRef<number | null>(null);
// 经典药丸专属:done 态的「已插入 N 字」与 thinking/using 文案。payload 只在状态变化
// 时到达,用 ref 保存即可 —— 触发重渲的是 setState,这里只是给渲染读取终态值。
const insertedCharsRef = useRef(0);
const operatingRef = useRef(false);
// `leaving` 与 `lastVisibleState` 协同实现「退出动画」:
// - 当 state 从非 idle 变成 idle 时,不立即卸载,而是把 leaving 置为 true 并保留
// 最后一帧的可见 state(lastVisibleState),让语音 orb 用 capsule-out 动画收缩淡出。
// 最后一帧的可见 state(lastVisibleState),让胶囊用 capsule-out 动画收缩淡出。
// - 动画结束(EXIT_ANIM_MS)后再把 leaving 置回 false,组件回到「真正未挂载」分支。
// - 若期间 state 又切回非 idle(例如用户连按热键),立刻中止 leaving 并恢复显示。
const [leaving, setLeaving] = useState<boolean>(false);
@@ -340,8 +764,15 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
preview.selectionPolish,
);
const [lastVisibleMessage, setLastVisibleMessage] = useState<string | undefined>(preview.message);
// 退出动画时长跟随样式;leaving effect 故意只依赖 state,所以经 ref 读取最新值。
const exitMsRef = useRef(isClassic ? EXIT_ANIM_MS_CLASSIC : EXIT_ANIM_MS_SIRI);
exitMsRef.current = isClassic ? EXIT_ANIM_MS_CLASSIC : EXIT_ANIM_MS_SIRI;
const exitMs = exitMsRef.current;
// 前端 host 与原生窗口保持同一份透明语音 orb 舞台尺寸。
const hostMetrics = getCapsuleHostMetrics(os, translation);
// Windows 端 host 用「host 高 − pill 高」把 pill 垂直居中;Siri 舞台 460×180 与 host
// 等高(padding 0),经典药丸则在 180 高的窗口里垂直居中。
const pillMetrics = isClassic ? classicPillMetrics(os) : metrics;
const badgeBottom = Math.round(metrics.height * 0.73);
// 空闲预热 shader 编译缓存:首次按下热键时光条不用现场编译(响应延迟反馈)。
@@ -376,6 +807,9 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
setTranslation(p.translation === true);
setWarming(p.warming === true);
setSelectionPolish(p.selectionPolish === true);
if (p.capsuleStyle != null) setCapsuleStyle(p.capsuleStyle);
if (p.insertedChars != null) insertedCharsRef.current = p.insertedChars;
operatingRef.current = p.operating === true;
});
if (cancelled) handle();
else unlisten = handle;
@@ -386,7 +820,8 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
};
}, []);
// 退出动画调度:在 state 真正进入 idle 时,先用 capsule-out 播放 EXIT_ANIM_MS,再卸载。
// 退出动画调度:在 state 真正进入 idle 时,先用 capsule-out 播放
// EXIT_ANIM_MS_SIRI / EXIT_ANIM_MS_CLASSIC(按当前样式,经 exitMsRef 读取),再卸载。
// 设计要点:
// 1. 进入非 idle:清掉 leaving,记录最新可见 state;
// 2. 进入 idle 且之前可见:开启 leaving 并启动定时器;
@@ -405,7 +840,7 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
const timer = setTimeout(() => {
setLeaving(false);
setLastVisibleState('idle');
}, EXIT_ANIM_MS);
}, exitMsRef.current);
return () => clearTimeout(timer);
// 故意只依赖 state —— lastVisibleState / leaving 是内部派生量,
// 把它们加进依赖会让定时器被反复重建。
@@ -413,7 +848,7 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
}, [state]);
// Idle payload 不携带终态文案。保留最近一帧可见 selection 提示,才能让 auto-hide
// 触发后的 520ms 离场动画继续显示“已替换 / 未选中内容”等正确反馈,而不闪回语音舞台。
// 触发后的离场动画继续显示“已替换 / 未选中内容”等正确反馈,而不闪回语音舞台。
useEffect(() => {
if (state === 'idle') return;
setLastVisibleSelectionPolish(selectionPolish);
@@ -463,12 +898,12 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
paddingRight: hostMetrics.horizontalInset,
boxSizing: hostMetrics.boxSizing,
paddingTop: os === 'win'
? Math.max(0, hostMetrics.height - metrics.height - hostMetrics.bottomInset)
? Math.max(0, hostMetrics.height - pillMetrics.height - hostMetrics.bottomInset)
: 0,
paddingBottom: os === 'win' ? hostMetrics.bottomInset : 0,
background: 'transparent',
animation: leaving
? 'capsule-out .52s cubic-bezier(.55,.06,.68,.19) forwards'
? `capsule-out ${exitMs}ms cubic-bezier(.55,.06,.68,.19) forwards`
// .68s 的入场被反馈「按下之后有延迟」:压到 .38s,曲线保持轻微弹性,
// 光条几乎跟手出现。
: 'capsule-in .38s cubic-bezier(.3,1.2,.4,1) both',
@@ -476,7 +911,17 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
willChange: 'transform, opacity',
}}
>
{!renderedSelectionPolish && (
{!renderedSelectionPolish && (isClassic ? (
<ClassicCapsule
os={os}
state={renderedState}
level={leaving ? 0 : level}
insertedChars={insertedCharsRef.current}
message={renderedMessage}
operating={operatingRef.current}
translation={translation}
/>
) : (
<>
{/* "正在翻译" 徽章 — 嵌套两层:
外层只负责"绝对定位 + 水平居中(translateX(-50%))",不参与动画;
@@ -532,7 +977,7 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
message={renderedMessage}
/>
</>
)}
))}
{renderedSelectionPolish && (
<SelectionPolishNotice state={renderedState} message={renderedMessage} />
)}
+3
View File
@@ -744,6 +744,9 @@ export const en: typeof zhCN = {
microphoneMonitorError: 'Failed to monitor input level: {{message}}',
capsuleLabel: 'Recording capsule',
capsuleDesc: 'Show a translucent capsule at the bottom of the screen while recording.',
capsuleStyleLabel: 'Capsule style',
capsuleStyleSiri: 'Shimmer Siri style',
capsuleStyleClassic: 'OpenLess default style',
muteDuringRecordingLabel: 'Mute while recording',
muteDuringRecordingDesc: 'Temporarily mute system output during voice input to avoid speaker echo.',
audioCueLabel: 'Recording start sound',
+3
View File
@@ -746,6 +746,9 @@ export const ja: typeof zhCN = {
microphoneMonitorError: '入力レベルの監視に失敗:{{message}}',
capsuleLabel: '録音カプセル',
capsuleDesc: '録音 / 転写中、画面下部に半透明のカプセルを表示。',
capsuleStyleLabel: 'カプセルスタイル',
capsuleStyleSiri: '光条 Siri スタイル',
capsuleStyleClassic: 'Openless デフォルトスタイル',
muteDuringRecordingLabel: '録音中はミュート',
muteDuringRecordingDesc: '録音中にシステム出力を一時的にミュートし、スピーカーのエコーを防ぎます。',
audioCueLabel: '録音開始音',
+3
View File
@@ -746,6 +746,9 @@ export const ko: typeof zhCN = {
microphoneMonitorError: '입력 레벨 모니터링 실패: {{message}}',
capsuleLabel: '녹음 캡슐',
capsuleDesc: '녹음 / 전사 중 화면 하단에 반투명 캡슐을 표시합니다.',
capsuleStyleLabel: '캡슐 스타일',
capsuleStyleSiri: '시리 광선 스타일',
capsuleStyleClassic: 'Openless 기본 스타일',
muteDuringRecordingLabel: '녹음 중 음소거',
muteDuringRecordingDesc: '녹음 중 시스템 출력을 일시적으로 음소거하여 스피커 에코를 방지합니다.',
audioCueLabel: '녹음 시작음',
+3
View File
@@ -742,6 +742,9 @@ export const zhCN = {
microphoneMonitorError: '输入电平监听失败:{{message}}',
capsuleLabel: '录音胶囊',
capsuleDesc: '录音 / 转写时显示屏幕底部胶囊。',
capsuleStyleLabel: '胶囊样式',
capsuleStyleSiri: '流光 Siri 风格',
capsuleStyleClassic: 'Openless 默认风格',
muteDuringRecordingLabel: '录音时静音',
muteDuringRecordingDesc: '录音期间临时静音系统输出,避免扬声器回音。',
audioCueLabel: '录音提示音',
+3
View File
@@ -755,6 +755,9 @@ export const zhTW: typeof zhCN = {
microphoneMonitorError: '輸入電平監聽失敗:{{message}}',
capsuleLabel: '錄音膠囊',
capsuleDesc: '錄音 / 轉寫時在屏幕底部顯示半透明膠囊。',
capsuleStyleLabel: '膠囊樣式',
capsuleStyleSiri: '流光 Siri 風格',
capsuleStyleClassic: 'Openless 預設風格',
muteDuringRecordingLabel: '錄音時靜音',
muteDuringRecordingDesc: '錄音期間臨時靜音系統輸出,避免揚聲器回音。',
audioCueLabel: '錄音提示音',
@@ -42,6 +42,7 @@ export let mockSettings: UserPreferences = {
customStylePrompts: { raw: "", light: "", structured: "", formal: "" },
launchAtLogin: false,
showCapsule: true,
capsuleStyle: 'siri',
muteDuringRecording: false,
audioCueOnRecord: true,
microphoneDeviceName: "",
@@ -34,6 +34,7 @@ const previousPrefs: UserPreferences = {
customStylePrompts: { raw: '', light: '', structured: '', formal: '' },
launchAtLogin: false,
showCapsule: true,
capsuleStyle: 'siri',
muteDuringRecording: false,
audioCueOnRecord: true,
microphoneDeviceName: '',
+10
View File
@@ -281,6 +281,8 @@ export interface UserPreferences {
customStylePrompts: CustomStylePrompts;
launchAtLogin: boolean;
showCapsule: boolean;
/** 录音胶囊样式('siri' | 'classic')。见 CapsulePayload.capsuleStyle 的运行时下发。 */
capsuleStyle: CapsuleStyle;
/** 录音期间临时静音系统输出,停止/取消/出错后恢复原静音状态。 */
muteDuringRecording: boolean;
/** 按下录音热键进入 recording 状态时,播放一段合成提示音提醒「已开始录音」。
@@ -561,6 +563,9 @@ export type CapsuleState =
| 'cancelled'
| 'error';
/** 录音胶囊样式:'siri' = 流光 Siri 光效版(默认);'classic' = Openless 经典药丸版。 */
export type CapsuleStyle = 'siri' | 'classic';
export interface CapsulePayload {
state: CapsuleState;
level: number; // 0..1 RMS
@@ -577,6 +582,11 @@ export interface CapsulePayload {
* 再开口;麦克风就绪后翻 false,光条点亮进入正式录音。只对 recording 有意义。
*/
warming?: boolean;
/**
* 用户选择的胶囊样式(siri / classic)。随每次状态事件下发;缺失时回落默认
* 'siri',兼容旧后端 payload。
*/
capsuleStyle?: CapsuleStyle;
/**
* 选区润色复用 capsule 的无焦点原生窗口,但渲染为轻量状态提示;缺失时保持原有
* 语音/QA 胶囊行为,兼容旧后端 payload。
@@ -16,6 +16,7 @@ import {
} from '../../lib/ipc';
import { getPlatformCapabilities } from '../../lib/platform';
import type {
CapsuleStyle,
HotkeyMode,
MicrophoneDevice,
PasteShortcut,
@@ -284,6 +285,20 @@ export function RecordingInputSection() {
<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>
@@ -1,23 +1,17 @@
// 通用 → 选区润色:这是与录音输入并列的独立入口,快捷键、交付方式均不再藏在快捷键列表中。
import type { CSSProperties } from 'react';
import type { PlatformCapabilities, SelectionPolishOutputMode } from '../../lib/types';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ShortcutRecorder } from '../../components/ShortcutRecorder';
import { defaultSelectionPolishShortcut } from '../../lib/hotkey';
import { setSelectionPolishHotkey } from '../../lib/ipc';
import { getPlatformCapabilities } from '../../lib/platform';
import type { PlatformCapabilities, SelectionPolishOutputMode } from '../../lib/types';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { Card } from '../_atoms';
import { SectionTitle, SettingRow, chipSelectedStyle, segmentedTrackStyle } from './shared';
import { detectOS } from '../../components/WindowChrome';
const enableButton: CSSProperties = {
padding: '5px 14px', background: 'var(--ol-blue)', color: '#fff', border: 0,
borderRadius: 6, fontFamily: 'inherit', fontWeight: 500, cursor: 'default', fontSize: 12,
};
const outputOptions: Array<{ value: SelectionPolishOutputMode }> = [
{ value: 'directReplace' },
{ value: 'previewConfirm' },
@@ -43,32 +37,23 @@ export function SelectionPolishSection() {
label={t('settings.selectionPolish.hotkey', '触发快捷键')}
desc={t('settings.selectionPolish.hotkeyDesc')}
>
{prefs.selectionPolishHotkey ? (
<ShortcutRecorder
value={prefs.selectionPolishHotkey}
alignRecordButton
sideSpecificModifiers
modifierPresets={capability.availableTriggers ?? []}
onSave={async binding => {
await setSelectionPolishHotkey(binding);
await refresh();
}}
onDisable={async () => {
await setSelectionPolishHotkey(null);
await refresh();
}}
/>
) : (
<button
onClick={async () => {
await setSelectionPolishHotkey(defaultSelectionPolishShortcut());
await refresh();
}}
style={enableButton}
>
{t('settings.shortcuts.enable', '启用 Right Alt(可修改)')}
</button>
)}
<ShortcutRecorder
value={prefs.selectionPolishHotkey}
sideSpecificModifiers
onSave={async binding => {
await setSelectionPolishHotkey(binding);
await refresh();
}}
onDisable={async () => {
await setSelectionPolishHotkey(null);
await refresh();
}}
// 「重置」= 恢复默认快捷键,等价于旧版独立「启用」按钮。
onReset={async () => {
await setSelectionPolishHotkey(defaultSelectionPolishShortcut());
await refresh();
}}
/>
</SettingRow>
<SettingRow label={t('settings.selectionPolish.delivery', '结果处理方式')}>
<div style={{ ...segmentedTrackStyle, flexWrap: 'wrap', gap: 4 }}>