Redesign: premium drop-to-transcribe main experience (#1396)

* Add redesign design contract and Inter font

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Redesign main experience: premium drop-to-transcribe flow

- New design language: paper/ink palette, Inter Variable, aurora gradient
  accent, pill buttons, hairline borders (light + dark)
- New unified main page replacing home + batch: aurora hero dropzone,
  drop/browse/record/link -> transcription starts immediately, file queue
  with per-file progress, live streaming transcript, editorial reading
  view with Copy/Export/Search toolbar
- Topbar with wordmark + ghost actions replaces the "..." menu
- Setup page restyled; settings modal inherits the new tokens
- Premium logo: gradient ink, refined sparkles (SVG + regenerated PNG)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Simplify idle screen, fix settings modal viewport overflow

- Whole dropzone card is now the single click/drop target (dashed border,
  upload glyph); removed eyebrow, big headline and separate Browse button
- Secondary actions (Record / From link / Folder) moved below the card
- Removed model-name item from the quiet row
- Settings modal height now capped to the viewport

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Folder auto-detect in picker/drop, source switcher, cleaner topbar

- Dropped or picked folders are detected and expanded to their media
  files automatically (recursive per advanced options); Select folder
  pill removed
- Idle screen is now a source switcher (File / Record / From link) where
  the active source replaces the drop area entirely
- Topbar: removed back arrow and New button; gear (and update indicator
  when available) only

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Mock: multi-file dialog selection to exercise batch queue

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Recents sidebar + auto-saved transcripts, settings row system, website redesign

- Transcripts auto-save as JSON in Documents/Vibe (opt-out in Privacy);
  collapsible Recents sidebar: reopen, re-transcribe, reveal, delete
- Idle screen: content-sized panels, prominent language selector,
  tighter rhythm
- Settings: production row system across all nine sections (inline
  descriptions replace (i) icons, compact controls, switch for API
  server, inline icon-button rename)
- Website: desktop token/design parity (paper/ink, Inter, aurora), new
  nav/footer/pages, new logo assets, fresh app preview screenshot
- Mock: forward invoke headers so plugin-fs writes reach real paths
- justfile: dev-web recipe (frontend-only browser mode)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Quiet dropzone (aurora on drag only), mock single/multi toggle, Save label

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix recents open, stable idle slot, smooth sidebar, mock seeds

- Mock read_text_file now returns bytes (plugin-fs decodes via
  Uint8Array), fixing silent failures opening saved transcripts
- Idle: fixed-height source slot so switching File/Record/Link never
  moves the pills; panels share the drop zone footprint
- Sidebar animates width (no content jump) and auto-closes for
  multi-file sessions; language dropdown widened
- Mock: seeded recents, single/multi file toggle pill
- Transcript toolbar: 'Save Transcript' -> 'Save'

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Bottom audio player bar, recents rename, mock copy_file

- ElevenLabs-style docked player on the transcript view: title +
  show-in-folder path, play/pause, freely draggable aurora seek bar,
  download-a-copy (plugin-fs copyFile + new capability)
- Recents: Rename action with inline edit; store rewrites the file
  keeping its timestamp
- Mock: copy_file handler so download works in browser mode

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop unknown eslint disable directive

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Smooth player seek fill via requestAnimationFrame while playing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* App shell like Claude desktop, full-width player, website polish sweep

Desktop:
- Full-height start-edge sidebar slot + full-width bottom player slot in
  Layout; topbar spans only the content column
- Sidebar closed by default; player fill monochrome (no aurora)

Website:
- Nav slimmed (features + download; language selector dev-only with
  badge), version under platform picker, inline code-drawn app mock
  replaces the screenshot, RTL highlights fixes, Ko-fi-warm Wall of
  Love with per-person pastel tints, visible dark-mode CTA band,
  single merged footer, logical-property sweep, docs rail sticky offset

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Titlebar sidebar toggle (macOS overlay), website i18n keys

- macOS: overlay titlebar; the sidebar toggle sits beside the traffic
  lights (in the sidebar strip when open, in the header when closed);
  titlebar row is a drag region
- Website: 12 new i18n keys translated across all 12 locales replace
  hardcoded English (hero eyebrow, highlights, closing band, features
  eyebrow, post-download steps); dir=ltr workarounds removed; docs rail
  sticky offset fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fixed-position sidebar toggle, wordmark in sidebar, link-panel brand row

- Toggle keeps the exact same titlebar spot whether the sidebar is open
  or closed (ChatGPT style); the Vibe wordmark shows only in the open
  sidebar
- From-link panel: 'Works with' row of bundled monochrome yt-dlp source
  marks (simple-icons) fills the empty space

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Traffic lights aligned with toggle row, Codex-style wordmark row

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Glass sidebar via vibrancy, immovable toggle, wider sidebar, lights y=28

- macOS: transparent window + NSVisualEffectView (Sidebar material); the
  sidebar tint is translucent so the blurred desktop shows through, the
  content column stays opaque
- One fixed-position toggle owned by the window — it no longer moves or
  swaps containers when the sidebar opens/closes
- Sidebar widened to 288px; traffic lights nudged to align with the
  toggle row

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Light-mode glass fix (window theme sync, stronger tint), top-5 source icons

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Player bar spans only the content column beside the full-height sidebar

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Nearly opaque light-mode sidebar tint over vibrancy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Sidebar toggle works in batch view (auto-close once, reopenable)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Thin 6px scrollbars, subtle at rest and stronger on hover

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Inline transcript editing + timestamp seek

- Click a segment to edit in place (identical typography, no layout
  jump), Enter/blur commits, Esc cancels; empty edits revert; edits
  update the queue and rewrite the saved .vibe.json
- Timestamps are quiet buttons that seek the bottom player and play
- Copy/Save/exports automatically use the edited text

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Nudge sidebar toggle up to the traffic-light line

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ChatGPT-style palette: neutral grays + blue primary accent

Light: pure white content, #f9f9f9 sidebar, #2563eb primary.
Dark: #181818 background, #212121 surfaces, #339cff primary.
Switches, active states, focus rings and primary buttons pick up the
blue; neutrals lose the warm paper cast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Project folders per transcription with bundled audio copy

- Documents/Vibe/<name>-<stamp>/ holds transcript.vibe.json + a copy of
  the source media (audio.<ext>); reopening from Recents plays the
  project's own audio even if the original moved
- Re-transcribe falls back to the project copy when the source is gone
- Rename moves the folder, delete removes it recursively, reveal opens it
- Legacy flat .vibe.json saves stay fully supported
- fs:allow-rename capability; mock: rename handler + project seeds

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Live recording level meter from real capture frames

- Rust: per-buffer peak level in the cpal callbacks (allocation-free,
  atomic max + 100ms throttle) emitted as 'record_level'; input and
  loopback streams share one meter
- UI: five live bars with fast-attack/slow-release smoothing; sustained
  silence dims to stubs so a dead mic is visibly different from speech
- Mock emits a plausible speech envelope while recording

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Joined segmented source switcher (File / Record / From link)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Icon-only segmented source switcher with tooltips

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Larger segmented switcher keys

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* FileAudio icon for the file segment (Upload stays on the dropzone)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Plain folder icon for the file segment

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Breathing room between device selects and record button

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Whole transcript line is the edit target, not just the text

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Redesigned language dropdown: pinned Auto-detect, flags, popular/all groups

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Settings moves to sidebar footer with small update badge

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Language picker is a searchable combobox

Type to filter freely; grouped browse (pinned Auto-detect, Recents,
Popular, Others) collapses to a flat result list while typing; arrow
keys + Enter select; plain scrolling with no scroll arrows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop unknown eslint directive

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Language pill morphs into the search input in place, wider by default

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Language search: no autocorrect, matches English names and codes too

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Calmer dark-mode primary (#2563eb) — no neon glare on big buttons

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Remove the topbar settings menu — settings lives in the sidebar footer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Whole language pill is clickable to open the picker

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Format icons in the Save menu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Confirm-on-close only for in-flight runs or truly unsaved results

Everything else persists instantly, so quitting is silent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split website changes into their own PR; bump version to 3.1.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Move new website logo asset to the website PR

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
thewh1teagle
2026-08-21 20:53:29 +03:00
committed by GitHub
parent 04fdc1e3c3
commit 596346fd86
52 changed files with 4687 additions and 1147 deletions
Generated
+1
View File
@@ -6854,6 +6854,7 @@ dependencies = [
"url",
"urlencoding",
"which 8.0.0",
"window-vibrancy",
"windows 0.62.2",
"winreg",
]
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 61 KiB

+32 -13
View File
@@ -1,15 +1,34 @@
<svg width="169" height="169" viewBox="0 0 169 169" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="84.5006" cy="84.5006" r="84.5006" fill="white"/>
<circle cx="84.5007" cy="84.5007" r="75.437" fill="white"/>
<path d="M169.001 84.5007C169.001 131.169 131.169 169.001 84.5006 169.001C37.8322 169.001 0 131.169 0 84.5007C0 37.8323 37.8322 3.05176e-05 84.5006 3.05176e-05C131.169 3.05176e-05 169.001 37.8323 169.001 84.5007ZM4.29053 84.5007C4.29053 128.799 40.2018 164.711 84.5006 164.711C128.799 164.711 164.711 128.799 164.711 84.5007C164.711 40.2019 128.799 4.29056 84.5006 4.29056C40.2018 4.29056 4.29053 40.2019 4.29053 84.5007Z" fill="#2088FF"/>
<path d="M119.652 67.8957C121.459 67.648 123.139 68.9128 123.232 70.7344C123.458 75.1399 122.93 79.56 121.664 83.8026C120.083 89.1021 117.386 94.0023 113.756 98.1741C110.125 102.346 105.644 105.693 100.613 107.991C95.5829 110.288 90.1191 111.484 84.5886 111.496C79.0582 111.509 73.589 110.338 68.5481 108.063C63.5072 105.789 59.0111 102.462 55.3615 98.3066C51.7119 94.1513 48.9933 89.2634 47.388 83.9711C46.1029 79.7343 45.5551 75.3167 45.7603 70.9102C45.8451 69.0882 47.5199 67.8158 49.3281 68.0553C51.1362 68.2948 52.39 69.9563 52.337 71.7794C52.2362 75.2486 52.6972 78.7188 53.7088 82.0539C55.0407 86.4449 57.2963 90.5002 60.3243 93.9478C63.3523 97.3955 67.0826 100.156 71.265 102.043C75.4474 103.93 79.9851 104.902 84.5737 104.891C89.1622 104.881 93.6954 103.889 97.8692 101.983C102.043 100.076 105.761 97.2992 108.773 93.8379C111.785 90.3766 114.023 86.311 115.335 81.914C116.331 78.5744 116.776 75.1022 116.66 71.6335C116.598 69.8106 117.845 68.1434 119.652 67.8957Z" fill="#2088FF"/>
<rect x="80.6226" y="107.005" width="7.75611" height="47.3815" rx="3.87805" fill="#2088FF"/>
<rect x="62.4029" y="14.6147" width="44.1956" height="85.2479" rx="22.0978" fill="#2088FF"/>
<path d="M100.297 56.158L96.5972 54.9653C94.2305 54.1844 92.8439 52.2692 92.1931 49.8627L90.5649 41.9311C90.5224 41.7744 90.4188 41.5645 90.1 41.5645C89.8317 41.5645 89.6777 41.7744 89.6352 41.9311L88.0069 49.8653C87.3534 52.2719 85.9695 54.187 83.6028 54.968L79.9027 56.1606C79.3794 56.3306 79.3714 57.0691 79.892 57.247L83.6188 58.53C85.9775 59.3136 87.3534 61.2261 88.0069 63.622L89.6378 71.4659C89.6803 71.6227 89.768 71.9042 90.1027 71.9042C90.4559 71.9042 90.525 71.6227 90.5675 71.4659L92.1984 63.622C92.8519 61.2234 94.2278 59.3109 96.5866 58.53L100.313 57.247C100.829 57.0664 100.821 56.328 100.297 56.158Z" fill="#FDD835"/>
<path d="M100.648 56.49C100.59 56.3439 100.475 56.2164 100.297 56.158L96.5972 54.9653C94.2305 54.1844 92.8439 52.2692 92.1931 49.8627L90.5649 41.9311C90.5409 41.8408 90.4559 41.6761 90.3577 41.6283L90.8836 49.5891C91.2741 53.2228 91.6088 54.9334 94.5094 55.4328C97.0009 55.8631 99.883 56.3572 100.648 56.49Z" fill="#FFEE58"/>
<path d="M100.656 56.9044L94.2384 58.3759C91.978 58.9497 90.7322 60.2592 90.7322 63.6858L90.1027 71.9042C90.3178 71.883 90.4931 71.7661 90.5675 71.4659L92.1984 63.622C92.8519 61.2234 94.2278 59.3109 96.5866 58.53L100.313 57.247C100.489 57.1833 100.6 57.0531 100.656 56.9044Z" fill="#F4B400"/>
<path d="M79.1058 63.0589C76.8931 62.3284 76.6913 61.505 76.3194 60.1158L75.3923 56.8645C75.3366 56.6547 74.7894 56.6547 74.7309 56.8645L74.1014 59.8688C73.7269 61.2527 72.93 62.3523 71.57 62.8013L69.4025 63.7416C69.1023 63.8398 69.097 64.2648 69.3972 64.3658L71.5806 65.1388C72.9353 65.5877 73.7269 66.6873 74.1041 68.0659L74.7336 70.94C74.792 71.1498 75.3366 71.1498 75.3923 70.94L76.1308 68.0792C76.5053 66.6927 77.0791 65.5903 78.9198 65.1388L80.9705 64.3658C81.2706 64.2622 81.268 63.8372 80.9652 63.7389L79.1058 63.0589Z" fill="#FDD835"/>
<path d="M75.5677 60.068C75.7934 62.1558 75.9077 62.7348 77.5997 63.1678L81.1245 63.8558C81.0874 63.8053 81.0342 63.7628 80.9625 63.7416L79.1031 63.0589C77.1614 62.3948 76.6249 61.5502 76.2609 59.8608C75.897 58.1714 75.5092 57.0691 75.5092 57.0691C75.3738 56.7052 75.1799 56.7237 75.1799 56.7237L75.5677 60.068Z" fill="#FFEE58"/>
<path d="M75.6314 67.3567C75.6314 65.3884 76.7072 64.4136 78.4178 64.4136L81.0794 64.3047C81.0794 64.3047 80.9253 64.5092 80.657 64.573L78.9198 65.1387C77.4456 65.7311 76.7736 66.0286 76.3167 68.0447C76.3167 68.0447 75.727 70.3901 75.6155 70.6372C75.4614 70.9825 75.2781 71.0542 75.2781 71.0542L75.6314 67.3567Z" fill="#F4B400"/>
<path d="M83.8684 47.4747C84.0172 47.4242 84.0119 47.2117 83.8605 47.1692L81.8098 46.6167C81.3795 46.4998 81.0448 46.1678 80.9226 45.7402L80.1151 42.4066C80.0753 42.2445 79.8442 42.2445 79.8044 42.4092L79.0447 45.7295C78.9278 46.1705 78.5825 46.5105 78.1416 46.6247L76.1042 47.1533C75.9528 47.1931 75.9422 47.4056 76.0909 47.4588L78.2398 48.2025C78.625 48.3353 78.9225 48.6488 79.0394 49.0392L79.807 52.1178C79.8469 52.2772 80.0753 52.2798 80.1151 52.1178L80.9094 49.0286C81.0289 48.6381 81.3291 48.3273 81.7169 48.1972L83.8684 47.4747Z" fill="#F4B400" stroke="#F4B400" stroke-width="0.894737" stroke-miterlimit="10"/>
<defs>
<radialGradient id="badge" cx="50%" cy="36%" r="80%">
<stop offset="0%" stop-color="#FFFFFF"/>
<stop offset="100%" stop-color="#F2F3F1"/>
</radialGradient>
<linearGradient id="ink" x1="62" y1="14" x2="110" y2="118" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#4C8DF6"/>
<stop offset="55%" stop-color="#6D7CEF"/>
<stop offset="100%" stop-color="#8E6CE6"/>
</linearGradient>
<linearGradient id="gold" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#FFE082"/>
<stop offset="100%" stop-color="#F4B400"/>
</linearGradient>
</defs>
<!-- badge -->
<circle cx="84.5" cy="84.5" r="84.5" fill="url(#badge)"/>
<circle cx="84.5" cy="84.5" r="81.75" stroke="url(#ink)" stroke-width="4.5"/>
<!-- microphone capsule -->
<rect x="62.4" y="14.6" width="44.2" height="85.2" rx="22.1" fill="url(#ink)"/>
<rect x="65.4" y="17.6" width="38.2" height="79.2" rx="19.1" stroke="#FFFFFF" stroke-opacity="0.22" stroke-width="1.5"/>
<!-- cradle + stem -->
<path d="M48.5 72 A36 36 0 0 0 120.5 72" stroke="url(#ink)" stroke-width="8" stroke-linecap="round"/>
<rect x="80.62" y="106" width="7.76" height="48.5" rx="3.88" fill="url(#ink)"/>
<!-- sparkles -->
<path d="M90 41.7 Q92.7 53.9 95 56.2 Q97.3 58.4 105 56.9 Q97.3 55.5 95 57.7 Q92.7 59.9 90 72.1 Q87.3 59.9 85 57.7 Q82.7 55.5 75 56.9 Q82.7 58.4 85 56.2 Q87.3 53.9 90 41.7 Z" fill="url(#gold)"/>
<path d="M75 56.9 Q76.3 62.6 77.4 63.7 Q78.5 64.7 82.1 64 Q78.5 63.3 77.4 64.4 Q76.3 65.4 75 71.1 Q73.7 65.4 72.6 64.4 Q71.5 63.3 67.9 64 Q71.5 64.7 72.6 63.7 Q73.7 62.6 75 56.9 Z" fill="url(#gold)"/>
<path d="M80 42.3 Q80.9 46.2 81.6 46.9 Q82.4 47.6 84.9 47.2 Q82.4 46.7 81.6 47.4 Q80.9 48.1 80 52.1 Q79.1 48.1 78.4 47.4 Q77.6 46.7 75.1 47.2 Q77.6 47.6 78.4 46.9 Q79.1 46.2 80 42.3 Z" fill="url(#gold)"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

+2
View File
@@ -14,6 +14,7 @@
"lint": "eslint ."
},
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@fontsource/roboto": "^5.2.9",
"@formatjs/intl-durationformat": "^0.10.1",
"@radix-ui/react-collapsible": "^1.1.12",
@@ -57,6 +58,7 @@
"react-error-boundary": "^6.1.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0",
"simple-icons": "^16.28.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
+17
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@fontsource-variable/inter':
specifier: ^5.3.0
version: 5.3.0
'@fontsource/roboto':
specifier: ^5.2.9
version: 5.2.9
@@ -137,6 +140,9 @@ importers:
react-router-dom:
specifier: ^7.13.0
version: 7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
simple-icons:
specifier: ^16.28.0
version: 16.28.0
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -582,6 +588,9 @@ packages:
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@fontsource-variable/inter@5.3.0':
resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==}
'@fontsource/roboto@5.2.9':
resolution: {integrity: sha512-ZTkyHiPk74B/aj8BZWbsxD5Yu+Lq+nR64eV4wirlrac2qXR7jYk2h6JlLYuOuoruTkGQWNw2fMuKNavw7/rg0w==}
@@ -3147,6 +3156,10 @@ packages:
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
simple-icons@16.28.0:
resolution: {integrity: sha512-sQPR5AtK/ijRjou7zw7mlLp08oB6FH7i0lOy5XJ2zp9mJs/yejgiOn7KvQoe2q4YJIx6VmgUSW5AOefebPt5kg==}
engines: {node: '>=0.12.18'}
snake-case@3.0.4:
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
@@ -3878,6 +3891,8 @@ snapshots:
'@floating-ui/utils@0.2.10': {}
'@fontsource-variable/inter@5.3.0': {}
'@fontsource/roboto@5.2.9': {}
'@formatjs/ecma402-abstract@3.1.1':
@@ -6771,6 +6786,8 @@ snapshots:
siginfo@2.0.0: {}
simple-icons@16.28.0: {}
snake-case@3.0.4:
dependencies:
dot-case: 3.0.4
+1
View File
@@ -84,6 +84,7 @@ libc-stdhandle = "=0.1.0"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6.4"
objc2-app-kit = { version = "0.3.2", features = ["NSApplication", "NSWindow"] }
window-vibrancy = "0.6"
[features]
default = []
+4
View File
@@ -21,6 +21,10 @@
"core:menu:default",
"core:tray:default",
"fs:allow-remove",
"fs:allow-copy-file",
"fs:allow-rename",
"fs:allow-mkdir",
"fs:allow-write-text-file",
"fs:allow-read-dir",
"fs:allow-read-text-file",
"fs:allow-app-write",
+93 -8
View File
@@ -7,7 +7,9 @@ use serde_json::json;
use std::fs::File;
use std::io::BufWriter;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use tauri::{AppHandle, Emitter, Listener, Manager};
use crate::error::LogError;
@@ -63,6 +65,72 @@ struct StreamHandle(Stream);
unsafe impl Send for StreamHandle {}
unsafe impl Sync for StreamHandle {}
/// At most ~10 `record_level` events per second reach the webview.
const LEVEL_EMIT_INTERVAL_MS: u64 = 100;
/// Peak is taken from every Nth sample — plenty for a meter, and keeps the callback cheap.
const LEVEL_SAMPLE_STRIDE: usize = 4;
/// Shared by every capture stream of a recording session, so the emitted value is the max
/// level across input + output (loopback) devices.
///
/// Everything here is atomic and allocation-free: the audio callback only does a few relaxed
/// loads/stores, and once per 100ms one callback also performs the (non-blocking) `emit_to`.
struct LevelMeter {
app_handle: AppHandle,
started_at: Instant,
/// Max level seen since the last emit, stored as `f32::to_bits` (monotonic for +0.0..=1.0).
peak_bits: AtomicU32,
last_emit_ms: AtomicU64,
}
impl LevelMeter {
fn new(app_handle: AppHandle) -> Self {
Self {
app_handle,
started_at: Instant::now(),
peak_bits: AtomicU32::new(0),
last_emit_ms: AtomicU64::new(0),
}
}
/// Accumulate one buffer's peak and emit if the throttle window elapsed.
fn push(&self, peak: f32) {
self.peak_bits.fetch_max(peak.to_bits(), Ordering::Relaxed);
let now_ms = self.started_at.elapsed().as_millis() as u64;
let last_ms = self.last_emit_ms.load(Ordering::Relaxed);
if now_ms.saturating_sub(last_ms) < LEVEL_EMIT_INTERVAL_MS {
return;
}
// Only the stream that wins the swap emits, so two devices can't double-emit a window.
if self
.last_emit_ms
.compare_exchange(last_ms, now_ms, Ordering::Relaxed, Ordering::Relaxed)
.is_err()
{
return;
}
let level = f32::from_bits(self.peak_bits.swap(0, Ordering::Relaxed)).clamp(0.0, 1.0);
self.app_handle.emit_to("main", "record_level", level).ok();
}
}
/// Peak magnitude of a buffer, normalized to 0..1.
fn buffer_peak<T>(input: &[T]) -> f32
where
T: Sample,
f32: FromSample<T>,
{
let mut peak = 0.0f32;
for &sample in input.iter().step_by(LEVEL_SAMPLE_STRIDE) {
let value = f32::from_sample(sample).abs();
if value > peak {
peak = value;
}
}
peak.min(1.0)
}
#[tauri::command]
/// Record audio from the given devices, store to wav, merge with ffmpeg, and return path
pub async fn start_record(
@@ -77,6 +145,9 @@ pub async fn start_record(
let mut wav_paths: Vec<(PathBuf, u32)> = Vec::new();
let mut stream_handles = Vec::new();
let mut stream_writers = Vec::new();
// One meter for the whole session: input and output streams both feed it, so the UI sees
// the max of the two under a single throttled `record_level` event.
let meter = Arc::new(LevelMeter::new(app_handle.clone()));
for device in devices {
tracing::debug!("Recording from device: {}", device.name);
@@ -102,7 +173,7 @@ pub async fn start_record(
stream_writers.push(writer.clone());
let writer_2 = writer.clone();
let stream = build_input_stream(&device, config, writer_2)?;
let stream = build_input_stream(&device, config, writer_2, meter.clone())?;
stream.play()?;
tracing::debug!("Stream started playing");
@@ -236,25 +307,39 @@ fn get_output_device_and_config(host: &cpal::Host, audio_device: &AudioDevice) -
}
}
fn build_input_stream_typed<T>(device: &Device, config: SupportedStreamConfig, writer: WavWriterHandle) -> Result<Stream>
fn build_input_stream_typed<T>(
device: &Device,
config: SupportedStreamConfig,
writer: WavWriterHandle,
meter: Arc<LevelMeter>,
) -> Result<Stream>
where
T: SizedSample + hound::Sample + FromSample<T> + Mul<Output = T> + Copy,
f32: FromSample<T>,
{
let stream = device.build_input_stream(
config.into(),
move |data: &[T], _: &_| write_input_data::<T, T>(data, &writer),
move |data: &[T], _: &_| {
meter.push(buffer_peak(data));
write_input_data::<T, T>(data, &writer)
},
|err| tracing::error!("An error occurred on stream: {}", err),
None,
)?;
Ok(stream)
}
fn build_input_stream(device: &Device, config: SupportedStreamConfig, writer: WavWriterHandle) -> Result<Stream> {
fn build_input_stream(
device: &Device,
config: SupportedStreamConfig,
writer: WavWriterHandle,
meter: Arc<LevelMeter>,
) -> Result<Stream> {
match config.sample_format() {
cpal::SampleFormat::I8 => build_input_stream_typed::<i8>(device, config, writer),
cpal::SampleFormat::I16 => build_input_stream_typed::<i16>(device, config, writer),
cpal::SampleFormat::I32 => build_input_stream_typed::<i32>(device, config, writer),
cpal::SampleFormat::F32 => build_input_stream_typed::<f32>(device, config, writer),
cpal::SampleFormat::I8 => build_input_stream_typed::<i8>(device, config, writer, meter),
cpal::SampleFormat::I16 => build_input_stream_typed::<i16>(device, config, writer, meter),
cpal::SampleFormat::I32 => build_input_stream_typed::<i32>(device, config, writer, meter),
cpal::SampleFormat::F32 => build_input_stream_typed::<f32>(device, config, writer, meter),
sample_format => bail!("Unsupported sample format '{}'", sample_format),
}
}
+26 -5
View File
@@ -107,7 +107,7 @@ pub fn setup(app: &App) -> Result<(), Box<dyn std::error::Error>> {
} else {
tracing::debug!("Non CLI mode");
// Create main window
let result = tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App("index.html".into()))
let builder = tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App("index.html".into()))
.inner_size(800.0, 700.0)
.min_inner_size(800.0, 700.0)
.center()
@@ -115,10 +115,31 @@ pub fn setup(app: &App) -> Result<(), Box<dyn std::error::Error>> {
.resizable(true)
.focused(true)
.shadow(true)
.visible(true)
.build();
if let Err(error) = result {
tracing::error!("{:?}", error);
.visible(true);
// The web content extends under the titlebar so the sidebar toggle can sit
// beside the traffic lights (ChatGPT-desktop style); the topbar is a drag region.
#[cfg(target_os = "macos")]
let builder = builder
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true)
// Centered on the 56px titlebar row so the lights align with the sidebar toggle.
.traffic_light_position(tauri::LogicalPosition::new(16.0, 28.0))
.transparent(true);
match builder.build() {
Ok(_window) => {
// Glass sidebar: the window is transparent on macOS and an NSVisualEffectView
// shows the blurred desktop wherever the web content leaves alpha (the sidebar).
#[cfg(target_os = "macos")]
{
use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial};
if let Err(error) = apply_vibrancy(&_window, NSVisualEffectMaterial::Sidebar, None, None) {
tracing::warn!("failed to apply vibrancy: {:?}", error);
}
}
}
Err(error) => {
tracing::error!("{:?}", error);
}
}
crate::dictation_indicator::initialize(app.handle());
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "./gen/schemas/desktop-schema.json",
"productName": "vibe",
"version": "3.0.23",
"version": "3.1.0",
"identifier": "github.com.thewh1teagle.vibe",
"app": {
"macOSPrivateApi": true,
+4 -5
View File
@@ -1,11 +1,10 @@
import { useEffect } from 'react'
import { getTextDirection } from '~/paraglide/runtime.js'
import { Route, Routes } from 'react-router-dom'
import { Navigate, Route, Routes } from 'react-router-dom'
import UpdateProgress from '~/components/updater-progress'
import '~/globals.css'
import SetupPage from '~/pages/setup/page'
import HomePage from '~/pages/home/page'
import BatchPage from './pages/batch/page'
import MainPage from '~/pages/main/page'
import { ErrorModalProvider } from './providers/error-modal'
import { UpdaterProvider } from './providers/updater'
import { PreferenceProvider } from './providers/preference'
@@ -48,9 +47,9 @@ function AppContent() {
<UpdateProgress />
<FilesProvider>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/" element={<MainPage />} />
<Route path="/setup" element={<SetupPage />} />
<Route path="/batch" element={<BatchPage />} />
<Route path="/batch" element={<Navigate to="/" replace />} />
</Routes>
</FilesProvider>
<Toaster position="bottom-right" />
+24 -44
View File
@@ -1,10 +1,8 @@
import { useState } from 'react'
import { m } from '~/paraglide/messages.js'
import { useLocation, useNavigate } from 'react-router-dom'
import { ReactComponent as IndicatorIcon } from '~/icons/update-indicator.svg'
import { ArrowLeft, MoreHorizontal, RefreshCcw, Settings2, Terminal } from 'lucide-react'
import { RefreshCcw, Settings } from 'lucide-react'
import { Button } from '~/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '~/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip'
interface AppMenuProps {
availableUpdate: boolean
@@ -12,49 +10,31 @@ interface AppMenuProps {
onClickSettings: (scrollTo?: string) => void
}
export default function AppMenu({ availableUpdate, updateApp, onClickSettings }: AppMenuProps) {
const navigate = useNavigate()
const location = useLocation()
const disableBack = Boolean((location.state as { disableBack?: boolean } | null)?.disableBack)
const canGoBack = location.key !== 'default' && !disableBack
const [open, setOpen] = useState(false)
const iconButtonClassName = 'h-9 w-9 rounded-full text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground [&_svg]:size-[18px]'
export default function AppMenu({ availableUpdate, updateApp, onClickSettings }: AppMenuProps) {
return (
<div dir="ltr">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="group relative h-11 w-11 rounded-xl border-border/75 bg-card/92 shadow-xs transition-all hover:-translate-y-px hover:bg-card hover:shadow-sm"
aria-label={m.moreOptions()}>
<MoreHorizontal className="h-4.5 w-4.5 text-foreground/90 transition-colors group-hover:text-foreground" strokeWidth={2.35} />
{availableUpdate && <IndicatorIcon className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 drop-shadow-sm" />}
<div dir="ltr" className="flex items-center gap-1">
{availableUpdate && (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className={`${iconButtonClassName} relative`} aria-label={m.updateVersion()} onClick={updateApp}>
<RefreshCcw strokeWidth={1.75} />
<IndicatorIcon className="absolute right-1.5 top-1.5 h-2 w-2" />
</Button>
</TooltipTrigger>
<TooltipContent>{m.updateVersion()}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className={iconButtonClassName} aria-label={m.settings()} onClick={() => onClickSettings()}>
<Settings strokeWidth={1.75} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 rounded-xl border-border/75 bg-popover/98 p-1.5 shadow-lg">
<DropdownMenuItem onClick={() => onClickSettings()} className="h-10 rounded-md px-3 text-[15px] font-medium">
<Settings2 className="h-4 w-4 text-muted-foreground" />
{m.settings()}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onClickSettings('api')} className="h-10 rounded-md px-3 text-[15px] font-medium">
<Terminal className="h-4 w-4 text-muted-foreground" />
{m.apiAndAgents()}
</DropdownMenuItem>
{canGoBack && (
<DropdownMenuItem onClick={() => navigate(-1)} className="h-10 rounded-md px-3 text-[15px] font-medium">
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
{m.back()}
</DropdownMenuItem>
)}
{availableUpdate && (
<DropdownMenuItem onClick={updateApp} className="h-10 rounded-md px-3 text-[15px] font-medium text-primary">
<RefreshCcw className="h-4 w-4 text-muted-foreground" />
{m.updateVersion()}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</TooltipTrigger>
<TooltipContent>{m.settings()}</TooltipContent>
</Tooltip>
</div>
)
}
+3 -3
View File
@@ -15,10 +15,10 @@ interface Position {
function Document({ position, path }: { position: Position; path: string }) {
return (
<div
className="absolute z-[100000] bg-accent p-4 -translate-x-[50%] -translate-y-[50%] rounded-2xl flex flex-col items-center justify-center gap-4"
className="absolute z-[100000] flex -translate-x-[50%] -translate-y-[50%] flex-col items-center justify-center gap-3 rounded-2xl border border-border bg-card p-4 text-card-foreground shadow-lg"
style={{ left: position.x, top: position.y, cursor: 'grabbing' }}>
<DocumentIcon />
<p className="text-md font-light font-mono">{path.length > 10 ? path.substring(0, 10) + '...' : path}</p>
<p className="font-mono text-[13px] text-muted-foreground">{path.length > 10 ? path.substring(0, 10) + '...' : path}</p>
</div>
)
}
@@ -77,7 +77,7 @@ export default function DropModal() {
return (
<div>
{open && platform === 'windows' && <Document path={path} position={position} />}
<div className={cn('fixed inset-0 backdrop-blur-sm bg-background/60 z-50', open ? 'block' : 'hidden')}></div>
<div className={cn('fixed inset-0 z-50 bg-background/70 backdrop-blur-sm', open ? 'block' : 'hidden')}></div>
</div>
)
}
+376 -62
View File
@@ -1,86 +1,400 @@
import { subDays, isAfter } from 'date-fns'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, Globe } from 'lucide-react'
import { m } from '~/paraglide/messages.js'
import { getI18nLanguageName, getLocalizedLanguageName } from '~/lib/i18n'
import { cn } from '~/lib/style'
import { usePreferenceProvider } from '~/providers/preference'
import { Label } from '~/components/ui/label'
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '~/components/ui/select'
import { Popover, PopoverAnchor, PopoverContent } from '~/components/ui/popover'
export default function LanguageInput() {
interface LanguageInputProps {
/**
* `prominent` renders a compact, self-labelled control (globe + current language) meant to stand
* on its own in a toolbar row. `default` keeps the classic stacked label + full-width select.
*/
variant?: 'default' | 'prominent'
}
/** The `auto` message ships a sparkle; it reads gimmicky inside a permanent toolbar control. */
function stripDecoration(label: string) {
return label.replace(/\s*[\u{1F300}-\u{1FAFF}]\s*$/u, '').trim()
}
// Whisper reports languages either as ISO codes or as lowercase English names — key both.
const FLAGS: Record<string, string> = {
en: '🇺🇸',
english: '🇺🇸',
es: '🇪🇸',
spanish: '🇪🇸',
hi: '🇮🇳',
hindi: '🇮🇳',
fr: '🇫🇷',
french: '🇫🇷',
de: '🇩🇪',
german: '🇩🇪',
it: '🇮🇹',
italian: '🇮🇹',
pt: '🇧🇷',
portuguese: '🇧🇷',
ru: '🇷🇺',
russian: '🇷🇺',
ja: '🇯🇵',
japanese: '🇯🇵',
ko: '🇰🇷',
korean: '🇰🇷',
zh: '🇨🇳',
chinese: '🇨🇳',
ar: '🇸🇦',
arabic: '🇸🇦',
he: '🇮🇱',
iw: '🇮🇱',
hebrew: '🇮🇱',
tr: '🇹🇷',
turkish: '🇹🇷',
nl: '🇳🇱',
dutch: '🇳🇱',
pl: '🇵🇱',
polish: '🇵🇱',
uk: '🇺🇦',
ukrainian: '🇺🇦',
sv: '🇸🇪',
swedish: '🇸🇪',
no: '🇳🇴',
nb: '🇳🇴',
norwegian: '🇳🇴',
da: '🇩🇰',
danish: '🇩🇰',
fi: '🇫🇮',
finnish: '🇫🇮',
cs: '🇨🇿',
czech: '🇨🇿',
sk: '🇸🇰',
slovak: '🇸🇰',
hu: '🇭🇺',
hungarian: '🇭🇺',
ro: '🇷🇴',
romanian: '🇷🇴',
bg: '🇧🇬',
bulgarian: '🇧🇬',
hr: '🇭🇷',
croatian: '🇭🇷',
sr: '🇷🇸',
serbian: '🇷🇸',
sl: '🇸🇮',
slovenian: '🇸🇮',
el: '🇬🇷',
greek: '🇬🇷',
id: '🇮🇩',
indonesian: '🇮🇩',
ms: '🇲🇾',
malay: '🇲🇾',
vi: '🇻🇳',
vietnamese: '🇻🇳',
th: '🇹🇭',
thai: '🇹🇭',
fa: '🇮🇷',
persian: '🇮🇷',
ur: '🇵🇰',
urdu: '🇵🇰',
bn: '🇧🇩',
bengali: '🇧🇩',
ta: '🇮🇳',
tamil: '🇮🇳',
te: '🇮🇳',
telugu: '🇮🇳',
mr: '🇮🇳',
marathi: '🇮🇳',
kn: '🇮🇳',
kannada: '🇮🇳',
ml: '🇮🇳',
malayalam: '🇮🇳',
pa: '🇮🇳',
punjabi: '🇮🇳',
gu: '🇮🇳',
gujarati: '🇮🇳',
az: '🇦🇿',
azerbaijani: '🇦🇿',
kk: '🇰🇿',
kazakh: '🇰🇿',
uz: '🇺🇿',
uzbek: '🇺🇿',
ka: '🇬🇪',
georgian: '🇬🇪',
hy: '🇦🇲',
armenian: '🇦🇲',
et: '🇪🇪',
estonian: '🇪🇪',
lv: '🇱🇻',
latvian: '🇱🇻',
lt: '🇱🇹',
lithuanian: '🇱🇹',
be: '🇧🇾',
belarusian: '🇧🇾',
mk: '🇲🇰',
macedonian: '🇲🇰',
sq: '🇦🇱',
albanian: '🇦🇱',
bs: '🇧🇦',
bosnian: '🇧🇦',
is: '🇮🇸',
icelandic: '🇮🇸',
mt: '🇲🇹',
maltese: '🇲🇹',
sw: '🇰🇪',
swahili: '🇰🇪',
af: '🇿🇦',
afrikaans: '🇿🇦',
am: '🇪🇹',
amharic: '🇪🇹',
ne: '🇳🇵',
nepali: '🇳🇵',
si: '🇱🇰',
sinhala: '🇱🇰',
my: '🇲🇲',
myanmar: '🇲🇲',
burmese: '🇲🇲',
km: '🇰🇭',
khmer: '🇰🇭',
lo: '🇱🇦',
lao: '🇱🇦',
mn: '🇲🇳',
mongolian: '🇲🇳',
tl: '🇵🇭',
tagalog: '🇵🇭',
filipino: '🇵🇭',
ca: '🇦🇩',
catalan: '🇦🇩',
gl: '🇪🇸',
galician: '🇪🇸',
eu: '🇪🇸',
basque: '🇪🇸',
cy: '🇬🇧',
welsh: '🇬🇧',
ga: '🇮🇪',
irish: '🇮🇪',
yi: '🇮🇱',
yiddish: '🇮🇱',
}
function flagFor(code: string, name: string): string | null {
return FLAGS[code.toLowerCase()] ?? FLAGS[name.toLowerCase()] ?? null
}
function FlagSlot({ code, name }: { code: string; name: string }) {
const flag = flagFor(code, name)
return (
<span aria-hidden className="inline-flex w-6 shrink-0 justify-center text-[15px] leading-none">
{flag ?? <Globe className="h-3.5 w-3.5 text-muted-foreground" />}
</span>
)
}
export default function LanguageInput({ variant = 'default' }: LanguageInputProps) {
const preference = usePreferenceProvider()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [highlighted, setHighlighted] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const capabilities = preference.modelMetadata?.capabilities
const displayNames = new Intl.DisplayNames([preference.displayLanguage], { type: 'language' })
const entries = (capabilities?.languages ?? []).map((code) => ({ label: displayNames.of(code) ?? code, name: code, code }))
if (capabilities?.language_detection) entries.push({ label: getLocalizedLanguageName('auto'), name: 'auto', code: 'auto' })
const hasAutoDetect = capabilities?.language_detection ?? false
const selectedLang = preference.modelOptions.lang
entries.sort((a, b) => a.label.localeCompare(b.label))
const entries = useMemo(() => {
const displayNames = new Intl.DisplayNames([preference.displayLanguage], { type: 'language' })
const englishNames = new Intl.DisplayNames(['en'], { type: 'language' })
const of = (names: Intl.DisplayNames, code: string) => {
try {
return names.of(code) ?? code
} catch {
return code
}
}
const list = (capabilities?.languages ?? []).map((code) => ({
label: of(displayNames, code),
english: of(englishNames, code),
name: code,
code,
}))
list.sort((a, b) => a.label.localeCompare(b.label))
return list
}, [capabilities, preference.displayLanguage])
function onValueChange(value: string) {
preference.setModelOptions({ ...preference.modelOptions, lang: value })
if (value !== 'auto') {
function select(code: string) {
preference.setModelOptions({ ...preference.modelOptions, lang: code })
if (code !== 'auto') {
const now = Date.now()
const recent = [{ code: value, ts: now }, ...preference.recentLanguages.filter((r) => r.code !== value)].slice(0, 5)
const recent = [{ code, ts: now }, ...preference.recentLanguages.filter((r) => r.code !== code)].slice(0, 5)
preference.setRecentLanguages(recent)
}
setOpen(false)
}
// Grouped view for browsing; a flat filtered list the moment the user types.
const cutoff = subDays(new Date(), 7)
const popularLanguages = new Set(['english', 'en', 'hindi', 'hi', 'spanish', 'es', getI18nLanguageName()])
const recentCodes = new Set(preference.recentLanguages.filter((r) => isAfter(r.ts, cutoff)).map((r) => r.code))
const needle = query.trim().toLowerCase()
type Entry = { label: string; english: string; name: string; code: string }
const autoEntry: Entry | null = hasAutoDetect
? { label: stripDecoration(getLocalizedLanguageName('auto')), english: 'auto detect', name: 'auto', code: 'auto' }
: null
const groups = useMemo(() => {
if (needle) {
const matches = entries.filter(
(entry) =>
entry.label.toLowerCase().includes(needle) || entry.english.toLowerCase().includes(needle) || entry.code.toLowerCase().includes(needle),
)
if (autoEntry && autoEntry.label.toLowerCase().includes(needle)) matches.unshift(autoEntry)
return [{ label: null as string | null, items: matches }]
}
const recent: Entry[] = []
const popular: Entry[] = []
const others: Entry[] = []
for (const entry of entries) {
if (recentCodes.has(entry.code)) recent.push(entry)
else if (popularLanguages.has(entry.name.toLowerCase())) popular.push(entry)
else others.push(entry)
}
const recentOrder = preference.recentLanguages.map((r) => r.code)
recent.sort((a, b) => recentOrder.indexOf(a.code) - recentOrder.indexOf(b.code))
const result: { label: string | null; items: Entry[] }[] = []
if (autoEntry) result.push({ label: null, items: [autoEntry] })
if (recent.length) result.push({ label: m.recentlyUsed(), items: recent })
if (popular.length) result.push({ label: m.popular(), items: popular })
if (others.length) result.push({ label: m.others(), items: others })
return result
}, [entries, needle, preference.recentLanguages])
const flat = useMemo(() => groups.flatMap((group) => group.items), [groups])
useEffect(() => {
setHighlighted(0)
}, [needle, open])
useEffect(() => {
if (!open) setQuery('')
}, [open])
function onInputKeyDown(event: React.KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault()
setHighlighted((index) => Math.min(index + 1, flat.length - 1))
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setHighlighted((index) => Math.max(index - 1, 0))
} else if (event.key === 'Enter') {
event.preventDefault()
const pick = flat[highlighted] ?? flat[0]
if (pick) select(pick.code)
} else if (event.key === 'Escape') {
setOpen(false)
}
}
const cutoff = subDays(new Date(), 7)
const popularLanguages = [getI18nLanguageName(), 'auto', 'english']
const recentCodes = new Set(preference.recentLanguages.filter((r) => isAfter(r.ts, cutoff)).map((r) => r.code))
const recentEntries: { label: string; code: string }[] = []
const popularEntries: { label: string; code: string }[] = []
const otherEntries: { label: string; code: string }[] = []
// Keep the highlighted row in view while arrowing through the list.
useEffect(() => {
listRef.current?.querySelector(`[data-index="${highlighted}"]`)?.scrollIntoView({ block: 'nearest' })
}, [highlighted])
entries.forEach(({ label, name, code }) => {
if (recentCodes.has(code)) {
recentEntries.push({ label, code })
} else if (popularLanguages.includes(name)) {
popularEntries.push({ label, code })
} else {
otherEntries.push({ label, code })
}
})
const prominent = variant === 'prominent'
const selected = entries.find(({ code }) => code === selectedLang)
const triggerLabel = selectedLang === 'auto' && autoEntry ? autoEntry.label : selected ? stripDecoration(selected.label) : m.selectLanguage()
// Sort recent entries by recency order
const recentOrder = preference.recentLanguages.map((r) => r.code)
recentEntries.sort((a, b) => recentOrder.indexOf(a.code) - recentOrder.indexOf(b.code))
let runningIndex = -1
return (
<div className="space-y-2 w-full">
<Label>{m.language()}</Label>
<Select value={preference.modelOptions.lang} onValueChange={onValueChange}>
<SelectTrigger>
<SelectValue placeholder={m.language()} />
</SelectTrigger>
<SelectContent>
{recentEntries.length > 0 && (
<SelectGroup>
<SelectLabel>{m.recentlyUsed()}</SelectLabel>
{recentEntries.map(({ label, code }) => (
<SelectItem key={code} value={code}>
{label}
</SelectItem>
))}
</SelectGroup>
)}
<SelectGroup>
<SelectLabel>{m.popular()}</SelectLabel>
{popularEntries.map(({ label, code }) => (
<SelectItem key={code} value={code}>
{label}
</SelectItem>
<div className={cn('w-full', prominent ? '' : 'space-y-2')}>
{prominent ? null : <Label>{m.language()}</Label>}
<Popover open={open} onOpenChange={setOpen}>
{/* The pill itself becomes the input while open — same container, same shape. */}
<PopoverAnchor asChild>
<div
onClick={() => {
if (!open) setOpen(true)
}}
className={cn(
'flex items-center gap-2 border text-start transition-colors duration-150',
prominent ? 'h-9 w-[200px] rounded-full bg-card px-3 text-[13px] font-medium' : 'h-9 w-full rounded-lg bg-transparent px-3 text-sm',
open ? 'border-ring/60' : 'cursor-pointer border-border hover:bg-muted/60',
)}>
<Globe className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{open ? (
<input
ref={inputRef}
autoFocus
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={onInputKeyDown}
placeholder={triggerLabel}
aria-label={m.language()}
className="w-full min-w-0 bg-transparent text-inherit outline-none placeholder:text-muted-foreground"
/>
) : (
<button
type="button"
aria-label={m.language()}
aria-expanded={open}
className="min-w-0 flex-1 cursor-pointer truncate text-start text-inherit outline-none">
{triggerLabel}
</button>
)}
</div>
</PopoverAnchor>
<PopoverContent
align="start"
onOpenAutoFocus={(event) => event.preventDefault()}
className={cn('rounded-2xl p-0', prominent ? 'w-[240px]' : 'w-[var(--radix-popper-anchor-width)] min-w-[240px]')}>
<div ref={listRef} className="max-h-72 overflow-y-auto p-1.5">
{flat.length === 0 && <p className="px-3 py-6 text-center text-[13px] text-muted-foreground">No matches</p>}
{groups.map((group, groupIndex) => (
<div key={group.label ?? `group-${groupIndex}`}>
{group.label && (
<p className="px-2.5 pt-2.5 pb-1 text-[11px] font-medium tracking-[0.08em] text-muted-foreground uppercase">
{group.label}
</p>
)}
{group.items.map((entry) => {
runningIndex += 1
const index = runningIndex
const active = entry.code === selectedLang
return (
<button
key={entry.code}
type="button"
data-index={index}
onClick={() => select(entry.code)}
onMouseMove={() => setHighlighted(index)}
className={cn(
'flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-start text-sm text-foreground',
index === highlighted && 'bg-muted/70',
entry.code === 'auto' && 'font-medium',
)}>
{entry.code === 'auto' ? (
<span aria-hidden className="inline-flex w-6 shrink-0 justify-center">
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
</span>
) : (
<FlagSlot code={entry.code} name={entry.name} />
)}
<span className="min-w-0 flex-1 truncate">{entry.label}</span>
{active && <Check className="h-4 w-4 shrink-0 text-foreground" />}
</button>
)
})}
</div>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>{m.others()}</SelectLabel>
{otherEntries.map(({ label, code }) => (
<SelectItem key={code} value={code}>
{label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</PopoverContent>
</Popover>
</div>
)
}
+84 -18
View File
@@ -1,44 +1,110 @@
import { ReactNode, useContext, useEffect, useState } from 'react'
import { m } from '~/paraglide/messages.js'
import { UpdaterContext } from '~/providers/updater'
import AppMenu from './app-menu'
import DropModal from './drop-modal'
import { PanelLeft } from 'lucide-react'
import { platform } from '@tauri-apps/plugin-os'
import { ReactNode, useEffect, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { Button } from '~/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip'
import SettingsModal from './settings-modal'
import PageTransition from './page-transition'
import ModelDownloadPrompt from './model-download-prompt'
export default function Layout({ children }: { children: ReactNode }) {
/**
* The topbar lives here but the Recents sidebar belongs to the main page, so the toggle travels as
* a window event and the collapsed state is read from localStorage by whoever renders the sidebar.
*/
export const TOGGLE_SIDEBAR_EVENT = 'vibe:toggle-sidebar'
export const SIDEBAR_STORAGE_KEY = 'vibe_sidebar_collapsed'
/** Start inset that clears the macOS traffic lights; identical for header and sidebar so the toggle never moves. */
export function titlebarInset(): number {
return isMacOverlayTitlebar() ? 76 : 10
}
/** True in the real macOS app, where the titlebar is an overlay and traffic lights sit over our UI. */
export function isMacOverlayTitlebar(): boolean {
try {
return '__TAURI_INTERNALS__' in window && !('__vibeMockTauriRuntime__' in globalThis) && platform() === 'macos'
} catch {
return false
}
}
export function SidebarToggleButton() {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label="Toggle recents"
className="h-9 w-9 rounded-full text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground [&_svg]:size-[18px]"
onClick={() => window.dispatchEvent(new CustomEvent(TOGGLE_SIDEBAR_EVENT))}>
<PanelLeft strokeWidth={1.75} />
</Button>
</TooltipTrigger>
<TooltipContent>Toggle recents</TooltipContent>
</Tooltip>
)
}
interface LayoutProps {
children: ReactNode
/** Full-height panel docked at the window's start edge (Claude-desktop style). */
sidebar?: ReactNode
/** Full-width bar docked at the very bottom of the window (player). */
bottomBar?: ReactNode
}
export default function Layout({ children, sidebar, bottomBar }: LayoutProps) {
const [settingsVisible, setSettingsVisible] = useState(false)
const [settingsScrollTo, setSettingsScrollTo] = useState<string | undefined>(undefined)
const { updateApp, availableUpdate } = useContext(UpdaterContext)
// Only the main page renders a Recents sidebar, so only it gets the toggle.
const showSidebarToggle = useLocation().pathname === '/'
function openSettings(scrollTo?: string) {
setSettingsScrollTo(scrollTo)
setSettingsVisible(true)
}
useEffect(() => {
// Vibrancy: the macOS window is transparent; html/body must not paint over it.
if (isMacOverlayTitlebar()) document.documentElement.classList.add('mac-vibrancy')
}, [])
useEffect(() => {
function onOpenSettings(event: Event) {
const scrollTo = (event as CustomEvent<{ scrollTo?: string }>).detail?.scrollTo
openSettings(scrollTo)
}
window.addEventListener('vibe:open-settings', onOpenSettings)
if (window.location.hash === '#settings') {
openSettings()
}
return () => window.removeEventListener('vibe:open-settings', onOpenSettings)
}, [])
return (
<div className="min-h-screen">
{settingsVisible && <SettingsModal visible={settingsVisible} setVisible={setSettingsVisible} scrollTo={settingsScrollTo} />}
<DropModal />
<ModelDownloadPrompt />
<div className="app-shell">
<div className="stagger-in mb-6 flex items-center justify-between gap-4 pb-1">
<h1 className="app-title">{m.appTitle()}</h1>
<AppMenu onClickSettings={openSettings} availableUpdate={availableUpdate} updateApp={updateApp} />
<div className="flex h-screen min-h-0 flex-col overflow-hidden">
{/* Single toggle, fixed to the window so it never moves when the sidebar opens or closes. */}
{showSidebarToggle && (
<div className="fixed z-50" style={{ left: titlebarInset(), top: 7 }}>
<SidebarToggleButton />
</div>
)}
{settingsVisible && <SettingsModal visible={settingsVisible} setVisible={setSettingsVisible} scrollTo={settingsScrollTo} />}
<ModelDownloadPrompt />
<div className="flex min-h-0 flex-1">
{sidebar}
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
<header data-tauri-drag-region className="flex h-14 shrink-0 items-center justify-end gap-4 pe-4"></header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<PageTransition>
<div className="stagger-in h-full">{children}</div>
</PageTransition>
</div>
{/* The player fills only the content column; the sidebar stays full height beside it. */}
{bottomBar}
</div>
<PageTransition>
<div className="stagger-in [animation-delay:120ms]">{children}</div>
</PageTransition>
</div>
</div>
)
+1 -2
View File
@@ -9,8 +9,7 @@ const buttonVariants = cva(
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-sm hover:bg-primary/94 dark:bg-[#2168b8] dark:text-white dark:shadow-none dark:hover:bg-[#2168b8] dark:active:bg-[#1d5fa9]',
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border border-input/75 bg-card text-foreground shadow-xs hover:bg-accent/65',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/86',
+3 -1
View File
@@ -27,4 +27,6 @@ const PopoverContent = React.forwardRef<React.ElementRef<typeof PopoverPrimitive
)
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }
const PopoverAnchor = PopoverPrimitive.Anchor
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+1 -1
View File
@@ -15,7 +15,7 @@ export default function UpdateProgress() {
<DialogDescription>{m.updatingModalBody({ version: String(manifest?.version ?? '') })}</DialogDescription>
</DialogHeader>
<div className="flex justify-center">
<Progress value={progress ?? 0} className="w-56" />
<Progress value={progress ?? 0} className="progress-aurora h-1.5 w-56 bg-muted" />
</div>
</DialogContent>
</Dialog>
+219 -137
View File
@@ -1,3 +1,4 @@
@import '@fontsource-variable/inter';
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@@ -32,9 +33,9 @@ html.dictation-indicator-window #root {
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--font-sans: 'SF Pro Text', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif;
--font-serif: 'SF Pro Text', -apple-system, 'Helvetica Neue', Arial, sans-serif;
--radius: 0.9rem;
--font-sans: 'Inter Variable', 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', Arial, sans-serif;
--font-serif: 'Inter Variable', 'Inter', -apple-system, 'Helvetica Neue', Arial, sans-serif;
--radius: 1rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
@@ -73,92 +74,111 @@ html.dictation-indicator-window #root {
}
:root {
--radius: 0.75rem;
--background: hsl(210 34% 96%);
--foreground: hsl(215 22% 17%);
--card: hsl(0 0% 100% / 0.95);
--card-foreground: hsl(215 22% 17%);
--popover: hsl(0 0% 100% / 0.98);
--popover-foreground: hsl(215 22% 17%);
--primary: hsl(205 74% 43%);
--primary-foreground: hsl(0 0% 100%);
--secondary: hsl(206 28% 92%);
--secondary-foreground: hsl(214 20% 24%);
--muted: hsl(207 24% 93%);
--muted-foreground: hsl(214 12% 44%);
--accent: hsl(206 44% 90%);
--accent-foreground: hsl(214 20% 24%);
--destructive: hsl(0 78% 57%);
--destructive-foreground: hsl(210 40% 98%);
--success: hsl(154 64% 38%);
--success-foreground: hsl(154 100% 96%);
--border: hsl(208 22% 83%);
--input: hsl(208 22% 83%);
--ring: hsl(205 72% 45%);
--chart-1: hsl(205 74% 43%);
--chart-2: hsl(197 60% 42%);
--chart-3: hsl(28 70% 52%);
--chart-4: hsl(45 80% 52%);
--chart-5: hsl(265 45% 55%);
--sidebar: hsl(0 0% 100% / 0.9);
--sidebar-foreground: hsl(215 20% 18%);
--sidebar-primary: hsl(205 74% 43%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(206 30% 92%);
--sidebar-accent-foreground: hsl(214 20% 24%);
--sidebar-border: hsl(208 20% 84%);
--sidebar-ring: hsl(205 72% 45%);
--shadow-2xs: 0 1px 1px hsl(211 60% 11% / 0.06);
--shadow-xs: 0 1px 2px hsl(211 60% 11% / 0.08);
--shadow-sm: 0 4px 12px hsl(220 18% 16% / 0.05);
--shadow: 0 8px 20px hsl(220 18% 16% / 0.07);
--shadow-md: 0 12px 28px hsl(220 18% 16% / 0.09);
--shadow-lg: 0 16px 36px hsl(220 10% 20% / 0.12);
--shadow-xl: 0 24px 52px hsl(220 10% 20% / 0.16);
--shadow-2xl: 0 32px 68px hsl(220 10% 20% / 0.2);
--tracking-normal: 0em;
--radius: 1rem;
/* ChatGPT-style neutrals — light (default) */
--background: #ffffff;
--foreground: #1a1c1f;
--card: #ffffff;
--card-foreground: #1a1c1f;
--popover: #ffffff;
--popover-foreground: #1a1c1f;
--primary: #2563eb;
--primary-foreground: #ffffff;
--secondary: #f4f4f4;
--secondary-foreground: #1a1c1f;
--muted: #f4f4f4;
--muted-foreground: #6e6e6e;
--accent: #f4f4f4;
--accent-foreground: #1a1c1f;
--destructive: #b4453c;
--destructive-foreground: #fdf6f5;
--success: #3f7d5f;
--success-foreground: #f3faf6;
--border: #e6e6e6;
--input: #e6e6e6;
--ring: rgb(37 99 235 / 0.35);
/* Aurora hues — the only chroma in the system */
--aurora-1: #5b8def;
--aurora-2: #b98de3;
--aurora-3: #e8a87c;
--aurora-4: #7cc5a0;
--chart-1: var(--aurora-1);
--chart-2: var(--aurora-2);
--chart-3: var(--aurora-3);
--chart-4: var(--aurora-4);
--chart-5: #6f6f6a;
--sidebar: #f9f9f9;
--sidebar-foreground: #1a1c1f;
--sidebar-primary: #2563eb;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f1f1ee;
--sidebar-accent-foreground: #111110;
--sidebar-border: #e7e7e2;
--sidebar-ring: rgb(17 17 16 / 0.2);
--shadow-2xs: 0 1px 1px rgb(17 17 16 / 0.04);
--shadow-xs: 0 1px 2px rgb(17 17 16 / 0.05);
--shadow-sm: 0 2px 8px rgb(17 17 16 / 0.04);
--shadow: 0 6px 18px rgb(17 17 16 / 0.05);
--shadow-md: 0 10px 26px rgb(17 17 16 / 0.06);
--shadow-lg: 0 16px 36px rgb(17 17 16 / 0.08);
--shadow-xl: 0 24px 56px rgb(17 17 16 / 0.1);
--shadow-2xl: 0 32px 72px rgb(17 17 16 / 0.12);
--tracking-normal: -0.01em;
}
.dark {
--background: hsl(220 10% 10%);
--foreground: hsl(210 18% 93%);
--card: hsl(220 9% 16%);
--card-foreground: hsl(210 18% 93%);
--popover: hsl(220 9% 17%);
--popover-foreground: hsl(210 18% 93%);
--primary: hsl(212 100% 56%);
--primary-foreground: hsl(0 0% 100%);
--secondary: hsl(220 8% 22%);
--secondary-foreground: hsl(210 14% 90%);
--muted: hsl(220 8% 20%);
--muted-foreground: hsl(214 10% 74%);
--accent: hsl(220 8% 24%);
--accent-foreground: hsl(210 14% 90%);
--destructive: hsl(0 70% 48%);
--destructive-foreground: hsl(210 28% 96%);
--success: hsl(154 64% 45%);
--success-foreground: hsl(153 58% 10%);
--border: hsl(220 8% 34%);
--input: hsl(220 8% 34%);
--ring: hsl(212 100% 60%);
--chart-1: hsl(212 100% 56%);
--chart-2: hsl(197 38% 60%);
--chart-3: hsl(30 76% 58%);
--chart-4: hsl(50 74% 58%);
--chart-5: hsl(270 58% 62%);
--sidebar: hsl(220 9% 16%);
--sidebar-foreground: hsl(210 18% 93%);
--sidebar-primary: hsl(212 100% 56%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(220 8% 24%);
--sidebar-accent-foreground: hsl(210 14% 90%);
--sidebar-border: hsl(220 8% 34%);
--sidebar-ring: hsl(212 100% 60%);
--shadow-sm: 0 8px 20px hsl(225 28% 4% / 0.28);
--shadow: 0 12px 28px hsl(225 28% 4% / 0.35);
--shadow-md: 0 18px 38px hsl(225 28% 4% / 0.42);
--shadow-lg: 0 26px 52px hsl(225 28% 4% / 0.5);
--shadow-xl: 0 34px 70px hsl(225 28% 4% / 0.58);
--background: #181818;
--foreground: #ececec;
--card: #212121;
--card-foreground: #ececec;
--popover: #2a2a2a;
--popover-foreground: #ececec;
--primary: #2563eb;
--primary-foreground: #ffffff;
--secondary: #242424;
--secondary-foreground: #ececec;
--muted: #242424;
--muted-foreground: #9a9a9a;
--accent: #2a2a2a;
--accent-foreground: #ececec;
--destructive: #c4655c;
--destructive-foreground: #1a0f0e;
--success: #6aab8b;
--success-foreground: #0d1512;
--border: #303030;
--input: #303030;
--ring: rgb(51 156 255 / 0.4);
--aurora-1: #5b8def;
--aurora-2: #b98de3;
--aurora-3: #e8a87c;
--aurora-4: #7cc5a0;
--chart-5: #9a9a94;
--sidebar: #212121;
--sidebar-foreground: #ececec;
--sidebar-primary: #2563eb;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #2a2a2a;
--sidebar-accent-foreground: #ececec;
--sidebar-border: #303030;
--sidebar-ring: rgb(51 156 255 / 0.4);
--shadow-2xs: 0 1px 1px rgb(0 0 0 / 0.3);
--shadow-xs: 0 1px 2px rgb(0 0 0 / 0.35);
--shadow-sm: 0 2px 10px rgb(0 0 0 / 0.4);
--shadow: 0 8px 22px rgb(0 0 0 / 0.45);
--shadow-md: 0 14px 32px rgb(0 0 0 / 0.5);
--shadow-lg: 0 22px 46px rgb(0 0 0 / 0.55);
--shadow-xl: 0 30px 64px rgb(0 0 0 / 0.6);
--shadow-2xl: 0 40px 80px rgb(0 0 0 / 0.65);
}
@layer base {
@@ -170,30 +190,37 @@ html.dictation-indicator-window #root {
html {
font-family:
'SF Pro Text',
'SF Pro Display',
'Inter Variable',
'Inter',
-apple-system,
BlinkMacSystemFont,
'SF Pro Text',
'Helvetica Neue',
Arial,
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
@apply bg-background text-foreground;
font-size: 14px;
line-height: 1.55;
letter-spacing: -0.01em;
font-feature-settings:
'rlig' 1,
'calt' 1;
background-image:
radial-gradient(1200px 700px at 15% -10%, hsl(205 90% 88% / 0.55) 0%, transparent 60%),
radial-gradient(900px 600px at 100% 0%, hsl(280 70% 88% / 0.35) 0%, transparent 55%),
linear-gradient(180deg, hsl(210 32% 97%) 0%, hsl(210 28% 95%) 100%);
background-repeat: no-repeat;
background-attachment: fixed;
background-size: 100% 100%;
'calt' 1,
'cv05' 1;
background-image: none;
overflow-x: hidden;
}
h1,
h2,
h3 {
letter-spacing: -0.03em;
}
html,
body {
scrollbar-width: none;
@@ -206,27 +233,24 @@ html.dictation-indicator-window #root {
}
.dark body {
background-image:
radial-gradient(1200px 700px at 15% -10%, hsl(212 100% 30% / 0.16) 0%, transparent 60%),
radial-gradient(900px 600px at 100% 0%, hsl(280 60% 30% / 0.12) 0%, transparent 55%),
linear-gradient(180deg, hsl(220 10% 11%) 0%, hsl(220 10% 9%) 100%);
background-image: none;
}
* {
border-color: hsl(var(--border));
border-color: var(--border);
}
::selection {
background: hsl(205 74% 43% / 0.22);
background: rgb(17 17 16 / 0.14);
}
.dark ::selection {
background: hsl(212 100% 56% / 0.26);
background: rgb(244 244 241 / 0.2);
}
::-webkit-scrollbar {
height: 10px;
width: 10px;
height: 6px;
width: 6px;
}
::-webkit-scrollbar-track {
@@ -234,17 +258,25 @@ html.dictation-indicator-window #root {
}
::-webkit-scrollbar-thumb {
background: hsl(210 13% 52% / 0.35);
background: rgb(17 17 16 / 0.14);
border-radius: 999px;
}
:hover::-webkit-scrollbar-thumb {
background: rgb(17 17 16 / 0.22);
}
.dark ::-webkit-scrollbar-thumb {
background: hsl(214 6% 56% / 0.42);
background: rgb(244 244 241 / 0.14);
}
.dark :hover::-webkit-scrollbar-thumb {
background: rgb(244 244 241 / 0.22);
}
.transcript-editor {
scrollbar-width: auto;
scrollbar-color: hsl(208 36% 52% / 0.78) hsl(210 18% 90% / 0.85);
scrollbar-color: rgb(17 17 16 / 0.28) rgb(17 17 16 / 0.05);
scrollbar-gutter: stable;
}
@@ -255,92 +287,135 @@ html.dictation-indicator-window #root {
}
.transcript-editor::-webkit-scrollbar-thumb {
background: hsl(208 36% 52% / 0.8) !important;
border: 3px solid hsl(210 18% 90% / 0.85) !important;
background: rgb(17 17 16 / 0.28) !important;
border: 3px solid transparent !important;
background-clip: padding-box !important;
border-radius: 999px !important;
min-height: 96px !important;
}
.transcript-editor::-webkit-scrollbar-thumb:hover {
background: hsl(208 36% 46% / 0.9) !important;
background: rgb(17 17 16 / 0.4) !important;
background-clip: padding-box !important;
}
.transcript-editor::-webkit-scrollbar-track {
background: hsl(210 18% 90% / 0.85) !important;
border-left: 1px solid hsl(208 22% 83% / 0.75) !important;
background: transparent !important;
border-left: 1px solid var(--border) !important;
}
.dark .transcript-editor::-webkit-scrollbar-thumb {
background: hsl(214 10% 62% / 0.82) !important;
border: 3px solid hsl(220 8% 18% / 0.96) !important;
background: rgb(244 244 241 / 0.24) !important;
border: 3px solid transparent !important;
background-clip: padding-box !important;
}
.dark .transcript-editor::-webkit-scrollbar-thumb:hover {
background: hsl(214 12% 70% / 0.9) !important;
background: rgb(244 244 241 / 0.36) !important;
background-clip: padding-box !important;
}
.dark .transcript-editor::-webkit-scrollbar-track {
background: hsl(220 8% 18% / 0.96) !important;
border-left: 1px solid hsl(220 8% 34% / 0.8) !important;
background: transparent !important;
border-left: 1px solid var(--border) !important;
}
.dark .transcript-editor {
scrollbar-color: hsl(214 10% 62% / 0.82) hsl(220 8% 18% / 0.96);
scrollbar-color: rgb(244 244 241 / 0.24) transparent;
}
}
@layer components {
.app-shell {
@apply mx-auto w-full max-w-5xl px-4 pb-8 pt-4 md:px-6 md:pt-6;
@apply mx-auto w-full max-w-5xl px-5 pb-10 pt-3 md:px-8 md:pt-4;
}
.app-hero {
@apply relative mb-4 rounded-xl border border-border/70 bg-card/95 px-5 py-4 shadow-sm md:mb-5 md:px-6;
@apply relative mb-5 rounded-xl border border-border bg-card px-6 py-5;
}
.app-panel {
@apply rounded-xl border border-border/70 bg-card/95 p-4 shadow-sm md:p-5;
@apply rounded-xl border border-border bg-card p-5 md:p-6;
}
.app-subtle {
@apply rounded-xl border border-border/70 bg-background/80 p-3;
}
.dark .app-hero {
@apply border-border/70 bg-card text-card-foreground;
background-image: none;
@apply rounded-lg border border-border bg-muted/60 p-3;
}
.dark .app-hero,
.dark .app-panel {
@apply border-border/85 bg-card text-card-foreground shadow-md;
@apply bg-card text-card-foreground;
background-image: none;
}
.dark .app-subtle {
@apply border-border/80 bg-muted/85 text-card-foreground;
@apply bg-muted text-card-foreground;
}
.app-title {
@apply text-3xl font-semibold tracking-tight md:text-4xl;
@apply text-[28px] font-semibold leading-[1.1] tracking-[-0.03em] md:text-[40px];
}
.app-kicker {
@apply text-xs font-medium uppercase tracking-[0.08em] text-muted-foreground;
/* Eyebrow label — 11px, uppercase, +0.08em */
.app-kicker,
.eyebrow {
@apply text-[11px] font-medium uppercase leading-none tracking-[0.08em] text-muted-foreground;
}
.app-main-card {
@apply rounded-3xl border border-border/60 bg-card/95 p-6 shadow-xl md:p-8;
@apply rounded-2xl border border-border bg-card p-6 shadow-sm md:p-9;
}
.dark .app-main-card {
@apply border-border/70 bg-card shadow-2xl;
@apply bg-card shadow-md;
background-image: none;
}
/*
* Aurora — the single expressive surface. Soft multi-hue gradient plus a
* grain overlay. Always behind a card or as a thin fill, never behind body text.
*/
.aurora {
position: relative;
isolation: isolate;
background-color: var(--muted);
background-image:
radial-gradient(58% 72% at 14% 16%, #5b8def8c 0%, transparent 68%), radial-gradient(54% 68% at 84% 10%, #b98de37a 0%, transparent 70%),
radial-gradient(58% 66% at 80% 88%, #e8a87c73 0%, transparent 70%), radial-gradient(62% 70% at 18% 92%, #7cc5a073 0%, transparent 70%);
background-repeat: no-repeat;
filter: saturate(0.85);
}
.aurora::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
border-radius: inherit;
opacity: 0.28;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
}
.dark .aurora {
filter: saturate(0.7) brightness(0.8);
}
/* Thin aurora fill for progress bars */
.aurora-bar {
background-image: linear-gradient(90deg, var(--aurora-1) 0%, var(--aurora-2) 34%, var(--aurora-3) 67%, var(--aurora-4) 100%);
filter: saturate(0.85);
}
/* Radix <Progress> whose indicator is filled with the aurora gradient */
.progress-aurora > * {
background-color: transparent;
background-image: linear-gradient(90deg, var(--aurora-1) 0%, var(--aurora-2) 34%, var(--aurora-3) 67%, var(--aurora-4) 100%);
filter: saturate(0.85);
}
.stagger-in {
animation: fade-up 380ms ease both;
animation: fade-up 240ms ease-out both;
}
}
@@ -469,7 +544,7 @@ html.dictation-indicator-window #root {
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(10px);
transform: translateY(6px);
}
to {
@@ -477,3 +552,10 @@ html.dictation-indicator-window #root {
transform: translateY(0);
}
}
/* macOS vibrancy: the window itself is transparent; only the content column paints a solid
background, so the sidebar area shows the blurred desktop through its translucent tint. */
html.mac-vibrancy,
html.mac-vibrancy body {
background: transparent;
}
+357
View File
@@ -0,0 +1,357 @@
import * as pathApi from '@tauri-apps/api/path'
import * as fs from '@tauri-apps/plugin-fs'
import type { Segment } from './transcript'
/**
* Transcript persistence.
*
* The store is *a folder of project folders*: `Documents/Vibe/<name>-<yyyyMMdd-HHmmss>/` holding
* `transcript.vibe.json` next to a copy of the media it was transcribed from (`audio.<ext>`), so a
* saved transcript stays playable even after the original file moves or is deleted.
*
* There is no index and no database — listing scans the store and derives the display name and
* creation date from the folder name, so the list never has to read (and parse) every transcript.
* Legacy flat saves (`Documents/Vibe/<name>-<stamp>.vibe.json`) are still listed, read, renamed and
* deleted in place; only new saves use project folders.
*
* Every operation here is best-effort: failures are logged and reported as null/empty so a broken
* disk, a missing folder or a corrupt file can never break the transcription flow.
*/
export const TRANSCRIPTS_FOLDER = 'Vibe'
export const TRANSCRIPT_EXTENSION = '.vibe.json'
/** Name of the record inside a project folder. */
export const TRANSCRIPT_FILENAME = `transcript${TRANSCRIPT_EXTENSION}`
export const TRANSCRIPT_VERSION = 1
export interface TranscriptRecord {
version: number
name: string
sourcePath: string
/** ISO 8601 */
createdAt: string
language?: string
modelPath?: string | null
/** Filename of the media copy inside the project folder, relative to it (e.g. `audio.mp3`). */
audioFile?: string
segments: Segment[]
}
export interface TranscriptEntry {
/** absolute path of the .vibe.json file (inside its project folder, for non-legacy saves) */
path: string
/** display name, derived from the filename */
name: string
/** parsed from the filename stamp; falls back to the epoch when unparsable */
createdAt: Date
}
export interface SaveTranscriptInput {
name: string
sourcePath: string
segments: Segment[]
language?: string
modelPath?: string | null
createdAt?: Date
}
function pad(value: number, length = 2) {
return String(value).padStart(length, '0')
}
/** `yyyyMMdd-HHmmss` in local time — sortable and readable in the file manager. */
function stamp(date: Date) {
return (
`${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` + `-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
)
}
const stampPattern = /-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/
function parseStamp(stem: string): { name: string; createdAt: Date } {
const match = stem.match(stampPattern)
if (!match) return { name: stem, createdAt: new Date(0) }
const [, year, month, day, hours, minutes, seconds] = match
const createdAt = new Date(Number(year), Number(month) - 1, Number(day), Number(hours), Number(minutes), Number(seconds))
return {
name: stem.slice(0, match.index) || stem,
createdAt: Number.isNaN(createdAt.getTime()) ? new Date(0) : createdAt,
}
}
/** Strip the extension and anything a file system would object to. */
function toFileStem(name: string) {
const withoutExtension = name.replace(/\.[^./\\]+$/, '')
const cleaned = withoutExtension
.replace(/[/\\?%*:|"<>]/g, '-')
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u001f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.replace(/^\.+/, '')
return (cleaned || 'transcript').slice(0, 120)
}
/** Last path segment, for both posix and windows separators. */
function basename(path: string) {
return path.split(/[/\\]/).pop() ?? path
}
/** Containing folder of a path, or `''` when there is none. */
function parentOf(path: string) {
const index = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
return index > 0 ? path.slice(0, index) : ''
}
/** Lowercase-free extension of a filename, without the dot; `''` when it has none. */
function extensionOf(path: string) {
const name = basename(path)
const dot = name.lastIndexOf('.')
if (dot <= 0) return ''
const extension = name.slice(dot + 1)
return /^[A-Za-z0-9]{1,10}$/.test(extension) ? extension : ''
}
/** A project save is a `transcript.vibe.json` inside its own folder; anything else is legacy flat. */
function isProjectRecordPath(path: string) {
return basename(path) === TRANSCRIPT_FILENAME
}
/** Absolute path of the store folder, created on demand. */
export async function transcriptsFolder(): Promise<string> {
const folder = await pathApi.join(await pathApi.documentDir(), TRANSCRIPTS_FOLDER)
if (!(await fs.exists(folder))) await fs.mkdir(folder, { recursive: true })
return folder
}
function isRecord(value: unknown): value is TranscriptRecord {
if (typeof value !== 'object' || value === null) return false
const candidate = value as Partial<TranscriptRecord>
return typeof candidate.name === 'string' && Array.isArray(candidate.segments)
}
function serialize(record: TranscriptRecord) {
return JSON.stringify(record, null, '\t')
}
/**
* Copy the transcribed media next to its record so the project folder stays self-contained.
* Best-effort: a missing extension, an unreadable source or a full disk only costs the copy.
* @returns the relative filename of the copy, or undefined when there is none.
*/
async function copySourceMedia(projectFolder: string, sourcePath: string): Promise<string | undefined> {
const extension = extensionOf(sourcePath)
if (!sourcePath || !extension) return undefined
const audioFile = `audio.${extension}`
try {
await fs.copyFile(sourcePath, await pathApi.join(projectFolder, audioFile))
return audioFile
} catch (error) {
console.warn('failed to copy source media into the transcript folder:', sourcePath, error)
return undefined
}
}
/**
* Write one transcript into the store as a project folder holding the record and a copy of its
* media.
* @returns the path of the written `transcript.vibe.json`, or null when saving failed (never throws).
*/
export async function saveTranscript(input: SaveTranscriptInput): Promise<string | null> {
try {
const createdAt = input.createdAt ?? new Date()
const folder = await transcriptsFolder()
const projectFolder = await pathApi.join(folder, `${toFileStem(input.name)}-${stamp(createdAt)}`)
await fs.mkdir(projectFolder, { recursive: true })
const target = await pathApi.join(projectFolder, TRANSCRIPT_FILENAME)
const record: TranscriptRecord = {
version: TRANSCRIPT_VERSION,
name: input.name,
sourcePath: input.sourcePath,
createdAt: createdAt.toISOString(),
language: input.language,
modelPath: input.modelPath ?? null,
segments: input.segments,
}
await fs.writeTextFile(target, serialize(record))
// The transcript itself is already safe on disk; the media copy may fail without losing it.
const audioFile = await copySourceMedia(projectFolder, input.sourcePath)
if (audioFile) {
try {
await fs.writeTextFile(target, serialize({ ...record, audioFile }))
} catch (error) {
console.warn('failed to record the media copy in the transcript:', target, error)
}
}
return target
} catch (error) {
console.warn('failed to save transcript:', error)
return null
}
}
/**
* Every saved transcript, newest first: project folders plus legacy flat files. Reads folder and
* file *names* only — never the records themselves.
*/
export async function listTranscripts(): Promise<TranscriptEntry[]> {
try {
const folder = await pathApi.join(await pathApi.documentDir(), TRANSCRIPTS_FOLDER)
if (!(await fs.exists(folder))) return []
const entries = await fs.readDir(folder)
const found: TranscriptEntry[] = []
for (const entry of entries) {
if (entry.isDirectory) {
const record = await pathApi.join(folder, entry.name, TRANSCRIPT_FILENAME)
if (!(await fs.exists(record))) continue
const { name, createdAt } = parseStamp(entry.name)
found.push({ path: record, name, createdAt })
continue
}
if (!entry.name.endsWith(TRANSCRIPT_EXTENSION)) continue
const stem = entry.name.slice(0, -TRANSCRIPT_EXTENSION.length)
const { name, createdAt } = parseStamp(stem)
found.push({ path: await pathApi.join(folder, entry.name), name, createdAt })
}
return found.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || b.path.localeCompare(a.path))
} catch (error) {
console.warn('failed to list transcripts:', error)
return []
}
}
/** Read and validate one transcript. Corrupt or foreign files resolve to null. */
export async function readTranscript(path: string): Promise<TranscriptRecord | null> {
try {
const raw = await fs.readTextFile(path)
const parsed: unknown = JSON.parse(raw)
if (!isRecord(parsed)) {
console.warn('skipping malformed transcript:', path)
return null
}
return {
version: typeof parsed.version === 'number' ? parsed.version : TRANSCRIPT_VERSION,
name: parsed.name,
sourcePath: typeof parsed.sourcePath === 'string' ? parsed.sourcePath : '',
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : new Date(0).toISOString(),
language: typeof parsed.language === 'string' ? parsed.language : undefined,
modelPath: typeof parsed.modelPath === 'string' ? parsed.modelPath : null,
audioFile: typeof parsed.audioFile === 'string' && parsed.audioFile ? basename(parsed.audioFile) : undefined,
segments: parsed.segments.filter(
(segment): segment is Segment => typeof segment === 'object' && segment !== null && typeof (segment as Segment).text === 'string',
),
}
} catch (error) {
console.warn('failed to read transcript:', path, error)
return null
}
}
/**
* Absolute path of the media copy stored beside a record, when the project folder still has one.
* Legacy flat saves (and records whose copy was deleted) resolve to null — the caller falls back to
* `record.sourcePath`.
*/
export async function resolveProjectAudio(jsonPath: string, record: TranscriptRecord): Promise<string | null> {
try {
if (!record.audioFile) return null
const audioPath = await pathApi.join(await pathApi.dirname(jsonPath), record.audioFile)
return (await fs.exists(audioPath)) ? audioPath : null
} catch (error) {
console.warn('failed to resolve the transcript media copy:', jsonPath, error)
return null
}
}
/**
* Rewrite the segments of an already stored transcript, keeping every other field untouched.
* Used by inline editing, which changes text only.
* @returns whether the file now holds the new segments (never throws).
*/
export async function updateTranscriptSegments(path: string, segments: Segment[]): Promise<boolean> {
try {
const record = await readTranscript(path)
if (!record) return false
await fs.writeTextFile(path, serialize({ ...record, segments }))
return true
} catch (error) {
console.warn('failed to update transcript segments:', path, error)
return false
}
}
/**
* Fired on `window` whenever the store changed (a save or a delete) so open lists can refresh
* without the writer having to know about them.
*/
export const TRANSCRIPTS_CHANGED_EVENT = 'vibe:transcripts-changed'
export function notifyTranscriptsChanged() {
window.dispatchEvent(new CustomEvent(TRANSCRIPTS_CHANGED_EVENT))
}
/**
* Remove one transcript: the whole project folder (media copy included) for project saves, the
* single file for legacy ones. Returns whether it is gone.
*/
export async function deleteTranscript(path: string): Promise<boolean> {
try {
if (isProjectRecordPath(path)) {
await fs.remove(await pathApi.dirname(path), { recursive: true })
return true
}
await fs.remove(path)
return true
} catch (error) {
console.warn('failed to delete transcript:', path, error)
return false
}
}
/** The stamp suffix of a folder/file stem, or a fresh one when the old name carries none. */
function keptStampOf(stem: string) {
const match = stem.match(stampPattern)
return match ? stem.slice(match.index) : `-${stamp(new Date())}`
}
/**
* Rename a stored transcript: updates the record's `name` and moves the project folder (legacy: the
* file) to one carrying the new name while keeping the original timestamp stamp.
* @returns the new entry, or null on failure (never throws).
*/
export async function renameTranscript(path: string, newName: string): Promise<TranscriptEntry | null> {
try {
const record = await readTranscript(path)
if (!record) return null
const stem = toFileStem(newName)
if (isProjectRecordPath(path)) {
const projectFolder = await pathApi.dirname(path)
const oldFolderName = basename(projectFolder)
const folderName = `${stem}${keptStampOf(oldFolderName)}`
const createdAt = parseStamp(folderName).createdAt
let target = path
if (folderName !== oldFolderName) {
const renamed = await pathApi.join(parentOf(projectFolder) || (await transcriptsFolder()), folderName)
await fs.rename(projectFolder, renamed)
target = await pathApi.join(renamed, TRANSCRIPT_FILENAME)
}
await fs.writeTextFile(target, serialize({ ...record, name: stem }))
return { path: target, name: stem, createdAt }
}
const filename = basename(path)
const oldStem = filename.slice(0, -TRANSCRIPT_EXTENSION.length)
const keptStamp = keptStampOf(oldStem)
const folder = parentOf(path) || (await transcriptsFolder())
const target = await pathApi.join(folder, `${stem}${keptStamp}${TRANSCRIPT_EXTENSION}`)
if (target === path) return { path, name: stem, createdAt: parseStamp(oldStem).createdAt }
await fs.writeTextFile(target, serialize({ ...record, name: stem }))
await fs.remove(path)
return { path: target, name: stem, createdAt: parseStamp(`${stem}${keptStamp}`).createdAt }
} catch (error) {
console.warn('failed to rename transcript:', path, error)
return null
}
}
@@ -15,6 +15,7 @@ const MOCK_AUDIO_DEVICES: MockAudioDevice[] = [
]
const RECORD_FINISH_DELAY_MS = 200
const RECORD_LEVEL_TICK_MS = 100
const YTDLP_TICKS = 20
const YTDLP_TICK_MS = 100
@@ -25,6 +26,18 @@ function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
/**
* Fake speech envelope: two slow sines (syllables + phrase breathing) plus jitter, so the mock
* meter moves like a voice instead of a metronome. Returns 0.05..0.9.
*/
function mockSpeechLevel(tick: number) {
const syllables = 0.5 + 0.5 * Math.sin(tick * 0.55)
const phrase = 0.55 + 0.45 * Math.sin(tick * 0.09 + 1.3)
const jitter = Math.random() * 0.18
const level = syllables * phrase * 0.8 + jitter
return Math.min(0.9, Math.max(0.05, level))
}
function basename(path: string) {
const normalized = path.replace(/\/+$/, '')
return normalized.slice(normalized.lastIndexOf('/') + 1)
@@ -53,9 +66,16 @@ export const mediaMiscHandlers: CommandHandlerMap = {
start_record: (args) => {
console.info('[mock] start_record', args)
const path = `${DOCUMENTS_FOLDER}/recording.wav`
// Stand in for the capture callbacks: a level event every 100ms, like the throttled Rust side.
let tick = 0
const levelTimer = window.setInterval(() => {
tick += 1
emitMockEvent('record_level', mockSpeechLevel(tick))
}, RECORD_LEVEL_TICK_MS)
// The frontend stops recording by emitting `stop_record` on the event bus.
const unsub = onMockEvent('stop_record', () => {
unsub()
window.clearInterval(levelTimer)
setTimeout(() => {
virtualFs.set(path, null)
emitMockEvent('record_finish', { path, name: basename(path) })
+35 -5
View File
@@ -54,7 +54,7 @@ function resolveWritePath(args: Record<string, unknown>): string {
}
function resolveWriteData(args: Record<string, unknown>): Uint8Array {
const candidate = args.data ?? args.__payload ?? args.payload ?? args
const candidate = args.data ?? args.__body ?? args.__payload ?? args.payload ?? args
if (candidate instanceof Uint8Array) {
return candidate
}
@@ -197,6 +197,30 @@ const fsHandlers: CommandHandlerMap = {
}))
},
'plugin:fs|mkdir': () => null,
'plugin:fs|copy_file': (args) => {
const from = toPosixPath(args.fromPath ?? args.from)
const to = toPosixPath(args.toPath ?? args.to)
virtualFs.set(to, virtualFs.get(from) ?? null)
return null
},
// Moves a file, or a whole folder subtree (how a transcript project folder gets renamed).
'plugin:fs|rename': (args) => {
const from = toPosixPath(args.oldPath ?? args.from)
const to = toPosixPath(args.newPath ?? args.to)
if (virtualFs.has(from)) {
virtualFs.set(to, virtualFs.get(from) ?? null)
virtualFs.delete(from)
return null
}
const prefix = `${from}/`
for (const key of Array.from(virtualFs.keys())) {
if (key.startsWith(prefix)) {
virtualFs.set(`${to}/${key.slice(prefix.length)}`, virtualFs.get(key) ?? null)
virtualFs.delete(key)
}
}
return null
},
'plugin:fs|remove': (args) => {
const path = toPosixPath(args.path)
virtualFs.delete(path)
@@ -224,11 +248,12 @@ const fsHandlers: CommandHandlerMap = {
return contents ? Array.from(contents) : []
},
'plugin:fs|read_text_file': (args) => {
// plugin-fs decodes the response via Uint8Array.from(...), so this must be raw bytes.
const contents = virtualFs.get(toPosixPath(args.path))
if (typeof contents === 'string') {
return contents
return Array.from(new TextEncoder().encode(contents))
}
return contents ? new TextDecoder().decode(contents) : ''
return contents ? Array.from(contents) : []
},
}
@@ -248,8 +273,13 @@ const dialogHandlers: CommandHandlerMap = {
if (options.directory) {
return options.multiple ? [DOCUMENTS_FOLDER] : DOCUMENTS_FOLDER
}
const picked = `${DOCUMENTS_FOLDER}/sample.mp3`
return options.multiple ? [picked] : picked
// multiple:true simulates a multi-select so the batch queue UI is reachable in browser
// mode; the floating mock toolbar (runtime.ts) can force single-file picks instead.
if (options.multiple && localStorage.getItem('mock-dialog-multi') !== 'off') {
return [...virtualFs.keys()].filter((key) => key.startsWith(`${DOCUMENTS_FOLDER}/`) && /\.(mp3|mp4|wav)$/.test(key))
}
const single = `${DOCUMENTS_FOLDER}/sample.mp3`
return options.multiple ? [single] : single
},
'plugin:dialog|save': (args) => {
const options = (args.options ?? {}) as DialogOpenOptions
+48
View File
@@ -139,6 +139,54 @@ export function installRuntime(handlers: CommandHandlerMap): void {
mockIPC(async (cmd, args) => route(cmd, (args ?? {}) as Record<string, unknown>))
// mockIPC drops the third invoke argument, but plugin-fs writes carry their target path in
// its `headers` and send the file contents as a raw (non-record) payload. Re-wrap the real
// internals.invoke so headers and raw bodies reach the handlers as `__headers`/`__body`.
const tauriInternals = (window as Window & { __TAURI_INTERNALS__?: { invoke?: (...args: unknown[]) => Promise<unknown> } }).__TAURI_INTERNALS__
const baseInvoke = tauriInternals?.invoke
if (tauriInternals && baseInvoke) {
tauriInternals.invoke = (cmd: unknown, payload?: unknown, options?: unknown) => {
const headers = (options as { headers?: Record<string, unknown> } | undefined)?.headers
if (headers) {
const record =
payload && typeof payload === 'object' && !ArrayBuffer.isView(payload) && !(payload instanceof ArrayBuffer) ? payload : { __body: payload }
payload = { ...(record as Record<string, unknown>), __headers: headers }
}
return baseInvoke.call(tauriInternals, cmd, payload, options)
}
}
installOsPluginInternals()
installGlobalTauri()
installMockToolbar()
}
// Tiny floating control for mock-only knobs. Plain DOM so it never touches app code.
function installMockToolbar(): void {
const ID = '__vibe-mock-toolbar'
if (document.getElementById(ID)) {
return
}
const label = () => (localStorage.getItem('mock-dialog-multi') === 'off' ? 'mock: pick 1 file' : 'mock: pick 3 files')
const button = document.createElement('button')
button.id = ID
button.type = 'button'
button.textContent = label()
button.title = 'Browser mock: toggle how many files the fake open-dialog returns'
button.style.cssText =
'position:fixed;bottom:10px;left:10px;z-index:99999;padding:4px 10px;border-radius:999px;' +
'border:1px solid rgba(128,128,128,.35);background:rgba(128,128,128,.12);color:inherit;' +
'font:11px/1.6 ui-monospace,monospace;opacity:.55;cursor:pointer;backdrop-filter:blur(4px)'
button.addEventListener('mouseenter', () => (button.style.opacity = '1'))
button.addEventListener('mouseleave', () => (button.style.opacity = '.55'))
button.addEventListener('click', () => {
localStorage.setItem('mock-dialog-multi', localStorage.getItem('mock-dialog-multi') === 'off' ? 'on' : 'off')
button.textContent = label()
})
const mount = () => document.body.appendChild(button)
if (document.body) {
mount()
} else {
window.addEventListener('DOMContentLoaded', mount, { once: true })
}
}
+41
View File
@@ -10,8 +10,49 @@ export const DEFAULT_MODEL_FILE = 'ggml-medium.bin'
export const virtualFs = new Map<string, string | Uint8Array | null>([
[`${MODELS_FOLDER}/${DEFAULT_MODEL_FILE}`, null],
[`${DOCUMENTS_FOLDER}/sample.mp3`, null],
[`${DOCUMENTS_FOLDER}/interview.mp4`, null],
[`${DOCUMENTS_FOLDER}/meeting-notes.wav`, null],
])
// Pre-saved transcripts so the Recents sidebar has entries to open on first load.
// Shape, folder layout and stamp must match src/lib/transcripts-store.ts.
function record(name: string, texts: string[], audioFile?: string) {
return JSON.stringify({
version: 1,
name,
sourcePath: `${DOCUMENTS_FOLDER}/${name}.mp3`,
createdAt: new Date().toISOString(),
language: 'english',
modelPath: `${MODELS_FOLDER}/${DEFAULT_MODEL_FILE}`,
...(audioFile ? { audioFile } : {}),
segments: texts.map((text, index) => ({ start: index * 300, stop: index * 300 + 280, text })),
})
}
/** A project folder: the record plus the copy of the media it was transcribed from. */
function seedProject(name: string, stamp: string, texts: string[]) {
const folder = `${DOCUMENTS_FOLDER}/Vibe/${name}-${stamp}`
virtualFs.set(`${folder}/transcript.vibe.json`, record(name, texts, 'audio.mp3'))
virtualFs.set(`${folder}/audio.mp3`, null)
}
seedProject('team-standup', '20260820-093015', [
'Quick recap of what everyone shipped yesterday.',
'The new release is scheduled for Friday morning.',
'Open blockers move to the afternoon sync.',
])
seedProject('podcast-episode', '20260818-174200', [
'Welcome back to the show, today we talk about local AI.',
'On-device transcription keeps your audio private.',
'Thanks for listening, see you next week.',
])
// One save in the pre-project-folder layout, so the mock keeps exercising back-compat.
virtualFs.set(
`${DOCUMENTS_FOLDER}/Vibe/legacy-interview-20260810-101500.vibe.json`,
record('legacy-interview', ['This transcript was saved as a flat file by an older build.', 'It still opens, renames and deletes in place.']),
)
export const sampleSegments: { start: number; stop: number; text: string; speaker?: number }[] = [
{ start: 0, stop: 240, text: 'Welcome to Vibe running in browser mock mode.' },
{ start: 240, stop: 520, text: 'This transcript is generated by the mock Tauri layer.' },
@@ -0,0 +1,113 @@
import { motion } from 'framer-motion'
import { AlertCircle, Check, CircleSlash, X } from 'lucide-react'
import { m } from '~/paraglide/messages.js'
import { Spinner } from '~/components/ui/spinner'
import { cn } from '~/lib/style'
import type { Job } from '../hooks/use-transcribe-queue'
import { useSession } from '../session'
function StatusIcon({ job }: { job: Job }) {
switch (job.status) {
case 'running':
return <Spinner className="h-3.5 w-3.5 text-foreground" />
case 'done':
return <Check className="h-3.5 w-3.5 text-success" strokeWidth={2.4} />
case 'error':
return <AlertCircle className="h-3.5 w-3.5 text-destructive" />
case 'cancelled':
return <CircleSlash className="h-3.5 w-3.5 text-muted-foreground" />
default:
return <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/50" />
}
}
function statusLabel(job: Job) {
switch (job.status) {
case 'running':
return `${Math.round(job.progress)}%`
case 'done':
return job.seconds != null ? m.transcribeTook({ total: String(job.seconds) }) : m.transcribed()
case 'error':
return job.error ?? m.error()
case 'cancelled':
return m.cancel()
default:
return m.loading()
}
}
export default function FileQueue() {
const { queue } = useSession()
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center justify-between px-4 pt-4 pb-3">
<p className="text-[11px] font-medium tracking-[0.08em] text-muted-foreground uppercase">
{m.files()} · {queue.jobs.length}
</p>
{queue.running && (
<button
type="button"
onClick={queue.cancelAll}
disabled={queue.isAborting}
className="cursor-pointer text-[11px] font-medium tracking-[0.08em] text-muted-foreground uppercase transition-colors duration-150 hover:text-destructive disabled:opacity-50">
{queue.isAborting ? m.aborting() : m.cancel()}
</button>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{queue.jobs.map((job) => {
const selected = job.id === queue.selectedId
return (
<button
type="button"
key={job.id}
onClick={() => queue.selectJob(job.id)}
className={cn(
'group relative block w-full cursor-pointer rounded-xl px-3 py-2.5 text-start transition-colors duration-150',
selected ? 'bg-muted' : 'hover:bg-muted/60',
)}>
<div className="flex items-center gap-2.5">
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<StatusIcon job={job} />
</span>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">{job.name}</span>
{job.status === 'running' && (
<span
role="button"
tabIndex={0}
onClick={(event) => {
event.stopPropagation()
queue.cancelCurrent()
}}
onKeyDown={(event) => {
if (event.key === 'Enter') queue.cancelCurrent()
}}
className="cursor-pointer text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100 hover:text-destructive">
<X className="h-3.5 w-3.5" />
</span>
)}
</div>
<p className={cn('mt-1 ps-6 truncate text-[11px]', job.status === 'error' ? 'text-destructive' : 'text-muted-foreground')}>
{statusLabel(job)}
</p>
{job.status === 'running' && (
<div className="mt-2 ms-6 h-[3px] overflow-hidden rounded-full bg-muted-foreground/15">
<motion.div
className="aurora-bar h-full rounded-full bg-primary/70"
initial={false}
animate={{ width: `${Math.max(2, job.progress)}%` }}
transition={{ duration: 0.2, ease: 'easeOut' }}
/>
</div>
)}
</button>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,296 @@
import { listen } from '@tauri-apps/api/event'
import { AnimatePresence, motion } from 'framer-motion'
import { FolderOpen, Link2, Mic, Square, Upload } from 'lucide-react'
import { siFacebook, siInstagram, siTiktok, siX, siYoutube } from 'simple-icons'
import { useEffect, useRef, useState } from 'react'
import { m } from '~/paraglide/messages.js'
import AudioDeviceInput from '~/components/audio-device-input'
import { Button } from '~/components/ui/button'
import { Input } from '~/components/ui/input'
import { Spinner } from '~/components/ui/spinner'
import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip'
import { cn } from '~/lib/style'
import { useSession, type IdlePanel } from '../session'
import QuietRow from './quiet-row'
function formatElapsed(seconds: number) {
const minutes = Math.floor(seconds / 60)
return `${String(minutes).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`
}
// Live capture meter — five hairline bars driven by the backend's `record_level` event
// (a 0..1 peak, throttled to ~10/s). Center bars react hardest so the shape reads as a voice.
const BAR_WEIGHTS = [0.5, 0.78, 1, 0.78, 0.5]
const BAR_MIN_HEIGHT = 6
const BAR_MAX_HEIGHT = 18
/** Below this the capture counts as silence. */
const SILENCE_LEVEL = 0.03
/** How long silence must hold before the meter drops to dim stubs. */
const SILENCE_HOLD_MS = 1000
const METER_TICK_MS = 60
/** No event for this long means the stream stalled — let the target fall back to zero. */
const LEVEL_STALE_MS = 160
function LevelMeter() {
const [level, setLevel] = useState(0)
const [silent, setSilent] = useState(false)
// Refs keep the audio-rate values out of the render path; only the smoothed level is state.
const targetRef = useRef(0)
const lastEventRef = useRef(Date.now())
const lastSoundRef = useRef(Date.now())
useEffect(() => {
const unlisten = listen<number>('record_level', ({ payload }) => {
const next = typeof payload === 'number' && Number.isFinite(payload) ? Math.min(Math.max(payload, 0), 1) : 0
targetRef.current = next
lastEventRef.current = Date.now()
if (next > SILENCE_LEVEL) lastSoundRef.current = Date.now()
})
const timer = window.setInterval(() => {
const now = Date.now()
// A dead device emits nothing at all — decay so it can't freeze mid-bar.
if (now - lastEventRef.current > LEVEL_STALE_MS) targetRef.current *= 0.6
// Fast attack, slower release: peaks stay legible, the fall stays calm.
setLevel((prev) => {
const target = targetRef.current
const eased = prev + (target - prev) * (target > prev ? 0.7 : 0.3)
return Math.abs(eased - target) < 0.004 ? target : eased
})
setSilent(now - lastSoundRef.current > SILENCE_HOLD_MS)
}, METER_TICK_MS)
return () => {
window.clearInterval(timer)
unlisten.then((fn) => fn())
}
}, [])
return (
<span aria-hidden className={cn('flex h-[18px] items-center gap-[3px]', silent && 'opacity-40')}>
{BAR_WEIGHTS.map((weight, index) => (
<span
key={index}
className="w-[2px] rounded-full bg-foreground transition-[height] duration-75 ease-out"
style={{ height: silent ? BAR_MIN_HEIGHT : BAR_MIN_HEIGHT + level * weight * (BAR_MAX_HEIGHT - BAR_MIN_HEIGHT) }}
/>
))}
</span>
)
}
/** One segment of the joined source switcher: icon-only keys, the active one reads as raised. */
function Segment({ active, label, onClick, children }: { active?: boolean; label: string; onClick: () => void; children: React.ReactNode }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-pressed={active}
aria-label={label}
onClick={onClick}
className={cn(
'inline-flex h-10 w-14 cursor-pointer items-center justify-center rounded-full transition-colors duration-150',
active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
)}>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
)
}
function RecordPanel() {
const { recording, recordElapsed, preference } = useSession()
useEffect(() => {
// The recording hook loads audio devices for this tab only.
preference.setHomeTab('record')
}, [])
if (recording.isRecording) {
return (
<div className="flex flex-col items-center gap-4 py-2.5">
<div className="flex items-center gap-3">
<LevelMeter />
<span className="font-mono text-2xl tracking-tight tabular-nums">{formatElapsed(recordElapsed)}</span>
</div>
<Button onClick={() => recording.stopRecord()} className="h-10 w-full rounded-xl">
<Square className="h-3.5 w-3.5 fill-current" />
{m.stopAndTranscribe()}
</Button>
</div>
)
}
return (
// Device labels become eyebrows and the triggers drop to h-10 so the panel stays a compact
// tool row instead of a form. Restyled from here to keep AudioDeviceInput's API untouched.
<div
className={cn(
'flex w-full flex-col gap-5 py-2.5',
'[&_label]:text-[11px] [&_label]:font-medium [&_label]:tracking-[0.08em] [&_label]:text-muted-foreground [&_label]:uppercase',
)}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 [&>div]:space-y-1.5 [&_button]:h-10 [&_button]:rounded-xl">
<AudioDeviceInput type="input" devices={recording.devices} device={recording.inputDevice} setDevice={recording.setInputDevice} />
<AudioDeviceInput type="output" devices={recording.devices} device={recording.outputDevice} setDevice={recording.setOutputDevice} />
</div>
<Button
onClick={() => recording.startRecord()}
disabled={!preference.modelPath || (!recording.inputDevice && !recording.outputDevice)}
className="h-10 w-full rounded-xl disabled:opacity-40">
<Mic className="h-4 w-4" />
{m.startRecord()}
</Button>
</div>
)
}
// Bundled monochrome brand marks of the most-used yt-dlp sources — fills the panel with a
// "works with" row instead of dead space. Rendered tiny and muted so it stays quiet.
const linkSources: { title: string; path: string }[] = [
{ title: siYoutube.title, path: siYoutube.path },
{ title: siTiktok.title, path: siTiktok.path },
{ title: siInstagram.title, path: siInstagram.path },
{ title: siX.title, path: siX.path },
{ title: siFacebook.title, path: siFacebook.path },
]
function LinkPanel() {
const { link, preference } = useSession()
if (link.downloadingAudio) {
return (
<div className="flex h-10 items-center justify-center gap-3 py-2.5 text-sm text-muted-foreground">
<Spinner />
<span>{m.downloading({ progress: String(link.ytdlpProgress ?? 0) })}</span>
<button type="button" className="cursor-pointer text-destructive hover:underline" onClick={() => link.cancelYtDlpDownload()}>
{m.cancel()}
</button>
</div>
)
}
return (
<div className="flex w-full flex-col items-center gap-5 py-2.5">
<div className="flex w-full items-center gap-3">
<Input
type="text"
value={link.audioUrl}
onChange={(event) => link.setAudioUrl(event.target.value)}
onKeyDown={(event) => (event.key === 'Enter' ? link.downloadAudio() : null)}
// Short enough to stay fully readable when the window is narrow.
placeholder="Paste a video or audio link"
className="h-10 min-w-0 flex-1 rounded-xl px-3.5 text-sm"
/>
<Button
onClick={() => link.downloadAudio()}
disabled={!preference.modelPath || !link.audioUrl}
className="h-10 shrink-0 rounded-xl px-4 disabled:opacity-40">
{m.transcribe()}
</Button>
</div>
<div className="flex flex-col items-center gap-3">
<p className="text-[11px] font-medium tracking-[0.08em] text-muted-foreground/80 uppercase">Works with</p>
<div className="flex flex-wrap items-center justify-center gap-x-5 gap-y-3 text-muted-foreground/70">
{linkSources.map((source) => (
<Tooltip key={source.title}>
<TooltipTrigger asChild>
<svg
viewBox="0 0 24 24"
aria-label={source.title}
role="img"
className="h-[18px] w-[18px] transition-colors duration-150 hover:text-foreground">
<path d={source.path} fill="currentColor" />
</svg>
</TooltipTrigger>
<TooltipContent side="bottom">{source.title}</TooltipContent>
</Tooltip>
))}
<span className="text-[12px] text-muted-foreground/70">+1000 more</span>
</div>
</div>
</div>
)
}
export default function IdleHero() {
const { dragging, browse, collectingFolder, panel, setPanel, link, recording } = useSession()
function selectPanel(next: IdlePanel) {
if (recording.isRecording || next === panel) return
setPanel(next)
if (next === 'link') void link.switchToLinkTab()
}
return (
// One optical column: pills, active source and quiet row all span max-w-xl with a 20px rhythm.
<div className="mx-auto flex w-full max-w-xl flex-1 flex-col justify-center gap-5 px-6 py-10">
{/* Joined source switcher: one control, three keys; the active source replaces the drop area. */}
<div className="mx-auto inline-flex items-center gap-1 rounded-full border border-border bg-muted/60 p-1">
<Segment active={panel === 'none'} label="File" onClick={() => selectPanel('none')}>
<FolderOpen className="h-[18px] w-[18px]" />
</Segment>
<Segment active={panel === 'record'} label={m.record()} onClick={() => selectPanel('record')}>
<Mic className="h-[18px] w-[18px]" />
</Segment>
<Segment active={panel === 'link'} label="From link" onClick={() => selectPanel('link')}>
<Link2 className="h-[18px] w-[18px]" />
</Segment>
</div>
{/* Fixed-height slot sized to the drop zone so switching sources never moves the pills or the quiet row. */}
<div className="flex min-h-[220px] w-full flex-col justify-center">
<AnimatePresence mode="wait" initial={false}>
{panel === 'none' ? (
<motion.button
key="drop"
type="button"
onClick={() => void browse()}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className={cn(
'group relative cursor-pointer overflow-hidden rounded-[1.25rem] border-2 border-dashed transition-colors duration-150',
dragging ? 'border-ring' : 'border-border bg-muted/30 hover:border-ring/50 hover:bg-muted/50',
)}>
{/* The aurora is drag feedback only — at rest the zone stays a quiet surface. */}
<div
className={cn(
'aurora pointer-events-none absolute inset-0 transition-opacity duration-200',
dragging ? 'opacity-100' : 'opacity-0',
)}
/>
<div className="relative flex flex-col items-center gap-3.5 px-8 py-12 text-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-background/70 text-foreground shadow-sm">
{collectingFolder ? <Spinner className="h-5 w-5" /> : <Upload className="h-5 w-5" />}
</span>
<div className="space-y-1">
<h1 className="text-xl font-semibold tracking-[-0.02em] text-foreground">Drop audio, video or a folder here</h1>
<p className="text-[13px] text-muted-foreground">or click to browse your files</p>
</div>
</div>
</motion.button>
) : (
<motion.div
key={panel}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
// Same footprint as the drop zone so the composition stays anchored.
className="flex min-h-[220px] w-full flex-col justify-center rounded-[1.25rem] border border-border bg-muted/30 px-7">
{panel === 'record' ? <RecordPanel /> : <LinkPanel />}
</motion.div>
)}
</AnimatePresence>
</div>
<QuietRow />
</div>
)
}
@@ -0,0 +1,285 @@
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import * as dialog from '@tauri-apps/plugin-dialog'
import * as fs from '@tauri-apps/plugin-fs'
import { motion } from 'framer-motion'
import { Download, Pause, Play } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { m } from '~/paraglide/messages.js'
import { Button } from '~/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip'
import { cn } from '~/lib/style'
import type { NamedPath } from '~/lib/types'
import type { Job } from '../hooks/use-transcribe-queue'
/**
* Fired on `window` with `{ seconds }` to move this player, so anything on screen (a transcript
* timestamp, for one) can seek without a handle on the audio element.
*/
export const PLAYER_SEEK_EVENT = 'vibe:player-seek'
/** mm:ss, or --:-- when the media has no usable duration (streams, failed metadata). */
function formatTime(seconds: number) {
if (!Number.isFinite(seconds) || seconds < 0) return '--:--'
const total = Math.floor(seconds)
const minutes = Math.floor(total / 60)
return `${minutes}:${String(total % 60).padStart(2, '0')}`
}
/** Containing folder of a path, for both posix and windows separators. */
function dirname(path: string) {
const index = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
return index > 0 ? path.slice(0, index) : path
}
function extension(path: string) {
const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
const dot = name.lastIndexOf('.')
return dot > 0 ? name.slice(dot + 1) : ''
}
export default function PlayerBar({ job }: { job: Job }) {
const audioRef = useRef<HTMLAudioElement | null>(null)
const trackRef = useRef<HTMLDivElement>(null)
const [playing, setPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(Number.NaN)
const sourcePath = job.path
const seekable = Number.isFinite(duration) && duration > 0
// One audio element per source: switching files stops playback and swaps the stream.
useEffect(() => {
const audio = new Audio(convertFileSrc(sourcePath))
audio.preload = 'metadata'
audioRef.current = audio
setPlaying(false)
setCurrentTime(0)
setDuration(Number.NaN)
const onTime = () => setCurrentTime(audio.currentTime)
const onMetadata = () => setDuration(audio.duration)
const onPlay = () => setPlaying(true)
const onPause = () => setPlaying(false)
const onEnded = () => {
setPlaying(false)
setCurrentTime(0)
}
audio.addEventListener('timeupdate', onTime)
audio.addEventListener('loadedmetadata', onMetadata)
audio.addEventListener('durationchange', onMetadata)
audio.addEventListener('play', onPlay)
audio.addEventListener('pause', onPause)
audio.addEventListener('ended', onEnded)
return () => {
audio.removeEventListener('timeupdate', onTime)
audio.removeEventListener('loadedmetadata', onMetadata)
audio.removeEventListener('durationchange', onMetadata)
audio.removeEventListener('play', onPlay)
audio.removeEventListener('pause', onPause)
audio.removeEventListener('ended', onEnded)
audio.pause()
audio.src = ''
if (audioRef.current === audio) audioRef.current = null
}
}, [sourcePath])
// `timeupdate` only fires ~4x/second which makes the fill step visibly; while playing, the
// position is sampled every animation frame instead so the bar glides.
useEffect(() => {
if (!playing) return
let frame = 0
const tick = () => {
const audio = audioRef.current
if (audio) setCurrentTime(audio.currentTime)
frame = requestAnimationFrame(tick)
}
frame = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frame)
}, [playing])
const toggle = useCallback(() => {
const audio = audioRef.current
if (!audio) return
if (audio.paused) void audio.play().catch(() => setPlaying(false))
else audio.pause()
}, [])
const seekTo = useCallback(
(seconds: number) => {
const audio = audioRef.current
if (!audio || !seekable) return
const next = Math.min(Math.max(seconds, 0), duration)
audio.currentTime = next
setCurrentTime(next)
},
[duration, seekable],
)
// Seek requests from elsewhere in the window (transcript timestamps). Read straight off the
// element so the handler never goes stale, and no-op while there is nothing to seek yet.
useEffect(() => {
const onSeekRequest = (event: Event) => {
const seconds = (event as CustomEvent<{ seconds?: number }>).detail?.seconds
const audio = audioRef.current
if (!audio || typeof seconds !== 'number' || !Number.isFinite(seconds)) return
const limit = Number.isFinite(audio.duration) && audio.duration > 0 ? audio.duration : Number.POSITIVE_INFINITY
const next = Math.min(Math.max(seconds, 0), limit)
try {
audio.currentTime = next
} catch {
// The media has no seekable range yet; nothing to do.
return
}
setCurrentTime(next)
if (audio.paused) void audio.play().catch(() => setPlaying(false))
}
window.addEventListener(PLAYER_SEEK_EVENT, onSeekRequest)
return () => window.removeEventListener(PLAYER_SEEK_EVENT, onSeekRequest)
}, [])
const seekToClientX = useCallback(
(clientX: number) => {
const track = trackRef.current
if (!track) return
const rect = track.getBoundingClientRect()
if (rect.width === 0) return
const ratio = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1)
seekTo(ratio * duration)
},
[duration, seekTo],
)
async function revealInFolder() {
try {
await invoke('open_path', { path: dirname(sourcePath) })
} catch {
toast.error('Could not open the containing folder', { position: 'bottom-center' })
}
}
async function saveACopy() {
const ext = extension(sourcePath)
try {
let defaultPath = sourcePath
try {
const suggested = await invoke<NamedPath>('get_save_path', { srcPath: sourcePath, targetExt: ext })
defaultPath = suggested.path
} catch {
// Falling back to the source path as the dialog suggestion is fine.
}
const target = await dialog.save({
filters: ext ? [{ name: '', extensions: [ext] }] : undefined,
canCreateDirectories: true,
defaultPath,
})
if (!target) return
await fs.copyFile(sourcePath, target)
toast.success(m.saveSuccess(), {
description: job.name,
position: 'bottom-center',
action: { label: m.findHere(), onClick: () => void invoke('open_path', { path: dirname(target) }) },
})
} catch {
toast.error('Saving a copy of the audio is not available here', { position: 'bottom-center' })
}
}
const progress = seekable ? Math.min(Math.max(currentTime / duration, 0), 1) : 0
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="sticky bottom-0 z-10 flex h-16 shrink-0 items-center gap-4 border-t border-border bg-background/85 px-4 backdrop-blur-md">
<div className="hidden min-w-0 basis-[200px] flex-col justify-center sm:flex">
<div className="truncate text-[13px] font-medium text-foreground">{job.name}</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => void revealInFolder()}
className="truncate text-start text-[12px] text-muted-foreground transition-colors duration-150 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/80">
{sourcePath}
</button>
</TooltipTrigger>
<TooltipContent side="top">Show in folder</TooltipContent>
</Tooltip>
</div>
<div className="flex min-w-0 flex-1 items-center gap-3">
<Button
size="icon"
onClick={toggle}
aria-label={playing ? 'Pause' : 'Play'}
className="h-9 w-9 shrink-0 rounded-full bg-primary text-primary-foreground hover:bg-primary/90">
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<span className="w-10 shrink-0 text-end font-mono text-[11px] tabular-nums text-muted-foreground">{formatTime(currentTime)}</span>
<div
ref={trackRef}
role="slider"
tabIndex={seekable ? 0 : -1}
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={seekable ? Math.floor(duration) : 0}
aria-valuenow={Math.floor(currentTime)}
aria-disabled={!seekable}
onPointerDown={(event) => {
if (!seekable) return
event.currentTarget.setPointerCapture(event.pointerId)
seekToClientX(event.clientX)
}}
onPointerMove={(event) => {
if (!seekable || !event.currentTarget.hasPointerCapture(event.pointerId)) return
seekToClientX(event.clientX)
}}
onPointerUp={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId)
}}
onKeyDown={(event) => {
if (!seekable) return
if (event.key === 'ArrowRight') {
event.preventDefault()
seekTo(currentTime + 5)
} else if (event.key === 'ArrowLeft') {
event.preventDefault()
seekTo(currentTime - 5)
} else if (event.key === ' ') {
event.preventDefault()
toggle()
}
}}
className={cn(
'group relative flex h-6 min-w-0 flex-1 touch-none items-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/80',
seekable ? 'cursor-pointer' : 'cursor-default opacity-60',
)}>
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-foreground" style={{ width: `${progress * 100}%` }} />
</div>
<div
aria-hidden
className="pointer-events-none absolute top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary opacity-0 shadow-sm transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100"
style={{ left: `${progress * 100}%` }}
/>
</div>
<span className="w-10 shrink-0 font-mono text-[11px] tabular-nums text-muted-foreground">{formatTime(duration)}</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="iconSm" onClick={() => void saveACopy()} aria-label={m.save()} className="shrink-0 rounded-full">
<Download className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">{m.save()}</TooltipContent>
</Tooltip>
</motion.div>
)
}
@@ -0,0 +1,60 @@
import { ChevronRight, SlidersHorizontal } from 'lucide-react'
import { m } from '~/paraglide/messages.js'
import LanguageInput from '~/components/language-input'
import { Button } from '~/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '~/components/ui/popover'
import { Switch } from '~/components/ui/switch'
import { openSettingsSection } from '~/lib/app'
import { useSession } from '../session'
function OptionRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) {
return (
<div className="flex items-center justify-between gap-4 py-2">
<span className="text-sm text-foreground">{label}</span>
<Switch checked={checked} onCheckedChange={onChange} />
</div>
)
}
export default function QuietRow() {
const { preference } = useSession()
return (
<div className="flex flex-wrap items-center gap-2">
{/* Language leads the row: it changes the output, so it gets the only bordered control here. */}
<div className="w-auto max-w-[220px]">
<LanguageInput variant="prominent" />
</div>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-9 rounded-full px-3 text-[13px] font-medium text-muted-foreground hover:text-foreground">
<SlidersHorizontal className="h-3.5 w-3.5" />
{m.moreOptions()}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 rounded-2xl p-4">
<p className="mb-2 text-[11px] font-medium tracking-[0.08em] text-muted-foreground uppercase">{m.modelOptions()}</p>
<div className="divide-y divide-border">
<OptionRow label={m.recognizeSpeakers()} checked={preference.diarizeEnabled} onChange={preference.setDiarizeEnabled} />
<OptionRow label={m.stableTimestamps()} checked={preference.stableTimestampsEnabled} onChange={preference.setStableTimestampsEnabled} />
<OptionRow
label={m.translateToEnglish()}
checked={preference.modelOptions.translate ?? false}
onChange={(value) => preference.setModelOptions({ ...preference.modelOptions, translate: value })}
/>
<OptionRow
label={m.useWordTimestamps()}
checked={preference.modelOptions.word_timestamps ?? false}
onChange={(value) => preference.setModelOptions({ ...preference.modelOptions, word_timestamps: value })}
/>
</div>
<Button variant="ghost" size="sm" className="mt-3 w-full justify-between rounded-xl px-3" onClick={() => openSettingsSection('tuning')}>
{m.fineTuning()}
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</PopoverContent>
</Popover>
</div>
)
}
@@ -0,0 +1,298 @@
import { invoke } from '@tauri-apps/api/core'
import * as pathApi from '@tauri-apps/api/path'
import * as dialog from '@tauri-apps/plugin-dialog'
import * as fs from '@tauri-apps/plugin-fs'
import { Download, MoreHorizontal, Plus, Search, Settings } from 'lucide-react'
import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { m } from '~/paraglide/messages.js'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '~/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip'
import { cn } from '~/lib/style'
import {
deleteTranscript,
listTranscripts,
readTranscript,
renameTranscript,
resolveProjectAudio,
TRANSCRIPTS_CHANGED_EVENT,
type TranscriptEntry,
} from '~/lib/transcripts-store'
import { openSettingsSection } from '~/lib/app'
import { UpdaterContext } from '~/providers/updater'
import { useSession } from '../session'
/** "just now" / "14m ago" / "3h ago" / "2d ago" / "Aug 19" / "Aug 19, 2024" */
function relativeDate(date: Date) {
const time = date.getTime()
if (!time) return ''
const minutes = Math.floor((Date.now() - time) / 60_000)
if (minutes < 1) return 'just now'
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d ago`
const sameYear = date.getFullYear() === new Date().getFullYear()
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', ...(sameYear ? {} : { year: 'numeric' }) })
}
interface RowMenuState {
/** media to re-transcribe: the original file, else the project folder's copy; null when neither */
sourcePath: string | null
sourceExists: boolean
}
function RecentRow({
entry,
active,
disabled,
onOpen,
onDeleted,
onRenamed,
}: {
entry: TranscriptEntry
active: boolean
disabled: boolean
onOpen: () => void
onDeleted: () => void
onRenamed: () => void
}) {
const { queue } = useSession()
const [menu, setMenu] = useState<RowMenuState>({ sourcePath: null, sourceExists: false })
const [renaming, setRenaming] = useState(false)
const [draftName, setDraftName] = useState(entry.name)
// The list is built from names only, so the media is located lazily — the first time the row's
// menu opens — to decide whether "Re-transcribe" can do anything. The original file wins; the
// project folder's copy keeps re-transcribing possible once the original is gone.
const loadSource = useCallback(async () => {
const record = await readTranscript(entry.path)
if (!record) {
setMenu({ sourcePath: null, sourceExists: false })
return
}
let sourceExists = false
try {
sourceExists = !!record.sourcePath && (await fs.exists(record.sourcePath))
} catch (error) {
console.warn('failed to check source file:', error)
}
if (sourceExists) {
setMenu({ sourcePath: record.sourcePath, sourceExists: true })
return
}
const copy = await resolveProjectAudio(entry.path, record)
setMenu({ sourcePath: copy ?? (record.sourcePath || null), sourceExists: !!copy })
}, [entry.path])
async function reveal() {
try {
await invoke('open_path', { path: await pathApi.dirname(entry.path) })
} catch (error) {
console.warn('failed to reveal transcript:', error)
}
}
async function remove() {
const confirmed = await dialog.ask(`Delete “${entry.name}”? The transcript and its copy of the audio will be removed.`, {
title: 'Delete transcript',
kind: 'warning',
})
if (!confirmed) return
await deleteTranscript(entry.path)
onDeleted()
}
function retranscribe() {
if (!menu.sourcePath || !menu.sourceExists) return
queue.enqueue([{ name: entry.name, path: menu.sourcePath }])
}
async function commitRename() {
const next = draftName.trim()
setRenaming(false)
if (!next || next === entry.name) return
if (await renameTranscript(entry.path, next)) onRenamed()
}
if (renaming) {
return (
<div className="flex items-center rounded-xl bg-muted px-2 py-1.5">
<input
autoFocus
value={draftName}
onChange={(event) => setDraftName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void commitRename()
if (event.key === 'Escape') setRenaming(false)
}}
onBlur={() => void commitRename()}
aria-label="Transcript name"
className="h-7 w-full min-w-0 rounded-lg border border-ring/40 bg-background px-2 text-[13px] text-foreground outline-none"
/>
</div>
)
}
return (
<div className={cn('group relative flex items-center rounded-xl transition-colors duration-150', active ? 'bg-muted' : 'hover:bg-muted/60')}>
<button
type="button"
onClick={onOpen}
disabled={disabled}
title={entry.name}
className="min-w-0 flex-1 cursor-pointer px-3 py-2 text-start disabled:cursor-default disabled:opacity-50">
<p className="truncate text-[13px] font-medium text-foreground">{entry.name}</p>
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{relativeDate(entry.createdAt)}</p>
</button>
<DropdownMenu
onOpenChange={(open) => {
if (open) void loadSource()
}}>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Transcript actions"
className={cn(
'me-1.5 flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-opacity duration-150',
'opacity-0 group-hover:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100 hover:text-foreground',
)}>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem disabled={disabled || !menu.sourceExists} onSelect={retranscribe}>
Re-transcribe
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setDraftName(entry.name)
setRenaming(true)
}}>
Rename
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void reveal()}>{m.showInFolder()}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive" onSelect={() => void remove()}>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
export default function RecentsSidebar() {
const { queue, startNew } = useSession()
const { updateApp, availableUpdate } = useContext(UpdaterContext)
const [entries, setEntries] = useState<TranscriptEntry[]>([])
const [query, setQuery] = useState('')
const refresh = useCallback(() => {
void listTranscripts().then(setEntries)
}, [])
useEffect(() => {
refresh()
// Saves happen inside the queue; deletes happen in other rows. Both announce themselves.
window.addEventListener(TRANSCRIPTS_CHANGED_EVENT, refresh)
return () => window.removeEventListener(TRANSCRIPTS_CHANGED_EVENT, refresh)
}, [refresh])
const filtered = useMemo(() => {
const needle = query.trim().toLowerCase()
if (!needle) return entries
return entries.filter((entry) => entry.name.toLowerCase().includes(needle))
}, [entries, query])
const activePath = queue.selectedJob?.savedPath ?? null
async function open(entry: TranscriptEntry) {
const record = await readTranscript(entry.path)
if (!record) return
// Resolved here rather than in the queue so `hydrate` stays synchronous.
queue.hydrate(record, entry.path, await resolveProjectAudio(entry.path, record))
}
return (
<aside className="flex h-full w-[288px] shrink-0 flex-col border-e border-border">
{/* Titlebar strip: hosts the toggle beside the macOS traffic lights (ChatGPT style). */}
<div data-tauri-drag-region className="h-14 shrink-0" />
{/* Codex-style: the wordmark gets its own row under the titlebar strip. */}
<div className="px-4 pt-0.5 pb-2">
<span className="select-none text-[17px] font-semibold tracking-[-0.03em] text-foreground">{m.appTitle()}</span>
</div>
<div className="px-2">
<button
type="button"
onClick={startNew}
className="flex w-full cursor-pointer items-center gap-2 rounded-xl px-3 py-2 text-[13px] font-medium text-foreground transition-colors duration-150 hover:bg-muted/60">
<Plus className="h-4 w-4 text-muted-foreground" strokeWidth={1.75} />
New transcription
</button>
</div>
{entries.length > 8 && (
<div className="relative mt-2 px-3">
<Search className="pointer-events-none absolute start-6 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search recents"
className="h-8 w-full rounded-full border border-border bg-transparent ps-7 pe-3 text-[13px] text-foreground outline-none placeholder:text-muted-foreground focus:border-ring/40"
/>
</div>
)}
<p className="px-4 pt-4 pb-2 text-[11px] font-medium tracking-[0.08em] text-muted-foreground uppercase">Recents</p>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{filtered.length === 0 ? (
<p className="px-3 py-2 text-[13px] text-muted-foreground">{entries.length === 0 ? 'Transcripts you create appear here' : 'No matches'}</p>
) : (
filtered.map((entry) => (
<RecentRow
key={entry.path}
entry={entry}
active={entry.path === activePath}
// Loading another transcript mid-run would fight the queue for the view.
disabled={queue.running}
onOpen={() => void open(entry)}
onDeleted={refresh}
onRenamed={refresh}
/>
))
)}
</div>
{/* Footer, ChatGPT-style: Settings row with a small update badge when one is waiting. */}
<div className="mt-auto border-t border-border/60 p-2">
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => openSettingsSection('')}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-xl px-3 py-2 text-[13px] font-medium text-foreground transition-colors duration-150 hover:bg-muted/60">
<Settings className="h-4 w-4 text-muted-foreground" strokeWidth={1.75} />
{m.settings()}
</button>
{availableUpdate && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={m.updateVersion()}
onClick={updateApp}
className="me-1 flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground transition-opacity duration-150 hover:opacity-90">
<Download className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{m.updateVersion()}</TooltipContent>
</Tooltip>
)}
</div>
</div>
</aside>
)
}
@@ -0,0 +1,36 @@
import { motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { useSession } from '../session'
import FileQueue from './file-queue'
import TranscriptToolbar from './transcript-toolbar'
import TranscriptView from './transcript-view'
export default function SessionView() {
const { queue } = useSession()
const [query, setQuery] = useState('')
const multiple = queue.jobs.length > 1
const selected = queue.selectedJob
useEffect(() => {
setQuery('')
}, [queue.selectedId])
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
className="flex min-h-0 flex-1">
{multiple && (
<aside className="hidden w-[264px] shrink-0 border-e border-border md:block">
<FileQueue />
</aside>
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<TranscriptToolbar job={selected} query={query} setQuery={setQuery} />
<div className="min-h-0 flex-1">{selected && <TranscriptView job={selected} query={query} />}</div>
</div>
</motion.div>
)
}
@@ -0,0 +1,143 @@
import { AnimatePresence, motion } from 'framer-motion'
import { Check, ChevronDown, Copy, Download, Plus, Search, X, AlignLeft, Braces, Captions, CodeXml, FileText, FileType } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { m } from '~/paraglide/messages.js'
import { Button } from '~/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '~/components/ui/dropdown-menu'
import { cn } from '~/lib/style'
import type { Job } from '../hooks/use-transcribe-queue'
import { exportFormatLabels, exportFormats, useTranscriptExport } from '../hooks/use-transcript-export'
// One glyph per format so the save menu scans visually.
const formatIcons: Record<string, typeof AlignLeft> = {
normal: AlignLeft,
srt: Captions,
vtt: Captions,
html: CodeXml,
pdf: FileText,
json: Braces,
docx: FileType,
}
import { useSession } from '../session'
export default function TranscriptToolbar({ job, query, setQuery }: { job: Job | null; query: string; setQuery: (value: string) => void }) {
const { queue, startNew } = useSession()
const { copy, exportAs } = useTranscriptExport(job?.segments ?? [], job ? { name: job.name, path: job.path } : null)
const [copied, setCopied] = useState(false)
const [searching, setSearching] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
const resetRef = useRef<number | null>(null)
useEffect(() => {
return () => {
if (resetRef.current) window.clearTimeout(resetRef.current)
}
}, [])
useEffect(() => {
if (searching) searchRef.current?.focus()
}, [searching])
async function onCopy() {
await copy()
setCopied(true)
if (resetRef.current) window.clearTimeout(resetRef.current)
resetRef.current = window.setTimeout(() => setCopied(false), 1200)
}
function closeSearch() {
setSearching(false)
setQuery('')
}
const hasText = (job?.segments.length ?? 0) > 0
return (
<div className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-1 border-b border-border bg-background/85 px-4 backdrop-blur-md">
<AnimatePresence initial={false} mode="wait">
{searching ? (
<motion.div
key="search"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
className="flex w-full items-center gap-2">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
ref={searchRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => event.key === 'Escape' && closeSearch()}
placeholder="Search transcript"
className="h-9 w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
/>
<Button variant="ghost" size="iconSm" onClick={closeSearch} className="rounded-full">
<X className="h-4 w-4" />
</Button>
</motion.div>
) : (
<motion.div
key="actions"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
className="flex w-full items-center gap-1">
<Button variant="ghost" size="sm" onClick={onCopy} disabled={!hasText} className="rounded-full px-3 text-[13px] font-medium">
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? m.copied() : m.copy()}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" disabled={!hasText} className="rounded-full px-3 text-[13px] font-medium">
<Download className="h-3.5 w-3.5" />
{m.save()}
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[9rem] rounded-xl">
{exportFormats.map((format) => {
const Icon = formatIcons[format] ?? FileText
return (
<DropdownMenuItem key={format} onSelect={() => void exportAs(format)} className="gap-2.5 rounded-lg text-[13px]">
<Icon className="h-4 w-4 text-muted-foreground" />
{exportFormatLabels[format]}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="sm"
onClick={() => setSearching(true)}
disabled={!hasText}
className="rounded-full px-3 text-[13px] font-medium">
<Search className="h-3.5 w-3.5" />
</Button>
<div className="ms-auto flex items-center gap-1">
{queue.running && (
<Button
variant="ghost"
size="sm"
onClick={queue.cancelAll}
disabled={queue.isAborting}
className={cn('rounded-full px-3 text-[13px] font-medium text-muted-foreground hover:text-destructive')}>
{queue.isAborting ? m.aborting() : m.cancel()}
</Button>
)}
<Button variant="ghost" size="sm" onClick={startNew} className="rounded-full px-3 text-[13px] font-medium">
<Plus className="h-3.5 w-3.5" />
New
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
@@ -0,0 +1,271 @@
import { motion } from 'framer-motion'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { m } from '~/paraglide/messages.js'
import HTMLView from '~/components/html-view'
import { formatTimestamp, type Segment } from '~/lib/transcript'
import { cn } from '~/lib/style'
import { usePreferenceProvider } from '~/providers/preference'
import { useSession } from '../session'
import type { Job } from '../hooks/use-transcribe-queue'
import { PLAYER_SEEK_EVENT } from './player-bar'
/** Segment timestamps are centiseconds (see `formatTimestamp` / `asJson` in lib/transcript). */
const CENTISECONDS_PER_SECOND = 100
function Highlight({ text, query }: { text: string; query: string }) {
if (!query) return <>{text}</>
const parts = text.split(new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'ig'))
return (
<>
{parts.map((part, index) =>
part.toLowerCase() === query.toLowerCase() ? (
<mark key={index} className="rounded bg-primary/15 text-foreground">
{part}
</mark>
) : (
<span key={index}>{part}</span>
),
)}
</>
)
}
/** Typography shared by the static text and the editor, so swapping one for the other is invisible. */
const textClass = 'text-[15px] leading-[1.75] text-foreground'
interface SegmentBlockProps {
segment: Segment
/** index inside `job.segments` — search filtering means it is not the render index */
index: number
query: string
animate: boolean
editable: boolean
editing: boolean
onStartEdit: (index: number, indent: number) => void
onCancel: () => void
onCommit: (index: number, text: string) => void
/** distance from the paragraph's inline start to where the text begins, measured on open */
indent: number
}
function SegmentBlock({ segment, index, query, animate, editable, editing, onStartEdit, onCancel, onCommit, indent }: SegmentBlockProps) {
const paragraphRef = useRef<HTMLParagraphElement>(null)
const markerRef = useRef<HTMLSpanElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Set once the edit ended, so the blur that follows a commit or a cancel does not commit again.
const settledRef = useRef(false)
const [draft, setDraft] = useState('')
const text = segment.text.trim()
function beginEdit() {
if (!editable || editing) return
const paragraph = paragraphRef.current
const marker = markerRef.current
let measured = 0
if (paragraph && marker) {
const box = paragraph.getBoundingClientRect()
const at = marker.getBoundingClientRect()
const rtl = window.getComputedStyle(paragraph).direction === 'rtl'
measured = Math.max(0, Math.round(rtl ? box.right - at.right : at.left - box.left))
}
settledRef.current = false
setDraft(text)
onStartEdit(index, measured)
}
// Focus with the caret at the end, the moment the editor takes over.
useEffect(() => {
if (!editing) return
const textarea = textareaRef.current
if (!textarea) return
textarea.focus()
const end = textarea.value.length
textarea.setSelectionRange(end, end)
return () => {
// Closed from the outside (file switch, search): whatever blur follows must not commit.
settledRef.current = true
}
}, [editing])
function commit() {
if (settledRef.current) return
settledRef.current = true
const next = draft.trim()
// An empty segment would be lost text, not an edit: fall back to what was there.
if (next) onCommit(index, next)
else onCancel()
}
function cancel() {
if (settledRef.current) return
settledRef.current = true
onCancel()
}
function seek() {
window.dispatchEvent(new CustomEvent(PLAYER_SEEK_EVENT, { detail: { seconds: segment.start / CENTISECONDS_PER_SECOND } }))
}
return (
<motion.p
ref={paragraphRef}
initial={animate ? { opacity: 0, y: 4 } : false}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
// The whole line is the edit target — clicking near the text (not just on it) starts editing.
onClick={editable && !editing ? beginEdit : undefined}
className={cn('relative', textClass, editable && !editing && 'cursor-text rounded-sm transition-colors duration-150 hover:bg-muted/40')}>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
seek()
}}
aria-label={`Play from ${formatTimestamp(segment.start, false, '', false)}`}
className="me-3 cursor-pointer font-mono text-[11px] tracking-tight text-muted-foreground tabular-nums underline-offset-4 transition-colors duration-150 select-none hover:text-foreground hover:underline focus-visible:rounded-sm focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/80">
{formatTimestamp(segment.start, false, '', false)}
</button>
{segment.speaker != null && (
<span className="me-2 text-[11px] font-semibold text-muted-foreground">
{m.speakerPrefix()} {segment.speaker + 1}
</span>
)}
{/* Zero-width anchor marking where the text starts, so the editor can indent its first line to match. */}
<span ref={markerRef} aria-hidden className="inline-block w-0 align-baseline" />
{editing ? (
<>
{/* The draft, invisible: it keeps the paragraph exactly as tall as the text being typed. */}
<span aria-hidden className="invisible whitespace-pre-wrap">
{draft || ' '}
</span>
<textarea
ref={textareaRef}
value={draft}
rows={1}
spellCheck={false}
aria-label="Edit line"
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
commit()
} else if (event.key === 'Escape') {
event.preventDefault()
cancel()
}
}}
style={{ textIndent: `${indent}px` }}
className={cn(
'absolute inset-0 m-0 resize-none overflow-hidden border-0 bg-transparent p-0 outline-none focus:outline-none focus:ring-0',
textClass,
)}
/>
</>
) : editable ? (
<span
role="button"
tabIndex={0}
onClick={beginEdit}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
beginEdit()
}
}}
className="cursor-text rounded-sm focus-visible:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/80">
<Highlight text={text} query={query} />
</span>
) : (
<Highlight text={text} query={query} />
)}
</motion.p>
)
}
export default function TranscriptView({ job, query }: { job: Job; query: string }) {
const preference = usePreferenceProvider()
const { queue } = useSession()
const scrollRef = useRef<HTMLDivElement>(null)
const running = job.status === 'running'
const editable = !running && (job.status === 'done' || job.hydrated === true)
const [editing, setEditing] = useState<{ index: number; indent: number } | null>(null)
// Filtering keeps the original index so an edit lands on the right segment.
const visible = useMemo(() => {
const all = job.segments.map((segment, index) => ({ segment, index }))
if (!query) return all
return all.filter(({ segment }) => segment.text.toLowerCase().includes(query.toLowerCase()))
}, [job.segments, query])
// Switching file, searching or a new run must never leave an editor open over other text.
useEffect(() => {
setEditing(null)
}, [job.id, query])
useEffect(() => {
if (!editable) setEditing(null)
}, [editable])
useEffect(() => {
if (!running) return
const node = scrollRef.current
if (node) node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' })
}, [job.segments.length, running])
const startEdit = useCallback((index: number, indent: number) => setEditing({ index, indent }), [])
const cancelEdit = useCallback(() => setEditing(null), [])
const commitEdit = useCallback(
(index: number, text: string) => {
setEditing(null)
queue.updateSegmentText(job.id, index, text)
},
[job.id, queue],
)
return (
<div ref={scrollRef} className="h-full min-h-0 overflow-y-auto">
<div dir={preference.textAreaDirection} className="mx-auto w-full max-w-[68ch] px-8 py-10">
<p className="mb-8 truncate text-[11px] font-medium tracking-[0.08em] text-muted-foreground uppercase">{job.name}</p>
{job.status === 'error' && <p className="mb-8 text-sm text-destructive">{job.error}</p>}
{visible.length === 0 && (
<p className="text-sm text-muted-foreground">
{query ? 'No matching lines.' : running ? m.transcriptWillDisplayedShortly() : job.status === 'queued' ? m.loading() : ''}
</p>
)}
<div className="space-y-6">
{visible.map(({ segment, index }) => (
<SegmentBlock
key={`${segment.start}-${index}`}
segment={segment}
index={index}
query={query}
animate={running}
editable={editable}
editing={editing?.index === index}
indent={editing?.index === index ? editing.indent : 0}
onStartEdit={startEdit}
onCancel={cancelEdit}
onCommit={commitEdit}
/>
))}
</div>
{running && <div className={cn('mt-8 h-4 w-24 animate-pulse rounded-full bg-muted')} />}
</div>
{/* Offscreen source for the html / pdf exports (print CSS pulls `.html` back on-page). */}
{job.segments.length > 0 && (
<div aria-hidden className="pointer-events-none fixed top-0 -left-[10000px] w-[1000px]">
<HTMLView preference={preference} segments={job.segments} file={{ name: job.name, path: job.path }} />
</div>
)}
</div>
)
}
@@ -0,0 +1,71 @@
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
/**
* Drag & drop for the whole window.
*
* The webview delivers real filesystem paths through `tauri://drag-drop`. In browser mock mode
* those events never fire and the HTML5 `File` objects carry no path we could transcribe, so a
* browser drop only explains that the mock dialog (Browse) is the way in.
*/
export function useDropTarget(onPaths: (paths: string[]) => void) {
const [dragging, setDragging] = useState(false)
const onPathsRef = useRef(onPaths)
const browserDepth = useRef(0)
useEffect(() => {
onPathsRef.current = onPaths
}, [onPaths])
useEffect(() => {
const unlisteners: Promise<UnlistenFn>[] = []
unlisteners.push(listen('tauri://drag-enter', () => setDragging(true)))
unlisteners.push(listen('tauri://drag-leave', () => setDragging(false)))
unlisteners.push(
listen<{ paths?: string[] }>('tauri://drag-drop', ({ payload }) => {
setDragging(false)
if (payload.paths?.length) onPathsRef.current(payload.paths)
}),
)
function onDragEnter(event: DragEvent) {
event.preventDefault()
browserDepth.current += 1
setDragging(true)
}
function onDragOver(event: DragEvent) {
event.preventDefault()
}
function onDragLeave(event: DragEvent) {
event.preventDefault()
browserDepth.current = Math.max(0, browserDepth.current - 1)
if (browserDepth.current === 0) setDragging(false)
}
function onDrop(event: DragEvent) {
event.preventDefault()
browserDepth.current = 0
setDragging(false)
const files = Array.from(event.dataTransfer?.files ?? [])
// Only reachable in a plain browser — the webview swallows HTML drops.
if (files.length > 0) {
toast.info(`Browser preview can't read “${files[0].name}” from disk. Use Browse to pick a file.`, { position: 'bottom-center' })
}
}
window.addEventListener('dragenter', onDragEnter)
window.addEventListener('dragover', onDragOver)
window.addEventListener('dragleave', onDragLeave)
window.addEventListener('drop', onDrop)
return () => {
unlisteners.forEach((promise) => promise.then((unlisten) => unlisten()))
window.removeEventListener('dragenter', onDragEnter)
window.removeEventListener('dragover', onDragOver)
window.removeEventListener('dragleave', onDragLeave)
window.removeEventListener('drop', onDrop)
}
}, [])
return dragging
}
@@ -0,0 +1,410 @@
import { invoke } from '@tauri-apps/api/core'
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
import * as webview from '@tauri-apps/api/webviewWindow'
import * as dialog from '@tauri-apps/plugin-dialog'
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import successSound from '~/assets/success.mp3'
import { m } from '~/paraglide/messages.js'
import { analyticsEvents, trackAnalyticsEvent } from '~/lib/analytics'
import * as config from '~/lib/config'
import { startKeepAwake, stopKeepAwake } from '~/lib/keep-awake'
import { validPath } from '~/lib/media'
import { isUserError } from '~/lib/sona-errors'
import type { Segment, Transcript } from '~/lib/transcript'
import { notifyTranscriptsChanged, saveTranscript, updateTranscriptSegments, type TranscriptRecord } from '~/lib/transcripts-store'
import type { NamedPath } from '~/lib/types'
import { ErrorModalContext } from '~/providers/error-modal'
import { type Preference, usePreferenceProvider } from '~/providers/preference'
export type JobStatus = 'queued' | 'running' | 'done' | 'error' | 'cancelled'
export interface Job {
id: string
name: string
path: string
status: JobStatus
/** 0..100, only meaningful while running */
progress: number
segments: Segment[]
error?: string
/** wall clock seconds the transcription took */
seconds?: number
/** path of the .vibe.json this job was saved to / loaded from, when persisted */
savedPath?: string
/** true when the job was loaded from the store rather than transcribed in this session */
hydrated?: boolean
}
export interface TranscribeQueue {
jobs: Job[]
activeId: string | null
selectedId: string | null
selectedJob: Job | null
running: boolean
isAborting: boolean
hasResults: boolean
selectJob: (id: string) => void
enqueue: (files: NamedPath[]) => void
/**
* Replace the session with a transcript loaded from the store, shown as a finished job.
* `audioPath` is the media copy kept in the project folder, when it still has one; it becomes the
* job's path so the player keeps working after the original file moved away.
*/
hydrate: (record: TranscriptRecord, savedPath: string, audioPath?: string | null) => void
/** Inline edit of one segment's text. Persists to the job's saved file when it has one. */
updateSegmentText: (jobId: string, segmentIndex: number, text: string) => void
cancelCurrent: () => void
cancelAll: () => void
reset: () => void
}
let jobCounter = 0
function nextJobId() {
jobCounter += 1
return `job-${jobCounter}`
}
/** Options shared by every file of a run (diarize / vad model paths). */
async function buildSharedOptions(preference: Preference) {
const requiresVad = preference.modelMetadata?.capabilities.requires_vad ?? false
const needsFolder = preference.diarizeEnabled || preference.stableTimestampsEnabled || requiresVad
const modelsFolder = needsFolder ? await invoke<string>('get_models_folder') : null
return {
...(preference.diarizeEnabled ? { diarize_model: `${modelsFolder}/${config.diarizeModelFilename}` } : {}),
...(preference.stableTimestampsEnabled || requiresVad ? { vad_model: `${modelsFolder}/${config.vadModelFilename}` } : {}),
...(preference.stableTimestampsEnabled ? { stable_timestamps: true } : {}),
}
}
function errorParts(error: unknown) {
const object = typeof error === 'object' && error !== null ? (error as { code?: string; message?: string }) : null
return { code: object?.code, message: object?.message || String(error) }
}
/**
* One sequential transcription queue: the model is loaded once, then every queued file runs
* in order. Segments of the currently running file stream in live through `new_segment`.
*/
export function useTranscribeQueue(): TranscribeQueue {
const preference = usePreferenceProvider()
const preferenceRef = useRef(preference)
const { setState: setErrorModal } = useContext(ErrorModalContext)
const [jobs, setJobs] = useState<Job[]>([])
const jobsRef = useRef<Job[]>([])
const [activeId, setActiveId] = useState<string | null>(null)
const activeIdRef = useRef<string | null>(null)
const [selectedId, setSelectedId] = useState<string | null>(null)
const selectedIdRef = useRef<string | null>(null)
const pinnedRef = useRef(false)
const [running, setRunning] = useState(false)
const runningRef = useRef(false)
const [isAborting, setIsAborting] = useState(false)
const abortCurrentRef = useRef(false)
const abortAllRef = useRef(false)
useEffect(() => {
preferenceRef.current = preference
}, [preference])
const commit = useCallback((next: Job[]) => {
jobsRef.current = next
setJobs(next)
}, [])
const patch = useCallback(
(id: string, changes: Partial<Job>) => {
commit(jobsRef.current.map((job) => (job.id === id ? { ...job, ...changes } : job)))
},
[commit],
)
const select = useCallback((id: string | null) => {
selectedIdRef.current = id
setSelectedId(id)
}, [])
const selectJob = useCallback(
(id: string) => {
pinnedRef.current = true
select(id)
},
[select],
)
useEffect(() => {
const unlisteners: Promise<UnlistenFn>[] = []
unlisteners.push(
listen<number>('transcribe_progress', ({ payload }) => {
const id = activeIdRef.current
if (id && payload >= 0 && payload <= 100) patch(id, { progress: payload })
}),
)
unlisteners.push(
listen<Segment>('new_segment', ({ payload }) => {
const id = activeIdRef.current
if (!id) return
commit(jobsRef.current.map((job) => (job.id === id ? { ...job, segments: [...job.segments, payload] } : job)))
}),
)
return () => {
unlisteners.forEach((promise) => promise.then((unlisten) => unlisten()))
}
}, [commit, patch])
const failPending = useCallback(
(message: string) => {
commit(jobsRef.current.map((job) => (job.status === 'queued' || job.status === 'running' ? { ...job, status: 'error', error: message } : job)))
},
[commit],
)
/**
* Auto-save a finished job into the transcripts store. Fire-and-forget on purpose: persistence
* must never delay or break the queue, so failures only surface in the console.
*/
const persist = useCallback(
(job: Job, segments: Segment[]) => {
const current = preferenceRef.current
if (!current.saveTranscripts || segments.length === 0) return
void saveTranscript({
name: job.name,
sourcePath: job.path,
segments,
language: current.modelOptions.lang,
modelPath: current.modelPath,
}).then((savedPath) => {
if (!savedPath) return
patch(job.id, { savedPath })
notifyTranscriptsChanged()
})
},
[patch],
)
const runLoop = useCallback(async () => {
if (runningRef.current) return
runningRef.current = true
setRunning(true)
abortAllRef.current = false
setIsAborting(false)
startKeepAwake()
let completedAny = false
try {
const avx2 = await invoke<boolean>('is_avx2_enabled')
if (!avx2) {
trackAnalyticsEvent(analyticsEvents.AVX2_NOT_SUPPORTED)
await dialog.message(m.avx2NotSupported(), { kind: 'error' })
failPending(m.avx2NotSupported())
return
}
const current = preferenceRef.current
if (!current.modelPath) {
failPending(m.noModelSelected())
toast.error(m.noModelSelected(), { position: 'bottom-center' })
return
}
let shared: Record<string, unknown>
try {
const loadResult = await invoke<string>('load_model', {
modelPath: current.modelPath,
gpuDevice: current.gpuDevice,
unloadTimeoutMinutes: current.unloadTimeoutMinutes,
})
if (loadResult === 'gpu_fallback') toast.warning(m.gpuFallbackToCpu(), { position: 'bottom-center', duration: 8000 })
shared = await buildSharedOptions(current)
} catch (error) {
const { message } = errorParts(error)
failPending(message)
setErrorModal?.({ log: message, open: true })
return
}
while (!abortAllRef.current) {
const next = jobsRef.current.find((job) => job.status === 'queued')
if (!next) break
activeIdRef.current = next.id
setActiveId(next.id)
if (!pinnedRef.current) select(next.id)
patch(next.id, { status: 'running', progress: 0, segments: [], error: undefined })
abortCurrentRef.current = false
const startedAt = performance.now()
trackAnalyticsEvent(analyticsEvents.TRANSCRIBE_STARTED, { source: 'main' })
try {
const result = await invoke<Transcript>('transcribe', {
options: { path: next.path, ...preferenceRef.current.modelOptions, ...shared },
})
const seconds = Math.round((performance.now() - startedAt) / 1000)
patch(next.id, { status: 'done', progress: 100, segments: result.segments, seconds })
completedAny = true
persist(next, result.segments)
trackAnalyticsEvent(analyticsEvents.TRANSCRIBE_SUCCEEDED, {
source: 'main',
duration_seconds: seconds,
segments_count: result.segments.length,
})
} catch (error) {
if (abortCurrentRef.current || abortAllRef.current) {
patch(next.id, { status: 'cancelled', progress: 0 })
} else {
const { code, message } = errorParts(error)
patch(next.id, { status: 'error', progress: 0, error: message })
if (code && isUserError(code)) {
toast.error(`${m.error()}: ${message}`, { position: 'bottom-center' })
} else {
trackAnalyticsEvent(analyticsEvents.TRANSCRIBE_FAILED, {
source: 'main',
error_message: message,
file_ext: next.path.split('.').pop() ?? 'unknown',
})
setErrorModal?.({ log: message, open: true })
// Every following file would fail the same way.
if (message.includes('no model loaded')) {
toast.error(m.noModelLoadedBatchStopped(), { position: 'bottom-center' })
failPending(message)
break
}
}
}
} finally {
abortCurrentRef.current = false
setIsAborting(false)
}
}
if (abortAllRef.current) {
commit(jobsRef.current.map((job) => (job.status === 'queued' ? { ...job, status: 'cancelled' } : job)))
}
} finally {
stopKeepAwake()
activeIdRef.current = null
setActiveId(null)
runningRef.current = false
setRunning(false)
setIsAborting(false)
if (completedAny) {
const finished = preferenceRef.current
if (finished.soundOnFinish) new Audio(successSound).play()
if (finished.focusOnFinish) {
webview.getCurrentWebviewWindow().unminimize()
webview.getCurrentWebviewWindow().setFocus()
}
}
// A file enqueued while the loop was winding down would otherwise stay queued forever.
if (!abortAllRef.current && jobsRef.current.some((job) => job.status === 'queued')) void runLoop()
}
}, [commit, failPending, patch, persist, select, setErrorModal])
const enqueue = useCallback(
(files: NamedPath[]) => {
const accepted = files.filter((file) => validPath(file.path.toLowerCase()))
if (accepted.length === 0) {
if (files.length > 0) toast.error(m.supportsFormats(), { position: 'bottom-center' })
return
}
const created: Job[] = accepted.map((file) => ({
id: nextJobId(),
name: file.name,
path: file.path,
status: 'queued',
progress: 0,
segments: [],
}))
commit([...jobsRef.current, ...created])
if (!selectedIdRef.current) select(created[0].id)
void runLoop()
},
[commit, runLoop, select],
)
/** Load a saved transcript as the whole session: one finished job the done view can render. */
const hydrate = useCallback(
(record: TranscriptRecord, savedPath: string, audioPath?: string | null) => {
if (runningRef.current) return
const job: Job = {
id: nextJobId(),
name: record.name,
// The project folder's own copy of the media, else the original path it came from.
path: audioPath || record.sourcePath,
status: 'done',
progress: 100,
segments: record.segments,
savedPath,
hydrated: true,
}
pinnedRef.current = true
commit([job])
select(job.id)
},
[commit, select],
)
/**
* Replace the text of one segment. The write back to the store is fire-and-forget for the same
* reason `persist` is: an unwritable file must not cost the user their edit on screen.
*/
const updateSegmentText = useCallback(
(jobId: string, segmentIndex: number, text: string) => {
const job = jobsRef.current.find((candidate) => candidate.id === jobId)
const segment = job?.segments[segmentIndex]
if (!job || !segment || segment.text === text) return
const segments = job.segments.map((item, index) => (index === segmentIndex ? { ...item, text } : item))
commit(jobsRef.current.map((candidate) => (candidate.id === jobId ? { ...candidate, segments } : candidate)))
if (job.savedPath) void updateTranscriptSegments(job.savedPath, segments)
},
[commit],
)
const cancelCurrent = useCallback(() => {
if (!activeIdRef.current) return
abortCurrentRef.current = true
setIsAborting(true)
emit('abort_transcribe')
}, [])
const cancelAll = useCallback(() => {
abortAllRef.current = true
abortCurrentRef.current = true
setIsAborting(true)
emit('abort_transcribe')
commit(jobsRef.current.map((job) => (job.status === 'queued' ? { ...job, status: 'cancelled' } : job)))
}, [commit])
const reset = useCallback(() => {
if (runningRef.current) cancelAll()
pinnedRef.current = false
commit([])
select(null)
}, [cancelAll, commit, select])
const selectedJob = jobs.find((job) => job.id === selectedId) ?? null
const hasResults = jobs.some((job) => job.segments.length > 0)
return {
jobs,
activeId,
selectedId,
selectedJob,
running,
isAborting,
hasResults,
selectJob,
enqueue,
hydrate,
updateSegmentText,
cancelCurrent,
cancelAll,
reset,
}
}
@@ -0,0 +1,97 @@
import { path as pathApi } from '@tauri-apps/api'
import { invoke } from '@tauri-apps/api/core'
import * as clipboard from '@tauri-apps/plugin-clipboard-manager'
import * as dialog from '@tauri-apps/plugin-dialog'
import * as fs from '@tauri-apps/plugin-fs'
import { useCallback, useMemo } from 'react'
import { toast } from 'sonner'
import { m } from '~/paraglide/messages.js'
import { formatExtensions, type TextFormat } from '~/components/format-select'
import { openPath } from '~/lib/app'
import { toDocx } from '~/lib/docx'
import { asCsv, asJson, asSrt, asText, asVtt, type Segment } from '~/lib/transcript'
import type { NamedPath } from '~/lib/types'
import { usePreferenceProvider } from '~/providers/preference'
/** Formats offered by the Export dropdown, in menu order. */
export const exportFormats: TextFormat[] = ['normal', 'srt', 'vtt', 'html', 'pdf', 'json', 'docx']
export const exportFormatLabels: Record<TextFormat, string> = {
normal: 'txt',
srt: 'srt',
vtt: 'vtt',
html: 'html',
pdf: 'pdf',
json: 'json',
docx: 'docx',
csv: 'csv',
md: 'md',
}
export function useTranscriptExport(segments: Segment[], file: NamedPath | null) {
const preference = usePreferenceProvider()
const speakerLabel = m.speakerPrefix()
const plainText = useMemo(() => asText(segments, speakerLabel), [segments, speakerLabel])
const asFormat = useCallback(
(format: TextFormat) => {
switch (format) {
case 'srt':
return asSrt(segments, speakerLabel)
case 'vtt':
return asVtt(segments, speakerLabel)
case 'json':
return asJson(segments)
case 'csv':
return asCsv(segments)
case 'html':
// The reading view keeps an offscreen HTMLView mounted for exactly this.
return document.querySelector('.html')?.outerHTML.replace('contenteditable="true"', 'contenteditable="false"') ?? plainText
default:
return plainText
}
},
[plainText, segments, speakerLabel],
)
const copy = useCallback(async () => {
await clipboard.writeText(plainText)
}, [plainText])
const exportAs = useCallback(
async (format: TextFormat) => {
if (!file || segments.length === 0) return
if (format === 'pdf') {
window.print()
return
}
const extension = formatExtensions[format].slice(1)
const suggested = await invoke<NamedPath>('get_save_path', { srcPath: file.path, targetExt: extension })
const target = await dialog.save({
filters: [{ name: '', extensions: [extension] }],
canCreateDirectories: true,
defaultPath: suggested.path,
})
if (!target) return
if (format === 'docx') {
const title = await pathApi.basename(target)
const document = await toDocx(title, segments, preference.textAreaDirection, speakerLabel)
await fs.writeFile(target, new Uint8Array(await document.arrayBuffer()))
} else {
await fs.writeTextFile(target, asFormat(format))
}
toast.success(m.saveSuccess(), {
description: suggested.name,
position: 'bottom-center',
action: { label: m.findHere(), onClick: () => openPath({ name: '', path: target }) },
})
},
[asFormat, file, preference.textAreaDirection, segments, speakerLabel],
)
return { plainText, copy, exportAs }
}
+145
View File
@@ -0,0 +1,145 @@
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useRef, useState } from 'react'
import Layout, { SIDEBAR_STORAGE_KEY, TOGGLE_SIDEBAR_EVENT } from '~/components/layout'
import IdleHero from './components/idle-hero'
import PlayerBar from './components/player-bar'
import RecentsSidebar from './components/recents-sidebar'
import SessionView from './components/session-view'
import { SessionProvider, useSession } from './session'
/** Collapsed = hidden entirely; the state lives in localStorage so it survives a reload. */
function useSidebarVisible() {
const [visible, setVisible] = useState(() => {
try {
return window.localStorage.getItem(SIDEBAR_STORAGE_KEY) === 'expanded'
} catch {
return false
}
})
useEffect(() => {
function toggle() {
setVisible((previous) => {
const next = !previous
try {
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, next ? 'expanded' : 'collapsed')
} catch {
/* private mode — the toggle still works for this session */
}
return next
})
}
window.addEventListener(TOGGLE_SIDEBAR_EVENT, toggle)
return () => window.removeEventListener(TOGGLE_SIDEBAR_EVENT, toggle)
}, [])
return visible
}
function MainContent() {
const { mode, dragging } = useSession()
return (
<div className="relative flex h-full min-h-0 w-full min-w-0 flex-col">
<AnimatePresence mode="wait" initial={false}>
{mode === 'idle' ? (
<motion.div
key="idle"
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
className="flex min-h-0 flex-1 flex-col">
<IdleHero />
</motion.div>
) : (
<motion.div
key="session"
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
className="flex min-h-0 flex-1 flex-col">
<SessionView />
</motion.div>
)}
</AnimatePresence>
{/* Dropping onto a running session appends to the queue — say so. */}
<AnimatePresence>
{dragging && mode !== 'idle' && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
className="pointer-events-none absolute inset-3 rounded-[1.25rem] border-2 border-dashed border-ring/40 bg-background/60 backdrop-blur-[2px]">
<div className="flex h-full items-center justify-center">
<p className="text-sm font-medium text-muted-foreground">Drop to add to the queue</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
/**
* Sidebar visibility: user toggle everywhere; entering a multi-file (batch) session
* closes it ONCE (its own file rail takes the space) but the toggle still reopens it.
*/
function useShowSidebar() {
const sidebarVisible = useSidebarVisible()
const { queue } = useSession()
const batch = queue.jobs.length > 1
const wasBatch = useRef(batch)
const visibleRef = useRef(sidebarVisible)
visibleRef.current = sidebarVisible
useEffect(() => {
if (batch && !wasBatch.current && visibleRef.current) {
window.dispatchEvent(new CustomEvent(TOGGLE_SIDEBAR_EVENT))
}
wasBatch.current = batch
}, [batch])
return sidebarVisible
}
/** Full-width bottom player, ElevenLabs style: spans the whole window under sidebar and content. */
function PlayerSlot() {
const { queue } = useSession()
const selected = queue.selectedJob
if (!selected || (selected.status !== 'done' && !selected.hydrated)) return null
return <PlayerBar key={selected.id} job={selected} />
}
function Shell() {
const showSidebar = useShowSidebar()
return (
<Layout
bottomBar={<PlayerSlot />}
sidebar={
<AnimatePresence initial={false}>
{showSidebar && (
<motion.div
key="recents"
initial={{ opacity: 0, width: 0 }}
animate={{ opacity: 1, width: 'auto' }}
exit={{ opacity: 0, width: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="flex min-h-0 overflow-hidden bg-background/90 dark:bg-background/55">
<RecentsSidebar />
</motion.div>
)}
</AnimatePresence>
}>
<MainContent />
</Layout>
)
}
export default function MainPage() {
return (
<SessionProvider>
<Shell />
</SessionProvider>
)
}
+218
View File
@@ -0,0 +1,218 @@
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { path as pathApi } from '@tauri-apps/api'
import { webviewWindow } from '@tauri-apps/api'
import * as dialog from '@tauri-apps/plugin-dialog'
import * as fs from '@tauri-apps/plugin-fs'
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { m } from '~/paraglide/messages.js'
import * as config from '~/lib/config'
import { ls, pathToNamedPath } from '~/lib/fs'
import { isModelFile } from '~/lib/model'
import type { NamedPath } from '~/lib/types'
import { useConfirmExit } from '~/lib/use-confirm-exit'
import { hotkeyRecordingActive } from '~/providers/hotkey'
import { usePreferenceProvider, type Preference } from '~/providers/preference'
import { useAudioDownload } from '~/pages/home/hooks/use-audio-download'
import { useRecording } from '~/pages/home/hooks/use-recording'
import { useDropTarget } from './hooks/use-drop-target'
import { useTranscribeQueue, type TranscribeQueue } from './hooks/use-transcribe-queue'
export type SessionMode = 'idle' | 'working' | 'done'
export type IdlePanel = 'none' | 'record' | 'link'
type Recording = ReturnType<typeof useRecording>
type AudioDownload = ReturnType<typeof useAudioDownload>
export interface Session {
mode: SessionMode
queue: TranscribeQueue
preference: Preference
dragging: boolean
panel: IdlePanel
setPanel: (panel: IdlePanel) => void
recording: Recording
recordElapsed: number
link: AudioDownload
collectingFolder: boolean
browse: () => Promise<void>
startNew: () => void
}
const SessionContext = createContext<Session | null>(null)
export function useSession() {
const session = useContext(SessionContext)
if (!session) throw new Error('useSession must be used inside <SessionProvider>')
return session
}
const mediaExtensions = [...config.audioExtensions, ...config.videoExtensions]
export function SessionProvider({ children }: { children: ReactNode }) {
const navigate = useNavigate()
const preference = usePreferenceProvider()
const queue = useTranscribeQueue()
const [panel, setPanel] = useState<IdlePanel>('none')
const [collectingFolder, setCollectingFolder] = useState(false)
const [recordElapsed, setRecordElapsed] = useState(0)
const enqueueRef = useRef(queue.enqueue)
useEffect(() => {
enqueueRef.current = queue.enqueue
}, [queue.enqueue])
const enqueuePaths = useCallback(
async (paths: string[]) => {
const files: NamedPath[] = []
for (const item of paths) {
// A picked or dropped path may be a folder — detect and expand to its media files.
const isMediaFile = mediaExtensions.some((ext) => item.toLowerCase().endsWith(`.${ext.toLowerCase()}`))
if (isMediaFile) {
files.push(await pathToNamedPath(item))
continue
}
setCollectingFolder(true)
try {
const expanded = await invoke<string[]>('glob_files', {
folder: item,
patterns: mediaExtensions,
recursive: preference.advancedTranscribeOptions.includeSubFolders,
})
for (const path of expanded) files.push(await pathToNamedPath(path))
} catch {
files.push(await pathToNamedPath(item))
} finally {
setCollectingFolder(false)
}
}
if (files.length) enqueueRef.current(files)
},
[preference.advancedTranscribeOptions.includeSubFolders],
)
const dragging = useDropTarget(
useCallback(
(paths: string[]) => {
setPanel('none')
void enqueuePaths(paths)
},
[enqueuePaths],
),
)
/** Adapter for hooks that hand us a single produced file (recording / downloaded audio). */
const transcribeOne = useCallback(async (filePath: string) => {
await enqueuePaths([filePath])
}, [])
const recording = useRecording(() => setPanel('record'))
const link = useAudioDownload(transcribeOne)
useEffect(() => {
if (!recording.isRecording) {
setRecordElapsed(0)
return
}
setRecordElapsed(0)
const startedAt = Date.now()
const timer = window.setInterval(() => setRecordElapsed(Math.floor((Date.now() - startedAt) / 1000)), 500)
return () => window.clearInterval(timer)
}, [recording.isRecording])
// Recording finished in the backend -> the produced file goes straight into the queue.
useEffect(() => {
const unlisten: Promise<UnlistenFn> = listen<{ path: string; name: string }>('record_finish', ({ payload }) => {
if (hotkeyRecordingActive) return
recording.setIsRecording(false)
setPanel('none')
enqueueRef.current([{ name: payload.name, path: payload.path }])
})
return () => {
unlisten.then((fn) => fn())
}
}, [recording.setIsRecording])
const browse = useCallback(async () => {
const selected = await dialog.open({ multiple: true, filters: [{ name: 'Audio or Video files', extensions: mediaExtensions }] })
if (!selected) return
setPanel('none')
await enqueuePaths(Array.isArray(selected) ? selected : [selected])
}, [enqueuePaths])
const startNew = useCallback(() => {
queue.reset()
setPanel('none')
}, [queue])
// Same guards the old home page ran on mount.
useEffect(() => {
async function checkIfCrashedRecently() {
try {
if (await invoke<boolean>('is_crashed_recently')) {
dialog.message(m.crashedRecently())
await invoke('rename_crash_file')
}
} catch (error) {
console.error(error)
}
}
async function checkModelExists() {
try {
const modelsFolder = await invoke<string>('get_models_folder')
const models = (await ls(modelsFolder)).filter((entry) => isModelFile(entry.name))
if (models.length === 0) {
preference.setModelPath(null)
if (!preference.skippedSetup) navigate('/setup')
return
}
if (!preference.modelPath || !(await fs.exists(preference.modelPath))) {
preference.setModelPath(await pathApi.join(modelsFolder, models[0].name))
}
} catch (error) {
console.error(error)
navigate('/setup')
}
}
async function showWindow() {
const currentWindow = webviewWindow.getCurrentWebviewWindow()
await currentWindow.show()
if (import.meta.env.PROD) await currentWindow.setFocus()
}
void checkIfCrashedRecently()
void checkModelExists()
void showWindow()
}, [])
// Results persist the moment they exist, so closing only warns when something would
// actually be lost: a run in flight, or finished results that never reached disk
// (saving disabled or failed).
const hasUnsavedResults = queue.jobs.some((job) => job.status === 'done' && !job.hydrated && !job.savedPath)
useConfirmExit(queue.running || hasUnsavedResults)
const mode: SessionMode = queue.jobs.length === 0 ? 'idle' : queue.running || queue.jobs.some((job) => job.status === 'queued') ? 'working' : 'done'
const value = useMemo<Session>(
() => ({
mode,
queue,
preference,
dragging,
panel,
setPanel,
recording,
recordElapsed,
link,
collectingFolder,
browse,
startNew,
}),
[mode, queue, preference, dragging, panel, recording, recordElapsed, link, collectingFolder, browse, startNew],
)
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
}
+1 -1
View File
@@ -74,7 +74,7 @@ export default function SettingsPage({ setVisible, scrollTo }: SettingsPageProps
<div className="flex min-h-screen items-center justify-center p-6">
<div
onMouseDown={(event) => event.stopPropagation()}
className="flex h-[640px] w-full max-w-3xl overflow-hidden rounded-2xl border border-border/60 bg-card shadow-2xl">
className="flex h-[640px] max-h-[calc(100dvh-3rem)] w-full max-w-3xl overflow-hidden rounded-2xl border border-border/60 bg-card shadow-2xl">
<div className="flex w-56 shrink-0 flex-col border-r border-border/55 bg-muted/40 p-3">
<div className="mb-2 flex items-center justify-between px-1 pb-1">
<span className="text-sm font-semibold">{m.settings()}</span>
@@ -1,80 +1,44 @@
import { m } from '~/paraglide/messages.js'
import { InfoTooltip } from '~/components/info-tooltip'
import { Button } from '~/components/ui/button'
import { Switch } from '~/components/ui/switch'
import { Input } from '~/components/ui/input'
import { ReactComponent as CopyIcon } from '~/icons/copy.svg'
import { ReactComponent as FolderIcon } from '~/icons/folder.svg'
import { ReactComponent as ResetIcon } from '~/icons/reset.svg'
import { SectionCard, type SettingsViewModel } from './shared'
import { ActionRow, SettingsGroup, SettingsRow, rowControlClass, type SettingsViewModel } from './shared'
export function AdvancedSection({ vm }: { vm: SettingsViewModel }) {
return (
<div className="space-y-5">
<div className="space-y-2">
<div className="flex items-center gap-1 px-1">
<InfoTooltip text={m.ytdlpOptionsInfo()} />
<span className="text-sm font-semibold text-foreground/95">{m.ytdlpOptions()}</span>
</div>
<SectionCard>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-medium">{m.checkYtdlpUpdates()}</span>
<Switch checked={vm.preference.shouldCheckYtDlpVersion} onCheckedChange={vm.preference.setShouldCheckYtDlpVersion} />
</div>
</SectionCard>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1 px-1">
<InfoTooltip text={`${m.unloadModelAfterInactivityInfo()} ${m.zeroMeansNever()}`} />
<span className="text-sm font-semibold text-foreground/95">{m.modelMemory()}</span>
</div>
<SectionCard>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">{m.unloadModelAfterInactivity()}</span>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
max={1440}
step={1}
value={vm.preference.unloadTimeoutMinutes}
onChange={(event) => {
const minutes = Number(event.target.value)
if (Number.isFinite(minutes)) vm.preference.setUnloadTimeoutMinutes(Math.min(1440, Math.max(0, Math.floor(minutes))))
}}
className="h-6 w-20 rounded-lg px-2 py-0 text-right"
/>
<span className="text-sm text-muted-foreground">{m.minutes()}</span>
</div>
</div>
</SectionCard>
</div>
<div className="divide-y divide-border/45 rounded-2xl border border-border/60 bg-card/92 shadow-xs">
<Button
variant="ghost"
onMouseDown={vm.copyLogs}
className="h-12 w-full justify-between rounded-none px-4 font-medium first:rounded-t-2xl last:rounded-b-2xl hover:bg-accent/55">
{m.copyLogs()} <CopyIcon className="h-4 w-4 text-muted-foreground" />
</Button>
<Button
variant="ghost"
onMouseDown={vm.revealLogs}
className="h-12 w-full justify-between rounded-none px-4 font-medium first:rounded-t-2xl last:rounded-b-2xl hover:bg-accent/55">
{m.logsFolder()} <FolderIcon className="h-4 w-4 text-muted-foreground" />
</Button>
<Button
variant="ghost"
onMouseDown={vm.revealTemp}
className="h-12 w-full justify-between rounded-none px-4 font-medium first:rounded-t-2xl last:rounded-b-2xl hover:bg-accent/55">
{m.tempFolder()} <FolderIcon className="h-4 w-4 text-muted-foreground" />
</Button>
<Button
variant="ghost"
onClick={vm.askAndReset}
className="h-12 w-full justify-between rounded-none px-4 font-medium text-destructive first:rounded-t-2xl last:rounded-b-2xl hover:bg-destructive/12 hover:text-destructive">
{m.resetApp()} <ResetIcon className="h-5 w-5" />
</Button>
</div>
<div className="space-y-6">
<SettingsGroup title={m.ytdlpOptions()}>
<SettingsRow label={m.checkYtdlpUpdates()} description={m.ytdlpOptionsInfo()}>
<Switch checked={vm.preference.shouldCheckYtDlpVersion} onCheckedChange={vm.preference.setShouldCheckYtDlpVersion} />
</SettingsRow>
</SettingsGroup>
<SettingsGroup title={m.modelMemory()}>
<SettingsRow label={m.unloadModelAfterInactivity()} description={`${m.unloadModelAfterInactivityInfo()} ${m.zeroMeansNever()}`}>
<Input
type="number"
min={0}
max={1440}
step={1}
value={vm.preference.unloadTimeoutMinutes}
onChange={(event) => {
const minutes = Number(event.target.value)
if (Number.isFinite(minutes)) vm.preference.setUnloadTimeoutMinutes(Math.min(1440, Math.max(0, Math.floor(minutes))))
}}
className={`w-24 text-end ${rowControlClass}`}
/>
<span className="text-sm text-muted-foreground">{m.minutes()}</span>
</SettingsRow>
</SettingsGroup>
<SettingsGroup>
<ActionRow label={m.copyLogs()} icon={<CopyIcon className="h-4 w-4" />} onClick={vm.copyLogs} />
<ActionRow label={m.logsFolder()} icon={<FolderIcon className="h-4 w-4" />} onClick={vm.revealLogs} />
<ActionRow label={m.tempFolder()} icon={<FolderIcon className="h-4 w-4" />} onClick={vm.revealTemp} />
<ActionRow label={m.resetApp()} icon={<ResetIcon className="h-4 w-4" />} onClick={vm.askAndReset} destructive activateOnClick />
</SettingsGroup>
</div>
)
}
+24 -49
View File
@@ -3,61 +3,36 @@ import { Bot } from 'lucide-react'
import { m } from '~/paraglide/messages.js'
import { ReactComponent as CopyIcon } from '~/icons/copy.svg'
import { ReactComponent as LinkIcon } from '~/icons/link.svg'
import { Button } from '~/components/ui/button'
import { SectionCard, type SettingsViewModel } from './shared'
import { Switch } from '~/components/ui/switch'
import { ActionRow, SettingsGroup, SettingsRow, type SettingsViewModel } from './shared'
export function ApiSection({ vm }: { vm: SettingsViewModel }) {
const apiDocsUrl = vm.apiBaseUrl ? `${vm.apiBaseUrl}/docs` : null
const serverActionBusy = vm.isStartingApiServer || vm.isStoppingApiServer
return (
<div className="space-y-5">
<p className="px-1 text-sm text-muted-foreground">{m.apiAgentsDescription()}</p>
<SectionCard>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2.5">
<span
className={`h-2 w-2 rounded-full ${vm.apiBaseUrl ? 'bg-emerald-500 shadow-[0_0_0_4px_rgba(16,185,129,0.18)]' : 'bg-muted-foreground/40 shadow-[0_0_0_4px_rgba(148,163,184,0.12)]'}`}
/>
<div>
<p className="text-sm font-semibold">{vm.apiBaseUrl ? m.apiServerRunning() : m.apiServerOff()}</p>
{vm.apiBaseUrl && <p className="text-xs text-muted-foreground">{vm.apiBaseUrl}</p>}
</div>
</div>
<Button
variant={vm.apiBaseUrl ? 'outline' : 'default'}
size="sm"
className="rounded-lg"
onMouseDown={vm.apiBaseUrl ? vm.stopApiServer : vm.startApiServer}
disabled={serverActionBusy}>
{vm.isStartingApiServer ? m.apiStarting() : vm.isStoppingApiServer ? m.apiStopping() : vm.apiBaseUrl ? m.stop() : m.start()}
</Button>
</div>
</SectionCard>
const statusDescription = vm.isStartingApiServer ? m.apiStarting() : vm.isStoppingApiServer ? m.apiStopping() : (vm.apiBaseUrl ?? undefined)
<div
className={`divide-y divide-border/45 rounded-2xl border border-border/60 bg-card/92 shadow-xs ${!vm.apiBaseUrl ? 'pointer-events-none opacity-50' : ''}`}>
<Button
variant="ghost"
onMouseDown={() => (apiDocsUrl ? openUrl(apiDocsUrl) : null)}
return (
<div className="space-y-6">
<SettingsGroup description={m.apiAgentsDescription()}>
<SettingsRow label={vm.apiBaseUrl ? m.apiServerRunning() : m.apiServerOff()} description={statusDescription}>
<Switch
checked={Boolean(vm.apiBaseUrl)}
disabled={serverActionBusy}
onCheckedChange={(checked) => (checked ? vm.startApiServer() : vm.stopApiServer())}
/>
</SettingsRow>
</SettingsGroup>
<SettingsGroup className={!vm.apiBaseUrl ? 'pointer-events-none opacity-50' : undefined}>
<ActionRow
label={m.swaggerDocs()}
icon={<LinkIcon className="h-4 w-4" />}
disabled={!apiDocsUrl}
className="h-12 w-full justify-between rounded-none px-4 font-medium first:rounded-t-2xl last:rounded-b-2xl hover:bg-accent/55">
{m.swaggerDocs()} <LinkIcon className="h-4 w-4 text-muted-foreground" />
</Button>
<Button
variant="ghost"
onMouseDown={vm.copyCurlExample}
disabled={!vm.apiBaseUrl}
className="h-12 w-full justify-between rounded-none px-4 font-medium first:rounded-t-2xl last:rounded-b-2xl hover:bg-accent/55">
{m.copyCurlExample()} <CopyIcon className="h-4 w-4 text-muted-foreground" />
</Button>
<Button
variant="ghost"
onMouseDown={vm.copyAgentSkill}
disabled={!vm.apiBaseUrl}
className="h-12 w-full justify-between rounded-none px-4 font-medium first:rounded-t-2xl last:rounded-b-2xl hover:bg-accent/55">
{m.copyAgentSkill()} <Bot className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
onClick={() => (apiDocsUrl ? openUrl(apiDocsUrl) : null)}
/>
<ActionRow label={m.copyCurlExample()} icon={<CopyIcon className="h-4 w-4" />} disabled={!vm.apiBaseUrl} onClick={vm.copyCurlExample} />
<ActionRow label={m.copyAgentSkill()} icon={<Bot className="h-4 w-4" />} disabled={!vm.apiBaseUrl} onClick={vm.copyAgentSkill} />
</SettingsGroup>
</div>
)
}
@@ -1,12 +1,29 @@
import { useEffect, useMemo, useState } from 'react'
import { m } from '~/paraglide/messages.js'
import { InfoTooltip } from '~/components/info-tooltip'
import { Input } from '~/components/ui/input'
import { Switch } from '~/components/ui/switch'
import { useHotkeyProvider, type HotkeyActivationMode, type HotkeyOutputMode } from '~/providers/hotkey'
import { Field, SectionCard } from './shared'
import { SettingsGroup, SettingsRow, rowControlClass } from './shared'
import { getDictationIndicatorEnabled, setDictationIndicatorEnabled } from '~/lib/dictation-indicator'
function SegmentedControl<T extends string>({ value, options, onChange }: { value: T; options: { value: T; label: string }[]; onChange: (v: T) => void }) {
return (
<div className="flex items-center gap-1 rounded-lg border border-border/65 bg-muted/40 p-0.5">
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={`h-8 cursor-pointer rounded-md px-2.5 text-xs font-medium transition-colors ${
value === option.value ? 'bg-card text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'
}`}>
{option.label}
</button>
))}
</div>
)
}
export function DictationSection() {
const hotkey = useHotkeyProvider()
const [indicatorEnabled, setIndicatorEnabled] = useState(true)
@@ -23,18 +40,18 @@ export function DictationSection() {
}
}
const isMac = navigator.platform.toUpperCase().includes('MAC')
const activationLabels = {
'push-to-talk': m.hotkeyActivationPushToTalk,
toggle: m.hotkeyActivationToggle,
} as const
const activationDescriptions = {
'push-to-talk': m.hotkeyActivationPushToTalkDescription,
toggle: m.hotkeyActivationToggleDescription,
} as const
const outputLabels = {
clipboard: m.hotkeyOutputClipboard,
type: m.hotkeyOutputType,
} as const
const activationOptions: { value: HotkeyActivationMode; label: string }[] = [
{ value: 'push-to-talk', label: m.hotkeyActivationPushToTalk() },
{ value: 'toggle', label: m.hotkeyActivationToggle() },
]
const outputOptions: { value: HotkeyOutputMode; label: string }[] = [
{ value: 'clipboard', label: m.hotkeyOutputClipboard() },
{ value: 'type', label: m.hotkeyOutputType() },
]
const shortcutKeys = useMemo(() => {
const keyMap: Record<string, string> = {
CmdOrCtrl: isMac ? '⌘' : 'Ctrl',
@@ -46,90 +63,55 @@ export function DictationSection() {
}
return hotkey.hotkeyShortcut.split('+').map((key) => keyMap[key] ?? key)
}, [hotkey.hotkeyShortcut, isMac])
return (
<div className="space-y-5">
<p className="px-1 text-sm text-muted-foreground">{m.globalDictationPromo()}</p>
<SectionCard>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{m.globalHotkeyEnabled()}</span>
<Switch checked={hotkey.hotkeyEnabled} onCheckedChange={hotkey.setHotkeyEnabled} />
</div>
<div className="space-y-6">
<SettingsGroup description={m.globalDictationPromo()}>
<SettingsRow label={m.globalHotkeyEnabled()}>
<Switch checked={hotkey.hotkeyEnabled} onCheckedChange={hotkey.setHotkeyEnabled} />
</SettingsRow>
{hotkey.hotkeyEnabled && (
<>
<div className="flex items-center justify-between gap-3">
<span className="flex items-center gap-1 text-sm font-medium">
<InfoTooltip text={m.dictationIndicatorSettingInfo()} />
{m.dictationIndicatorSetting()}
</span>
<Switch checked={indicatorEnabled} onCheckedChange={changeIndicatorEnabled} />
</div>
<div className="h-px bg-border/45" />
<Field label={m.hotkeyActivationMode()}>
<div className="flex gap-2">
{(['push-to-talk', 'toggle'] as HotkeyActivationMode[]).map((mode) => (
<button
key={mode}
type="button"
onClick={() => hotkey.setHotkeyActivationMode(mode)}
className={`flex-1 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
hotkey.hotkeyActivationMode === mode
? 'border-primary bg-primary/10 text-primary'
: 'border-border/65 bg-background/50 text-muted-foreground hover:bg-accent/40'
}`}>
{activationLabels[mode]()}
</button>
{hotkey.hotkeyEnabled && (
<>
<SettingsRow label={m.dictationIndicatorSetting()} description={m.dictationIndicatorSettingInfo()}>
<Switch checked={indicatorEnabled} onCheckedChange={changeIndicatorEnabled} />
</SettingsRow>
<SettingsRow label={m.hotkeyActivationMode()} description={activationDescriptions[hotkey.hotkeyActivationMode]()}>
<SegmentedControl value={hotkey.hotkeyActivationMode} options={activationOptions} onChange={hotkey.setHotkeyActivationMode} />
</SettingsRow>
<SettingsRow
label={m.globalHotkeyShortcut()}
description={
<span className="flex items-center gap-1">
{shortcutKeys.map((key, i) => (
<kbd
key={i}
className="inline-flex h-5 min-w-5 items-center justify-center rounded-md border border-border/80 bg-muted/50 px-1 font-mono text-[10px] font-medium text-foreground/80">
{key}
</kbd>
))}
</div>
</Field>
<Field
label={
<span className="flex items-center gap-2">
{m.globalHotkeyShortcut()}
<span className="flex items-center gap-1">
{shortcutKeys.map((key, i) => (
<kbd
key={i}
className="inline-flex h-6 min-w-6 items-center justify-center rounded-md border border-border/80 bg-background/70 px-1.5 font-mono text-[11px] font-medium text-foreground/80 shadow-[0_1px_0_1px_rgba(0,0,0,0.04)]">
{key}
</kbd>
))}
</span>
</span>
}>
<Input type="text" value={hotkey.hotkeyShortcut} onChange={(e) => hotkey.setHotkeyShortcut(e.target.value)} />
</Field>
<div className="flex gap-2">
{(['clipboard', 'type'] as HotkeyOutputMode[]).map((mode) => (
<button
key={mode}
type="button"
onClick={() => hotkey.setHotkeyOutputMode(mode)}
className={`flex-1 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
hotkey.hotkeyOutputMode === mode
? 'border-primary bg-primary/10 text-primary'
: 'border-border/65 bg-background/50 text-muted-foreground hover:bg-accent/40'
}`}>
{outputLabels[mode]()}
</button>
))}
</div>
<p className="text-xs italic text-muted-foreground">{activationDescriptions[hotkey.hotkeyActivationMode]()}</p>
<div className="h-px bg-border/45" />
<div className="flex items-center justify-between gap-3">
<span className="flex items-center gap-1 text-sm font-medium">
<InfoTooltip text={m.normalizeHotkeyOutputInfo()} />
{m.normalizeHotkeyOutput()}
</span>
<Switch checked={hotkey.hotkeyNormalizeOutput} onCheckedChange={hotkey.setHotkeyNormalizeOutput} />
</div>
</>
)}
</div>
</SectionCard>
}>
<Input
type="text"
value={hotkey.hotkeyShortcut}
onChange={(e) => hotkey.setHotkeyShortcut(e.target.value)}
className={`w-48 ${rowControlClass}`}
/>
</SettingsRow>
<SettingsRow label={m.hotkeyOutputMode()}>
<SegmentedControl value={hotkey.hotkeyOutputMode} options={outputOptions} onChange={hotkey.setHotkeyOutputMode} />
</SettingsRow>
<SettingsRow label={m.normalizeHotkeyOutput()} description={m.normalizeHotkeyOutputInfo()}>
<Switch checked={hotkey.hotkeyNormalizeOutput} onCheckedChange={hotkey.setHotkeyNormalizeOutput} />
</SettingsRow>
</>
)}
</SettingsGroup>
</div>
)
}
+44 -66
View File
@@ -7,79 +7,57 @@ import { ReactComponent as HeartIcon } from '~/icons/heart.svg'
import { ReactComponent as LinkIcon } from '~/icons/link.svg'
import * as config from '~/lib/config'
import { getLocalizedLanguageName, supportedLanguages } from '~/lib/i18n'
import { Button } from '~/components/ui/button'
import { Label } from '~/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select'
import { SectionCard, type SettingsViewModel } from './shared'
import { ActionRow, SettingsGroup, SettingsRow, rowControlClass, type SettingsViewModel } from './shared'
export function GeneralSection({ vm }: { vm: SettingsViewModel }) {
const themeLabels = { light: m.light, dark: m.dark } as const
return (
<div className="space-y-5">
<SectionCard>
<div className="grid grid-cols-1 divide-y divide-border/45 sm:grid-cols-2 sm:divide-x sm:divide-y-0">
<div className="space-y-2 sm:pe-5">
<Label>{m.language()}</Label>
<Select
value={supportedLanguages[vm.preference.displayLanguage] ? vm.preference.displayLanguage : 'en-US'}
onValueChange={vm.preference.setDisplayLanguage}>
<SelectTrigger className="capitalize">
<SelectValue placeholder={m.selectLanguage()} />
</SelectTrigger>
<SelectContent>
{Object.entries(supportedLanguages).map(([code, name]) => (
<SelectItem key={code} value={code} className="capitalize">
{code === getLocale() ? getLocalizedLanguageName(name) : name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 pt-4 sm:ps-5 sm:pt-0">
<Label>{m.theme()}</Label>
<Select value={vm.preference.theme} onValueChange={(value) => vm.preference.setTheme(value as 'light' | 'dark')}>
<SelectTrigger className="capitalize">
<SelectValue placeholder={m.selectTheme()} />
</SelectTrigger>
<SelectContent>
{config.themes.map((theme) => (
<SelectItem key={theme} value={theme} className="capitalize">
{themeLabels[theme as keyof typeof themeLabels]()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</SectionCard>
<div className="space-y-6">
<SettingsGroup>
<SettingsRow label={m.language()}>
<Select
value={supportedLanguages[vm.preference.displayLanguage] ? vm.preference.displayLanguage : 'en-US'}
onValueChange={vm.preference.setDisplayLanguage}>
<SelectTrigger className={`w-52 capitalize ${rowControlClass}`}>
<SelectValue placeholder={m.selectLanguage()} />
</SelectTrigger>
<SelectContent>
{Object.entries(supportedLanguages).map(([code, name]) => (
<SelectItem key={code} value={code} className="capitalize">
{code === getLocale() ? getLocalizedLanguageName(name) : name}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsRow>
<SettingsRow label={m.theme()}>
<Select value={vm.preference.theme} onValueChange={(value) => vm.preference.setTheme(value as 'light' | 'dark')}>
<SelectTrigger className={`w-36 capitalize ${rowControlClass}`}>
<SelectValue placeholder={m.selectTheme()} />
</SelectTrigger>
<SelectContent>
{config.themes.map((theme) => (
<SelectItem key={theme} value={theme} className="capitalize">
{themeLabels[theme as keyof typeof themeLabels]()}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsRow>
</SettingsGroup>
<div className="divide-y divide-border/45 rounded-2xl border border-border/60 bg-card/92 shadow-xs">
<Button
variant="ghost"
onMouseDown={() => openUrl(config.aboutURL)}
className="h-12 w-full justify-between rounded-none px-4 font-medium first:rounded-t-2xl last:rounded-b-2xl hover:bg-accent/55">
{m.projectLink()} <LinkIcon className="h-4 w-4 text-muted-foreground" />
</Button>
<Button
variant="ghost"
onMouseDown={vm.reportIssue}
className="h-12 w-full justify-between rounded-none px-4 font-medium first:rounded-t-2xl last:rounded-b-2xl hover:bg-accent/55">
{m.reportIssue()} <GithubIcon className="h-4 w-4 text-muted-foreground" />
</Button>
<Button
variant="ghost"
onMouseDown={() => openUrl(config.supportVibeURL)}
className="h-12 w-full justify-between rounded-none px-4 font-medium first:rounded-t-2xl last:rounded-b-2xl hover:bg-accent/55">
{m.supportTheProject()} <HeartIcon className="h-4 w-4 fill-red-500 text-red-500 dark:fill-red-400 dark:text-red-400" />
</Button>
<Button
variant="ghost"
onMouseDown={() => openUrl(config.discordURL)}
className="h-12 w-full justify-between rounded-none px-4 font-medium first:rounded-t-2xl last:rounded-b-2xl hover:bg-accent/55">
{m.discordCommunity()} <DiscordIcon className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
<SettingsGroup>
<ActionRow label={m.projectLink()} icon={<LinkIcon className="h-4 w-4" />} onClick={() => openUrl(config.aboutURL)} />
<ActionRow label={m.reportIssue()} icon={<GithubIcon className="h-4 w-4" />} onClick={vm.reportIssue} />
<ActionRow
label={m.supportTheProject()}
icon={<HeartIcon className="h-4 w-4 fill-red-500 text-red-500 dark:fill-red-400 dark:text-red-400" />}
onClick={() => openUrl(config.supportVibeURL)}
/>
<ActionRow label={m.discordCommunity()} icon={<DiscordIcon className="h-4 w-4" />} onClick={() => openUrl(config.discordURL)} />
</SettingsGroup>
</div>
)
}
+113 -158
View File
@@ -1,180 +1,135 @@
import { useState } from 'react'
import { FolderOpen, PencilLine } from 'lucide-react'
import { Check, Download, FolderOpen, PencilLine, X } from 'lucide-react'
import { m } from '~/paraglide/messages.js'
import { ReactComponent as FolderIcon } from '~/icons/folder.svg'
import { ReactComponent as LinkIcon } from '~/icons/link.svg'
import { ReactComponent as WrenchIcon } from '~/icons/wrench.svg'
import { Button } from '~/components/ui/button'
import { Input } from '~/components/ui/input'
import { Label } from '~/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select'
import { SectionCard, type SettingsViewModel } from './shared'
import { ActionRow, IconAction, SettingsGroup, SettingsRow, rowControlClass, type SettingsViewModel } from './shared'
import { getFriendlyModelName } from '~/lib/model'
export function ModelsSection({ vm }: { vm: SettingsViewModel }) {
const [editingPath, setEditingPath] = useState<string | null>(null)
const [editingName, setEditingName] = useState('')
const currentModel = vm.models.find((model) => model.path === vm.preference.modelPath)
const isRenaming = Boolean(currentModel && editingPath === currentModel.path)
function commitRename() {
if (!currentModel) return
const name = editingName.trim()
if (name) vm.preference.setModelDisplayNames({ ...vm.preference.modelDisplayNames, [currentModel.path]: name })
setEditingPath(null)
}
function startRename() {
if (!currentModel) return
setEditingPath(currentModel.path)
setEditingName(vm.preference.modelDisplayNames[currentModel.path] ?? getFriendlyModelName(currentModel.name))
}
return (
<div className="space-y-5">
<SectionCard>
<div className="space-y-5">
<div className="space-y-2">
<Label>{m.downloadModel()}</Label>
<div className="flex items-center gap-2">
<div className="space-y-6">
<SettingsGroup>
<SettingsRow label={m.selectModel()}>
{isRenaming ? (
<>
<Input
type="text"
value={vm.downloadURL}
onChange={(event) => vm.setDownloadURL(event.target.value)}
placeholder={m.pasteModelLink()}
onKeyDown={(event) => (event.key === 'Enter' ? vm.downloadModel() : null)}
autoFocus
value={editingName}
onChange={(event) => setEditingName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') commitRename()
if (event.key === 'Escape') setEditingPath(null)
}}
className={`w-56 ${rowControlClass}`}
/>
<Button variant="default" size="icon" onClick={vm.downloadModel} className="shrink-0">
<svg
aria-hidden="true"
focusable="false"
role="img"
className="octicon octicon-download"
viewBox="0 0 16 16"
width="16"
height="16"
fill="currentColor">
<path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"></path>
<path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969Z"></path>
</svg>
</Button>
</div>
</div>
<div className="space-y-2">
<Label>{m.selectModel()}</Label>
<Select
value={vm.preference.modelPath ?? undefined}
onValueChange={vm.selectModel}
onOpenChange={(open) => {
if (open) vm.loadModels()
}}>
<SelectTrigger>
<SelectValue placeholder={m.selectModel()} />
</SelectTrigger>
<SelectContent>
{vm.models.map((model, index) => (
<SelectItem key={index} value={model.path}>
{vm.preference.modelDisplayNames[model.path] ?? getFriendlyModelName(model.name)}
</SelectItem>
))}
</SelectContent>
</Select>
{currentModel &&
(editingPath === currentModel.path ? (
<div className="flex items-center gap-2">
<Input
autoFocus
value={editingName}
onChange={(event) => setEditingName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
const name = editingName.trim()
if (name) vm.preference.setModelDisplayNames({ ...vm.preference.modelDisplayNames, [currentModel.path]: name })
setEditingPath(null)
}
if (event.key === 'Escape') setEditingPath(null)
}}
/>
<Button
size="sm"
onClick={() => {
const name = editingName.trim()
if (name) vm.preference.setModelDisplayNames({ ...vm.preference.modelDisplayNames, [currentModel.path]: name })
setEditingPath(null)
}}>
{m.save()}
</Button>
<Button variant="ghost" size="sm" onClick={() => setEditingPath(null)}>
{m.cancel()}
</Button>
</div>
) : (
<div className="mt-2 flex items-center justify-end gap-1 px-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2.5 text-muted-foreground hover:text-foreground"
onClick={() => vm.openSelectedModel(currentModel.path)}>
<FolderOpen className="size-3.5" /> {m.showInFolder()}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2.5 text-muted-foreground hover:text-foreground"
onClick={() => {
setEditingPath(currentModel.path)
setEditingName(vm.preference.modelDisplayNames[currentModel.path] ?? getFriendlyModelName(currentModel.name))
}}>
<PencilLine className="size-3.5" /> {m.rename()}
</Button>
</div>
))}
</div>
{!vm.isMacOS && (
<div className="space-y-2">
<Label>{m.gpuDevice()}</Label>
{vm.gpuDevices.length > 0 ? (
<Select
value={vm.preference.gpuDevice != null ? String(vm.preference.gpuDevice) : 'auto'}
onValueChange={(value) => {
vm.preference.setGpuDevice(value === 'auto' ? null : parseInt(value, 10))
}}>
<SelectTrigger>
<SelectValue placeholder={m.gpuDevice()} />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{m.auto()}</SelectItem>
{vm.gpuDevices.map((device) => (
<SelectItem key={device.index} value={String(device.index)}>
{device.description}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
type="number"
value={vm.preference.gpuDevice ?? ''}
onChange={(e) => {
const val = e.target.value
vm.preference.setGpuDevice(val === '' ? null : parseInt(val, 10))
}}
placeholder={m.gpuDevicePlaceholder()}
/>
)}
</div>
<IconAction label={m.save()} icon={<Check className="h-4 w-4" />} onClick={commitRename} />
<IconAction label={m.cancel()} icon={<X className="h-4 w-4" />} onClick={() => setEditingPath(null)} />
</>
) : (
<>
<Select
value={vm.preference.modelPath ?? undefined}
onValueChange={vm.selectModel}
onOpenChange={(open) => {
if (open) vm.loadModels()
}}>
<SelectTrigger className={`w-56 ${rowControlClass}`}>
<SelectValue placeholder={m.selectModel()} />
</SelectTrigger>
<SelectContent>
{vm.models.map((model, index) => (
<SelectItem key={index} value={model.path}>
{vm.preference.modelDisplayNames[model.path] ?? getFriendlyModelName(model.name)}
</SelectItem>
))}
</SelectContent>
</Select>
<IconAction
label={m.showInFolder()}
icon={<FolderOpen className="h-4 w-4" />}
disabled={!currentModel}
onClick={() => currentModel && vm.openSelectedModel(currentModel.path)}
/>
<IconAction label={m.rename()} icon={<PencilLine className="h-4 w-4" />} disabled={!currentModel} onClick={startRename} />
</>
)}
</SettingsRow>
<div className="space-y-1 pt-1">
<Button
variant="ghost"
onMouseDown={vm.openModelsUrl}
className="h-11 w-full justify-between rounded-lg px-3 font-medium hover:bg-accent/60">
{m.downloadModelsLink()} <LinkIcon className="h-4 w-4 text-muted-foreground" />
</Button>
<Button
variant="ghost"
onMouseDown={vm.openModelPath}
className="h-11 w-full justify-between rounded-lg px-3 font-medium hover:bg-accent/60">
{m.modelsFolder()} <FolderIcon className="h-4 w-4 text-muted-foreground" />
</Button>
<Button
variant="ghost"
onMouseDown={vm.changeModelsFolder}
className="h-11 w-full justify-between rounded-lg px-3 font-medium hover:bg-accent/60">
{m.changeModelsFolder()} <WrenchIcon className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
</div>
</SectionCard>
{!vm.isMacOS && (
<SettingsRow label={m.gpuDevice()}>
{vm.gpuDevices.length > 0 ? (
<Select
value={vm.preference.gpuDevice != null ? String(vm.preference.gpuDevice) : 'auto'}
onValueChange={(value) => {
vm.preference.setGpuDevice(value === 'auto' ? null : parseInt(value, 10))
}}>
<SelectTrigger className={`w-56 ${rowControlClass}`}>
<SelectValue placeholder={m.gpuDevice()} />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{m.auto()}</SelectItem>
{vm.gpuDevices.map((device) => (
<SelectItem key={device.index} value={String(device.index)}>
{device.description}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
type="number"
value={vm.preference.gpuDevice ?? ''}
onChange={(e) => {
const val = e.target.value
vm.preference.setGpuDevice(val === '' ? null : parseInt(val, 10))
}}
placeholder={m.gpuDevicePlaceholder()}
className={`w-40 ${rowControlClass}`}
/>
)}
</SettingsRow>
)}
<SettingsRow label={m.downloadModel()}>
<Input
type="text"
value={vm.downloadURL}
onChange={(event) => vm.setDownloadURL(event.target.value)}
placeholder={m.pasteModelLink()}
onKeyDown={(event) => (event.key === 'Enter' ? vm.downloadModel() : null)}
className={`w-56 ${rowControlClass}`}
/>
<IconAction label={m.downloadModel()} icon={<Download className="h-4 w-4" />} onClick={vm.downloadModel} disabled={!vm.downloadURL} />
</SettingsRow>
</SettingsGroup>
<SettingsGroup>
<ActionRow label={m.downloadModelsLink()} icon={<LinkIcon className="h-4 w-4" />} onClick={vm.openModelsUrl} />
<ActionRow label={m.modelsFolder()} icon={<FolderIcon className="h-4 w-4" />} onClick={vm.openModelPath} />
<ActionRow label={m.changeModelsFolder()} icon={<WrenchIcon className="h-4 w-4" />} onClick={vm.changeModelsFolder} />
</SettingsGroup>
</div>
)
}
+16 -15
View File
@@ -2,26 +2,27 @@ import { openUrl } from '@tauri-apps/plugin-opener'
import { m } from '~/paraglide/messages.js'
import { ReactComponent as LinkIcon } from '~/icons/link.svg'
import * as config from '~/lib/config'
import { Button } from '~/components/ui/button'
import { Switch } from '~/components/ui/switch'
import { SectionCard, type SettingsViewModel } from './shared'
import { ActionRow, SettingsGroup, SettingsRow, type SettingsViewModel } from './shared'
export function PrivacySection({ vm }: { vm: SettingsViewModel }) {
return (
<div className="space-y-5">
<SectionCard>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-medium">{m.analyticsEnabled()}</span>
<div className="space-y-6">
<SettingsGroup>
<SettingsRow
label={m.analyticsEnabled()}
description={!vm.preference.analyticsEnabled ? m.analyticsDisabledWarning() : undefined}
clampDescription={false}>
<Switch checked={vm.preference.analyticsEnabled} onCheckedChange={vm.preference.setAnalyticsEnabled} />
</div>
{!vm.preference.analyticsEnabled && <p className="mt-2 text-xs italic text-muted-foreground">{m.analyticsDisabledWarning()}</p>}
</SectionCard>
<Button
variant="ghost"
onMouseDown={() => openUrl(config.privacyPolicyURL)}
className="h-11 w-full justify-between rounded-xl border border-border/55 bg-card/92 px-4 font-medium hover:bg-accent/55">
{m.privacyPolicy()} <LinkIcon className="h-4 w-4 text-muted-foreground" />
</Button>
</SettingsRow>
<SettingsRow label="Save transcripts to Documents" description="Keep a local copy of every transcript so you can reopen it later">
<Switch checked={vm.preference.saveTranscripts} onCheckedChange={vm.preference.setSaveTranscripts} />
</SettingsRow>
</SettingsGroup>
<SettingsGroup>
<ActionRow label={m.privacyPolicy()} icon={<LinkIcon className="h-4 w-4" />} onClick={() => openUrl(config.privacyPolicyURL)} />
</SettingsGroup>
</div>
)
}
+162 -8
View File
@@ -1,18 +1,172 @@
import type { ReactNode } from 'react'
import { Label } from '~/components/ui/label'
import type { ComponentProps, ReactNode } from 'react'
import { cn } from '~/lib/style'
import { Button } from '~/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip'
import type { viewModel } from '../view-model'
export type SettingsViewModel = ReturnType<typeof viewModel>
export function SectionCard({ children }: { children: ReactNode }) {
return <div className="rounded-2xl border border-border/60 bg-card/92 p-5 text-card-foreground shadow-xs">{children}</div>
/** Shared control sizing for compact row controls (selects / inputs). */
export const rowControlClass = 'h-9 rounded-lg'
/**
* A settings group: optional heading + one bordered container whose rows are
* separated by hairline borders.
*/
export function SettingsGroup({
title,
description,
children,
className,
}: {
title?: ReactNode
description?: ReactNode
children: ReactNode
className?: string
}) {
return (
<section className="space-y-2">
{(title || description) && (
<div className="space-y-0.5 px-1">
{title && <h3 className="text-[13px] font-medium text-foreground">{title}</h3>}
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</div>
)}
<div className={cn('divide-y divide-border/45 overflow-hidden rounded-xl border border-border/60 bg-card', className)}>{children}</div>
</section>
)
}
export function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
/** Label + optional one-line description, used on the left side of every row. */
function RowLabel({ label, description, clamp = true }: { label: ReactNode; description?: ReactNode; clamp?: boolean }) {
return (
<div className="w-full space-y-2">
<Label className="flex items-center gap-1">{label}</Label>
{children}
<div className="min-w-0 space-y-0.5">
<div className="text-sm text-foreground">{label}</div>
{description && <p className={cn('text-xs text-muted-foreground', clamp && 'line-clamp-1')}>{description}</p>}
</div>
)
}
/** A single row: label (+ description) on the left, the control on the right. */
export function SettingsRow({
label,
description,
children,
className,
clampDescription = true,
}: {
label: ReactNode
description?: ReactNode
children?: ReactNode
className?: string
clampDescription?: boolean
}) {
return (
<div className={cn('flex min-h-[52px] items-center justify-between gap-4 px-4 py-2.5', className)}>
<RowLabel label={label} description={description} clamp={clampDescription} />
{children && <div className="flex shrink-0 items-center gap-1.5">{children}</div>}
</div>
)
}
/** A stacked row: label (+ description) on top, a full-width control below. */
export function SettingsField({
label,
description,
children,
footer,
className,
}: {
label?: ReactNode
description?: ReactNode
children: ReactNode
footer?: ReactNode
className?: string
}) {
return (
<div className={cn('space-y-2 px-4 py-3', className)}>
{(label || description) && <RowLabel label={label} description={description} clamp={false} />}
{children}
{footer && <div className="text-end text-xs text-muted-foreground">{footer}</div>}
</div>
)
}
/** A quiet note inside a group (e.g. "supports up to 4 speakers"). */
export function SettingsNote({ children }: { children: ReactNode }) {
return <p className="px-4 py-2.5 text-xs text-muted-foreground">{children}</p>
}
/** Whole-row clickable action: label on the left, small muted icon on the right. */
export function ActionRow({
label,
description,
icon,
onClick,
destructive,
disabled,
className,
activateOnClick,
}: {
label: ReactNode
description?: ReactNode
icon?: ReactNode
onClick?: () => void
destructive?: boolean
disabled?: boolean
className?: string
/** Use the click event instead of mousedown (for actions that open dialogs). */
activateOnClick?: boolean
}) {
return (
<button
type="button"
disabled={disabled}
onMouseDown={activateOnClick ? undefined : onClick}
onClick={activateOnClick ? onClick : undefined}
className={cn(
'flex min-h-[52px] w-full cursor-pointer items-center justify-between gap-4 px-4 py-2.5 text-start transition-colors',
destructive ? 'text-destructive hover:bg-destructive/10' : 'hover:bg-accent/45',
'disabled:pointer-events-none disabled:opacity-50',
className,
)}>
<RowLabel label={<span className="text-sm">{label}</span>} description={description} />
{icon && <span className={cn('shrink-0', destructive ? 'text-destructive' : 'text-muted-foreground')}>{icon}</span>}
</button>
)
}
/** Small ghost icon button with a tooltip — for inline row actions. */
export function IconAction({
label,
icon,
onClick,
disabled,
className,
...props
}: {
label: string
icon: ReactNode
onClick?: () => void
disabled?: boolean
className?: string
} & Omit<ComponentProps<typeof Button>, 'children' | 'onClick'>) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="iconSm"
aria-label={label}
disabled={disabled}
onClick={onClick}
className={cn('h-9 w-9 rounded-lg text-muted-foreground hover:text-foreground', className)}
{...props}>
{icon}
</Button>
</TooltipTrigger>
<TooltipContent side="top">{label}</TooltipContent>
</Tooltip>
)
}
+98 -104
View File
@@ -1,32 +1,43 @@
import { openUrl } from '@tauri-apps/plugin-opener'
import { Check, Copy } from 'lucide-react'
import { Check, Copy, ExternalLink } from 'lucide-react'
import { m } from '~/paraglide/messages.js'
import { getLocale } from '~/paraglide/runtime.js'
import { ReactComponent as LinkIcon } from '~/icons/link.svg'
import * as config from '~/lib/config'
import { defaultClaudeConfig, defaultOllamaConfig, defaultOpenAIConfig } from '~/lib/llm'
import { InfoTooltip } from '~/components/info-tooltip'
import { Button } from '~/components/ui/button'
import { Input } from '~/components/ui/input'
import { Switch } from '~/components/ui/switch'
import { Textarea } from '~/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select'
import { Field, type SettingsViewModel } from './shared'
import { ActionRow, IconAction, SettingsField, SettingsGroup, SettingsRow, rowControlClass, type SettingsViewModel } from './shared'
const inlineLinkClass = 'h-6 w-6 rounded-md [&_svg]:size-3.5'
function LabelWithLink({ label, tooltip, onClick }: { label: string; tooltip: string; onClick: () => void }) {
return (
<span className="inline-flex items-center gap-1">
{label}
<IconAction label={tooltip} icon={<ExternalLink />} onClick={onClick} className={inlineLinkClass} />
</span>
)
}
export function SummarizeSection({ vm }: { vm: SettingsViewModel }) {
return (
<div className="space-y-5">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">{m.processWithLlm()} </h3>
<InfoTooltip text={m.infoLlmSummarize()} />
</div>
<Switch checked={vm.preference.llmConfig?.enabled} onCheckedChange={vm.onEnableLlm} />
</div>
const platform = vm.preference.llmConfig?.platform
<Field label={m.llmPlatform()}>
return (
<div className="space-y-6">
<SettingsGroup>
<SettingsRow label={m.processWithLlm()} description={m.infoLlmSummarize()}>
<Switch checked={vm.preference.llmConfig?.enabled} onCheckedChange={vm.onEnableLlm} />
</SettingsRow>
</SettingsGroup>
<SettingsGroup>
<SettingsRow label={m.llmPlatform()}>
<Select
value={vm.preference.llmConfig?.platform}
value={platform}
onValueChange={(value) => {
const lang = new Intl.DisplayNames([getLocale()], { type: 'language' }).of(getLocale()) ?? 'English'
const defaults =
@@ -40,7 +51,7 @@ export function SummarizeSection({ vm }: { vm: SettingsViewModel }) {
enabled: vm.preference.llmConfig?.enabled ?? false,
})
}}>
<SelectTrigger className="capitalize">
<SelectTrigger className={`w-52 capitalize ${rowControlClass}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -51,159 +62,142 @@ export function SummarizeSection({ vm }: { vm: SettingsViewModel }) {
))}
</SelectContent>
</Select>
</Field>
</SettingsRow>
{vm.preference.llmConfig?.platform === 'claude' && (
{platform === 'claude' && (
<>
<Field
label={
<>
<InfoTooltip text={m.infoLlmApiKey()} />
{m.llmApiKey()}
<button
type="button"
className="ml-1 text-primary underline hover:text-primary/80"
onClick={() => openUrl(config.llmApiKeyUrl)}>
{m.findHere()}
</button>
</>
}>
<SettingsRow
label={<LabelWithLink label={m.llmApiKey()} tooltip={m.findHere()} onClick={() => openUrl(config.llmApiKeyUrl)} />}
description={m.infoLlmApiKey()}>
<Input
value={vm.preference.llmConfig?.claudeApiKey}
onChange={(e) => vm.preference.setLlmConfig({ ...vm.preference.llmConfig, claudeApiKey: e.target.value })}
placeholder={m.pasteApiKey()}
type="text"
className={`w-64 ${rowControlClass}`}
/>
</Field>
<Field
</SettingsRow>
<SettingsRow
label={
<>
{m.llmModel()}
<button
type="button"
className="ml-1 text-primary underline hover:text-primary/80"
onClick={() => openUrl('https://docs.anthropic.com/en/docs/about-claude/models')}>
{m.findHere()}
</button>
</>
<LabelWithLink
label={m.llmModel()}
tooltip={m.findHere()}
onClick={() => openUrl('https://docs.anthropic.com/en/docs/about-claude/models')}
/>
}>
<Input
value={vm.preference.llmConfig?.model}
onChange={(e) => vm.preference.setLlmConfig({ ...vm.preference.llmConfig, model: e.target.value })}
placeholder="claude-sonnet-4-5"
className={`w-64 ${rowControlClass}`}
/>
</Field>
</SettingsRow>
</>
)}
{vm.preference.llmConfig?.platform === 'ollama' && (
{platform === 'ollama' && (
<>
<Field label={m.ollamaBaseUrl()}>
<SettingsRow label={m.ollamaBaseUrl()}>
<Input
value={vm.preference.llmConfig?.ollamaBaseUrl}
onChange={(e) => vm.preference.setLlmConfig({ ...vm.preference.llmConfig, ollamaBaseUrl: e.target.value })}
className={`w-64 ${rowControlClass}`}
/>
</Field>
<Field
</SettingsRow>
<SettingsRow
label={
<>
{m.llmModel()}
<button
type="button"
className="ml-1 text-primary underline hover:text-primary/80"
onClick={() => openUrl(`https://ollama.com/library/${vm.preference.llmConfig.model}`)}>
{m.findHere()}
</button>
</>
<LabelWithLink
label={m.llmModel()}
tooltip={m.findHere()}
onClick={() => openUrl(`https://ollama.com/library/${vm.preference.llmConfig.model}`)}
/>
}>
<Input
value={vm.preference.llmConfig?.model}
onChange={(e) => vm.preference.setLlmConfig({ ...vm.preference.llmConfig, model: e.target.value })}
placeholder="llama3.2"
className={`w-64 ${rowControlClass}`}
/>
</Field>
</SettingsRow>
</>
)}
{vm.preference.llmConfig?.platform === 'openai' && (
{platform === 'openai' && (
<>
<Field label={m.baseUrl()}>
<SettingsRow label={m.baseUrl()}>
<Input
value={vm.preference.llmConfig?.openaiBaseUrl}
onChange={(e) => vm.preference.setLlmConfig({ ...vm.preference.llmConfig, openaiBaseUrl: e.target.value })}
placeholder="https://api.openai.com/v1"
className={`w-64 ${rowControlClass}`}
/>
</Field>
<Field label={m.apiKey()}>
</SettingsRow>
<SettingsRow label={m.apiKey()}>
<Input
value={vm.preference.llmConfig?.openaiApiKey}
onChange={(e) => vm.preference.setLlmConfig({ ...vm.preference.llmConfig, openaiApiKey: e.target.value })}
placeholder={m.optionalLocalServerKey()}
type="text"
className={`w-64 ${rowControlClass}`}
/>
</Field>
<Field label={m.llmModel()}>
</SettingsRow>
<SettingsRow label={m.llmModel()}>
<Input
value={vm.preference.llmConfig?.model}
onChange={(e) => vm.preference.setLlmConfig({ ...vm.preference.llmConfig, model: e.target.value })}
placeholder="gpt-4o-mini"
className={`w-64 ${rowControlClass}`}
/>
</Field>
</SettingsRow>
</>
)}
<Field
label={
<>
<InfoTooltip text={m.infoLlmPrompt()} />
{m.llmPrompt()}
</>
}>
<Textarea
value={vm.preference.llmConfig?.prompt}
onChange={(e) => vm.preference.setLlmConfig({ ...vm.preference.llmConfig, prompt: e.target.value })}
onBlur={vm.validateLlmPrompt}
className="min-h-[100px]"
/>
</Field>
<Field
label={
<>
<InfoTooltip text={m.infoMaxTokens()} />
{m.maxTokens()}
</>
}>
<SettingsRow label={m.maxTokens()} description={m.infoMaxTokens()}>
<Input
type="number"
onChange={(e) => vm.preference.setLlmConfig({ ...vm.preference.llmConfig, maxTokens: vm.parseIntOr(e.target.value, 1) })}
value={vm.preference.llmConfig?.maxTokens}
className={`w-24 text-end ${rowControlClass}`}
/>
</Field>
</SettingsRow>
<Button onClick={vm.checkLlm} size="sm" className="w-full">
{m.runLlmCheck()}
</Button>
<SettingsField label={m.llmPrompt()} description={m.infoLlmPrompt()}>
<Textarea
value={vm.preference.llmConfig?.prompt}
onChange={(e) => vm.preference.setLlmConfig({ ...vm.preference.llmConfig, prompt: e.target.value })}
onBlur={vm.validateLlmPrompt}
placeholder="Summarize the following transcript in clear bullet points:%s"
className="min-h-24 max-h-60 w-full resize-none overflow-y-auto rounded-lg bg-muted/40 text-sm"
/>
</SettingsField>
</SettingsGroup>
<SettingsGroup>
<SettingsRow label="Connection" description={vm.llmError ? m.checkError() : undefined}>
<Button variant="outline" size="sm" onClick={vm.checkLlm}>
{m.runLlmCheck()}
</Button>
</SettingsRow>
{vm.llmError && (
<div className="relative rounded-lg border border-destructive/30 bg-destructive/5 p-3 pe-10">
<button type="button" className="absolute end-2 top-2 p-1 text-muted-foreground hover:text-foreground" onClick={vm.copyLlmError}>
<div className="relative px-4 py-3">
<button
type="button"
className="absolute end-5 top-4 p-1 text-muted-foreground hover:text-foreground"
onClick={vm.copyLlmError}
aria-label={m.copy()}>
{vm.llmErrorCopied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</button>
<pre className="whitespace-pre-wrap break-all text-xs text-destructive">{vm.llmError}</pre>
<pre className="max-h-40 overflow-y-auto whitespace-pre-wrap break-all pe-8 text-xs text-destructive">{vm.llmError}</pre>
</div>
)}
</SettingsGroup>
{vm.preference.llmConfig?.platform === 'claude' && (
<div className="flex flex-col gap-2 text-sm">
<button type="button" className="text-left text-primary underline hover:text-primary/80" onClick={() => openUrl(config.llmLimitsUrl)}>
{m.setMonthlySpendLimit()}
</button>
<button type="button" className="text-left text-primary underline hover:text-primary/80" onClick={() => openUrl(config.llmCostUrl)}>
{m.llmCurrentCost()}
</button>
</div>
)}
</div>
{platform === 'claude' && (
<SettingsGroup>
<ActionRow label={m.setMonthlySpendLimit()} icon={<LinkIcon className="h-4 w-4" />} onClick={() => openUrl(config.llmLimitsUrl)} />
<ActionRow label={m.llmCurrentCost()} icon={<LinkIcon className="h-4 w-4" />} onClick={() => openUrl(config.llmCostUrl)} />
</SettingsGroup>
)}
</div>
)
}
@@ -1,49 +1,47 @@
import { m } from '~/paraglide/messages.js'
import LanguageInput from '~/components/language-input'
import { InfoTooltip } from '~/components/info-tooltip'
import { Button } from '~/components/ui/button'
import { Switch } from '~/components/ui/switch'
import { SectionCard, type SettingsViewModel } from './shared'
import { SettingsGroup, SettingsRow, type SettingsViewModel } from './shared'
export function TranscriptionSection({ vm }: { vm: SettingsViewModel }) {
const recordingPath = vm.preference.customRecordingPath ?? vm.defaultRecordingPath
return (
<div className="space-y-5">
<SectionCard>
<LanguageInput />
</SectionCard>
<SectionCard>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/45 py-2">
<span className="text-sm font-medium">{m.playSoundOnFinish()}</span>
<div className="space-y-6">
<SettingsGroup>
{/* LanguageInput lives outside this page; reflow it into a settings row. */}
<div className="[&>div]:flex [&>div]:min-h-[52px] [&>div]:items-center [&>div]:justify-between [&>div]:gap-4 [&>div]:space-y-0 [&>div]:px-4 [&>div]:py-2.5 [&_label]:text-sm [&_label]:font-normal [&_button]:h-9 [&_button]:w-52 [&_button]:rounded-lg">
<LanguageInput />
</div>
</SettingsGroup>
<SettingsGroup>
<SettingsRow label={m.playSoundOnFinish()}>
<Switch checked={vm.preference.soundOnFinish} onCheckedChange={vm.preference.setSoundOnFinish} />
</div>
<div className="flex flex-wrap items-center justify-between gap-2 pb-1 pt-4">
<span className="text-sm font-medium">{m.focusWindowOnFinish()}</span>
</SettingsRow>
<SettingsRow label={m.focusWindowOnFinish()}>
<Switch checked={vm.preference.focusOnFinish} onCheckedChange={vm.preference.setFocusOnFinish} />
</div>
</SectionCard>
<div className="space-y-2">
<div className="flex items-center gap-1 px-1">
<InfoTooltip text={m.recordingSavePathInfo()} />
<span className="text-sm font-semibold text-foreground/95">{m.recordingSavePath()}</span>
</div>
<SectionCard>
<div className="flex items-center justify-between gap-2">
<p className="min-w-0 truncate text-sm text-muted-foreground" title={vm.preference.customRecordingPath ?? vm.defaultRecordingPath}>
{vm.preference.customRecordingPath ?? vm.defaultRecordingPath}
</p>
<div className="flex shrink-0 items-center gap-2">
{vm.preference.customRecordingPath && (
<Button variant="ghost" size="sm" onMouseDown={vm.resetRecordingPath}>
{m.resetToDefault()}
</Button>
)}
<Button variant="outline" size="sm" onMouseDown={vm.changeRecordingPath}>
{m.changeRecordingPath()}
</Button>
</div>
</div>
</SectionCard>
</div>
</SettingsRow>
</SettingsGroup>
<SettingsGroup title={m.recordingSavePath()} description={m.recordingSavePathInfo()}>
<SettingsRow
label={
<span title={recordingPath} className="block truncate font-mono text-xs text-muted-foreground">
{recordingPath}
</span>
}>
{vm.preference.customRecordingPath && (
<Button variant="ghost" size="sm" onMouseDown={vm.resetRecordingPath}>
{m.resetToDefault()}
</Button>
)}
<Button variant="outline" size="sm" onMouseDown={vm.changeRecordingPath}>
{m.changeRecordingPath()}
</Button>
</SettingsRow>
</SettingsGroup>
</div>
)
}
+141 -230
View File
@@ -1,253 +1,164 @@
import { message } from '@tauri-apps/plugin-dialog'
import { RotateCcw, Wand2 } from 'lucide-react'
import { m } from '~/paraglide/messages.js'
import { InfoTooltip } from '~/components/info-tooltip'
import { Button } from '~/components/ui/button'
import { Input } from '~/components/ui/input'
import { Switch } from '~/components/ui/switch'
import { Textarea } from '~/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select'
import { Field, SectionCard, type SettingsViewModel } from './shared'
import { ActionRow, SettingsField, SettingsGroup, SettingsNote, SettingsRow, rowControlClass, type SettingsViewModel } from './shared'
const numberInputClass = 'w-24 text-end'
const textareaClass = 'min-h-24 max-h-60 w-full resize-none overflow-y-auto rounded-lg bg-muted/40 text-sm'
export function TuningSection({ vm }: { vm: SettingsViewModel }) {
const promptLength = vm.preference.modelOptions?.init_prompt?.length ?? 0
const isGreedy = vm.preference.modelOptions.sampling_strategy === 'greedy'
return (
<div className="space-y-5">
<div className="space-y-2">
<span className="px-1 text-sm font-semibold text-foreground/95">{m.speakerTiming()}</span>
<SectionCard>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1 text-sm font-medium">
<InfoTooltip text={m.infoDiarization()} />
{m.enableDiarization()}
</span>
<Switch checked={vm.preference.diarizeEnabled} onCheckedChange={vm.toggleDiarization} />
</div>
{vm.preference.diarizeEnabled && <p className="text-sm italic text-muted-foreground">{m.diarizeMaxSpeakersNote()}</p>}
<div className="h-px bg-border/45" />
<div className="flex items-center justify-between">
<span className="flex items-center gap-1 text-sm font-medium">
<InfoTooltip text={m.stableTimestampsInfo()} />
{m.enableStableTimestamps()}
</span>
<Switch checked={vm.preference.stableTimestampsEnabled} onCheckedChange={vm.handleStableTimestampsToggle} />
</div>
{vm.preference.stableTimestampsEnabled && <p className="text-sm italic text-muted-foreground">{m.stableTimestampsSlowNote()}</p>}
</div>
</SectionCard>
</div>
<div className="space-y-6">
<SettingsGroup title={m.speakerTiming()}>
<SettingsRow label={m.enableDiarization()} description={m.infoDiarization()}>
<Switch checked={vm.preference.diarizeEnabled} onCheckedChange={vm.toggleDiarization} />
</SettingsRow>
{vm.preference.diarizeEnabled && <SettingsNote>{m.diarizeMaxSpeakersNote()}</SettingsNote>}
<SettingsRow label={m.enableStableTimestamps()} description={m.stableTimestampsInfo()}>
<Switch checked={vm.preference.stableTimestampsEnabled} onCheckedChange={vm.handleStableTimestampsToggle} />
</SettingsRow>
{vm.preference.stableTimestampsEnabled && <SettingsNote>{m.stableTimestampsSlowNote()}</SettingsNote>}
</SettingsGroup>
<div className="space-y-2">
<span className="px-1 text-sm font-semibold text-foreground/95">{m.modelOptions()}</span>
<SectionCard>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1 text-sm font-medium">
<InfoTooltip text={m.infoTranslateToEnglish()} />
{m.translateToEnglish()}
</span>
<Switch
checked={Boolean(vm.preference.modelOptions.translate)}
onCheckedChange={(checked) => vm.preference.setModelOptions({ ...vm.preference.modelOptions, translate: checked })}
/>
</div>
{vm.preference.modelOptions.translate && <p className="text-sm italic text-muted-foreground">{m.translateWhisperModelNote()}</p>}
<SettingsGroup title={m.modelOptions()}>
<SettingsRow label={m.translateToEnglish()} description={m.infoTranslateToEnglish()}>
<Switch
checked={Boolean(vm.preference.modelOptions.translate)}
onCheckedChange={(checked) => vm.preference.setModelOptions({ ...vm.preference.modelOptions, translate: checked })}
/>
</SettingsRow>
{vm.preference.modelOptions.translate && <SettingsNote>{m.translateWhisperModelNote()}</SettingsNote>}
<Field
label={
<>
<InfoTooltip text={m.infoPrompt()} />
{m.prompt()} ({m.leftover()} {1024 - (vm.preference.modelOptions?.init_prompt?.length ?? 0)} {m.characters()})
</>
}>
<Textarea
value={vm.preference.modelOptions?.init_prompt}
onChange={(e) => vm.preference.setModelOptions({ ...vm.preference.modelOptions, init_prompt: e.target.value.slice(0, 1024) })}
className="min-h-[80px]"
/>
</Field>
<SettingsRow label={m.useWordTimestamps()} description={m.infoUseWordTimestamps()}>
<Switch
checked={Boolean(vm.preference.modelOptions.word_timestamps)}
onCheckedChange={(checked) => vm.preference.setModelOptions({ ...vm.preference.modelOptions, word_timestamps: checked })}
/>
</SettingsRow>
<div className="flex items-center justify-between">
<span className="flex items-center gap-1 text-sm font-medium">
<InfoTooltip text={m.infoUseWordTimestamps()} />
{m.useWordTimestamps()}
</span>
<Switch
checked={Boolean(vm.preference.modelOptions.word_timestamps)}
onCheckedChange={(checked) => vm.preference.setModelOptions({ ...vm.preference.modelOptions, word_timestamps: checked })}
/>
</div>
<SettingsRow label={m.maxSentenceLen()} description={m.infoMaxSentenceLen()}>
<Input
type="number"
value={vm.preference.modelOptions.max_sentence_len}
onChange={(e) => {
if (!vm.preference.modelOptions.word_timestamps) message(m.pleaseEnableWordTimestamps())
vm.preference.setModelOptions({ ...vm.preference.modelOptions, max_sentence_len: vm.parseIntOr(e.target.value, 1) })
}}
className={`${numberInputClass} ${rowControlClass}`}
/>
</SettingsRow>
<div className="grid grid-cols-2 gap-4">
<Field
label={
<>
<InfoTooltip text={m.infoMaxSentenceLen()} />
{m.maxSentenceLen()}
</>
}>
<Input
type="number"
value={vm.preference.modelOptions.max_sentence_len}
onChange={(e) => {
if (!vm.preference.modelOptions.word_timestamps) message(m.pleaseEnableWordTimestamps())
vm.preference.setModelOptions({ ...vm.preference.modelOptions, max_sentence_len: vm.parseIntOr(e.target.value, 1) })
}}
/>
</Field>
<SettingsField
label={m.prompt()}
description={m.infoPrompt()}
footer={promptLength > 0 ? `${1024 - promptLength} ${m.characters()} ${m.leftover().toLowerCase()}` : undefined}>
<Textarea
value={vm.preference.modelOptions?.init_prompt}
onChange={(e) => vm.preference.setModelOptions({ ...vm.preference.modelOptions, init_prompt: e.target.value.slice(0, 1024) })}
placeholder="Names, jargon or spellings you expect in the audio — for example: Vibe, Tauri, Whisper"
className={textareaClass}
/>
</SettingsField>
</SettingsGroup>
<Field
label={
<>
<InfoTooltip text={m.infoThreads()} />
{m.threads()}
</>
}>
<Input
type="number"
value={vm.preference.modelOptions.n_threads}
onChange={(e) =>
vm.preference.setModelOptions({ ...vm.preference.modelOptions, n_threads: vm.parseIntOr(e.target.value, 1) })
}
/>
</Field>
<SettingsGroup title="Decoding">
<SettingsRow label={m.samplingStrategy()} description={m.samplingStrategyInfo()}>
<Select
value={vm.preference.modelOptions.sampling_strategy}
onValueChange={(value) =>
vm.preference.setModelOptions({ ...vm.preference.modelOptions, sampling_strategy: value as 'greedy' | 'beam search' })
}>
<SelectTrigger className={`w-40 capitalize ${rowControlClass}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{['beam search', 'greedy'].map((name) => (
<SelectItem key={name} value={name} className="capitalize">
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsRow>
<Field
label={
<>
<InfoTooltip text={m.infoTemperature()} />
{m.temperature()}
</>
}>
<Input
type="number"
step={0.1}
value={vm.preference.modelOptions.temperature}
onChange={(e) =>
vm.preference.setModelOptions({ ...vm.preference.modelOptions, temperature: parseFloat(e.target.value) || 0 })
}
/>
</Field>
<SettingsRow label={isGreedy ? m.bestOf() : m.beamSize()} description={isGreedy ? m.greedyInfo() : m.beamInfo()}>
<Input
type="number"
step={1}
value={isGreedy ? (vm.preference.modelOptions.best_of ?? 5) : (vm.preference.modelOptions.beam_size ?? 5)}
onChange={(e) => {
const val = vm.parseIntOr(e.target.value, 5)
if (isGreedy) {
vm.preference.setModelOptions({ ...vm.preference.modelOptions, best_of: val })
} else {
vm.preference.setModelOptions({ ...vm.preference.modelOptions, beam_size: val })
}
}}
className={`${numberInputClass} ${rowControlClass}`}
/>
</SettingsRow>
<Field
label={
<>
<InfoTooltip text={m.infoMaxTextCtx()} />
{m.maxTextCtx()}
</>
}>
<Input
type="number"
step={1}
value={vm.preference.modelOptions.max_text_ctx ?? 0}
onChange={(e) =>
vm.preference.setModelOptions({ ...vm.preference.modelOptions, max_text_ctx: vm.parseIntOr(e.target.value, 0) })
}
/>
</Field>
</div>
<SettingsRow label={m.temperature()} description={m.infoTemperature()}>
<Input
type="number"
step={0.1}
value={vm.preference.modelOptions.temperature}
onChange={(e) => vm.preference.setModelOptions({ ...vm.preference.modelOptions, temperature: parseFloat(e.target.value) || 0 })}
className={`${numberInputClass} ${rowControlClass}`}
/>
</SettingsRow>
<div className="grid grid-cols-2 gap-4">
<Field
label={
<>
<InfoTooltip text={m.samplingStrategyInfo()} />
{m.samplingStrategy()}
</>
}>
<Select
value={vm.preference.modelOptions.sampling_strategy}
onValueChange={(value) =>
vm.preference.setModelOptions({ ...vm.preference.modelOptions, sampling_strategy: value as 'greedy' | 'beam search' })
}>
<SelectTrigger className="capitalize">
<SelectValue />
</SelectTrigger>
<SelectContent>
{['beam search', 'greedy'].map((name) => (
<SelectItem key={name} value={name} className="capitalize">
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<SettingsRow label={m.maxTextCtx()} description={m.infoMaxTextCtx()}>
<Input
type="number"
step={1}
value={vm.preference.modelOptions.max_text_ctx ?? 0}
onChange={(e) => vm.preference.setModelOptions({ ...vm.preference.modelOptions, max_text_ctx: vm.parseIntOr(e.target.value, 0) })}
className={`${numberInputClass} ${rowControlClass}`}
/>
</SettingsRow>
<Field
label={
<>
<InfoTooltip text={vm.preference.modelOptions.sampling_strategy === 'greedy' ? m.greedyInfo() : m.beamInfo()} />
{vm.preference.modelOptions.sampling_strategy === 'greedy' ? m.bestOf() : m.beamSize()}
</>
}>
<Input
type="number"
step={1}
value={
vm.preference.modelOptions.sampling_strategy === 'greedy'
? (vm.preference.modelOptions.best_of ?? 5)
: (vm.preference.modelOptions.beam_size ?? 5)
}
onChange={(e) => {
const val = vm.parseIntOr(e.target.value, 5)
if (vm.preference.modelOptions.sampling_strategy === 'greedy') {
vm.preference.setModelOptions({ ...vm.preference.modelOptions, best_of: val })
} else {
vm.preference.setModelOptions({ ...vm.preference.modelOptions, beam_size: val })
}
}}
/>
</Field>
</div>
</div>
</SectionCard>
</div>
<SettingsRow label={m.threads()} description={m.infoThreads()}>
<Input
type="number"
value={vm.preference.modelOptions.n_threads}
onChange={(e) => vm.preference.setModelOptions({ ...vm.preference.modelOptions, n_threads: vm.parseIntOr(e.target.value, 1) })}
className={`${numberInputClass} ${rowControlClass}`}
/>
</SettingsRow>
</SettingsGroup>
<div className="space-y-2">
<span className="px-1 text-sm font-semibold text-foreground/95">{m.ffmpegOptions()}</span>
<SectionCard>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1 text-sm font-medium">
<InfoTooltip text={m.infoNormalizeLoudness()} />
{m.normalizeLoudness()}
</span>
<Switch
checked={vm.preference.ffmpegOptions.normalize_loudness}
onCheckedChange={(checked) => vm.preference.setFfmpegOptions({ ...vm.preference.ffmpegOptions, normalize_loudness: checked })}
/>
</div>
<SettingsGroup title={m.ffmpegOptions()}>
<SettingsRow label={m.normalizeLoudness()} description={m.infoNormalizeLoudness()}>
<Switch
checked={vm.preference.ffmpegOptions.normalize_loudness}
onCheckedChange={(checked) => vm.preference.setFfmpegOptions({ ...vm.preference.ffmpegOptions, normalize_loudness: checked })}
/>
</SettingsRow>
<SettingsRow
label={m.customFfmpegCommand()}
description="ffmpeg -i {input} -ar 16000 -ac 1 -c:a pcm_s16le {custom_command} -hide_banner -y -loglevel error">
<Input
value={vm.preference.ffmpegOptions.custom_command ?? ''}
onChange={(e) => vm.preference.setFfmpegOptions({ ...vm.preference.ffmpegOptions, custom_command: e.target.value || null })}
placeholder={vm.preference.ffmpegOptions.normalize_loudness ? '-af loudnorm=I=-16:TP=-1.5:LRA=11' : '-af ...'}
type="text"
className={`w-64 ${rowControlClass}`}
/>
</SettingsRow>
</SettingsGroup>
<Field
label={
<>
<InfoTooltip text={'ffmpeg -i {input} -ar 16000 -ac 1 -c:a pcm_s16le {custom_command} -hide_banner -y -loglevel error'} />
{m.customFfmpegCommand()}
</>
}>
<Input
value={vm.preference.ffmpegOptions.custom_command ?? ''}
onChange={(e) => vm.preference.setFfmpegOptions({ ...vm.preference.ffmpegOptions, custom_command: e.target.value || null })}
placeholder={vm.preference.ffmpegOptions.normalize_loudness ? '-af loudnorm=I=-16:TP=-1.5:LRA=11' : ''}
type="text"
/>
</Field>
</div>
</SectionCard>
</div>
<div className="space-y-2">
<span className="px-1 text-sm font-semibold text-foreground/95">{m.presets()}</span>
<SectionCard>
<div className="flex gap-4">
<Button variant="secondary" onClick={vm.preference.enableSubtitlesPreset} className="flex-1">
{m.presetForSubtitles()}
</Button>
<Button variant="secondary" onClick={vm.preference.resetOptions} className="flex-1">
{m.resetOptions()}
</Button>
</div>
</SectionCard>
</div>
<SettingsGroup title={m.presets()}>
<ActionRow label={m.presetForSubtitles()} icon={<Wand2 className="h-4 w-4" />} onClick={vm.preference.enableSubtitlesPreset} />
<ActionRow label={m.resetOptions()} icon={<RotateCcw className="h-4 w-4" />} onClick={vm.preference.resetOptions} />
</SettingsGroup>
</div>
)
}
+33 -16
View File
@@ -1,3 +1,4 @@
import { motion } from 'framer-motion'
import { m } from '~/paraglide/messages.js'
import { viewModel } from './view-model'
import { Progress } from '~/components/ui/progress'
@@ -10,40 +11,56 @@ function App() {
const vm = viewModel()
return (
<div className="app-shell flex min-h-screen items-center justify-center">
<div className="app-panel w-full max-w-xl text-center">
<p className="app-kicker mb-2">{m.setup({ defaultValue: 'Setup' })}</p>
<div className="text-balance text-2xl font-semibold md:text-3xl">{m.downloadingModel()}</div>
<div className="flex min-h-screen items-center justify-center px-5 py-10">
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
className="w-full max-w-lg text-center">
<p className="eyebrow">{m.setup({ defaultValue: 'Setup' })}</p>
<h1 className="mt-4 text-balance text-[28px] font-semibold leading-[1.1] tracking-[-0.03em] md:text-[36px]">{m.downloadingModel()}</h1>
<div className="mt-6 flex flex-col items-center gap-3">
<div className="mt-9 flex flex-col items-center gap-4">
{vm.downloadProgress > 0 && (
<>
<Progress className="w-full max-w-sm" value={vm.downloadProgress} />
{!vm?.location?.state?.downloadURL && <p className="text-sm text-muted-foreground">{m.thisHappensOnce()}</p>}
<Progress className="progress-aurora h-1.5 w-full max-w-sm bg-muted" value={vm.downloadProgress} />
<div className="flex items-center gap-3 text-[13px] text-muted-foreground">
<span className="tabular-nums">{Math.round(vm.downloadProgress)}%</span>
{!vm?.location?.state?.downloadURL && (
<>
<span className="h-1 w-1 rounded-full bg-border" />
<span>{m.thisHappensOnce()}</span>
</>
)}
</div>
</>
)}
{(vm.downloadProgress === 0 || vm.isOnline === null) && <Spinner className="h-8 w-8" />}
{(vm.downloadProgress === 0 || vm.isOnline === null) && <Spinner className="h-6 w-6 text-muted-foreground" />}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" className="mt-5 text-destructive text-xs" onClick={vm.cancelSetup}>
<Button
variant="ghost"
size="sm"
className="mt-8 rounded-full px-4 text-[13px] font-medium text-muted-foreground hover:text-foreground"
onClick={vm.cancelSetup}>
{m.cancel()}
</Button>
</TooltipTrigger>
<TooltipContent>{m.infoCancelDownload()}</TooltipContent>
</Tooltip>
</div>
</motion.div>
<Dialog open={vm.isOnline === false}>
<DialogContent>
<div className="text-center text-2xl font-semibold">{m.noConnection()}</div>
<p className="mt-3 text-center text-muted-foreground">{m.infoManualDownload()}</p>
<div className="mt-5 flex flex-col justify-center gap-2">
<Button className="flex-1" onClick={vm.downloadIfOnline}>
<DialogContent className="rounded-2xl">
<h2 className="text-center text-[22px] font-semibold tracking-[-0.03em]">{m.noConnection()}</h2>
<p className="mt-2 text-center text-[13px] leading-relaxed text-muted-foreground">{m.infoManualDownload()}</p>
<div className="mt-6 flex flex-col justify-center gap-2">
<Button className="rounded-full" onClick={vm.downloadIfOnline}>
{m.tryAgain()}
</Button>
<Button variant="secondary" size="sm" onClick={vm.cancelSetup}>
<Button variant="ghost" size="sm" className="rounded-full text-muted-foreground hover:text-foreground" onClick={vm.cancelSetup}>
{m.iPreferManualSetup()}
</Button>
</div>
+15
View File
@@ -1,5 +1,6 @@
import { ReactNode, SetStateAction, createContext, useContext, useEffect, useRef, useState } from 'react'
import { useLocalStorage } from 'usehooks-ts'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { load } from '@tauri-apps/plugin-store'
import * as config from '~/lib/config'
import { TextFormat } from '~/components/format-select'
@@ -84,6 +85,10 @@ export interface Preference {
analyticsEnabled: boolean
setAnalyticsEnabled: (value: boolean) => void
/** Auto-save every finished transcription into Documents/Vibe (powers the Recents sidebar). */
saveTranscripts: boolean
setSaveTranscripts: ModifyState<boolean>
}
// Create the context
@@ -181,6 +186,7 @@ export function PreferenceProvider({ children }: { children: ReactNode }) {
const [stableTimestampsEnabled, setStableTimestampsEnabled] = useLocalStorage<boolean>('prefs_stable_timestamps_enabled', false)
const [gpuDevice, setGpuDevice] = useLocalStorage<number | null>('prefs_gpu_device', null)
const [unloadTimeoutMinutes, setUnloadTimeoutMinutes] = useLocalStorage<number>('prefs_unload_timeout_minutes', 5)
const [saveTranscripts, setSaveTranscripts] = useLocalStorage<boolean>('prefs_save_transcripts', true)
const [analyticsEnabled, setAnalyticsEnabledLocal] = useState(true)
useEffect(() => {
@@ -222,6 +228,13 @@ export function PreferenceProvider({ children }: { children: ReactNode }) {
} else {
document.documentElement.classList.remove('dark')
}
// Keep the native window appearance in sync so macOS vibrancy (glass sidebar)
// renders light glass in light mode instead of the system appearance.
try {
void getCurrentWebviewWindow().setTheme(theme)
} catch {
/* browser mode */
}
}, [theme])
function setLanguageDefaults() {
@@ -327,6 +340,8 @@ export function PreferenceProvider({ children }: { children: ReactNode }) {
setUnloadTimeoutMinutes,
analyticsEnabled,
setAnalyticsEnabled,
saveTranscripts,
setSaveTranscripts,
}
return <PreferenceContext.Provider value={preference}>{children}</PreferenceContext.Provider>
+4
View File
@@ -16,6 +16,10 @@ install:
dev: setup
cd desktop && pnpm exec tauri dev
# Run only the desktop frontend in the browser (mock Tauri, no Rust build)
dev-web:
cd desktop && pnpm dev
# Build the app for production (runs pre-build first)
build: setup
cd desktop && pnpm exec tauri build
+61
View File
@@ -0,0 +1,61 @@
# Vibe UI Redesign — Design Contract
Premium, calm, editorial. Reference: ElevenLabs product pages. The product promise is
**drop a file → get text**; everything else recedes.
## Principles
- One hero action per screen. Secondary actions are quiet pills or ghost buttons.
- Generous whitespace, hairline borders (1px, low-contrast), big radii (cards 1.25rem, buttons pill).
- Exactly one expressive element per screen: the **aurora** surface (soft multi-hue gradient with
subtle grain) used for the hero dropzone and progress accents. Everything else is monochrome ink/paper.
- Motion: framer-motion, 150250ms ease-out, translate+fade only. No bouncy springs.
## Tokens (globals.css, shadcn-style vars — keep every existing var name working)
Light (default): background #FAFAF8, foreground #111110, card #FFFFFF, muted #F1F1EE,
muted-foreground #6F6F6A, border #E7E7E2, primary = ink #111110 (primary-foreground #FFFFFF),
ring #111110/20.
Dark: background #0E0E0D, card #161615, foreground #F4F4F1, muted #1E1E1C,
muted-foreground #9A9A94, border #262624, primary #F4F4F1 (primary-foreground #111110).
Destructive/success: keep hues, desaturate slightly to match.
Aurora gradient (both themes): conic/radial blend of #5B8DEF, #B98DE3, #E8A87C, #7CC5A0 at low
saturation, heavy blur, optional grain via inline SVG noise; always behind a card or as a thin
progress fill, never behind body text.
Typography: 'Inter Variable' (@fontsource-variable/inter, already installed — import in globals.css)
as --font-sans. Headlines: font-semibold, tracking -0.03em. Body 14px, headlines 2840px.
Eyebrow labels: 11px, uppercase, tracking +0.08em, muted-foreground.
## Architecture (single flow replaces home + batch)
New `src/pages/main/` — one page, three states, one session store:
1. **Idle** — full-window hero: aurora dropzone card centered, "Drop audio or video" headline,
Browse button (primary pill), quiet pills: Record · From link · Folder. Below, a single quiet
row: language select + model name (opens settings model section). Drag-anywhere on window
highlights the zone.
2. **Working** — dropping file(s) starts transcription immediately (no confirm). Left: file queue
(each row: name, duration, thin aurora progress bar, per-file state check/spinner/error).
Right (or full width for single file): live transcript streaming in as segments arrive,
auto-scroll, timestamps in muted ink. Cancel per file + Cancel all.
3. **Done** — transcript reading view: serif-free editorial text blocks with muted timestamps,
sticky toolbar: Copy, Export (format dropdown: srt/vtt/txt/html/pdf/json/docx), Search,
New transcription (resets to Idle). Batch: file list stays left, click to switch transcript.
Topbar (all states): wordmark "Vibe" left (text, tight tracking); right: ghost icon buttons —
New, Settings (gear). No "..." menu.
`/batch` route redirects to `/`. `/setup` keeps its logic but restyled with the same tokens.
Settings modal: keep as-is (it inherits new tokens automatically).
## Code rules
- Reuse existing invoke/event logic — hooks in src/pages/home/hooks and batch view-model show all
wiring (load_model → transcribe, events transcribe_progress / new_segment, abort_transcribe).
- Reuse src/components/ui primitives; restyle via tokens, not forks.
- i18n: use existing `m.*` messages from ~/paraglide/messages.js where a fitting key exists;
otherwise plain English (keys added later).
- Preserve: drag-drop via tauri://drag-drop events (webview) AND browser dnd fallback, deep links,
record + from-URL flows, advanced options reachable (small "Options" ghost button on the quiet row).