feat: implement frameless OS-level floating dictation widget

- Refactor CaptureButton into standalone CaptureWidget
- Add secondary transparent Tauri window configuration
- Map global hotkey to show/hide widget instead of focusing main app
- Implement auto-hide post-paste
- Add social preview image
This commit is contained in:
debpalash
2026-05-03 07:27:43 +05:30
parent fba066c3d0
commit 37a03acae3
9 changed files with 76 additions and 49 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+12 -12
View File
@@ -15,7 +15,7 @@
},
"frontend": {
"name": "omnivoice-studio",
"version": "0.2.6",
"version": "0.2.7",
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/source-serif-4": "^5.2.9",
@@ -29,40 +29,40 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "4",
"@tanstack/react-query": "^5.100.4",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.100.8",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-window-state": "^2.4.1",
"i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.8.0",
"lucide-react": "^1.14.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hot-toast": "^2.6.0",
"react-i18next": "^17.0.6",
"react-window": "^2.2.7",
"tailwindcss": "4",
"tailwindcss": "^4.2.4",
"wavesurfer.js": "^7.12.6",
"zustand": "^5.0.12",
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/cli": "^2.10.1",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/cli": "^2.11.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"globals": "^17.6.0",
"typescript": "^6.0.3",
"vite": "^8.0.9",
"vite": "^8.0.10",
},
},
},
+1 -1
View File
@@ -2757,7 +2757,7 @@ dependencies = [
[[package]]
name = "omnivoice-studio"
version = "0.2.6"
version = "0.2.7"
dependencies = [
"enigo",
"libc",
+1 -1
View File
@@ -126,7 +126,7 @@ pub fn run() {
.with_handler(move |app_handle, _shortcut, event| {
if event.state == ShortcutState::Pressed {
log::info!("Global shortcut triggered: dictation");
if let Some(win) = app_handle.get_webview_window("main") {
if let Some(win) = app_handle.get_webview_window("widget") {
let _ = win.show();
let _ = win.set_focus();
}
+14
View File
@@ -23,6 +23,20 @@
"fullscreen": false,
"titleBarStyle": "Overlay",
"hiddenTitle": true
},
{
"label": "widget",
"title": "Dictation Widget",
"url": "/?window=widget",
"width": 350,
"height": 220,
"resizable": false,
"fullscreen": false,
"transparent": true,
"decorations": false,
"alwaysOnTop": true,
"visible": false,
"skipTaskbar": true
}
],
"security": {
+2 -2
View File
@@ -28,7 +28,7 @@ import Header from './components/Header';
import NavRail from './components/NavRail';
import ErrorBoundary from './components/ErrorBoundary';
import FloatingPill from './components/FloatingPill';
import CaptureButton from './components/CaptureButton';
import useRealtimeEvents from './hooks/useRealtimeEvents';
import { BootstrapSplash, useBootstrapStage } from './components/BootstrapSplash';
@@ -1767,7 +1767,7 @@ function App() {
}}/>
<FloatingPill />
<CaptureButton />
<Header
mode={mode} setMode={setMode}
@@ -1,16 +1,14 @@
/* ── CaptureButton — Global dictation FAB ─────────────────────────────── */
.capture-widget {
position: fixed;
/* Sit above the footer status bar (28px collapsed, expands via CSS var) */
bottom: calc(var(--logs-footer-height, 28px) + 12px);
right: 18px;
z-index: 9000;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
pointer-events: none;
align-items: center;
justify-content: center;
padding: 10px;
box-sizing: border-box;
}
.capture-widget > * {
@@ -69,8 +67,11 @@
border: 1px solid color-mix(in srgb, var(--chrome-border, #3c3836) 60%, transparent);
border-radius: 16px;
padding: 14px 16px;
min-width: 260px;
max-width: 320px;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.35);
animation: capture-slide-up 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Mic, MicOff, Clipboard, X, Loader, Zap, Target, Check } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { useAppStore } from '../store';
import './CaptureButton.css';
import './CaptureWidget.css';
import { API as API_BASE } from '../api/client';
import { addTranscription } from '../pages/Transcriptions';
@@ -33,11 +33,10 @@ const LS_AUTO_COPY = 'omni_capture_auto_copy';
*
* Auto-copies to clipboard so users can immediately V into any app.
*/
export default function CaptureButton() {
export default function CaptureWidget() {
const [state, setState] = useState('idle'); // idle | recording | transcribing | done | error
const [transcript, setTranscript] = useState('');
const [duration, setDuration] = useState(0);
const [expanded, setExpanded] = useState(false);
const [captureMode, setCaptureMode] = useState(() =>
localStorage.getItem(LS_CAPTURE_MODE) || 'fast'
);
@@ -137,6 +136,19 @@ export default function CaptureButton() {
} catch {
toast.success('Copied to clipboard — paste with ⌘V', { duration: 2000 });
}
// Auto-dismiss the floating widget after 2.5 seconds so it gets out of the way
setTimeout(async () => {
setState('idle');
setTranscript('');
setDuration(0);
setCopied(false);
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().hide();
} catch { /* not in Tauri */ }
}, 2500);
} catch { /* clipboard API may fail in some contexts */ }
}
}, [autoCopy]);
@@ -371,12 +383,15 @@ export default function CaptureButton() {
});
}, [transcript]);
const dismiss = () => {
const dismiss = async () => {
setState('idle');
setTranscript('');
setExpanded(false);
setDuration(0);
setCopied(false);
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().hide();
} catch { /* not in Tauri */ }
};
const toggleCapture = () => {
@@ -395,11 +410,9 @@ export default function CaptureButton() {
};
return (
<div className={`capture-widget ${expanded ? 'capture-widget--expanded' : ''}`}>
{/* Expanded panel */}
{expanded && (
<div className="capture-widget">
<div className="capture-panel">
<div className="capture-panel__header">
<div className="capture-panel__header" data-tauri-drag-region>
<span className="capture-panel__title">
{state === 'recording' && '🎙️ Listening…'}
{state === 'transcribing' && '📝 Transcribing…'}
@@ -491,22 +504,10 @@ export default function CaptureButton() {
</button>
</div>
<div className="capture-panel__hint">
<div className="capture-panel__hint" data-tauri-drag-region>
<kbd>{navigator.platform?.includes('Mac') ? '⌘' : 'Ctrl'}</kbd>+<kbd></kbd>+<kbd>Space</kbd>
</div>
</div>
)}
{/* Main FAB button */}
<button
className={`capture-fab ${state === 'recording' ? 'capture-fab--recording' : ''} ${state === 'transcribing' ? 'capture-fab--busy' : ''}`}
onClick={toggleCapture}
disabled={state === 'transcribing'}
title={state === 'recording' ? 'Stop recording' : 'Start dictation (⌘+⇧+Space)'}
aria-label={state === 'recording' ? 'Stop recording' : 'Start voice dictation'}
>
{state === 'recording' ? <MicOff size={20} /> : state === 'transcribing' ? <Loader size={20} className="spinner" /> : <Mic size={20} />}
</button>
</div>
);
}
+11
View File
@@ -27,11 +27,22 @@ const queryClient = new QueryClient({
},
});
import { Suspense, lazy } from 'react';
const CaptureWidget = lazy(() => import('./components/CaptureWidget.jsx'));
export function bootstrapApp() {
const isWidget = window.location.search.includes('window=widget');
createRoot(document.getElementById('root')).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
{isWidget ? (
<Suspense fallback={<div>Loading...</div>}>
<CaptureWidget />
</Suspense>
) : (
<App />
)}
</QueryClientProvider>
</StrictMode>,
);