diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index c0bc5e37..59622fe3 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -160,6 +160,9 @@ pub struct UserPreferences { pub enabled_modes: Vec, pub launch_at_login: bool, pub show_capsule: bool, + /// 是否在录音胶囊中显示本次录音计时。默认关闭,保持既有胶囊视觉不变。 + #[serde(default)] + pub show_capsule_elapsed_time: bool, /// 录音期间临时静音系统输出,停止/取消/出错后恢复原静音状态。 #[serde(default)] pub mute_during_recording: bool, @@ -341,6 +344,8 @@ struct UserPreferencesWire { launch_at_login: bool, show_capsule: bool, #[serde(default)] + show_capsule_elapsed_time: bool, + #[serde(default)] mute_during_recording: bool, #[serde(default)] microphone_device_name: String, @@ -401,6 +406,7 @@ impl Default for UserPreferencesWire { enabled_modes: prefs.enabled_modes, launch_at_login: prefs.launch_at_login, show_capsule: prefs.show_capsule, + show_capsule_elapsed_time: prefs.show_capsule_elapsed_time, mute_during_recording: prefs.mute_during_recording, microphone_device_name: prefs.microphone_device_name, active_asr_provider: prefs.active_asr_provider, @@ -454,6 +460,7 @@ impl<'de> Deserialize<'de> for UserPreferences { enabled_modes: wire.enabled_modes, launch_at_login: wire.launch_at_login, show_capsule: wire.show_capsule, + show_capsule_elapsed_time: wire.show_capsule_elapsed_time, mute_during_recording: wire.mute_during_recording, microphone_device_name: wire.microphone_device_name, active_asr_provider: wire.active_asr_provider, @@ -574,6 +581,7 @@ impl Default for UserPreferences { ], launch_at_login: false, show_capsule: true, + show_capsule_elapsed_time: false, mute_during_recording: false, microphone_device_name: String::new(), active_asr_provider: default_active_asr_provider(), diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index 283ce712..0b9da146 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -6,13 +6,20 @@ import { getCapsuleMessageLayout, getCapsulePillMetrics, } from '../lib/capsuleLayout'; -import { invokeOrMock, isTauri } from '../lib/ipc'; -import type { CapsulePayload, CapsuleState } from '../lib/types'; +import { getSettings, invokeOrMock, isTauri } from '../lib/ipc'; +import type { CapsulePayload, CapsuleState, UserPreferences } from '../lib/types'; interface AudioBarsProps { level: number; } +function formatElapsed(ms: number) { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; +} + function AudioBars({ level }: AudioBarsProps) { const envelope = [0.55, 0.85, 1.0, 0.85, 0.55]; const base = 2; @@ -144,13 +151,15 @@ interface PillProps { os: OS; state: CapsuleState; level: number; + elapsedMs: number; + showElapsedTime: boolean; insertedChars: number; message?: string; onCancel: () => void; onConfirm: () => void; } -function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }: PillProps) { +function Pill({ os, state, level, elapsedMs, showElapsedTime, insertedChars, message, onCancel, onConfirm }: PillProps) { const { t } = useTranslation(); const metrics = getCapsulePillMetrics(os); const processingLayout = getCapsuleMessageLayout(os, 'processing'); @@ -173,7 +182,34 @@ function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }: let center: JSX.Element; switch (state) { case 'recording': - center = ; + center = showElapsedTime ? ( +
+ + + {t('capsule.recordingElapsed', { time: formatElapsed(elapsedMs) })} + +
+ ) : ( + + ); break; case 'transcribing': case 'polishing': @@ -296,6 +332,8 @@ export function Capsule() { const metrics = getCapsulePillMetrics(os); const [state, setState] = useState(INITIAL_VISIBLE_STATE); const [level, setLevel] = useState(isTauri ? 0 : 0.6); + const [elapsedMs, setElapsedMs] = useState(0); + const [showElapsedTime, setShowElapsedTime] = useState(false); const [insertedChars, setInsertedChars] = useState(0); const [message, setMessage] = useState(); const [translation, setTranslation] = useState(false); @@ -319,6 +357,9 @@ export function Capsule() { const p = event.payload; setState(p.state); setLevel(p.level ?? 0); + if (p.elapsedMs != null) { + setElapsedMs(p.elapsedMs); + } setMessage(p.message ?? undefined); if (p.insertedChars != null) setInsertedChars(p.insertedChars); setTranslation(p.translation === true); @@ -332,6 +373,37 @@ export function Capsule() { }; }, []); + useEffect(() => { + let unlisten: (() => void) | undefined; + let cancelled = false; + + void getSettings() + .then(prefs => { + if (!cancelled) setShowElapsedTime(prefs.showCapsuleElapsedTime === true); + }) + .catch(error => { + console.warn('[capsule] failed to load preferences', error); + }); + + if (isTauri) { + (async () => { + const { listen } = await import('@tauri-apps/api/event'); + const handle = await listen('prefs:changed', event => { + setShowElapsedTime(event.payload.showCapsuleElapsedTime === true); + }); + if (cancelled) handle(); + else unlisten = handle; + })().catch(error => { + console.warn('[capsule] prefs:changed listener setup failed', error); + }); + } + + return () => { + cancelled = true; + if (unlisten) unlisten(); + }; + }, []); + // 退出动画调度:在 state 真正进入 idle 时,先用 capsule-out 播放 EXIT_ANIM_MS,再卸载。 // 设计要点: // 1. 进入非 idle:清掉 leaving,记录最新可见 state; @@ -351,6 +423,7 @@ export function Capsule() { const timer = setTimeout(() => { setLeaving(false); setLastVisibleState('idle'); + setElapsedMs(0); }, EXIT_ANIM_MS); return () => clearTimeout(timer); // 故意只依赖 state —— lastVisibleState / leaving 是内部派生量, @@ -456,6 +529,8 @@ export function Capsule() { os={os} state={renderedState} level={leaving ? 0 : level} + elapsedMs={elapsedMs} + showElapsedTime={showElapsedTime} insertedChars={insertedChars} message={message} onCancel={onCancel} diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 71a0c593..cb72adbc 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -31,9 +31,10 @@ export const en: typeof zhCN = { }, capsule: { thinking: 'thinking', + recordingElapsed: '{{time}}', cancelled: 'Cancelled', error: 'Something went wrong', - inserted: 'Inserted {{count}}', + inserted: 'Inserted {{count}} chars', translating: 'Translating', }, qa: { @@ -322,6 +323,8 @@ 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.', + capsuleElapsedTimeLabel: 'Show recording timer', + capsuleElapsedTimeDesc: 'Show elapsed recording time inside the capsule while recording.', muteDuringRecordingLabel: 'Mute while recording', muteDuringRecordingDesc: 'Temporarily mute system output during voice input to avoid speaker echo.', insertGroupTitle: 'Insertion & clipboard', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index aae1e2b9..6b6c84c8 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -33,6 +33,7 @@ export const ja: typeof zhCN = { }, capsule: { thinking: 'thinking', + recordingElapsed: '{{time}}', cancelled: 'キャンセルしました', error: 'エラーが発生しました', inserted: '{{count}} 文字を入力しました', @@ -324,6 +325,8 @@ export const ja: typeof zhCN = { microphoneMonitorError: '入力レベルの監視に失敗:{{message}}', capsuleLabel: '録音カプセル', capsuleDesc: '録音 / 転写中、画面下部に半透明のカプセルを表示。', + capsuleElapsedTimeLabel: '録音タイマーを表示', + capsuleElapsedTimeDesc: 'オンにすると、録音中の経過時間をカプセル内に表示します。', muteDuringRecordingLabel: '録音中はミュート', muteDuringRecordingDesc: '録音中にシステム出力を一時的にミュートし、スピーカーのエコーを防ぎます。', insertGroupTitle: '挿入とクリップボード', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 864c0aea..8d669b55 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -33,6 +33,7 @@ export const ko: typeof zhCN = { }, capsule: { thinking: 'thinking', + recordingElapsed: '{{time}}', cancelled: '취소됨', error: '오류 발생', inserted: '{{count}}자 입력됨', @@ -324,6 +325,8 @@ export const ko: typeof zhCN = { microphoneMonitorError: '입력 레벨 모니터링 실패: {{message}}', capsuleLabel: '녹음 캡슐', capsuleDesc: '녹음 / 전사 중 화면 하단에 반투명 캡슐을 표시합니다.', + capsuleElapsedTimeLabel: '녹음 타이머 표시', + capsuleElapsedTimeDesc: '켜면 녹음 중 경과 시간을 캡슐 안에 표시합니다.', muteDuringRecordingLabel: '녹음 중 음소거', muteDuringRecordingDesc: '녹음 중 시스템 출력을 일시적으로 음소거하여 스피커 에코를 방지합니다.', insertGroupTitle: '삽입 및 클립보드', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 646d2ebc..effdbe39 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -29,9 +29,10 @@ export const zhCN = { }, capsule: { thinking: 'thinking', + recordingElapsed: '{{time}}', cancelled: '已取消', error: '出错了', - inserted: '已插入 {{count}}', + inserted: '已插入 {{count}} 字', translating: '正在翻译', }, qa: { @@ -320,6 +321,8 @@ export const zhCN = { microphoneMonitorError: '输入电平监听失败:{{message}}', capsuleLabel: '录音胶囊', capsuleDesc: '录音 / 转写时显示屏幕底部胶囊。', + capsuleElapsedTimeLabel: '显示录音计时', + capsuleElapsedTimeDesc: '开启后在录音胶囊中显示本次录音时长。', muteDuringRecordingLabel: '录音时静音', muteDuringRecordingDesc: '录音期间临时静音系统输出,避免扬声器回音。', insertGroupTitle: '插入与剪贴板', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 3c872353..ffcb142d 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -31,9 +31,10 @@ export const zhTW: typeof zhCN = { }, capsule: { thinking: 'thinking', + recordingElapsed: '{{time}}', cancelled: '已取消', error: '出錯了', - inserted: '已插入 {{count}}', + inserted: '已插入 {{count}} 字', translating: '正在翻譯', }, qa: { @@ -329,6 +330,8 @@ export const zhTW: typeof zhCN = { microphoneMonitorError: '輸入電平監聽失敗:{{message}}', capsuleLabel: '錄音膠囊', capsuleDesc: '錄音 / 轉寫時在屏幕底部顯示半透明膠囊。', + capsuleElapsedTimeLabel: '顯示錄音計時', + capsuleElapsedTimeDesc: '開啟後在錄音膠囊中顯示本次錄音時長。', muteDuringRecordingLabel: '錄音時靜音', muteDuringRecordingDesc: '錄音期間臨時靜音系統輸出,避免揚聲器回音。', insertGroupTitle: '插入與剪貼板', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index f700c9cc..40b27900 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -53,6 +53,7 @@ const mockSettings: UserPreferences = { enabledModes: ['raw', 'light', 'structured', 'formal'], launchAtLogin: false, showCapsule: true, + showCapsuleElapsedTime: false, muteDuringRecording: false, microphoneDeviceName: '', activeAsrProvider: 'foundry-local-whisper', diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 74a044cb..0be36155 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -22,6 +22,7 @@ const previousPrefs: UserPreferences = { enabledModes: ['raw', 'light', 'structured'], launchAtLogin: false, showCapsule: true, + showCapsuleElapsedTime: false, muteDuringRecording: false, microphoneDeviceName: '', activeAsrProvider: 'volcengine', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index b45242fa..0bc0948b 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -141,6 +141,8 @@ export interface UserPreferences { enabledModes: PolishMode[]; launchAtLogin: boolean; showCapsule: boolean; + /** 在录音胶囊中显示本次录音计时。默认关闭,保持原胶囊视觉不变。 */ + showCapsuleElapsedTime: boolean; /** 录音期间临时静音系统输出,停止/取消/出错后恢复原静音状态。 */ muteDuringRecording: boolean; /** 录音输入设备名称。空字符串 = 使用系统默认麦克风。 */ diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 72e89d36..33233467 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -288,8 +288,10 @@ function RecordingSection() { const onModeChange = (mode: HotkeyMode) => savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, mode } }); - const onShowCapsuleChange = (showCapsule: boolean) => - savePrefs({ ...prefs, showCapsule }); + const onShowCapsuleChange = (nextShowCapsule: boolean) => + savePrefs({ ...prefs, showCapsule: nextShowCapsule }); + const onShowCapsuleElapsedTimeChange = (showCapsuleElapsedTime: boolean) => + savePrefs({ ...prefs, showCapsuleElapsedTime }); const onMuteDuringRecordingChange = (muteDuringRecording: boolean) => savePrefs({ ...prefs, muteDuringRecording }); const onMicrophoneDeviceChange = (microphoneDeviceName: string) => @@ -454,6 +456,12 @@ function RecordingSection() { + + +