feat: redesign mic button UI, restructure README (RU first) (#9)
## UI Changes - Replace emoji icons (🎤⏻⌛) with clean SVG (Material Design mic, stop, spinner) - Move button to **top-right corner** of composer (both PC and mobile) - Separate timer pill element — no more cramped/overlapping timer inside button - Pulsing red dot + monospace timer during recording - Spinner animation during transcription - Ensure composer is `position: relative` for absolute positioning ## README - Russian section first, then English - Minimalistic: removed Development/CI-CD sections - Prominent one-click install link - Kept: compatibility table, setup, usage, auto-update, settings ## Test Results - ✅ 44 tests passed - ✅ 100% coverage - ✅ Lint, Typecheck, Knip — clean - ✅ Build: 20.13 KB Co-authored-by: opencode-agent <agent@slaid098.dev>
This commit is contained in:
parent
a2cf35b509
commit
33f2f48ac9
2 changed files with 167 additions and 256 deletions
286
README.md
286
README.md
|
|
@ -1,268 +1,110 @@
|
||||||
# OpenCode Voice Dictation / Голосовая диктовка для OpenCode
|
# OpenCode Voice Dictation / Голосовая диктовка для OpenCode
|
||||||
|
|
||||||
[](https://github.com/slaid098/opencode-voice-dictation/actions/workflows/ci.yml)
|
[](https://github.com/slaid098/opencode-voice-dictation/actions/workflows/ci.yml)
|
||||||
|
[](https://github.com/slaid098/opencode-voice-dictation/actions/workflows/release.yml)
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
|
||||||
Voice dictation for [OpenCode](https://opencode.ai) web interface using OpenAI Whisper via Groq API. Works on both desktop and mobile browsers through a Tampermonkey/Violentmonkey userscript.
|
|
||||||
|
|
||||||
Голосовая диктовка для веб-интерфейса [OpenCode](https://opencode.ai) с использованием OpenAI Whisper через Groq API. Работает как на десктопных, так и на мобильных браузерах через юзерскрипт Tampermonkey/Violentmonkey.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## English
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **Whisper large-v3** transcription via Groq API (same quality as Cursor)
|
|
||||||
- **Tap-to-toggle** recording (tap to start, tap to stop)
|
|
||||||
- **Noise suppression** + echo cancellation (great for outdoor use)
|
|
||||||
- **Recording timer** (MM:SS display)
|
|
||||||
- **Ctrl+Space** keyboard shortcut on desktop
|
|
||||||
- **Custom Whisper prompt** for software development context
|
|
||||||
- **Auto-detect language** (Russian + English technical terms)
|
|
||||||
- **Auto-submit** option (configurable)
|
|
||||||
- **No backend needed** — API calls go directly from browser via `GM_xmlhttpRequest`
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
|
|
||||||
| Component | Requirement |
|
|
||||||
|-----------|-------------|
|
|
||||||
| OpenCode | Web interface running (`opencode web`) |
|
|
||||||
| Groq API key | Free at [console.groq.com/keys](https://console.groq.com/keys) |
|
|
||||||
| PC Browser | Vivaldi / Chrome / Firefox + Violentmonkey or Tampermonkey |
|
|
||||||
| Mobile Browser | Firefox for Android + Tampermonkey |
|
|
||||||
|
|
||||||
### Compatibility
|
|
||||||
|
|
||||||
| Platform | Browser | Userscript Manager | Status |
|
|
||||||
|----------|---------|-------------------|--------|
|
|
||||||
| Desktop (Linux/macOS/Windows) | Vivaldi | Violentmonkey | ✅ Supported |
|
|
||||||
| Desktop | Chrome | Tampermonkey | ✅ Supported |
|
|
||||||
| Desktop | Firefox | Tampermonkey | ✅ Supported |
|
|
||||||
| Android | Firefox | Tampermonkey | ✅ Supported |
|
|
||||||
| Android | Vivaldi | — | ❌ No extension support |
|
|
||||||
| Android | Chrome | — | ❌ No extension support |
|
|
||||||
|
|
||||||
### Install
|
|
||||||
|
|
||||||
> **One-click install**: Open this link in your browser with Violentmonkey/Tampermonkey installed:
|
|
||||||
>
|
|
||||||
> **[Install Script](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)**
|
|
||||||
>
|
|
||||||
> Your userscript manager will intercept the download and show an installation dialog.
|
|
||||||
|
|
||||||
#### Desktop (Vivaldi + Violentmonkey)
|
|
||||||
|
|
||||||
1. Install [Violentmonkey](https://violentmonkey.github.io/) extension in Vivaldi
|
|
||||||
2. Click the [Install Script](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js) link
|
|
||||||
3. Confirm installation in the Violentmonkey dialog
|
|
||||||
|
|
||||||
#### Android (Firefox + Tampermonkey)
|
|
||||||
|
|
||||||
1. Install [Firefox for Android](https://play.google.com/store/apps/details?id=org.mozilla.firefox) from Play Store
|
|
||||||
2. Install [Tampermonkey](https://addons.mozilla.org/en-US/android/addon/tampermonkey/) from Firefox Add-ons
|
|
||||||
3. In Firefox, open the [Install Script](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js) link
|
|
||||||
4. Confirm installation in the Tampermonkey dialog
|
|
||||||
|
|
||||||
#### Manual install (from GitHub Releases)
|
|
||||||
|
|
||||||
1. Go to [Releases](https://github.com/slaid098/opencode-voice-dictation/releases)
|
|
||||||
2. Download `opencode-voice-dictation.user.js` from the latest release
|
|
||||||
3. Open Violentmonkey/Tampermonkey dashboard → **+** → paste the file contents → Save
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
1. Get a free Groq API key at [console.groq.com/keys](https://console.groq.com/keys)
|
|
||||||
2. Open any OpenCode web page
|
|
||||||
3. Open Violentmonkey/Tampermonkey menu → **"Set Groq API Key"**
|
|
||||||
4. Paste your key (`gsk_...`)
|
|
||||||
|
|
||||||
### Auto-Update
|
|
||||||
|
|
||||||
The script updates automatically. No action needed.
|
|
||||||
|
|
||||||
- `@updateURL` checks for new versions periodically (every 24h in Tampermonkey, every 7 days in Violentmonkey)
|
|
||||||
- Only a tiny `.meta.js` (~1KB) is downloaded to check the version
|
|
||||||
- If a new version is found, the full `.user.js` (~18KB) is downloaded and installed
|
|
||||||
- Updates are published automatically by CI when code is pushed to `main`
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
1. Open OpenCode web in your browser
|
|
||||||
2. A **microphone button** appears next to the chat input
|
|
||||||
3. **Tap** the button to start recording (red pulse + timer)
|
|
||||||
4. **Tap** again to stop — audio is transcribed and inserted into the input
|
|
||||||
5. (Desktop) Press **Ctrl+Space** as an alternative to clicking
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
All settings available via the Violentmonkey/Tampermonkey menu:
|
|
||||||
|
|
||||||
| Menu Item | Description |
|
|
||||||
|-----------|-------------|
|
|
||||||
| Set Groq API Key | Enter your `gsk_...` key |
|
|
||||||
| Toggle Auto-Submit | Auto-send message after transcription |
|
|
||||||
| Set Whisper Model | `whisper-large-v3` (default) or `whisper-large-v3-turbo` (faster) |
|
|
||||||
| Set Language | Language code (e.g., `ru`, `en`) or empty for auto-detect |
|
|
||||||
| Set Whisper Prompt | Context prompt for transcription accuracy |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Русский
|
## Русский
|
||||||
|
|
||||||
### Возможности
|
Голосовая диктовка для [OpenCode](https://opencode.ai) web через Whisper (Groq API). Качество как в Cursor. Работает на ПК и телефоне.
|
||||||
|
|
||||||
- **Whisper large-v3** транскрипция через Groq API (качество как в Cursor)
|
### Установка
|
||||||
- **Tap-to-toggle** запись (тапнул — начал, тапнул — стоп)
|
|
||||||
- **Шумоподавление** + эхоподавление (для записи на улице)
|
|
||||||
- **Таймер записи** (формат MM:SS)
|
|
||||||
- **Ctrl+Space** горячая клавиша на ПК
|
|
||||||
- **Кастомный Whisper prompt** для контекста разработки
|
|
||||||
- **Авто-определение языка** (русский + английские технические термины)
|
|
||||||
- **Авто-отправка** опционально (настраивается)
|
|
||||||
- **Без бэкенда** — запросы идут напрямую из браузера через `GM_xmlhttpRequest`
|
|
||||||
|
|
||||||
### Требования
|
> **[Установить скрипт](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)** — открой ссылку в браузере с Violentmonkey/Tampermonkey и подтверди установку.
|
||||||
|
|
||||||
| Компонент | Требование |
|
Или скачай с [Releases](https://github.com/slaid098/opencode-voice-dictation/releases) и импортируй вручную.
|
||||||
|-----------|------------|
|
|
||||||
| OpenCode | Запущенный веб-интерфейс (`opencode web`) |
|
|
||||||
| Groq API ключ | Бесплатно на [console.groq.com/keys](https://console.groq.com/keys) |
|
|
||||||
| Браузер ПК | Vivaldi / Chrome / Firefox + Violentmonkey или Tampermonkey |
|
|
||||||
| Браузер телефон | Firefox для Android + Tampermonkey |
|
|
||||||
|
|
||||||
### Совместимость
|
### Совместимость
|
||||||
|
|
||||||
| Платформа | Браузер | Менеджер скриптов | Статус |
|
| Платформа | Браузер | Менеджер скриптов | Статус |
|
||||||
|-----------|---------|-------------------|--------|
|
|-----------|---------|-------------------|--------|
|
||||||
| Десктоп (Linux/macOS/Windows) | Vivaldi | Violentmonkey | ✅ Поддерживается |
|
| ПК | Vivaldi | Violentmonkey | ✅ |
|
||||||
| Десктоп | Chrome | Tampermonkey | ✅ Поддерживается |
|
| ПК | Chrome | Tampermonkey | ✅ |
|
||||||
| Десктоп | Firefox | Tampermonkey | ✅ Поддерживается |
|
| ПК | Firefox | Tampermonkey | ✅ |
|
||||||
| Android | Firefox | Tampermonkey | ✅ Поддерживается |
|
| Android | Firefox | Tampermonkey | ✅ |
|
||||||
| Android | Vivaldi | — | ❌ Нет поддержки расширений |
|
| Android | Vivaldi | — | ❌ Нет расширений |
|
||||||
| Android | Chrome | — | ❌ Нет поддержки расширений |
|
| Android | Chrome | — | ❌ Нет расширений |
|
||||||
|
|
||||||
### Установка
|
|
||||||
|
|
||||||
> **Установка в один клик**: Откройте эту ссылку в браузере с установленным Violentmonkey/Tampermonkey:
|
|
||||||
>
|
|
||||||
> **[Установить скрипт](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)**
|
|
||||||
>
|
|
||||||
> Менеджер скриптов перехватит загрузку и покажет диалог установки.
|
|
||||||
|
|
||||||
#### ПК (Vivaldi + Violentmonkey)
|
|
||||||
|
|
||||||
1. Установите расширение [Violentmonkey](https://violentmonkey.github.io/) в Vivaldi
|
|
||||||
2. Нажмите на ссылку [Установить скрипт](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)
|
|
||||||
3. Подтвердите установку в диалоге Violentmonkey
|
|
||||||
|
|
||||||
#### Android (Firefox + Tampermonkey)
|
|
||||||
|
|
||||||
1. Установите [Firefox для Android](https://play.google.com/store/apps/details?id=org.mozilla.firefox) из Play Store
|
|
||||||
2. Установите [Tampermonkey](https://addons.mozilla.org/ru/android/addon/tampermonkey/) из дополнений Firefox
|
|
||||||
3. В Firefox откройте ссылку [Установить скрипт](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)
|
|
||||||
4. Подтвердите установку в диалоге Tampermonkey
|
|
||||||
|
|
||||||
#### Ручная установка (из GitHub Releases)
|
|
||||||
|
|
||||||
1. Перейдите на страницу [Releases](https://github.com/slaid098/opencode-voice-dictation/releases)
|
|
||||||
2. Скачайте `opencode-voice-dictation.user.js` из последнего релиза
|
|
||||||
3. Откройте панель Violentmonkey/Tampermonkey → **+** → вставьте содержимое файла → Сохранить
|
|
||||||
|
|
||||||
### Настройка
|
### Настройка
|
||||||
|
|
||||||
1. Получите бесплатный ключ Groq на [console.groq.com/keys](https://console.groq.com/keys)
|
1. Получи бесплатный ключ на [console.groq.com/keys](https://console.groq.com/keys)
|
||||||
2. Откройте любую страницу OpenCode web
|
2. Открой OpenCode web
|
||||||
3. Откройте меню Violentmonkey/Tampermonkey → **"Set Groq API Key"**
|
3. Меню Violentmonkey/Tampermonkey → **Set Groq API Key** → вставь `gsk_...`
|
||||||
4. Вставьте ваш ключ (`gsk_...`)
|
|
||||||
|
|
||||||
### Автообновление
|
|
||||||
|
|
||||||
Скрипт обновляется автоматически. Ничего делать не нужно.
|
|
||||||
|
|
||||||
- `@updateURL` периодически проверяет новые версии (каждые 24ч в Tampermonkey, каждые 7 дней в Violentmonkey)
|
|
||||||
- Скачивается только маленький `.meta.js` (~1KB) для проверки версии
|
|
||||||
- Если найдена новая версия — скачивается полный `.user.js` (~18KB) и устанавливается
|
|
||||||
- Обновления публикуются автоматически CI при пуше в `main`
|
|
||||||
|
|
||||||
### Использование
|
### Использование
|
||||||
|
|
||||||
1. Откройте OpenCode web в браузере
|
1. Нажми кнопку микрофона в правом верхнем углу поля ввода
|
||||||
2. Рядом с полем ввода появится **кнопка микрофона**
|
2. Говори (виден таймер записи)
|
||||||
3. **Тапните** кнопку для начала записи (красная пульсация + таймер)
|
3. Нажми снова — текст появится в поле ввода
|
||||||
4. **Тапните** снова для остановки — аудио транскрибируется и вставляется в поле ввода
|
4. На ПК: **Ctrl+Space** — горячая клавиша
|
||||||
5. (На ПК) Нажмите **Ctrl+Space** как альтернативу клику
|
|
||||||
|
### Автообновление
|
||||||
|
|
||||||
|
Скрипт обновляется сам. При пуше в `main` CI собирает новую версию и публикует в ветку `dist`. Пользователи получают обновление автоматически.
|
||||||
|
|
||||||
### Настройки
|
### Настройки
|
||||||
|
|
||||||
Все параметры доступны через меню Violentmonkey/Tampermonkey:
|
|
||||||
|
|
||||||
| Пункт меню | Описание |
|
| Пункт меню | Описание |
|
||||||
|------------|----------|
|
|------------|----------|
|
||||||
| Set Groq API Key | Ввести ключ `gsk_...` |
|
| Set Groq API Key | Ключ `gsk_...` |
|
||||||
| Toggle Auto-Submit | Автоматическая отправка после транскрипции |
|
| Toggle Auto-Submit | Отправлять после транскрипции |
|
||||||
| Set Whisper Model | `whisper-large-v3` (по умолчанию) или `whisper-large-v3-turbo` (быстрее) |
|
| Set Whisper Model | `whisper-large-v3` или `whisper-large-v3-turbo` |
|
||||||
| Set Language | Код языка (например, `ru`, `en`) или пусто для авто-определения |
|
| Set Language | `ru`, `en` или пусто (авто) |
|
||||||
| Set Whisper Prompt | Контекстный промпт для точности транскрипции |
|
| Set Whisper Prompt | Контекст для точности |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development / Разработка
|
## English
|
||||||
|
|
||||||
### Prerequisites
|
Voice dictation for [OpenCode](https://opencode.ai) web via Whisper (Groq API). Cursor-quality transcription. Works on desktop and mobile.
|
||||||
|
|
||||||
- Node.js 22+
|
### Install
|
||||||
- npm
|
|
||||||
|
> **[Install Script](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)** — open this link in a browser with Violentmonkey/Tampermonkey and confirm installation.
|
||||||
|
|
||||||
|
Or download from [Releases](https://github.com/slaid098/opencode-voice-dictation/releases) and import manually.
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
| Platform | Browser | Userscript Manager | Status |
|
||||||
|
|----------|---------|-------------------|--------|
|
||||||
|
| Desktop | Vivaldi | Violentmonkey | ✅ |
|
||||||
|
| Desktop | Chrome | Tampermonkey | ✅ |
|
||||||
|
| Desktop | Firefox | Tampermonkey | ✅ |
|
||||||
|
| Android | Firefox | Tampermonkey | ✅ |
|
||||||
|
| Android | Vivaldi | — | ❌ No extensions |
|
||||||
|
| Android | Chrome | — | ❌ No extensions |
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
```bash
|
1. Get a free API key at [console.groq.com/keys](https://console.groq.com/keys)
|
||||||
git clone https://github.com/slaid098/opencode-voice-dictation.git
|
2. Open OpenCode web
|
||||||
cd opencode-voice-dictation
|
3. Violentmonkey/Tampermonkey menu → **Set Groq API Key** → paste `gsk_...`
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Commands
|
### Usage
|
||||||
|
|
||||||
| Command | Description |
|
1. Click the mic button in the top-right corner of the input area
|
||||||
|---------|-------------|
|
2. Speak (recording timer is displayed)
|
||||||
| `npm run dev` | Development mode with auto-reload |
|
3. Click again — transcribed text appears in the input
|
||||||
| `npm run build` | Build to `dist/opencode-voice-dictation.user.js` |
|
4. Desktop: **Ctrl+Space** shortcut
|
||||||
| `npm run lint` | Lint with Biome |
|
|
||||||
| `npm run format` | Format with Biome |
|
|
||||||
| `npm run typecheck` | TypeScript type checking |
|
|
||||||
| `npm run test` | Run tests with coverage (60% threshold) |
|
|
||||||
| `npm run knip` | Find unused code |
|
|
||||||
|
|
||||||
### Project Structure
|
### Auto-Update
|
||||||
|
|
||||||
```
|
The script updates automatically. When code is pushed to `main`, CI builds and publishes to the `dist` branch. Users receive updates automatically.
|
||||||
src/
|
|
||||||
├── index.ts # Entry point — orchestrates all modules
|
|
||||||
├── config.ts # GM storage, defaults, validation, menu commands
|
|
||||||
├── audio.ts # MediaRecorder, getUserMedia, audio constraints
|
|
||||||
├── transcribe.ts # Groq API call, FormData builder, error parsing
|
|
||||||
├── insert.ts # Contenteditable text insertion + InputEvent dispatch
|
|
||||||
├── ui.ts # Button injection, MutationObserver, toast notifications
|
|
||||||
├── keyboard.ts # Ctrl+Space keyboard shortcut handler
|
|
||||||
└── types.ts # Shared TypeScript types
|
|
||||||
|
|
||||||
tests/
|
### Settings
|
||||||
├── transcribe.test.ts # FormData format, error parsing (5 tests)
|
|
||||||
├── insert.test.ts # Contenteditable insertion, event dispatch (5 tests)
|
|
||||||
├── config.test.ts # Defaults, key validation, state (9 tests)
|
|
||||||
├── audio.test.ts # Time formatting (5 tests)
|
|
||||||
└── __mocks__/$/ # Mock for GM_api functions
|
|
||||||
```
|
|
||||||
|
|
||||||
### CI/CD
|
| Menu Item | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| Set Groq API Key | API key `gsk_...` |
|
||||||
|
| Toggle Auto-Submit | Auto-send after transcription |
|
||||||
|
| Set Whisper Model | `whisper-large-v3` or `whisper-large-v3-turbo` |
|
||||||
|
| Set Language | `ru`, `en`, or empty (auto-detect) |
|
||||||
|
| Set Whisper Prompt | Context for accuracy |
|
||||||
|
|
||||||
- **Lint**: Biome (recommended rules, `noExplicitAny: error`)
|
---
|
||||||
- **Typecheck**: `tsc --noEmit`
|
|
||||||
- **Knip**: Dead code detection
|
|
||||||
- **Test**: Vitest + happy-dom, 60% coverage threshold
|
|
||||||
- **Build**: Vite → single `.user.js` file
|
|
||||||
- **Dependabot**: Weekly npm + GitHub Actions updates
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
137
src/ui.ts
137
src/ui.ts
|
|
@ -1,56 +1,103 @@
|
||||||
import type { DictationState } from "./types.js";
|
import type { DictationState } from "./types.js";
|
||||||
|
|
||||||
const BUTTON_ID = "opencode-voice-dictation-btn";
|
const BUTTON_ID = "opencode-voice-dictation-btn";
|
||||||
|
const CONTAINER_ID = "opencode-voice-dictation-container";
|
||||||
|
const TIMER_ID = "opencode-voice-dictation-timer";
|
||||||
const COMPOSER_SELECTORS = [
|
const COMPOSER_SELECTORS = [
|
||||||
'[data-component="session-composer"]',
|
'[data-component="session-composer"]',
|
||||||
'[data-component="session-new-composer"]',
|
'[data-component="session-new-composer"]',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ICON_MIC = `<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/><path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>`;
|
||||||
|
const ICON_STOP = `<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="3"/></svg>`;
|
||||||
|
const ICON_SPINNER = `<svg class="ocvd-spin" viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8z"/></svg>`;
|
||||||
|
|
||||||
function createButtonStyle(): string {
|
function createButtonStyle(): string {
|
||||||
return `
|
return `
|
||||||
|
#${CONTAINER_ID} {
|
||||||
|
position: absolute !important;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 999998;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#${CONTAINER_ID} > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
#${BUTTON_ID} {
|
#${BUTTON_ID} {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 36px;
|
width: 32px;
|
||||||
height: 36px;
|
height: 32px;
|
||||||
border-radius: 8px;
|
border-radius: 50%;
|
||||||
border: 1px solid var(--color-border, #333);
|
border: none;
|
||||||
background: var(--color-bg-secondary, #1a1a1a);
|
background: var(--color-bg-tertiary, rgba(128, 128, 128, 0.15));
|
||||||
color: var(--color-text-secondary, #888);
|
color: var(--color-text-secondary, #888);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
gap: 4px;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
#${BUTTON_ID}:hover {
|
#${BUTTON_ID}:hover {
|
||||||
background: var(--color-bg-tertiary, #2a2a2a);
|
background: var(--color-bg-hover, rgba(128, 128, 128, 0.25));
|
||||||
color: var(--color-text-primary, #fff);
|
color: var(--color-text-primary, #fff);
|
||||||
}
|
}
|
||||||
#${BUTTON_ID}.recording {
|
#${BUTTON_ID}.recording {
|
||||||
background: #e53935;
|
background: #e53935;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: #e53935;
|
animation: ocvd-pulse 1.5s ease-in-out infinite;
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
|
||||||
}
|
}
|
||||||
#${BUTTON_ID}.processing {
|
#${BUTTON_ID}.processing {
|
||||||
background: var(--color-accent, #4a9eff);
|
background: var(--color-accent, #4a9eff);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: var(--color-accent, #4a9eff);
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.7;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
#${BUTTON_ID} .timer {
|
#${TIMER_ID} {
|
||||||
font-size: 11px;
|
display: none;
|
||||||
font-weight: 600;
|
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
margin-right: 2px;
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e53935;
|
||||||
|
background: rgba(229, 57, 53, 0.1);
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@keyframes pulse {
|
#${TIMER_ID}.visible {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
#${TIMER_ID}::before {
|
||||||
|
content: "";
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
background: #e53935;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: ocvd-blink 1s ease-in-out infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
@keyframes ocvd-pulse {
|
||||||
0%, 100% { box-shadow: 0 0 0 0 rgba(229, 57, 53, 0.4); }
|
0%, 100% { box-shadow: 0 0 0 0 rgba(229, 57, 53, 0.4); }
|
||||||
50% { box-shadow: 0 0 0 8px rgba(229, 57, 53, 0); }
|
50% { box-shadow: 0 0 0 6px rgba(229, 57, 53, 0); }
|
||||||
|
}
|
||||||
|
@keyframes ocvd-blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
@keyframes ocvd-spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.ocvd-spin {
|
||||||
|
animation: ocvd-spin 0.8s linear infinite;
|
||||||
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
#opencode-voice-toast {
|
#opencode-voice-toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
@ -103,35 +150,50 @@ function showToast(message: string, isError = false): void {
|
||||||
}, 4000);
|
}, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createButton(): HTMLButtonElement {
|
function createContainer(): HTMLDivElement {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.id = CONTAINER_ID;
|
||||||
|
|
||||||
|
const timer = document.createElement("span");
|
||||||
|
timer.id = TIMER_ID;
|
||||||
|
timer.textContent = "00:00";
|
||||||
|
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.id = BUTTON_ID;
|
button.id = BUTTON_ID;
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.title = "Voice Dictation (Ctrl+Space)";
|
button.title = "Voice Dictation (Ctrl+Space)";
|
||||||
button.innerHTML = "🎤";
|
button.innerHTML = ICON_MIC;
|
||||||
return button;
|
|
||||||
|
container.appendChild(timer);
|
||||||
|
container.appendChild(button);
|
||||||
|
|
||||||
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateButtonState(
|
function updateButtonState(
|
||||||
button: HTMLButtonElement,
|
button: HTMLButtonElement,
|
||||||
|
timer: HTMLElement,
|
||||||
state: DictationState,
|
state: DictationState,
|
||||||
elapsedSeconds = 0,
|
elapsedSeconds = 0,
|
||||||
): void {
|
): void {
|
||||||
button.classList.remove("recording", "processing");
|
button.classList.remove("recording", "processing");
|
||||||
|
timer.classList.remove("visible");
|
||||||
|
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "idle":
|
case "idle":
|
||||||
button.innerHTML = "🎤";
|
button.innerHTML = ICON_MIC;
|
||||||
button.title = "Voice Dictation (Ctrl+Space)";
|
button.title = "Voice Dictation (Ctrl+Space)";
|
||||||
break;
|
break;
|
||||||
case "recording":
|
case "recording":
|
||||||
button.classList.add("recording");
|
button.classList.add("recording");
|
||||||
button.innerHTML = `<span class="timer">${formatTimer(elapsedSeconds)}</span>⏹`;
|
button.innerHTML = ICON_STOP;
|
||||||
button.title = "Stop recording";
|
button.title = "Stop recording";
|
||||||
|
timer.textContent = formatTimer(elapsedSeconds);
|
||||||
|
timer.classList.add("visible");
|
||||||
break;
|
break;
|
||||||
case "processing":
|
case "processing":
|
||||||
button.classList.add("processing");
|
button.classList.add("processing");
|
||||||
button.innerHTML = "⌛";
|
button.innerHTML = ICON_SPINNER;
|
||||||
button.title = "Transcribing...";
|
button.title = "Transcribing...";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -153,12 +215,12 @@ function findComposer(): HTMLElement | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isButtonInjected(): boolean {
|
function isContainerInjected(): boolean {
|
||||||
return document.getElementById(BUTTON_ID) !== null;
|
return document.getElementById(CONTAINER_ID) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function injectButton(onToggle: () => void): void {
|
function injectButton(onToggle: () => void): void {
|
||||||
if (isButtonInjected()) {
|
if (isContainerInjected()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,19 +231,25 @@ function injectButton(onToggle: () => void): void {
|
||||||
|
|
||||||
injectStyles();
|
injectStyles();
|
||||||
|
|
||||||
const button = createButton();
|
const computedPosition = window.getComputedStyle(composer).position;
|
||||||
|
if (computedPosition === "static") {
|
||||||
|
composer.style.position = "relative";
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = createContainer();
|
||||||
|
const button = container.querySelector(`#${BUTTON_ID}`) as HTMLButtonElement;
|
||||||
button.addEventListener("click", (e) => {
|
button.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onToggle();
|
onToggle();
|
||||||
});
|
});
|
||||||
|
|
||||||
composer.appendChild(button);
|
composer.appendChild(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setupUI(
|
export function setupUI(
|
||||||
onToggle: () => void,
|
onToggle: () => void,
|
||||||
getState: () => DictationState,
|
_getState: () => DictationState,
|
||||||
): {
|
): {
|
||||||
inject: () => void;
|
inject: () => void;
|
||||||
updateState: (state: DictationState, elapsedSeconds?: number) => void;
|
updateState: (state: DictationState, elapsedSeconds?: number) => void;
|
||||||
|
|
@ -202,8 +270,9 @@ export function setupUI(
|
||||||
inject: () => injectButton(onToggle),
|
inject: () => injectButton(onToggle),
|
||||||
updateState: (state: DictationState, elapsedSeconds = 0) => {
|
updateState: (state: DictationState, elapsedSeconds = 0) => {
|
||||||
const button = document.getElementById(BUTTON_ID) as HTMLButtonElement | null;
|
const button = document.getElementById(BUTTON_ID) as HTMLButtonElement | null;
|
||||||
if (button) {
|
const timer = document.getElementById(TIMER_ID);
|
||||||
updateButtonState(button, state, elapsedSeconds);
|
if (button && timer) {
|
||||||
|
updateButtonState(button, timer, state, elapsedSeconds);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
toast: showToast,
|
toast: showToast,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue