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:
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Generated
+1
-1
@@ -2757,7 +2757,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.2.6"
|
||||
version = "0.2.7"
|
||||
dependencies = [
|
||||
"enigo",
|
||||
"libc",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}
|
||||
|
||||
+11
-10
@@ -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);
|
||||
}
|
||||
+23
-22
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
<App />
|
||||
{isWidget ? (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<CaptureWidget />
|
||||
</Suspense>
|
||||
) : (
|
||||
<App />
|
||||
)}
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user