mirror of
https://github.com/SerTimBerrners-Lee/talkis.git
synced 2026-07-09 17:29:15 +00:00
Release v0.1.11
This commit is contained in:
parent
9d9c81add1
commit
7dd5f54174
24 changed files with 765 additions and 119 deletions
18
README.md
18
README.md
|
|
@ -10,6 +10,8 @@ It sits in a small floating widget, listens while you hold a hotkey, sends audio
|
|||
- Release the hotkey to stop and process the audio
|
||||
- The recognized text is pasted automatically into the current app
|
||||
- A second press during recording locks the recording mode
|
||||
- The floating widget can start/stop recording with a mouse click and copy the latest result
|
||||
- Autostart can be enabled from settings
|
||||
- The settings window lets you choose language, microphone, API key, and text cleanup style
|
||||
- Recent recordings are saved in local history with processing time
|
||||
|
||||
|
|
@ -110,6 +112,16 @@ If you want to speak longer without holding the keys:
|
|||
3. Recording becomes locked
|
||||
4. Press the hotkey once more to stop and process
|
||||
|
||||
### Floating widget controls
|
||||
|
||||
When the widget is idle, hover it to show quick controls:
|
||||
|
||||
- click the red record button to start a locked recording
|
||||
- click the stop button during recording to finish and process
|
||||
- click the copy button to copy the latest successful result from local history
|
||||
|
||||
The copy shortcut is cleared when history is cleared, and it refreshes after entries are deleted.
|
||||
|
||||
### Settings you can change
|
||||
|
||||
- Recognition language (`ru`, `en`, or auto)
|
||||
|
|
@ -118,6 +130,7 @@ If you want to speak longer without holding the keys:
|
|||
- STT / LLM endpoints and API keys (custom mode)
|
||||
- STT / LLM model names
|
||||
- Global hotkey
|
||||
- Autostart at system login
|
||||
|
||||
## Text styles
|
||||
|
||||
|
|
@ -135,6 +148,7 @@ The `Main` tab stores recent recordings locally so you can:
|
|||
- see processing time for each entry
|
||||
- retry failed entries
|
||||
- copy text again
|
||||
- copy the latest successful result from the floating widget
|
||||
- delete individual entries
|
||||
- clear the full history
|
||||
|
||||
|
|
@ -223,7 +237,7 @@ The repository includes a GitHub Actions workflow at `.github/workflows/release.
|
|||
|
||||
- The canonical release process is documented in `docs/release/rule.md`
|
||||
- Before every release, refresh `README.md` and create a release review file from `docs/release/review-template.md`
|
||||
- Push a tag like `v0.1.10` to build and publish a GitHub Release
|
||||
- Push a tag like `v0.1.11` to build and publish a GitHub Release
|
||||
- Or run the workflow manually and provide a tag
|
||||
- The current workflow publishes macOS release artifacts
|
||||
- For macOS release builds, move `Talkis.app` to `Applications` before granting Accessibility access
|
||||
|
|
@ -250,4 +264,4 @@ Without these secrets, the workflow can still produce unsigned macOS release art
|
|||
|
||||
## Status
|
||||
|
||||
Talkis is an active work in progress. Current version: **0.1.10**.
|
||||
Talkis is an active work in progress. Current version: **0.1.11**.
|
||||
|
|
|
|||
3
bun.lock
3
bun.lock
|
|
@ -5,6 +5,7 @@
|
|||
"name": "talk-flow",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.8",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||
|
|
@ -235,6 +236,8 @@
|
|||
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="],
|
||||
|
||||
"@tauri-apps/plugin-autostart": ["@tauri-apps/plugin-autostart@2.5.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w=="],
|
||||
|
||||
"@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="],
|
||||
|
||||
"@tauri-apps/plugin-deep-link": ["@tauri-apps/plugin-deep-link@2.4.8", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-Cd2Cs960MGuGONeIwxOPx9wqwedetAHOGlwK5boJ/SMTfAtAyfErpfVPEn+EJzgXsJun8EKzsEumHjr+64V4fw=="],
|
||||
|
|
|
|||
68
docs/release/review-v0.1.11.md
Normal file
68
docs/release/review-v0.1.11.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
## Release
|
||||
|
||||
- Version: 0.1.11
|
||||
- Release branch: release/v0.1.11
|
||||
- Target tag: v0.1.11
|
||||
- Reviewer: Codex
|
||||
- Date: 2026-04-27
|
||||
|
||||
## Scope
|
||||
|
||||
- Key changes included in this release:
|
||||
- Added autostart setting backed by `tauri-plugin-autostart`.
|
||||
- Added hover controls to the floating widget: click-to-record, click-to-stop, and copy latest successful result.
|
||||
- Refined widget active/idle sizing, recording controls, waveform rendering, and visual edge treatment.
|
||||
- Added Accessibility preflight for automatic paste attempts.
|
||||
- Fixed latest-copy cache invalidation when history entries are deleted or history is cleared.
|
||||
- Adjusted release packaging so Tauri builds the `.app` and the project postprocess script creates the DMG.
|
||||
- User-facing changes:
|
||||
- Users can enable launch-at-login in Settings.
|
||||
- Users can start and stop a locked recording with the widget mouse control.
|
||||
- Users can copy the latest successful transcription directly from the idle widget.
|
||||
- Widget hover/recording states are larger and easier to target.
|
||||
- Risky areas:
|
||||
- Floating widget hit testing and hover polling on macOS transparent always-on-top windows.
|
||||
- Manual recording start/stop interactions sharing the same state machine as hotkeys.
|
||||
- Release packaging target changed from Tauri `all` to `.app` plus project DMG postprocess.
|
||||
|
||||
## Checks run
|
||||
|
||||
- `bun run check:release` - passed
|
||||
- `bun run build:release:macos` - passed
|
||||
- Additional manual checks:
|
||||
- Confirmed generated macOS app bundle at `src-tauri/target/release/bundle/macos/Talkis.app`.
|
||||
- Confirmed generated DMG at `src-tauri/target/release/bundle/dmg/Talkis_0.1.11_aarch64.dmg`.
|
||||
- Reviewed release diff for version, README, widget controls, autostart permissions, and history-clear copy-cache handling.
|
||||
|
||||
## Manual review
|
||||
|
||||
- Hotkey flow:
|
||||
- Existing hotkey FSM smoke tests passed.
|
||||
- Manual recording actions were added as separate state-machine actions and do not bypass the existing recording pipeline.
|
||||
- Onboarding permissions:
|
||||
- Existing permission flow remains in place.
|
||||
- `paste_text` now checks macOS Accessibility permission before simulating Cmd+V.
|
||||
- Widget position and notice behavior:
|
||||
- Widget resize constants now use a stable active hit-test area to avoid hover clipping.
|
||||
- Notice window behavior is unchanged after reverting the earlier fallback-copy notice.
|
||||
- Transcription quality and short-utterance handling:
|
||||
- Existing short-recording guards remain unchanged.
|
||||
- Waveform rendering changes are visual only.
|
||||
- README refreshed:
|
||||
- README documents autostart, widget mouse controls, latest-copy behavior, and version 0.1.11.
|
||||
|
||||
## Findings
|
||||
|
||||
- Blockers:
|
||||
- None remaining.
|
||||
- Non-blocking issues:
|
||||
- Hover detection relies on polling cursor position relative to the Tauri window because React hover events are unreliable for this transparent widget before activation.
|
||||
- macOS release artifacts are ad-hoc signed unless signing/notarization secrets are configured.
|
||||
- Follow-ups after release:
|
||||
- Consider adding automated tests for history deletion/clear events and latest-copy cache behavior.
|
||||
- Consider manual QA on a clean macOS account for autostart and Accessibility paste preflight.
|
||||
|
||||
## Decision
|
||||
|
||||
- Ready for `main` merge: yes
|
||||
- Ready for tag publish: yes
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "talkis",
|
||||
"private": true,
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.11",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.2.13",
|
||||
"scripts": {
|
||||
|
|
@ -9,10 +9,10 @@
|
|||
"build": "tsc && vite build",
|
||||
"check": "bunx tsc --noEmit && cargo check --manifest-path src-tauri/Cargo.toml",
|
||||
"test": "bun test",
|
||||
"smoke:hotkey": "bun test src/windows/widget/services/hotkeyFsm.test.js",
|
||||
"smoke:hotkey": "bun test ./src/windows/widget/services/hotkeyFsm.test.js",
|
||||
"check:versions": "bash scripts/check-version-sync.sh",
|
||||
"check:release": "bun run check:versions && bun run check && bun run smoke:hotkey && bun run build",
|
||||
"postprocess:macos-release": "VERSION=$(python3 -c \"import json; print(json.load(open('package.json'))['version'])\") && bash scripts/postprocess-macos-release.sh \"$VERSION\"",
|
||||
"postprocess:macos-release": "VERSION=$(python3 -c \"import json; print(json.load(open('package.json'))['version'])\") && BUILD_ROOT=src-tauri/target/release/bundle bash scripts/postprocess-macos-release.sh \"$VERSION\"",
|
||||
"build:release:macos": "bun run tauri build && bun run postprocess:macos-release",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.8",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ BUILD_ROOT="${BUILD_ROOT:-/tmp/talkis-target/release/bundle}"
|
|||
APP_PATH="${BUILD_ROOT}/macos/${APP_NAME}.app"
|
||||
DMG_PATH="${BUILD_ROOT}/dmg/${APP_NAME}_${VERSION}_aarch64.dmg"
|
||||
STAGING_DIR="${BUILD_ROOT}/macos/dmg-staging"
|
||||
DMG_DIR="$(dirname "${DMG_PATH}")"
|
||||
|
||||
if [[ ! -d "${APP_PATH}" ]]; then
|
||||
echo "App bundle not found at ${APP_PATH}" >&2
|
||||
|
|
@ -21,6 +22,7 @@ codesign --force --deep --sign - --identifier "${APP_IDENTIFIER}" "${APP_PATH}"
|
|||
echo "Rebuilding DMG from signed app bundle"
|
||||
rm -rf "${STAGING_DIR}"
|
||||
mkdir -p "${STAGING_DIR}"
|
||||
mkdir -p "${DMG_DIR}"
|
||||
cp -R "${APP_PATH}" "${STAGING_DIR}/"
|
||||
ln -s /Applications "${STAGING_DIR}/Applications"
|
||||
rm -f "${DMG_PATH}"
|
||||
|
|
|
|||
59
src-tauri/Cargo.lock
generated
59
src-tauri/Cargo.lock
generated
|
|
@ -228,6 +228,17 @@ version = "1.1.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "auto-launch"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471"
|
||||
dependencies = [
|
||||
"dirs 4.0.0",
|
||||
"thiserror 1.0.69",
|
||||
"winreg 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
|
|
@ -830,6 +841,15 @@ dependencies = [
|
|||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
|
||||
dependencies = [
|
||||
"dirs-sys 0.3.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
|
|
@ -848,6 +868,17 @@ dependencies = [
|
|||
"dirs-sys 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"redox_users 0.4.6",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
|
|
@ -995,7 +1026,7 @@ dependencies = [
|
|||
"rustc_version",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
"winreg 0.55.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4337,7 +4368,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "talkis"
|
||||
version = "0.1.10"
|
||||
version = "0.1.11"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"block2 0.6.2",
|
||||
|
|
@ -4353,6 +4384,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-autostart",
|
||||
"tauri-plugin-clipboard-manager",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-global-shortcut",
|
||||
|
|
@ -4550,6 +4582,20 @@ dependencies = [
|
|||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-autostart"
|
||||
version = "2.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70"
|
||||
dependencies = [
|
||||
"auto-launch",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-clipboard-manager"
|
||||
version = "2.3.2"
|
||||
|
|
@ -6270,6 +6316,15 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.55.0"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "talkis"
|
||||
version = "0.1.10"
|
||||
version = "0.1.11"
|
||||
description = "Talkis - Voice to Text macOS Widget"
|
||||
authors = ["trixter"]
|
||||
edition = "2021"
|
||||
|
|
@ -36,3 +36,6 @@ block2 = "0.6"
|
|||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", features = ["NSView", "NSWindow", "NSEvent", "block2"] }
|
||||
objc2-foundation = "0.3"
|
||||
|
||||
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
|
||||
tauri-plugin-autostart = "2.5.1"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@
|
|||
"store:allow-delete",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"clipboard-manager:allow-read-text",
|
||||
"deep-link:default"
|
||||
"deep-link:default",
|
||||
"autostart:allow-enable",
|
||||
"autostart:allow-disable",
|
||||
"autostart:allow-is-enabled"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ pub const NOTICE_WIDTH: f64 = 212.0;
|
|||
pub const NOTICE_HEIGHT: f64 = 52.0;
|
||||
/// Must match NOTICE_WIDGET_GAP in src/windows/widget/widgetConstants.ts (logical pixels).
|
||||
pub const NOTICE_GAP: f64 = 2.0;
|
||||
pub const WIDGET_WIDTH: f64 = 50.0;
|
||||
pub const WIDGET_HEIGHT: f64 = 18.0;
|
||||
pub const WIDGET_WIDTH: f64 = 78.0;
|
||||
pub const WIDGET_HEIGHT: f64 = 32.0;
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct WidgetNoticePayload {
|
||||
|
|
@ -155,7 +155,12 @@ fn resize_widget_window(app: &AppHandle, width: f64, height: f64) -> Result<(),
|
|||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn show_widget_notice(app: AppHandle, message: String, tone: String, _anchor_state: String) -> Result<(), String> {
|
||||
pub async fn show_widget_notice(
|
||||
app: AppHandle,
|
||||
message: String,
|
||||
tone: String,
|
||||
_anchor_state: String,
|
||||
) -> Result<(), String> {
|
||||
let widget_window = app
|
||||
.get_webview_window("widget")
|
||||
.ok_or_else(|| "Widget window not found".to_string())?;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@ pub fn run() {
|
|||
.setup(|app| {
|
||||
logger::log_info("INIT", "Application starting...");
|
||||
|
||||
#[cfg(desktop)]
|
||||
app.handle().plugin(tauri_plugin_autostart::init(
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
None,
|
||||
))?;
|
||||
|
||||
// Auto-prompt for accessibility if not granted.
|
||||
// AXIsProcessTrustedWithOptions(prompt=true) makes macOS
|
||||
// show its native dialog and auto-register the binary.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,27 @@ use std::time::Duration;
|
|||
|
||||
use crate::logger;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[link(name = "ApplicationServices", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn AXIsProcessTrusted() -> u8;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn ensure_input_event_permission() -> Result<(), String> {
|
||||
let trusted = unsafe { AXIsProcessTrusted() != 0 };
|
||||
if trusted {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err("Accessibility permission is required to paste text into other apps".into())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn ensure_input_event_permission() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Simulate Cmd+V using CoreGraphics directly.
|
||||
///
|
||||
/// Unlike enigo (which sends separate Meta-press and V-click events that can
|
||||
|
|
@ -93,6 +114,8 @@ fn simulate_cmd_v() -> Result<(), String> {
|
|||
pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result<(), String> {
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||
|
||||
ensure_input_event_permission()?;
|
||||
|
||||
let char_count = text.chars().count();
|
||||
let previous_clipboard_text = app.clipboard().read_text().ok();
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Talkis",
|
||||
"mainBinaryName": "Talkis",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.11",
|
||||
"identifier": "com.trixter.talkis",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
|
@ -16,8 +16,8 @@
|
|||
"label": "widget",
|
||||
"title": "Talkis",
|
||||
"url": "index.html?window=widget",
|
||||
"width": 50,
|
||||
"height": 18,
|
||||
"width": 78,
|
||||
"height": 32,
|
||||
"minWidth": 18,
|
||||
"minHeight": 18,
|
||||
"resizable": false,
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"targets": ["app"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
|
|
|||
|
|
@ -61,24 +61,20 @@ export function Waveform({ stream, isActive }: WaveformProps) {
|
|||
|
||||
const time = performance.now() / 420;
|
||||
const centerY = displayHeight / 2;
|
||||
const baseAmplitude = 1 + levelRef.current * displayHeight * 0.34;
|
||||
const baseAmplitude = 0.7 + levelRef.current * displayHeight * 0.26;
|
||||
const lineConfigs = [
|
||||
{ amplitude: 1.42, speed: 1, phase: 0, alpha: 0.36, width: 1.08 },
|
||||
{ amplitude: 1.34, speed: 1.06, phase: Math.PI / 10, alpha: 0.33, width: 1.02 },
|
||||
{ amplitude: 1.26, speed: 1.12, phase: Math.PI / 8, alpha: 0.3, width: 0.98 },
|
||||
{ amplitude: 1.18, speed: 1.18, phase: Math.PI / 6.2, alpha: 0.28, width: 0.94 },
|
||||
{ amplitude: 1.1, speed: 1.24, phase: Math.PI / 5.2, alpha: 0.25, width: 0.9 },
|
||||
{ amplitude: 1.02, speed: 0.92, phase: Math.PI / 4.5, alpha: 0.23, width: 0.87 },
|
||||
{ amplitude: 0.95, speed: 1.3, phase: Math.PI / 3.8, alpha: 0.21, width: 0.84 },
|
||||
{ amplitude: 0.88, speed: 0.86, phase: Math.PI / 3.15, alpha: 0.19, width: 0.8 },
|
||||
{ amplitude: 0.81, speed: 1.38, phase: Math.PI / 2.8, alpha: 0.17, width: 0.76 },
|
||||
{ amplitude: 0.74, speed: 0.8, phase: Math.PI / 2.45, alpha: 0.15, width: 0.73 },
|
||||
{ amplitude: 0.67, speed: 1.46, phase: Math.PI / 2.05, alpha: 0.14, width: 0.7 },
|
||||
{ amplitude: 0.61, speed: 0.74, phase: Math.PI / 1.8, alpha: 0.13, width: 0.67 },
|
||||
{ amplitude: 0.55, speed: 1.54, phase: Math.PI / 1.56, alpha: 0.12, width: 0.64 },
|
||||
{ amplitude: 0.49, speed: 0.68, phase: Math.PI / 1.34, alpha: 0.11, width: 0.61 },
|
||||
{ amplitude: 0.44, speed: 1.62, phase: Math.PI / 1.18, alpha: 0.1, width: 0.58 },
|
||||
{ amplitude: 0.39, speed: 0.62, phase: Math.PI / 1.04, alpha: 0.09, width: 0.54 },
|
||||
{ amplitude: 1.16, speed: 1.42, phase: 0.18, alpha: 0.3, width: 0.62, wobble: 0.9 },
|
||||
{ amplitude: 0.92, speed: 0.81, phase: 1.7, alpha: 0.27, width: 0.58, wobble: 1.8 },
|
||||
{ amplitude: 1.31, speed: 1.18, phase: 2.85, alpha: 0.24, width: 0.55, wobble: 1.2 },
|
||||
{ amplitude: 0.78, speed: 1.67, phase: 4.1, alpha: 0.22, width: 0.52, wobble: 2.4 },
|
||||
{ amplitude: 1.04, speed: 0.96, phase: 5.35, alpha: 0.2, width: 0.5, wobble: 1.5 },
|
||||
{ amplitude: 0.68, speed: 1.92, phase: 0.9, alpha: 0.18, width: 0.48, wobble: 2.9 },
|
||||
{ amplitude: 1.22, speed: 1.05, phase: 3.55, alpha: 0.16, width: 0.46, wobble: 2.1 },
|
||||
{ amplitude: 0.84, speed: 1.74, phase: 2.25, alpha: 0.14, width: 0.44, wobble: 3.2 },
|
||||
{ amplitude: 1.08, speed: 0.72, phase: 4.85, alpha: 0.13, width: 0.42, wobble: 1.1 },
|
||||
{ amplitude: 0.56, speed: 2.15, phase: 1.28, alpha: 0.12, width: 0.4, wobble: 3.8 },
|
||||
{ amplitude: 0.98, speed: 1.31, phase: 5.9, alpha: 0.11, width: 0.38, wobble: 2.6 },
|
||||
{ amplitude: 0.72, speed: 1.58, phase: 3.08, alpha: 0.1, width: 0.36, wobble: 4.1 },
|
||||
];
|
||||
|
||||
ctx.lineCap = "round";
|
||||
|
|
@ -87,14 +83,16 @@ export function Waveform({ stream, isActive }: WaveformProps) {
|
|||
lineConfigs.forEach((line) => {
|
||||
ctx.beginPath();
|
||||
|
||||
for (let x = 0; x <= displayWidth; x += 1) {
|
||||
for (let x = 0; x <= displayWidth; x += 1.25) {
|
||||
const progress = x / displayWidth;
|
||||
const edgeFade = Math.sin(progress * Math.PI);
|
||||
const envelope = Math.pow(Math.max(0, edgeFade), 1.35);
|
||||
const primary = Math.sin(progress * Math.PI * 2.8 + time * line.speed + line.phase);
|
||||
const secondary = Math.sin(progress * Math.PI * 5.6 - time * (line.speed * 1.08) + line.phase * 0.72);
|
||||
const tertiary = Math.cos(progress * Math.PI * 8.2 + time * 0.74 + line.phase);
|
||||
const displacement = ((primary * 0.68) + (secondary * 0.22) + (tertiary * 0.1)) * baseAmplitude * line.amplitude * envelope;
|
||||
const envelope = Math.pow(Math.max(0, edgeFade), 1.08);
|
||||
const drift = Math.sin(time * 0.37 + line.phase) * 0.18;
|
||||
const primary = Math.sin(progress * Math.PI * (3.1 + line.wobble * 0.18) + time * line.speed + line.phase);
|
||||
const secondary = Math.sin(progress * Math.PI * (7.3 + line.wobble * 0.42) - time * (line.speed * 0.83) + line.phase * 1.37);
|
||||
const tertiary = Math.cos(progress * Math.PI * (13.4 + line.wobble * 0.31) + time * (0.56 + line.wobble * 0.04) + line.phase);
|
||||
const grain = Math.sin((progress + line.phase) * 38 + time * (1.1 + line.wobble * 0.11)) * 0.055;
|
||||
const displacement = ((primary * 0.52) + (secondary * 0.31) + (tertiary * 0.12) + grain + drift) * baseAmplitude * line.amplitude * envelope;
|
||||
const y = centerY + displacement;
|
||||
|
||||
if (x === 0) {
|
||||
|
|
@ -106,8 +104,7 @@ export function Waveform({ stream, isActive }: WaveformProps) {
|
|||
|
||||
ctx.strokeStyle = `rgba(0, 0, 0, ${line.alpha})`;
|
||||
ctx.lineWidth = line.width;
|
||||
ctx.shadowBlur = line.alpha > 0.28 ? 4 : 0;
|
||||
ctx.shadowColor = "rgba(0, 0, 0, 0.08)";
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
export const SETTINGS_UPDATED_EVENT = "settings-updated";
|
||||
export const HISTORY_UPDATED_EVENT = "history-updated";
|
||||
export const HISTORY_DELETED_EVENT = "history-deleted";
|
||||
export const HISTORY_CLEARED_EVENT = "history-cleared";
|
||||
export const HOTKEY_CHANGE_REQUEST_EVENT = "hotkey-change-request";
|
||||
export const HOTKEY_REGISTRATION_RESULT_EVENT = "hotkey-registration-result";
|
||||
export const NATIVE_HOTKEY_CAPTURE_EVENT = "native-hotkey-capture";
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useState, useEffect, useMemo } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { emit, listen } from "@tauri-apps/api/event";
|
||||
import { clearHistory, DEFAULT_HOTKEY, deleteHistoryEntry, formatHotkeyLabel, getHistory, getSettings, HistoryEntry } from "../../../lib/store";
|
||||
import { AlertCircle, Check, Copy, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { HISTORY_UPDATED_EVENT, SETTINGS_UPDATED_EVENT } from "../../../lib/hotkeyEvents";
|
||||
import { HISTORY_CLEARED_EVENT, HISTORY_DELETED_EVENT, HISTORY_UPDATED_EVENT, SETTINGS_UPDATED_EVENT } from "../../../lib/hotkeyEvents";
|
||||
import { retryHistoryEntry } from "../../widget/services/transcriptionPipeline";
|
||||
|
||||
interface MainTabProps {
|
||||
|
|
@ -73,6 +73,7 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
|
|||
const deleteEntry = async (id: string) => {
|
||||
await deleteHistoryEntry(id);
|
||||
setHistory((h) => h.filter((x) => x.id !== id));
|
||||
await emit(HISTORY_DELETED_EVENT, { id });
|
||||
};
|
||||
|
||||
const clearAllHistory = async () => {
|
||||
|
|
@ -87,6 +88,7 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
|
|||
await clearHistory();
|
||||
setHistory([]);
|
||||
setIsClearArmed(false);
|
||||
await emit(HISTORY_CLEARED_EVENT);
|
||||
};
|
||||
|
||||
const copyText = async (id: string, text: string) => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit, listen } from "@tauri-apps/api/event";
|
||||
import { getSettings, saveSettings, AppSettings, DEFAULT_HOTKEY, formatHotkeyLabel } from "../../../lib/store";
|
||||
import { disable as disableAutostart, enable as enableAutostart, isEnabled as isAutostartEnabled } from "@tauri-apps/plugin-autostart";
|
||||
import { Check, ChevronDown, Search } from "lucide-react";
|
||||
|
||||
import { getSettings, saveSettings, AppSettings, DEFAULT_HOTKEY, formatHotkeyLabel } from "../../../lib/store";
|
||||
import {
|
||||
HOTKEY_CAPTURE_STATE_EVENT,
|
||||
HOTKEY_CHANGE_REQUEST_EVENT,
|
||||
|
|
@ -41,6 +43,9 @@ export function SettingsTab() {
|
|||
const [hotkeyDraft, setHotkeyDraft] = useState<string | null>(null);
|
||||
const [hotkeyFeedback, setHotkeyFeedback] = useState("Нажмите на поле и введите новую комбинацию. Esc отменяет ввод.");
|
||||
const [hotkeyFeedbackTone, setHotkeyFeedbackTone] = useState<HotkeyFeedbackTone>("idle");
|
||||
const [autostartEnabled, setAutostartEnabled] = useState(false);
|
||||
const [autostartLoaded, setAutostartLoaded] = useState(false);
|
||||
const [autostartPending, setAutostartPending] = useState(false);
|
||||
|
||||
type MicAvailabilityState = "ready" | "missing-selected" | "permission-needed" | "empty";
|
||||
|
||||
|
|
@ -60,6 +65,29 @@ export function SettingsTab() {
|
|||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const loadAutostartState = async (): Promise<void> => {
|
||||
try {
|
||||
const enabled = await isAutostartEnabled();
|
||||
if (!mounted) return;
|
||||
setAutostartEnabled(enabled);
|
||||
setAutostartLoaded(true);
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setAutostartLoaded(true);
|
||||
void logError("SETTINGS", `Failed to load autostart state: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
void loadAutostartState();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unlistenHotkeyResult = listen<HotkeyRegistrationResultPayload>(
|
||||
HOTKEY_REGISTRATION_RESULT_EVENT,
|
||||
|
|
@ -335,6 +363,32 @@ export function SettingsTab() {
|
|||
void startHotkeyCapture();
|
||||
};
|
||||
|
||||
const toggleAutostart = async (): Promise<void> => {
|
||||
if (autostartPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEnabled = !autostartEnabled;
|
||||
setAutostartPending(true);
|
||||
|
||||
try {
|
||||
if (nextEnabled) {
|
||||
await enableAutostart();
|
||||
} else {
|
||||
await disableAutostart();
|
||||
}
|
||||
|
||||
const confirmedEnabled = await isAutostartEnabled();
|
||||
setAutostartEnabled(confirmedEnabled);
|
||||
setAutostartLoaded(true);
|
||||
void logInfo("SETTINGS", `Autostart ${confirmedEnabled ? "enabled" : "disabled"}`);
|
||||
} catch (error) {
|
||||
void logError("SETTINGS", `Failed to update autostart: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
setAutostartPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getMicrophoneLabel = (mic: MediaDeviceInfo, index: number): string => {
|
||||
const label = mic.label?.trim();
|
||||
return label ? label : `Микрофон ${index + 1}`;
|
||||
|
|
@ -364,6 +418,7 @@ export function SettingsTab() {
|
|||
: hotkeyFeedbackTone === "success"
|
||||
? "#027a48"
|
||||
: "var(--text-mid)";
|
||||
const autostartDisabled = !autostartLoaded || autostartPending;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
|
|
@ -518,6 +573,68 @@ export function SettingsTab() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ display: "grid", gap: 10, background: "rgba(255,255,255,0.82)" }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) 280px", alignItems: "center", gap: 16 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)", margin: 0 }}>Автозапуск</div>
|
||||
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.65, marginTop: 6 }}>
|
||||
Запускать Talkis автоматически при входе в систему.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={autostartEnabled}
|
||||
aria-disabled={autostartDisabled}
|
||||
onClick={() => { void toggleAutostart(); }}
|
||||
className="btn"
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: 46,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(0, 1fr) 42px",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
opacity: autostartDisabled ? 0.72 : 1,
|
||||
cursor: autostartDisabled ? "wait" : "pointer",
|
||||
transform: "none",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--text-hi)", fontSize: 13, fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", minWidth: 74 }}>
|
||||
{autostartEnabled ? "Включен" : "Выключен"}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 42,
|
||||
height: 24,
|
||||
borderRadius: 999,
|
||||
background: autostartEnabled ? "#111" : "rgba(0,0,0,0.12)",
|
||||
padding: 3,
|
||||
position: "relative",
|
||||
transition: "background 0.15s ease",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 3,
|
||||
left: 3,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: "50%",
|
||||
background: "#fff",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.18)",
|
||||
transform: autostartEnabled ? "translateX(18px)" : "translateX(0)",
|
||||
transition: "transform 0.18s ease",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,84 @@
|
|||
import { useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { cursorPosition, getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { Circle, Copy, Square } from "lucide-react";
|
||||
|
||||
import { Waveform } from "../../components/Waveform";
|
||||
import { HISTORY_CLEARED_EVENT, HISTORY_DELETED_EVENT, HISTORY_UPDATED_EVENT } from "../../lib/hotkeyEvents";
|
||||
import { getHistory, type HistoryEntry } from "../../lib/store";
|
||||
import { logError } from "../../lib/logger";
|
||||
import { useWidgetController } from "./hooks/useWidgetController";
|
||||
import { WIDGET_SHELL_HEIGHT, WIDGET_SHELL_WIDTH } from "./widgetConstants";
|
||||
import {
|
||||
ACTIVE_WIDGET_SHELL_HEIGHT,
|
||||
ACTIVE_WIDGET_SHELL_WIDTH,
|
||||
IDLE_HOVER_WIDGET_HEIGHT,
|
||||
IDLE_HOVER_WIDGET_WIDTH,
|
||||
IDLE_HOVER_SCALE,
|
||||
WIDGET_SHELL_HEIGHT,
|
||||
WIDGET_SHELL_WIDTH,
|
||||
} from "./widgetConstants";
|
||||
|
||||
const WIDGET_RECORD_BUTTON_LEFT = 10;
|
||||
const WIDGET_RECORD_BUTTON_SIZE = 18;
|
||||
|
||||
function getCopyableText(entry: HistoryEntry | null | undefined): string | null {
|
||||
if (!entry || entry.status === "failed") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleaned = entry.cleaned.trim();
|
||||
return cleaned.length > 0 ? cleaned : null;
|
||||
}
|
||||
|
||||
export function Widget() {
|
||||
const widgetWindow = getCurrentWindow();
|
||||
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const dragTriggeredRef = useRef(false);
|
||||
const { state, stream, lockedRecording } = useWidgetController();
|
||||
const { state, stream, lockedRecording, toggleManualRecording } = useWidgetController();
|
||||
const [latestCopyText, setLatestCopyText] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const refreshLatestCopyText = async () => {
|
||||
try {
|
||||
const history = await getHistory();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const latestCompleted = history.find((entry) => getCopyableText(entry) !== null);
|
||||
setLatestCopyText(getCopyableText(latestCompleted));
|
||||
} catch (error) {
|
||||
logError("WIDGET", `Failed to load latest history entry: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
void refreshLatestCopyText();
|
||||
|
||||
const unlistenUpdatedPromise = listen<HistoryEntry>(HISTORY_UPDATED_EVENT, ({ payload }) => {
|
||||
const text = getCopyableText(payload);
|
||||
if (text) {
|
||||
setLatestCopyText(text);
|
||||
}
|
||||
});
|
||||
const unlistenDeletedPromise = listen<{ id: string }>(HISTORY_DELETED_EVENT, () => {
|
||||
void refreshLatestCopyText();
|
||||
});
|
||||
const unlistenClearedPromise = listen(HISTORY_CLEARED_EVENT, () => {
|
||||
setLatestCopyText(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
void unlistenUpdatedPromise.then((unlisten) => unlisten());
|
||||
void unlistenDeletedPromise.then((unlisten) => unlisten());
|
||||
void unlistenClearedPromise.then((unlisten) => unlisten());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDragPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) {
|
||||
|
|
@ -73,6 +140,8 @@ export function Widget() {
|
|||
>
|
||||
{state === "idle" && (
|
||||
<IdlePill
|
||||
latestCopyText={latestCopyText}
|
||||
onToggleRecording={toggleManualRecording}
|
||||
onClick={handleIdleClick}
|
||||
onPointerDown={handleDragPointerDown}
|
||||
onPointerMove={handleDragPointerMove}
|
||||
|
|
@ -84,6 +153,7 @@ export function Widget() {
|
|||
<RecordingPill
|
||||
stream={stream}
|
||||
locked={lockedRecording}
|
||||
onToggleRecording={toggleManualRecording}
|
||||
onPointerDown={handleDragPointerDown}
|
||||
onPointerMove={handleDragPointerMove}
|
||||
onPointerUp={handleDragPointerUp}
|
||||
|
|
@ -109,38 +179,196 @@ interface DragHandlers {
|
|||
onPointerCancel: () => void;
|
||||
}
|
||||
|
||||
function IdlePill({ onClick, onPointerDown, onPointerMove, onPointerUp, onPointerCancel }: DragHandlers & { onClick: () => void }) {
|
||||
function IdlePill({
|
||||
latestCopyText,
|
||||
onToggleRecording,
|
||||
onClick,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPointerCancel,
|
||||
}: DragHandlers & { latestCopyText: string | null; onToggleRecording: () => void; onClick: () => void }) {
|
||||
const widgetWindow = getCurrentWindow();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const canCopy = Boolean(latestCopyText);
|
||||
const controlsVisible = isHovered;
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
const hoverMarginPx = 2;
|
||||
|
||||
const updateHoverState = async () => {
|
||||
try {
|
||||
const [cursor, position, size] = await Promise.all([
|
||||
cursorPosition(),
|
||||
widgetWindow.outerPosition(),
|
||||
widgetWindow.outerSize(),
|
||||
]);
|
||||
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hovered =
|
||||
cursor.x >= position.x - hoverMarginPx &&
|
||||
cursor.x <= position.x + size.width + hoverMarginPx &&
|
||||
cursor.y >= position.y - hoverMarginPx &&
|
||||
cursor.y <= position.y + size.height + hoverMarginPx;
|
||||
|
||||
setIsHovered(hovered);
|
||||
} catch (error) {
|
||||
logError("WIDGET", `Failed to poll widget hover state: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
void updateHoverState();
|
||||
const interval = window.setInterval(() => {
|
||||
void updateHoverState();
|
||||
}, 80);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [widgetWindow]);
|
||||
|
||||
const copyLatestText = async () => {
|
||||
if (!latestCopyText) {
|
||||
return;
|
||||
}
|
||||
|
||||
await writeText(latestCopyText);
|
||||
};
|
||||
|
||||
return (
|
||||
<ActiveWidgetShell
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerCancel}
|
||||
onPointerEnter={() => setIsHovered(true)}
|
||||
onPointerLeave={() => setIsHovered(false)}
|
||||
width={IDLE_HOVER_WIDGET_WIDTH}
|
||||
height={IDLE_HOVER_WIDGET_HEIGHT}
|
||||
cursor="pointer"
|
||||
onClick={() => {
|
||||
void onClick();
|
||||
}}
|
||||
>
|
||||
<WidgetCoreShell />
|
||||
<WidgetCoreShell width={WIDGET_SHELL_WIDTH} height={WIDGET_SHELL_HEIGHT} scale={isHovered ? IDLE_HOVER_SCALE : 1} />
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Начать запись"
|
||||
title="Начать запись"
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleRecording();
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: WIDGET_RECORD_BUTTON_LEFT,
|
||||
top: "50%",
|
||||
width: WIDGET_RECORD_BUTTON_SIZE,
|
||||
height: WIDGET_RECORD_BUTTON_SIZE,
|
||||
border: "none",
|
||||
borderRadius: 999,
|
||||
padding: 0,
|
||||
background: "rgba(217,45,32,0.12)",
|
||||
color: "#d92d20",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
opacity: controlsVisible ? 1 : 0,
|
||||
transform: controlsVisible ? "translateY(-50%) scale(1)" : "translateY(-50%) scale(0.84)",
|
||||
transition: "opacity 0.14s ease, transform 0.14s ease, background 0.14s ease",
|
||||
pointerEvents: controlsVisible ? "auto" : "none",
|
||||
cursor: "pointer",
|
||||
WebkitFontSmoothing: "antialiased",
|
||||
}}
|
||||
>
|
||||
<Circle size={10} strokeWidth={2.4} fill="currentColor" />
|
||||
</button>
|
||||
{canCopy && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Скопировать последнюю запись"
|
||||
title="Скопировать"
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void copyLatestText();
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 10,
|
||||
top: "50%",
|
||||
width: 18,
|
||||
height: 18,
|
||||
minWidth: 18,
|
||||
border: "none",
|
||||
borderRadius: 999,
|
||||
padding: 0,
|
||||
background: "rgba(0,0,0,0.06)",
|
||||
color: "rgba(0,0,0,0.7)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
opacity: controlsVisible ? 1 : 0,
|
||||
transform: controlsVisible ? "translateY(-50%) scale(1)" : "translateY(-50%) scale(0.84)",
|
||||
transition: "opacity 0.14s ease, transform 0.14s ease, background 0.14s ease, color 0.14s ease",
|
||||
pointerEvents: controlsVisible ? "auto" : "none",
|
||||
cursor: "pointer",
|
||||
WebkitFontSmoothing: "antialiased",
|
||||
}}
|
||||
>
|
||||
<Copy size={12} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</ActiveWidgetShell>
|
||||
);
|
||||
}
|
||||
|
||||
function WidgetCoreShell({ children }: { children?: ReactNode }) {
|
||||
function WidgetCoreShell({
|
||||
children,
|
||||
width = "100%",
|
||||
height = "100%",
|
||||
scale = 1,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
scale?: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
width,
|
||||
height,
|
||||
borderRadius: 999,
|
||||
background: "linear-gradient(180deg, rgba(252,251,248,0.98) 0%, rgba(245,241,234,0.98) 100%)",
|
||||
border: "1px solid rgba(0,0,0,0.1)",
|
||||
backdropFilter: "blur(18px)",
|
||||
WebkitBackdropFilter: "blur(18px)",
|
||||
background: "linear-gradient(180deg, #fcfbf8 0%, #f6f2eb 100%)",
|
||||
border: "1px solid rgba(0,0,0,0.13)",
|
||||
boxShadow: "0 1px 2px rgba(0,0,0,0.08)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "center center",
|
||||
transition: "transform 0.18s cubic-bezier(0.22, 1, 0.36, 1)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -151,14 +379,35 @@ function WidgetCoreShell({ children }: { children?: ReactNode }) {
|
|||
interface RecordingPillProps {
|
||||
stream: MediaStream | null;
|
||||
locked: boolean;
|
||||
onToggleRecording: () => void;
|
||||
}
|
||||
|
||||
function ActiveWidgetShell({ children, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onClick, cursor = "grab" }: { children: ReactNode; onClick?: () => void; cursor?: string } & DragHandlers) {
|
||||
function ActiveWidgetShell({
|
||||
children,
|
||||
width = WIDGET_SHELL_WIDTH,
|
||||
height = WIDGET_SHELL_HEIGHT,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPointerCancel,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
onClick,
|
||||
cursor = "grab",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
width?: number;
|
||||
height?: number;
|
||||
onClick?: () => void;
|
||||
cursor?: string;
|
||||
onPointerEnter?: () => void;
|
||||
onPointerLeave?: () => void;
|
||||
} & DragHandlers) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: WIDGET_SHELL_WIDTH,
|
||||
height: WIDGET_SHELL_HEIGHT,
|
||||
width,
|
||||
height,
|
||||
borderRadius: 999,
|
||||
background: "transparent",
|
||||
display: "flex",
|
||||
|
|
@ -179,21 +428,25 @@ function ActiveWidgetShell({ children, onPointerDown, onPointerMove, onPointerUp
|
|||
}}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerCancel}
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordingPill({ stream, locked, onPointerDown, onPointerMove, onPointerUp, onPointerCancel }: RecordingPillProps & DragHandlers) {
|
||||
function RecordingPill({ stream, locked, onToggleRecording, onPointerDown, onPointerMove, onPointerUp, onPointerCancel }: RecordingPillProps & DragHandlers) {
|
||||
return (
|
||||
<ActiveWidgetShell
|
||||
width={IDLE_HOVER_WIDGET_WIDTH}
|
||||
height={IDLE_HOVER_WIDGET_HEIGHT}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerCancel}
|
||||
>
|
||||
<WidgetCoreShell>
|
||||
<WidgetCoreShell width={ACTIVE_WIDGET_SHELL_WIDTH} height={ACTIVE_WIDGET_SHELL_HEIGHT}>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
|
|
@ -202,27 +455,52 @@ function RecordingPill({ stream, locked, onPointerDown, onPointerMove, onPointer
|
|||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: locked ? "0 10px 0 3px" : "0 3px",
|
||||
padding: "0 4px 0 24px",
|
||||
}}
|
||||
>
|
||||
<Waveform stream={stream} isActive={true} />
|
||||
{locked && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
right: 4,
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: 999,
|
||||
background: "#d92d20",
|
||||
boxShadow: "0 0 0 2px rgba(217,45,32,0.14)",
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</WidgetCoreShell>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Закончить запись"
|
||||
title="Закончить запись"
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleRecording();
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: WIDGET_RECORD_BUTTON_LEFT,
|
||||
width: WIDGET_RECORD_BUTTON_SIZE,
|
||||
height: WIDGET_RECORD_BUTTON_SIZE,
|
||||
border: "none",
|
||||
borderRadius: 999,
|
||||
padding: 0,
|
||||
background: locked ? "rgba(217,45,32,0.14)" : "rgba(217,45,32,0.1)",
|
||||
color: "#d92d20",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transform: "translateY(-50%)",
|
||||
pointerEvents: "auto",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Square size={8} strokeWidth={2.4} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</ActiveWidgetShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -230,12 +508,14 @@ function RecordingPill({ stream, locked, onPointerDown, onPointerMove, onPointer
|
|||
function ProcessingPill({ onPointerDown, onPointerMove, onPointerUp, onPointerCancel }: DragHandlers) {
|
||||
return (
|
||||
<ActiveWidgetShell
|
||||
width={IDLE_HOVER_WIDGET_WIDTH}
|
||||
height={IDLE_HOVER_WIDGET_HEIGHT}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerCancel}
|
||||
>
|
||||
<WidgetCoreShell>
|
||||
<WidgetCoreShell width={ACTIVE_WIDGET_SHELL_WIDTH} height={ACTIVE_WIDGET_SHELL_HEIGHT}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ interface WidgetControllerState {
|
|||
stream: MediaStream | null;
|
||||
notice: WidgetNoticeState | null;
|
||||
lockedRecording: boolean;
|
||||
toggleManualRecording: () => void;
|
||||
}
|
||||
|
||||
export function useWidgetController(): WidgetControllerState {
|
||||
|
|
@ -233,7 +234,7 @@ export function useWidgetController(): WidgetControllerState {
|
|||
stateRefForNotice.current = widgetState;
|
||||
}, [widgetState]);
|
||||
|
||||
const { showNotice } = useWidgetNotice({ stateRef: stateRefForNotice });
|
||||
const { showNotice, hideNotice } = useWidgetNotice({ stateRef: stateRefForNotice });
|
||||
|
||||
// ── Error handler ───────────────────────────────────────────────────────
|
||||
const showError = useCallback(
|
||||
|
|
@ -254,7 +255,7 @@ export function useWidgetController(): WidgetControllerState {
|
|||
setStream,
|
||||
resizeWidget,
|
||||
showError,
|
||||
showNotice,
|
||||
hideNotice,
|
||||
stopAndProcessRef,
|
||||
});
|
||||
|
||||
|
|
@ -275,6 +276,19 @@ export function useWidgetController(): WidgetControllerState {
|
|||
showError,
|
||||
});
|
||||
|
||||
const toggleManualRecording = useCallback(() => {
|
||||
const currentState = machineRef.current.widgetState;
|
||||
|
||||
if (currentState === "idle") {
|
||||
dispatch({ type: "MANUAL_RECORDING_START" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentState === "recording") {
|
||||
dispatch({ type: "MANUAL_RECORDING_STOP" });
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
// ── Cleanup ─────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
@ -288,5 +302,6 @@ export function useWidgetController(): WidgetControllerState {
|
|||
stream,
|
||||
notice: null,
|
||||
lockedRecording,
|
||||
toggleManualRecording,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,15 +10,26 @@ interface UseWidgetNoticeParams {
|
|||
|
||||
interface UseWidgetNoticeResult {
|
||||
showNotice: (message: string, tone?: WidgetNoticeTone) => void;
|
||||
hideNotice: () => void;
|
||||
}
|
||||
|
||||
export function useWidgetNotice({ stateRef }: UseWidgetNoticeParams): UseWidgetNoticeResult {
|
||||
const noticeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const hideNotice = useCallback(() => {
|
||||
if (noticeTimerRef.current) {
|
||||
clearTimeout(noticeTimerRef.current);
|
||||
noticeTimerRef.current = null;
|
||||
}
|
||||
|
||||
void invoke("hide_widget_notice");
|
||||
}, []);
|
||||
|
||||
const showNotice = useCallback(
|
||||
(message: string, tone: WidgetNoticeTone = "error") => {
|
||||
if (noticeTimerRef.current) {
|
||||
clearTimeout(noticeTimerRef.current);
|
||||
noticeTimerRef.current = null;
|
||||
}
|
||||
|
||||
void invoke("show_widget_notice", {
|
||||
|
|
@ -28,24 +39,20 @@ export function useWidgetNotice({ stateRef }: UseWidgetNoticeParams): UseWidgetN
|
|||
});
|
||||
|
||||
noticeTimerRef.current = setTimeout(() => {
|
||||
void invoke("hide_widget_notice");
|
||||
noticeTimerRef.current = null;
|
||||
hideNotice();
|
||||
}, NOTICE_TIMEOUT_MS);
|
||||
},
|
||||
[stateRef],
|
||||
[hideNotice, stateRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (noticeTimerRef.current) {
|
||||
clearTimeout(noticeTimerRef.current);
|
||||
}
|
||||
|
||||
void invoke("hide_widget_notice");
|
||||
hideNotice();
|
||||
};
|
||||
}, []);
|
||||
}, [hideNotice]);
|
||||
|
||||
return {
|
||||
showNotice,
|
||||
hideNotice,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
MIN_RECORDING_DURATION_MS,
|
||||
RECORDING_WIDGET_HEIGHT,
|
||||
RECORDING_WIDGET_WIDTH,
|
||||
WidgetNoticeTone,
|
||||
} from "../widgetConstants";
|
||||
import { createRecordingRuntimeController } from "../services/recordingRuntime";
|
||||
import { processRecordingBlob } from "../services/transcriptionPipeline";
|
||||
|
|
@ -24,7 +23,7 @@ interface UseWidgetRecordingParams {
|
|||
setStream: Dispatch<SetStateAction<MediaStream | null>>;
|
||||
resizeWidget: (width: number, height: number) => Promise<void>;
|
||||
showError: (message: string) => void;
|
||||
showNotice: (message: string, tone?: WidgetNoticeTone) => void;
|
||||
hideNotice: () => void;
|
||||
stopAndProcessRef: MutableRefObject<() => Promise<void>>;
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +76,7 @@ export function useWidgetRecording({
|
|||
setStream,
|
||||
resizeWidget,
|
||||
showError,
|
||||
showNotice,
|
||||
hideNotice,
|
||||
stopAndProcessRef,
|
||||
}: UseWidgetRecordingParams): UseWidgetRecordingResult {
|
||||
const runtimeRef = useRef(createRecordingRuntimeController());
|
||||
|
|
@ -89,6 +88,7 @@ export function useWidgetRecording({
|
|||
// ── Start recording ─────────────────────────────────────────────────────
|
||||
const startRecording = useCallback(async () => {
|
||||
logInfo("RECORDING", "startRecording called");
|
||||
hideNotice();
|
||||
|
||||
if (!settings) {
|
||||
logError("RECORDING", "Settings not loaded");
|
||||
|
|
@ -172,7 +172,7 @@ export function useWidgetRecording({
|
|||
`Ошибка запуска записи: ${error instanceof Error ? error.message : "Неизвестная ошибка"}`,
|
||||
);
|
||||
}
|
||||
}, [dispatch, machineRef, resizeWidget, setStream, settings, showError]);
|
||||
}, [dispatch, hideNotice, machineRef, resizeWidget, setStream, settings, showError]);
|
||||
|
||||
// ── Stop and process ────────────────────────────────────────────────────
|
||||
const stopAndProcess = useCallback(async () => {
|
||||
|
|
@ -235,10 +235,6 @@ export function useWidgetRecording({
|
|||
runtimeRef.current.reset();
|
||||
dispatch({ type: "PROCESSING_COMPLETE" });
|
||||
await resizeWidget(IDLE_WIDGET_WIDTH, IDLE_WIDGET_HEIGHT);
|
||||
|
||||
if (pipelineResult.pasteErrorMessage) {
|
||||
showNotice(pipelineResult.pasteErrorMessage, "info");
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = formatErrorMessage(error);
|
||||
logError("API", `Processing error: ${errorMessage}`);
|
||||
|
|
@ -248,7 +244,7 @@ export function useWidgetRecording({
|
|||
runtimeRef.current.reset();
|
||||
showError(message);
|
||||
}
|
||||
}, [dispatch, machineRef, resizeWidget, setStream, settings, showError, showNotice]);
|
||||
}, [dispatch, machineRef, resizeWidget, setStream, settings, showError]);
|
||||
|
||||
// ── Keep stopAndProcessRef current ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ export interface ProcessRecordingBlobParams {
|
|||
export interface ProcessRecordingBlobResult {
|
||||
durationSeconds: number;
|
||||
hasTranscription: boolean;
|
||||
pasteErrorMessage?: string;
|
||||
}
|
||||
|
||||
export interface RetryHistoryEntryResult {
|
||||
|
|
@ -105,10 +104,6 @@ function toUserFacingErrorMessage(error: unknown): string {
|
|||
return "Не удалось обработать запись. Попробуйте отправить ее повторно.";
|
||||
}
|
||||
|
||||
function toUserFacingPasteErrorMessage(): string {
|
||||
return "Текст распознан, но вставить его не удалось. Скопируйте его из истории.";
|
||||
}
|
||||
|
||||
const PROXY_BASE_URL = "https://proxy.talkis.ru";
|
||||
|
||||
async function transcribeViaProxy({
|
||||
|
|
@ -213,7 +208,7 @@ async function transcribeAudio({
|
|||
async function pasteCleanedText(text: string): Promise<void> {
|
||||
logInfo("PASTE", "Sending cleaned text to paste_text");
|
||||
await invoke("paste_text", { text });
|
||||
logInfo("PASTE", "paste_text finished successfully");
|
||||
logInfo("PASTE", "paste_text command finished; target app insertion cannot be confirmed reliably");
|
||||
}
|
||||
|
||||
async function saveAndEmitHistoryEntry(entry: HistoryEntry, mode: "add" | "update"): Promise<void> {
|
||||
|
|
@ -268,18 +263,17 @@ export async function processRecordingBlob({
|
|||
|
||||
await saveAndEmitHistoryEntry(historyEntry, "add");
|
||||
|
||||
let pasteFailed = false;
|
||||
try {
|
||||
await pasteCleanedText(result.cleaned);
|
||||
} catch (pasteError) {
|
||||
pasteFailed = true;
|
||||
logError("PASTE", `Paste failed after successful transcription: ${formatErrorMessage(pasteError)}`);
|
||||
|
||||
return {
|
||||
durationSeconds,
|
||||
hasTranscription: true,
|
||||
pasteErrorMessage: toUserFacingPasteErrorMessage(),
|
||||
};
|
||||
}
|
||||
|
||||
logInfo("PASTE", pasteFailed
|
||||
? "Automatic paste command failed; latest text remains copyable from the idle widget"
|
||||
: "Automatic paste command completed without OS-level errors");
|
||||
return { durationSeconds, hasTranscription: true };
|
||||
} catch (error) {
|
||||
const rawErrorMessage = error instanceof Error
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ export type WidgetAction =
|
|||
| { type: "RELEASE_STOP_TIMER_FIRED" }
|
||||
// State transitions
|
||||
| { type: "SET_PROCESSING" }
|
||||
| { type: "MANUAL_RECORDING_START" }
|
||||
| { type: "MANUAL_RECORDING_STOP" }
|
||||
// Error
|
||||
| { type: "ERROR"; message: string }
|
||||
// Direct state transitions for hotkey capture interference
|
||||
|
|
@ -106,6 +108,10 @@ export function widgetReducer(
|
|||
return handleReleaseStopTimerFired(state);
|
||||
case "SET_PROCESSING":
|
||||
return handleSetProcessing(state);
|
||||
case "MANUAL_RECORDING_START":
|
||||
return handleManualRecordingStart(state);
|
||||
case "MANUAL_RECORDING_STOP":
|
||||
return handleManualRecordingStop(state);
|
||||
case "ERROR":
|
||||
return handleError(state, action.message);
|
||||
case "RESET_HOTKEY_STATE":
|
||||
|
|
@ -184,8 +190,14 @@ function handleRecordingStarted(state: WidgetMachineState, timestamp: number): R
|
|||
recordingStartTimestamp: timestamp,
|
||||
};
|
||||
|
||||
// If user released while we were starting up → immediate stop
|
||||
if ((!state.hotkeyHeld || state.pendingStopAfterStart) && !state.lockedRecording) {
|
||||
// If user requested stop while recorder was starting up → immediate stop.
|
||||
if (state.pendingStopAfterStart) {
|
||||
next.pendingStopAfterStart = false;
|
||||
return result(next, [{ type: "stop_and_process" }]);
|
||||
}
|
||||
|
||||
// If user released while we were starting up → immediate stop.
|
||||
if (!state.hotkeyHeld && !state.lockedRecording) {
|
||||
next.pendingStopAfterStart = false;
|
||||
return result(next, [{ type: "stop_and_process" }]);
|
||||
}
|
||||
|
|
@ -229,6 +241,41 @@ function handleSetProcessing(state: WidgetMachineState): ReducerResult {
|
|||
}, []);
|
||||
}
|
||||
|
||||
function handleManualRecordingStart(state: WidgetMachineState): ReducerResult {
|
||||
if (state.widgetState !== "idle") {
|
||||
return result(state, []);
|
||||
}
|
||||
|
||||
return result({
|
||||
...state,
|
||||
widgetState: "recording",
|
||||
hotkeyHeld: false,
|
||||
lockedRecording: true,
|
||||
recordingActive: false,
|
||||
pendingStopAfterStart: false,
|
||||
releaseStopTimerActive: false,
|
||||
suppressNextRelease: false,
|
||||
}, [
|
||||
{ type: "set_locked_recording_ui", value: true },
|
||||
{ type: "start_recording" },
|
||||
]);
|
||||
}
|
||||
|
||||
function handleManualRecordingStop(state: WidgetMachineState): ReducerResult {
|
||||
if (state.widgetState !== "recording") {
|
||||
return result(state, []);
|
||||
}
|
||||
|
||||
if (!state.recordingActive) {
|
||||
return result({
|
||||
...state,
|
||||
pendingStopAfterStart: true,
|
||||
}, []);
|
||||
}
|
||||
|
||||
return result(state, [{ type: "stop_and_process" }]);
|
||||
}
|
||||
|
||||
function handleReleaseStopTimerFired(state: WidgetMachineState): ReducerResult {
|
||||
const next = { ...state, releaseStopTimerActive: false };
|
||||
|
||||
|
|
|
|||
|
|
@ -9,12 +9,17 @@ export interface WidgetNoticeState {
|
|||
export const MIN_RECORDING_DURATION_MS = 500;
|
||||
export const MIN_AUDIO_BLOB_BYTES = 1024;
|
||||
export const NOTICE_TIMEOUT_MS = 5000;
|
||||
export const WIDGET_SHELL_WIDTH = 50;
|
||||
export const WIDGET_SHELL_HEIGHT = 18;
|
||||
export const IDLE_WIDGET_WIDTH = WIDGET_SHELL_WIDTH;
|
||||
export const IDLE_WIDGET_HEIGHT = WIDGET_SHELL_HEIGHT;
|
||||
export const RECORDING_WIDGET_WIDTH = WIDGET_SHELL_WIDTH;
|
||||
export const RECORDING_WIDGET_HEIGHT = WIDGET_SHELL_HEIGHT;
|
||||
export const WIDGET_SHELL_WIDTH = 44;
|
||||
export const WIDGET_SHELL_HEIGHT = 16;
|
||||
export const IDLE_HOVER_SCALE = 1.5;
|
||||
export const ACTIVE_WIDGET_SHELL_WIDTH = WIDGET_SHELL_WIDTH * IDLE_HOVER_SCALE;
|
||||
export const ACTIVE_WIDGET_SHELL_HEIGHT = WIDGET_SHELL_HEIGHT * IDLE_HOVER_SCALE;
|
||||
export const IDLE_HOVER_WIDGET_WIDTH = ACTIVE_WIDGET_SHELL_WIDTH + 12;
|
||||
export const IDLE_HOVER_WIDGET_HEIGHT = ACTIVE_WIDGET_SHELL_HEIGHT + 8;
|
||||
export const IDLE_WIDGET_WIDTH = IDLE_HOVER_WIDGET_WIDTH;
|
||||
export const IDLE_WIDGET_HEIGHT = IDLE_HOVER_WIDGET_HEIGHT;
|
||||
export const RECORDING_WIDGET_WIDTH = IDLE_HOVER_WIDGET_WIDTH;
|
||||
export const RECORDING_WIDGET_HEIGHT = IDLE_HOVER_WIDGET_HEIGHT;
|
||||
export const NOTICE_WIDGET_WIDTH = 212;
|
||||
export const NOTICE_AREA_HEIGHT = 52;
|
||||
/** Must match NOTICE_GAP in src-tauri/src/lib.rs (logical pixels). */
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@
|
|||
| 5.4 | Вставка в терминал | Текст в терминал | Cmd+V работает |
|
||||
| 5.5 | Быстрая двойная запись (2 подряд) | Оба текста вставлены последовательно | Нет потери второго |
|
||||
| 5.6 | Буфер обмена восстановлен | Скопировать текст → записать → вставить (Cmd+V ещё раз) | Исходный буфер восстановился |
|
||||
| 5.7 | Быстрое копирование последней записи | Навести курсор на неактивный виджет после успешной транскрибации | Внутри виджета появляется иконка копирования; клик копирует последний обработанный текст и кратко показывает состояние успеха |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue