diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 9b90d52e..4b8236b0 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -345,24 +345,40 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { }; let inner_for_level = Arc::clone(inner); + // 节流:电平回调本身约 185 Hz(cpal 默认音频块),全部转发到前端会让 CSS + // transition 互相覆盖、视觉上"被平均"成静止。限制为 ~30 Hz(33ms 最少间隔), + // 配合 CSS 短 transition 让每次 emit 完整可见。 + let last_emit_at = Arc::new(Mutex::new(None::)); + const LEVEL_EMIT_MIN_INTERVAL_MS: u64 = 33; let level_handler: Arc = Arc::new(move |level| { let phase = inner_for_level.state.lock().phase; - if phase == SessionPhase::Listening || phase == SessionPhase::Starting { - let elapsed = inner_for_level - .state - .lock() - .started_at - .elapsed() - .as_millis() as u64; - emit_capsule( - &inner_for_level, - CapsuleState::Recording, - level, - elapsed, - None, - None, - ); + if phase != SessionPhase::Listening && phase != SessionPhase::Starting { + return; } + let now = Instant::now(); + { + let mut last = last_emit_at.lock(); + if let Some(prev) = *last { + if now.duration_since(prev).as_millis() < LEVEL_EMIT_MIN_INTERVAL_MS as u128 { + return; + } + } + *last = Some(now); + } + let elapsed = inner_for_level + .state + .lock() + .started_at + .elapsed() + .as_millis() as u64; + emit_capsule( + &inner_for_level, + CapsuleState::Recording, + level, + elapsed, + None, + None, + ); }); match Recorder::start(consumer, level_handler) { @@ -479,6 +495,13 @@ async fn end_session(inner: &Arc) -> Result<(), String> { 0 } }; + // 词汇本页面在打开时通常需要立即看到 hits 增长,否则用户得手动切走再切回来才刷新。 + // 命中数 > 0 时通知前端:Vocab 页面订阅 vocab:updated 即时 listVocab() 重新加载。 + if total_hits > 0 { + if let Some(app) = inner.app.lock().clone() { + let _ = app.emit("vocab:updated", total_hits); + } + } let session = DictationSession { id: Uuid::new_v4().to_string(), diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index 3806bd12..1da8cdf2 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -56,9 +56,9 @@ function AudioBars({ level }: AudioBarsProps) { borderRadius: 999, background: 'var(--ol-blue)', opacity: 0.82, - // 注:原版用 cubic-bezier(.5, 1.7, .5, 1) 近似 Swift 弹簧,但 overshoot 与 ~100Hz 电平 - // 更新叠加会出现可见抖动。改为非 overshoot 的 ease-out,单次稍硬但稳定不抽搐。 - transition: 'height 0.12s cubic-bezier(.4, 0, .2, 1)', + // 后端节流到 ~30 Hz(33ms),CSS 用 0.06s linear 让每次 emit 在下一次到达前 + // 已经完整跳到目标高度 → 视觉上是 30 帧/s 的"跳动"动画,不会被叠加平均成静止。 + transition: 'height 0.06s linear', }} /> ))} @@ -203,6 +203,12 @@ function Pill({ state, level, insertedChars, message, onCancel, onConfirm }: Pil center = ; } + // 玻璃整体随电平做微缩放 + 投影增强,让"说话时整个胶囊在呼吸"。 + // 仅在录音态联动;其他态保持静止。系数控制在 ≤2%,不破坏 176×42 的视觉规格。 + const ambient = state === 'recording' ? Math.min(1, Math.max(0, level)) : 0; + const scale = 1 + ambient * 0.018; + const shadowAlpha = 0.20 + ambient * 0.10; + return (
diff --git a/openless-all/app/src/pages/Vocab.tsx b/openless-all/app/src/pages/Vocab.tsx index 4b1ecf8e..c68eeef2 100644 --- a/openless-all/app/src/pages/Vocab.tsx +++ b/openless-all/app/src/pages/Vocab.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { addVocab, listVocab, removeVocab, setVocabEnabled } from '../lib/ipc'; +import { addVocab, isTauri, listVocab, removeVocab, setVocabEnabled } from '../lib/ipc'; import type { DictionaryEntry } from '../lib/types'; import { Btn, Card, PageHeader } from './_atoms'; @@ -30,6 +30,23 @@ export function Vocab() { useEffect(() => { refresh(); + // 订阅后端 vocab:updated:每段口述结束、record_hits 触发后由 coordinator 推送。 + // Vocab 页面打开期间能即时看到命中数累加,无需切到其他 tab 再切回。 + if (!isTauri) return; + let unlisten: (() => void) | undefined; + let cancelled = false; + (async () => { + const { listen } = await import('@tauri-apps/api/event'); + const handle = await listen('vocab:updated', () => { + void refresh(); + }); + if (cancelled) handle(); + else unlisten = handle; + })(); + return () => { + cancelled = true; + if (unlisten) unlisten(); + }; }, []); const onAdd = async () => {