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:
1 parent
8a2febf59d
commit
006cb78a93
14 files changed
+599
-50
No files matched your search
@@ -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 没触
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -746,6 +746,9 @@ export const ja: typeof zhCN = {
|
||||
microphoneMonitorError: '入力レベルの監視に失敗:{{message}}',
|
||||
capsuleLabel: '録音カプセル',
|
||||
capsuleDesc: '録音 / 転写中、画面下部に半透明のカプセルを表示。',
|
||||
capsuleStyleLabel: 'カプセルスタイル',
|
||||
capsuleStyleSiri: '光条 Siri スタイル',
|
||||
capsuleStyleClassic: 'Openless デフォルトスタイル',
|
||||
muteDuringRecordingLabel: '録音中はミュート',
|
||||
muteDuringRecordingDesc: '録音中にシステム出力を一時的にミュートし、スピーカーのエコーを防ぎます。',
|
||||
audioCueLabel: '録音開始音',
|
||||
|
||||
@@ -746,6 +746,9 @@ export const ko: typeof zhCN = {
|
||||
microphoneMonitorError: '입력 레벨 모니터링 실패: {{message}}',
|
||||
capsuleLabel: '녹음 캡슐',
|
||||
capsuleDesc: '녹음 / 전사 중 화면 하단에 반투명 캡슐을 표시합니다.',
|
||||
capsuleStyleLabel: '캡슐 스타일',
|
||||
capsuleStyleSiri: '시리 광선 스타일',
|
||||
capsuleStyleClassic: 'Openless 기본 스타일',
|
||||
muteDuringRecordingLabel: '녹음 중 음소거',
|
||||
muteDuringRecordingDesc: '녹음 중 시스템 출력을 일시적으로 음소거하여 스피커 에코를 방지합니다.',
|
||||
audioCueLabel: '녹음 시작음',
|
||||
|
||||
@@ -742,6 +742,9 @@ export const zhCN = {
|
||||
microphoneMonitorError: '输入电平监听失败:{{message}}',
|
||||
capsuleLabel: '录音胶囊',
|
||||
capsuleDesc: '录音 / 转写时显示屏幕底部胶囊。',
|
||||
capsuleStyleLabel: '胶囊样式',
|
||||
capsuleStyleSiri: '流光 Siri 风格',
|
||||
capsuleStyleClassic: 'Openless 默认风格',
|
||||
muteDuringRecordingLabel: '录音时静音',
|
||||
muteDuringRecordingDesc: '录音期间临时静音系统输出,避免扬声器回音。',
|
||||
audioCueLabel: '录音提示音',
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
Reference in new issue
Block a user