From 006cb78a9354ea1bf6bdcb2561d49d9b7836423b Mon Sep 17 00:00:00 2001 From: TRIP <1933142963@qq.com> Date: Mon, 3 Aug 2026 14:17:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(capsule):=20=E5=BD=95=E9=9F=B3=E8=83=B6?= =?UTF-8?q?=E5=9B=8A=E6=A0=B7=E5=BC=8F=E5=8F=AF=E9=80=89=EF=BC=88=E6=B5=81?= =?UTF-8?q?=E5=85=89=20Siri=20/=20Openless=20=E9=BB=98=E8=AE=A4=EF=BC=89+?= =?UTF-8?q?=20=E4=BF=AE=E5=A4=8D=20beta=20=E6=9E=84=E5=BB=BA=E7=A0=B4?= =?UTF-8?q?=E5=9D=8F=20(#887)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- openless-all/app/src-tauri/src/coordinator.rs | 19 +- .../src/coordinator/capsule_focus.rs | 41 +- openless-all/app/src-tauri/src/types.rs | 25 + openless-all/app/src/components/Capsule.tsx | 471 +++++++++++++++++- openless-all/app/src/i18n/en.ts | 3 + openless-all/app/src/i18n/ja.ts | 3 + openless-all/app/src/i18n/ko.ts | 3 + openless-all/app/src/i18n/zh-CN.ts | 3 + openless-all/app/src/i18n/zh-TW.ts | 3 + openless-all/app/src/lib/ipc/mock-data.ts | 1 + openless-all/app/src/lib/stylePrefs.test.ts | 1 + openless-all/app/src/lib/types.ts | 10 + .../pages/settings/RecordingInputSection.tsx | 15 + .../pages/settings/SelectionPolishSection.tsx | 51 +- 14 files changed, 599 insertions(+), 50 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 209ec930..3c726ec2 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -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>>, /// 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)), diff --git a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs index a527a604..1bd0533f 100644 --- a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs +++ b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs @@ -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 没触 diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index a35028a4..2deee23a 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -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)] diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index b658fd37..d9b6138b 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -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 这个