chore(format): adopt oxfmt for JS/TS/JSX + CI format gate (#774)

Adds oxfmt (Rust formatter, Prettier-conformant) — the repo had no formatter, so
this is a one-time normalization of the JS/TS/JSX code (257 files; purely
cosmetic — full suite stays 638/638).

Scope is deliberately narrowed in .oxfmtrc.json to JS/TS/JSX only:
- singleQuote:true + jsxSingleQuote:false — preserve the project's existing
  style (single-quoted JS, double-quoted JSX attrs), not oxfmt's double-quote
  default. (Flipping quotes globally also broke a source-string-parsing test;
  preserving them keeps featureCoverage green.)
- Excludes **/*.css (the CSS→Tailwind migration will rewrite those — formatting
  them now is wasted churn), **/*.json (avoids reformatting 20 i18n locale
  files + config), **/*.toml, and src-tauri/** (Rust/Tauri config — out of scope
  for a frontend JS formatter; oxfmt was reformatting Cargo.toml/tauri.conf.json).

Tooling:
- `bun run format` (write) / `bun run format:check` (verify).
- ci.yml: new "Frontend format check (oxfmt)" gate after the oxlint gate.

Verified: format:check clean; oxlint 0 errors; vite build; full suite 638/638;
bun install --frozen-lockfile in sync.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-30 15:18:01 +05:30
committed by GitHub
co-authored by mergetest Claude Opus 4.8
parent f293f10e7e
commit 5c9b7ca313
261 changed files with 15785 additions and 7938 deletions
+6
View File
@@ -112,6 +112,12 @@ jobs:
working-directory: frontend
run: bun run lint
# oxfmt format gate — JS/TS/JSX only (CSS/JSON/Tauri excluded; see
# frontend/.oxfmtrc.json). `bun run format` fixes locally.
- name: Frontend format check (oxfmt)
working-directory: frontend
run: bun run format:check
- name: Run Vitest (frontend)
working-directory: frontend
run: bunx vitest run
+43
View File
@@ -65,6 +65,7 @@
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"knip": "^6.23.0",
"oxfmt": "^0.57.0",
"oxlint": "^1.71.0",
"playwright-core": "1.61.0",
"typescript": "^6.0.3",
@@ -284,6 +285,44 @@
"@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.21.3", "", { "os": "win32", "cpu": "x64" }, "sha512-H7BCt/VnS9hnmMp42eGhZ99izSCRvlnWwy/N71K1/J8QoExwY4262Z8QiEkMDtduRJrztayDxETTckmUuAVL9Q=="],
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.57.0", "", { "os": "android", "cpu": "arm" }, "sha512-qVBsEO+KugOsCmUHcO8iqNnqc65p7PCKpCs8M66mPZ+Ri+CWbcpoQOEJBg2OTu03+0qu++NK1jj6IzvQVs0Sig=="],
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.57.0", "", { "os": "android", "cpu": "arm64" }, "sha512-mp6PibWbao3aizijcheOeHQaYEhcUAt8pwLniYbtLfHxL/psFF0BykAwCj+s3c6qIpa8yN8keZICWrqtZ70w8g=="],
"@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.57.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-T+0stuCBqmUVY+aMIvrgXhzGhHO3sD5tNiiEcYqgSdPsnukskQqn2u5qOVD0sv1l7RLdFS5Z/f5Wi9Ktyjr3Eg=="],
"@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.57.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-O+3JbqWs/mCI2oi4xfhRO2IVPFJNDDEBV8Odo+ZpmsUOeKJfjXoNH7nDmBEQcDgK7NfjDIyE7kRgYSZcTLDO0A=="],
"@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.57.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-pxwhxVC+JkLX9twOQ/8C/vbuOQcMZyKIDmiRDZfO7yITuVcIdZCiLRqqf4QOxb2+8FWrRXzQpm+1DBKcMpHSSQ=="],
"@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.57.0", "", { "os": "linux", "cpu": "arm" }, "sha512-pxBU4zH2imB/MDBfth2rOMeVxXUMjRQLCazagwLARIFH3hVlxZJBlM4nSnHXaIHJK4/qezoFCIORN6AY8Mra4A=="],
"@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.57.0", "", { "os": "linux", "cpu": "arm" }, "sha512-JAprOzt8tycYou36ZgEw14DlRHTiN8qdtKANdV3VZIRIvTI/lh/cX13c9pJ/EnDk2GT3FASH7KvCgQ2AufAifQ=="],
"@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.57.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ajtjaxSaj9xl4BW7REt+Cef/ttzbAq00Bq4z7JUDZEfgFXdwSjH8K9bF+IcIJzZB9lKqMfQ4eHuSFOvvlvtqOg=="],
"@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.57.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-p4Y/+RYk9Bk5WO+zHSUXAClRmZ2fbJCejMuCAsU2HhyME4jqf6Ftt/mJYEwIah1wGCBDYOB7wEGV1x5bCEZ6hA=="],
"@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.57.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-By6tRALAZsno0F4zedmtG+wdMvJiJmJoXM4d3+A9zHE4HRXLqXITwRH8mgrlcXc5yJM2g2W3riRPwTYdgemZLQ=="],
"@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.57.0", "", { "os": "linux", "cpu": "none" }, "sha512-skYeG+RgvyzspqVEBsEprL90OYYZfoVNqB3HcCNR6QDJyXKOzfDRT3zncnHmUaFluIlBHuY23mU1b5WGgR98hA=="],
"@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.57.0", "", { "os": "linux", "cpu": "none" }, "sha512-FFgACrZOXAXUh5KQh2mt1CDOVOZmn+QzHP71wM9QobNwyQvoFfyAeefVUltW83g3sm7LTiH3yfFqLLVUpA5ZFQ=="],
"@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.57.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Nm/BAOfQeFiiKd502mZn/GAVKJwtd0RdCg17G3Wz/WSOIQmDi3+7/SZH4BHn1Ye5KvTVH3ua8WvfwLLycNIuvA=="],
"@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.57.0", "", { "os": "linux", "cpu": "x64" }, "sha512-BiSy5Ku3mQqyxS6YIqAJgd403wEUWvI7kerfzPxc2l/txZVmZM0pSj7oDM+4bGBExowxOi7o73jEam1W0EDTZg=="],
"@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.57.0", "", { "os": "linux", "cpu": "x64" }, "sha512-BCRkJiotz5s9afLYD2LuMvzAoDYx9H17E/YbDyu4xK7l4zHDPeny9ErSXL//i/nJyaOwRk08x4b8cgJC00+JDg=="],
"@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.57.0", "", { "os": "none", "cpu": "arm64" }, "sha512-4Oaxe1qrGgXfpCJ1C/ERJ2iCtV2rN1R79ga9fsfyVHfSQRu/hVW780u2KDqZWFZ/iGTHODJji0JemxqFZ63eIQ=="],
"@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.57.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-MYLAsDnhdNsSGheLYhWgbk0vfIrlS84iQYun/y21fX6u0jj8iBtYtbpZMdiqYeuf8U12eVPUjVY2xE2NrCfJ0g=="],
"@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.57.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-PBwdzZALJY/jcCx2E6is0yu+cuVXeySTDmwuseD+9j0mHqlRNxwlKgsyRTBed/woPeqfVfuXfWjoq4Cx2Zt3Eg=="],
"@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bQJdH9i4RRfw55jm7+8/xS7GzHLLTbHx4huhrrDxQJaJtbSDbsyOnODvP1ftT7EG0KFKAYO2S+q6AcioXODx8w=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.71.0", "", { "os": "android", "cpu": "arm" }, "sha512-ImGmd1njEg4FEJH03jhRnveEegtO3czCtfptvaHivKAZQIYATbVFBrrzbaYMYv0oJioTnxZAZVSyV+oL7W8S2g=="],
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.71.0", "", { "os": "android", "cpu": "arm64" }, "sha512-4A5BEexBrwY1YFF8Kiq/lp/wQPRG79G3BWIE1FuWaM5MvmpYSd+7ZySVcKkHdwo0UDzdQGddp6pD9mpctMqLnw=="],
@@ -914,6 +953,8 @@
"oxc-resolver": ["oxc-resolver@11.21.3", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.21.3", "@oxc-resolver/binding-android-arm64": "11.21.3", "@oxc-resolver/binding-darwin-arm64": "11.21.3", "@oxc-resolver/binding-darwin-x64": "11.21.3", "@oxc-resolver/binding-freebsd-x64": "11.21.3", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.3", "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.3", "@oxc-resolver/binding-linux-arm64-gnu": "11.21.3", "@oxc-resolver/binding-linux-arm64-musl": "11.21.3", "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.3", "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.3", "@oxc-resolver/binding-linux-riscv64-musl": "11.21.3", "@oxc-resolver/binding-linux-s390x-gnu": "11.21.3", "@oxc-resolver/binding-linux-x64-gnu": "11.21.3", "@oxc-resolver/binding-linux-x64-musl": "11.21.3", "@oxc-resolver/binding-openharmony-arm64": "11.21.3", "@oxc-resolver/binding-wasm32-wasi": "11.21.3", "@oxc-resolver/binding-win32-arm64-msvc": "11.21.3", "@oxc-resolver/binding-win32-x64-msvc": "11.21.3" } }, "sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA=="],
"oxfmt": ["oxfmt@0.57.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.57.0", "@oxfmt/binding-android-arm64": "0.57.0", "@oxfmt/binding-darwin-arm64": "0.57.0", "@oxfmt/binding-darwin-x64": "0.57.0", "@oxfmt/binding-freebsd-x64": "0.57.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.57.0", "@oxfmt/binding-linux-arm-musleabihf": "0.57.0", "@oxfmt/binding-linux-arm64-gnu": "0.57.0", "@oxfmt/binding-linux-arm64-musl": "0.57.0", "@oxfmt/binding-linux-ppc64-gnu": "0.57.0", "@oxfmt/binding-linux-riscv64-gnu": "0.57.0", "@oxfmt/binding-linux-riscv64-musl": "0.57.0", "@oxfmt/binding-linux-s390x-gnu": "0.57.0", "@oxfmt/binding-linux-x64-gnu": "0.57.0", "@oxfmt/binding-linux-x64-musl": "0.57.0", "@oxfmt/binding-openharmony-arm64": "0.57.0", "@oxfmt/binding-win32-arm64-msvc": "0.57.0", "@oxfmt/binding-win32-ia32-msvc": "0.57.0", "@oxfmt/binding-win32-x64-msvc": "0.57.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-ZB7Bi+rGDSqmVIo9jwcLyFgjxXvQhDdU+jx+ZrVy6VRiVXK2+CHc4hO3J4dUQjHe7V0ymHB+MDuv5z+NhK07HA=="],
"oxlint": ["oxlint@1.71.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.71.0", "@oxlint/binding-android-arm64": "1.71.0", "@oxlint/binding-darwin-arm64": "1.71.0", "@oxlint/binding-darwin-x64": "1.71.0", "@oxlint/binding-freebsd-x64": "1.71.0", "@oxlint/binding-linux-arm-gnueabihf": "1.71.0", "@oxlint/binding-linux-arm-musleabihf": "1.71.0", "@oxlint/binding-linux-arm64-gnu": "1.71.0", "@oxlint/binding-linux-arm64-musl": "1.71.0", "@oxlint/binding-linux-ppc64-gnu": "1.71.0", "@oxlint/binding-linux-riscv64-gnu": "1.71.0", "@oxlint/binding-linux-riscv64-musl": "1.71.0", "@oxlint/binding-linux-s390x-gnu": "1.71.0", "@oxlint/binding-linux-x64-gnu": "1.71.0", "@oxlint/binding-linux-x64-musl": "1.71.0", "@oxlint/binding-openharmony-arm64": "1.71.0", "@oxlint/binding-win32-arm64-msvc": "1.71.0", "@oxlint/binding-win32-ia32-msvc": "1.71.0", "@oxlint/binding-win32-x64-msvc": "1.71.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-U1m1X+C0vDj7DC1e13IoZULzEcPczE7UOMTs8VlZGHUEIUaSTZKo5qkPsQEfzpgnQ29Pea/w3Xntk62UCecxZw=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
@@ -1050,6 +1091,8 @@
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="],
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"singleQuote": true,
"jsxSingleQuote": false,
"ignorePatterns": [
"**/*.css",
"**/*.json",
"**/*.toml",
"**/*.html",
"**/*.md",
"src-tauri/**"
]
}
+12 -2
View File
@@ -2,8 +2,18 @@ import type { Page } from '@playwright/test';
/** Every routable view (the `mode` values in App.jsx). */
export const MODES = [
'launchpad', 'clone', 'design', 'gallery', 'dub', 'stories',
'projects', 'queue', 'tools', 'transcriptions', 'settings', 'donate',
'launchpad',
'clone',
'design',
'gallery',
'dub',
'stories',
'projects',
'queue',
'tools',
'transcriptions',
'settings',
'donate',
] as const;
/**
+6 -4
View File
@@ -18,7 +18,9 @@ test.describe('OmniVoice Gallery', () => {
expect(bg).toBe('rgba(255, 255, 255, 0.04)');
});
test('opening an archetype in the Designer mounts the design view (no chunk-load failure)', async ({ page }) => {
test('opening an archetype in the Designer mounts the design view (no chunk-load failure)', async ({
page,
}) => {
const errors = collectErrors(page);
await gotoMode(page, 'gallery');
@@ -29,9 +31,9 @@ test.describe('OmniVoice Gallery', () => {
// The design view (CloneDesignTab — the lazy chunk that failed when Vite
// was down) must mount. Its prompt/personality UI is the tell.
await expect(
page.getByText(/personality|prompt|steps/i).first()
).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(/personality|prompt|steps/i).first()).toBeVisible({
timeout: 15_000,
});
await expect(page.getByText(/this tab hit a snag/i)).toHaveCount(0);
expect(errors.fatal, errors.fatal.join('\n')).toEqual([]);
+4 -4
View File
@@ -7,9 +7,9 @@
// rules-of-hooks, no-undef, max-lines, react-refresh) is turned OFF here so the
// two tools don't double-report. Drop this file once oxlint's JS-plugin support
// graduates from alpha and can run eslint-plugin-react-hooks directly.
import reactHooks from 'eslint-plugin-react-hooks'
import globals from 'globals'
import { defineConfig, globalIgnores } from 'eslint/config'
import reactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
import { defineConfig, globalIgnores } from 'eslint/config';
export default defineConfig([
globalIgnores(['dist']),
@@ -30,4 +30,4 @@ export default defineConfig([
'react-hooks/rules-of-hooks': 'off',
},
},
])
]);
+4 -1
View File
@@ -1,7 +1,7 @@
{
"name": "omnivoice-studio",
"private": true,
"version": "0.3.8",
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
@@ -11,6 +11,8 @@
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"lint:hooks": "eslint .",
"format": "oxfmt --write",
"format:check": "oxfmt --check",
"knip": "knip",
"typecheck": "tsc --noEmit",
"typecheck:ci": "tsc --noEmit --checkJs false",
@@ -69,6 +71,7 @@
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"knip": "^6.23.0",
"oxfmt": "^0.57.0",
"oxlint": "^1.71.0",
"playwright-core": "1.61.0",
"typescript": "^6.0.3",
+3 -2
View File
@@ -10,8 +10,9 @@
class AecFrameEmitter extends AudioWorkletProcessor {
constructor(options) {
super();
const frame = (options && options.processorOptions && options.processorOptions.frameSize) || 320;
this._frameSize = frame; // 320 samples = 20 ms @ 16 kHz
const frame =
(options && options.processorOptions && options.processorOptions.frameSize) || 320;
this._frameSize = frame; // 320 samples = 20 ms @ 16 kHz
this._buf = new Float32Array(frame);
this._n = 0;
}
+669 -406
View File
File diff suppressed because it is too large Load Diff
+20 -8
View File
@@ -17,9 +17,10 @@ export interface AudiobookPlan {
}
/** Parse a script into a chapter/span plan (pure preview, no synthesis). */
export async function audiobookPlan(
body: { text: string; default_voice?: string | null },
): Promise<AudiobookPlan> {
export async function audiobookPlan(body: {
text: string;
default_voice?: string | null;
}): Promise<AudiobookPlan> {
const res = await apiFetch('/audiobook/plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -29,16 +30,19 @@ export async function audiobookPlan(
}
export interface AudiobookPreview {
output: string; // path under OUTPUTS_DIR, served via /audio
output: string; // path under OUTPUTS_DIR, served via /audio
duration_s: number;
cached: boolean;
title: string;
}
/** Render a single chapter to audition it (also warms the resume cache). */
export async function audiobookPreviewChapter(
body: { text: string; chapter_index: number; default_voice?: string | null; lexicon?: Record<string, string> | null },
): Promise<AudiobookPreview> {
export async function audiobookPreviewChapter(body: {
text: string;
chapter_index: number;
default_voice?: string | null;
lexicon?: Record<string, string> | null;
}): Promise<AudiobookPreview> {
const res = await apiFetch('/audiobook/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -98,7 +102,15 @@ export async function audiobookImport(file: File): Promise<{ text: string; chapt
}
export interface LongformRenderBody {
chapters: Array<{ title?: string; spans: Array<{ voice_id: string | null; text: string; pause_ms_after: number; speed?: number | null }> }>;
chapters: Array<{
title?: string;
spans: Array<{
voice_id: string | null;
text: string;
pause_ms_after: number;
speed?: number | null;
}>;
}>;
default_voice?: string | null;
bitrate?: string;
format?: 'm4b' | 'mp3';
+15 -4
View File
@@ -34,18 +34,29 @@ describe('_resolveApiBase', () => {
});
it('runtime window.__OMNIVOICE_API_BASE__ wins over everything (Docker prebuilt-image override)', () => {
const win = { __TAURI__: {}, __OMNIVOICE_API_BASE__: 'https://api.example.com/', location: { origin: 'http://x', hostname: 'x' } };
const win = {
__TAURI__: {},
__OMNIVOICE_API_BASE__: 'https://api.example.com/',
location: { origin: 'http://x', hostname: 'x' },
};
// Beats Tauri loopback AND VITE_API_URL; trailing slash stripped.
expect(_resolveApiBase({ VITE_API_URL: 'http://10.0.0.5:9' }, win)).toBe('https://api.example.com');
expect(_resolveApiBase({ VITE_API_URL: 'http://10.0.0.5:9' }, win)).toBe(
'https://api.example.com',
);
});
it('honors VITE_OMNIVOICE_API (the documented Docker var) and strips trailing slash', () => {
const win = { location: { origin: 'http://x', hostname: 'x' } };
expect(_resolveApiBase({ VITE_OMNIVOICE_API: 'http://10.0.0.5:9/' }, win)).toBe('http://10.0.0.5:9');
expect(_resolveApiBase({ VITE_OMNIVOICE_API: 'http://10.0.0.5:9/' }, win)).toBe(
'http://10.0.0.5:9',
);
});
it('Tauri via __TAURI_INTERNALS__ → loopback', () => {
const win = { __TAURI_INTERNALS__: {}, location: { origin: 'tauri://localhost', hostname: 'localhost' } };
const win = {
__TAURI_INTERNALS__: {},
location: { origin: 'tauri://localhost', hostname: 'localhost' },
};
expect(_resolveApiBase({}, win)).toBe('http://127.0.0.1:3900');
});
});
+22 -6
View File
@@ -2,13 +2,22 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
describe('apiFetch PIN header', () => {
let realFetch: typeof globalThis.fetch;
beforeEach(() => { realFetch = globalThis.fetch; sessionStorage.clear(); });
afterEach(() => { globalThis.fetch = realFetch; sessionStorage.clear(); });
beforeEach(() => {
realFetch = globalThis.fetch;
sessionStorage.clear();
});
afterEach(() => {
globalThis.fetch = realFetch;
sessionStorage.clear();
});
it('attaches X-OmniVoice-Pin when present in sessionStorage', async () => {
sessionStorage.setItem('ov_pin', '424242');
const seen: any = {};
globalThis.fetch = vi.fn((_url, opts) => { Object.assign(seen, opts); return Promise.resolve({ ok: true, json: async () => ({}) }); }) as any;
globalThis.fetch = vi.fn((_url, opts) => {
Object.assign(seen, opts);
return Promise.resolve({ ok: true, json: async () => ({}) });
}) as any;
const { apiFetch } = await import('./client');
await apiFetch('/system/info');
expect((seen.headers || {})['X-OmniVoice-Pin']).toBe('424242');
@@ -16,7 +25,10 @@ describe('apiFetch PIN header', () => {
it('omits the header when no pin', async () => {
const seen: any = {};
globalThis.fetch = vi.fn((_url, opts) => { Object.assign(seen, opts); return Promise.resolve({ ok: true, json: async () => ({}) }); }) as any;
globalThis.fetch = vi.fn((_url, opts) => {
Object.assign(seen, opts);
return Promise.resolve({ ok: true, json: async () => ({}) });
}) as any;
const { apiFetch } = await import('./client');
await apiFetch('/system/info');
expect((seen.headers || {})['X-OmniVoice-Pin']).toBeUndefined();
@@ -27,9 +39,13 @@ describe('apiFetch PIN header', () => {
globalThis.fetch = vi.fn(() => Promise.reject(new TypeError('Failed to fetch'))) as any;
const { apiFetch, ApiError } = await import('./client');
let err: any;
try { await apiFetch('/system/info'); } catch (e) { err = e; }
try {
await apiFetch('/system/info');
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(ApiError);
expect(err.status).toBe(0); // transport failure, not HTTP
expect(err.status).toBe(0); // transport failure, not HTTP
expect(String(err.message)).toMatch(/reach the local OmniVoice backend/i);
expect(String(err.detail)).toMatch(/Failed to fetch/);
});
+17 -6
View File
@@ -31,8 +31,11 @@ export function _resolveApiBase(env: any, win: any): string {
let stored = '';
try {
stored = (win && win.localStorage && win.localStorage.getItem(LS_BACKEND_URL)) || '';
} catch { /* storage unavailable (privacy mode) */ }
const runtime = win && typeof win.__OMNIVOICE_API_BASE__ === 'string' ? win.__OMNIVOICE_API_BASE__ : '';
} catch {
/* storage unavailable (privacy mode) */
}
const runtime =
win && typeof win.__OMNIVOICE_API_BASE__ === 'string' ? win.__OMNIVOICE_API_BASE__ : '';
const override = stored || runtime || env?.VITE_OMNIVOICE_API || env?.VITE_API_URL;
if (override) return String(override).replace(/\/+$/, '');
if (!win) return `http://127.0.0.1:${port}`;
@@ -70,7 +73,9 @@ if (typeof window !== 'undefined') {
try {
const p = new URL(window.location.href).searchParams.get('pin');
if (p) sessionStorage.setItem('ov_pin', p);
} catch { /* noop */ }
} catch {
/* noop */
}
}
export class ApiError extends Error {
@@ -140,7 +145,7 @@ export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Re
}
throw new ApiError(
"Can't reach the local OmniVoice backend — it may still be starting up, or it stopped. " +
"Wait a few seconds and try again; if it persists, restart the app (or check Settings → Logs → Backend).",
'Wait a few seconds and try again; if it persists, restart the app (or check Settings → Logs → Backend).',
{ status: 0, detail: lastDetail },
);
}
@@ -151,7 +156,10 @@ export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Re
window.dispatchEvent(new Event('ov:pin-required'));
}
const detail = await readError(res);
throw new ApiError(`${res.status} ${res.statusText}: ${detail}`, { status: res.status, detail });
throw new ApiError(`${res.status} ${res.statusText}: ${detail}`, {
status: res.status,
detail,
});
}
return res;
}
@@ -171,7 +179,10 @@ export async function apiPost<T = unknown>(
if (body instanceof FormData) {
init.body = body;
} else if (body !== undefined) {
init.headers = { 'Content-Type': 'application/json', ...(opts.headers as Record<string, string>) };
init.headers = {
'Content-Type': 'application/json',
...(opts.headers as Record<string, string>),
};
init.body = JSON.stringify(body);
}
return apiJson<T>(path, init);
+4 -1
View File
@@ -52,7 +52,10 @@ export const listCommunityItems = (filters: CommunityFilters = {}): Promise<Comm
export const communitySubmitUrl = (type: 'preset' | 'voice'): Promise<{ url: string }> =>
apiJson(`/community/submit-url?type=${type}`);
export const addCommunityItem = (id: string, name?: string): Promise<{ profile_id: string; name: string }> => {
export const addCommunityItem = (
id: string,
name?: string,
): Promise<{ profile_id: string; name: string }> => {
const q = name ? `?name=${encodeURIComponent(name)}` : '';
return apiJson(`/community/items/${encodeURIComponent(id)}/use${q}`, { method: 'POST' });
};
+21 -6
View File
@@ -9,7 +9,7 @@ export async function dubUpload(
const fd = new FormData();
fd.append('video', file);
fd.append('job_id', jobId);
fd.append('input_type', inputType); // #119: audio-only dubbing
fd.append('input_type', inputType); // #119: audio-only dubbing
return apiPost('/dub/upload', fd, { signal });
}
@@ -50,7 +50,11 @@ export function transcribeStreamUrl(jobId: string, numSpeakers?: number | null):
}
export async function dubAbort(jobId: string): Promise<void> {
try { await apiFetch(`/dub/abort/${jobId}`, { method: 'POST' }); } catch { /* best-effort */ }
try {
await apiFetch(`/dub/abort/${jobId}`, { method: 'POST' });
} catch {
/* best-effort */
}
}
export async function dubCleanupSegments(jobId: string): Promise<unknown> {
@@ -74,7 +78,10 @@ export interface DubImportSrtResponse {
};
}
export async function dubImportSrt(jobId: string, file: File | Blob): Promise<DubImportSrtResponse> {
export async function dubImportSrt(
jobId: string,
file: File | Blob,
): Promise<DubImportSrtResponse> {
const fd = new FormData();
fd.append('file', file);
return apiPost<DubImportSrtResponse>(`/dub/import-srt/${jobId}`, fd);
@@ -110,14 +117,22 @@ export interface DubQCResponse {
flagged_count: number;
drift_threshold: number;
segments: {
seg_id: string; drift: number; flagged: boolean;
recognized_text: string; measured_start: number | null; measured_end: number | null;
seg_id: string;
drift: number;
flagged: boolean;
recognized_text: string;
measured_start: number | null;
measured_end: number | null;
}[];
}
/** Wave 3.3: second-pass ASR QC — re-recognize the dubbed audio and flag
* lines whose recognized text drifts from the target. Non-destructive. */
export async function dubQc(jobId: string, lang?: string, driftThreshold?: number): Promise<DubQCResponse> {
export async function dubQc(
jobId: string,
lang?: string,
driftThreshold?: number,
): Promise<DubQCResponse> {
const qs = new URLSearchParams();
if (lang) qs.set('lang', lang);
if (driftThreshold != null) qs.set('drift_threshold', String(driftThreshold));
+10 -2
View File
@@ -23,7 +23,12 @@ export interface TranslationEnginesResponse {
sandboxed: boolean;
}
export interface InstallEngineResponse {
status: 'installed' | 'already_installed' | 'installed_but_probe_failed' | 'uninstalled' | 'no_op';
status:
| 'installed'
| 'already_installed'
| 'installed_but_probe_failed'
| 'uninstalled'
| 'no_op';
engine: string;
package?: string;
log_tail?: string;
@@ -34,7 +39,10 @@ export async function listEngines(): Promise<AllEnginesResponse> {
return apiJson<AllEnginesResponse>('/engines');
}
export async function selectEngine(family: EngineFamily, backendId: string): Promise<SelectEngineResponse> {
export async function selectEngine(
family: EngineFamily,
backendId: string,
): Promise<SelectEngineResponse> {
return apiPost<SelectEngineResponse>('/engines/select', { family, backend_id: backendId });
}
+22 -11
View File
@@ -16,13 +16,19 @@ export interface GalleryVoice {
created_at: number;
}
export const listGalleryVoices = (params?: { category?: string; search?: string; limit?: number }): Promise<GalleryVoice[]> => {
const query = params ? '?' + new URLSearchParams(params as Record<string, string>).toString() : '';
export const listGalleryVoices = (params?: {
category?: string;
search?: string;
limit?: number;
}): Promise<GalleryVoice[]> => {
const query = params
? '?' + new URLSearchParams(params as Record<string, string>).toString()
: '';
return apiJson(`/gallery/voices${query}`);
};
export const deleteGalleryVoice = (voiceId: string): Promise<{ success: boolean }> =>
apiFetch(`/gallery/voices/${voiceId}`, { method: 'DELETE' }).then(r => r.json());
export const deleteGalleryVoice = (voiceId: string): Promise<{ success: boolean }> =>
apiFetch(`/gallery/voices/${voiceId}`, { method: 'DELETE' }).then((r) => r.json());
export interface YoutubeSearchResult {
title: string;
@@ -32,9 +38,9 @@ export interface YoutubeSearchResult {
}
export const searchYoutube = async (
query: string,
category: string,
maxResults: number = 5
query: string,
category: string,
maxResults: number = 5,
): Promise<{ results: YoutubeSearchResult[]; query: string; category: string }> => {
const url = `/gallery/search/youtube?query=${encodeURIComponent(query)}&category=${encodeURIComponent(category)}&max_results=${maxResults}`;
return apiJson(url, { method: 'POST' });
@@ -49,17 +55,22 @@ export interface DownloadParams {
description?: string;
}
export const downloadYoutubeClip = async (params: DownloadParams): Promise<{ success: boolean; voice_id: string }> => {
export const downloadYoutubeClip = async (
params: DownloadParams,
): Promise<{ success: boolean; voice_id: string }> => {
const url = `/gallery/download?video_url=${encodeURIComponent(params.video_url)}&start_time=${params.start_time}&duration=${params.duration}&character_name=${encodeURIComponent(params.character_name)}&category=${encodeURIComponent(params.category)}&description=${encodeURIComponent(params.description || '')}`;
return apiJson(url, { method: 'POST' });
};
export const uploadVoiceClip = async (formData: FormData): Promise<{ id: string; name: string }> =>
export const uploadVoiceClip = async (formData: FormData): Promise<{ id: string; name: string }> =>
apiPost('/gallery/upload', formData);
export const saveVoiceAsProfile = async (voiceId: string, profileName: string): Promise<{ profile_id: string; name: string }> => {
export const saveVoiceAsProfile = async (
voiceId: string,
profileName: string,
): Promise<{ profile_id: string; name: string }> => {
const url = `/gallery/voices/${voiceId}/save-as-profile?profile_name=${encodeURIComponent(profileName)}`;
return apiJson(url, { method: 'POST' });
};
export const previewVoiceUrl = (voiceId: string): string => `/gallery/voices/${voiceId}/preview`;
export const previewVoiceUrl = (voiceId: string): string => `/gallery/voices/${voiceId}/preview`;
+12 -3
View File
@@ -5,7 +5,10 @@ export async function listGlossary(projectId: string): Promise<GlossaryTerm[]> {
return apiJson<GlossaryTerm[]>(`/glossary/${encodeURIComponent(projectId)}`);
}
export async function addGlossaryTerm(projectId: string, term: Partial<GlossaryTerm>): Promise<GlossaryTerm> {
export async function addGlossaryTerm(
projectId: string,
term: Partial<GlossaryTerm>,
): Promise<GlossaryTerm> {
return apiPost<GlossaryTerm>(`/glossary/${encodeURIComponent(projectId)}`, term);
}
@@ -22,14 +25,20 @@ export async function updateGlossaryTerm(
return r.json() as Promise<GlossaryTerm>;
}
export async function deleteGlossaryTerm(projectId: string, termId: number): Promise<DeletedResponse> {
export async function deleteGlossaryTerm(
projectId: string,
termId: number,
): Promise<DeletedResponse> {
const r = await apiFetch(`/glossary/${encodeURIComponent(projectId)}/${termId}`, {
method: 'DELETE',
});
return r.json() as Promise<DeletedResponse>;
}
export async function clearGlossary(projectId: string, onlyAuto: boolean = false): Promise<DeletedResponse> {
export async function clearGlossary(
projectId: string,
onlyAuto: boolean = false,
): Promise<DeletedResponse> {
const qs = onlyAuto ? '?only_auto=true' : '';
const r = await apiFetch(`/glossary/${encodeURIComponent(projectId)}${qs}`, {
method: 'DELETE',
+12 -13
View File
@@ -15,21 +15,21 @@ import type { CommunityFilters } from './community';
// ── Keys (prevents typos, enables targeted invalidation) ─────────────────
export const queryKeys = {
sysinfo: ['sysinfo'] as const,
modelStatus: ['model-status'] as const,
notifications: ['notifications'] as const,
systemInfo: ['system-info'] as const,
systemLogs: (tail?: number) => ['system-logs', tail ?? 300] as const,
tauriLogs: (tail?: number) => ['tauri-logs', tail ?? 300] as const,
models: ['models'] as const,
sysinfo: ['sysinfo'] as const,
modelStatus: ['model-status'] as const,
notifications: ['notifications'] as const,
systemInfo: ['system-info'] as const,
systemLogs: (tail?: number) => ['system-logs', tail ?? 300] as const,
tauriLogs: (tail?: number) => ['tauri-logs', tail ?? 300] as const,
models: ['models'] as const,
recommendations: ['recommendations'] as const,
preflight: ['preflight'] as const,
setupStatus: ['setup-status'] as const,
galleryVoices: (params?: any) => ['gallery-voices', params] as const,
preflight: ['preflight'] as const,
setupStatus: ['setup-status'] as const,
galleryVoices: (params?: any) => ['gallery-voices', params] as const,
galleryCategories: ['gallery-categories'] as const,
archetypeCategories: ['archetype-categories'] as const,
archetypes: (filters?: any) => ['archetypes', filters] as const,
communityItems: (filters?: any) => ['community-items', filters] as const,
archetypes: (filters?: any) => ['archetypes', filters] as const,
communityItems: (filters?: any) => ['community-items', filters] as const,
communityManifest: (refresh?: boolean) => ['community-manifest', !!refresh] as const,
};
@@ -204,4 +204,3 @@ export function useDeleteModel() {
},
});
}
+5 -1
View File
@@ -25,7 +25,11 @@ describe('exportPersona', () => {
it('builds the query string for license/tags/include_reference', async () => {
apiFetch.mockResolvedValue({ ok: true, blob: () => Promise.resolve(new Blob(['z'])) });
await exportPersona('abc', { license_spdx: 'CC-BY-4.0', tags: 'a,b', include_reference: false });
await exportPersona('abc', {
license_spdx: 'CC-BY-4.0',
tags: 'a,b',
include_reference: false,
});
const url = apiFetch.mock.calls[0][0] as string;
expect(url).toContain('license_spdx=CC-BY-4.0');
expect(url).toContain('tags=a%2Cb');
+4 -1
View File
@@ -5,7 +5,10 @@ export async function listProjects(): Promise<ProjectSummary[]> {
return apiJson<ProjectSummary[]>('/projects');
}
export async function saveProject(body: Record<string, unknown>, id?: string): Promise<ProjectDetail> {
export async function saveProject(
body: Record<string, unknown>,
id?: string,
): Promise<ProjectDetail> {
if (id) return apiPost<ProjectDetail>(`/projects/${id}`, body, { method: 'PUT' });
return apiPost<ProjectDetail>('/projects', body);
}
+3 -2
View File
@@ -84,7 +84,9 @@ export async function getRecommendations(): Promise<Recommendations> {
return apiJson<Recommendations>('/setup/recommendations');
}
export async function deleteModel(repo_id: string): Promise<{ deleted: boolean; repo_id: string; freed_bytes: number }> {
export async function deleteModel(
repo_id: string,
): Promise<{ deleted: boolean; repo_id: string; freed_bytes: number }> {
// HF repo_ids look like "owner/name" — encode each segment so special chars
// are escaped but the literal "/" survives into FastAPI's `:path` converter.
// encodeURIComponent on the whole string would turn "/" into "%2F", which
@@ -128,4 +130,3 @@ export interface PreflightReport {
export async function preflight(): Promise<PreflightReport> {
return apiJson<PreflightReport>('/setup/preflight');
}
+6 -12
View File
@@ -56,10 +56,8 @@ const VRAM_CACHE_TTL = 15_000;
export async function sysinfo(): Promise<SysinfoData> {
// Rust provides CPU + RAM; VRAM stays at 0. We merge with the Python
// endpoint to get GPU data when available.
const rustData = await invokeOrFetch<SysinfoData>(
'get_sysinfo',
undefined,
() => apiJson<SysinfoData>('/sysinfo'),
const rustData = await invokeOrFetch<SysinfoData>('get_sysinfo', undefined, () =>
apiJson<SysinfoData>('/sysinfo'),
);
// If we got data from Rust (vram=0), enrich with Python's VRAM data
@@ -124,18 +122,14 @@ export async function systemNotifications(): Promise<NotificationsResponse> {
// ── Logs (polled every 5s) ───────────────────────────────────────────────
export async function systemLogs(tail: number = 300): Promise<LogsResponse> {
return invokeOrFetch<LogsResponse>(
'read_log_tail',
{ source: 'backend', tail },
() => apiJson<LogsResponse>(`/system/logs?tail=${tail}`),
return invokeOrFetch<LogsResponse>('read_log_tail', { source: 'backend', tail }, () =>
apiJson<LogsResponse>(`/system/logs?tail=${tail}`),
);
}
export async function systemLogsTauri(tail: number = 300): Promise<LogsResponse> {
return invokeOrFetch<LogsResponse>(
'read_log_tail',
{ source: 'tauri', tail },
() => apiJson<LogsResponse>(`/system/logs/tauri?tail=${tail}`),
return invokeOrFetch<LogsResponse>('read_log_tail', { source: 'tauri', tail }, () =>
apiJson<LogsResponse>(`/system/logs/tauri?tail=${tail}`),
);
}
+9 -4
View File
@@ -22,8 +22,7 @@ type GPUTarget = 'cuda' | 'mps' | 'rocm' | 'xpu' | 'cpu';
// Where an engine actually runs on THIS host. `network` is LLM-only (remote).
type EffectiveDevice = GPUTarget | 'network';
// `n/a` is LLM-only; resolve_routing only ever returns the first four.
type RoutingStatus =
| 'accelerated' | 'cpu_fallback' | 'cpu_only' | 'unavailable' | 'n/a';
type RoutingStatus = 'accelerated' | 'cpu_fallback' | 'cpu_only' | 'unavailable' | 'n/a';
interface EngineBackend {
id: string;
@@ -151,7 +150,7 @@ export interface PersonaImportResult {
}
export interface PersonaBundleMeta {
format: string; // "ovsvoice" | "omnivoice-legacy"
format: string; // "ovsvoice" | "omnivoice-legacy"
schema_version: number;
name: string;
kind: ProfileKind | string;
@@ -203,7 +202,13 @@ export interface DubHistoryResponse {
}
export interface DubTranslateResponse {
segments: { id: string; text: string; text_original?: string; rate_ratio?: number; rate_error?: string }[];
segments: {
id: string;
text: string;
text_original?: string;
rate_ratio?: number;
rate_error?: string;
}[];
}
// ── Generic ──────────────────────────────────────────────────────────────
+253 -71
View File
@@ -1,10 +1,19 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { Scissors, Play, Pause, Check, ZoomIn, ZoomOut, Maximize2, Repeat } from 'lucide-react';
import {
clamp, encodeWav, computePeaksFromChannel, computePeaksAsync, pickTickInterval,
xToTime as xToTimeUtil, pickHandle as pickHandleUtil,
applyDrag as applyDragUtil, zoomAtCursor, zoomCenter, sliceToMono,
decodeToMonoLowRate, DEFAULT_PEAK_BUCKETS,
clamp,
encodeWav,
computePeaksFromChannel,
computePeaksAsync,
pickTickInterval,
xToTime as xToTimeUtil,
pickHandle as pickHandleUtil,
applyDrag as applyDragUtil,
zoomAtCursor,
zoomCenter,
sliceToMono,
decodeToMonoLowRate,
DEFAULT_PEAK_BUCKETS,
} from '../utils/audioTrim.js';
import { Dialog, Button } from '../ui';
import { useTranslation } from 'react-i18next';
@@ -58,13 +67,21 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
useEffect(() => {
stateRef.current = {
start, end, cursor, viewStart, viewEnd,
start,
end,
cursor,
viewStart,
viewEnd,
duration: bufferRef.current ? bufferRef.current.duration : 0,
};
}, [start, end, cursor, viewStart, viewEnd]);
useEffect(() => { setStartInput(start.toFixed(2)); }, [start]);
useEffect(() => { setEndInput(end.toFixed(2)); }, [end]);
useEffect(() => {
setStartInput(start.toFixed(2));
}, [start]);
useEffect(() => {
setEndInput(end.toFixed(2));
}, [end]);
// Decode (low rate mono) + async peaks — keeps UI responsive for long files.
useEffect(() => {
@@ -92,7 +109,9 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
const refined = await computePeaksAsync(
buf.getChannelData(0),
DEFAULT_PEAK_BUCKETS,
(p) => { if (!cancelled) setPeakProgress(p); },
(p) => {
if (!cancelled) setPeakProgress(p);
},
);
if (cancelled) return;
peaksRef.current = refined;
@@ -102,7 +121,9 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
setDecoding(false);
}
})();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [file, maxSeconds]);
// Bind audio src
@@ -110,9 +131,18 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
if (!file) return;
const url = URL.createObjectURL(file);
const a = audioRef.current;
if (a) { a.src = url; a.load(); }
if (a) {
a.src = url;
a.load();
}
return () => {
if (a) { try { a.pause(); a.removeAttribute('src'); a.load(); } catch {} }
if (a) {
try {
a.pause();
a.removeAttribute('src');
a.load();
} catch {}
}
URL.revokeObjectURL(url);
};
}, [file]);
@@ -139,7 +169,14 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, w, h);
const { viewStart: vs, viewEnd: ve, start: s, end: e, cursor: c, duration: dur } = stateRef.current;
const {
viewStart: vs,
viewEnd: ve,
start: s,
end: e,
cursor: c,
duration: dur,
} = stateRef.current;
const viewDur = Math.max(1e-6, ve - vs);
const totalBuckets = peaks.length / 2;
const secPerBucket = dur / totalBuckets;
@@ -161,7 +198,8 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
for (let x = 0; x < w; x++) {
const t0 = vs + (x / w) * viewDur;
const t1 = vs + ((x + 1) / w) * viewDur;
let mn = 1, mx = -1;
let mn = 1,
mx = -1;
if (useRaw) {
const i0 = Math.max(0, Math.floor(t0 * sr));
const i1 = Math.min(ch.length, Math.ceil(t1 * sr));
@@ -180,7 +218,10 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
if (pmx > mx) mx = pmx;
}
}
if (mx < mn) { mn = 0; mx = 0; }
if (mx < mn) {
mn = 0;
mx = 0;
}
const y1 = (1 - mx) * 0.5 * h;
const y2 = (1 - mn) * 0.5 * h;
ctx.fillRect(x, y1, 1, Math.max(1, y2 - y1));
@@ -203,7 +244,8 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
for (let x = Math.max(0, Math.floor(sx)); x < Math.min(w, Math.ceil(ex)); x++) {
const t0 = vs + (x / w) * viewDur;
const t1 = vs + ((x + 1) / w) * viewDur;
let mn = 1, mx = -1;
let mn = 1,
mx = -1;
if (useRaw) {
const i0 = Math.max(0, Math.floor(t0 * sr));
const i1 = Math.min(ch.length, Math.ceil(t1 * sr));
@@ -222,7 +264,10 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
if (pmx > mx) mx = pmx;
}
}
if (mx < mn) { mn = 0; mx = 0; }
if (mx < mn) {
mn = 0;
mx = 0;
}
const y1 = (1 - mx) * 0.5 * h;
const y2 = (1 - mn) * 0.5 * h;
ctx.fillRect(x, y1, 1, Math.max(1, y2 - y1));
@@ -289,7 +334,9 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
});
}, [drawWave, drawRuler]);
useEffect(() => { scheduleDraw(); }, [start, end, cursor, viewStart, viewEnd, ready, scheduleDraw]);
useEffect(() => {
scheduleDraw();
}, [start, end, cursor, viewStart, viewEnd, ready, scheduleDraw]);
useEffect(() => {
const onResize = () => scheduleDraw();
@@ -305,8 +352,12 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
const xToTime = (clientX) => {
const rect = canvasRect();
if (!rect) return 0;
return xToTimeUtil(clientX - rect.left, rect.width,
stateRef.current.viewStart, stateRef.current.viewEnd);
return xToTimeUtil(
clientX - rect.left,
rect.width,
stateRef.current.viewStart,
stateRef.current.viewEnd,
);
};
const pickHandleAt = (clientX) => {
@@ -324,16 +375,25 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
if (!buffer || !rect) return;
const drag = dragStateRef.current;
if (!drag) {
const t = xToTimeUtil(pos.clientX - rect.left, rect.width,
stateRef.current.viewStart, stateRef.current.viewEnd);
const t = xToTimeUtil(
pos.clientX - rect.left,
rect.width,
stateRef.current.viewStart,
stateRef.current.viewEnd,
);
setCursor(clamp(t, 0, stateRef.current.duration));
return;
}
const out = applyDragUtil(stateRef.current, pos.clientX, rect.left, rect.width, drag, 0.02);
if (drag.mode === 'start') setStart(out.start);
else if (drag.mode === 'end') setEnd(out.end);
else if (drag.mode === 'region' || drag.mode === 'new') { setStart(out.start); setEnd(out.end); }
else if (drag.mode === 'pan') { setViewStart(out.viewStart); setViewEnd(out.viewEnd); }
else if (drag.mode === 'region' || drag.mode === 'new') {
setStart(out.start);
setEnd(out.end);
} else if (drag.mode === 'pan') {
setViewStart(out.viewStart);
setViewEnd(out.viewEnd);
}
}, []);
const schedulePointer = useCallback(() => {
@@ -349,7 +409,10 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
pointerRef.current = { clientX: e.clientX };
schedulePointer();
};
const up = () => { dragStateRef.current = null; pointerRef.current = null; };
const up = () => {
dragStateRef.current = null;
pointerRef.current = null;
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
return () => {
@@ -391,7 +454,11 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
setEnd(t);
setCursor(t);
const a = audioRef.current;
if (a) { try { a.currentTime = t; } catch {} }
if (a) {
try {
a.currentTime = t;
} catch {}
}
dragStateRef.current = { mode: 'new', anchorT: t };
}
pointerRef.current = { clientX: e.clientX };
@@ -424,30 +491,38 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
useEffect(() => {
const canvas = waveRef.current;
if (!canvas) return;
const handler = (e) => { if (ready) onWheel(e); };
const handler = (e) => {
if (ready) onWheel(e);
};
canvas.addEventListener('wheel', handler, { passive: false });
return () => canvas.removeEventListener('wheel', handler);
}, [onWheel, ready]);
const zoomIn = () => {
const buffer = bufferRef.current; if (!buffer) return;
const buffer = bufferRef.current;
if (!buffer) return;
const { viewStart: vs, viewEnd: ve } = stateRef.current;
const out = zoomCenter(vs, ve, buffer.duration, 0.5);
setViewStart(out.viewStart); setViewEnd(out.viewEnd);
setViewStart(out.viewStart);
setViewEnd(out.viewEnd);
};
const zoomOut = () => {
const buffer = bufferRef.current; if (!buffer) return;
const buffer = bufferRef.current;
if (!buffer) return;
const { viewStart: vs, viewEnd: ve } = stateRef.current;
const out = zoomCenter(vs, ve, buffer.duration, 2);
setViewStart(out.viewStart); setViewEnd(out.viewEnd);
setViewStart(out.viewStart);
setViewEnd(out.viewEnd);
};
const fitAll = () => {
const buffer = bufferRef.current; if (!buffer) return;
const buffer = bufferRef.current;
if (!buffer) return;
setViewStart(0);
setViewEnd(buffer.duration);
};
const fitSelection = () => {
const buffer = bufferRef.current; if (!buffer) return;
const buffer = bufferRef.current;
if (!buffer) return;
const { start: s, end: e } = stateRef.current;
const pad = Math.max(0.1, (e - s) * 0.2);
const nvs = clamp(s - pad, 0, buffer.duration);
@@ -459,13 +534,23 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
const togglePlay = () => {
const a = audioRef.current;
if (!a) return;
if (playing) { a.pause(); setPlaying(false); return; }
if (playing) {
a.pause();
setPlaying(false);
return;
}
const s = stateRef.current.start;
const doPlay = () => {
try { a.currentTime = s; } catch (err) { console.warn('currentTime set failed', err); }
a.play().then(() => setPlaying(true)).catch((err) => {
setError(t('trimmer.playback_failed', { message: err.message || err }));
});
try {
a.currentTime = s;
} catch (err) {
console.warn('currentTime set failed', err);
}
a.play()
.then(() => setPlaying(true))
.catch((err) => {
setError(t('trimmer.playback_failed', { message: err.message || err }));
});
};
// HAVE_METADATA = 1 is enough to set currentTime on most browsers.
if (a.readyState >= 1) {
@@ -481,12 +566,17 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
if (!a) return;
let raf = null;
const tick = () => {
if (a.paused) { raf = null; return; }
if (a.paused) {
raf = null;
return;
}
setCursor(a.currentTime);
const { start: s, end: e } = stateRef.current;
if (a.currentTime >= e) {
if (loop) {
try { a.currentTime = s; } catch {}
try {
a.currentTime = s;
} catch {}
} else {
a.pause();
setPlaying(false);
@@ -498,8 +588,15 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
};
// Only run the playhead loop while audio is actually playing — a
// free-running rAF would tick at ~60fps for the trimmer's whole life.
const startLoop = () => { if (raf == null) raf = requestAnimationFrame(tick); };
const stopLoop = () => { if (raf != null) { cancelAnimationFrame(raf); raf = null; } };
const startLoop = () => {
if (raf == null) raf = requestAnimationFrame(tick);
};
const stopLoop = () => {
if (raf != null) {
cancelAnimationFrame(raf);
raf = null;
}
};
a.addEventListener('play', startLoop);
a.addEventListener('pause', stopLoop);
a.addEventListener('ended', stopLoop);
@@ -535,7 +632,11 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
const tgt = e.target;
if (tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA')) return;
const fine = e.shiftKey ? 0.01 : e.altKey ? 1.0 : 0.1;
if (e.key === ' ' || e.code === 'Space') { e.preventDefault(); togglePlay(); return; }
if (e.key === ' ' || e.code === 'Space') {
e.preventDefault();
togglePlay();
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
if (e.ctrlKey || e.metaKey) setEnd(clamp(end - fine, start + 0.02, buffer.duration));
@@ -544,16 +645,30 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
e.preventDefault();
if (e.ctrlKey || e.metaKey) setEnd(clamp(end + fine, start + 0.02, buffer.duration));
else setStart(clamp(start + fine, 0, Math.max(0, end - 0.02)));
} else if (e.key === '+' || e.key === '=') { e.preventDefault(); zoomIn(); }
else if (e.key === '-' || e.key === '_') { e.preventDefault(); zoomOut(); }
else if (e.key === 'Home') { e.preventDefault(); fitAll(); }
else if (e.key === 'End') { e.preventDefault(); fitSelection(); }
else if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
else if (e.key === 'Enter') { if (!tooLong && !tooShort) handleConfirm(); }
} else if (e.key === '+' || e.key === '=') {
e.preventDefault();
zoomIn();
} else if (e.key === '-' || e.key === '_') {
e.preventDefault();
zoomOut();
} else if (e.key === 'Home') {
e.preventDefault();
fitAll();
} else if (e.key === 'End') {
e.preventDefault();
fitSelection();
} else if (e.key === 'Escape') {
e.preventDefault();
onCancel();
} else if (e.key === 'Enter') {
if (!tooLong && !tooShort) handleConfirm();
}
};
const keyHandlerRef = useRef(onKeyDown);
useEffect(() => { keyHandlerRef.current = onKeyDown; }, [onKeyDown]);
useEffect(() => {
keyHandlerRef.current = onKeyDown;
}, [onKeyDown]);
useEffect(() => {
const node = containerRef.current;
@@ -581,31 +696,70 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
open
onClose={onCancel}
size="xl"
title={<><Scissors size={15} color="var(--color-brand)" /> {t('trimmer.title')}</>}
title={
<>
<Scissors size={15} color="var(--color-brand)" /> {t('trimmer.title')}
</>
}
>
<div ref={containerRef} tabIndex={-1} className="audio-trimmer">
<div className="audio-trimmer__meta">
<span>{decoding
? t('trimmer.decoding')
: (audioMeta
<span>
{decoding
? t('trimmer.decoding')
: audioMeta
? `${t('trimmer.meta_length', { duration: fmtHMS(audioMeta.duration), sampleRate: audioMeta.sampleRate })}${peakProgress > 0 && peakProgress < 1 ? ` · ${t('trimmer.meta_rendering', { percent: Math.round(peakProgress * 100) })}` : ''}`
: '…')
}</span>
<span className="audio-trimmer__hint">
{t('trimmer.keyboard_hint')}
: '…'}
</span>
<span className="audio-trimmer__hint">{t('trimmer.keyboard_hint')}</span>
</div>
{error && <div className="audio-trimmer__error">{error}</div>}
{/* Zoom controls */}
<div className="audio-trimmer__toolbar">
<Button variant="subtle" iconSize="md" onClick={zoomIn} disabled={!ready} title={t('trimmer.zoom_in')}><ZoomIn size={12}/></Button>
<Button variant="subtle" iconSize="md" onClick={zoomOut} disabled={!ready} title={t('trimmer.zoom_out')}><ZoomOut size={12}/></Button>
<Button variant="subtle" iconSize="md" onClick={fitAll} disabled={!ready} title={t('trimmer.fit_all')}><Maximize2 size={12}/></Button>
<Button variant="chip" size="sm" onClick={fitSelection} disabled={!ready} title={t('trimmer.fit_selection')}>{t('trimmer.fit_sel_btn')}</Button>
<Button
variant="subtle"
iconSize="md"
onClick={zoomIn}
disabled={!ready}
title={t('trimmer.zoom_in')}
>
<ZoomIn size={12} />
</Button>
<Button
variant="subtle"
iconSize="md"
onClick={zoomOut}
disabled={!ready}
title={t('trimmer.zoom_out')}
>
<ZoomOut size={12} />
</Button>
<Button
variant="subtle"
iconSize="md"
onClick={fitAll}
disabled={!ready}
title={t('trimmer.fit_all')}
>
<Maximize2 size={12} />
</Button>
<Button
variant="chip"
size="sm"
onClick={fitSelection}
disabled={!ready}
title={t('trimmer.fit_selection')}
>
{t('trimmer.fit_sel_btn')}
</Button>
<div className="audio-trimmer__view-info">
{t('trimmer.view_range', { start: fmtHMS(viewStart), end: fmtHMS(viewEnd), duration: fmtSec(viewEnd - viewStart, viewEnd - viewStart < 10 ? 2 : 0) })}
{t('trimmer.view_range', {
start: fmtHMS(viewStart),
end: fmtHMS(viewEnd),
duration: fmtSec(viewEnd - viewStart, viewEnd - viewStart < 10 ? 2 : 0),
})}
</div>
</div>
@@ -620,10 +774,17 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
<label className="trim-field">
<span className="trim-field__label">{t('trimmer.start_label')}</span>
<input
type="text" inputMode="decimal" value={startInput}
type="text"
inputMode="decimal"
value={startInput}
onChange={(e) => setStartInput(e.target.value)}
onBlur={commitStartInput}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitStartInput(); } }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commitStartInput();
}
}}
className="trim-field__input"
/>
<span className="trim-field__unit">{t('trimmer.unit_seconds')}</span>
@@ -631,10 +792,17 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
<label className="trim-field">
<span className="trim-field__label">{t('trimmer.end_label')}</span>
<input
type="text" inputMode="decimal" value={endInput}
type="text"
inputMode="decimal"
value={endInput}
onChange={(e) => setEndInput(e.target.value)}
onBlur={commitEndInput}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitEndInput(); } }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commitEndInput();
}
}}
className="trim-field__input"
/>
<span className="trim-field__unit">{t('trimmer.unit_seconds')}</span>
@@ -642,9 +810,16 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
<div className="trim-field trim-field--readonly">
<span className="trim-field__label">{t('trimmer.length_label')}</span>
<span className={`trim-field__value ${tooLong ? 'is-err' : ''}`}>
{(duration_ms / 1000).toFixed(2)}{t('trimmer.unit_seconds')}
{(duration_ms / 1000).toFixed(2)}
{t('trimmer.unit_seconds')}
</span>
<span className="trim-field__unit">
{tooLong
? t('trimmer.too_long', { max: maxSeconds })
: tooShort
? t('trimmer.too_short')
: t('trimmer.length_ok')}
</span>
<span className="trim-field__unit">{tooLong ? t('trimmer.too_long', { max: maxSeconds }) : tooShort ? t('trimmer.too_short') : t('trimmer.length_ok')}</span>
</div>
<Button
variant="icon"
@@ -671,9 +846,11 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
<span className="audio-trimmer__kbd-hint">{t('trimmer.play_hint')}</span>
<div className="audio-trimmer__actions-right">
<Button variant="ghost" onClick={onCancel}>{t('trimmer.cancel')}</Button>
<Button variant="ghost" onClick={onCancel}>
{t('trimmer.cancel')}
</Button>
<Button
variant={(tooLong || tooShort) ? 'danger' : 'primary'}
variant={tooLong || tooShort ? 'danger' : 'primary'}
disabled={!ready || tooLong || tooShort}
onClick={handleConfirm}
leading={<Check size={12} />}
@@ -683,7 +860,12 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
</div>
</div>
<audio ref={audioRef} preload="auto" onEnded={() => setPlaying(false)} className="audio-trimmer__audio" />
<audio
ref={audioRef}
preload="auto"
onEnded={() => setPlaying(false)}
className="audio-trimmer__audio"
/>
</div>
</Dialog>
);
+51 -24
View File
@@ -16,7 +16,7 @@ export default function BatchAddDialog({
open,
onClose,
profiles = [],
onEnqueue, // async (files, settings) => void
onEnqueue, // async (files, settings) => void
}) {
const { t } = useTranslation();
const [files, setFiles] = useState([]);
@@ -28,12 +28,12 @@ export default function BatchAddDialog({
const handleDrop = useCallback((e) => {
e.preventDefault();
const dropped = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('video/'));
if (dropped.length) setFiles(prev => [...prev, ...dropped]);
const dropped = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('video/'));
if (dropped.length) setFiles((prev) => [...prev, ...dropped]);
}, []);
const removeFile = (idx) => {
setFiles(prev => prev.filter((_, i) => i !== idx));
setFiles((prev) => prev.filter((_, i) => i !== idx));
};
const handleSubmit = async () => {
@@ -52,7 +52,7 @@ export default function BatchAddDialog({
return (
<div className="batch-add-overlay" onClick={onClose}>
<div className="batch-add" onClick={e => e.stopPropagation()}>
<div className="batch-add" onClick={(e) => e.stopPropagation()}>
<div className="batch-add__head">
<span className="batch-add__title">
<Plus size={13} /> {t('batch.add_to_queue_title')}
@@ -66,9 +66,15 @@ export default function BatchAddDialog({
{/* Drop zone */}
<div
className="batch-add__drop"
onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add('is-over'); }}
onDragLeave={e => e.currentTarget.classList.remove('is-over')}
onDrop={e => { e.currentTarget.classList.remove('is-over'); handleDrop(e); }}
onDragOver={(e) => {
e.preventDefault();
e.currentTarget.classList.add('is-over');
}}
onDragLeave={(e) => e.currentTarget.classList.remove('is-over')}
onDrop={(e) => {
e.currentTarget.classList.remove('is-over');
handleDrop(e);
}}
onClick={() => fileInputRef.current?.click()}
>
<Upload size={24} />
@@ -81,9 +87,9 @@ export default function BatchAddDialog({
accept="video/*"
multiple
className="batch-add__file-input"
onChange={e => {
onChange={(e) => {
const added = Array.from(e.target.files);
if (added.length) setFiles(prev => [...prev, ...added]);
if (added.length) setFiles((prev) => [...prev, ...added]);
e.target.value = '';
}}
/>
@@ -91,12 +97,16 @@ export default function BatchAddDialog({
{/* File list */}
{files.length > 0 && (
<div className="batch-add__files">
<span className="batch-add__kicker">{t('batch.files_kicker', { count: files.length })}</span>
<span className="batch-add__kicker">
{t('batch.files_kicker', { count: files.length })}
</span>
{files.map((f, i) => (
<div key={`${f.name}-${i}`} className="batch-add__file-row">
<Film size={10} />
<span className="batch-add__file-name">{f.name}</span>
<span className="batch-add__file-size">{t('batch.file_size_mb', { size: (f.size / 1024 / 1024).toFixed(1) })}</span>
<span className="batch-add__file-size">
{t('batch.file_size_mb', { size: (f.size / 1024 / 1024).toFixed(1) })}
</span>
<button type="button" className="batch-add__file-x" onClick={() => removeFile(i)}>
<X size={9} />
</button>
@@ -108,7 +118,9 @@ export default function BatchAddDialog({
{/* Settings */}
<div className="batch-add__settings">
<div className="batch-add__field">
<span className="batch-add__kicker"><Globe size={9} /> {t('batch.target_languages')}</span>
<span className="batch-add__kicker">
<Globe size={9} /> {t('batch.target_languages')}
</span>
<MultiLangPicker selected={langs} onChange={setLangs} />
</div>
@@ -117,20 +129,26 @@ export default function BatchAddDialog({
<select
className="input-base batch-add__select"
value={voiceId}
onChange={e => setVoiceId(e.target.value)}
onChange={(e) => setVoiceId(e.target.value)}
>
<option value="">{t('batch.default_option')}</option>
{profiles.filter(p => !p.instruct).length > 0 && (
{profiles.filter((p) => !p.instruct).length > 0 && (
<optgroup label={t('batch.clone_profiles')}>
{profiles.filter(p => !p.instruct).map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
{profiles
.filter((p) => !p.instruct)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</optgroup>
)}
{PRESETS.length > 0 && (
<optgroup label={t('batch.presets')}>
{PRESETS.map(p => (
<option key={p.id} value={`preset:${p.id}`}>{p.name}</option>
{PRESETS.map((p) => (
<option key={p.id} value={`preset:${p.id}`}>
{p.name}
</option>
))}
</optgroup>
)}
@@ -138,7 +156,11 @@ export default function BatchAddDialog({
</div>
<label className="batch-add__toggle">
<input type="checkbox" checked={preserveBg} onChange={e => setPreserveBg(e.target.checked)} />
<input
type="checkbox"
checked={preserveBg}
onChange={(e) => setPreserveBg(e.target.checked)}
/>
<span>{t('batch.preserve_bg')}</span>
</label>
</div>
@@ -147,10 +169,16 @@ export default function BatchAddDialog({
<div className="batch-add__foot">
<span className="batch-add__estimate">
{files.length > 0 && langs.length > 0
? t('batch.estimate', { videos: files.length, langs: langs.length, jobs: files.length * langs.length })
? t('batch.estimate', {
videos: files.length,
langs: langs.length,
jobs: files.length * langs.length,
})
: t('batch.select_files_langs')}
</span>
<Button variant="ghost" size="sm" onClick={onClose}>{t('common.cancel')}</Button>
<Button variant="ghost" size="sm" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
size="sm"
@@ -166,4 +194,3 @@ export default function BatchAddDialog({
</div>
);
}
+197 -83
View File
@@ -15,7 +15,7 @@
*/
import { Suspense, lazy, useEffect, useMemo, useRef, useState } from 'react';
import { Brush, Check, Clipboard, Globe, Lightbulb } from 'lucide-react';
import { copyText } from "../utils/copyText";
import { copyText } from '../utils/copyText';
import './FirstRunSetup.css';
import './BootstrapSplash.css';
import { useTranslation } from 'react-i18next';
@@ -30,7 +30,27 @@ const getSystemLanguage = () => {
if (typeof navigator === 'undefined') return 'en';
const navLang = navigator.language || (navigator.languages && navigator.languages[0]) || 'en';
if (navLang.toLowerCase().includes('tw') || navLang.toLowerCase().includes('hk')) return 'zh-TW';
const match = ['zh-CN', 'es', 'fr', 'de', 'ja', 'pt', 'it', 'ru', 'ko', 'hi', 'tr', 'pl', 'nl', 'sv', 'th', 'vi', 'id', 'uk', 'ar'].find(code => navLang.startsWith(code.split('-')[0]));
const match = [
'zh-CN',
'es',
'fr',
'de',
'ja',
'pt',
'it',
'ru',
'ko',
'hi',
'tr',
'pl',
'nl',
'sv',
'th',
'vi',
'id',
'uk',
'ar',
].find((code) => navLang.startsWith(code.split('-')[0]));
return match || 'en';
};
@@ -38,13 +58,13 @@ const getSystemLanguage = () => {
const APP_VERSION = __APP_VERSION__ || '0.0.0';
const STAGE_LABEL = {
checking: 'Checking environment…',
downloading_uv: 'Downloading uv (Python package manager)…',
creating_venv: 'Creating Python virtual environment…',
installing_deps: 'Installing dependencies — first run, 510 min.',
starting_backend: 'Starting backend…',
ready: 'Ready',
failed: 'Setup failed',
checking: 'Checking environment…',
downloading_uv: 'Downloading uv (Python package manager)…',
creating_venv: 'Creating Python virtual environment…',
installing_deps: 'Installing dependencies — first run, 510 min.',
starting_backend: 'Starting backend…',
ready: 'Ready',
failed: 'Setup failed',
};
const STEPS = [
@@ -62,23 +82,25 @@ const MAX_LOG_LINES = 200;
* live in locales/en.json under `bootstrap.hint_*`). */
function detectHints(message, logs) {
const hints = [];
const all = (message || '') + '\n' + logs.map(l => l.line).join('\n');
if (/README\.md/i.test(all)) hints.push('bootstrap.hint_readme');
const all = (message || '') + '\n' + logs.map((l) => l.line).join('\n');
if (/README\.md/i.test(all)) hints.push('bootstrap.hint_readme');
// python-build-standalone download failure (issue #57, #60): user's network
// can't reach the github.com release. We auto-retry with a system-Python
// fallback in bootstrap.rs, but if that also fails the user needs an actionable next step.
if (/python-build-standalone|managed-python download failed/i.test(all)) {
hints.push('bootstrap.hint_python_mirror');
}
if (/uv.*download|uv.*install/i.test(all) && /timeout|connection/i.test(all)) hints.push('bootstrap.hint_uv_timeout');
if (/uv sync failed/i.test(all)) hints.push('bootstrap.hint_uv_sync');
if (/uv.*download|uv.*install/i.test(all) && /timeout|connection/i.test(all))
hints.push('bootstrap.hint_uv_timeout');
if (/uv sync failed/i.test(all)) hints.push('bootstrap.hint_uv_sync');
if (/hatchling|build_editable/i.test(all)) hints.push('bootstrap.hint_build_backend');
if (/ffmpeg/i.test(all) && /download|timeout/i.test(all)) hints.push('bootstrap.hint_ffmpeg');
if (/port.*in use|address.*in use/i.test(all)) hints.push('bootstrap.hint_port');
if (/no error output/i.test(all)) hints.push('bootstrap.hint_silent_crash');
if (/no error output/i.test(all)) hints.push('bootstrap.hint_silent_crash');
if (/seems stuck at|never reported ready/i.test(all)) hints.push('bootstrap.hint_stuck');
if (/blocking GitHub|couldn't download Python|python-build-standalone|dns error/i.test(all)) hints.push('bootstrap.hint_github_blocked');
if (hints.length === 0) hints.push('bootstrap.hint_default');
if (/blocking GitHub|couldn't download Python|python-build-standalone|dns error/i.test(all))
hints.push('bootstrap.hint_github_blocked');
if (hints.length === 0) hints.push('bootstrap.hint_default');
return hints;
}
@@ -93,7 +115,10 @@ function formatBytes(n) {
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let v = n;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i += 1;
}
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`;
}
@@ -102,21 +127,26 @@ function formatBytes(n) {
* every poll tick and the silhouette never changes. */
function Waveform({ bars = 96 }) {
const heights = useMemo(
() => Array.from({ length: bars }, (_, i) => {
const t = i / bars;
const v = Math.abs(
Math.sin(t * Math.PI * 7.3) * 0.55 +
Math.sin(t * Math.PI * 2.1 + 1.2) * 0.3 +
Math.sin(t * Math.PI * 17.0 + 0.4) * 0.15
);
return 0.18 + v * 0.82;
}),
() =>
Array.from({ length: bars }, (_, i) => {
const t = i / bars;
const v = Math.abs(
Math.sin(t * Math.PI * 7.3) * 0.55 +
Math.sin(t * Math.PI * 2.1 + 1.2) * 0.3 +
Math.sin(t * Math.PI * 17.0 + 0.4) * 0.15,
);
return 0.18 + v * 0.82;
}),
[bars],
);
return (
<div className="frs-wave" aria-hidden="true">
{heights.map((h, i) => (
<span key={i} className="frs-wave__bar" style={{ '--h': h, '--d': `${(i * 73) % 1400}ms` }} />
<span
key={i}
className="frs-wave__bar"
style={{ '--h': h, '--d': `${(i * 73) % 1400}ms` }}
/>
))}
</div>
);
@@ -124,8 +154,8 @@ function Waveform({ bars = 96 }) {
export function BootstrapSplash({ stage, message }) {
const { t } = useTranslation();
const locale = useAppStore(s => s.locale);
const setLocale = useAppStore(s => s.setLocale);
const locale = useAppStore((s) => s.locale);
const setLocale = useAppStore((s) => s.setLocale);
const handleLocaleChange = (id) => {
setLocale(id);
@@ -163,7 +193,7 @@ export function BootstrapSplash({ stage, message }) {
const [retrying, setRetrying] = useState(false);
const logRef = useRef(null);
const prevProgRef = useRef(null); // {bytes, t} — last progress event
const rateRef = useRef(0); // EMA bytes/sec across events
const rateRef = useRef(0); // EMA bytes/sec across events
const handleRetry = async () => {
if (retrying) return;
@@ -172,8 +202,11 @@ export function BootstrapSplash({ stage, message }) {
const { invoke } = await import('@tauri-apps/api/core');
setLogs([]);
await invoke('retry_bootstrap');
} catch (e) { console.error('retry failed', e); }
finally { setRetrying(false); }
} catch (e) {
console.error('retry failed', e);
} finally {
setRetrying(false);
}
};
const handleCleanRetry = async () => {
@@ -184,8 +217,11 @@ export function BootstrapSplash({ stage, message }) {
const { invoke } = await import('@tauri-apps/api/core');
setLogs([]);
await invoke('clean_and_retry_bootstrap');
} catch (e) { console.error('clean retry failed', e); }
finally { setRetrying(false); }
} catch (e) {
console.error('clean retry failed', e);
} finally {
setRetrying(false);
}
};
// Load persisted region on mount.
@@ -196,7 +232,9 @@ export function BootstrapSplash({ stage, message }) {
const { invoke } = await import('@tauri-apps/api/core');
const r = await invoke('get_region');
if (r) setRegionState(r);
} catch { /* older build without region support */ }
} catch {
/* older build without region support */
}
})();
}, []);
@@ -205,7 +243,9 @@ export function BootstrapSplash({ stage, message }) {
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('set_region', { region: newRegion });
} catch { /* silent */ }
} catch {
/* silent */
}
};
// Subscribe to live log + progress events from the Rust bootstrap.
@@ -228,11 +268,17 @@ export function BootstrapSplash({ stage, message }) {
try {
const buffered = await invoke('get_bootstrap_logs');
if (!cancelled && Array.isArray(buffered) && buffered.length > 0) {
setLogs(buffered.map(({ stage: s, line }) => ({
stage: s, line, t: Date.now(),
})));
setLogs(
buffered.map(({ stage: s, line }) => ({
stage: s,
line,
t: Date.now(),
})),
);
}
} catch { /* command may not exist in older builds */ }
} catch {
/* command may not exist in older builds */
}
// Subscribe to live events for anything new from here on.
unlistenLog = await listen('bootstrap-log', (e) => {
@@ -241,11 +287,9 @@ export function BootstrapSplash({ stage, message }) {
setLogs((prev) => {
// Deduplicate against backfill by checking the last few lines.
const lastFew = prev.slice(-5);
if (lastFew.some(l => l.stage === s && l.line === line)) return prev;
if (lastFew.some((l) => l.stage === s && l.line === line)) return prev;
const next = prev.concat([{ stage: s, line, t: Date.now() }]);
return next.length > MAX_LOG_LINES
? next.slice(next.length - MAX_LOG_LINES)
: next;
return next.length > MAX_LOG_LINES ? next.slice(next.length - MAX_LOG_LINES) : next;
});
});
unlistenProgress = await listen('bootstrap-progress', (e) => {
@@ -287,16 +331,18 @@ export function BootstrapSplash({ stage, message }) {
}, [isFailed]);
const handleCopyLogs = () => {
const logText = logs.length === 0
? 'No log output captured.'
: logs.map(l => `[${l.stage}] ${l.line}`).join('\n');
const full = isFailed && message
? `ERROR: ${message}\n\n--- Bootstrap Logs ---\n${logText}`
: logText;
copyText(full).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}).catch(() => {});
const logText =
logs.length === 0
? 'No log output captured.'
: logs.map((l) => `[${l.stage}] ${l.line}`).join('\n');
const full =
isFailed && message ? `ERROR: ${message}\n\n--- Bootstrap Logs ---\n${logText}` : logText;
copyText(full)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(() => {});
};
const stageProgress = progress && progress.stage === stage ? progress : null;
@@ -315,7 +361,13 @@ export function BootstrapSplash({ stage, message }) {
// the hook order stable.)
if (stage === 'awaiting_setup') {
return (
<Suspense fallback={<div className="frs"><div className="frs__atmo" /></div>}>
<Suspense
fallback={
<div className="frs">
<div className="frs__atmo" />
</div>
}
>
<FirstRunSetup />
</Suspense>
);
@@ -325,12 +377,14 @@ export function BootstrapSplash({ stage, message }) {
<div className="frs">
<div className="frs__atmo" aria-hidden="true" />
<div className="frs__deck frs__deck--focus">
{/* ── Masthead: same identity as the setup screen ───────────────── */}
<header className="frs__mast frs-rise" style={{ '--rise': 0 }} data-tauri-drag-region>
<Waveform />
{/* Journey rail: act 2 of the install flow (setup already done). */}
<nav className="frs-wsteps frs-wsteps--journey" aria-label={t('bootstrap.title', 'OmniVoice Studio')}>
<nav
className="frs-wsteps frs-wsteps--journey"
aria-label={t('bootstrap.title', 'OmniVoice Studio')}
>
<span className="frs-wstep is-done">
<span className="frs-wstep__led" aria-hidden="true" />
{t('firstrun.stage_setup', 'Setup')}
@@ -347,7 +401,9 @@ export function BootstrapSplash({ stage, message }) {
<div className="frs__mast-row">
<div className="frs__mast-text">
<h1 className="frs__title">{t('bootstrap.title', 'OmniVoice Studio')}</h1>
<p className="frs__subtitle" aria-live="polite">{label}</p>
<p className="frs__subtitle" aria-live="polite">
{label}
</p>
</div>
<div className="frs__mast-meta">
<div className="frs__mast-selects">
@@ -358,7 +414,9 @@ export function BootstrapSplash({ stage, message }) {
aria-label={t('firstrun.language', 'Language')}
>
{LANGUAGES.map((l) => (
<option key={l.code} value={l.code}>{l.label}</option>
<option key={l.code} value={l.code}>
{l.label}
</option>
))}
</select>
<select
@@ -380,10 +438,19 @@ export function BootstrapSplash({ stage, message }) {
{showSuggestion && (
<div className="frs-banner frs-rise" style={{ '--rise': 1 }}>
<span><Globe size={12} /> {t('bootstrap.suggest_lang', { lang: LANGUAGES.find(l => l.code === systemLang)?.label || systemLang })}</span>
<span>
<Globe size={12} />{' '}
{t('bootstrap.suggest_lang', {
lang: LANGUAGES.find((l) => l.code === systemLang)?.label || systemLang,
})}
</span>
<div className="frs-banner__actions">
<button type="button" className="frs-btn frs-btn--quiet" onClick={acceptSuggestion}>{t('common.yes', 'Yes')}</button>
<button type="button" className="frs-btn frs-btn--quiet" onClick={dismissSuggestion}>{t('common.no', 'No')}</button>
<button type="button" className="frs-btn frs-btn--quiet" onClick={acceptSuggestion}>
{t('common.yes', 'Yes')}
</button>
<button type="button" className="frs-btn frs-btn--quiet" onClick={dismissSuggestion}>
{t('common.no', 'No')}
</button>
</div>
</div>
)}
@@ -393,9 +460,13 @@ export function BootstrapSplash({ stage, message }) {
<h2 className="frs-panel__title">{t('bootstrap.failed', 'Setup failed')}</h2>
<pre className="frs__error">{message || t('bootstrap.unknown_error')}</pre>
<div className="frs-hints">
<span className="frs-hints__label"><Lightbulb size={12} /> {t('bootstrap.what_to_try', 'What to try:')}</span>
<span className="frs-hints__label">
<Lightbulb size={12} /> {t('bootstrap.what_to_try', 'What to try:')}
</span>
<ul>
{detectHints(message, logs).map((key) => <li key={key}>{t(key)}</li>)}
{detectHints(message, logs).map((key) => (
<li key={key}>{t(key)}</li>
))}
</ul>
</div>
<div className="frs-banner__actions frs-banner__actions--end">
@@ -423,7 +494,13 @@ export function BootstrapSplash({ stage, message }) {
<h2 className="frs-panel__title">{t('firstrun.installing_title', 'Installing')}</h2>
{/* Overall journey meter — the same LED segments as the setup
screen's disk gate, now measuring progress instead of space. */}
<div className="frs-meter frs-meter--progress" role="progressbar" aria-valuenow={Math.round(overallPct)} aria-valuemin={0} aria-valuemax={100}>
<div
className="frs-meter frs-meter--progress"
role="progressbar"
aria-valuenow={Math.round(overallPct)}
aria-valuemin={0}
aria-valuemax={100}
>
<span className="frs-meter__fill" style={{ width: `${overallPct}%` }} />
</div>
<ol className="frs-steps">
@@ -440,21 +517,30 @@ export function BootstrapSplash({ stage, message }) {
{i === stepIndex && stageProgress && (
<span className="frs-step__bytes">
{formatBytes(stageProgress.bytes_done)}
{stageProgress.bytes_total > 0 ? ` / ${formatBytes(stageProgress.bytes_total)}` : ''}
{stageProgress.bytes_total > 0
? ` / ${formatBytes(stageProgress.bytes_total)}`
: ''}
{pctFromBytes != null ? ` (${pctFromBytes}%)` : ''}
{stageProgress.bytes_total > 0 && rateRef.current > 0 && stageProgress.bytes_done < stageProgress.bytes_total && (
{stageProgress.bytes_total > 0 &&
rateRef.current > 0 &&
stageProgress.bytes_done < stageProgress.bytes_total &&
` · ${t('firstrun.eta_left', {
eta: formatEta((stageProgress.bytes_total - stageProgress.bytes_done) / rateRef.current),
eta: formatEta(
(stageProgress.bytes_total - stageProgress.bytes_done) /
rateRef.current,
),
defaultValue: '~{{eta}} left',
})}`
)}
})}`}
</span>
)}
</li>
))}
</ol>
<p className="frs__trust">
{t('firstrun.resume_note', 'Interrupted downloads resume automatically — closing the app is safe.')}
{t(
'firstrun.resume_note',
'Interrupted downloads resume automatically — closing the app is safe.',
)}
</p>
</section>
)}
@@ -468,13 +554,25 @@ export function BootstrapSplash({ stage, message }) {
</span>
</h2>
<div className="frs-log__bar">
<button type="button" className="frs-btn frs-btn--quiet" onClick={() => setLogsOpen(v => !v)}>
{logsOpen ? '▾ ' + t('bootstrap.hide_logs', 'Hide logs') : '▸ ' + t('bootstrap.show_logs', 'Show logs')}
<button
type="button"
className="frs-btn frs-btn--quiet"
onClick={() => setLogsOpen((v) => !v)}
>
{logsOpen
? '▾ ' + t('bootstrap.hide_logs', 'Hide logs')
: '▸ ' + t('bootstrap.show_logs', 'Show logs')}
</button>
<button type="button" className="frs-btn frs-btn--quiet" onClick={handleCopyLogs}>
{copied
? <><Check size={12} /> {t('bootstrap.copied', 'Copied!')}</>
: <><Clipboard size={12} /> {t('bootstrap.copy', 'Copy')}</>}
{copied ? (
<>
<Check size={12} /> {t('bootstrap.copied', 'Copied!')}
</>
) : (
<>
<Clipboard size={12} /> {t('bootstrap.copy', 'Copy')}
</>
)}
</button>
</div>
{logsOpen && (
@@ -507,9 +605,18 @@ export function useBootstrapStage(pollMs = 1000) {
const [state, setState] = useState({ stage: 'checking', message: null });
useEffect(() => {
if (typeof window === 'undefined') { setState({ stage: 'ready', message: null }); return; }
if (!('__TAURI_INTERNALS__' in window)) { setState({ stage: 'ready', message: null }); return; }
if (import.meta.env.DEV) { setState({ stage: 'ready', message: null }); return; }
if (typeof window === 'undefined') {
setState({ stage: 'ready', message: null });
return;
}
if (!('__TAURI_INTERNALS__' in window)) {
setState({ stage: 'ready', message: null });
return;
}
if (import.meta.env.DEV) {
setState({ stage: 'ready', message: null });
return;
}
let cancelled = false;
let timer = null;
@@ -535,7 +642,10 @@ export function useBootstrapStage(pollMs = 1000) {
};
(async () => {
const tauriInvoke = await invoke();
if (!tauriInvoke) { setState({ stage: 'ready', message: null }); return; }
if (!tauriInvoke) {
setState({ stage: 'ready', message: null });
return;
}
const tick = async () => {
if (cancelled) return;
try {
@@ -546,19 +656,23 @@ export function useBootstrapStage(pollMs = 1000) {
const message = res.message || null;
// Reset the stall clock whenever something actually changes.
const key = `${stage}|${message || ''}`;
if (key !== lastKey) { lastKey = key; lastChangeTs = Date.now(); }
if (key !== lastKey) {
lastKey = key;
lastChangeTs = Date.now();
}
// Rust returns { stage: 'ready' } or { stage: 'failed', message: '…' } etc.
if (stage !== 'ready' && stage !== 'failed') {
if (Date.now() - lastChangeTs > stallBudgetMs(stage)) {
// Stuck — surface it as a failure so Retry/logs/hints appear.
setState({
stage: 'failed',
message: (message ? message + '\n\n' : '') +
message:
(message ? message + '\n\n' : '') +
`Setup seems stuck at "${stage}" — the backend never reported ready. ` +
`Check the log below, then Retry. If you're running from source, make sure ` +
`\`uv sync\` completed and uv/Python are on your PATH.`,
});
return; // stop polling — failed is terminal
return; // stop polling — failed is terminal
}
setState({ stage, message });
timer = setTimeout(tick, pollMs);
+172 -117
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { copyText } from "../utils/copyText";
import { copyText } from '../utils/copyText';
import { X, Loader } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { useAppStore } from '../store';
@@ -16,7 +16,9 @@ async function setTrayRecording(recording) {
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('set_tray_recording', { recording });
} catch { /* not in Tauri */ }
} catch {
/* not in Tauri */
}
}
const LS_CAPTURE_MODE = 'omni_capture_mode';
@@ -91,8 +93,12 @@ async function pasteSegment(text) {
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('simulate_paste', { text });
} catch { /* not in Tauri — WebView clipboard copy above already ran */ }
} catch { /* clipboard unavailable */ }
} catch {
/* not in Tauri — WebView clipboard copy above already ran */
}
} catch {
/* clipboard unavailable */
}
}
// Live, word-by-word typing of the in-flight utterance into whatever app has
@@ -133,9 +139,7 @@ export default function CaptureWidget({ onDismiss }) {
const [state, setState] = useState('idle'); // idle | recording | transcribing | done | error
const [transcript, setTranscript] = useState('');
const [duration, setDuration] = useState(0);
const [captureMode] = useState(() =>
localStorage.getItem(LS_CAPTURE_MODE) || 'fast'
);
const [captureMode] = useState(() => localStorage.getItem(LS_CAPTURE_MODE) || 'fast');
const [, setLastEngine] = useState('');
const [, setLastTime] = useState(0);
const [partialText, setPartialText] = useState('');
@@ -151,8 +155,12 @@ export default function CaptureWidget({ onDismiss }) {
// re-subscribing on every pref change.
const modeRef = useRef(dictationMode);
const enabledRef = useRef(dictationEnabled);
useEffect(() => { modeRef.current = dictationMode; }, [dictationMode]);
useEffect(() => { enabledRef.current = dictationEnabled; }, [dictationEnabled]);
useEffect(() => {
modeRef.current = dictationMode;
}, [dictationMode]);
useEffect(() => {
enabledRef.current = dictationEnabled;
}, [dictationEnabled]);
// Sherpa live-streaming session refs. `sherpaModeRef` flips on at start when a
// sherpa model is selected; `committedRef` accumulates per-utterance finals so
@@ -189,15 +197,23 @@ export default function CaptureWidget({ onDismiss }) {
// raw PCM via an AudioWorklet and tag mic/far-end frames instead of using
// MediaRecorder. All AEC state lives in refs so the default path is inert.
const aecModeRef = useRef(false);
const aecStopRef = useRef(null); // async teardown of the mic worklet graph
const aecStopRef = useRef(null); // async teardown of the mic worklet graph
const farEndUnsubRef = useRef(null); // unsubscribe from the far-end bus
const teardownAec = useCallback(async () => {
try { farEndUnsubRef.current?.(); } catch { /* ignore */ }
try {
farEndUnsubRef.current?.();
} catch {
/* ignore */
}
farEndUnsubRef.current = null;
const stop = aecStopRef.current;
aecStopRef.current = null;
try { await stop?.(); } catch { /* ignore */ }
try {
await stop?.();
} catch {
/* ignore */
}
aecModeRef.current = false;
}, []);
@@ -205,7 +221,9 @@ export default function CaptureWidget({ onDismiss }) {
// widget runs in its own Tauri webview (a separate JS context from the main
// window), so it loads the prefs itself rather than relying on the Settings
// window having loaded them.
useEffect(() => { loadDictationPrefs(); }, [loadDictationPrefs]);
useEffect(() => {
loadDictationPrefs();
}, [loadDictationPrefs]);
// ── Tray hotkey: tray-dictate (start) + tray-dictate-stop (release) ──
// Toggle mode: tray-dictate flips start↔stop, tray-dictate-stop is ignored
@@ -237,7 +255,9 @@ export default function CaptureWidget({ onDismiss }) {
stopRecording();
}
});
} catch { /* not in Tauri */ }
} catch {
/* not in Tauri */
}
})();
return () => {
if (unlistenStart) unlistenStart();
@@ -251,8 +271,7 @@ export default function CaptureWidget({ onDismiss }) {
// keyup stops. The Ctrl/Cmd+Shift+Space combo matches the documented default
// shortcut; the desktop app's user-rebindable accelerator is a Tauri concern.
useEffect(() => {
const isCombo = (e) =>
(e.metaKey || e.ctrlKey) && e.shiftKey && e.code === 'Space';
const isCombo = (e) => (e.metaKey || e.ctrlKey) && e.shiftKey && e.code === 'Space';
const onKeyDown = (e) => {
if (!isCombo(e)) return;
e.preventDefault();
@@ -270,7 +289,8 @@ export default function CaptureWidget({ onDismiss }) {
const onKeyUp = (e) => {
// Hold mode stops as soon as Space (or a modifier) is released.
if (modeRef.current !== 'hold') return;
if (e.code !== 'Space' && e.key !== 'Meta' && e.key !== 'Control' && e.key !== 'Shift') return;
if (e.code !== 'Space' && e.key !== 'Meta' && e.key !== 'Control' && e.key !== 'Shift')
return;
if (state === 'recording') stopRecording();
};
window.addEventListener('keydown', onKeyDown);
@@ -292,87 +312,103 @@ export default function CaptureWidget({ onDismiss }) {
}, [state]);
// Apply transcription result → auto-paste → auto-dismiss
const applyResult = useCallback(async (data) => {
// Wave 2.1: the backend may attach an LLM-refined version of the final
// text (filler words removed, self-corrections applied). Paste/show the
// refined text when present; the raw text is kept in history alongside.
const finalText = data.refined_text || data.text || '';
setTranscript(finalText);
setLastEngine(data.engine || '');
setLastTime(data.transcription_time_s || 0);
setState('done');
const applyResult = useCallback(
async (data) => {
// Wave 2.1: the backend may attach an LLM-refined version of the final
// text (filler words removed, self-corrections applied). Paste/show the
// refined text when present; the raw text is kept in history alongside.
const finalText = data.refined_text || data.text || '';
setTranscript(finalText);
setLastEngine(data.engine || '');
setLastTime(data.transcription_time_s || 0);
setState('done');
if (data.text) {
addTranscription(data);
}
if (data.text) {
addTranscription(data);
}
if (finalText) {
try {
// Best-effort WebView copy (works in browser mode). In Tauri the
// widget window is unfocused on macOS, where WebView clipboard APIs
// fail silently — so pass the transcript to simulate_paste, which
// writes the clipboard natively (OS-side) before sending ⌘V (#287).
await copyText(finalText);
if (finalText) {
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('simulate_paste', { text: finalText });
} catch { /* not in Tauri */ }
} catch { /* clipboard API may fail */ }
// Best-effort WebView copy (works in browser mode). In Tauri the
// widget window is unfocused on macOS, where WebView clipboard APIs
// fail silently — so pass the transcript to simulate_paste, which
// writes the clipboard natively (OS-side) before sending ⌘V (#287).
await copyText(finalText);
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('simulate_paste', { text: finalText });
} catch {
/* not in Tauri */
}
} catch {
/* clipboard API may fail */
}
// Auto-dismiss after 1.5s
setTimeout(async () => {
setState('idle');
setTranscript('');
setDuration(0);
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().hide();
} catch { /* not in Tauri */ }
if (onDismiss) onDismiss();
}, 1500);
} else {
// No speech — auto-dismiss after 2.5s
setTimeout(async () => {
setState('idle');
setTranscript('');
setDuration(0);
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().hide();
} catch { /* not in Tauri */ }
if (onDismiss) onDismiss();
}, 2500);
}
}, [onDismiss]);
// Auto-dismiss after 1.5s
setTimeout(async () => {
setState('idle');
setTranscript('');
setDuration(0);
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().hide();
} catch {
/* not in Tauri */
}
if (onDismiss) onDismiss();
}, 1500);
} else {
// No speech — auto-dismiss after 2.5s
setTimeout(async () => {
setState('idle');
setTranscript('');
setDuration(0);
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().hide();
} catch {
/* not in Tauri */
}
if (onDismiss) onDismiss();
}, 2500);
}
},
[onDismiss],
);
// Finalise a sherpa LIVE-streaming session. The per-utterance finals were
// already pasted into the focused field as the user paused, so this does NOT
// re-paste — it shows the authoritative full transcript in the pill, records
// it in history, and auto-dismisses. The EOF-summary `final` (or an early
// socket close) drives this.
const finalizeSession = useCallback(async (data) => {
const fullText = data.refined_text || data.text || '';
setTranscript(fullText);
setLastEngine(data.engine || 'sherpa-onnx-asr');
setLastTime(data.transcription_time_s || 0);
setState('done');
// NB: history was already recorded per-utterance as each `final` was pasted
// live (see the message handler), so finalisation does NOT re-record — that
// would duplicate the session. It only resolves the pill + auto-dismisses.
setPartialText('');
committedRef.current = [];
const delay = fullText ? 1500 : 2500;
setTimeout(async () => {
setState('idle');
setTranscript('');
setDuration(0);
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().hide();
} catch { /* not in Tauri */ }
if (onDismiss) onDismiss();
}, delay);
}, [onDismiss]);
const finalizeSession = useCallback(
async (data) => {
const fullText = data.refined_text || data.text || '';
setTranscript(fullText);
setLastEngine(data.engine || 'sherpa-onnx-asr');
setLastTime(data.transcription_time_s || 0);
setState('done');
// NB: history was already recorded per-utterance as each `final` was pasted
// live (see the message handler), so finalisation does NOT re-record — that
// would duplicate the session. It only resolves the pill + auto-dismisses.
setPartialText('');
committedRef.current = [];
const delay = fullText ? 1500 : 2500;
setTimeout(async () => {
setState('idle');
setTranscript('');
setDuration(0);
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().hide();
} catch {
/* not in Tauri */
}
if (onDismiss) onDismiss();
}, delay);
},
[onDismiss],
);
// Type the recognizer's latest revision of the in-flight utterance into the
// focused field, reconciling against what we typed before via a prefix diff.
@@ -408,7 +444,7 @@ export default function CaptureWidget({ onDismiss }) {
const startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000 }
audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000 },
});
streamRef.current = stream;
chunksRef.current = [];
@@ -457,7 +493,9 @@ export default function CaptureWidget({ onDismiss }) {
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
for (const buf of wsPendingRef.current) {
try { ws.send(buf); } catch {}
try {
ws.send(buf);
} catch {}
}
wsPendingRef.current = [];
};
@@ -502,7 +540,9 @@ export default function CaptureWidget({ onDismiss }) {
fallbackTimerRef.current = null;
}
finalizeSession(msg);
try { ws.close(); } catch {}
try {
ws.close();
} catch {}
} else if (cls === 'utterance') {
// A per-utterance commit. Reconcile the focused field to the
// recognizer's AUTHORITATIVE final for this utterance (it can
@@ -536,20 +576,26 @@ export default function CaptureWidget({ onDismiss }) {
fallbackTimerRef.current = null;
}
applyResult(msg);
try { ws.close(); } catch {}
try {
ws.close();
} catch {}
}
} else if (msg.type === 'error') {
if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current);
fallbackTimerRef.current = null;
}
try { ws.close(); } catch {}
try {
ws.close();
} catch {}
wsRef.current = null;
if (!wsHadFinalRef.current) sendForTranscription();
}
} catch {}
};
ws.onerror = () => { wsRef.current = null; };
ws.onerror = () => {
wsRef.current = null;
};
ws.onclose = () => {
wsRef.current = null;
if (sherpaModeRef.current) {
@@ -563,9 +609,9 @@ export default function CaptureWidget({ onDismiss }) {
return;
}
if (
!wsHadFinalRef.current
&& mediaRecorderRef.current
&& mediaRecorderRef.current.state === 'inactive'
!wsHadFinalRef.current &&
mediaRecorderRef.current &&
mediaRecorderRef.current.state === 'inactive'
) {
if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current);
@@ -587,14 +633,15 @@ export default function CaptureWidget({ onDismiss }) {
// • AEC on → frames are 1-byte tagged (0x00 mic / 0x01 far-end) and the
// audio player's output is subscribed as the echo reference.
const [{ startMicCapture }, { frameFromFloat, floatToInt16, AEC_NEAR, AEC_FAR }] =
await Promise.all([
import('../utils/aec/micCapture'),
import('../utils/aec/pcm'),
]);
await Promise.all([import('../utils/aec/micCapture'), import('../utils/aec/pcm')]);
const sendBuf = (buf) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
try { ws.send(buf); } catch { /* ignore */ }
try {
ws.send(buf);
} catch {
/* ignore */
}
} else {
wsPendingRef.current.push(buf);
}
@@ -605,9 +652,9 @@ export default function CaptureWidget({ onDismiss }) {
// sherpa handler sees the cleaned near-end PCM.
const { subscribeFarEnd } = await import('../utils/aec/farEndBus');
const sendTagged = (float32, kind) => sendBuf(frameFromFloat(float32, kind));
aecStopRef.current = await startMicCapture(
stream, (f) => sendTagged(f, AEC_NEAR), { sampleRate: 16000 },
);
aecStopRef.current = await startMicCapture(stream, (f) => sendTagged(f, AEC_NEAR), {
sampleRate: 16000,
});
farEndUnsubRef.current = subscribeFarEnd((f) => sendTagged(f, AEC_FAR));
} else {
// Untagged int16 frames for the plain sherpa live path. Send the
@@ -628,7 +675,7 @@ export default function CaptureWidget({ onDismiss }) {
recorder.ondataavailable = (e) => {
if (e.data.size > 0) {
chunksRef.current.push(e.data);
e.data.arrayBuffer().then(buf => {
e.data.arrayBuffer().then((buf) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(buf);
@@ -668,13 +715,17 @@ export default function CaptureWidget({ onDismiss }) {
teardownAec();
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(t => t.stop());
streamRef.current.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
// Signal EOF to WebSocket
const ws = wsRef.current;
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
const sendEof = () => { try { ws.send('EOF'); } catch {} };
const sendEof = () => {
try {
ws.send('EOF');
} catch {}
};
if (ws.readyState === WebSocket.OPEN) {
sendEof();
} else {
@@ -687,7 +738,9 @@ export default function CaptureWidget({ onDismiss }) {
fallbackTimerRef.current = setTimeout(() => {
fallbackTimerRef.current = null;
if (!wsHadFinalRef.current) {
try { wsRef.current?.close(); } catch {}
try {
wsRef.current?.close();
} catch {}
wsRef.current = null;
sendForTranscription();
}
@@ -734,7 +787,9 @@ export default function CaptureWidget({ onDismiss }) {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().hide();
} catch { /* not in Tauri */ }
} catch {
/* not in Tauri */
}
if (onDismiss) onDismiss();
};
@@ -778,19 +833,19 @@ export default function CaptureWidget({ onDismiss }) {
{/* Timer */}
{(state === 'recording' || state === 'transcribing') && (
<span className="capture-pill__timer">
{formatElapsed(duration)}
</span>
<span className="capture-pill__timer">{formatElapsed(duration)}</span>
)}
{/* Transcribing spinner */}
{state === 'transcribing' && (
<Loader size={14} className="capture-pill__spinner" />
)}
{state === 'transcribing' && <Loader size={14} className="capture-pill__spinner" />}
{/* Dismiss — only on done/error */}
{(state === 'done' || state === 'error') && (
<button className="capture-pill__dismiss" onClick={dismiss} aria-label={t('common.dismiss')}>
<button
className="capture-pill__dismiss"
onClick={dismiss}
aria-label={t('common.dismiss')}
>
<X size={12} />
</button>
)}
+8 -10
View File
@@ -25,7 +25,11 @@ const STAGE_ICONS = {
const STAGE_KEYS = {
asr: { title: 'checkpoint.asr_title', cta: 'checkpoint.asr_cta', hint: 'checkpoint.asr_hint' },
translate: { title: 'checkpoint.translate_title', cta: 'checkpoint.translate_cta', hint: 'checkpoint.translate_hint' },
translate: {
title: 'checkpoint.translate_title',
cta: 'checkpoint.translate_cta',
hint: 'checkpoint.translate_hint',
},
done: { title: 'checkpoint.done_title', cta: null, hint: 'checkpoint.done_hint' },
};
@@ -47,18 +51,12 @@ export default function CheckpointBanner({ stage, count, onContinue, onDismiss,
<Icon size={14} color={icons.accent} className="ckpt-icon" />
<div className="ckpt-body">
<div className="ckpt-head">
<span className="ckpt-title">
{t(keys.title)}
</span>
<span className="ckpt-title">{t(keys.title)}</span>
{typeof count === 'number' && (
<span className="ckpt-count">
{t('checkpoint.segment', { count })}
</span>
<span className="ckpt-count">{t('checkpoint.segment', { count })}</span>
)}
</div>
<span className="ckpt-hint">
{t(keys.hint)}
</span>
<span className="ckpt-hint">{t(keys.hint)}</span>
</div>
{keys.cta && onContinue && (
<Button
+53 -25
View File
@@ -9,24 +9,39 @@ import { useTranslation } from 'react-i18next';
import './CompareModal.css';
export default function CompareModal({
open, onClose,
open,
onClose,
profiles,
compareText, setCompareText,
compareVoiceA, setCompareVoiceA,
compareVoiceB, setCompareVoiceB,
compareResultA, setCompareResultA,
compareResultB, setCompareResultB,
compareProgress, setCompareProgress,
isComparing, setIsComparing,
steps, cfg, speed, denoise, postprocess,
fileToMediaUrl, loadHistory,
compareText,
setCompareText,
compareVoiceA,
setCompareVoiceA,
compareVoiceB,
setCompareVoiceB,
compareResultA,
setCompareResultA,
compareResultB,
setCompareResultB,
compareProgress,
setCompareProgress,
isComparing,
setIsComparing,
steps,
cfg,
speed,
denoise,
postprocess,
fileToMediaUrl,
loadHistory,
}) {
const drawerRef = useRef(null);
const { t } = useTranslation();
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === 'Escape') onClose?.(); };
const onKey = (e) => {
if (e.key === 'Escape') onClose?.();
};
const onPointer = (e) => {
if (drawerRef.current && !drawerRef.current.contains(e.target)) onClose?.();
};
@@ -52,14 +67,14 @@ export default function CompareModal({
let fin_prof = voiceId;
let fin_inst = '';
if (fin_prof.startsWith('preset:')) {
const pr = PRESETS.find(p => p.id === fin_prof.replace('preset:', ''));
const pr = PRESETS.find((p) => p.id === fin_prof.replace('preset:', ''));
if (pr) {
const parts = Object.values(pr.attrs).filter(v => v !== 'Auto');
const parts = Object.values(pr.attrs).filter((v) => v !== 'Auto');
fin_inst = parts.join(', ');
}
fin_prof = '';
} else if (profiles.find(p => p.id === fin_prof)?.instruct) {
fin_inst = profiles.find(p => p.id === fin_prof).instruct;
} else if (profiles.find((p) => p.id === fin_prof)?.instruct) {
fin_inst = profiles.find((p) => p.id === fin_prof).instruct;
}
if (fin_prof) formData.append('profile_id', fin_prof);
if (fin_inst) formData.append('instruct', fin_inst);
@@ -97,7 +112,12 @@ export default function CompareModal({
if (!open) return null;
return (
<div className="compare-drawer" role="dialog" aria-modal="false" aria-label={t('compare.title')}>
<div
className="compare-drawer"
role="dialog"
aria-modal="false"
aria-label={t('compare.title')}
>
<div className="compare-drawer__sheet" ref={drawerRef}>
<header className="compare-drawer__head">
<span className="compare-drawer__handle" aria-hidden="true" />
@@ -115,14 +135,12 @@ export default function CompareModal({
</header>
<div className="compare-drawer__body">
<p className="ui-compare__desc">
{t('compare.desc')}
</p>
<p className="ui-compare__desc">{t('compare.desc')}</p>
<Field label={t('compare.test_phrase')}>
<Textarea
value={compareText}
onChange={e => setCompareText(e.target.value)}
onChange={(e) => setCompareText(e.target.value)}
rows={2}
className="compare-textarea--noresize"
/>
@@ -149,7 +167,9 @@ export default function CompareModal({
</div>
<footer className="compare-drawer__foot">
<Button variant="ghost" onClick={onClose}>{t('compare.close_btn')}</Button>
<Button variant="ghost" onClick={onClose}>
{t('compare.close_btn')}
</Button>
<Button
variant="primary"
loading={isComparing}
@@ -157,7 +177,7 @@ export default function CompareModal({
onClick={runCompare}
leading={!isComparing && <Play size={12} />}
>
{isComparing ? (compareProgress || t('compare.comparing')) : t('compare.compare_btn')}
{isComparing ? compareProgress || t('compare.comparing') : t('compare.compare_btn')}
</Button>
</footer>
</div>
@@ -173,10 +193,18 @@ function CompareSide({ accent, label, profiles, value, onChange, audio }) {
<Fingerprint size={14} /> {label}
</h3>
<Field>
<Select value={value} onChange={e => onChange(e.target.value)}>
<Select value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">{t('compare.select_voice')}</option>
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
{PRESETS.map(p => <option key={p.id} value={`preset:${p.id}`}>{p.name} {t('compare.preset_suffix')}</option>)}
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
{PRESETS.map((p) => (
<option key={p.id} value={`preset:${p.id}`}>
{p.name} {t('compare.preset_suffix')}
</option>
))}
</Select>
</Field>
{audio ? (
+5 -2
View File
@@ -55,7 +55,8 @@ export default function DemoPresetGrid({ presets, onUse }) {
}, 'design-preview');
audio.src = `${API}${preset.preview_url}`;
audio.currentTime = 0;
audio.play()
audio
.play()
.then(() => setPlayingId(preset.id))
.catch((e) => {
// Most common failure: WAV missing on disk (someone deleted it or
@@ -87,7 +88,9 @@ export default function DemoPresetGrid({ presets, onUse }) {
return (
<div key={p.id} className="demo-preset-card">
<div className="demo-preset-card__head">
<span className="demo-preset-card__icon" aria-hidden><Icon size={18} /></span>
<span className="demo-preset-card__icon" aria-hidden>
<Icon size={18} />
</span>
<span className="demo-preset-card__name">{stripVoiceEmoji(p.name)}</span>
</div>
<p className="demo-preset-card__desc">{p.description}</p>
+78 -53
View File
@@ -73,9 +73,15 @@ export default function DictationDemo({ embedded = false }) {
useEffect(() => {
let cancelled = false;
apiFetch(`${API}${SCRIPTS[0].wav}`, { method: 'HEAD' })
.then(() => { if (!cancelled) setAssetsAvailable(true); })
.catch(() => { if (!cancelled) setAssetsAvailable(false); });
return () => { cancelled = true; };
.then(() => {
if (!cancelled) setAssetsAvailable(true);
})
.catch(() => {
if (!cancelled) setAssetsAvailable(false);
});
return () => {
cancelled = true;
};
}, []);
// Read the registered hotkey on mount.
@@ -94,7 +100,9 @@ export default function DictationDemo({ embedded = false }) {
if (!cancelled) setHotkeyState('unknown');
}
})();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, []);
// Subscribe to dictation events: the moment the user presses their
@@ -116,8 +124,16 @@ export default function DictationDemo({ embedded = false }) {
}
})();
return () => {
try { unlistenStart && unlistenStart(); } catch { /* noop */ }
try { unlistenStop && unlistenStop(); } catch { /* noop */ }
try {
unlistenStart && unlistenStart();
} catch {
/* noop */
}
try {
unlistenStop && unlistenStop();
} catch {
/* noop */
}
};
}, []);
@@ -131,7 +147,8 @@ export default function DictationDemo({ embedded = false }) {
}
audio.src = `${API}${script.wav}`;
audio.currentTime = 0;
audio.play()
audio
.play()
.then(() => setPlayingId(script.id))
.catch((e) => {
console.warn('Sample playback failed:', e);
@@ -208,59 +225,67 @@ export default function DictationDemo({ embedded = false }) {
<p className="dictation-demo__lede">
{showScripts
? t('demo.dictation_lede')
: t('demo.dictation_lede_hotkey_only',
'Hold the shortcut above anywhere on your desktop, speak, release — the text lands in whatever app has focus. Press it now to verify it works.')}
: t(
'demo.dictation_lede_hotkey_only',
'Hold the shortcut above anywhere on your desktop, speak, release — the text lands in whatever app has focus. Press it now to verify it works.',
)}
</p>
<audio ref={audioRef} onEnded={() => setPlayingId(null)} preload="none" />
{showScripts && (
<div className="dictation-demo__scripts">
{SCRIPTS.map((s) => {
const isPlaying = playingId === s.id;
const tx = transcripts[s.id] || {};
return (
<div key={s.id} className="dictation-demo__card">
<div className="dictation-demo__card-head">
<span className="dictation-demo__lang">{s.language}</span>
<span className="dictation-demo__card-label">{t(s.labelKey)}</span>
</div>
<blockquote className="dictation-demo__script">{s.text}</blockquote>
<div className="dictation-demo__card-actions">
<Button
size="sm"
variant="subtle"
onClick={() => togglePlay(s)}
leading={isPlaying ? <Pause size={11} /> : <Play size={11} />}
aria-label={isPlaying ? t('demo.aria_pause', { label: t(s.labelKey) }) : t('demo.aria_hear', { label: t(s.labelKey) })}
>
{isPlaying ? t('demo.dictation_stop') : t('demo.dictation_hear')}
</Button>
<Button
size="sm"
variant="subtle"
onClick={() => replay(s)}
loading={tx.state === 'loading'}
leading={tx.state !== 'loading' && <Mic size={11} />}
aria-label={t('demo.aria_replay', { label: t(s.labelKey) })}
>
{tx.state === 'loading' ? t('demo.dictation_transcribing') : t('demo.dictation_replay')}
</Button>
</div>
{tx.state === 'ok' && (
<div className="dictation-demo__result dictation-demo__result--ok">
<CheckCircle2 size={11} /> <em>{tx.text}</em>
<div className="dictation-demo__scripts">
{SCRIPTS.map((s) => {
const isPlaying = playingId === s.id;
const tx = transcripts[s.id] || {};
return (
<div key={s.id} className="dictation-demo__card">
<div className="dictation-demo__card-head">
<span className="dictation-demo__lang">{s.language}</span>
<span className="dictation-demo__card-label">{t(s.labelKey)}</span>
</div>
)}
{tx.state === 'fail' && (
<div className="dictation-demo__result dictation-demo__result--fail">
<AlertTriangle size={11} /> {tx.error}
<blockquote className="dictation-demo__script">{s.text}</blockquote>
<div className="dictation-demo__card-actions">
<Button
size="sm"
variant="subtle"
onClick={() => togglePlay(s)}
leading={isPlaying ? <Pause size={11} /> : <Play size={11} />}
aria-label={
isPlaying
? t('demo.aria_pause', { label: t(s.labelKey) })
: t('demo.aria_hear', { label: t(s.labelKey) })
}
>
{isPlaying ? t('demo.dictation_stop') : t('demo.dictation_hear')}
</Button>
<Button
size="sm"
variant="subtle"
onClick={() => replay(s)}
loading={tx.state === 'loading'}
leading={tx.state !== 'loading' && <Mic size={11} />}
aria-label={t('demo.aria_replay', { label: t(s.labelKey) })}
>
{tx.state === 'loading'
? t('demo.dictation_transcribing')
: t('demo.dictation_replay')}
</Button>
</div>
)}
</div>
);
})}
</div>
{tx.state === 'ok' && (
<div className="dictation-demo__result dictation-demo__result--ok">
<CheckCircle2 size={11} /> <em>{tx.text}</em>
</div>
)}
{tx.state === 'fail' && (
<div className="dictation-demo__result dictation-demo__result--fail">
<AlertTriangle size={11} /> {tx.error}
</div>
)}
</div>
);
})}
</div>
)}
</section>
);
+56 -24
View File
@@ -32,7 +32,10 @@ export default function DirectionDialog({ open, seg, onSave, onClose }) {
}, [open, seg?.id, seg?.direction]);
const runPreview = async () => {
if (!text.trim()) { setPreview(null); return; }
if (!text.trim()) {
setPreview(null);
return;
}
setParsing(true);
try {
setPreview(await apiPost('/tools/direction', { text }));
@@ -48,7 +51,9 @@ export default function DirectionDialog({ open, seg, onSave, onClose }) {
try {
await onSave?.(text.trim());
onClose?.();
} finally { setSaving(false); }
} finally {
setSaving(false);
}
};
if (!open) return null;
@@ -58,35 +63,53 @@ export default function DirectionDialog({ open, seg, onSave, onClose }) {
open
onClose={onClose}
size="md"
title={<><Sparkles size={14} /> {t('direction.title', { id: seg?.id?.slice?.(0, 6) || '' })}</>}
title={
<>
<Sparkles size={14} /> {t('direction.title', { id: seg?.id?.slice?.(0, 6) || '' })}
</>
}
footer={
<>
{seg?.direction && (
<Button
variant="ghost" size="sm"
onClick={() => { setText(''); }}
variant="ghost"
size="sm"
onClick={() => {
setText('');
}}
leading={<X size={11} />}
className="dir-clear-btn"
>
{t('direction.clear')}
</Button>
)}
<Button variant="ghost" onClick={onClose}>{t('direction.cancel')}</Button>
<Button variant="primary" onClick={save} loading={saving}>{t('direction.saveDirection')}</Button>
<Button variant="ghost" onClick={onClose}>
{t('direction.cancel')}
</Button>
<Button variant="primary" onClick={save} loading={saving}>
{t('direction.saveDirection')}
</Button>
</>
}
>
<p className="direction-dialog__desc">
{t('direction.desc')}
</p>
<p className="direction-dialog__desc">{t('direction.desc')}</p>
<Field label={t('direction.label')} hint={
seg?.text ? <>{t('direction.lineHint', { text: seg.text.slice(0, 80) + (seg.text.length > 80 ? '…' : '') })}</> : null
}>
<Field
label={t('direction.label')}
hint={
seg?.text ? (
<>
{t('direction.lineHint', {
text: seg.text.slice(0, 80) + (seg.text.length > 80 ? '…' : ''),
})}
</>
) : null
}
>
<Textarea
rows={3}
value={text}
onChange={e => setText(e.target.value)}
onChange={(e) => setText(e.target.value)}
placeholder={t('direction.placeholder')}
autoFocus
/>
@@ -94,7 +117,8 @@ export default function DirectionDialog({ open, seg, onSave, onClose }) {
<div className="dir-preview-actions">
<Button
variant="subtle" size="sm"
variant="subtle"
size="sm"
onClick={runPreview}
loading={parsing}
disabled={!text.trim()}
@@ -111,15 +135,27 @@ export default function DirectionDialog({ open, seg, onSave, onClose }) {
{preview && (
<div className="direction-dialog__preview">
<div>
<strong>{t('direction.ttsInstruct')}</strong> <code>{preview.instruct_prompt || t('direction.nothingParsed')}</code>
<strong>{t('direction.ttsInstruct')}</strong>{' '}
<code>{preview.instruct_prompt || t('direction.nothingParsed')}</code>
</div>
<div>
<strong>{t('direction.translateHint')}</strong> <em>{preview.translate_hint || '—'}</em>
</div>
<div>
<strong>{t('direction.rateBias')}</strong> <code>{preview.rate_bias?.toFixed?.(2)}</code>
{preview.rate_bias > 1.05 && <> · <span className="dir-rate-up">{t('direction.speedsUp')}</span></>}
{preview.rate_bias < 0.95 && <> · <span className="dir-rate-down">{t('direction.slowsDown')}</span></>}
<strong>{t('direction.rateBias')}</strong>{' '}
<code>{preview.rate_bias?.toFixed?.(2)}</code>
{preview.rate_bias > 1.05 && (
<>
{' '}
· <span className="dir-rate-up">{t('direction.speedsUp')}</span>
</>
)}
{preview.rate_bias < 0.95 && (
<>
{' '}
· <span className="dir-rate-down">{t('direction.slowsDown')}</span>
</>
)}
</div>
{Object.keys(preview.tokens || {}).length > 0 && (
<details>
@@ -127,11 +163,7 @@ export default function DirectionDialog({ open, seg, onSave, onClose }) {
<pre>{JSON.stringify(preview.tokens, null, 2)}</pre>
</details>
)}
{preview.error && (
<div className="dir-error">
{preview.error}
</div>
)}
{preview.error && <div className="dir-error">{preview.error}</div>}
</div>
)}
</Dialog>
+162 -66
View File
@@ -1,8 +1,16 @@
import React, { memo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
CheckCircle, AlertCircle, Circle, Trash2, Loader, Headphones, Scissors, Merge,
MoreHorizontal, Sparkles,
CheckCircle,
AlertCircle,
Circle,
Trash2,
Loader,
Headphones,
Scissors,
Merge,
MoreHorizontal,
Sparkles,
} from 'lucide-react';
import { formatTime } from '../utils/format';
import { LANG_CODES } from '../utils/languages';
@@ -22,11 +30,15 @@ function rowClass(isActive, isDone, selected, isPlaying, timelineSelected) {
// then the literal midpoint as a last resort.
function bestSplitPoint(text) {
const mid = Math.floor(text.length / 2);
let best = -1, bestDist = Infinity;
let best = -1,
bestDist = Infinity;
for (let i = 0; i < text.length; i++) {
if (SENTENCE_END.test(text[i])) {
const d = Math.abs(i + 1 - mid);
if (d < bestDist) { best = i + 1; bestDist = d; }
if (d < bestDist) {
best = i + 1;
bestDist = d;
}
}
}
if (best > 0 && best < text.length) return best;
@@ -47,9 +59,28 @@ function parseTime(s) {
}
function DubSegmentRow({
seg, idx, style, disabled, isActive, isDone, isPlaying, previewLoading, selected,
profiles, speakerClones, onEditField, onDelete, onRestore, onPreview, onSelect, onSplit, onMerge, canMerge,
onDirect, onSeek, timelineSelected,
seg,
idx,
style,
disabled,
isActive,
isDone,
isPlaying,
previewLoading,
selected,
profiles,
speakerClones,
onEditField,
onDelete,
onRestore,
onPreview,
onSelect,
onSplit,
onMerge,
canMerge,
onDirect,
onSeek,
timelineSelected,
}) {
const { t } = useTranslation();
const textInputRef = useRef(null);
@@ -75,7 +106,12 @@ function DubSegmentRow({
let fitBadge = null;
if (fitStatus) {
if (fitStatus.status === 'fits') {
fitBadge = { color: '#b8bb26', Icon: CheckCircle, label: t('segment.fit_fits'), title: t('segment.fit_fits_title') };
fitBadge = {
color: '#b8bb26',
Icon: CheckCircle,
label: t('segment.fit_fits'),
title: t('segment.fit_fits_title'),
};
} else if (fitStatus.status === 'overflows') {
const over = fitStatus.overflow_s || 0;
fitBadge = {
@@ -96,16 +132,31 @@ function DubSegmentRow({
} else if (seg.sync_ratio !== undefined) {
const r = seg.sync_ratio;
if (r > 1.25) {
fitBadge = { color: '#fb4934', Icon: AlertCircle, label: `${Math.round(r * 100)}%`, title: t('segment.fit_compressed_title', { pct: Math.round(r * 100) }) };
fitBadge = {
color: '#fb4934',
Icon: AlertCircle,
label: `${Math.round(r * 100)}%`,
title: t('segment.fit_compressed_title', { pct: Math.round(r * 100) }),
};
} else if (r >= 0.95 && r <= 1.05) {
fitBadge = { color: '#b8bb26', Icon: CheckCircle, label: t('segment.fit_fits'), title: t('segment.fit_audio_title') };
fitBadge = {
color: '#b8bb26',
Icon: CheckCircle,
label: t('segment.fit_fits'),
title: t('segment.fit_audio_title'),
};
} else {
fitBadge = { color: '#fabd2f', Icon: Circle, label: `${Math.round(r * 100)}%`, title: t('segment.fit_ratio_title', { pct: Math.round(r * 100) }) };
fitBadge = {
color: '#fabd2f',
Icon: Circle,
label: `${Math.round(r * 100)}%`,
title: t('segment.fit_ratio_title', { pct: Math.round(r * 100) }),
};
}
}
const overBudget = seg.text_original
&& seg.text.length > Math.ceil(seg.text_original.length * CHAR_BUDGET_RATIO);
const overBudget =
seg.text_original && seg.text.length > Math.ceil(seg.text_original.length * CHAR_BUDGET_RATIO);
const handleTextKeyDown = (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === 'd' || e.key === 'D')) {
@@ -133,7 +184,11 @@ function DubSegmentRow({
};
return (
<div style={style} className={rowClass(isActive, isDone, selected, isPlaying, timelineSelected)} onClick={handleRowClick}>
<div
style={style}
className={rowClass(isActive, isDone, selected, isPlaying, timelineSelected)}
onClick={handleRowClick}
>
<input
type="checkbox"
checked={!!selected}
@@ -156,7 +211,10 @@ function DubSegmentRow({
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') e.target.blur();
if (e.key === 'Escape') { e.target.value = formatTime(seg.start); e.target.blur(); }
if (e.key === 'Escape') {
e.target.value = formatTime(seg.start);
e.target.blur();
}
}}
onBlur={(e) => {
const v = parseTime(e.target.value);
@@ -174,17 +232,16 @@ function DubSegmentRow({
<span className="seg-time-sep"></span>
<span className="seg-time-end">{formatTime(seg.end)}</span>
{seg.speed && seg.speed !== 1.0 && (
<span className="seg-speed-badge" style={{ color: seg.speed > 1 ? '#d3869b' : '#8ec07c' }}>
<span
className="seg-speed-badge"
style={{ color: seg.speed > 1 ? '#d3869b' : '#8ec07c' }}
>
{seg.speed.toFixed(2)}x
</span>
)}
</span>
{fitBadge && (
<span
className="seg-sync-badge"
style={{ color: fitBadge.color }}
title={fitBadge.title}
>
<span className="seg-sync-badge" style={{ color: fitBadge.color }} title={fitBadge.title}>
<fitBadge.Icon size={8} /> {fitBadge.label}
</span>
)}
@@ -202,8 +259,14 @@ function DubSegmentRow({
{seg.rate_ratio != null && Math.abs(seg.rate_ratio - 1.0) > 0.03 && (
<span
className="seg-rate-badge"
style={{ color: seg.rate_ratio > 1.15 ? '#fb4934' : seg.rate_ratio < 0.85 ? '#83a598' : '#a89984' }}
title={t('segment.rate_title', { ratio: seg.rate_ratio.toFixed(2), error: seg.rate_error || '' })}
style={{
color:
seg.rate_ratio > 1.15 ? '#fb4934' : seg.rate_ratio < 0.85 ? '#83a598' : '#a89984',
}}
title={t('segment.rate_title', {
ratio: seg.rate_ratio.toFixed(2),
error: seg.rate_error || '',
})}
>
📖 {seg.rate_ratio.toFixed(2)}×
</span>
@@ -218,13 +281,17 @@ function DubSegmentRow({
disabled={disabled}
list={speakerOptions.length ? speakerListId : undefined}
placeholder={speakerOptions.length ? t('segment.speaker_pick') : ''}
title={speakerOptions.length
? t('segment.speaker_title_detected')
: t('segment.speaker_title_custom')}
title={
speakerOptions.length
? t('segment.speaker_title_detected')
: t('segment.speaker_title_custom')
}
/>
{speakerOptions.length > 0 && (
<datalist id={speakerListId}>
{speakerOptions.map(spk => <option key={spk} value={spk} />)}
{speakerOptions.map((spk) => (
<option key={spk} value={spk} />
))}
</datalist>
)}
@@ -237,17 +304,26 @@ function DubSegmentRow({
onKeyDown={handleTextKeyDown}
onKeyUp={captureCursor}
onSelect={captureCursor}
onClick={(e) => { e.stopPropagation(); captureCursor(e); }}
onClick={(e) => {
e.stopPropagation();
captureCursor(e);
}}
disabled={disabled}
title={seg.translate_error
? t('segment.translate_error_title', { error: seg.translate_error })
: overBudget
? t('segment.budget_title', { pct: Math.round((seg.text.length / seg.text_original.length) * 100) })
: t('segment.text_title')}
title={
seg.translate_error
? t('segment.translate_error_title', { error: seg.translate_error })
: overBudget
? t('segment.budget_title', {
pct: Math.round((seg.text.length / seg.text_original.length) * 100),
})
: t('segment.text_title')
}
style={
overBudget ? { borderColor: 'rgba(250,189,47,0.6)', background: 'rgba(250,189,47,0.06)' }
: seg.translate_error ? { borderColor: 'rgba(251,73,52,0.5)' }
: undefined
overBudget
? { borderColor: 'rgba(250,189,47,0.6)', background: 'rgba(250,189,47,0.06)' }
: seg.translate_error
? { borderColor: 'rgba(251,73,52,0.5)' }
: undefined
}
/>
{seg.text_original && seg.text_original !== seg.text && (
@@ -280,8 +356,10 @@ function DubSegmentRow({
onChange={(e) => onEditField(seg.id, 'target_lang', e.target.value)}
>
<option value="">{t('segment.lang_default')}</option>
{LANG_CODES.map(lc => (
<option key={lc.code} value={lc.code}>{lc.code.toUpperCase()}</option>
{LANG_CODES.map((lc) => (
<option key={lc.code} value={lc.code}>
{lc.code.toUpperCase()}
</option>
))}
</select>
@@ -294,33 +372,49 @@ function DubSegmentRow({
<option value="">{t('segment.voice_default')}</option>
{speakerClones && Object.keys(speakerClones).length > 0 && (
<optgroup label={t('segment.from_video')}>
{Object.keys(speakerClones).map(spk => {
{Object.keys(speakerClones).map((spk) => {
const autoId = `auto:${(spk || '').toLowerCase().replace(/\s+/g, '_')}`;
return <option key={autoId} value={autoId}>🎤 {spk}</option>;
return (
<option key={autoId} value={autoId}>
🎤 {spk}
</option>
);
})}
</optgroup>
)}
{profiles.length > 0 && (
<optgroup label={t('segment.clone_profiles')}>
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</optgroup>
)}
{PRESETS.length > 0 && (
<optgroup label={t('segment.design_presets')}>
{PRESETS.map(p => <option key={p.id} value={`preset:${p.id}`}>{p.name}</option>)}
{PRESETS.map((p) => (
<option key={p.id} value={`preset:${p.id}`}>
{p.name}
</option>
))}
</optgroup>
)}
</select>
<input
type="range"
min="0" max="200"
min="0"
max="200"
value={Math.round((seg.gain ?? 1.0) * 100)}
title={`${Math.round((seg.gain ?? 1.0) * 100)}%`}
disabled={disabled}
onChange={(e) => onEditField(seg.id, 'gain', Number(e.target.value) / 100)}
className="seg-gain-slider"
style={{ accentColor: (seg.gain ?? 1.0) > 1.2 ? '#fb4934' : (seg.gain ?? 1.0) < 0.5 ? '#83a598' : '#a89984' }}
style={{
accentColor:
(seg.gain ?? 1.0) > 1.2 ? '#fb4934' : (seg.gain ?? 1.0) < 0.5 ? '#83a598' : '#a89984',
}}
/>
<div className="seg-actions">
@@ -376,16 +470,16 @@ function DubSegmentRow({
<button
className={`segment-play ${seg.direction ? 'has-direction' : ''}`}
disabled={disabled}
title={seg.direction ? t('segment.direction_title', { dir: seg.direction }) : t('segment.more_actions_title')}
title={
seg.direction
? t('segment.direction_title', { dir: seg.direction })
: t('segment.more_actions_title')
}
>
{seg.direction ? <Sparkles size={9} /> : <MoreHorizontal size={9} />}
</button>
</Menu>
<button
className="segment-del"
disabled={disabled}
onClick={() => onDelete(seg.id)}
>
<button className="segment-del" disabled={disabled} onClick={() => onDelete(seg.id)}>
<Trash2 size={9} />
</button>
</div>
@@ -393,19 +487,21 @@ function DubSegmentRow({
);
}
export default memo(DubSegmentRow, (prev, next) => (
prev.seg === next.seg &&
prev.disabled === next.disabled &&
prev.isActive === next.isActive &&
prev.isDone === next.isDone &&
prev.isPlaying === next.isPlaying &&
prev.timelineSelected === next.timelineSelected &&
prev.previewLoading === next.previewLoading &&
prev.onDirect === next.onDirect &&
prev.onSeek === next.onSeek &&
prev.selected === next.selected &&
prev.canMerge === next.canMerge &&
prev.profiles === next.profiles &&
prev.speakerClones === next.speakerClones &&
prev.idx === next.idx
));
export default memo(
DubSegmentRow,
(prev, next) =>
prev.seg === next.seg &&
prev.disabled === next.disabled &&
prev.isActive === next.isActive &&
prev.isDone === next.isDone &&
prev.isPlaying === next.isPlaying &&
prev.timelineSelected === next.timelineSelected &&
prev.previewLoading === next.previewLoading &&
prev.onDirect === next.onDirect &&
prev.onSeek === next.onSeek &&
prev.selected === next.selected &&
prev.canMerge === next.canMerge &&
prev.profiles === next.profiles &&
prev.speakerClones === next.speakerClones &&
prev.idx === next.idx,
);
+178 -61
View File
@@ -10,19 +10,34 @@ const BASE_ROW_HEIGHT = 26;
const ROW_HEIGHT_WITH_ORIG = 40;
const COLUMNS = [
{ key: 'time', width: 50 },
{ key: 'spkr', width: 45 },
{ key: 'text', flex: 1 },
{ key: 'lang', width: 42 },
{ key: 'time', width: 50 },
{ key: 'spkr', width: 45 },
{ key: 'text', flex: 1 },
{ key: 'lang', width: 42 },
{ key: 'voice', width: 60 },
{ key: 'vol', width: 40 },
{ key: 'act', width: 42 },
{ key: 'vol', width: 40 },
{ key: 'act', width: 42 },
];
export default function DubSegmentTable({
segments, profiles, speakerClones, dubStep, dubProgress, previewLoadingId,
selectedIds, onSelect, onSelectAll, onClearSelection,
onEditField, onDelete, onRestore, onPreview, onSplit, onMerge, onDirect, onSeek,
segments,
profiles,
speakerClones,
dubStep,
dubProgress,
previewLoadingId,
selectedIds,
onSelect,
onSelectAll,
onClearSelection,
onEditField,
onDelete,
onRestore,
onPreview,
onSplit,
onMerge,
onDirect,
onSeek,
timelineSelectedId = null,
}) {
const { t } = useTranslation();
@@ -32,14 +47,14 @@ export default function DubSegmentTable({
// ID of the segment under the playhead. Subscribed via selector so the
// table re-renders only when the playing segment changes, not on every
// timeupdate tick.
const currentSegId = useAppStore(s => s.dubCurrentSegId);
const currentSegId = useAppStore((s) => s.dubCurrentSegId);
// Imperative handle for react-window v2 so we can auto-scroll the row
// containing the playhead into view. (The scroll effect itself lives
// below the `filtered` memo so it can depend on it without TDZ.)
const listRef = useRef(null);
const columns = COLUMNS.map(c => {
const columns = COLUMNS.map((c) => {
if (c.key === 'vol') return { ...c, label: t('segment.vol'), title: t('segment.vol_title') };
if (c.key === 'act') return { ...c, label: '' };
return { ...c, label: t(`segment.${c.key}`) };
@@ -60,18 +75,20 @@ export default function DubSegmentTable({
}, []);
const speakers = useMemo(() => {
const s = new Set(segments.map(x => x.speaker_id).filter(Boolean));
const s = new Set(segments.map((x) => x.speaker_id).filter(Boolean));
return Array.from(s).sort();
}, [segments]);
const filtered = useMemo(() => {
if (!query && !speakerFilter) return segments;
const q = query.trim().toLowerCase();
return segments.filter(s => {
return segments.filter((s) => {
if (speakerFilter && s.speaker_id !== speakerFilter) return false;
if (!q) return true;
return (s.text && s.text.toLowerCase().includes(q))
|| (s.text_original && s.text_original.toLowerCase().includes(q));
return (
(s.text && s.text.toLowerCase().includes(q)) ||
(s.text_original && s.text_original.toLowerCase().includes(q))
);
});
}, [segments, query, speakerFilter]);
@@ -80,73 +97,169 @@ export default function DubSegmentTable({
// Placed after `filtered` so it can depend on it without TDZ.
useEffect(() => {
if (!currentSegId || !listRef.current) return;
const filteredIdx = filtered.findIndex(s => s.id === currentSegId);
const filteredIdx = filtered.findIndex((s) => s.id === currentSegId);
if (filteredIdx < 0) return;
try {
listRef.current.scrollToRow({
index: filteredIdx, align: 'smart', behavior: 'smooth',
index: filteredIdx,
align: 'smart',
behavior: 'smooth',
});
} catch (_) { /* react-window may not be ready yet */ }
} catch (_) {
/* react-window may not be ready yet */
}
}, [currentSegId, filtered]);
// Timeline → table sync (#280, item 3): clicking a segment box on the
// waveform timeline scrolls its row into view and highlights it.
useEffect(() => {
if (timelineSelectedId == null || !listRef.current) return;
const filteredIdx = filtered.findIndex(s => String(s.id) === String(timelineSelectedId));
const filteredIdx = filtered.findIndex((s) => String(s.id) === String(timelineSelectedId));
if (filteredIdx < 0) return;
try {
listRef.current.scrollToRow({
index: filteredIdx, align: 'smart', behavior: 'smooth',
index: filteredIdx,
align: 'smart',
behavior: 'smooth',
});
} catch (_) { /* react-window may not be ready yet */ }
} catch (_) {
/* react-window may not be ready yet */
}
}, [timelineSelectedId, filtered]);
const rowHeight = useCallback((index) => {
const s = filtered[index];
if (!s) return BASE_ROW_HEIGHT;
return (s.text_original && s.text_original !== s.text) ? ROW_HEIGHT_WITH_ORIG : BASE_ROW_HEIGHT;
}, [filtered]);
const rowHeight = useCallback(
(index) => {
const s = filtered[index];
if (!s) return BASE_ROW_HEIGHT;
return s.text_original && s.text_original !== s.text ? ROW_HEIGHT_WITH_ORIG : BASE_ROW_HEIGHT;
},
[filtered],
);
const rowProps = useMemo(() => ({
filtered, profiles, speakerClones, disabled, dubStep, dubProgress, previewLoadingId,
selectedIds, onSelect, onEditField, onDelete, onRestore, onPreview, onSplit, onMerge, onDirect, onSeek,
segments, currentSegId, timelineSelectedId,
}), [filtered, profiles, speakerClones, disabled, dubStep, dubProgress, previewLoadingId,
selectedIds, onSelect, onEditField, onDelete, onRestore, onPreview, onSplit, onMerge, onDirect, onSeek, segments, currentSegId, timelineSelectedId]);
const rowProps = useMemo(
() => ({
filtered,
profiles,
speakerClones,
disabled,
dubStep,
dubProgress,
previewLoadingId,
selectedIds,
onSelect,
onEditField,
onDelete,
onRestore,
onPreview,
onSplit,
onMerge,
onDirect,
onSeek,
segments,
currentSegId,
timelineSelectedId,
}),
[
filtered,
profiles,
speakerClones,
disabled,
dubStep,
dubProgress,
previewLoadingId,
selectedIds,
onSelect,
onEditField,
onDelete,
onRestore,
onPreview,
onSplit,
onMerge,
onDirect,
onSeek,
segments,
currentSegId,
timelineSelectedId,
],
);
const Row = useCallback(({ index, style, filtered: fl, profiles: profs, speakerClones: clones, disabled: dis, dubProgress: prog, dubStep: step, previewLoadingId: previewId, selectedIds: sel, onSelect: pick, onEditField: edit, onDelete: del, onRestore: rest, onPreview: prev, onSplit: split, onMerge: merge, onDirect: direct, onSeek: seek, segments: segs, currentSegId: curId, timelineSelectedId: tlSel }) => {
const seg = fl[index];
if (!seg) return null;
const absoluteIndex = segs.indexOf(seg);
const isActive = (step === 'generating' || step === 'stopping') && prog.current === absoluteIndex + 1;
const isDone = (step === 'generating' || step === 'stopping') && prog.current > absoluteIndex + 1;
const isPlaying = curId === seg.id;
const timelineSelected = tlSel != null && String(tlSel) === String(seg.id);
const canMerge = index < fl.length - 1;
return (
<DubSegmentRow
seg={seg} idx={index} style={style}
disabled={dis} isActive={isActive} isDone={isDone}
isPlaying={isPlaying}
timelineSelected={timelineSelected}
previewLoading={previewId === seg.id}
selected={sel && sel.has(seg.id)}
canMerge={canMerge}
profiles={profs}
speakerClones={clones}
onEditField={edit} onDelete={del} onRestore={rest} onPreview={prev}
onSelect={pick} onSplit={split} onMerge={merge} onDirect={direct} onSeek={seek}
/>
);
}, []);
const Row = useCallback(
({
index,
style,
filtered: fl,
profiles: profs,
speakerClones: clones,
disabled: dis,
dubProgress: prog,
dubStep: step,
previewLoadingId: previewId,
selectedIds: sel,
onSelect: pick,
onEditField: edit,
onDelete: del,
onRestore: rest,
onPreview: prev,
onSplit: split,
onMerge: merge,
onDirect: direct,
onSeek: seek,
segments: segs,
currentSegId: curId,
timelineSelectedId: tlSel,
}) => {
const seg = fl[index];
if (!seg) return null;
const absoluteIndex = segs.indexOf(seg);
const isActive =
(step === 'generating' || step === 'stopping') && prog.current === absoluteIndex + 1;
const isDone =
(step === 'generating' || step === 'stopping') && prog.current > absoluteIndex + 1;
const isPlaying = curId === seg.id;
const timelineSelected = tlSel != null && String(tlSel) === String(seg.id);
const canMerge = index < fl.length - 1;
return (
<DubSegmentRow
seg={seg}
idx={index}
style={style}
disabled={dis}
isActive={isActive}
isDone={isDone}
isPlaying={isPlaying}
timelineSelected={timelineSelected}
previewLoading={previewId === seg.id}
selected={sel && sel.has(seg.id)}
canMerge={canMerge}
profiles={profs}
speakerClones={clones}
onEditField={edit}
onDelete={del}
onRestore={rest}
onPreview={prev}
onSelect={pick}
onSplit={split}
onMerge={merge}
onDirect={direct}
onSeek={seek}
/>
);
},
[],
);
const allFilteredSelected = filtered.length > 0 && filtered.every(s => selectedIds && selectedIds.has(s.id));
const allFilteredSelected =
filtered.length > 0 && filtered.every((s) => selectedIds && selectedIds.has(s.id));
const selCount = selectedIds?.size ?? 0;
const meta = (
<>
{filtered.length}/{segments.length}
{selCount > 0 && <span className="dub-segment-table__sel-count"> · {t('segment.sel_count', { count: selCount })}</span>}
{selCount > 0 && (
<span className="dub-segment-table__sel-count">
{' '}
· {t('segment.sel_count', { count: selCount })}
</span>
)}
</>
);
@@ -166,7 +279,11 @@ export default function DubSegmentTable({
className="dub-segment-table__spk-filter"
>
<option value="">{t('segment.all_speakers')}</option>
{speakers.map(s => <option key={s} value={s}>{s}</option>)}
{speakers.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</Select>
)}
</Table.Toolbar>
@@ -179,7 +296,7 @@ export default function DubSegmentTable({
<input
type="checkbox"
checked={allFilteredSelected}
onChange={(e) => e.target.checked ? onSelectAll(filtered) : onClearSelection()}
onChange={(e) => (e.target.checked ? onSelectAll(filtered) : onClearSelection())}
title={t('segment.select_all_title')}
/>
</span>
+27 -21
View File
@@ -28,10 +28,16 @@ export default function DubbingDemo({ onDismiss }) {
useEffect(() => {
let cancelled = false;
apiFetch(`${API}/demo_audio/demo/dubbing/manifest.json`)
.then(r => r.json())
.then(j => { if (!cancelled) setManifest(j); })
.catch(e => { if (!cancelled) setError(e?.message || String(e)); });
return () => { cancelled = true; };
.then((r) => r.json())
.then((j) => {
if (!cancelled) setManifest(j);
})
.catch((e) => {
if (!cancelled) setError(e?.message || String(e));
});
return () => {
cancelled = true;
};
}, []);
// Sync the two players when syncPlay is on — play/pause/seek the
@@ -49,8 +55,12 @@ export default function DubbingDemo({ onDismiss }) {
dst.play().catch(() => {});
}
};
const onPause = (dst) => () => { if (!dst.paused) dst.pause(); };
const onSeek = (src, dst) => () => { dst.currentTime = src.currentTime; };
const onPause = (dst) => () => {
if (!dst.paused) dst.pause();
};
const onSeek = (src, dst) => () => {
dst.currentTime = src.currentTime;
};
const aPlay = onPlay(a, b);
const bPlay = onPlay(b, a);
@@ -79,15 +89,11 @@ export default function DubbingDemo({ onDismiss }) {
return null; // No demo manifest yet — silently fall through to drop zone.
}
if (!manifest) {
return (
<div className="dubbing-demo dubbing-demo--loading">
{t('demo.dubbing_loading')}
</div>
);
return <div className="dubbing-demo dubbing-demo--loading">{t('demo.dubbing_loading')}</div>;
}
const source = manifest.source;
const dubbed = manifest.dubbed?.find(d => d.code === pickedCode) || manifest.dubbed?.[0];
const dubbed = manifest.dubbed?.find((d) => d.code === pickedCode) || manifest.dubbed?.[0];
if (!dubbed) return null;
const base = `${API}/demo_audio/demo/dubbing`;
@@ -103,7 +109,7 @@ export default function DubbingDemo({ onDismiss }) {
<input
type="checkbox"
checked={syncPlay}
onChange={e => setSyncPlay(e.target.checked)}
onChange={(e) => setSyncPlay(e.target.checked)}
/>
{t('demo.dubbing_sync')}
</label>
@@ -122,7 +128,9 @@ export default function DubbingDemo({ onDismiss }) {
<div className="dubbing-demo__players">
<div className="dubbing-demo__pane">
<div className="dubbing-demo__pane-label">{source.label} <span>· {t('demo.original_tag')}</span></div>
<div className="dubbing-demo__pane-label">
{source.label} <span>· {t('demo.original_tag')}</span>
</div>
<video
ref={sourceRef}
src={`${base}/${source.video}`}
@@ -144,13 +152,15 @@ export default function DubbingDemo({ onDismiss }) {
preload="metadata"
dir={dubbed.dir}
/>
<p className="dubbing-demo__caption" dir={dubbed.dir}>{dubbed.script}</p>
<p className="dubbing-demo__caption" dir={dubbed.dir}>
{dubbed.script}
</p>
</div>
</div>
<div className="dubbing-demo__picker">
<span className="dubbing-demo__picker-label">{t('demo.dubbing_picker')}</span>
{manifest.dubbed.map(d => (
{manifest.dubbed.map((d) => (
<button
key={d.code}
type="button"
@@ -163,11 +173,7 @@ export default function DubbingDemo({ onDismiss }) {
</div>
{onDismiss && (
<button
type="button"
className="dubbing-demo__cta"
onClick={onDismiss}
>
<button type="button" className="dubbing-demo__cta" onClick={onDismiss}>
<Play size={12} /> {t('demo.dubbing_cta')}
</button>
)}
@@ -1,5 +1,14 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Cpu, Mic, MessageSquare, Activity, AlertTriangle, CheckCircle2, RefreshCw, Layers } from 'lucide-react';
import {
Cpu,
Mic,
MessageSquare,
Activity,
AlertTriangle,
CheckCircle2,
RefreshCw,
Layers,
} from 'lucide-react';
import { toastErrorWithReport } from '../utils/errorToast';
import { useTranslation } from 'react-i18next';
import { listEngines, getEngineHealth } from '../api/engines';
@@ -80,10 +89,10 @@ const GPU_LABEL = {
// suppressed there. Any status not in this map (or a legacy payload with no
// routing_status at all) falls back to a neutral "Unknown" badge / no badge.
const ROUTING_BADGE = {
accelerated: { tone: 'success', k: 'engines.routingAccelerated' },
cpu_fallback: { tone: 'warn', k: 'engines.routingCpuFallback' },
cpu_only: { tone: 'neutral', k: 'engines.routingCpuOnly' },
'n/a': { tone: 'neutral', k: 'engines.routingRemote' },
accelerated: { tone: 'success', k: 'engines.routingAccelerated' },
cpu_fallback: { tone: 'warn', k: 'engines.routingCpuFallback' },
cpu_only: { tone: 'neutral', k: 'engines.routingCpuOnly' },
'n/a': { tone: 'neutral', k: 'engines.routingRemote' },
};
const TEST_COOLDOWN_MS = 5000;
@@ -98,9 +107,8 @@ function normalizeEntry(entry) {
install_hint: entry.install_hint || null,
last_error: entry.last_error || null,
isolation_mode: entry.isolation_mode || 'in-process',
gpu_compat: Array.isArray(entry.gpu_compat) && entry.gpu_compat.length > 0
? entry.gpu_compat
: ['cpu'],
gpu_compat:
Array.isArray(entry.gpu_compat) && entry.gpu_compat.length > 0 ? entry.gpu_compat : ['cpu'],
// Routing (#21) — may be absent on a legacy/older backend payload, in
// which case the matrix renders exactly as before (no routing badge).
effective_device: entry.effective_device || null,
@@ -132,7 +140,9 @@ export default function EngineCompatibilityMatrix({
// latency_ms?: number, lastClickAt?: number } }
const [healthByEngine, setHealthByEngine] = useState({});
useEffect(() => { setActiveFamily(family); }, [family]);
useEffect(() => {
setActiveFamily(family);
}, [family]);
const reload = useCallback(async () => {
setLoading(true);
@@ -149,62 +159,64 @@ export default function EngineCompatibilityMatrix({
}
}, [apiListEngines, t]);
useEffect(() => { reload(); }, [reload]);
useEffect(() => {
reload();
}, [reload]);
const familyData = data?.[activeFamily];
const backends = useMemo(
() => (familyData?.backends || []).map(normalizeEntry),
[familyData],
);
const backends = useMemo(() => (familyData?.backends || []).map(normalizeEntry), [familyData]);
const families = useMemo(
() => Object.keys(FAMILY_META).filter((f) => data?.[f]?.backends),
[data],
);
const testHealth = useCallback(async (id) => {
const now = Date.now();
const cur = healthByEngine[id];
if (cur?.inflight) return;
if (cur?.lastClickAt && now - cur.lastClickAt < TEST_COOLDOWN_MS) {
// Click-storm cooldown — silently ignore.
return;
}
setHealthByEngine((prev) => ({
...prev,
[id]: { inflight: true, lastClickAt: now },
}));
try {
const result = await apiGetEngineHealth(id);
const testHealth = useCallback(
async (id) => {
const now = Date.now();
const cur = healthByEngine[id];
if (cur?.inflight) return;
if (cur?.lastClickAt && now - cur.lastClickAt < TEST_COOLDOWN_MS) {
// Click-storm cooldown — silently ignore.
return;
}
setHealthByEngine((prev) => ({
...prev,
[id]: {
inflight: false,
ok: !!result.ok,
message: result.message || '',
latency_ms: Math.round(result.latency_ms || 0),
lastClickAt: now,
},
[id]: { inflight: true, lastClickAt: now },
}));
} catch (e) {
setHealthByEngine((prev) => ({
...prev,
[id]: {
inflight: false,
ok: false,
message: e?.message || String(e),
latency_ms: 0,
lastClickAt: now,
},
}));
}
}, [apiGetEngineHealth, healthByEngine]);
try {
const result = await apiGetEngineHealth(id);
setHealthByEngine((prev) => ({
...prev,
[id]: {
inflight: false,
ok: !!result.ok,
message: result.message || '',
latency_ms: Math.round(result.latency_ms || 0),
lastClickAt: now,
},
}));
} catch (e) {
setHealthByEngine((prev) => ({
...prev,
[id]: {
inflight: false,
ok: false,
message: e?.message || String(e),
latency_ms: 0,
lastClickAt: now,
},
}));
}
},
[apiGetEngineHealth, healthByEngine],
);
const COLUMNS = [
{ key: 'name', label: t('engines.matrixTitle').split(' ')[0] || 'Engine', flex: 3 },
{ key: 'status', label: t('engines.status'), width: 130, align: 'center' },
{ key: 'gpu', label: 'GPU compat', width: 170, align: 'left' },
{ key: 'isolation', label: 'Isolation', width: 110, align: 'center' },
{ key: 'action', label: 'Actions', width: 220, align: 'right' },
{ key: 'name', label: t('engines.matrixTitle').split(' ')[0] || 'Engine', flex: 3 },
{ key: 'status', label: t('engines.status'), width: 130, align: 'center' },
{ key: 'gpu', label: 'GPU compat', width: 170, align: 'left' },
{ key: 'isolation', label: 'Isolation', width: 110, align: 'center' },
{ key: 'action', label: 'Actions', width: 220, align: 'right' },
];
if (loading && !data) {
@@ -252,7 +264,10 @@ export default function EngineCompatibilityMatrix({
onChange={setActiveFamily}
items={families.map((f) => ({
value: f,
title: t('engines.activeEngine', { family: FAMILY_META[f].label, engine: data[f].active }),
title: t('engines.activeEngine', {
family: FAMILY_META[f].label,
engine: data[f].active,
}),
label: (
<span className="engine-matrix__tab-label">
<span className="engine-matrix__tab-family">{FAMILY_META[f].label}</span>
@@ -263,7 +278,11 @@ export default function EngineCompatibilityMatrix({
/>
)}
<Table className="engine-matrix__table" role="table" aria-label={t('engines.engineCompatLabel', { family: activeFamily })}>
<Table
className="engine-matrix__table"
role="table"
aria-label={t('engines.engineCompatLabel', { family: activeFamily })}
>
<Table.Header columns={COLUMNS} />
<div className="engine-matrix__body" role="rowgroup">
{backends.map((b) => {
@@ -277,10 +296,18 @@ export default function EngineCompatibilityMatrix({
className={`engine-matrix__row ${b.available ? 'is-ok' : 'is-off'}`}
>
{/* Engine name + reason / install_hint */}
<div role="cell" className="engine-matrix__cell engine-matrix__cell--name" style={{ flex: 3 }}>
<div
role="cell"
className="engine-matrix__cell engine-matrix__cell--name"
style={{ flex: 3 }}
>
<span className="engine-matrix__name">
{b.display_name}
{isActive && <Badge tone="brand" size="xs">{t('engines.active')}</Badge>}
{isActive && (
<Badge tone="brand" size="xs">
{t('engines.active')}
</Badge>
)}
</span>
<code className="engine-matrix__id">{b.id}</code>
{/* For available rows, show install_hint inline (one line — usually
@@ -294,11 +321,11 @@ export default function EngineCompatibilityMatrix({
)}
{!b.available && (b.reason || b.install_hint || b.last_error) && (
<details className="engine-matrix__why">
<summary className="engine-matrix__why-summary">{t('engines.whyUnavailable')}</summary>
<summary className="engine-matrix__why-summary">
{t('engines.whyUnavailable')}
</summary>
<div className="engine-matrix__why-body">
{b.reason && (
<span className="engine-matrix__reason">{b.reason}</span>
)}
{b.reason && <span className="engine-matrix__reason">{b.reason}</span>}
{b.install_hint && b.install_hint !== b.reason && (
<span className="engine-matrix__hint">{b.install_hint}</span>
)}
@@ -317,33 +344,52 @@ export default function EngineCompatibilityMatrix({
role="cell"
className="engine-matrix__cell engine-matrix__cell--center"
style={{ width: 130 }}
title={b.available ? t('engines.installedAndReady') : (b.reason || t('engines.notInstalled'))}
title={
b.available
? t('engines.installedAndReady')
: b.reason || t('engines.notInstalled')
}
>
{b.available
? <Badge tone="success" size="xs"><CheckCircle2 size={10} /> {t('engines.available')}</Badge>
: <Badge tone="warn" size="xs"><AlertTriangle size={10} /> {t('engines.unavailable')}</Badge>}
{b.available ? (
<Badge tone="success" size="xs">
<CheckCircle2 size={10} /> {t('engines.available')}
</Badge>
) : (
<Badge tone="warn" size="xs">
<AlertTriangle size={10} /> {t('engines.unavailable')}
</Badge>
)}
</div>
{/* GPU compat chips + routing badge (the device this engine
will actually use on THIS machine). LLM (routing 'n/a')
shows a single "Remote" badge instead of device chips. */}
<div role="cell" className="engine-matrix__cell engine-matrix__cell--gpu" style={{ width: 170 }}>
<div
role="cell"
className="engine-matrix__cell engine-matrix__cell--gpu"
style={{ width: 170 }}
>
<div className="engine-matrix__chips">
{b.routing_status === 'n/a' ? (
<Badge tone="neutral" size="xs">{t('engines.routingRemote')}</Badge>
<Badge tone="neutral" size="xs">
{t('engines.routingRemote')}
</Badge>
) : (
<>
{b.gpu_compat.map((g) => {
const isEffective = b.routing_status
&& b.routing_status !== 'unavailable'
&& g === b.effective_device;
const isEffective =
b.routing_status &&
b.routing_status !== 'unavailable' &&
g === b.effective_device;
return (
<span
key={g}
className={`engine-matrix__chip engine-matrix__chip--${g}${isEffective ? ' is-effective' : ''}`}
title={isEffective
? t('engines.routingEffectiveChip', { device: GPU_LABEL[g] || g })
: undefined}
title={
isEffective
? t('engines.routingEffectiveChip', { device: GPU_LABEL[g] || g })
: undefined
}
>
{GPU_LABEL[g] || g.toUpperCase()}
</span>
@@ -353,14 +399,22 @@ export default function EngineCompatibilityMatrix({
status → neutral fallback; suppressed when the row is
unavailable (availability badge covers it) or legacy
(no routing_status → no badge). */}
{b.routing_status && b.available && b.routing_status !== 'unavailable' && (
ROUTING_BADGE[b.routing_status]
? <Badge tone={ROUTING_BADGE[b.routing_status].tone} size="xs"
title={b.routing_reason || undefined}>
{t(ROUTING_BADGE[b.routing_status].k)}
</Badge>
: <Badge tone="neutral" size="xs">{t('engines.routingUnknown')}</Badge>
)}
{b.routing_status &&
b.available &&
b.routing_status !== 'unavailable' &&
(ROUTING_BADGE[b.routing_status] ? (
<Badge
tone={ROUTING_BADGE[b.routing_status].tone}
size="xs"
title={b.routing_reason || undefined}
>
{t(ROUTING_BADGE[b.routing_status].k)}
</Badge>
) : (
<Badge tone="neutral" size="xs">
{t('engines.routingUnknown')}
</Badge>
))}
</>
)}
</div>
@@ -371,9 +425,11 @@ export default function EngineCompatibilityMatrix({
role="cell"
className="engine-matrix__cell engine-matrix__cell--center"
style={{ width: 110 }}
title={b.isolation_mode === 'subprocess'
? t('engines.subprocessTitle')
: t('engines.inProcessTitle')}
title={
b.isolation_mode === 'subprocess'
? t('engines.subprocessTitle')
: t('engines.inProcessTitle')
}
>
<Badge tone={ISOLATION_TONE[b.isolation_mode] || 'neutral'} size="xs">
{b.isolation_mode}
@@ -441,19 +497,16 @@ export default function EngineCompatibilityMatrix({
the backend says the user hasn't accepted the
engine's license yet AND we have a dialog
registered for that engine id. */}
{!b.available
&& reasonMentionsLicense(b.reason)
&& LICENSE_DIALOGS[b.id]
&& (
<Button
size="sm"
variant="subtle"
onClick={() => setLicenseDialogFor(b.id)}
aria-label={`Review and accept ${b.display_name} license`}
>
{t('engines.acceptLicense')}
</Button>
)}
{!b.available && reasonMentionsLicense(b.reason) && LICENSE_DIALOGS[b.id] && (
<Button
size="sm"
variant="subtle"
onClick={() => setLicenseDialogFor(b.id)}
aria-label={`Review and accept ${b.display_name} license`}
>
{t('engines.acceptLicense')}
</Button>
)}
</div>
</div>
);
+3 -10
View File
@@ -66,18 +66,11 @@ export default class ErrorBoundary extends React.Component {
<div className="errbnd-wrap">
<div className="errbnd-card">
<AlertCircle size={32} color="var(--chrome-severity-err)" className="errbnd-icon" />
<h2 className="errbnd-title">
{i18next.t('errors.title')}
</h2>
<p className="errbnd-desc">
{i18next.t('errors.desc')}
</p>
<h2 className="errbnd-title">{i18next.t('errors.title')}</h2>
<p className="errbnd-desc">{i18next.t('errors.desc')}</p>
<pre className="errbnd-trace">{msg}</pre>
<div className="errbnd-actions">
<button
onClick={this.reset}
className="btn-primary errbnd-retry"
>
<button onClick={this.reset} className="btn-primary errbnd-retry">
<RefreshCw size={12} /> {i18next.t('errors.tryAgain')}
</button>
<button
+479 -256
View File
@@ -2,8 +2,18 @@ import React, { useMemo, useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import {
Film, Volume2, FileText, Package, Music, Layers, Download,
Check, Globe, Zap, X, Building2,
Film,
Volume2,
FileText,
Package,
Music,
Layers,
Download,
Check,
Globe,
Zap,
X,
Building2,
} from 'lucide-react';
import { Button, Segmented, Badge } from '../ui';
import './ExportModal.css';
@@ -18,24 +28,66 @@ import './ExportModal.css';
* source of truth instead of living as three separate dropdowns.
*/
const PRESETS = {
youtube: { labelKey: 'exportModal.preset_youtube', tab: 'video', format: 'mp4', preserveBg: true, burnSubs: false, defaultTrack: 'dub' },
archive: { labelKey: 'exportModal.preset_archive', tab: 'video', format: 'mp4', preserveBg: true, burnSubs: false, includeAll: true },
web: { labelKey: 'exportModal.preset_web', tab: 'video', format: 'mp4', preserveBg: true, burnSubs: true, dualSubs: false },
podcast: { labelKey: 'exportModal.preset_podcast', tab: 'audio', audioFormat: 'mp3', mp3Bitrate: '192', preserveBg: false },
studyset: { labelKey: 'exportModal.preset_studyset', tab: 'subs', subsFormat: 'srt', subsDual: true },
youtube: {
labelKey: 'exportModal.preset_youtube',
tab: 'video',
format: 'mp4',
preserveBg: true,
burnSubs: false,
defaultTrack: 'dub',
},
archive: {
labelKey: 'exportModal.preset_archive',
tab: 'video',
format: 'mp4',
preserveBg: true,
burnSubs: false,
includeAll: true,
},
web: {
labelKey: 'exportModal.preset_web',
tab: 'video',
format: 'mp4',
preserveBg: true,
burnSubs: true,
dualSubs: false,
},
podcast: {
labelKey: 'exportModal.preset_podcast',
tab: 'audio',
audioFormat: 'mp3',
mp3Bitrate: '192',
preserveBg: false,
},
studyset: {
labelKey: 'exportModal.preset_studyset',
tab: 'subs',
subsFormat: 'srt',
subsDual: true,
},
};
export default function ExportModal({
open, onClose,
jobId, filename, dubTracks, dubLangCode,
preserveBg, setPreserveBg,
defaultTrack, setDefaultTrack,
exportTracks, setExportTracks,
dualSubs, setDualSubs,
burnSubs, setBurnSubs,
open,
onClose,
jobId,
filename,
dubTracks,
dubLangCode,
preserveBg,
setPreserveBg,
defaultTrack,
setDefaultTrack,
exportTracks,
setExportTracks,
dualSubs,
setDualSubs,
burnSubs,
setBurnSubs,
API,
triggerDownload,
handleDubDownload, handleAudioExport,
handleDubDownload,
handleAudioExport,
segmentCount = 0,
timingStrategy = '',
onEnterprise,
@@ -44,25 +96,32 @@ export default function ExportModal({
const [tab, setTab] = useState('video');
// ── Tab-local state (not persisted across sessions — each open is fresh).
const [videoFormat, setVideoFormat] = useState('mp4'); // future: webm/mov
const [audioFormat, setAudioFormat] = useState('wav'); // wav | mp3
const [mp3Bitrate, setMp3Bitrate] = useState('192'); // 128/192/256/320
const [audioBatch, setAudioBatch] = useState('each'); // each | primary — per-lang or single file
const [videoFormat, setVideoFormat] = useState('mp4'); // future: webm/mov
const [audioFormat, setAudioFormat] = useState('wav'); // wav | mp3
const [mp3Bitrate, setMp3Bitrate] = useState('192'); // 128/192/256/320
const [audioBatch, setAudioBatch] = useState('each'); // each | primary — per-lang or single file
const [audioPrimaryLang, setAudioPrimaryLang] = useState(dubLangCode || '');
const [subsFormat, setSubsFormat] = useState('srt'); // srt | vtt | both
const [subsFormat, setSubsFormat] = useState('srt'); // srt | vtt | both
const [subsDual, setSubsDual] = useState(!!dualSubs);
const [subsBatch, setSubsBatch] = useState('target'); // target | all-dubs
const [subsBatch, setSubsBatch] = useState('target'); // target | all-dubs
// Reflect the parent's dual/burn once, then own them locally so the modal
// can toy with them without committing on cancel.
useEffect(() => { setSubsDual(!!dualSubs); }, [open, dualSubs]);
useEffect(() => {
setSubsDual(!!dualSubs);
}, [open, dualSubs]);
// ── Drawer dismiss — ESC closes; click-outside closes. The drawer is a
// bottom sheet (non-blocking), so background interactions stay live.
const drawerRef = useRef(null);
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); onClose?.(); } };
const onKey = (e) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose?.();
}
};
const onDown = (e) => {
if (drawerRef.current && !drawerRef.current.contains(e.target)) onClose?.();
};
@@ -76,16 +135,19 @@ export default function ExportModal({
const allTracks = useMemo(() => {
const out = [{ code: 'original', label: t('exportModal.original'), kind: 'original' }];
(dubTracks || []).forEach(t => out.push({ code: t, label: t.toUpperCase(), kind: 'dub' }));
(dubTracks || []).forEach((t) => out.push({ code: t, label: t.toUpperCase(), kind: 'dub' }));
return out;
}, [dubTracks, t]);
const selectedTracks = allTracks.filter(t => exportTracks[t.code] !== false);
const selectedDubs = selectedTracks.filter(t => t.kind === 'dub');
const selectedTracks = allTracks.filter((t) => exportTracks[t.code] !== false);
const selectedDubs = selectedTracks.filter((t) => t.kind === 'dub');
const toggleTrack = (code) => setExportTracks(prev => ({ ...prev, [code]: prev[code] === false ? true : false }));
const setAllTracks = (on) => setExportTracks(Object.fromEntries(allTracks.map(t => [t.code, on])));
const setDubsOnly = () => setExportTracks(Object.fromEntries(allTracks.map(t => [t.code, t.kind === 'dub'])));
const toggleTrack = (code) =>
setExportTracks((prev) => ({ ...prev, [code]: prev[code] === false ? true : false }));
const setAllTracks = (on) =>
setExportTracks(Object.fromEntries(allTracks.map((t) => [t.code, on])));
const setDubsOnly = () =>
setExportTracks(Object.fromEntries(allTracks.map((t) => [t.code, t.kind === 'dub'])));
// ── Presets — map label → state deltas and jump to the right tab.
const applyPreset = (key) => {
@@ -93,13 +155,13 @@ export default function ExportModal({
if (!p) return;
setTab(p.tab);
if (p.preserveBg !== undefined) setPreserveBg(!!p.preserveBg);
if (p.burnSubs !== undefined) setBurnSubs(!!p.burnSubs);
if (p.dualSubs !== undefined) setSubsDual(!!p.dualSubs);
if (p.burnSubs !== undefined) setBurnSubs(!!p.burnSubs);
if (p.dualSubs !== undefined) setSubsDual(!!p.dualSubs);
if (p.audioFormat) setAudioFormat(p.audioFormat);
if (p.mp3Bitrate) setMp3Bitrate(p.mp3Bitrate);
if (p.subsFormat) setSubsFormat(p.subsFormat);
if (p.mp3Bitrate) setMp3Bitrate(p.mp3Bitrate);
if (p.subsFormat) setSubsFormat(p.subsFormat);
if (p.subsDual !== undefined) setSubsDual(!!p.subsDual);
if (p.includeAll) setAllTracks(true);
if (p.includeAll) setAllTracks(true);
if (p.defaultTrack === 'dub' && dubLangCode) setDefaultTrack(dubLangCode);
};
@@ -113,7 +175,8 @@ export default function ExportModal({
if (tab === 'video') return `dubbed_${baseName}_…mp4`;
if (tab === 'audio') {
const ext = audioFormat;
if (audioBatch === 'each') return `dubbed_<lang>_${baseName}_…${ext} (${selectedDubs.length} files)`;
if (audioBatch === 'each')
return `dubbed_<lang>_${baseName}_…${ext} (${selectedDubs.length} files)`;
return `dubbed_${audioPrimaryLang || dubLangCode}_${baseName}_…${ext}`;
}
if (tab === 'subs') {
@@ -126,10 +189,11 @@ export default function ExportModal({
// ── Validity: what's runnable right now?
const canVideo = selectedTracks.length > 0 && (dubTracks || []).length > 0;
const canAudio = audioBatch === 'each'
? selectedDubs.length > 0
: !!audioPrimaryLang && (dubTracks || []).includes(audioPrimaryLang);
const canSubs = segmentCount > 0 && (subsBatch !== 'all-dubs' || selectedDubs.length > 0);
const canAudio =
audioBatch === 'each'
? selectedDubs.length > 0
: !!audioPrimaryLang && (dubTracks || []).includes(audioPrimaryLang);
const canSubs = segmentCount > 0 && (subsBatch !== 'all-dubs' || selectedDubs.length > 0);
// ── Runners — fire backend calls based on tab. Each returns quickly;
// toasts inside triggerDownload keep the user informed.
@@ -138,10 +202,9 @@ export default function ExportModal({
onClose?.();
};
const runAudio = () => {
const langs = audioBatch === 'each'
? selectedDubs.map(t => t.code)
: [audioPrimaryLang || dubLangCode];
langs.forEach(lang => {
const langs =
audioBatch === 'each' ? selectedDubs.map((t) => t.code) : [audioPrimaryLang || dubLangCode];
langs.forEach((lang) => {
if (!lang) return;
const q = `preserve_bg=${preserveBg ? 1 : 0}&lang=${encodeURIComponent(lang)}`;
if (audioFormat === 'wav') {
@@ -155,10 +218,10 @@ export default function ExportModal({
onClose?.();
};
const runSubs = () => {
const targets = subsBatch === 'all-dubs' ? selectedDubs.map(t => t.code) : [dubLangCode];
const targets = subsBatch === 'all-dubs' ? selectedDubs.map((t) => t.code) : [dubLangCode];
const formats = subsFormat === 'both' ? ['srt', 'vtt'] : [subsFormat];
targets.forEach(lang => {
formats.forEach(ext => {
targets.forEach((lang) => {
formats.forEach((ext) => {
const name = `subtitles${subsDual ? '_dual' : ''}_${lang}.${ext}`;
// `lang` lets the backend pick fitted-timeline cue times when the
// track was generated under Smart Fit; inert otherwise.
@@ -180,16 +243,28 @@ export default function ExportModal({
const runMap = {
video: { fn: runVideo, can: canVideo, label: t('exportModal.export_mp4') },
audio: { fn: runAudio, can: canAudio, label: audioBatch === 'each' ? t('exportModal.export_n_audio', { count: selectedDubs.length }) : t('exportModal.export_audio') },
subs: { fn: runSubs, can: canSubs, label: t('exportModal.export_subtitles') },
pkg: { fn: null, can: false, label: t('exportModal.export') },
audio: {
fn: runAudio,
can: canAudio,
label:
audioBatch === 'each'
? t('exportModal.export_n_audio', { count: selectedDubs.length })
: t('exportModal.export_audio'),
},
subs: { fn: runSubs, can: canSubs, label: t('exportModal.export_subtitles') },
pkg: { fn: null, can: false, label: t('exportModal.export') },
};
const active = runMap[tab];
if (!open) return null;
return createPortal(
<div className="export-drawer" role="dialog" aria-modal="false" aria-label={t('exportModal.export_options')}>
<div
className="export-drawer"
role="dialog"
aria-modal="false"
aria-label={t('exportModal.export_options')}
>
<div className="export-drawer__sheet" ref={drawerRef}>
<header className="export-drawer__head">
<span className="export-drawer__handle" aria-hidden="true" />
@@ -197,227 +272,367 @@ export default function ExportModal({
<Download size={13} /> {t('exportModal.export')}
{filename && <span className="export-modal__filename">· {filename}</span>}
</span>
<button type="button" className="export-drawer__close" onClick={onClose} aria-label={t('exportModal.close_drawer')}>
<button
type="button"
className="export-drawer__close"
onClick={onClose}
aria-label={t('exportModal.close_drawer')}
>
<X size={13} />
</button>
</header>
<div className="export-modal export-modal--drawer">
{/* Preset chips */}
<div className="export-modal__presets">
<span className="export-modal__kicker">{t('exportModal.presets')}</span>
{Object.entries(PRESETS).map(([k, v]) => (
<button key={k} type="button" className="export-modal__preset-chip" onClick={() => applyPreset(k)} title={t('exportModal.preset_title', { tab: v.tab, label: t(v.labelKey) })}>
<Zap size={9} /> {t(v.labelKey)}
</button>
))}
</div>
{/* Track checklist — shared across tabs */}
<div className="export-modal__tracks">
<div className="export-modal__section-head">
<span className="export-modal__kicker"><Globe size={9} /> {t('exportModal.tracks')}</span>
<div className="export-modal__track-quick">
<button type="button" onClick={() => setAllTracks(true)}>{t('exportModal.track_all')}</button>
<span>·</span>
<button type="button" onClick={() => setAllTracks(false)}>{t('exportModal.track_none')}</button>
<span>·</span>
<button type="button" onClick={setDubsOnly}>{t('exportModal.track_dubs_only')}</button>
</div>
{/* Preset chips */}
<div className="export-modal__presets">
<span className="export-modal__kicker">{t('exportModal.presets')}</span>
{Object.entries(PRESETS).map(([k, v]) => (
<button
key={k}
type="button"
className="export-modal__preset-chip"
onClick={() => applyPreset(k)}
title={t('exportModal.preset_title', { tab: v.tab, label: t(v.labelKey) })}
>
<Zap size={9} /> {t(v.labelKey)}
</button>
))}
</div>
<div className="export-modal__track-row">
{allTracks.map(track => {
const on = exportTracks[track.code] !== false;
return (
<label key={track.code} className={`export-modal__track ${on ? 'is-on' : ''} ${track.kind === 'original' ? 'is-original' : 'is-dub'}`}>
<input type="checkbox" checked={on} onChange={() => toggleTrack(track.code)} />
<span className="export-modal__track-label">{track.label}</span>
{track.kind === 'dub' && track.code === dubLangCode && <Badge tone="brand" size="xs">{t('exportModal.primary')}</Badge>}
</label>
);
})}
</div>
</div>
{/* Tabs */}
<div className="export-modal__tabs">
<button type="button" className={`export-modal__tab ${tab === 'video' ? 'is-active' : ''}`} onClick={() => setTab('video')}>
<Film size={10} /> {t('exportModal.tab_video')}
</button>
<button type="button" className={`export-modal__tab ${tab === 'audio' ? 'is-active' : ''}`} onClick={() => setTab('audio')}>
<Volume2 size={10} /> {t('exportModal.tab_audio')}
</button>
<button type="button" className={`export-modal__tab ${tab === 'subs' ? 'is-active' : ''}`} onClick={() => setTab('subs')}>
<FileText size={10} /> {t('exportModal.tab_subs')}
</button>
<button type="button" className={`export-modal__tab ${tab === 'pkg' ? 'is-active' : ''}`} onClick={() => setTab('pkg')}>
<Package size={10} /> {t('exportModal.tab_pkg')}
</button>
</div>
{/* Tab body */}
<div className="export-modal__body">
{tab === 'video' && (
<div className="export-modal__grid">
<Field label={t('exportModal.container')}>
<Segmented size="sm" value={videoFormat} onChange={setVideoFormat} items={[
{ value: 'mp4', label: t('exportModal.mp4_h264') },
]} />
</Field>
<Field label={t('exportModal.default_audio_track')} hint={t('exportModal.default_audio_hint')}>
<select className="input-base input-base--xs" value={defaultTrack} onChange={e => setDefaultTrack(e.target.value)}>
{exportTracks['original'] !== false && <option value="original">{t('exportModal.original')}</option>}
{(dubTracks || []).filter(code => exportTracks[code] !== false).map(code => (
<option key={code} value={code}>{code.toUpperCase()} {t('exportModal.dub_suffix')}</option>
))}
</select>
</Field>
<Field label={t('exportModal.bg_audio')}>
<label className="export-modal__toggle">
<input type="checkbox" checked={preserveBg} onChange={e => setPreserveBg(e.target.checked)} />
{t('exportModal.mix_bg_video')}
</label>
</Field>
<Field label={t('exportModal.subs_in_video')}>
<label className="export-modal__toggle">
<input type="checkbox" checked={burnSubs} onChange={e => setBurnSubs(e.target.checked)} />
{t('exportModal.hardsub')}
</label>
{burnSubs && (
<label className="export-modal__toggle export-modal__toggle--indent">
<input type="checkbox" checked={!!dualSubs} onChange={e => setDualSubs(e.target.checked)} />
{t('exportModal.dual_subs_video')}
</label>
)}
</Field>
{(timingStrategy === 'smart_fit' || timingStrategy === 'stretch_video') && (
<div className="export-modal__note">
{t('exportModal.retime_note')}
</div>
)}
</div>
)}
{tab === 'audio' && (
<div className="export-modal__grid">
<Field label={t('exportModal.format')}>
<Segmented size="sm" value={audioFormat} onChange={setAudioFormat} items={[
{ value: 'wav', label: t('exportModal.wav_lossless') },
{ value: 'mp3', label: t('exportModal.mp3_compressed') },
]} />
</Field>
{audioFormat === 'mp3' && (
<Field label={t('exportModal.bitrate')}>
<Segmented size="sm" value={mp3Bitrate} onChange={setMp3Bitrate} items={[
{ value: '128', label: '128k' },
{ value: '192', label: '192k' },
{ value: '256', label: '256k' },
{ value: '320', label: '320k' },
]} />
</Field>
)}
<Field label={t('exportModal.what_to_export')}>
<Segmented size="sm" value={audioBatch} onChange={setAudioBatch} items={[
{ value: 'each', label: t('exportModal.export_each_dub') },
{ value: 'primary', label: t('exportModal.export_single_lang') },
]} />
{audioBatch === 'primary' && (
<select className="input-base input-base--xs export-modal__mt6"
value={audioPrimaryLang} onChange={e => setAudioPrimaryLang(e.target.value)}>
{(dubTracks || []).map(code => <option key={code} value={code}>{code.toUpperCase()}</option>)}
</select>
)}
</Field>
<Field label={t('exportModal.bg_audio')}>
<label className="export-modal__toggle">
<input type="checkbox" checked={preserveBg} onChange={e => setPreserveBg(e.target.checked)} />
{t('exportModal.mix_bg_audio')}
</label>
</Field>
</div>
)}
{tab === 'subs' && (
<div className="export-modal__grid">
<Field label={t('exportModal.format')}>
<Segmented size="sm" value={subsFormat} onChange={setSubsFormat} items={[
{ value: 'srt', label: 'SRT' },
{ value: 'vtt', label: 'VTT' },
{ value: 'both', label: t('exportModal.both') },
]} />
</Field>
<Field label={t('exportModal.layout')}>
<Segmented size="sm" value={subsDual ? 'dual' : 'single'} onChange={v => setSubsDual(v === 'dual')} items={[
{ value: 'single', label: t('exportModal.single_line') },
{ value: 'dual', label: t('exportModal.dual_subs') },
]} />
</Field>
<Field label={t('exportModal.languages')}>
<Segmented size="sm" value={subsBatch} onChange={setSubsBatch} items={[
{ value: 'target', label: t('exportModal.current_target', { code: dubLangCode || '—' }) },
{ value: 'all-dubs', label: t('exportModal.all_selected_dubs', { count: selectedDubs.length }) },
]} />
</Field>
<div className="export-modal__note">
{t('exportModal.subs_note')}
{/* Track checklist — shared across tabs */}
<div className="export-modal__tracks">
<div className="export-modal__section-head">
<span className="export-modal__kicker">
<Globe size={9} /> {t('exportModal.tracks')}
</span>
<div className="export-modal__track-quick">
<button type="button" onClick={() => setAllTracks(true)}>
{t('exportModal.track_all')}
</button>
<span>·</span>
<button type="button" onClick={() => setAllTracks(false)}>
{t('exportModal.track_none')}
</button>
<span>·</span>
<button type="button" onClick={setDubsOnly}>
{t('exportModal.track_dubs_only')}
</button>
</div>
</div>
)}
{tab === 'pkg' && (
<div className="export-modal__pkg-grid">
<PkgCard
icon={<Package size={14} />} title={t('exportModal.pkg_clips_title')}
body={t('exportModal.pkg_clips_body')}
onClick={runClips} cta={t('exportModal.pkg_clips_cta')}
/>
<PkgCard
icon={<Layers size={14} />} title={t('exportModal.pkg_stems_title')}
body={t('exportModal.pkg_stems_body')}
onClick={runStems} cta={t('exportModal.pkg_stems_cta')}
/>
<PkgCard
icon={<Music size={14} />} title={t('exportModal.pkg_audio_title')}
body={t('exportModal.pkg_audio_body', { count: (dubTracks || []).length })}
onClick={() => setTab('audio')} cta={t('exportModal.pkg_audio_cta')}
ghost
/>
<div className="export-modal__track-row">
{allTracks.map((track) => {
const on = exportTracks[track.code] !== false;
return (
<label
key={track.code}
className={`export-modal__track ${on ? 'is-on' : ''} ${track.kind === 'original' ? 'is-original' : 'is-dub'}`}
>
<input type="checkbox" checked={on} onChange={() => toggleTrack(track.code)} />
<span className="export-modal__track-label">{track.label}</span>
{track.kind === 'dub' && track.code === dubLangCode && (
<Badge tone="brand" size="xs">
{t('exportModal.primary')}
</Badge>
)}
</label>
);
})}
</div>
)}
</div>
{/* Commercial license notice */}
<div className="export-modal__license-notice">
<Building2 size={11} />
<span>{t('exportModal.license_text')} <button type="button" className="export-modal__license-link" onClick={() => { onClose(); onEnterprise?.(); }}>{t('exportModal.license_link')}</button>.</span>
</div>
{/* Summary footer */}
<div className="export-modal__summary">
<div className="export-modal__summary-left">
<span className="export-modal__kicker">{t('exportModal.output')}</span>
<code className="export-modal__summary-name" title={filenamePreview}>{filenamePreview}</code>
</div>
<div className="export-modal__summary-right">
{tab !== 'pkg' && (
<>
<Button variant="ghost" size="sm" onClick={onClose}>{t('common.cancel')}</Button>
<Button
variant="primary" size="sm"
onClick={active.fn} disabled={!active.can}
leading={<Download size={11} />}
title={active.can ? '' : t('exportModal.nothing_selected')}
{/* Tabs */}
<div className="export-modal__tabs">
<button
type="button"
className={`export-modal__tab ${tab === 'video' ? 'is-active' : ''}`}
onClick={() => setTab('video')}
>
<Film size={10} /> {t('exportModal.tab_video')}
</button>
<button
type="button"
className={`export-modal__tab ${tab === 'audio' ? 'is-active' : ''}`}
onClick={() => setTab('audio')}
>
<Volume2 size={10} /> {t('exportModal.tab_audio')}
</button>
<button
type="button"
className={`export-modal__tab ${tab === 'subs' ? 'is-active' : ''}`}
onClick={() => setTab('subs')}
>
<FileText size={10} /> {t('exportModal.tab_subs')}
</button>
<button
type="button"
className={`export-modal__tab ${tab === 'pkg' ? 'is-active' : ''}`}
onClick={() => setTab('pkg')}
>
<Package size={10} /> {t('exportModal.tab_pkg')}
</button>
</div>
{/* Tab body */}
<div className="export-modal__body">
{tab === 'video' && (
<div className="export-modal__grid">
<Field label={t('exportModal.container')}>
<Segmented
size="sm"
value={videoFormat}
onChange={setVideoFormat}
items={[{ value: 'mp4', label: t('exportModal.mp4_h264') }]}
/>
</Field>
<Field
label={t('exportModal.default_audio_track')}
hint={t('exportModal.default_audio_hint')}
>
{active.label}
</Button>
</>
<select
className="input-base input-base--xs"
value={defaultTrack}
onChange={(e) => setDefaultTrack(e.target.value)}
>
{exportTracks['original'] !== false && (
<option value="original">{t('exportModal.original')}</option>
)}
{(dubTracks || [])
.filter((code) => exportTracks[code] !== false)
.map((code) => (
<option key={code} value={code}>
{code.toUpperCase()} {t('exportModal.dub_suffix')}
</option>
))}
</select>
</Field>
<Field label={t('exportModal.bg_audio')}>
<label className="export-modal__toggle">
<input
type="checkbox"
checked={preserveBg}
onChange={(e) => setPreserveBg(e.target.checked)}
/>
{t('exportModal.mix_bg_video')}
</label>
</Field>
<Field label={t('exportModal.subs_in_video')}>
<label className="export-modal__toggle">
<input
type="checkbox"
checked={burnSubs}
onChange={(e) => setBurnSubs(e.target.checked)}
/>
{t('exportModal.hardsub')}
</label>
{burnSubs && (
<label className="export-modal__toggle export-modal__toggle--indent">
<input
type="checkbox"
checked={!!dualSubs}
onChange={(e) => setDualSubs(e.target.checked)}
/>
{t('exportModal.dual_subs_video')}
</label>
)}
</Field>
{(timingStrategy === 'smart_fit' || timingStrategy === 'stretch_video') && (
<div className="export-modal__note">{t('exportModal.retime_note')}</div>
)}
</div>
)}
{tab === 'audio' && (
<div className="export-modal__grid">
<Field label={t('exportModal.format')}>
<Segmented
size="sm"
value={audioFormat}
onChange={setAudioFormat}
items={[
{ value: 'wav', label: t('exportModal.wav_lossless') },
{ value: 'mp3', label: t('exportModal.mp3_compressed') },
]}
/>
</Field>
{audioFormat === 'mp3' && (
<Field label={t('exportModal.bitrate')}>
<Segmented
size="sm"
value={mp3Bitrate}
onChange={setMp3Bitrate}
items={[
{ value: '128', label: '128k' },
{ value: '192', label: '192k' },
{ value: '256', label: '256k' },
{ value: '320', label: '320k' },
]}
/>
</Field>
)}
<Field label={t('exportModal.what_to_export')}>
<Segmented
size="sm"
value={audioBatch}
onChange={setAudioBatch}
items={[
{ value: 'each', label: t('exportModal.export_each_dub') },
{ value: 'primary', label: t('exportModal.export_single_lang') },
]}
/>
{audioBatch === 'primary' && (
<select
className="input-base input-base--xs export-modal__mt6"
value={audioPrimaryLang}
onChange={(e) => setAudioPrimaryLang(e.target.value)}
>
{(dubTracks || []).map((code) => (
<option key={code} value={code}>
{code.toUpperCase()}
</option>
))}
</select>
)}
</Field>
<Field label={t('exportModal.bg_audio')}>
<label className="export-modal__toggle">
<input
type="checkbox"
checked={preserveBg}
onChange={(e) => setPreserveBg(e.target.checked)}
/>
{t('exportModal.mix_bg_audio')}
</label>
</Field>
</div>
)}
{tab === 'subs' && (
<div className="export-modal__grid">
<Field label={t('exportModal.format')}>
<Segmented
size="sm"
value={subsFormat}
onChange={setSubsFormat}
items={[
{ value: 'srt', label: 'SRT' },
{ value: 'vtt', label: 'VTT' },
{ value: 'both', label: t('exportModal.both') },
]}
/>
</Field>
<Field label={t('exportModal.layout')}>
<Segmented
size="sm"
value={subsDual ? 'dual' : 'single'}
onChange={(v) => setSubsDual(v === 'dual')}
items={[
{ value: 'single', label: t('exportModal.single_line') },
{ value: 'dual', label: t('exportModal.dual_subs') },
]}
/>
</Field>
<Field label={t('exportModal.languages')}>
<Segmented
size="sm"
value={subsBatch}
onChange={setSubsBatch}
items={[
{
value: 'target',
label: t('exportModal.current_target', { code: dubLangCode || '—' }),
},
{
value: 'all-dubs',
label: t('exportModal.all_selected_dubs', { count: selectedDubs.length }),
},
]}
/>
</Field>
<div className="export-modal__note">{t('exportModal.subs_note')}</div>
</div>
)}
{tab === 'pkg' && (
<Button variant="ghost" size="sm" onClick={onClose}>{t('common.close')}</Button>
<div className="export-modal__pkg-grid">
<PkgCard
icon={<Package size={14} />}
title={t('exportModal.pkg_clips_title')}
body={t('exportModal.pkg_clips_body')}
onClick={runClips}
cta={t('exportModal.pkg_clips_cta')}
/>
<PkgCard
icon={<Layers size={14} />}
title={t('exportModal.pkg_stems_title')}
body={t('exportModal.pkg_stems_body')}
onClick={runStems}
cta={t('exportModal.pkg_stems_cta')}
/>
<PkgCard
icon={<Music size={14} />}
title={t('exportModal.pkg_audio_title')}
body={t('exportModal.pkg_audio_body', { count: (dubTracks || []).length })}
onClick={() => setTab('audio')}
cta={t('exportModal.pkg_audio_cta')}
ghost
/>
</div>
)}
</div>
{/* Commercial license notice */}
<div className="export-modal__license-notice">
<Building2 size={11} />
<span>
{t('exportModal.license_text')}{' '}
<button
type="button"
className="export-modal__license-link"
onClick={() => {
onClose();
onEnterprise?.();
}}
>
{t('exportModal.license_link')}
</button>
.
</span>
</div>
{/* Summary footer */}
<div className="export-modal__summary">
<div className="export-modal__summary-left">
<span className="export-modal__kicker">{t('exportModal.output')}</span>
<code className="export-modal__summary-name" title={filenamePreview}>
{filenamePreview}
</code>
</div>
<div className="export-modal__summary-right">
{tab !== 'pkg' && (
<>
<Button variant="ghost" size="sm" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
size="sm"
onClick={active.fn}
disabled={!active.can}
leading={<Download size={11} />}
title={active.can ? '' : t('exportModal.nothing_selected')}
>
{active.label}
</Button>
</>
)}
{tab === 'pkg' && (
<Button variant="ghost" size="sm" onClick={onClose}>
{t('common.close')}
</Button>
)}
</div>
</div>
</div>
</div>
</div>
</div>,
document.body,
);
@@ -438,9 +653,17 @@ function Field({ label, hint, children }) {
function PkgCard({ icon, title, body, onClick, cta, ghost = false }) {
return (
<div className={`export-modal__pkg-card ${ghost ? 'is-ghost' : ''}`}>
<div className="export-modal__pkg-head">{icon}<span>{title}</span></div>
<div className="export-modal__pkg-head">
{icon}
<span>{title}</span>
</div>
<p className="export-modal__pkg-body">{body}</p>
<Button variant={ghost ? 'subtle' : 'primary'} size="sm" onClick={onClick} leading={ghost ? null : <Check size={10} />}>
<Button
variant={ghost ? 'subtle' : 'primary'}
size="sm"
onClick={onClick}
leading={ghost ? null : <Check size={10} />}
>
{cta}
</Button>
</div>
+14 -7
View File
@@ -19,11 +19,16 @@ function renderModal(extra = {}) {
filename="video.mp4"
dubTracks={['es', 'fr']}
dubLangCode="es"
preserveBg={false} setPreserveBg={noop}
defaultTrack="original" setDefaultTrack={noop}
exportTracks={{}} setExportTracks={noop}
dualSubs={false} setDualSubs={noop}
burnSubs={false} setBurnSubs={noop}
preserveBg={false}
setPreserveBg={noop}
defaultTrack="original"
setDefaultTrack={noop}
exportTracks={{}}
setExportTracks={noop}
dualSubs={false}
setDualSubs={noop}
burnSubs={false}
setBurnSubs={noop}
API=""
triggerDownload={noop}
handleDubDownload={noop}
@@ -31,7 +36,7 @@ function renderModal(extra = {}) {
handleAudioExport={noop}
segmentCount={3}
{...extra}
/>
/>,
);
}
@@ -41,7 +46,9 @@ describe('ExportModal (regression #183)', () => {
});
it('renders nothing when closed', () => {
const { container } = render(<ExportModal open={false} onClose={noop} dubTracks={[]} exportTracks={{}} />);
const { container } = render(
<ExportModal open={false} onClose={noop} dubTracks={[]} exportTracks={{}} />,
);
expect(container.firstChild).toBeNull();
});
});
+421 -250
View File
@@ -39,15 +39,23 @@ const invoke = async (...args) => {
function useTargetCheck(path) {
const [check, setCheck] = useState(null);
useEffect(() => {
if (!path) { setCheck(null); return; }
if (!path) {
setCheck(null);
return;
}
let cancelled = false;
const t = setTimeout(async () => {
try {
const res = await invoke('check_install_target', { path });
if (!cancelled) setCheck(res);
} catch { if (!cancelled) setCheck(null); }
} catch {
if (!cancelled) setCheck(null);
}
}, 250);
return () => { cancelled = true; clearTimeout(t); };
return () => {
cancelled = true;
clearTimeout(t);
};
}, [path]);
return check;
}
@@ -55,17 +63,18 @@ function useTargetCheck(path) {
/** Breathing waveform masthead — bar heights are stable per mount. */
function Waveform({ bars = 96 }) {
const heights = useMemo(
() => Array.from({ length: bars }, (_, i) => {
// Deterministic pseudo-random silhouette: layered sines read as speech
// cadence (syllables + phrase envelope) rather than white noise.
const t = i / bars;
const v = Math.abs(
Math.sin(t * Math.PI * 7.3) * 0.55 +
Math.sin(t * Math.PI * 2.1 + 1.2) * 0.3 +
Math.sin(t * Math.PI * 17.0 + 0.4) * 0.15
);
return 0.18 + v * 0.82;
}),
() =>
Array.from({ length: bars }, (_, i) => {
// Deterministic pseudo-random silhouette: layered sines read as speech
// cadence (syllables + phrase envelope) rather than white noise.
const t = i / bars;
const v = Math.abs(
Math.sin(t * Math.PI * 7.3) * 0.55 +
Math.sin(t * Math.PI * 2.1 + 1.2) * 0.3 +
Math.sin(t * Math.PI * 17.0 + 0.4) * 0.15,
);
return 0.18 + v * 0.82;
}),
[bars],
);
return (
@@ -115,22 +124,26 @@ function StorageRow({ label, desc, path, need, check, onPick }) {
<div className={`frs-row ${blocked ? 'frs-row--blocked' : ''}`}>
<div className="frs-row__text" title={desc}>
<span className="frs-row__label">{label}</span>
<code className="frs-row__path" title={path}>{path}</code>
<code className="frs-row__path" title={path}>
{path}
</code>
</div>
<div className="frs-row__gauge">
{(blocked || tight) && check?.freeBytes != null && (
<CapacityMeter need={need} free={check.freeBytes} />
)}
<span className={`frs-row__readout ${lowSpace ? 'is-low' : ''}`}>
{check == null
? t('firstrun.checking', 'checking…')
: notWritable
? t('firstrun.not_writable', 'not writable')
: <>
{t('firstrun.needs', { size: fmtGB(need), defaultValue: 'needs ~{{size}}' })}
{' · '}
{t('firstrun.free', { size: fmtGB(check.freeBytes), defaultValue: '{{size}} free' })}
</>}
{check == null ? (
t('firstrun.checking', 'checking…')
) : notWritable ? (
t('firstrun.not_writable', 'not writable')
) : (
<>
{t('firstrun.needs', { size: fmtGB(need), defaultValue: 'needs ~{{size}}' })}
{' · '}
{t('firstrun.free', { size: fmtGB(check.freeBytes), defaultValue: '{{size}} free' })}
</>
)}
</span>
</div>
{onPick && (
@@ -194,7 +207,11 @@ function OptionCard({ active, disabled, onSelect, name, desc, badge, compact })
/** Fixed caption slot under a radio group: two reserved lines, the active
* option's description swaps in — the layout never shifts on selection. */
function GroupCaption({ text }) {
return <p className="frs__opt-caption" aria-live="polite">{text}</p>;
return (
<p className="frs__opt-caption" aria-live="polite">
{text}
</p>
);
}
export default function FirstRunSetup() {
@@ -202,12 +219,17 @@ export default function FirstRunSetup() {
const locale = useAppStore((s) => s.locale);
const setLocale = useAppStore((s) => s.setLocale);
const [setup, setSetup] = useState(null); // get_setup_state payload
const [plan, setPlan] = useState(null); // user's editable choices
const [setup, setSetup] = useState(null); // get_setup_state payload
const [plan, setPlan] = useState(null); // user's editable choices
const [submitting, setSubmitting] = useState(false);
const [serverError, setServerError] = useState(null);
const mounted = useRef(true);
useEffect(() => () => { mounted.current = false; }, []);
useEffect(
() => () => {
mounted.current = false;
},
[],
);
useEffect(() => {
(async () => {
@@ -216,7 +238,10 @@ export default function FirstRunSetup() {
if (!mounted.current) return;
setSetup(s);
setPlan({
installMode: s.portable.available && s.defaults.installMode === 'portable' ? 'portable' : 'installed',
installMode:
s.portable.available && s.defaults.installMode === 'portable'
? 'portable'
: 'installed',
envDir: s.defaults.envDir,
dataDir: s.defaults.dataDir,
modelsDir: s.defaults.modelsDir,
@@ -275,15 +300,30 @@ export default function FirstRunSetup() {
if (free != null && free < need) out.push({ key: 'space', need, free });
}
return out;
}, [plan, req, portable, portableBase, combinedNeed, envCheck, dataCheck, modelsCheck, portableCheck]);
}, [
plan,
req,
portable,
portableBase,
combinedNeed,
envCheck,
dataCheck,
modelsCheck,
portableCheck,
]);
const pickDir = useCallback(async (field) => {
try {
const { open } = await import('@tauri-apps/plugin-dialog');
const dir = await open({ directory: true, defaultPath: plan?.[field] || undefined });
if (typeof dir === 'string' && dir) setPlan((p) => ({ ...p, [field]: dir }));
} catch (e) { console.error('folder pick failed', e); }
}, [plan]);
const pickDir = useCallback(
async (field) => {
try {
const { open } = await import('@tauri-apps/plugin-dialog');
const dir = await open({ directory: true, defaultPath: plan?.[field] || undefined });
if (typeof dir === 'string' && dir) setPlan((p) => ({ ...p, [field]: dir }));
} catch (e) {
console.error('folder pick failed', e);
}
},
[plan],
);
const set = useCallback((patch) => setPlan((p) => ({ ...p, ...patch })), []);
@@ -313,7 +353,10 @@ export default function FirstRunSetup() {
// Success: the stage poll in App.jsx leaves `awaiting_setup` and the
// normal bootstrap progress UI takes over. Nothing to do here.
} catch (e) {
if (mounted.current) { setServerError(String(e)); setSubmitting(false); }
if (mounted.current) {
setServerError(String(e));
setSubmitting(false);
}
}
}, [plan, submitting, locale]);
@@ -322,9 +365,11 @@ export default function FirstRunSetup() {
<div className="frs">
<div className="frs__atmo" aria-hidden="true" />
<div className="frs__loading">
{serverError
? <pre className="frs__error">{serverError}</pre>
: t('firstrun.loading', 'Preparing setup…')}
{serverError ? (
<pre className="frs__error">{serverError}</pre>
) : (
t('firstrun.loading', 'Preparing setup…')
)}
</div>
</div>
);
@@ -340,7 +385,9 @@ export default function FirstRunSetup() {
hw.gpu,
hw.cpuCores ? `${hw.cpuCores}×CPU` : null,
hw.ramGb ? `${hw.ramGb} GB RAM` : null,
].filter(Boolean).join(' · ')
]
.filter(Boolean)
.join(' · ')
: null;
// ROCm wheels are Linux-only — never offer a choice that can't work on
// this platform (Rust clamps it server-side too).
@@ -351,226 +398,341 @@ export default function FirstRunSetup() {
<div className="frs__atmo" aria-hidden="true" />
<div className="frs__deck">
<div className="frs__scroll">
{/* ── Masthead: waveform + serif headline + serial plate ────────── */}
<header className="frs__mast frs-rise" style={{ '--rise': 0 }} data-tauri-drag-region>
<Waveform />
{/* Journey rail: this page is stage 1 of the install flow. */}
<nav className="frs-wsteps frs-wsteps--journey" aria-label={t('firstrun.title', 'Set up OmniVoice Studio')}>
<span className="frs-wstep is-active">
<span className="frs-wstep__led" aria-hidden="true" />
{t('firstrun.stage_setup', 'Setup')}
</span>
<span className="frs-wstep">
<span className="frs-wstep__led" aria-hidden="true" />
{t('firstrun.installing_title', 'Installing')}
</span>
<span className="frs-wstep">
<span className="frs-wstep__led" aria-hidden="true" />
{t('firstrun.stage_models', 'Models & engines')}
</span>
</nav>
<div className="frs__mast-row">
<div className="frs__mast-text">
<h1 className="frs__title">{t('firstrun.title', 'Set up OmniVoice Studio')}</h1>
<p className="frs__subtitle">
{t('firstrun.subtitle', "Nothing's installed yet — review where everything goes, then start. Change it later in Settings.")}
</p>
</div>
<div className="frs__mast-meta">
{/* Language + download region live together: the two "where am
{/* ── Masthead: waveform + serif headline + serial plate ────────── */}
<header className="frs__mast frs-rise" style={{ '--rise': 0 }} data-tauri-drag-region>
<Waveform />
{/* Journey rail: this page is stage 1 of the install flow. */}
<nav
className="frs-wsteps frs-wsteps--journey"
aria-label={t('firstrun.title', 'Set up OmniVoice Studio')}
>
<span className="frs-wstep is-active">
<span className="frs-wstep__led" aria-hidden="true" />
{t('firstrun.stage_setup', 'Setup')}
</span>
<span className="frs-wstep">
<span className="frs-wstep__led" aria-hidden="true" />
{t('firstrun.installing_title', 'Installing')}
</span>
<span className="frs-wstep">
<span className="frs-wstep__led" aria-hidden="true" />
{t('firstrun.stage_models', 'Models & engines')}
</span>
</nav>
<div className="frs__mast-row">
<div className="frs__mast-text">
<h1 className="frs__title">{t('firstrun.title', 'Set up OmniVoice Studio')}</h1>
<p className="frs__subtitle">
{t(
'firstrun.subtitle',
"Nothing's installed yet — review where everything goes, then start. Change it later in Settings.",
)}
</p>
</div>
<div className="frs__mast-meta">
{/* Language + download region live together: the two "where am
I" choices, settled before anything else. Custom mirrors
hang quietly beneath them, where they belong. */}
<div className="frs__mast-selects">
<select
className="frs-select frs-select--lang"
value={locale}
onChange={(e) => { setLocale(e.target.value); i18n.changeLanguage(e.target.value); }}
aria-label={t('firstrun.language', 'Language')}
>
{LANGUAGES.map((l) => <option key={l.code} value={l.code}>{l.label}</option>)}
</select>
<select
className="frs-select frs-select--lang"
value={plan.region}
onChange={(e) => set({ region: e.target.value })}
aria-label={t('firstrun.region_label', 'Download region')}
>
<option value="auto">{t('bootstrap.auto_detect', 'Auto-detect')}</option>
<option value="global">{t('bootstrap.region_global', 'Global (direct)')}</option>
<option value="china">{t('bootstrap.region_china', 'China (mirror)')}</option>
<option value="russia">{t('bootstrap.region_russia', 'Russia (mirror)')}</option>
<option value="restricted">{t('bootstrap.region_restricted', 'Restricted (mirror)')}</option>
</select>
</div>
<details className="frs__advanced frs__advanced--mast">
<summary>{t('firstrun.mirrors_title', 'Custom mirrors (advanced)')}</summary>
<div className="frs__mirror-fields">
{[
['pypiIndex', t('firstrun.mirror_pypi', 'PyPI index URL'), 'https://mirrors.aliyun.com/pypi/simple/'],
['hfEndpoint', t('firstrun.mirror_hf', 'Hugging Face endpoint'), 'https://hf-mirror.com'],
['pythonDownloads', t('firstrun.mirror_python', 'Python downloads mirror'), 'https://gh-proxy.com/…'],
].map(([field, label, ph]) => (
<label key={field} className="frs-field">
<span>{label}</span>
<input
className="frs-input"
type="url"
placeholder={ph}
value={plan.mirrors[field]}
onChange={(e) => set({ mirrors: { ...plan.mirrors, [field]: e.target.value } })}
/>
</label>
))}
<div className="frs__mast-selects">
<select
className="frs-select frs-select--lang"
value={locale}
onChange={(e) => {
setLocale(e.target.value);
i18n.changeLanguage(e.target.value);
}}
aria-label={t('firstrun.language', 'Language')}
>
{LANGUAGES.map((l) => (
<option key={l.code} value={l.code}>
{l.label}
</option>
))}
</select>
<select
className="frs-select frs-select--lang"
value={plan.region}
onChange={(e) => set({ region: e.target.value })}
aria-label={t('firstrun.region_label', 'Download region')}
>
<option value="auto">{t('bootstrap.auto_detect', 'Auto-detect')}</option>
<option value="global">
{t('bootstrap.region_global', 'Global (direct)')}
</option>
<option value="china">{t('bootstrap.region_china', 'China (mirror)')}</option>
<option value="russia">
{t('bootstrap.region_russia', 'Russia (mirror)')}
</option>
<option value="restricted">
{t('bootstrap.region_restricted', 'Restricted (mirror)')}
</option>
</select>
</div>
</details>
<details className="frs__advanced frs__advanced--mast">
<summary>{t('firstrun.mirrors_title', 'Custom mirrors (advanced)')}</summary>
<div className="frs__mirror-fields">
{[
[
'pypiIndex',
t('firstrun.mirror_pypi', 'PyPI index URL'),
'https://mirrors.aliyun.com/pypi/simple/',
],
[
'hfEndpoint',
t('firstrun.mirror_hf', 'Hugging Face endpoint'),
'https://hf-mirror.com',
],
[
'pythonDownloads',
t('firstrun.mirror_python', 'Python downloads mirror'),
'https://gh-proxy.com/…',
],
].map(([field, label, ph]) => (
<label key={field} className="frs-field">
<span>{label}</span>
<input
className="frs-input"
type="url"
placeholder={ph}
value={plan.mirrors[field]}
onChange={(e) =>
set({ mirrors: { ...plan.mirrors, [field]: e.target.value } })
}
/>
</label>
))}
</div>
</details>
</div>
</div>
</div>
</header>
</header>
{/* ── Wide deck: storage rail (left) + decision rail (right) ────── */}
<div className="frs__grid">
<div className="frs__col frs__col--main">
<Panel title={t('firstrun.mode_title', 'Install mode')} delay={1}>
<div className="frs__options frs__options--two" role="radiogroup" aria-label={t('firstrun.mode_title', 'Install mode')} onKeyDown={(e) => radioGroupNav(e, setup.portable.available ? ['installed', 'portable'] : ['installed'], plan.installMode, (v) => set({ installMode: v }))}>
<OptionCard
active={!portable}
onSelect={() => set({ installMode: 'installed' })}
name={t('firstrun.mode_installed', 'Installed')}
desc={t('firstrun.mode_installed_desc', 'Standard system folders. Recommended.')}
/>
<OptionCard
active={portable}
disabled={!setup.portable.available}
onSelect={() => set({ installMode: 'portable' })}
name={t('firstrun.mode_portable', 'Portable')}
desc={setup.portable.available
? t('firstrun.mode_portable_desc', 'Everything lives in one folder next to the app — move it to another disk or machine as a unit.')
: t('firstrun.mode_portable_unavailable', 'Unavailable: the folder next to the app is not writable.')}
/>
</div>
<GroupCaption text={portable
? t('firstrun.mode_portable_desc', 'Everything lives in one folder next to the app — move it to another disk or machine as a unit.')
: t('firstrun.mode_installed_desc', 'Standard system folders. Recommended.')} />
</Panel>
<Panel title={t('firstrun.storage_title', 'Storage')} delay={2}>
{portable ? (
<StorageRow
label={t('firstrun.portable_folder', 'Portable folder')}
desc={t('firstrun.portable_folder_desc', 'App environment, models, and your voice data — one folder, fully movable.')}
path={portableBase}
need={combinedNeed}
check={portableCheck}
/>
) : (
<>
<StorageRow
label={t('firstrun.env_dir', 'App environment')}
desc={t('firstrun.env_dir_desc', 'Python runtime and AI libraries.')}
path={plan.envDir}
need={req.envBytes}
check={envCheck}
onPick={() => pickDir('envDir')}
{/* ── Wide deck: storage rail (left) + decision rail (right) ────── */}
<div className="frs__grid">
<div className="frs__col frs__col--main">
<Panel title={t('firstrun.mode_title', 'Install mode')} delay={1}>
<div
className="frs__options frs__options--two"
role="radiogroup"
aria-label={t('firstrun.mode_title', 'Install mode')}
onKeyDown={(e) =>
radioGroupNav(
e,
setup.portable.available ? ['installed', 'portable'] : ['installed'],
plan.installMode,
(v) => set({ installMode: v }),
)
}
>
<OptionCard
active={!portable}
onSelect={() => set({ installMode: 'installed' })}
name={t('firstrun.mode_installed', 'Installed')}
desc={t(
'firstrun.mode_installed_desc',
'Standard system folders. Recommended.',
)}
/>
<StorageRow
label={t('firstrun.data_dir', 'Voice data & projects')}
desc={t('firstrun.data_dir_desc', 'Your voices, dubs, outputs and project database.')}
path={plan.dataDir}
need={req.dataBytes}
check={dataCheck}
onPick={() => pickDir('dataDir')}
<OptionCard
active={portable}
disabled={!setup.portable.available}
onSelect={() => set({ installMode: 'portable' })}
name={t('firstrun.mode_portable', 'Portable')}
desc={
setup.portable.available
? t(
'firstrun.mode_portable_desc',
'Everything lives in one folder next to the app — move it to another disk or machine as a unit.',
)
: t(
'firstrun.mode_portable_unavailable',
'Unavailable: the folder next to the app is not writable.',
)
}
/>
<StorageRow
label={t('firstrun.models_dir', 'Model cache')}
desc={t('firstrun.models_dir_desc', 'Downloaded AI models — the largest and most relocatable part.')}
path={plan.modelsDir}
need={req.modelsBytes}
check={modelsCheck}
onPick={() => pickDir('modelsDir')}
/>
</>
)}
</Panel>
</div>
<div className="frs__col frs__col--side">
<Panel title={t('firstrun.compute_title', 'Compute')} delay={2}>
{hwLine && (
<div className="frs__hw" title={hwLine}>
<span className="frs__hw-dot" aria-hidden="true" />
<span className="frs__hw-label">
{t('firstrun.compute_detected', { defaultValue: 'Detected' })}
</span>
<span className="frs__hw-value">{hwLine}</span>
</div>
)}
<div
className="frs__options"
role="radiogroup"
aria-label={t('firstrun.compute_title', 'Compute')}
onKeyDown={(e) => radioGroupNav(e, rocmAvailable ? ['auto', 'rocm'] : ['auto'], plan.torchVariant, (v) => set({ torchVariant: v }))}
>
<OptionCard
compact
active={plan.torchVariant === 'auto'}
onSelect={() => set({ torchVariant: 'auto' })}
name={t('firstrun.compute_auto', 'Auto (NVIDIA CUDA / Apple MPS / CPU)')}
desc={t('firstrun.compute_auto_desc', 'Best backend picked at runtime — CUDA on NVIDIA, MPS on Apple Silicon, else CPU.')}
badge={hw?.kind === 'cuda' || hw?.kind === 'mps'
? t('firstrun.compute_match', { defaultValue: 'matches this machine' })
: null}
<GroupCaption
text={
portable
? t(
'firstrun.mode_portable_desc',
'Everything lives in one folder next to the app — move it to another disk or machine as a unit.',
)
: t('firstrun.mode_installed_desc', 'Standard system folders. Recommended.')
}
/>
{rocmAvailable && (
</Panel>
<Panel title={t('firstrun.storage_title', 'Storage')} delay={2}>
{portable ? (
<StorageRow
label={t('firstrun.portable_folder', 'Portable folder')}
desc={t(
'firstrun.portable_folder_desc',
'App environment, models, and your voice data — one folder, fully movable.',
)}
path={portableBase}
need={combinedNeed}
check={portableCheck}
/>
) : (
<>
<StorageRow
label={t('firstrun.env_dir', 'App environment')}
desc={t('firstrun.env_dir_desc', 'Python runtime and AI libraries.')}
path={plan.envDir}
need={req.envBytes}
check={envCheck}
onPick={() => pickDir('envDir')}
/>
<StorageRow
label={t('firstrun.data_dir', 'Voice data & projects')}
desc={t(
'firstrun.data_dir_desc',
'Your voices, dubs, outputs and project database.',
)}
path={plan.dataDir}
need={req.dataBytes}
check={dataCheck}
onPick={() => pickDir('dataDir')}
/>
<StorageRow
label={t('firstrun.models_dir', 'Model cache')}
desc={t(
'firstrun.models_dir_desc',
'Downloaded AI models — the largest and most relocatable part.',
)}
path={plan.modelsDir}
need={req.modelsBytes}
check={modelsCheck}
onPick={() => pickDir('modelsDir')}
/>
</>
)}
</Panel>
</div>
<div className="frs__col frs__col--side">
<Panel title={t('firstrun.compute_title', 'Compute')} delay={2}>
{hwLine && (
<div className="frs__hw" title={hwLine}>
<span className="frs__hw-dot" aria-hidden="true" />
<span className="frs__hw-label">
{t('firstrun.compute_detected', { defaultValue: 'Detected' })}
</span>
<span className="frs__hw-value">{hwLine}</span>
</div>
)}
<div
className="frs__options"
role="radiogroup"
aria-label={t('firstrun.compute_title', 'Compute')}
onKeyDown={(e) =>
radioGroupNav(
e,
rocmAvailable ? ['auto', 'rocm'] : ['auto'],
plan.torchVariant,
(v) => set({ torchVariant: v }),
)
}
>
<OptionCard
compact
active={plan.torchVariant === 'rocm'}
onSelect={() => set({ torchVariant: 'rocm' })}
name={t('firstrun.compute_rocm', 'AMD GPU (ROCm, Linux)')}
desc={t('firstrun.compute_rocm_desc', 'Installs PyTorch ROCm wheels for AMD graphics cards on Linux. Leave on Auto if unsure.')}
badge={hw?.kind === 'rocm'
? t('firstrun.compute_match', { defaultValue: 'matches this machine' })
: null}
active={plan.torchVariant === 'auto'}
onSelect={() => set({ torchVariant: 'auto' })}
name={t('firstrun.compute_auto', 'Auto (NVIDIA CUDA / Apple MPS / CPU)')}
desc={t(
'firstrun.compute_auto_desc',
'Best backend picked at runtime — CUDA on NVIDIA, MPS on Apple Silicon, else CPU.',
)}
badge={
hw?.kind === 'cuda' || hw?.kind === 'mps'
? t('firstrun.compute_match', { defaultValue: 'matches this machine' })
: null
}
/>
)}
</div>
<GroupCaption text={plan.torchVariant === 'rocm'
? t('firstrun.compute_rocm_desc', 'Installs PyTorch ROCm wheels for AMD graphics cards on Linux. Leave on Auto if unsure.')
: t('firstrun.compute_auto_desc', 'Best backend picked at runtime — CUDA on NVIDIA, MPS on Apple Silicon, else CPU.')} />
</Panel>
{rocmAvailable && (
<OptionCard
compact
active={plan.torchVariant === 'rocm'}
onSelect={() => set({ torchVariant: 'rocm' })}
name={t('firstrun.compute_rocm', 'AMD GPU (ROCm, Linux)')}
desc={t(
'firstrun.compute_rocm_desc',
'Installs PyTorch ROCm wheels for AMD graphics cards on Linux. Leave on Auto if unsure.',
)}
badge={
hw?.kind === 'rocm'
? t('firstrun.compute_match', { defaultValue: 'matches this machine' })
: null
}
/>
)}
</div>
<GroupCaption
text={
plan.torchVariant === 'rocm'
? t(
'firstrun.compute_rocm_desc',
'Installs PyTorch ROCm wheels for AMD graphics cards on Linux. Leave on Auto if unsure.',
)
: t(
'firstrun.compute_auto_desc',
'Best backend picked at runtime — CUDA on NVIDIA, MPS on Apple Silicon, else CPU.',
)
}
/>
</Panel>
<Panel title={t('firstrun.channel_label', 'Update channel')} delay={3}>
<div
className="frs__options"
role="radiogroup"
aria-label={t('firstrun.channel_label', 'Update channel')}
onKeyDown={(e) => radioGroupNav(e, ['stable', 'preview'], plan.updateChannel, (v) => set({ updateChannel: v }))}
>
<OptionCard
compact
active={plan.updateChannel === 'stable'}
onSelect={() => set({ updateChannel: 'stable' })}
name={t('firstrun.channel_stable', 'Stable')}
desc={t('firstrun.channel_stable_desc', 'Tested releases only, after community validation.')}
<Panel title={t('firstrun.channel_label', 'Update channel')} delay={3}>
<div
className="frs__options"
role="radiogroup"
aria-label={t('firstrun.channel_label', 'Update channel')}
onKeyDown={(e) =>
radioGroupNav(e, ['stable', 'preview'], plan.updateChannel, (v) =>
set({ updateChannel: v }),
)
}
>
<OptionCard
compact
active={plan.updateChannel === 'stable'}
onSelect={() => set({ updateChannel: 'stable' })}
name={t('firstrun.channel_stable', 'Stable')}
desc={t(
'firstrun.channel_stable_desc',
'Tested releases only, after community validation.',
)}
/>
<OptionCard
compact
active={plan.updateChannel === 'preview'}
onSelect={() => set({ updateChannel: 'preview' })}
name={t('firstrun.channel_preview', 'Preview (latest main)')}
desc={t(
'firstrun.channel_preview_desc',
'Latest main — new engines and fixes first, occasional rough edges.',
)}
/>
</div>
<GroupCaption
text={
plan.updateChannel === 'preview'
? t(
'firstrun.channel_preview_desc',
'Latest main — new engines and fixes first, occasional rough edges.',
)
: t(
'firstrun.channel_stable_desc',
'Tested releases only, after community validation.',
)
}
/>
<OptionCard
compact
active={plan.updateChannel === 'preview'}
onSelect={() => set({ updateChannel: 'preview' })}
name={t('firstrun.channel_preview', 'Preview (latest main)')}
desc={t('firstrun.channel_preview_desc', 'Latest main — new engines and fixes first, occasional rough edges.')}
/>
</div>
<GroupCaption text={plan.updateChannel === 'preview'
? t('firstrun.channel_preview_desc', 'Latest main — new engines and fixes first, occasional rough edges.')
: t('firstrun.channel_stable_desc', 'Tested releases only, after community validation.')} />
</Panel>
</Panel>
</div>
</div>
</div>
</div>
{/* ── Footer: gate + arm ────────────────────────────────────────── */}
<footer className="frs__foot frs-rise" style={{ '--rise': 5 }}>
@@ -580,19 +742,25 @@ export default function FirstRunSetup() {
{t('firstrun.insufficient_space', {
need: fmtGB(spaceBlocker.need),
free: fmtGB(spaceBlocker.free),
defaultValue: 'Not enough free space: this layout needs ~{{need}} on one disk, only {{free}} available. Pick a different location.',
defaultValue:
'Not enough free space: this layout needs ~{{need}} on one disk, only {{free}} available. Pick a different location.',
})}
</p>
)}
{blockers.some((b) => b.key === 'not_writable') && (
<p className="frs__blocker">
{t('firstrun.blocked_not_writable', 'A chosen folder is not writable — pick a different location.')}
{t(
'firstrun.blocked_not_writable',
'A chosen folder is not writable — pick a different location.',
)}
</p>
)}
<div className="frs__foot-row">
<span className="frs__totals">
<span className="frs__plate">OVS&thinsp;·&thinsp;v{APP_VERSION}</span>
<span className="frs__totals-sep" aria-hidden="true"></span>
<span className="frs__totals-sep" aria-hidden="true">
</span>
{t('firstrun.total_required', {
size: fmtGB(combinedNeed),
defaultValue: 'Total disk needed: ~{{size}} (one-time download on first use)',
@@ -612,7 +780,10 @@ export default function FirstRunSetup() {
</div>
{/* The product's whole thesis, said where the user decides. */}
<p className="frs__trust">
{t('firstrun.trust_line', 'Everything runs and stays on this machine — no account, no cloud, no telemetry.')}
{t(
'firstrun.trust_line',
'Everything runs and stays on this machine — no account, no cloud, no telemetry.',
)}
</p>
</footer>
</div>
+28 -24
View File
@@ -25,28 +25,28 @@ function formatElapsed(ms) {
const STAGE_LABELS = {
'loading-model': '🧠',
'recording': '🎙️',
'transcribing': '📝',
'translating': '🌐',
'generating': '🔊',
'exporting': '📦',
'refining': '✨',
'done': '✅',
'error': '❌',
recording: '🎙️',
transcribing: '📝',
translating: '🌐',
generating: '🔊',
exporting: '📦',
refining: '✨',
done: '✅',
error: '❌',
};
export default function FloatingPill() {
const { t } = useTranslation();
const visible = useAppStore(s => s.visible);
const stage = useAppStore(s => s.stage);
const label = useAppStore(s => s.label);
const progress = useAppStore(s => s.progress);
const startedAt = useAppStore(s => s.startedAt);
const error = useAppStore(s => s.error);
const cancellable = useAppStore(s => s.cancellable);
const homeMode = useAppStore(s => s.homeMode);
const mode = useAppStore(s => s.mode);
const dismissPill = useAppStore(s => s.dismissPill);
const visible = useAppStore((s) => s.visible);
const stage = useAppStore((s) => s.stage);
const label = useAppStore((s) => s.label);
const progress = useAppStore((s) => s.progress);
const startedAt = useAppStore((s) => s.startedAt);
const error = useAppStore((s) => s.error);
const cancellable = useAppStore((s) => s.cancellable);
const homeMode = useAppStore((s) => s.homeMode);
const mode = useAppStore((s) => s.mode);
const dismissPill = useAppStore((s) => s.dismissPill);
const [elapsed, setElapsed] = useState(0);
const [exiting, setExiting] = useState(false);
@@ -91,7 +91,9 @@ export default function FloatingPill() {
exiting ? 'floating-pill--exiting' : '',
isDone ? 'floating-pill--done' : '',
isError ? 'floating-pill--error' : '',
].filter(Boolean).join(' ')}
]
.filter(Boolean)
.join(' ')}
role="status"
aria-live="polite"
>
@@ -109,11 +111,11 @@ export default function FloatingPill() {
{isActive && elapsed > 0 && (
<span className="floating-pill__timer">{formatElapsed(elapsed)}</span>
)}
{progress !== null && isActive && (
<span>{Math.round(progress)}%</span>
)}
{progress !== null && isActive && <span>{Math.round(progress)}%</span>}
{isError && error && (
<span className="floating-pill__error" title={error}>{error}</span>
<span className="floating-pill__error" title={error}>
{error}
</span>
)}
</div>
@@ -124,7 +126,9 @@ export default function FloatingPill() {
className={[
'floating-pill__progress-fill',
progress === null ? 'floating-pill__progress-fill--indeterminate' : '',
].filter(Boolean).join(' ')}
]
.filter(Boolean)
.join(' ')}
style={progress !== null ? { width: `${progress}%` } : undefined}
/>
</div>
+170 -71
View File
@@ -4,8 +4,12 @@ import { Plus, Trash2, BookOpen, Sparkles, Check, ChevronDown, X } from 'lucide-
import { toast } from 'react-hot-toast';
import { Panel, Button, Input, Badge } from '../ui';
import {
listGlossary, addGlossaryTerm, updateGlossaryTerm,
deleteGlossaryTerm, clearGlossary, autoExtractGlossary,
listGlossary,
addGlossaryTerm,
updateGlossaryTerm,
deleteGlossaryTerm,
clearGlossary,
autoExtractGlossary,
} from '../api/glossary';
import './GlossaryPanel.css';
@@ -23,10 +27,19 @@ export default function GlossaryPanel({
const [extracting, setExtracting] = useState(false);
const [draft, setDraft] = useState({ source: '', target: '', note: '' });
const pushChange = useCallback((next) => { onChange?.(next); }, [onChange]);
const pushChange = useCallback(
(next) => {
onChange?.(next);
},
[onChange],
);
const reload = useCallback(async () => {
if (!projectId) { setTerms([]); pushChange([]); return; }
if (!projectId) {
setTerms([]);
pushChange([]);
return;
}
setLoading(true);
try {
const rows = await listGlossary(projectId);
@@ -39,33 +52,47 @@ export default function GlossaryPanel({
}
}, [projectId, pushChange, t]);
useEffect(() => { reload(); }, [reload]);
useEffect(() => {
reload();
}, [reload]);
const onAdd = async () => {
if (!projectId) { toast.error(t('glossary.needs_project')); return; }
if (!projectId) {
toast.error(t('glossary.needs_project'));
return;
}
if (!draft.source.trim() || !draft.target.trim()) return;
try {
const row = await addGlossaryTerm(projectId, draft);
const next = [...terms, row];
setTerms(next); pushChange(next);
setTerms(next);
pushChange(next);
setDraft({ source: '', target: '', note: '' });
} catch (e) { toast.error(t('glossary.add_error', { message: e.message })); }
} catch (e) {
toast.error(t('glossary.add_error', { message: e.message }));
}
};
const onUpdate = async (id, patch) => {
try {
const row = await updateGlossaryTerm(projectId, id, patch);
const next = terms.map(t => t.id === id ? row : t);
setTerms(next); pushChange(next);
} catch (e) { toast.error(t('glossary.update_error', { message: e.message })); }
const next = terms.map((t) => (t.id === id ? row : t));
setTerms(next);
pushChange(next);
} catch (e) {
toast.error(t('glossary.update_error', { message: e.message }));
}
};
const onDelete = async (id) => {
try {
await deleteGlossaryTerm(projectId, id);
const next = terms.filter(t => t.id !== id);
setTerms(next); pushChange(next);
} catch (e) { toast.error(t('glossary.delete_error', { message: e.message })); }
const next = terms.filter((t) => t.id !== id);
setTerms(next);
pushChange(next);
} catch (e) {
toast.error(t('glossary.delete_error', { message: e.message }));
}
};
const onClearAuto = async () => {
@@ -73,19 +100,29 @@ export default function GlossaryPanel({
try {
await clearGlossary(projectId, true);
await reload();
} catch (e) { toast.error(t('glossary.clear_error', { message: e.message })); }
} catch (e) {
toast.error(t('glossary.clear_error', { message: e.message }));
}
};
const onAutoExtract = async () => {
if (!targetLang) { toast.error(t('glossary.pick_target')); return; }
if (!segments.length) { toast.error(t('glossary.no_segments')); return; }
if (!targetLang) {
toast.error(t('glossary.pick_target'));
return;
}
if (!segments.length) {
toast.error(t('glossary.no_segments'));
return;
}
setExtracting(true);
try {
const res = await autoExtractGlossary(projectId, {
sourceLang, targetLang,
segments: segments.map(s => ({ text: s.text_original || s.text })),
sourceLang,
targetLang,
segments: segments.map((s) => ({ text: s.text_original || s.text })),
});
setTerms(res.terms); pushChange(res.terms);
setTerms(res.terms);
pushChange(res.terms);
if (res.inserted === 0) {
toast(t('glossary.auto_empty'), { icon: '️' });
} else {
@@ -98,7 +135,7 @@ export default function GlossaryPanel({
}
};
const autoCount = terms.filter(t => t.auto).length;
const autoCount = terms.filter((t) => t.auto).length;
const manualCount = terms.length - autoCount;
return (
@@ -118,7 +155,8 @@ export default function GlossaryPanel({
actions={
<>
<Button
variant="subtle" size="sm"
variant="subtle"
size="sm"
leading={<Sparkles size={11} />}
onClick={onAutoExtract}
loading={extracting}
@@ -127,31 +165,26 @@ export default function GlossaryPanel({
>
{t('glossary.auto_btn')}
</Button>
{autoCount > 0 && (
<Button
variant="ghost" size="sm"
onClick={onClearAuto}
title={t('glossary.clear_auto_title')}
>
{t('glossary.clear_auto_btn')}
</Button>
)}
{onClose && (
<Button
variant="ghost" size="sm"
onClick={onClose}
title={t('glossary.close')}
>
<ChevronDown size={11} />
</Button>
)}
</>
}
>
{autoCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onClearAuto}
title={t('glossary.clear_auto_title')}
>
{t('glossary.clear_auto_btn')}
</Button>
)}
{onClose && (
<Button variant="ghost" size="sm" onClick={onClose} title={t('glossary.close')}>
<ChevronDown size={11} />
</Button>
)}
</>
}
>
{!projectId ? (
<div className="glossary-panel__empty">
{t('glossary.empty_save')}
</div>
<div className="glossary-panel__empty">{t('glossary.empty_save')}</div>
) : (
<>
<table className="glossary-panel__table">
@@ -166,12 +199,20 @@ export default function GlossaryPanel({
</thead>
<tbody>
{loading && !terms.length && (
<tr><td colSpan={5} className="glossary-panel__muted">{t('common.loading')}</td></tr>
<tr>
<td colSpan={5} className="glossary-panel__muted">
{t('common.loading')}
</td>
</tr>
)}
{!loading && !terms.length && (
<tr><td colSpan={5} className="glossary-panel__muted">{t('glossary.no_terms')}</td></tr>
<tr>
<td colSpan={5} className="glossary-panel__muted">
{t('glossary.no_terms')}
</td>
</tr>
)}
{terms.map(t => (
{terms.map((t) => (
<GlossaryRow
key={t.id}
term={t}
@@ -182,32 +223,42 @@ export default function GlossaryPanel({
<tr className="glossary-panel__row--new">
<td>
<Input
size="sm" placeholder={t('glossary.source_placeholder', { lang: sourceLang })}
size="sm"
placeholder={t('glossary.source_placeholder', { lang: sourceLang })}
value={draft.source}
onChange={e => setDraft({ ...draft, source: e.target.value })}
onKeyDown={e => { if (e.key === 'Enter') onAdd(); }}
onChange={(e) => setDraft({ ...draft, source: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') onAdd();
}}
/>
</td>
<td>
<Input
size="sm" placeholder={t('glossary.target_placeholder', { lang: targetLang || '—' })}
size="sm"
placeholder={t('glossary.target_placeholder', { lang: targetLang || '—' })}
value={draft.target}
onChange={e => setDraft({ ...draft, target: e.target.value })}
onKeyDown={e => { if (e.key === 'Enter') onAdd(); }}
onChange={(e) => setDraft({ ...draft, target: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') onAdd();
}}
/>
</td>
<td>
<Input
size="sm" placeholder={t('glossary.note_placeholder')}
size="sm"
placeholder={t('glossary.note_placeholder')}
value={draft.note}
onChange={e => setDraft({ ...draft, note: e.target.value })}
onKeyDown={e => { if (e.key === 'Enter') onAdd(); }}
onChange={(e) => setDraft({ ...draft, note: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') onAdd();
}}
/>
</td>
<td />
<td>
<Button
variant="subtle" iconSize="sm"
variant="subtle"
iconSize="sm"
disabled={!draft.source.trim() || !draft.target.trim()}
onClick={onAdd}
title={t('glossary.add_term')}
@@ -219,9 +270,7 @@ export default function GlossaryPanel({
</tbody>
</table>
{manualCount > 0 && targetLang && (
<div className="glossary-panel__hint">
{t('glossary.hint')}
</div>
<div className="glossary-panel__hint">{t('glossary.hint')}</div>
)}
</>
)}
@@ -232,7 +281,11 @@ export default function GlossaryPanel({
function GlossaryRow({ term, onUpdate, onDelete }) {
const { t } = useTranslation();
const [editing, setEditing] = useState(false);
const [local, setLocal] = useState({ source: term.source, target: term.target, note: term.note || '' });
const [local, setLocal] = useState({
source: term.source,
target: term.target,
note: term.note || '',
});
useEffect(() => {
setLocal({ source: term.source, target: term.target, note: term.note || '' });
@@ -245,13 +298,53 @@ function GlossaryRow({ term, onUpdate, onDelete }) {
};
return (
<tr>
<td><Input size="sm" value={local.source} onChange={e => setLocal({ ...local, source: e.target.value })} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') setEditing(false); }} autoFocus /></td>
<td><Input size="sm" value={local.target} onChange={e => setLocal({ ...local, target: e.target.value })} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') setEditing(false); }} /></td>
<td><Input size="sm" value={local.note} onChange={e => setLocal({ ...local, note: e.target.value })} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') setEditing(false); }} /></td>
<td>
<Input
size="sm"
value={local.source}
onChange={(e) => setLocal({ ...local, source: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') save();
if (e.key === 'Escape') setEditing(false);
}}
autoFocus
/>
</td>
<td>
<Input
size="sm"
value={local.target}
onChange={(e) => setLocal({ ...local, target: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') save();
if (e.key === 'Escape') setEditing(false);
}}
/>
</td>
<td>
<Input
size="sm"
value={local.note}
onChange={(e) => setLocal({ ...local, note: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') save();
if (e.key === 'Escape') setEditing(false);
}}
/>
</td>
<td />
<td className="glossary-panel__row-actions">
<Button variant="subtle" iconSize="sm" onClick={save} title={t('common.save')}><Check size={10} /></Button>
<Button variant="ghost" iconSize="sm" onClick={() => setEditing(false)} title={t('common.cancel')}><X size={10} /></Button>
<Button variant="subtle" iconSize="sm" onClick={save} title={t('common.save')}>
<Check size={10} />
</Button>
<Button
variant="ghost"
iconSize="sm"
onClick={() => setEditing(false)}
title={t('common.cancel')}
>
<X size={10} />
</Button>
</td>
</tr>
);
@@ -263,9 +356,15 @@ function GlossaryRow({ term, onUpdate, onDelete }) {
<td className="glossary-panel__cell-tgt">{term.target}</td>
<td className="glossary-panel__cell-note">{term.note}</td>
<td>
{term.auto
? <Badge tone="violet" size="xs">{t('glossary.auto_badge')}</Badge>
: <Badge tone="success" size="xs">{t('glossary.manual_badge')}</Badge>}
{term.auto ? (
<Badge tone="violet" size="xs">
{t('glossary.auto_badge')}
</Badge>
) : (
<Badge tone="success" size="xs">
{t('glossary.manual_badge')}
</Badge>
)}
</td>
<td className="glossary-panel__row-actions">
<Button variant="danger" iconSize="sm" onClick={onDelete} title={t('common.delete')}>
+200 -86
View File
@@ -1,25 +1,90 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import { Globe, Fingerprint, Wand2, Film, FolderOpen, RefreshCw, Settings2, ChevronRight, ChevronDown, Zap, Building2, Library, FileText, Trash2 } from 'lucide-react';
import {
Globe,
Fingerprint,
Wand2,
Film,
FolderOpen,
RefreshCw,
Settings2,
ChevronRight,
ChevronDown,
Zap,
Building2,
Library,
FileText,
Trash2,
} from 'lucide-react';
import { Button, Badge } from '../ui';
import NotificationPanel from './NotificationPanel';
import { useAppStore } from '../store';
import { useSysinfo } from '../api/hooks';
const VIEW_META = {
launchpad: { labelKey: 'header.label_launchpad', Icon: Globe, accent: '#f3a5b6', kickerKey: 'header.kicker_studio' },
studio: { labelKey: 'nav.voice', Icon: Fingerprint, accent: '#d3869b', kickerKey: 'header.kicker_studio' },
launchpad: {
labelKey: 'header.label_launchpad',
Icon: Globe,
accent: '#f3a5b6',
kickerKey: 'header.kicker_studio',
},
studio: {
labelKey: 'nav.voice',
Icon: Fingerprint,
accent: '#d3869b',
kickerKey: 'header.kicker_studio',
},
// Legacy ids — kept so a not-yet-shimmed persisted 'clone'/'design' mode
// still renders a sensible header (voice-studio-unification P4).
clone: { labelKey: 'header.label_clone', Icon: Fingerprint, accent: '#d3869b', kickerKey: 'header.kicker_studio' },
design: { labelKey: 'header.label_design', Icon: Wand2, accent: '#8ec07c', kickerKey: 'header.kicker_studio' },
dub: { labelKey: 'header.label_dub', Icon: Film, accent: '#fe8019', kickerKey: 'header.kicker_studio' },
projects: { labelKey: 'header.label_projects', Icon: FolderOpen, accent: '#83a598', kickerKey: 'header.kicker_library' },
gallery: { labelKey: 'header.label_gallery', Icon: Library, accent: '#b8bb26', kickerKey: 'header.kicker_library' },
transcriptions: { labelKey: 'header.label_transcriptions', Icon: FileText, accent: '#d3869b', kickerKey: 'header.kicker_library' },
settings: { labelKey: 'header.label_settings', Icon: Settings2, accent: '#fabd2f', kickerKey: 'header.kicker_preferences' },
enterprise: { labelKey: 'header.label_enterprise', Icon: Building2, accent: '#fe8019', kickerKey: 'header.kicker_licensing' },
clone: {
labelKey: 'header.label_clone',
Icon: Fingerprint,
accent: '#d3869b',
kickerKey: 'header.kicker_studio',
},
design: {
labelKey: 'header.label_design',
Icon: Wand2,
accent: '#8ec07c',
kickerKey: 'header.kicker_studio',
},
dub: {
labelKey: 'header.label_dub',
Icon: Film,
accent: '#fe8019',
kickerKey: 'header.kicker_studio',
},
projects: {
labelKey: 'header.label_projects',
Icon: FolderOpen,
accent: '#83a598',
kickerKey: 'header.kicker_library',
},
gallery: {
labelKey: 'header.label_gallery',
Icon: Library,
accent: '#b8bb26',
kickerKey: 'header.kicker_library',
},
transcriptions: {
labelKey: 'header.label_transcriptions',
Icon: FileText,
accent: '#d3869b',
kickerKey: 'header.kicker_library',
},
settings: {
labelKey: 'header.label_settings',
Icon: Settings2,
accent: '#fabd2f',
kickerKey: 'header.kicker_preferences',
},
enterprise: {
labelKey: 'header.label_enterprise',
Icon: Building2,
accent: '#fe8019',
kickerKey: 'header.kicker_licensing',
},
};
function WaveBars({ color = '#f3a5b6', active }) {
@@ -44,8 +109,12 @@ function WaveBars({ color = '#f3a5b6', active }) {
}
export default function Header({
mode, setMode, modelStatus, doubleClickMaximize,
activeProjectName, onFlushMemory,
mode,
setMode,
modelStatus,
doubleClickMaximize,
activeProjectName,
onFlushMemory,
}) {
const { t } = useTranslation();
// Sysinfo is subscribed here (not in App via useAppData) so the 5s poll
@@ -55,7 +124,7 @@ export default function Header({
// Default OFF — chrome shouldn't double as a resource monitor. Power users
// flip this on via Settings → Performance. Idle/Ready/Loading badge +
// Flush button stay visible regardless (action-relevant).
const showLiveStats = useAppStore(s => s.showHeaderLiveStats);
const showLiveStats = useAppStore((s) => s.showHeaderLiveStats);
const [flushing, setFlushing] = useState(false);
const [flushOpen, setFlushOpen] = useState(false);
const [loadedModels, setLoadedModels] = useState([]);
@@ -133,20 +202,17 @@ export default function Header({
try {
const { apiFetch } = await import('../api/client');
await apiFetch(`/model/unload/${modelId}`, { method: 'POST' });
setLoadedModels(prev => prev.filter(m => m.id !== modelId));
} catch {} finally {
setLoadedModels((prev) => prev.filter((m) => m.id !== modelId));
} catch {
} finally {
setUnloading(null);
}
};
// Dynamic accent color must stay inline — it's driven by the current view.
const dotStyle = { background: view.accent, boxShadow: `0 0 10px ${view.accent}90` };
const dotStyle = { background: view.accent, boxShadow: `0 0 10px ${view.accent}90` };
const labelStyle = { color: view.accent };
return (
<div
className="header-area"
data-tauri-drag-region
onDoubleClick={doubleClickMaximize}
>
<div className="header-area" data-tauri-drag-region onDoubleClick={doubleClickMaximize}>
{/* Left: view title + breadcrumb */}
<div className="hq-col-left">
<div className="hq-col-left__spacer" />
@@ -161,7 +227,9 @@ export default function Header({
{activeProjectName ? (
<>
<ChevronRight size={10} color="#504945" className="hq-breadcrumb-sep" />
<span className="hq-view-project" title={activeProjectName}>{activeProjectName}</span>
<span className="hq-view-project" title={activeProjectName}>
{activeProjectName}
</span>
</>
) : null}
</div>
@@ -181,7 +249,18 @@ export default function Header({
{/* Center: logo */}
<div className="hq-col-center">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f3a5b6" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="hq-logo-mark">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#f3a5b6"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="hq-logo-mark"
>
<circle cx="12" cy="12" r="10" opacity="0.18" fill="#f3a5b6" />
<circle cx="12" cy="12" r="10" />
<path d="M12 6v12" />
@@ -197,26 +276,52 @@ export default function Header({
LogsFooter bar so all app-wide chrome sits together. */}
<div className="hq-col-right">
<NotificationPanel onNavigate={setMode} />
<WaveBars color={view.accent} active={modelStatus === 'ready' || modelStatus === 'loading'} />
<WaveBars
color={view.accent}
active={modelStatus === 'ready' || modelStatus === 'loading'}
/>
{sysStats && (
<div className="hq-stats">
{showLiveStats && (
<>
<span><b className="hq-stats__key">RAM</b> {sysStats.ram.toFixed(1)}/{sysStats.total_ram.toFixed(0)}G</span>
<span><b className="hq-stats__key">CPU</b> {sysStats.cpu.toFixed(0)}%</span>
<span className="hq-stats__sep" aria-label={`VRAM usage: ${sysStats.vram.toFixed(1)} gigabytes`}>
<b className={`hq-stats__key ${sysStats.gpu_active ? 'hq-stats__key--gpu-active' : ''}`}>VRAM</b> {sysStats.vram.toFixed(1)}G
<span>
<b className="hq-stats__key">RAM</b> {sysStats.ram.toFixed(1)}/
{sysStats.total_ram.toFixed(0)}G
</span>
<span>
<b className="hq-stats__key">CPU</b> {sysStats.cpu.toFixed(0)}%
</span>
<span
className="hq-stats__sep"
aria-label={`VRAM usage: ${sysStats.vram.toFixed(1)} gigabytes`}
>
<b
className={`hq-stats__key ${sysStats.gpu_active ? 'hq-stats__key--gpu-active' : ''}`}
>
VRAM
</b>{' '}
{sysStats.vram.toFixed(1)}G
</span>
</>
)}
<span className="hq-stats__status-wrap">
<Badge
tone={modelStatus === 'ready' ? 'success' : modelStatus === 'loading' ? 'warn' : 'neutral'}
tone={
modelStatus === 'ready'
? 'success'
: modelStatus === 'loading'
? 'warn'
: 'neutral'
}
size="xs"
dot
className={`hq-stats__status-badge ${modelStatus === 'loading' ? 'ui-badge--pulse' : ''}`}
>
{modelStatus === 'ready' ? t('header.status_ready') : modelStatus === 'loading' ? t('header.status_loading') : t('header.status_idle')}
{modelStatus === 'ready'
? t('header.status_ready')
: modelStatus === 'loading'
? t('header.status_loading')
: t('header.status_idle')}
</Badge>
</span>
{onFlushMemory && (
@@ -229,66 +334,75 @@ export default function Header({
loading={flushing}
leading={!flushing && <Zap size={8} />}
trailing={<ChevronDown size={8} />}
onClick={() => setFlushOpen(o => !o)}
onClick={() => setFlushOpen((o) => !o)}
className="hq-flush-btn"
>
{t('header.flush')}
</Button>
{flushOpen && createPortal(
<div
className="hq-flush-dropdown"
style={{ top: dropdownPos.top, left: dropdownPos.left }}
ref={dropdownRef}
>
<div className="hq-flush-dropdown__header">{t('header.loaded_models')}</div>
{loadedModels.length === 0 ? (
<div className="hq-flush-dropdown__empty">{t('header.no_models')}</div>
) : (
loadedModels.map(m => (
<div key={m.id} className="hq-flush-dropdown__item">
<div className="hq-flush-dropdown__info">
<span className="hq-flush-dropdown__name">{m.name}</span>
<span className="hq-flush-dropdown__meta">
{m.device} {m.vram_mb > 0 ? `· ${m.vram_mb.toFixed(0)} MB` : ''}
</span>
{flushOpen &&
createPortal(
<div
className="hq-flush-dropdown"
style={{ top: dropdownPos.top, left: dropdownPos.left }}
ref={dropdownRef}
>
<div className="hq-flush-dropdown__header">{t('header.loaded_models')}</div>
{loadedModels.length === 0 ? (
<div className="hq-flush-dropdown__empty">{t('header.no_models')}</div>
) : (
loadedModels.map((m) => (
<div key={m.id} className="hq-flush-dropdown__item">
<div className="hq-flush-dropdown__info">
<span className="hq-flush-dropdown__name">{m.name}</span>
<span className="hq-flush-dropdown__meta">
{m.device} {m.vram_mb > 0 ? `· ${m.vram_mb.toFixed(0)} MB` : ''}
</span>
</div>
{m.unloadable && (
<button
className="hq-flush-dropdown__unload"
onClick={() => unloadModel(m.id)}
disabled={unloading === m.id}
aria-label={`Unload ${m.name}`}
>
{unloading === m.id ? '…' : t('header.unload')}
</button>
)}
</div>
{m.unloadable && (
<button
className="hq-flush-dropdown__unload"
onClick={() => unloadModel(m.id)}
disabled={unloading === m.id}
aria-label={`Unload ${m.name}`}
>
{unloading === m.id ? '…' : t('header.unload')}
</button>
)}
</div>
))
)}
<div className="hq-flush-dropdown__divider" />
<button
className="hq-flush-dropdown__action"
onClick={async () => {
setFlushing(true);
setFlushOpen(false);
try { await onFlushMemory(false); } finally { setFlushing(false); }
}}
>
<Zap size={10} /> {t('header.flush_caches')}
</button>
<button
className="hq-flush-dropdown__action hq-flush-dropdown__action--danger"
onClick={async () => {
setFlushing(true);
setFlushOpen(false);
try { await onFlushMemory(true); } finally { setFlushing(false); }
}}
>
<Trash2 size={10} /> {t('header.unload_all_flush')}
</button>
</div>,
document.body
)}
))
)}
<div className="hq-flush-dropdown__divider" />
<button
className="hq-flush-dropdown__action"
onClick={async () => {
setFlushing(true);
setFlushOpen(false);
try {
await onFlushMemory(false);
} finally {
setFlushing(false);
}
}}
>
<Zap size={10} /> {t('header.flush_caches')}
</button>
<button
className="hq-flush-dropdown__action hq-flush-dropdown__action--danger"
onClick={async () => {
setFlushing(true);
setFlushOpen(false);
try {
await onFlushMemory(true);
} finally {
setFlushing(false);
}
}}
>
<Trash2 size={10} /> {t('header.unload_all_flush')}
</button>
</div>,
document.body,
)}
</div>
)}
</div>
+18 -5
View File
@@ -48,7 +48,9 @@ export default function HfTokenCard({ className = '' }) {
return (
<div className={`swiz-hfbar ${className}`.trim()}>
<span className="swiz-hfbar__icon" aria-hidden="true"></span>
<span className="swiz-hfbar__icon" aria-hidden="true">
</span>
<span className="swiz-hfbar__prompt">
{t('firstrun.hf_token_inline_prompt', 'Speed up downloads with a free Hugging Face token')}
</span>
@@ -58,9 +60,17 @@ export default function HfTokenCard({ className = '' }) {
placeholder={t('firstrun.hf_token_inline_ph', 'Paste hf_… token (optional)')}
value={hfToken}
autoComplete="off"
onChange={(e) => { setHfToken(e.target.value); if (hfState !== 'idle') setHfState('idle'); }}
onKeyDown={(e) => { if (e.key === 'Enter') saveHfToken(); }}
aria-label={t('firstrun.hf_token_card_title', 'Add a free Hugging Face token for faster downloads')}
onChange={(e) => {
setHfToken(e.target.value);
if (hfState !== 'idle') setHfState('idle');
}}
onKeyDown={(e) => {
if (e.key === 'Enter') saveHfToken();
}}
aria-label={t(
'firstrun.hf_token_card_title',
'Add a free Hugging Face token for faster downloads',
)}
/>
<button
type="button"
@@ -81,7 +91,10 @@ export default function HfTokenCard({ className = '' }) {
</button>
{hfState === 'error' && (
<span className="swiz-hfbar__err">
{t('firstrun.hf_token_error', 'Could not save the token — try again or set it later in Settings → Credentials.')}
{t(
'firstrun.hf_token_error',
'Could not save the token — try again or set it later in Settings → Credentials.',
)}
</span>
)}
</div>
@@ -78,7 +78,9 @@ export default function KeyboardCheatsheet({ open, onClose }) {
{keys.split(' / ').map((group, i, arr) => (
<React.Fragment key={group}>
<span className="kcs-key-group">
{group.split('+').map((k) => <Kbd key={k}>{k}</Kbd>)}
{group.split('+').map((k) => (
<Kbd key={k}>{k}</Kbd>
))}
</span>
{i < arr.length - 1 && <span className="kcs-or">{t('keyboard.or')}</span>}
</React.Fragment>
+146 -71
View File
@@ -1,8 +1,19 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { copyText } from "../utils/copyText";
import { copyText } from '../utils/copyText';
import {
ChevronUp, ChevronDown, RefreshCw, Trash2, Copy, Bug, X,
AlertTriangle, AlertCircle, Info, FileText, Heart, Mail,
ChevronUp,
ChevronDown,
RefreshCw,
Trash2,
Copy,
Bug,
X,
AlertTriangle,
AlertCircle,
Info,
FileText,
Heart,
Mail,
} from 'lucide-react';
import toast from 'react-hot-toast';
@@ -24,9 +35,9 @@ import './LogsFooter.css';
*/
const SOURCES = [
{ id: 'backend', label: 'Backend', icon: FileText },
{ id: 'backend', label: 'Backend', icon: FileText },
{ id: 'frontend', label: 'Frontend', icon: FileText },
{ id: 'tauri', label: 'Tauri', icon: FileText },
{ id: 'tauri', label: 'Tauri', icon: FileText },
// Notifications used to live here as a 4th pill but that duplicated the
// header's bell+badge (single source of truth for notifications). The
// footer is logs-only now; bell handles notifications.
@@ -44,17 +55,18 @@ const MAX_H = 720;
// logger. Matches word-boundary to avoid false positives on identifiers
// like `warning_count` or `error_handler`.
const RE_ERROR = /\b(error|fatal|exception|traceback)\b/i;
const RE_WARN = /\b(warn(ing)?|deprecated)\b/i;
const RE_WARN = /\b(warn(ing)?|deprecated)\b/i;
function classifyLine(raw) {
const text = typeof raw === 'string' ? raw : (raw?.msg ?? String(raw));
if (RE_ERROR.test(text)) return 'error';
if (RE_WARN.test(text)) return 'warn';
if (RE_WARN.test(text)) return 'warn';
return 'info';
}
function countLevels(lines) {
let error = 0, warn = 0;
let error = 0,
warn = 0;
for (const l of lines) {
const sev = classifyLine(l);
if (sev === 'error') error++;
@@ -70,7 +82,7 @@ function formatFrontendLine(entry) {
function SeverityIcon({ level, size = 11 }) {
if (level === 'error') return <AlertCircle size={size} color="#fb4934" />;
if (level === 'warn') return <AlertTriangle size={size} color="#fabd2f" />;
if (level === 'warn') return <AlertTriangle size={size} color="#fabd2f" />;
return <Info size={size} color="#7c6f64" />;
}
@@ -82,7 +94,7 @@ function SeverityIcon({ level, size = 11 }) {
function SourcePill({ source, counts, active, onClick }) {
const hasErrors = counts.error > 0;
const hasWarns = counts.warn > 0;
const hasWarns = counts.warn > 0;
return (
<button
type="button"
@@ -90,7 +102,9 @@ function SourcePill({ source, counts, active, onClick }) {
'logs-footer__pill',
active ? 'logs-footer__pill--active' : '',
hasErrors ? 'logs-footer__pill--error' : hasWarns ? 'logs-footer__pill--warn' : '',
].filter(Boolean).join(' ')}
]
.filter(Boolean)
.join(' ')}
onClick={onClick}
aria-label={`${source.label} logs${hasErrors ? `, ${counts.error} errors` : hasWarns ? `, ${counts.warn} warnings` : ''}`}
>
@@ -113,11 +127,11 @@ function SourcePill({ source, counts, active, onClick }) {
// default pool rotates daily based on day-of-year.
const HEART_POOL = ['❤️', '🩷', '💜', '💙', '🧡', '💛', '🩵', '💖', '💗'];
const SEASONAL = [
{ month: 12, emoji: '🎄', color: '#e74c3c', title: 'Merry Christmas! Support this project' },
{ month: 2, emoji: '💝', color: '#ff6b81', title: 'Happy Valentine\'s! Support this project' },
{ month: 12, emoji: '🎄', color: '#e74c3c', title: 'Merry Christmas! Support this project' },
{ month: 2, emoji: '💝', color: '#ff6b81', title: "Happy Valentine's! Support this project" },
// Diwali window — roughly Kartik Amavasya (OctNov)
{ month: 10, emoji: '🪔', color: '#f5a623', title: 'Happy Diwali! Support this project' },
{ month: 11, emoji: '✨', color: '#f5a623', title: 'Happy Diwali! Support this project' },
{ month: 10, emoji: '🪔', color: '#f5a623', title: 'Happy Diwali! Support this project' },
{ month: 11, emoji: '✨', color: '#f5a623', title: 'Happy Diwali! Support this project' },
];
function DonateHeart() {
@@ -125,9 +139,13 @@ function DonateHeart() {
const month = now.getMonth() + 1;
const dayOfYear = Math.floor((now - new Date(now.getFullYear(), 0, 0)) / 86400000);
const seasonal = SEASONAL.find(s => s.month === month);
const seasonal = SEASONAL.find((s) => s.month === month);
if (seasonal) {
return <span style={{ fontSize: 14, lineHeight: 1 }} title={seasonal.title}>{seasonal.emoji}</span>;
return (
<span style={{ fontSize: 14, lineHeight: 1 }} title={seasonal.title}>
{seasonal.emoji}
</span>
);
}
// Rotate through the pool daily
const pick = HEART_POOL[dayOfYear % HEART_POOL.length];
@@ -153,7 +171,7 @@ export default function LogsFooter() {
});
const [active, setActive] = useState(() => {
const v = localStorage.getItem(LS_ACTIVE);
return SOURCES.some(s => s.id === v) ? v : 'backend';
return SOURCES.some((s) => s.id === v) ? v : 'backend';
});
// Raw log state per source. Backend / Tauri come from HTTP; frontend
@@ -163,7 +181,7 @@ export default function LogsFooter() {
const scrollRef = useRef(null);
useEffect(() => localStorage.setItem(LS_HEIGHT, String(height)), [height]);
useEffect(() => localStorage.setItem(LS_ACTIVE, active), [active]);
useEffect(() => localStorage.setItem(LS_ACTIVE, active), [active]);
// Expose the current footer height as a CSS variable on :root so the
// studio's .app-container grid + the setup-wizard wrapper both shrink
@@ -181,18 +199,18 @@ export default function LogsFooter() {
// While collapsed only the count badge is visible, so poll lazily (45s);
// tighten to 10s when the panel is open (same throttle idea as pullFrontend).
const backendLogs = useSystemLogs(300, true, collapsed ? 45_000 : 10_000);
const tauriLogs = useTauriLogs(300, true, collapsed ? 45_000 : 10_000);
const tauriLogs = useTauriLogs(300, true, collapsed ? 45_000 : 10_000);
// Sync query data into local state for the rendering pipeline
useEffect(() => {
if (backendLogs.data) {
setLines(prev => ({ ...prev, backend: backendLogs.data.lines || [] }));
setLines((prev) => ({ ...prev, backend: backendLogs.data.lines || [] }));
}
}, [backendLogs.data]);
useEffect(() => {
if (tauriLogs.data) {
setLines(prev => ({ ...prev, tauri: tauriLogs.data.lines || [] }));
setLines((prev) => ({ ...prev, tauri: tauriLogs.data.lines || [] }));
}
}, [tauriLogs.data]);
@@ -206,7 +224,7 @@ export default function LogsFooter() {
const seen = lastFrontendPull.current;
if (raw.length === seen.len && lastT === seen.t) return;
lastFrontendPull.current = { len: raw.length, t: lastT };
setLines(prev => ({
setLines((prev) => ({
...prev,
frontend: raw.map(formatFrontendLine),
}));
@@ -250,25 +268,33 @@ export default function LogsFooter() {
if (atBottom) el.scrollTop = el.scrollHeight;
}, [lines, active, collapsed]);
const counts = useMemo(() => ({
backend: countLevels(lines.backend),
frontend: countLevels(lines.frontend),
tauri: countLevels(lines.tauri),
notifications: {
error: notifications.filter(n => n.level === 'error').length,
warn: notifications.filter(n => n.level === 'warn').length,
total: notifications.length,
},
}), [lines, notifications]);
const counts = useMemo(
() => ({
backend: countLevels(lines.backend),
frontend: countLevels(lines.frontend),
tauri: countLevels(lines.tauri),
notifications: {
error: notifications.filter((n) => n.level === 'error').length,
warn: notifications.filter((n) => n.level === 'warn').length,
total: notifications.length,
},
}),
[lines, notifications],
);
const openTo = (id) => { setActive(id); setCollapsed(false); };
const openTo = (id) => {
setActive(id);
setCollapsed(false);
};
// Combined backend+frontend+tauri counts for the single collapsed "Logs" tab.
const mergedCounts = useMemo(() => {
const acc = { error: 0, warn: 0, total: 0 };
for (const s of SOURCES) {
const c = counts[s.id] || { error: 0, warn: 0, total: 0 };
acc.error += c.error; acc.warn += c.warn; acc.total += c.total;
acc.error += c.error;
acc.warn += c.warn;
acc.total += c.total;
}
return acc;
}, [counts]);
@@ -295,10 +321,10 @@ export default function LogsFooter() {
// ── Actions ─────────────────────────────────────────────────────────
const onClear = async () => {
try {
if (active === 'backend') await clearSystemLogs();
else if (active === 'tauri') await clearTauriLogs();
if (active === 'backend') await clearSystemLogs();
else if (active === 'tauri') await clearTauriLogs();
else if (active === 'frontend') clearFrontendLogs();
setLines(prev => ({ ...prev, [active]: [] }));
setLines((prev) => ({ ...prev, [active]: [] }));
toast.success(t('logs.log_cleared', { source: active }));
} catch (e) {
toast.error(t('logs.clear_failed', { message: e?.message || e }));
@@ -328,10 +354,12 @@ export default function LogsFooter() {
`tauri err=${counts.tauri.error}/warn=${counts.tauri.warn}`,
'',
].join('\n');
const body = SOURCES.map(s => {
const body = SOURCES.map((s) => {
const l = lines[s.id] || [];
return `── ${s.label} (last ${Math.min(l.length, 80)} of ${l.length}) ──────────────\n` +
l.slice(-80).join('\n');
return (
`── ${s.label} (last ${Math.min(l.length, 80)} of ${l.length}) ──────────────\n` +
l.slice(-80).join('\n')
);
}).join('\n\n');
try {
await copyText(header + body);
@@ -345,8 +373,12 @@ export default function LogsFooter() {
const current = lines[active] || [];
return (
<div className={['logs-footer', collapsed ? 'logs-footer--collapsed' : 'logs-footer--open'].join(' ')}
style={collapsed ? undefined : { height }}>
<div
className={['logs-footer', collapsed ? 'logs-footer--collapsed' : 'logs-footer--open'].join(
' ',
)}
style={collapsed ? undefined : { height }}
>
{!collapsed && (
<div
ref={dragRef}
@@ -364,7 +396,7 @@ export default function LogsFooter() {
<button
type="button"
className="logs-footer__toggle"
onClick={() => setCollapsed(c => !c)}
onClick={() => setCollapsed((c) => !c)}
title={collapsed ? t('logs.expand') : t('logs.collapse')}
aria-label={collapsed ? t('logs.expand_aria') : t('logs.collapse_aria')}
aria-expanded={!collapsed}
@@ -378,12 +410,12 @@ export default function LogsFooter() {
source={{ id: 'logs', label: t('logs.title') }}
counts={mergedCounts}
active={false}
onClick={() => openTo(SOURCES.some(s => s.id === active) ? active : 'backend')}
onClick={() => openTo(SOURCES.some((s) => s.id === active) ? active : 'backend')}
/>
) : (
<>
<span className="logs-footer__title">{t('logs.title')}</span>
{SOURCES.map(s => (
{SOURCES.map((s) => (
<SourcePill
key={s.id}
source={s}
@@ -398,19 +430,45 @@ export default function LogsFooter() {
<div className="logs-footer__right">
{!collapsed && (
<div className="logs-footer__actions">
<button className="logs-footer__icon-btn" onClick={refreshAll} disabled={loading} title={t('logs.refresh')} aria-label={t('logs.refresh_aria')}>
<button
className="logs-footer__icon-btn"
onClick={refreshAll}
disabled={loading}
title={t('logs.refresh')}
aria-label={t('logs.refresh_aria')}
>
<RefreshCw size={12} className={loading ? 'spinner' : ''} />
</button>
<button className="logs-footer__icon-btn" onClick={onCopy} title={t('logs.copy_visible')} aria-label={t('logs.copy_visible_aria')}>
<button
className="logs-footer__icon-btn"
onClick={onCopy}
title={t('logs.copy_visible')}
aria-label={t('logs.copy_visible_aria')}
>
<Copy size={12} />
</button>
<button className="logs-footer__icon-btn" onClick={onClear} title={t('logs.clear')} aria-label={t('logs.clear_aria')}>
<button
className="logs-footer__icon-btn"
onClick={onClear}
title={t('logs.clear')}
aria-label={t('logs.clear_aria')}
>
<Trash2 size={12} />
</button>
<button className="logs-footer__icon-btn logs-footer__icon-btn--report" onClick={onReportIssue} title={t('logs.report_issue')} aria-label={t('logs.report_issue_aria')}>
<button
className="logs-footer__icon-btn logs-footer__icon-btn--report"
onClick={onReportIssue}
title={t('logs.report_issue')}
aria-label={t('logs.report_issue_aria')}
>
<Bug size={12} />
</button>
<button className="logs-footer__icon-btn" onClick={() => setCollapsed(true)} title={t('logs.close')} aria-label={t('logs.close_aria')}>
<button
className="logs-footer__icon-btn"
onClick={() => setCollapsed(true)}
title={t('logs.close')}
aria-label={t('logs.close_aria')}
>
<X size={12} />
</button>
</div>
@@ -419,12 +477,25 @@ export default function LogsFooter() {
type="button"
className={`logs-footer__version ${updateReady ? 'logs-footer__version--update' : ''}`}
onClick={() => useAppStore.getState().openSettingsTab?.('updates')}
title={updateReady
? t('logs.update_available', { version: updateVersion || '', defaultValue: 'Update available ({{version}}) — click to update' })
: t('logs.version_updates', { defaultValue: 'Check for updates' })}
aria-label={updateReady
? t('logs.update_available_aria', { version: updateVersion || '', defaultValue: 'Update available ({{version}}) — open updates' })
: t('logs.version_updates_aria', { defaultValue: 'Open updates — app version {{v}}', v: APP_VERSION })}
title={
updateReady
? t('logs.update_available', {
version: updateVersion || '',
defaultValue: 'Update available ({{version}}) — click to update',
})
: t('logs.version_updates', { defaultValue: 'Check for updates' })
}
aria-label={
updateReady
? t('logs.update_available_aria', {
version: updateVersion || '',
defaultValue: 'Update available ({{version}}) — open updates',
})
: t('logs.version_updates_aria', {
defaultValue: 'Open updates — app version {{v}}',
v: APP_VERSION,
})
}
>
v{APP_VERSION}
{updateReady && <span className="logs-footer__version-dot" aria-hidden="true" />}
@@ -433,11 +504,17 @@ export default function LogsFooter() {
<button
type="button"
className="logs-footer__discord"
onClick={() => { import('../api/external').then(m => m.openExternal('https://discord.gg/bzQavDfVV9')); }}
onClick={() => {
import('../api/external').then((m) =>
m.openExternal('https://discord.gg/bzQavDfVV9'),
);
}}
title={t('logs.join_discord')}
aria-label={t('logs.join_discord_aria')}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.947 2.418-2.157 2.418z"/></svg>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.947 2.418-2.157 2.418z" />
</svg>
</button>
<button
type="button"
@@ -460,8 +537,6 @@ export default function LogsFooter() {
</div>
</div>
{!collapsed && active !== 'notifications' && (
<div ref={scrollRef} className="logs-footer__body">
{current.length === 0 && (
@@ -473,8 +548,12 @@ export default function LogsFooter() {
const level = classifyLine(line);
return (
<div key={i} className={`logs-footer__line logs-footer__line--${level}`}>
<span className="logs-footer__line-icon"><SeverityIcon level={level} /></span>
<pre className="logs-footer__line-text">{typeof line === 'string' ? line : JSON.stringify(line)}</pre>
<span className="logs-footer__line-icon">
<SeverityIcon level={level} />
</span>
<pre className="logs-footer__line-text">
{typeof line === 'string' ? line : JSON.stringify(line)}
</pre>
</div>
);
})}
@@ -484,11 +563,9 @@ export default function LogsFooter() {
{!collapsed && active === 'notifications' && (
<div className="logs-footer__body logs-footer__notif-body">
{notifications.length === 0 ? (
<div className="logs-footer__empty">
{t('logs.all_clear')}
</div>
<div className="logs-footer__empty">{t('logs.all_clear')}</div>
) : (
notifications.map(notif => (
notifications.map((notif) => (
<div
key={notif.id}
className={`logs-footer__notif-item logs-footer__notif-item--${notif.level} ${notif.action ? 'logs-footer__notif-item--clickable' : ''}`}
@@ -506,7 +583,7 @@ export default function LogsFooter() {
useAppStore.getState().setMode?.(notif.action.target);
setCollapsed(true);
} else if (notif.action.type === 'link') {
import('../api/external').then(m => m.openExternal(notif.action.target));
import('../api/external').then((m) => m.openExternal(notif.action.target));
}
}}
role={notif.action ? 'button' : undefined}
@@ -520,9 +597,7 @@ export default function LogsFooter() {
<span className="logs-footer__notif-msg">{notif.message}</span>
</div>
{notif.action && (
<span className="logs-footer__notif-action">
{notif.action.label}
</span>
<span className="logs-footer__notif-action">{notif.action.label} </span>
)}
</div>
))
+21 -17
View File
@@ -12,8 +12,8 @@ import './MultiLangPicker.css';
* searchable dropdown with Popular + All Languages sections.
*/
export default function MultiLangPicker({
selected = [], // array of { lang: string, code: string }
onChange, // (newSelected) => void
selected = [], // array of { lang: string, code: string }
onChange, // (newSelected) => void
disabled = false,
}) {
const { t } = useTranslation();
@@ -37,7 +37,7 @@ export default function MultiLangPicker({
if (dropOpen && inputRef.current) inputRef.current.focus();
}, [dropOpen]);
const selectedCodes = useMemo(() => new Set(selected.map(s => s.code)), [selected]);
const selectedCodes = useMemo(() => new Set(selected.map((s) => s.code)), [selected]);
const addLang = (lang, code) => {
if (selectedCodes.has(code)) return;
@@ -46,31 +46,35 @@ export default function MultiLangPicker({
};
const removeLang = (code) => {
onChange(selected.filter(s => s.code !== code));
onChange(selected.filter((s) => s.code !== code));
};
const filteredLangs = useMemo(() => {
const q = query.toLowerCase().trim();
return LANG_CODES.filter(lc =>
!selectedCodes.has(lc.code) &&
(!q || lc.label.toLowerCase().includes(q) || lc.code.toLowerCase().includes(q))
return LANG_CODES.filter(
(lc) =>
!selectedCodes.has(lc.code) &&
(!q || lc.label.toLowerCase().includes(q) || lc.code.toLowerCase().includes(q)),
);
}, [query, selectedCodes]);
const popularFiltered = useMemo(() => {
const q = query.toLowerCase().trim();
return POPULAR_LANGS
.map(lang => {
const match = LANG_CODES.find(lc => lc.label.toLowerCase() === lang.toLowerCase());
return match ? { lang, code: match.code } : null;
})
.filter(item => item && !selectedCodes.has(item.code) && (!q || item.lang.toLowerCase().includes(q) || item.code.includes(q)));
return POPULAR_LANGS.map((lang) => {
const match = LANG_CODES.find((lc) => lc.label.toLowerCase() === lang.toLowerCase());
return match ? { lang, code: match.code } : null;
}).filter(
(item) =>
item &&
!selectedCodes.has(item.code) &&
(!q || item.lang.toLowerCase().includes(q) || item.code.includes(q)),
);
}, [query, selectedCodes]);
return (
<div className="multi-lang" ref={dropRef}>
<div className="multi-lang__chips">
{selected.map(s => (
{selected.map((s) => (
<span key={s.code} className="multi-lang__chip">
<Globe size={9} />
<span>{s.code}</span>
@@ -111,7 +115,7 @@ export default function MultiLangPicker({
<input
ref={inputRef}
value={query}
onChange={e => setQuery(e.target.value)}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('dub.search_languages')}
spellCheck={false}
/>
@@ -120,7 +124,7 @@ export default function MultiLangPicker({
{popularFiltered.length > 0 && (
<>
<div className="multi-lang__section">{t('dub.popular')}</div>
{popularFiltered.map(item => (
{popularFiltered.map((item) => (
<button
key={item.code}
type="button"
@@ -134,7 +138,7 @@ export default function MultiLangPicker({
</>
)}
<div className="multi-lang__section">{t('dub.all_languages')}</div>
{filteredLangs.slice(0, 50).map(lc => (
{filteredLangs.slice(0, 50).map((lc) => (
<button
key={lc.code}
type="button"
+33 -19
View File
@@ -1,23 +1,29 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
Globe, Fingerprint, Film, FolderOpen, Settings2, ArrowLeftRight,
Library, FileText, BookOpen, BookMarked,
Globe,
Fingerprint,
Film,
FolderOpen,
Settings2,
ArrowLeftRight,
Library,
FileText,
BookOpen,
BookMarked,
} from 'lucide-react';
const ITEM_DEFS = [
{ id: 'launchpad', Icon: Globe, tKey: 'launchpad', accent: '#f3a5b6' },
{ id: 'studio', Icon: Fingerprint, tKey: 'voice', accent: '#d3869b' },
{ id: 'dub', Icon: Film, tKey: 'dub', accent: '#fe8019' },
{ id: 'stories', Icon: BookOpen, tKey: 'stories', accent: '#fabd2f' },
{ id: 'audiobook', Icon: BookMarked, tKey: 'audiobook', accent: '#8ec07c' },
{ id: 'gallery', Icon: Library, tKey: 'gallery', accent: '#b8bb26' },
{ id: 'transcriptions',Icon: FileText, tKey: 'transcripts', accent: '#d3869b' },
{ id: 'projects', Icon: FolderOpen, tKey: 'omnidrive', accent: '#83a598' },
];
const FOOTER_DEFS = [
{ id: 'settings', Icon: Settings2, tKey: 'settings', accent: '#fabd2f' },
{ id: 'launchpad', Icon: Globe, tKey: 'launchpad', accent: '#f3a5b6' },
{ id: 'studio', Icon: Fingerprint, tKey: 'voice', accent: '#d3869b' },
{ id: 'dub', Icon: Film, tKey: 'dub', accent: '#fe8019' },
{ id: 'stories', Icon: BookOpen, tKey: 'stories', accent: '#fabd2f' },
{ id: 'audiobook', Icon: BookMarked, tKey: 'audiobook', accent: '#8ec07c' },
{ id: 'gallery', Icon: Library, tKey: 'gallery', accent: '#b8bb26' },
{ id: 'transcriptions', Icon: FileText, tKey: 'transcripts', accent: '#d3869b' },
{ id: 'projects', Icon: FolderOpen, tKey: 'omnidrive', accent: '#83a598' },
];
const FOOTER_DEFS = [{ id: 'settings', Icon: Settings2, tKey: 'settings', accent: '#fabd2f' }];
function RailBtn({ active, Icon, label, accent, onClick }) {
return (
@@ -36,10 +42,14 @@ function RailBtn({ active, Icon, label, accent, onClick }) {
export default function NavRail({ mode, setMode, side = 'left', onFlipSide }) {
const { t } = useTranslation();
const items = React.useMemo(() =>
ITEM_DEFS.map(d => ({ ...d, label: t(`nav.${d.tKey}`) })), [t]);
const footerItems = React.useMemo(() =>
FOOTER_DEFS.map(d => ({ ...d, label: t(`nav.${d.tKey}`) })), [t]);
const items = React.useMemo(
() => ITEM_DEFS.map((d) => ({ ...d, label: t(`nav.${d.tKey}`) })),
[t],
);
const footerItems = React.useMemo(
() => FOOTER_DEFS.map((d) => ({ ...d, label: t(`nav.${d.tKey}`) })),
[t],
);
return (
<aside className={`nav-rail rail-${side}`}>
@@ -57,8 +67,12 @@ export default function NavRail({ mode, setMode, side = 'left', onFlipSide }) {
aria-label={t('donate.pill', { defaultValue: 'Support OmniVoice' })}
className={`rail-btn donate-pill ${mode === 'donate' ? 'active' : ''}`}
>
<span className="donate-pill__heart" aria-hidden="true">🩷</span>
<span className="rail-label">{t('donate.pill', { defaultValue: 'Support OmniVoice' })}</span>
<span className="donate-pill__heart" aria-hidden="true">
🩷
</span>
<span className="rail-label">
{t('donate.pill', { defaultValue: 'Support OmniVoice' })}
</span>
</button>
{footerItems.map((it) => (
<RailBtn key={it.id} {...it} active={mode === it.id} onClick={() => setMode(it.id)} />
+89 -25
View File
@@ -1,7 +1,7 @@
// frontend/src/components/NetworkToggle.jsx
import { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { copyText } from "../utils/copyText";
import { copyText } from '../utils/copyText';
import QRCode from 'qrcode';
import { Wifi, WifiOff, Copy, ExternalLink } from 'lucide-react';
import toast from 'react-hot-toast';
@@ -18,12 +18,21 @@ export default function NetworkToggle() {
const [qrs, setQrs] = useState({});
const refresh = useCallback(async () => {
try { setSt(await apiJson('/system/network/state')); } catch { /* loopback only; ignore */ }
try {
setSt(await apiJson('/system/network/state'));
} catch {
/* loopback only; ignore */
}
}, []);
useEffect(() => { refresh(); }, [refresh]);
useEffect(() => {
refresh();
}, [refresh]);
useEffect(() => {
if (!st.enabled || !st.pin) { setQrs({}); return; }
if (!st.enabled || !st.pin) {
setQrs({});
return;
}
let cancelled = false;
(async () => {
const next = {};
@@ -32,25 +41,42 @@ export default function NetworkToggle() {
}
if (!cancelled) setQrs(next);
})();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [st.enabled, st.pin, st.share_port, st.lan_addresses]);
// NOTE: do NOT use window.confirm here it's a no-op in the Tauri webview
// (returns false), which silently swallowed the enable action.
const enable = async () => {
setBusy(true);
try { setSt(await apiPost('/system/network/enable')); setConfirming(false); setOpen(true); }
catch (e) { toast.error(t('network.enable_error', { message: e.message })); }
finally { setBusy(false); }
try {
setSt(await apiPost('/system/network/enable'));
setConfirming(false);
setOpen(true);
} catch (e) {
toast.error(t('network.enable_error', { message: e.message }));
} finally {
setBusy(false);
}
};
const disable = async () => {
setBusy(true);
try { await apiPost('/system/network/disable'); await refresh(); setOpen(false); }
catch (e) { toast.error(t('network.disable_error', { message: e.message })); }
finally { setBusy(false); }
try {
await apiPost('/system/network/disable');
await refresh();
setOpen(false);
} catch (e) {
toast.error(t('network.disable_error', { message: e.message }));
} finally {
setBusy(false);
}
};
const copy = (text) => { copyText(text); toast.success(t('network.copied')); };
const copy = (text) => {
copyText(text);
toast.success(t('network.copied'));
};
return (
<div className="net-toggle">
@@ -61,18 +87,27 @@ export default function NetworkToggle() {
title={st.enabled ? t('network.sharing_on_title') : t('network.share_on_network')}
>
{st.enabled ? <Wifi size={12} /> : <WifiOff size={12} />}
<span>{busy ? t('network.switching') : st.enabled ? t('network.network') : t('network.local')}</span>
<span>
{busy ? t('network.switching') : st.enabled ? t('network.network') : t('network.local')}
</span>
</button>
{!st.enabled && confirming && (
<div className="net-toggle__panel net-toggle__panel--confirm">
<div className="net-toggle__panel-title">{t('network.share_confirm_title')}</div>
<p className="net-toggle__hint">
{t('network.share_confirm_hint')}
</p>
<p className="net-toggle__hint">{t('network.share_confirm_hint')}</p>
<div className="net-toggle__confirm-actions">
<button type="button" className="net-toggle__cancel" onClick={() => setConfirming(false)} disabled={busy}>{t('common.cancel')}</button>
<button type="button" className="net-toggle__enable" onClick={enable} disabled={busy}>{busy ? t('network.enabling') : t('network.enable')}</button>
<button
type="button"
className="net-toggle__cancel"
onClick={() => setConfirming(false)}
disabled={busy}
>
{t('common.cancel')}
</button>
<button type="button" className="net-toggle__enable" onClick={enable} disabled={busy}>
{busy ? t('network.enabling') : t('network.enable')}
</button>
</div>
</div>
)}
@@ -88,21 +123,50 @@ export default function NetworkToggle() {
return (
<div key={ip} className="net-toggle__row">
<div className="net-toggle__row-main">
<code className="net-toggle__addr">{ip}:{st.share_port}</code>
<code className="net-toggle__addr">
{ip}:{st.share_port}
</code>
<div className="net-toggle__row-actions">
<button type="button" className="net-toggle__iconbtn" onClick={() => copy(url)} aria-label={`Copy ${ip}`} title={t('network.copy_link')}><Copy size={12} /></button>
<button type="button" className="net-toggle__iconbtn" onClick={() => openExternal(url)} aria-label={`Open ${ip}`} title={t('network.open_in_browser')}><ExternalLink size={12} /></button>
<button
type="button"
className="net-toggle__iconbtn"
onClick={() => copy(url)}
aria-label={`Copy ${ip}`}
title={t('network.copy_link')}
>
<Copy size={12} />
</button>
<button
type="button"
className="net-toggle__iconbtn"
onClick={() => openExternal(url)}
aria-label={`Open ${ip}`}
title={t('network.open_in_browser')}
>
<ExternalLink size={12} />
</button>
</div>
</div>
{qrs[ip] && <img className="net-toggle__qr" src={qrs[ip]} alt={t('network.qr_alt', { ip })} width={104} height={104} />}
{qrs[ip] && (
<img
className="net-toggle__qr"
src={qrs[ip]}
alt={t('network.qr_alt', { ip })}
width={104}
height={104}
/>
)}
</div>
);
})}
<div className="net-toggle__pin">{t('network.pin')} <strong>{st.pin}</strong></div>
<button type="button" className="net-toggle__off" onClick={disable} disabled={busy}>{t('network.stop_sharing')}</button>
<div className="net-toggle__pin">
{t('network.pin')} <strong>{st.pin}</strong>
</div>
<button type="button" className="net-toggle__off" onClick={disable} disabled={busy}>
{t('network.stop_sharing')}
</button>
</div>
)}
</div>
);
}
+11 -7
View File
@@ -5,11 +5,17 @@ import NetworkToggle from './NetworkToggle';
describe('NetworkToggle', () => {
let realFetch;
beforeEach(() => { realFetch = global.fetch; });
afterEach(() => { global.fetch = realFetch; });
beforeEach(() => {
realFetch = global.fetch;
});
afterEach(() => {
global.fetch = realFetch;
});
it('defaults to Local when state reports disabled', async () => {
global.fetch = vi.fn(() => Promise.resolve({ ok: true, json: async () => ({ enabled: false }) }));
global.fetch = vi.fn(() =>
Promise.resolve({ ok: true, json: async () => ({ enabled: false }) }),
);
render(<NetworkToggle />);
await waitFor(() => expect(screen.getByText(/local/i)).toBeInTheDocument());
});
@@ -30,12 +36,10 @@ describe('NetworkToggle', () => {
render(<NetworkToggle />);
const pill = await screen.findByRole('button', { name: /local/i });
fireEvent.click(pill); // opens the in-app confirm must NOT depend on window.confirm
fireEvent.click(pill); // opens the in-app confirm must NOT depend on window.confirm
const enableBtn = await screen.findByRole('button', { name: /^enable$/i });
fireEvent.click(enableBtn);
await waitFor(() =>
expect(posts.some((u) => u.endsWith('/system/network/enable'))).toBe(true),
);
await waitFor(() => expect(posts.some((u) => u.endsWith('/system/network/enable'))).toBe(true));
});
});
@@ -12,8 +12,8 @@ export default function NotificationPanel() {
const { data } = useNotifications();
const notifs = data?.notifications || [];
const count = notifs.length;
const hasErrors = notifs.some(n => n.level === 'error');
const hasWarns = notifs.some(n => n.level === 'warn');
const hasErrors = notifs.some((n) => n.level === 'error');
const hasWarns = notifs.some((n) => n.level === 'warn');
const openNotifications = () => {
window.dispatchEvent(new CustomEvent('omni:open-notifications'));
+53 -27
View File
@@ -18,11 +18,16 @@ import './ReadinessChecklist.css';
const StatusIcon = ({ status, size = 14 }) => {
switch (status) {
case 'pass': return <CheckCircle size={size} />;
case 'warn': return <AlertTriangle size={size} />;
case 'fail': return <XCircle size={size} />;
case 'loading': return <Loader size={size} />;
default: return <Loader size={size} />;
case 'pass':
return <CheckCircle size={size} />;
case 'warn':
return <AlertTriangle size={size} />;
case 'fail':
return <XCircle size={size} />;
case 'loading':
return <Loader size={size} />;
default:
return <Loader size={size} />;
}
};
@@ -39,20 +44,32 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
// Model readiness (from /model/status)
const modelDetail = modelData?.detail || '';
const modelErr = modelData?.error || null;
const modelErr = modelData?.error || null;
const modelCheck = {
id: 'asr-model',
label: t('readiness.asr_model'),
status: modelStatus === 'ready' ? 'pass'
: modelStatus === 'loading' ? 'loading'
: modelStatus === 'error' || modelData?.sub_stage === 'error' ? 'fail'
: 'warn',
detail: modelStatus === 'ready' ? t('readiness.loaded_ready')
: modelStatus === 'loading' ? (modelDetail || t('readiness.loading_first_run'))
: (modelData?.sub_stage === 'error' ? (modelErr || t('readiness.failed_to_load')) : t('readiness.not_loaded_yet')),
fix: (modelStatus === 'error' || modelData?.sub_stage === 'error')
? (modelErr ? t('readiness.error_check_logs', { error: modelErr }) : t('readiness.check_logs_restart'))
: null,
status:
modelStatus === 'ready'
? 'pass'
: modelStatus === 'loading'
? 'loading'
: modelStatus === 'error' || modelData?.sub_stage === 'error'
? 'fail'
: 'warn',
detail:
modelStatus === 'ready'
? t('readiness.loaded_ready')
: modelStatus === 'loading'
? modelDetail || t('readiness.loading_first_run')
: modelData?.sub_stage === 'error'
? modelErr || t('readiness.failed_to_load')
: t('readiness.not_loaded_yet'),
fix:
modelStatus === 'error' || modelData?.sub_stage === 'error'
? modelErr
? t('readiness.error_check_logs', { error: modelErr })
: t('readiness.check_logs_restart')
: null,
};
checks.push(modelCheck);
@@ -77,7 +94,7 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
};
// If we have preflight and there's a network check passing, LLM is at least possible
if (preflight?.checks) {
const netCheck = preflight.checks.find(c => c.id === 'network');
const netCheck = preflight.checks.find((c) => c.id === 'network');
if (netCheck?.status === 'pass') {
llmCheck.detail = t('readiness.llm_optional');
}
@@ -85,8 +102,8 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
checks.push(llmCheck);
// Determine if all critical checks pass
const allPass = checks.every(c => c.status === 'pass' || c.status === 'warn');
const anyFail = checks.some(c => c.status === 'fail');
const allPass = checks.every((c) => c.status === 'pass' || c.status === 'warn');
const anyFail = checks.some((c) => c.status === 'fail');
// Hide when everything is fine (unless explicitly asked to show)
if (!showWhenAllPass && allPass && !isLoading) return null;
@@ -95,7 +112,9 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
return (
<div className="readiness-checklist">
<div className="readiness-checklist__title">
<span className="readiness-checklist__title-icon"><Search size={14} /></span>
<span className="readiness-checklist__title-icon">
<Search size={14} />
</span>
{t('readiness.checking_system')}
</div>
</div>
@@ -104,7 +123,7 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
if (compact) {
// Compact mode: just show failing/warning items
const issues = checks.filter(c => c.status !== 'pass');
const issues = checks.filter((c) => c.status !== 'pass');
if (issues.length === 0) {
return (
<div className="readiness-checklist__all-pass">
@@ -116,9 +135,11 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
return (
<div className="readiness-checklist">
<ul className="readiness-checklist__list">
{issues.map(check => (
{issues.map((check) => (
<li key={check.id} className="readiness-checklist__item">
<span className={`readiness-checklist__status readiness-checklist__status--${check.status}`}>
<span
className={`readiness-checklist__status readiness-checklist__status--${check.status}`}
>
<StatusIcon status={check.status} />
</span>
<div>
@@ -141,15 +162,21 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
{t('readiness.system_readiness')}
</div>
<ul className="readiness-checklist__list">
{checks.map(check => (
{checks.map((check) => (
<li key={check.id} className="readiness-checklist__item">
<span className={`readiness-checklist__status readiness-checklist__status--${check.status}`}>
<span
className={`readiness-checklist__status readiness-checklist__status--${check.status}`}
>
<StatusIcon status={check.status} />
</span>
<div>
<div className="readiness-checklist__label">{check.label}</div>
<div className="readiness-checklist__detail">{check.detail}</div>
{check.fix && <div className="readiness-checklist__fix"><Lightbulb size={12} /> {check.fix}</div>}
{check.fix && (
<div className="readiness-checklist__fix">
<Lightbulb size={12} /> {check.fix}
</div>
)}
</div>
</li>
))}
@@ -157,4 +184,3 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
</div>
);
}
+7 -1
View File
@@ -34,7 +34,13 @@ export default function RemoteAuthGate({ children, forceGate = false }) {
<h2>{t('remote_gate.title')}</h2>
<p>{t('remote_gate.body')}</p>
<label htmlFor="ov-pin">{t('remote_gate.label')}</label>
<input id="ov-pin" inputMode="numeric" value={pin} onChange={(e) => setPin(e.target.value)} autoFocus />
<input
id="ov-pin"
inputMode="numeric"
value={pin}
onChange={(e) => setPin(e.target.value)}
autoFocus
/>
<button type="submit">{t('remote_gate.connect')}</button>
</form>
</div>
@@ -7,12 +7,20 @@ describe('RemoteAuthGate', () => {
afterEach(() => sessionStorage.clear());
it('renders children when not gated', () => {
render(<RemoteAuthGate><div>app-content</div></RemoteAuthGate>);
render(
<RemoteAuthGate>
<div>app-content</div>
</RemoteAuthGate>,
);
expect(screen.getByText('app-content')).toBeInTheDocument();
});
it('stores the entered PIN', () => {
render(<RemoteAuthGate forceGate><div>app-content</div></RemoteAuthGate>);
render(
<RemoteAuthGate forceGate>
<div>app-content</div>
</RemoteAuthGate>,
);
fireEvent.change(screen.getByLabelText(/access pin/i), { target: { value: '999111' } });
fireEvent.click(screen.getByRole('button', { name: /connect/i }));
expect(sessionStorage.getItem('ov_pin')).toBe('999111');
+98 -58
View File
@@ -9,12 +9,16 @@ const readRecents = (key) => {
try {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : [];
} catch { return []; }
} catch {
return [];
}
};
const writeRecents = (key, list) => {
if (!key || typeof window === 'undefined') return;
try { window.localStorage.setItem(key, JSON.stringify(list.slice(0, 8))); } catch {}
try {
window.localStorage.setItem(key, JSON.stringify(list.slice(0, 8)));
} catch {}
};
const normalize = (s) => (s || '').toString().toLowerCase();
@@ -51,11 +55,14 @@ export default function SearchableSelect({
const inputRef = useRef(null);
const getVal = useCallback((o) => (typeof o === 'string' ? o : o?.value), []);
const getLabel = useCallback((o) => {
if (renderLabel) return renderLabel(o);
if (typeof o === 'string') return o;
return o?.label ?? o?.value ?? '';
}, [renderLabel]);
const getLabel = useCallback(
(o) => {
if (renderLabel) return renderLabel(o);
if (typeof o === 'string') return o;
return o?.label ?? o?.value ?? '';
},
[renderLabel],
);
const byVal = useMemo(() => {
const m = new Map();
@@ -65,13 +72,15 @@ export default function SearchableSelect({
const currentLabel = useMemo(() => {
const o = byVal.get(value);
return o ? getLabel(o) : (value || placeholder);
return o ? getLabel(o) : value || placeholder;
}, [byVal, value, getLabel, placeholder]);
const filtered = useMemo(() => {
const q = normalize(query);
if (!q) return options;
return options.filter(o => normalize(getLabel(o)).includes(q) || normalize(getVal(o)).includes(q));
return options.filter(
(o) => normalize(getLabel(o)).includes(q) || normalize(getVal(o)).includes(q),
);
}, [options, query, getLabel, getVal]);
const pinned = useMemo(() => {
@@ -80,13 +89,19 @@ export default function SearchableSelect({
const seen = new Set();
for (const v of recents) {
const o = byVal.get(v);
if (o && !seen.has(v)) { out.push({ o, kind: 'recent' }); seen.add(v); }
if (o && !seen.has(v)) {
out.push({ o, kind: 'recent' });
seen.add(v);
}
if (out.length >= 5) break;
}
for (const v of popular) {
if (seen.has(v)) continue;
const o = byVal.get(v);
if (o) { out.push({ o, kind: 'popular' }); seen.add(v); }
if (o) {
out.push({ o, kind: 'popular' });
seen.add(v);
}
if (out.length >= 12) break;
}
return out;
@@ -129,7 +144,7 @@ export default function SearchableSelect({
const v = getVal(o);
onChange?.(v);
if (recentsKey && isRecentable(v)) {
const next = [v, ...recents.filter(r => r !== v)].slice(0, 8);
const next = [v, ...recents.filter((r) => r !== v)].slice(0, 8);
setRecents(next);
writeRecents(recentsKey, next);
}
@@ -137,13 +152,20 @@ export default function SearchableSelect({
};
const onKey = (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(flatItems.length - 1, h + 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(h => Math.max(0, h - 1)); }
else if (e.key === 'Enter') {
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlight((h) => Math.min(flatItems.length - 1, h + 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlight((h) => Math.max(0, h - 1));
} else if (e.key === 'Enter') {
e.preventDefault();
const item = flatItems[highlight];
if (item) commit(item.o);
} else if (e.key === 'Escape') { e.preventDefault(); setOpen(false); }
} else if (e.key === 'Escape') {
e.preventDefault();
setOpen(false);
}
};
const sizeCls = size === 'sm' ? 'ss-sm' : 'ss-md';
@@ -154,7 +176,7 @@ export default function SearchableSelect({
type="button"
className={`${buttonClassName} ss-trigger`}
style={buttonStyle}
onClick={() => !disabled && setOpen(o => !o)}
onClick={() => !disabled && setOpen((o) => !o)}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
@@ -173,60 +195,78 @@ export default function SearchableSelect({
className="ss-search-input"
placeholder={t('common.search')}
value={query}
onChange={e => { setQuery(e.target.value); setHighlight(0); }}
onChange={(e) => {
setQuery(e.target.value);
setHighlight(0);
}}
onKeyDown={onKey}
/>
</div>
<div ref={listRef} className="ss-list">
{flatItems.length === 0 && (
<div className="ss-empty">{t('common.no_matches')}</div>
)}
{flatItems.length === 0 && <div className="ss-empty">{t('common.no_matches')}</div>}
{pinned.length > 0 && (
<div className="ss-group-label">
{recents.length ? <><Clock size={9}/> {t('common.recent_and_popular')}</> : <><Star size={9}/> {t('common.popular_label')}</>}
{recents.length ? (
<>
<Clock size={9} /> {t('common.recent_and_popular')}
</>
) : (
<>
<Star size={9} /> {t('common.popular_label')}
</>
)}
</div>
)}
{(() => { let lastGroup; return flatItems.map((it, idx) => {
const v = getVal(it.o);
const selected = v === value;
const highlighted = idx === highlight;
// Group header: emitted lazily on the first MAIN row of a new
// group whose option carries a non-empty groupLabel (#22). Pinned
// recent/popular rows never trigger a header. `lastGroup` advances
// only on main rows so a pinned row can't swallow the first header.
const showHeader =
renderGroupHeaders &&
it.kind === 'main' &&
it.o && it.o.groupLabel &&
it.o.group !== lastGroup;
if (it.kind === 'main') lastGroup = it.o?.group;
return (
<React.Fragment key={`${it.kind}-${v}-${idx}`}>
{showHeader && <div className="ss-group-label">{it.o.groupLabel}</div>}
<div
data-idx={idx}
className={`ss-option ${highlighted ? 'ss-hl' : ''} ${selected ? 'ss-sel' : ''}`}
onMouseEnter={() => setHighlight(idx)}
onMouseDown={(e) => { e.preventDefault(); commit(it.o); }}
role="option"
aria-selected={selected}
>
{it.kind === 'recent' && <Clock size={9} className="ss-kind-icon"/>}
{it.kind === 'popular' && <Star size={9} className="ss-kind-icon"/>}
<span className="ss-option-label">
{renderOption ? renderOption(it.o) : getLabel(it.o)}
</span>
{selected && <Check size={10} className="ss-check"/>}
</div>
</React.Fragment>
);
}); })()}
{(() => {
let lastGroup;
return flatItems.map((it, idx) => {
const v = getVal(it.o);
const selected = v === value;
const highlighted = idx === highlight;
// Group header: emitted lazily on the first MAIN row of a new
// group whose option carries a non-empty groupLabel (#22). Pinned
// recent/popular rows never trigger a header. `lastGroup` advances
// only on main rows so a pinned row can't swallow the first header.
const showHeader =
renderGroupHeaders &&
it.kind === 'main' &&
it.o &&
it.o.groupLabel &&
it.o.group !== lastGroup;
if (it.kind === 'main') lastGroup = it.o?.group;
return (
<React.Fragment key={`${it.kind}-${v}-${idx}`}>
{showHeader && <div className="ss-group-label">{it.o.groupLabel}</div>}
<div
data-idx={idx}
className={`ss-option ${highlighted ? 'ss-hl' : ''} ${selected ? 'ss-sel' : ''}`}
onMouseEnter={() => setHighlight(idx)}
onMouseDown={(e) => {
e.preventDefault();
commit(it.o);
}}
role="option"
aria-selected={selected}
>
{it.kind === 'recent' && <Clock size={9} className="ss-kind-icon" />}
{it.kind === 'popular' && <Star size={9} className="ss-kind-icon" />}
<span className="ss-option-label">
{renderOption ? renderOption(it.o) : getLabel(it.o)}
</span>
{selected && <Check size={10} className="ss-check" />}
</div>
</React.Fragment>
);
});
})()}
{!query && filtered.length > MAX_DISPLAY && (
<div className="ss-more">{t('common.showing_of', { shown: MAX_DISPLAY, total: filtered.length })}</div>
<div className="ss-more">
{t('common.showing_of', { shown: MAX_DISPLAY, total: filtered.length })}
</div>
)}
</div>
</div>
+280 -200
View File
@@ -2,14 +2,19 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
import { useTranslation } from 'react-i18next';
import { Play, Headphones } from 'lucide-react';
import {
REGION_COLORS, SNAP_PX,
visibleSegmentRange, snapTime, snapCandidates, clampSegmentEdit,
detectOverlaps, nearestOnset,
REGION_COLORS,
SNAP_PX,
visibleSegmentRange,
snapTime,
snapCandidates,
clampSegmentEdit,
detectOverlaps,
nearestOnset,
} from '../utils/timeline';
import './SegmentTrack.css';
const ONSET_STRIP_H = 8; // px non-interactive onset tick strip
const KB_STEP_S = 0.01; // / nudge
const ONSET_STRIP_H = 8; // px non-interactive onset tick strip
const KB_STEP_S = 0.01; // / nudge
const KB_STEP_BIG_S = 0.1; // Ctrl+/ nudge
const DRAG_DEADZONE_PX = 3;
@@ -70,12 +75,12 @@ export default function SegmentTrack({
const viewportRef = useRef(null);
const canvasRef = useRef(null);
const boxRefs = useRef(new Map());
const gestureRef = useRef(null); // live pointer gesture
const kbGestureRef = useRef(false); // first nudge of a focus session pushed undo?
const gestureRef = useRef(null); // live pointer gesture
const kbGestureRef = useRef(false); // first nudge of a focus session pushed undo?
const [viewWidth, setViewWidth] = useState(0);
const [innerScroll, setInnerScroll] = useState(0); // selfScroll mode only
const [live, setLive] = useState(null); // {id,start,end} during drag
const [live, setLive] = useState(null); // {id,start,end} during drag
const [activeEdge, setActiveEdge] = useState(null); // dragged edge time (onset highlight)
const [focusId, setFocusId] = useState(null);
const [editMode, setEditMode] = useState(false);
@@ -105,7 +110,9 @@ export default function SegmentTrack({
// Segments with the in-flight drag override applied (render + overlap).
const effSegments = useMemo(() => {
if (!live) return segments;
return segments.map(s => (String(s.id) === live.id ? { ...s, start: live.start, end: live.end } : s));
return segments.map((s) =>
String(s.id) === live.id ? { ...s, start: live.start, end: live.end } : s,
);
}, [segments, live]);
const overlaps = useMemo(() => detectOverlaps(effSegments), [effSegments]);
@@ -126,8 +133,10 @@ export default function SegmentTrack({
);
const speakerColor = useMemo(() => {
const speakers = [...new Set(segments.map(s => s.speaker_id).filter(Boolean))];
const bySpeaker = new Map(speakers.map((sp, i) => [sp, REGION_COLORS[i % REGION_COLORS.length]]));
const speakers = [...new Set(segments.map((s) => s.speaker_id).filter(Boolean))];
const bySpeaker = new Map(
speakers.map((sp, i) => [sp, REGION_COLORS[i % REGION_COLORS.length]]),
);
return (seg, idx) => bySpeaker.get(seg.speaker_id) || REGION_COLORS[idx % REGION_COLORS.length];
}, [segments]);
@@ -161,24 +170,30 @@ export default function SegmentTrack({
}, [onsets, pxPerSec, effScroll, viewWidth, activeEdge]);
// Selection / focus plumbing
const ensureVisible = useCallback((timeS) => {
if (selfScroll) {
const vp = viewportRef.current;
if (vp && pxPerSec > 0) {
const x = timeS * pxPerSec;
if (x < vp.scrollLeft || x > vp.scrollLeft + vp.clientWidth) {
vp.scrollLeft = Math.max(0, x - vp.clientWidth * 0.3);
const ensureVisible = useCallback(
(timeS) => {
if (selfScroll) {
const vp = viewportRef.current;
if (vp && pxPerSec > 0) {
const x = timeS * pxPerSec;
if (x < vp.scrollLeft || x > vp.scrollLeft + vp.clientWidth) {
vp.scrollLeft = Math.max(0, x - vp.clientWidth * 0.3);
}
}
return;
}
return;
}
onEnsureVisible?.(timeS);
}, [selfScroll, pxPerSec, onEnsureVisible]);
onEnsureVisible?.(timeS);
},
[selfScroll, pxPerSec, onEnsureVisible],
);
const selectAndFocus = useCallback((sid) => {
setFocusId(sid);
onSelectSeg?.(sid);
}, [onSelectSeg]);
const selectAndFocus = useCallback(
(sid) => {
setFocusId(sid);
onSelectSeg?.(sid);
},
[onSelectSeg],
);
// Keep DOM focus on the roving-focus box after re-renders, but only when
// focus already lives inside the track (never steal it from elsewhere).
@@ -192,70 +207,83 @@ export default function SegmentTrack({
}, [focusId, lo, hi]);
// Pointer gestures (drag = move, handles = resize)
const onBoxPointerDown = useCallback((e) => {
if (e.button !== 0) return;
const sid = e.currentTarget.dataset.segid;
selectAndFocus(sid);
if (disabled) return;
const idx = indexById.get(sid);
const s = segments[idx];
if (!s || pxPerSec <= 0) return;
const handle = e.target?.dataset?.handle || null;
gestureRef.current = {
sid, idx,
mode: handle || 'move',
startX: e.clientX,
origStart: s.start, origEnd: s.end,
moved: false, last: null,
};
try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* jsdom */ }
}, [disabled, indexById, segments, pxPerSec, selectAndFocus]);
const onBoxPointerDown = useCallback(
(e) => {
if (e.button !== 0) return;
const sid = e.currentTarget.dataset.segid;
selectAndFocus(sid);
if (disabled) return;
const idx = indexById.get(sid);
const s = segments[idx];
if (!s || pxPerSec <= 0) return;
const handle = e.target?.dataset?.handle || null;
gestureRef.current = {
sid,
idx,
mode: handle || 'move',
startX: e.clientX,
origStart: s.start,
origEnd: s.end,
moved: false,
last: null,
};
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
/* jsdom */
}
},
[disabled, indexById, segments, pxPerSec, selectAndFocus],
);
const onBoxPointerMove = useCallback((e) => {
const g = gestureRef.current;
if (!g || pxPerSec <= 0) return;
const dx = e.clientX - g.startX;
if (!g.moved && Math.abs(dx) < DRAG_DEADZONE_PX) return;
g.moved = true;
const dt = dx / pxPerSec;
let proposed;
if (g.mode === 'move') proposed = { start: g.origStart + dt, end: g.origEnd + dt };
else if (g.mode === 'start') proposed = { start: g.origStart + dt, end: g.origEnd };
else proposed = { start: g.origStart, end: g.origEnd + dt };
const onBoxPointerMove = useCallback(
(e) => {
const g = gestureRef.current;
if (!g || pxPerSec <= 0) return;
const dx = e.clientX - g.startX;
if (!g.moved && Math.abs(dx) < DRAG_DEADZONE_PX) return;
g.moved = true;
const dt = dx / pxPerSec;
let proposed;
if (g.mode === 'move') proposed = { start: g.origStart + dt, end: g.origEnd + dt };
else if (g.mode === 'start') proposed = { start: g.origStart + dt, end: g.origEnd };
else proposed = { start: g.origStart, end: g.origEnd + dt };
// Snap (unless Alt): onsets + adjacent edges + playhead + low-zoom grid.
const alt = e.altKey;
if (!alt) {
const thresholdS = SNAP_PX / pxPerSec;
const edge = g.mode === 'end' ? proposed.end : proposed.start;
const cands = snapCandidates({
onsets,
prevEnd: segments[g.idx - 1]?.end,
nextStart: segments[g.idx + 1]?.start,
playhead: currentTime,
pxPerSec,
t: edge,
});
const r = snapTime(edge, cands, thresholdS);
if (r.candidate != null) {
if (g.mode === 'move') {
const dur = g.origEnd - g.origStart;
proposed = { start: r.time, end: r.time + dur };
} else if (g.mode === 'start') {
proposed = { ...proposed, start: r.time };
} else {
proposed = { ...proposed, end: r.time };
// Snap (unless Alt): onsets + adjacent edges + playhead + low-zoom grid.
const alt = e.altKey;
if (!alt) {
const thresholdS = SNAP_PX / pxPerSec;
const edge = g.mode === 'end' ? proposed.end : proposed.start;
const cands = snapCandidates({
onsets,
prevEnd: segments[g.idx - 1]?.end,
nextStart: segments[g.idx + 1]?.start,
playhead: currentTime,
pxPerSec,
t: edge,
});
const r = snapTime(edge, cands, thresholdS);
if (r.candidate != null) {
if (g.mode === 'move') {
const dur = g.origEnd - g.origStart;
proposed = { start: r.time, end: r.time + dur };
} else if (g.mode === 'start') {
proposed = { ...proposed, start: r.time };
} else {
proposed = { ...proposed, end: r.time };
}
}
}
}
const clamped = clampSegmentEdit(segments, g.idx, g.mode, proposed, {
allowOverlap: alt,
duration: duration || Infinity,
});
g.last = clamped;
setLive({ id: String(g.sid), ...clamped });
setActiveEdge(g.mode === 'end' ? clamped.end : clamped.start);
}, [pxPerSec, onsets, segments, currentTime, duration]);
const clamped = clampSegmentEdit(segments, g.idx, g.mode, proposed, {
allowOverlap: alt,
duration: duration || Infinity,
});
g.last = clamped;
setLive({ id: String(g.sid), ...clamped });
setActiveEdge(g.mode === 'end' ? clamped.end : clamped.start);
},
[pxPerSec, onsets, segments, currentTime, duration],
);
const onBoxPointerUp = useCallback(() => {
const g = gestureRef.current;
@@ -270,121 +298,159 @@ export default function SegmentTrack({
if (g.mode === 'move') {
announce(t('timeline.moved_announce', { index: idx + 1, start: fmt(g.last.start) }));
} else {
announce(t('timeline.resized_announce', { index: idx + 1, start: fmt(g.last.start), end: fmt(g.last.end) }));
announce(
t('timeline.resized_announce', {
index: idx + 1,
start: fmt(g.last.start),
end: fmt(g.last.end),
}),
);
}
}, [onCommit, announce, t]);
// Keyboard (roving tabindex, #298 pattern)
const moveFocus = useCallback((sid, dir) => {
const idx = indexById.get(sid);
if (idx == null) return;
const next = segments[idx + dir];
if (!next) return;
const nid = String(next.id);
selectAndFocus(nid);
ensureVisible(next.start);
// Focus lands via the effect above once the box is mounted; force it if
// the box is already in the window.
const el = boxRefs.current.get(nid);
if (el) el.focus({ preventScroll: true });
}, [indexById, segments, selectAndFocus, ensureVisible]);
const moveFocus = useCallback(
(sid, dir) => {
const idx = indexById.get(sid);
if (idx == null) return;
const next = segments[idx + dir];
if (!next) return;
const nid = String(next.id);
selectAndFocus(nid);
ensureVisible(next.start);
// Focus lands via the effect above once the box is mounted; force it if
// the box is already in the window.
const el = boxRefs.current.get(nid);
if (el) el.focus({ preventScroll: true });
},
[indexById, segments, selectAndFocus, ensureVisible],
);
const nudge = useCallback((sid, dir, e) => {
if (disabled) return;
const idx = indexById.get(sid);
const s = segments[idx];
if (!s) return;
const step = (e.ctrlKey || e.metaKey) ? KB_STEP_BIG_S : KB_STEP_S;
const delta = dir * step;
let mode;
let proposed;
if (e.altKey) { mode = 'move'; proposed = { start: s.start + delta, end: s.end + delta }; }
else if (e.shiftKey) { mode = 'end'; proposed = { start: s.start, end: s.end + delta }; }
else { mode = 'start'; proposed = { start: s.start + delta, end: s.end }; }
const clamped = clampSegmentEdit(segments, idx, mode, proposed, { duration: duration || Infinity });
if (Math.abs(clamped.start - s.start) < 1e-4 && Math.abs(clamped.end - s.end) < 1e-4) return;
// First nudge of the focus session pushes undo; the rest coalesce.
onCommit?.(sid, clamped, { undo: !kbGestureRef.current });
kbGestureRef.current = true;
announce(t('timeline.resized_announce', { index: idx + 1, start: fmt(clamped.start), end: fmt(clamped.end) }));
}, [disabled, indexById, segments, duration, onCommit, announce, t]);
const snapFocusedEdge = useCallback((sid, useEndEdge) => {
if (disabled || !onsets.length) return;
const idx = indexById.get(sid);
const s = segments[idx];
if (!s) return;
const mode = useEndEdge ? 'end' : 'start';
const target = nearestOnset(useEndEdge ? s.end : s.start, onsets);
if (target == null) return;
const proposed = useEndEdge ? { start: s.start, end: target } : { start: target, end: s.end };
const clamped = clampSegmentEdit(segments, idx, mode, proposed, { duration: duration || Infinity });
if (Math.abs(clamped.start - s.start) < 1e-4 && Math.abs(clamped.end - s.end) < 1e-4) return;
onCommit?.(sid, clamped, { undo: true }); // discrete action = own gesture
announce(t('timeline.snapped_announce', { time: fmt(target) }));
}, [disabled, onsets, indexById, segments, duration, onCommit, announce, t]);
const handleDelete = useCallback((sid) => {
if (disabled) return;
const idx = indexById.get(sid);
if (idx == null) return;
const neighbour = segments[idx + 1] || segments[idx - 1];
onDelete?.(sid);
announce(t('timeline.deleted_announce', { index: idx + 1 }));
if (neighbour) selectAndFocus(String(neighbour.id));
}, [disabled, indexById, segments, onDelete, announce, selectAndFocus, t]);
const onBoxKeyDown = useCallback((e) => {
const sid = e.currentTarget.dataset.segid;
switch (e.key) {
case 'ArrowLeft':
case 'ArrowRight': {
e.preventDefault();
e.stopPropagation();
const dir = e.key === 'ArrowLeft' ? -1 : 1;
if (editMode) nudge(sid, dir, e);
else moveFocus(sid, dir);
break;
const nudge = useCallback(
(sid, dir, e) => {
if (disabled) return;
const idx = indexById.get(sid);
const s = segments[idx];
if (!s) return;
const step = e.ctrlKey || e.metaKey ? KB_STEP_BIG_S : KB_STEP_S;
const delta = dir * step;
let mode;
let proposed;
if (e.altKey) {
mode = 'move';
proposed = { start: s.start + delta, end: s.end + delta };
} else if (e.shiftKey) {
mode = 'end';
proposed = { start: s.start, end: s.end + delta };
} else {
mode = 'start';
proposed = { start: s.start + delta, end: s.end };
}
case 'Enter':
e.preventDefault();
e.stopPropagation();
if (disabled) break;
if (editMode) {
setEditMode(false);
kbGestureRef.current = false;
announce(t('timeline.edit_mode_off'));
} else {
setEditMode(true);
kbGestureRef.current = false;
announce(t('timeline.edit_mode_on'));
}
break;
case 'Escape':
if (editMode) {
const clamped = clampSegmentEdit(segments, idx, mode, proposed, {
duration: duration || Infinity,
});
if (Math.abs(clamped.start - s.start) < 1e-4 && Math.abs(clamped.end - s.end) < 1e-4) return;
// First nudge of the focus session pushes undo; the rest coalesce.
onCommit?.(sid, clamped, { undo: !kbGestureRef.current });
kbGestureRef.current = true;
announce(
t('timeline.resized_announce', {
index: idx + 1,
start: fmt(clamped.start),
end: fmt(clamped.end),
}),
);
},
[disabled, indexById, segments, duration, onCommit, announce, t],
);
const snapFocusedEdge = useCallback(
(sid, useEndEdge) => {
if (disabled || !onsets.length) return;
const idx = indexById.get(sid);
const s = segments[idx];
if (!s) return;
const mode = useEndEdge ? 'end' : 'start';
const target = nearestOnset(useEndEdge ? s.end : s.start, onsets);
if (target == null) return;
const proposed = useEndEdge ? { start: s.start, end: target } : { start: target, end: s.end };
const clamped = clampSegmentEdit(segments, idx, mode, proposed, {
duration: duration || Infinity,
});
if (Math.abs(clamped.start - s.start) < 1e-4 && Math.abs(clamped.end - s.end) < 1e-4) return;
onCommit?.(sid, clamped, { undo: true }); // discrete action = own gesture
announce(t('timeline.snapped_announce', { time: fmt(target) }));
},
[disabled, onsets, indexById, segments, duration, onCommit, announce, t],
);
const handleDelete = useCallback(
(sid) => {
if (disabled) return;
const idx = indexById.get(sid);
if (idx == null) return;
const neighbour = segments[idx + 1] || segments[idx - 1];
onDelete?.(sid);
announce(t('timeline.deleted_announce', { index: idx + 1 }));
if (neighbour) selectAndFocus(String(neighbour.id));
},
[disabled, indexById, segments, onDelete, announce, selectAndFocus, t],
);
const onBoxKeyDown = useCallback(
(e) => {
const sid = e.currentTarget.dataset.segid;
switch (e.key) {
case 'ArrowLeft':
case 'ArrowRight': {
e.preventDefault();
e.stopPropagation();
setEditMode(false);
kbGestureRef.current = false;
announce(t('timeline.edit_mode_off'));
const dir = e.key === 'ArrowLeft' ? -1 : 1;
if (editMode) nudge(sid, dir, e);
else moveFocus(sid, dir);
break;
}
break;
case 'Delete':
case 'Backspace':
e.preventDefault();
e.stopPropagation();
handleDelete(sid);
break;
case 's':
case 'S':
e.preventDefault();
e.stopPropagation();
snapFocusedEdge(sid, e.shiftKey);
break;
default:
break;
}
}, [editMode, disabled, nudge, moveFocus, handleDelete, snapFocusedEdge, announce, t]);
case 'Enter':
e.preventDefault();
e.stopPropagation();
if (disabled) break;
if (editMode) {
setEditMode(false);
kbGestureRef.current = false;
announce(t('timeline.edit_mode_off'));
} else {
setEditMode(true);
kbGestureRef.current = false;
announce(t('timeline.edit_mode_on'));
}
break;
case 'Escape':
if (editMode) {
e.preventDefault();
e.stopPropagation();
setEditMode(false);
kbGestureRef.current = false;
announce(t('timeline.edit_mode_off'));
}
break;
case 'Delete':
case 'Backspace':
e.preventDefault();
e.stopPropagation();
handleDelete(sid);
break;
case 's':
case 'S':
e.preventDefault();
e.stopPropagation();
snapFocusedEdge(sid, e.shiftKey);
break;
default:
break;
}
},
[editMode, disabled, nudge, moveFocus, handleDelete, snapFocusedEdge, announce, t],
);
const onBoxBlur = useCallback((e) => {
// Leaving the track ends the keyboard edit session.
@@ -443,7 +509,9 @@ export default function SegmentTrack({
stale && 'is-stale',
fresh && 'is-fresh',
live?.id === sid && 'is-dragging',
].filter(Boolean).join(' ');
]
.filter(Boolean)
.join(' ');
return (
<div
key={sid}
@@ -454,13 +522,15 @@ export default function SegmentTrack({
role="option"
aria-selected={isSel}
aria-label={t('timeline.segment_aria', {
index: idx + 1, start: fmt(s.start), end: fmt(s.end),
index: idx + 1,
start: fmt(s.start),
end: fmt(s.end),
})}
tabIndex={isFocus || (focusId == null && idx === 0) ? 0 : -1}
data-segid={sid}
className={cls}
style={{ left, width, background: speakerColor(s, idx) }}
title={hasOverlap ? t('timeline.overlap_warning') : (s.text || '')}
title={hasOverlap ? t('timeline.overlap_warning') : s.text || ''}
onPointerDown={onBoxPointerDown}
onPointerMove={onBoxPointerMove}
onPointerUp={onBoxPointerUp}
@@ -470,9 +540,11 @@ export default function SegmentTrack({
onFocus={() => setFocusId(sid)}
onBlur={onBoxBlur}
>
{!disabled && <span className="seg-track__handle seg-track__handle--l" data-handle="start" />}
{!disabled && (
<span className="seg-track__handle seg-track__handle--l" data-handle="start" />
)}
<span className="seg-track__label">
{s.text?.length > 32 ? `${s.text.slice(0, 30)}` : (s.text || '')}
{s.text?.length > 32 ? `${s.text.slice(0, 30)}` : s.text || ''}
</span>
{isSel && !disabled && width > 64 && (
<span className="seg-track__actions">
@@ -482,7 +554,10 @@ export default function SegmentTrack({
aria-label={t('timeline.play_slot')}
title={t('timeline.play_slot')}
onPointerDown={(ev) => ev.stopPropagation()}
onClick={(ev) => { ev.stopPropagation(); onPlayRange?.(s.start, s.end); }}
onClick={(ev) => {
ev.stopPropagation();
onPlayRange?.(s.start, s.end);
}}
>
<Play size={9} />
</button>
@@ -493,14 +568,19 @@ export default function SegmentTrack({
aria-label={t('timeline.preview_dub')}
title={t('timeline.preview_dub')}
onPointerDown={(ev) => ev.stopPropagation()}
onClick={(ev) => { ev.stopPropagation(); onPreviewSegment(s); }}
onClick={(ev) => {
ev.stopPropagation();
onPreviewSegment(s);
}}
>
<Headphones size={9} />
</button>
)}
</span>
)}
{!disabled && <span className="seg-track__handle seg-track__handle--r" data-handle="end" />}
{!disabled && (
<span className="seg-track__handle seg-track__handle--r" data-handle="end" />
)}
</div>
);
})}
@@ -63,7 +63,7 @@ describe('SegmentTrack — rendering', () => {
it('roving tabindex: exactly one tab stop', () => {
setup();
const stops = screen.getAllByRole('option').filter(el => el.tabIndex === 0);
const stops = screen.getAllByRole('option').filter((el) => el.tabIndex === 0);
expect(stops).toHaveLength(1);
});
@@ -74,9 +74,7 @@ describe('SegmentTrack — rendering', () => {
});
it('renders nothing without pxPerSec', () => {
const { container } = render(
<SegmentTrack segments={SEGS} pxPerSec={0} duration={10} />,
);
const { container } = render(<SegmentTrack segments={SEGS} pxPerSec={0} duration={10} />);
expect(container.firstChild).toBeNull();
});
});
@@ -99,7 +97,9 @@ describe('SegmentTrack — keyboard', () => {
// Segments prop is static here (mocked transport), so the next nudge
// computes from the unchanged start=3.
fireEvent.keyDown(box(1), { key: 'ArrowLeft', ctrlKey: true });
expect(onCommit).toHaveBeenLastCalledWith('3_a', expect.objectContaining({ start: 2.9 }), { undo: false });
expect(onCommit).toHaveBeenLastCalledWith('3_a', expect.objectContaining({ start: 2.9 }), {
undo: false,
});
});
it('one undo per focus session — only the FIRST nudge passes undo:true', () => {
@@ -109,7 +109,7 @@ describe('SegmentTrack — keyboard', () => {
fireEvent.keyDown(box(0), { key: 'ArrowRight' });
fireEvent.keyDown(box(0), { key: 'ArrowRight' });
fireEvent.keyDown(box(0), { key: 'ArrowRight' });
const undoFlags = onCommit.mock.calls.map(c => c[2].undo);
const undoFlags = onCommit.mock.calls.map((c) => c[2].undo);
expect(undoFlags).toEqual([true, false, false]);
});
+487 -231
View File
@@ -1,8 +1,25 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import {
FolderOpen, History, DownloadCloud, Film, Save, ChevronDown, ChevronUp,
Fingerprint, Wand2, Lock, Unlock, Trash2, Check, Clock, Play, Loader,
Download as DownloadIcon, Volume2, Search, X,
FolderOpen,
History,
DownloadCloud,
Film,
Save,
ChevronDown,
ChevronUp,
Fingerprint,
Wand2,
Lock,
Unlock,
Trash2,
Check,
Clock,
Play,
Loader,
Download as DownloadIcon,
Volume2,
Search,
X,
} from 'lucide-react';
import { toast } from 'react-hot-toast';
import { API } from '../api/client';
@@ -16,8 +33,8 @@ import './Sidebar.css';
import { askConfirm } from '../utils/dialog';
const SIDEBAR_TABS = [
{ id: 'projects', icon: FolderOpen, accent: '#b8bb26' },
{ id: 'history', icon: History, accent: '#d3869b' },
{ id: 'projects', icon: FolderOpen, accent: '#b8bb26' },
{ id: 'history', icon: History, accent: '#d3869b' },
{ id: 'downloads', icon: DownloadCloud, accent: '#8ec07c' },
];
@@ -33,9 +50,14 @@ function LazyWaveformPlayer({ height = 36, className = '', ...rest }) {
useEffect(() => {
if (visible) return;
const el = holderRef.current;
if (!el || typeof IntersectionObserver === 'undefined') { setVisible(true); return; }
if (!el || typeof IntersectionObserver === 'undefined') {
setVisible(true);
return;
}
const obs = new IntersectionObserver(
(entries) => { if (entries.some(e => e.isIntersecting)) setVisible(true); },
(entries) => {
if (entries.some((e) => e.isIntersecting)) setVisible(true);
},
{ rootMargin: '120px' },
);
obs.observe(el);
@@ -62,54 +84,85 @@ function timeAgo(ms) {
export default function Sidebar(props) {
const {
availableTabs = ['projects', 'history', 'downloads'],
isSidebarProjectsCollapsed, setIsSidebarProjectsCollapsed,
sidebarTab, setSidebarTab,
studioProjects, profiles, history, dubHistory, exportHistory,
isSidebarProjectsCollapsed,
setIsSidebarProjectsCollapsed,
sidebarTab,
setSidebarTab,
studioProjects,
profiles,
history,
dubHistory,
exportHistory,
dubVideoFile,
selectedProfile,
previewLoading,
saveProject, loadProject, deleteProject,
handleSelectProfile, handleDeleteProfile, handleOpenVoiceProfile,
handleUnlockProfile, handleLockProfile, handlePreviewVoice,
saveProject,
loadProject,
deleteProject,
handleSelectProfile,
handleDeleteProfile,
handleOpenVoiceProfile,
handleUnlockProfile,
handleLockProfile,
handlePreviewVoice,
onOpenVoicePreview,
restoreHistory, restoreDubHistory,
restoreHistory,
restoreDubHistory,
handleSaveHistoryAsProfile,
handleNativeExport, revealInFolder,
handleNativeExport,
revealInFolder,
deleteHistory,
loadHistory, loadDubHistory,
loadHistory,
loadDubHistory,
} = props;
// Phase 2.2 read UI + dub state straight from the store.
const mode = useAppStore(s => s.mode);
const mode = useAppStore((s) => s.mode);
// Voice ('studio') workspace's define method scopes which profiles show
// ('audio' = reference clones, 'design' = designed voices).
const defineMethod = useAppStore(s => s.defineMethod);
const isSidebarCollapsed = useAppStore(s => s.isSidebarCollapsed);
const dubStep = useAppStore(s => s.dubStep);
const activeProjectId = useAppStore(s => s.activeProjectId);
const defineMethod = useAppStore((s) => s.defineMethod);
const isSidebarCollapsed = useAppStore((s) => s.isSidebarCollapsed);
const dubStep = useAppStore((s) => s.dubStep);
const activeProjectId = useAppStore((s) => s.activeProjectId);
const { t } = useTranslation();
const [sbQuery, setSbQuery] = useState('');
const qLower = sbQuery.trim().toLowerCase();
const matchesSearch = (s) => !qLower || (s || '').toLowerCase().includes(qLower);
const filteredProjects = useMemo(() => studioProjects.filter(p =>
matchesSearch(p.name) || matchesSearch(p.video_path)
), [studioProjects, qLower]);
const filteredProfiles = useMemo(() => profiles.filter(p =>
matchesSearch(p.name) || matchesSearch(p.instruct)
), [profiles, qLower]);
const filteredHistory = useMemo(() => history.filter(i =>
matchesSearch(i.text) || matchesSearch(i.language) || matchesSearch(String(i.seed))
), [history, qLower]);
const filteredDubHistory = useMemo(() => dubHistory.filter(i =>
matchesSearch(i.filename) || matchesSearch(i.language) || matchesSearch(i.language_code)
), [dubHistory, qLower]);
const filteredExport = useMemo(() => exportHistory.filter(i =>
matchesSearch(i.filename) || matchesSearch(i.destination_path)
), [exportHistory, qLower]);
const filteredProjects = useMemo(
() => studioProjects.filter((p) => matchesSearch(p.name) || matchesSearch(p.video_path)),
[studioProjects, qLower],
);
const filteredProfiles = useMemo(
() => profiles.filter((p) => matchesSearch(p.name) || matchesSearch(p.instruct)),
[profiles, qLower],
);
const filteredHistory = useMemo(
() =>
history.filter(
(i) => matchesSearch(i.text) || matchesSearch(i.language) || matchesSearch(String(i.seed)),
),
[history, qLower],
);
const filteredDubHistory = useMemo(
() =>
dubHistory.filter(
(i) =>
matchesSearch(i.filename) || matchesSearch(i.language) || matchesSearch(i.language_code),
),
[dubHistory, qLower],
);
const filteredExport = useMemo(
() =>
exportHistory.filter((i) => matchesSearch(i.filename) || matchesSearch(i.destination_path)),
[exportHistory, qLower],
);
const handleClearHistory = async () => {
if (!(await askConfirm(t('sidebar.clear_confirm', { count: history.length + dubHistory.length })))) return;
if (
!(await askConfirm(t('sidebar.clear_confirm', { count: history.length + dubHistory.length })))
)
return;
await clearGenHistory();
await clearDubHistory();
await loadHistory();
@@ -118,28 +171,41 @@ export default function Sidebar(props) {
};
const tabCount = {
projects: mode === 'dub' ? studioProjects.length : (defineMethod === 'audio' ? profiles.filter(p => !p.instruct).length : profiles.filter(p => !!p.instruct).length),
projects:
mode === 'dub'
? studioProjects.length
: defineMethod === 'audio'
? profiles.filter((p) => !p.instruct).length
: profiles.filter((p) => !!p.instruct).length,
history: history.length + dubHistory.length,
downloads: exportHistory.length,
};
const tabLabel = { projects: t('sidebar.tab_drive'), history: t('sidebar.tab_history'), downloads: t('sidebar.tab_exports') };
const tabLabel = {
projects: t('sidebar.tab_drive'),
history: t('sidebar.tab_history'),
downloads: t('sidebar.tab_exports'),
};
return (
<div className={`glass-panel history-panel sidebar ${isSidebarCollapsed ? 'is-collapsed' : ''}`}>
<div
className={`glass-panel history-panel sidebar ${isSidebarCollapsed ? 'is-collapsed' : ''}`}
>
{/* Tab bar — only tabs relevant to the current view */}
<div className="sidebar__tabs">
{SIDEBAR_TABS.filter(t => availableTabs.includes(t.id)).map(({ id, icon: Icon, accent }) => (
<button
key={id}
onClick={() => setSidebarTab(id)}
className={`sidebar__tab ${sidebarTab === id ? 'is-active' : ''}`}
style={{ '--sidebar-tab-accent': accent }}
title={`${tabLabel[id]} (${tabCount[id]})`}
>
<Icon size={isSidebarCollapsed ? 18 : 13} />
{tabCount[id] > 0 && <span className="sidebar__tab-badge">{tabCount[id]}</span>}
</button>
))}
{SIDEBAR_TABS.filter((t) => availableTabs.includes(t.id)).map(
({ id, icon: Icon, accent }) => (
<button
key={id}
onClick={() => setSidebarTab(id)}
className={`sidebar__tab ${sidebarTab === id ? 'is-active' : ''}`}
style={{ '--sidebar-tab-accent': accent }}
title={`${tabLabel[id]} (${tabCount[id]})`}
>
<Icon size={isSidebarCollapsed ? 18 : 13} />
{tabCount[id] > 0 && <span className="sidebar__tab-badge">{tabCount[id]}</span>}
</button>
),
)}
</div>
{!isSidebarCollapsed && (
@@ -169,8 +235,9 @@ export default function Sidebar(props) {
{/* ── PROJECTS TAB ── */}
{sidebarTab === 'projects' && (
<>
{mode === 'dub' && (dubStep !== 'idle' || dubVideoFile) && (
isSidebarCollapsed ? (
{mode === 'dub' &&
(dubStep !== 'idle' || dubVideoFile) &&
(isSidebarCollapsed ? (
<Button
variant="subtle"
iconSize="md"
@@ -190,15 +257,20 @@ export default function Sidebar(props) {
>
{activeProjectId ? t('sidebar.save_project') : t('sidebar.save_new_project')}
</Button>
)
)}
))}
{!isSidebarCollapsed && (
<div
className="sidebar__section-title"
onClick={() => setIsSidebarProjectsCollapsed(!isSidebarProjectsCollapsed)}
>
<span>{mode === 'dub' ? t('sidebar.dub_projects') : (defineMethod === 'audio' ? t('sidebar.voice_clones') : t('sidebar.designed_voices'))}</span>
<span>
{mode === 'dub'
? t('sidebar.dub_projects')
: defineMethod === 'audio'
? t('sidebar.voice_clones')
: t('sidebar.designed_voices')}
</span>
{isSidebarProjectsCollapsed ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
</div>
)}
@@ -214,8 +286,9 @@ export default function Sidebar(props) {
hint={t('sidebar.no_dub_hint')}
/>
) : (
filteredProjects.map(proj => (
<div key={proj.id}
filteredProjects.map((proj) => (
<div
key={proj.id}
className={`history-item history-item--dub ${activeProjectId === proj.id ? 'project-active' : ''}`}
onClick={() => loadProject(proj.id)}
>
@@ -223,7 +296,10 @@ export default function Sidebar(props) {
<span className="history-kind history-kind--audio">
<Film size={9} /> {t('sidebar.dub_label')}
</span>
<span className="history-meta" title={new Date(proj.updated_at * 1000).toLocaleString()}>
<span
className="history-meta"
title={new Date(proj.updated_at * 1000).toLocaleString()}
>
{timeAgo(proj.updated_at * 1000)}
</span>
</div>
@@ -231,16 +307,31 @@ export default function Sidebar(props) {
<div className="history-subtitle">
{proj.duration ? `${Math.round(proj.duration)}s` : 'audio'}
{(() => {
const basename = proj.video_path ? proj.video_path.split(/[\\/]/).pop() : '';
const basename = proj.video_path
? proj.video_path.split(/[\\/]/).pop()
: '';
// Skip echoing the filename when it already matches the project name.
return basename && basename !== proj.name ? ` · ${basename}` : '';
})()}
</div>
<div className="history-actions">
<button className="history-action-btn accent" onClick={(e) => { e.stopPropagation(); loadProject(proj.id); }}>
<button
className="history-action-btn accent"
onClick={(e) => {
e.stopPropagation();
loadProject(proj.id);
}}
>
<FolderOpen size={10} /> {t('sidebar.open')}
</button>
<button className="history-action-btn danger history-action-icon" onClick={(e) => { e.stopPropagation(); deleteProject(proj.id); }} title="Delete">
<button
className="history-action-btn danger history-action-icon"
onClick={(e) => {
e.stopPropagation();
deleteProject(proj.id);
}}
title="Delete"
>
<Trash2 size={10} />
</button>
</div>
@@ -252,62 +343,133 @@ export default function Sidebar(props) {
{mode === 'studio' && (
<>
{filteredProfiles.filter(p => defineMethod === 'audio' ? !p.instruct : !!p.instruct).length === 0 ? (
{filteredProfiles.filter((p) =>
defineMethod === 'audio' ? !p.instruct : !!p.instruct,
).length === 0 ? (
<EmptyState
icon={defineMethod === 'audio' ? Fingerprint : Wand2}
title={`${defineMethod === 'audio' ? t('sidebar.no_clones') : t('sidebar.no_designs')}`}
hint={defineMethod === 'audio' ? t('sidebar.no_clones_hint') : t('sidebar.no_designs_hint')}
hint={
defineMethod === 'audio'
? t('sidebar.no_clones_hint')
: t('sidebar.no_designs_hint')
}
/>
) : (
(defineMethod === 'audio' ? filteredProfiles.filter(p => !p.instruct) : filteredProfiles.filter(p => !!p.instruct)).map(proj => {
const accent = proj.is_locked ? '#b8bb26' : (defineMethod === 'audio' ? '#d3869b' : '#8ec07c');
const KindIcon = proj.is_locked ? Lock : (defineMethod === 'audio' ? Fingerprint : Wand2);
(defineMethod === 'audio'
? filteredProfiles.filter((p) => !p.instruct)
: filteredProfiles.filter((p) => !!p.instruct)
).map((proj) => {
const accent = proj.is_locked
? '#b8bb26'
: defineMethod === 'audio'
? '#d3869b'
: '#8ec07c';
const KindIcon = proj.is_locked
? Lock
: defineMethod === 'audio'
? Fingerprint
: Wand2;
return (
<div key={proj.id}
<div
key={proj.id}
className={`history-item ${selectedProfile === proj.id ? 'project-active' : ''}`}
style={{ '--row-accent': accent }}
onClick={() => handleSelectProfile(proj)}
>
<div className="history-row-head">
<span className="history-kind" style={{ color: accent, borderColor: `${accent}40` }}>
<KindIcon size={9} /> {proj.is_locked ? t('sidebar.locked') : (defineMethod === 'audio' ? t('sidebar.clone_label') : t('sidebar.design_label'))}
<span
className="history-kind"
style={{ color: accent, borderColor: `${accent}40` }}
>
<KindIcon size={9} />{' '}
{proj.is_locked
? t('sidebar.locked')
: defineMethod === 'audio'
? t('sidebar.clone_label')
: t('sidebar.design_label')}
</span>
{proj.is_locked ? <span className="history-meta history-meta--locked">{t('sidebar.consistent')}</span> : null}
{proj.is_locked ? (
<span className="history-meta history-meta--locked">
{t('sidebar.consistent')}
</span>
) : null}
</div>
<div className="history-title">{proj.name}</div>
{proj.instruct ? <div className="history-subtitle history-subtitle--italic">{proj.instruct}</div> : null}
{proj.instruct ? (
<div className="history-subtitle history-subtitle--italic">
{proj.instruct}
</div>
) : null}
<div className="history-actions">
<button className="history-action-btn history-action-icon" onClick={(e) => { e.stopPropagation(); handlePreviewVoice(proj, e); }} title="Preview">
{previewLoading === proj.id ? <Loader className="spinner" size={10} /> : <Play size={10} />}
<button
className="history-action-btn history-action-icon"
onClick={(e) => {
e.stopPropagation();
handlePreviewVoice(proj, e);
}}
title="Preview"
>
{previewLoading === proj.id ? (
<Loader className="spinner" size={10} />
) : (
<Play size={10} />
)}
</button>
{handleOpenVoiceProfile && (
<button
className="history-action-btn"
onClick={(e) => { e.stopPropagation(); handleOpenVoiceProfile(proj.id); }}
onClick={(e) => {
e.stopPropagation();
handleOpenVoiceProfile(proj.id);
}}
title="Open full profile"
>
Open
</button>
)}
<button className="history-action-btn" onClick={(e) => { e.stopPropagation(); handleSelectProfile(proj); }}>
<button
className="history-action-btn"
onClick={(e) => {
e.stopPropagation();
handleSelectProfile(proj);
}}
>
<Check size={10} /> {t('sidebar.select')}
</button>
{onOpenVoicePreview && (
<button
className="history-action-btn accent"
onClick={(e) => { e.stopPropagation(); onOpenVoicePreview(proj.id); }}
onClick={(e) => {
e.stopPropagation();
onOpenVoicePreview(proj.id);
}}
title="Open interactive voice preview"
>
<Volume2 size={10} /> {t('sidebar.try_voice')}
</button>
)}
{proj.is_locked ? (
<button className="history-action-btn accent history-action-icon" onClick={(e) => { e.stopPropagation(); handleUnlockProfile(proj.id); }} title="Unlock">
<button
className="history-action-btn accent history-action-icon"
onClick={(e) => {
e.stopPropagation();
handleUnlockProfile(proj.id);
}}
title="Unlock"
>
<Unlock size={10} />
</button>
) : null}
<button className="history-action-btn danger history-action-icon" onClick={(e) => { e.stopPropagation(); handleDeleteProfile(proj.id); }} title="Delete">
<button
className="history-action-btn danger history-action-icon"
onClick={(e) => {
e.stopPropagation();
handleDeleteProfile(proj.id);
}}
title="Delete"
>
<Trash2 size={10} />
</button>
</div>
@@ -320,38 +482,47 @@ export default function Sidebar(props) {
</>
)}
{isSidebarCollapsed && mode === 'dub' && filteredProjects.map(proj => (
<IconTile
key={proj.id}
title={`Load: ${proj.name}`}
onClick={() => loadProject(proj.id)}
active={activeProjectId === proj.id}
rotSeed={proj.id}
>
<Film size={18} />
</IconTile>
))}
{isSidebarCollapsed &&
mode === 'dub' &&
filteredProjects.map((proj) => (
<IconTile
key={proj.id}
title={`Load: ${proj.name}`}
onClick={() => loadProject(proj.id)}
active={activeProjectId === proj.id}
rotSeed={proj.id}
>
<Film size={18} />
</IconTile>
))}
{isSidebarCollapsed && mode === 'studio' && (defineMethod === 'audio' ? filteredProfiles.filter(p => !p.instruct) : filteredProfiles.filter(p => !!p.instruct)).map(proj => (
<IconTile
key={proj.id}
title={`Select: ${proj.name}`}
onClick={() => handleSelectProfile(proj)}
active={selectedProfile === proj.id}
rotSeed={proj.id}
>
{defineMethod === 'audio' ? <Fingerprint size={18} /> : <Wand2 size={18} />}
{proj.is_locked && <Lock size={8} className="sidebar__icon-tile__lock" />}
</IconTile>
))}
{isSidebarCollapsed &&
mode === 'studio' &&
(defineMethod === 'audio'
? filteredProfiles.filter((p) => !p.instruct)
: filteredProfiles.filter((p) => !!p.instruct)
).map((proj) => (
<IconTile
key={proj.id}
title={`Select: ${proj.name}`}
onClick={() => handleSelectProfile(proj)}
active={selectedProfile === proj.id}
rotSeed={proj.id}
>
{defineMethod === 'audio' ? <Fingerprint size={18} /> : <Wand2 size={18} />}
{proj.is_locked && <Lock size={8} className="sidebar__icon-tile__lock" />}
</IconTile>
))}
</>
)}
{/* ── HISTORY TAB ── */}
{sidebarTab === 'history' && (
<>
{!isSidebarCollapsed && <div className="sidebar__subtitle">{t('sidebar.history_subtitle')}</div>}
{(history.length + dubHistory.length) === 0 ? (
{!isSidebarCollapsed && (
<div className="sidebar__subtitle">{t('sidebar.history_subtitle')}</div>
)}
{history.length + dubHistory.length === 0 ? (
<EmptyState
icon={History}
title={t('sidebar.no_history')}
@@ -359,110 +530,175 @@ export default function Sidebar(props) {
/>
) : (
<>
{!isSidebarCollapsed && filteredDubHistory.map(item => (
<div key={`dub-${item.id}`} className="history-item history-item--dub"
onClick={() => restoreDubHistory(item)}
>
<div className="history-row-head">
<span className="history-kind history-kind--audio">
<Film size={9} /> {t('sidebar.dub_label')}
</span>
<span className="history-meta">{item.segments_count} segs · {Math.round(item.duration || 0)}s</span>
</div>
<div className="history-title">{item.filename}</div>
<div className="history-subtitle">
{[item.language, item.language_code].filter(v => v && v !== 'und' && v !== 'Auto').join(' · ') || 'Auto'}
</div>
<div className="history-actions">
<button className="history-action-btn accent" onClick={(e) => { e.stopPropagation(); restoreDubHistory(item); }}>
<FolderOpen size={10} /> {t('sidebar.open')}
</button>
<button className="history-action-btn danger history-action-icon" onClick={(e) => { e.stopPropagation(); deleteHistory(item.id, 'dub'); }} title="Delete">
<Trash2 size={10} />
</button>
</div>
</div>
))}
{!isSidebarCollapsed && filteredHistory.map(item => {
const accent = item.mode === 'clone' ? '#d3869b' : '#b8bb26';
const KindIcon = item.mode === 'clone' ? Fingerprint : Wand2;
return (
<div key={item.id} className="history-item" style={{ '--row-accent': accent }}>
{!isSidebarCollapsed &&
filteredDubHistory.map((item) => (
<div
key={`dub-${item.id}`}
className="history-item history-item--dub"
onClick={() => restoreDubHistory(item)}
>
<div className="history-row-head">
<span className="history-kind" style={{ color: accent, borderColor: `${accent}40` }}>
<KindIcon size={9} /> {item.mode || 'synth'}
<span className="history-kind history-kind--audio">
<Film size={9} /> {t('sidebar.dub_label')}
</span>
<span className="history-meta">
{item.language && item.language !== 'Auto' ? `${item.language} · ` : ''}
{item.generation_time ? `${item.generation_time}s` : ''}
{item.segments_count} segs · {Math.round(item.duration || 0)}s
</span>
</div>
<div className="history-title history-title--clamp" title={item.text}>
{item.text}
<div className="history-title">{item.filename}</div>
<div className="history-subtitle">
{[item.language, item.language_code]
.filter((v) => v && v !== 'und' && v !== 'Auto')
.join(' · ') || 'Auto'}
</div>
<div className="history-actions">
<button
className="history-action-btn accent"
onClick={(e) => {
e.stopPropagation();
restoreDubHistory(item);
}}
>
<FolderOpen size={10} /> {t('sidebar.open')}
</button>
<button
className="history-action-btn danger history-action-icon"
onClick={(e) => {
e.stopPropagation();
deleteHistory(item.id, 'dub');
}}
title="Delete"
>
<Trash2 size={10} />
</button>
</div>
{item.seed != null && String(item.seed) !== ''
? <div className="history-subtitle history-subtitle--seed">seed {item.seed}</div>
: null}
{item.audio_path ? (
<LazyWaveformPlayer
src={`${API}/audio/${item.audio_path}`}
source="history"
height={36}
compact
className="history-audio"
/>
) : null}
{item.audio_path ? (
<div className="history-actions">
<button className="history-action-btn accent" onClick={(e) => { e.stopPropagation(); handleSaveHistoryAsProfile(item); }}>
<Save size={10} /> {t('sidebar.save_label')}
</button>
{item.profile_id ? (
<button className="history-action-btn accent history-action-icon"
onClick={(e) => { e.stopPropagation(); handleLockProfile(item.profile_id, item.id, item.seed); }}
title={t('sidebar.lock_identity')}>
<Lock size={10} />
</button>
) : null}
<button className="history-action-btn history-action-icon"
onClick={(e) => handleNativeExport(e, item.audio_path, item.audio_path, item.mode)}
title="Export">
<DownloadIcon size={10} />
</button>
<button className="history-action-btn history-action-icon"
onClick={(e) => { e.stopPropagation(); restoreHistory(item); }}
title="Load config">
<FolderOpen size={10} />
</button>
<button className="history-action-btn danger history-action-icon"
onClick={(e) => { e.stopPropagation(); deleteHistory(item.id, 'synth'); }}
title="Delete">
<Trash2 size={10} />
</button>
</div>
) : null}
</div>
);
})}
))}
{!isSidebarCollapsed &&
filteredHistory.map((item) => {
const accent = item.mode === 'clone' ? '#d3869b' : '#b8bb26';
const KindIcon = item.mode === 'clone' ? Fingerprint : Wand2;
return (
<div
key={item.id}
className="history-item"
style={{ '--row-accent': accent }}
>
<div className="history-row-head">
<span
className="history-kind"
style={{ color: accent, borderColor: `${accent}40` }}
>
<KindIcon size={9} /> {item.mode || 'synth'}
</span>
<span className="history-meta">
{item.language && item.language !== 'Auto' ? `${item.language} · ` : ''}
{item.generation_time ? `${item.generation_time}s` : ''}
</span>
</div>
<div className="history-title history-title--clamp" title={item.text}>
{item.text}
</div>
{item.seed != null && String(item.seed) !== '' ? (
<div className="history-subtitle history-subtitle--seed">
seed {item.seed}
</div>
) : null}
{item.audio_path ? (
<LazyWaveformPlayer
src={`${API}/audio/${item.audio_path}`}
source="history"
height={36}
compact
className="history-audio"
/>
) : null}
{item.audio_path ? (
<div className="history-actions">
<button
className="history-action-btn accent"
onClick={(e) => {
e.stopPropagation();
handleSaveHistoryAsProfile(item);
}}
>
<Save size={10} /> {t('sidebar.save_label')}
</button>
{item.profile_id ? (
<button
className="history-action-btn accent history-action-icon"
onClick={(e) => {
e.stopPropagation();
handleLockProfile(item.profile_id, item.id, item.seed);
}}
title={t('sidebar.lock_identity')}
>
<Lock size={10} />
</button>
) : null}
<button
className="history-action-btn history-action-icon"
onClick={(e) =>
handleNativeExport(e, item.audio_path, item.audio_path, item.mode)
}
title="Export"
>
<DownloadIcon size={10} />
</button>
<button
className="history-action-btn history-action-icon"
onClick={(e) => {
e.stopPropagation();
restoreHistory(item);
}}
title="Load config"
>
<FolderOpen size={10} />
</button>
<button
className="history-action-btn danger history-action-icon"
onClick={(e) => {
e.stopPropagation();
deleteHistory(item.id, 'synth');
}}
title="Delete"
>
<Trash2 size={10} />
</button>
</div>
) : null}
</div>
);
})}
</>
)}
{isSidebarCollapsed && filteredDubHistory.map(item => (
<div key={`dub-${item.id}`} title={`Dub: ${item.filename}`} onClick={() => restoreDubHistory(item)}
className="sidebar-tile sidebar-tile--audio">
<Film size={18} />
</div>
))}
{isSidebarCollapsed &&
filteredDubHistory.map((item) => (
<div
key={`dub-${item.id}`}
title={`Dub: ${item.filename}`}
onClick={() => restoreDubHistory(item)}
className="sidebar-tile sidebar-tile--audio"
>
<Film size={18} />
</div>
))}
{isSidebarCollapsed && filteredHistory.map(item => (
<div key={item.id} title={`${item.mode || 'history'}: ${item.text}`} onClick={() => restoreHistory(item)}
className={`sidebar-tile ${item.mode === 'clone' ? 'sidebar-tile--clone' : 'sidebar-tile--design'}`}>
{item.mode === 'clone' ? <Fingerprint size={18} /> : <Wand2 size={18} />}
</div>
))}
{isSidebarCollapsed &&
filteredHistory.map((item) => (
<div
key={item.id}
title={`${item.mode || 'history'}: ${item.text}`}
onClick={() => restoreHistory(item)}
className={`sidebar-tile ${item.mode === 'clone' ? 'sidebar-tile--clone' : 'sidebar-tile--design'}`}
>
{item.mode === 'clone' ? <Fingerprint size={18} /> : <Wand2 size={18} />}
</div>
))}
{(history.length + dubHistory.length) > 0 && !isSidebarCollapsed && (
{history.length + dubHistory.length > 0 && !isSidebarCollapsed && (
<Button
variant="ghost"
size="sm"
@@ -480,7 +716,9 @@ export default function Sidebar(props) {
{/* ── DOWNLOADS TAB ── */}
{sidebarTab === 'downloads' && (
<>
{!isSidebarCollapsed && <div className="sidebar__subtitle">{t('sidebar.recent_exports')}</div>}
{!isSidebarCollapsed && (
<div className="sidebar__subtitle">{t('sidebar.recent_exports')}</div>
)}
{exportHistory.length === 0 ? (
<EmptyState
icon={DownloadCloud}
@@ -489,41 +727,59 @@ export default function Sidebar(props) {
/>
) : (
<>
{!isSidebarCollapsed && filteredExport.map(item => {
const pathParts = item.destination_path.split(/[\\/]/);
const parentFolder = pathParts.length > 1 ? pathParts[pathParts.length - 2] : '…';
const accent = item.mode === 'audio' ? '#83a598' : '#8ec07c';
const KindIcon = item.mode === 'audio' ? Volume2 : Film;
return (
<div key={item.id} className="history-item"
style={{ '--row-accent': accent }}
onClick={() => revealInFolder(item.destination_path)}
>
<div className="history-row-head">
<span className="history-kind" style={{ color: accent, borderColor: `${accent}40` }}>
<KindIcon size={9} /> {item.mode}
</span>
<span className="history-meta">{timeAgo(item.created_at * 1000)}</span>
{!isSidebarCollapsed &&
filteredExport.map((item) => {
const pathParts = item.destination_path.split(/[\\/]/);
const parentFolder =
pathParts.length > 1 ? pathParts[pathParts.length - 2] : '…';
const accent = item.mode === 'audio' ? '#83a598' : '#8ec07c';
const KindIcon = item.mode === 'audio' ? Volume2 : Film;
return (
<div
key={item.id}
className="history-item"
style={{ '--row-accent': accent }}
onClick={() => revealInFolder(item.destination_path)}
>
<div className="history-row-head">
<span
className="history-kind"
style={{ color: accent, borderColor: `${accent}40` }}
>
<KindIcon size={9} /> {item.mode}
</span>
<span className="history-meta">{timeAgo(item.created_at * 1000)}</span>
</div>
<div className="history-title">{item.filename}</div>
<div className="history-subtitle">
{t('sidebar.in_folder', { folder: parentFolder })}
</div>
<div className="history-actions">
<button
className="history-action-btn accent"
onClick={(e) => {
e.stopPropagation();
revealInFolder(item.destination_path);
}}
>
<FolderOpen size={10} /> {t('sidebar.show_in_folder')}
</button>
</div>
</div>
<div className="history-title">{item.filename}</div>
<div className="history-subtitle">{t('sidebar.in_folder', { folder: parentFolder })}</div>
<div className="history-actions">
<button className="history-action-btn accent" onClick={(e) => { e.stopPropagation(); revealInFolder(item.destination_path); }}>
<FolderOpen size={10} /> {t('sidebar.show_in_folder')}
</button>
</div>
</div>
);
})}
);
})}
{isSidebarCollapsed && filteredExport.map(item => (
<div key={item.id} title={`Exported: ${item.filename}\nClick to open folder`}
onClick={() => revealInFolder(item.destination_path)}
className={`sidebar-tile ${item.mode === 'audio' ? 'sidebar-tile--audio' : 'sidebar-tile--success'}`}
>
<FolderOpen size={18} />
</div>
))}
{isSidebarCollapsed &&
filteredExport.map((item) => (
<div
key={item.id}
title={`Exported: ${item.filename}\nClick to open folder`}
onClick={() => revealInFolder(item.destination_path)}
className={`sidebar-tile ${item.mode === 'audio' ? 'sidebar-tile--audio' : 'sidebar-tile--success'}`}
>
<FolderOpen size={18} />
</div>
))}
</>
)}
</>
File diff suppressed because it is too large Load Diff
@@ -86,16 +86,12 @@ export default function SupertonicLicenseDialog({ open, onClose, onAccepted }) {
{t('license.title')}
</h2>
<p className="supertonic-license__intro">
{t('license.intro')}
</p>
<p className="supertonic-license__intro">{t('license.intro')}</p>
<div className="supertonic-license__sections">
<section className="supertonic-license__section">
<h3>{t('license.sdk_heading')}</h3>
<p>
{t('license.sdk_desc')}
</p>
<p>{t('license.sdk_desc')}</p>
<a
href={LICENSE_URLS.code}
target="_blank"
@@ -108,9 +104,7 @@ export default function SupertonicLicenseDialog({ open, onClose, onAccepted }) {
<section className="supertonic-license__section">
<h3>{t('license.model_heading')}</h3>
<p>
{t('license.model_desc')}
</p>
<p>{t('license.model_desc')}</p>
<a
href={LICENSE_URLS.model}
target="_blank"
@@ -122,9 +116,7 @@ export default function SupertonicLicenseDialog({ open, onClose, onAccepted }) {
</section>
</div>
<p className="supertonic-license__footer">
{t('license.footer')}
</p>
<p className="supertonic-license__footer">{t('license.footer')}</p>
<div className="supertonic-license__actions">
<button
@@ -149,4 +141,3 @@ export default function SupertonicLicenseDialog({ open, onClose, onAccepted }) {
</div>
);
}
+42 -16
View File
@@ -35,24 +35,35 @@ export default function TranscriptionPicker({ open, onClose, onPick }) {
if (diff < 60000) return t('transcriptions.just_now');
if (diff < 3600000) return t('transcriptions.m_ago', { count: Math.floor(diff / 60000) });
if (diff < 86400000) return t('transcriptions.h_ago', { count: Math.floor(diff / 3600000) });
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
// Per-row display normalization; hide empty-text rows entirely.
const rows = useMemo(() => entries
.map((e, idx) => ({
entry: e,
key: e?.id ?? idx,
text: String(e?.text ?? ''),
language: e?.language && e.language !== 'unknown' ? e.language : null,
duration: typeof e?.duration_s === 'number' && e.duration_s > 0 ? e.duration_s : null,
time: formatTime(e?.timestamp),
}))
.filter((r) => r.text.trim()), [entries]); // eslint-disable-line react-hooks/exhaustive-deps
const rows = useMemo(
() =>
entries
.map((e, idx) => ({
entry: e,
key: e?.id ?? idx,
text: String(e?.text ?? ''),
language: e?.language && e.language !== 'unknown' ? e.language : null,
duration: typeof e?.duration_s === 'number' && e.duration_s > 0 ? e.duration_s : null,
time: formatTime(e?.timestamp),
}))
.filter((r) => r.text.trim()),
[entries],
); // eslint-disable-line react-hooks/exhaustive-deps
const q = search.trim().toLowerCase();
const visible = q
? rows.filter((r) => r.text.toLowerCase().includes(q) || (r.language || '').toLowerCase().includes(q))
? rows.filter(
(r) => r.text.toLowerCase().includes(q) || (r.language || '').toLowerCase().includes(q),
)
: rows;
return (
@@ -73,7 +84,11 @@ export default function TranscriptionPicker({ open, onClose, onPick }) {
{visible.length === 0 ? (
<div className="txn-picker__empty">
<Mic size={24} />
<p>{rows.length === 0 ? t('transcriptionPicker.empty') : t('transcriptionPicker.empty_search')}</p>
<p>
{rows.length === 0
? t('transcriptionPicker.empty')
: t('transcriptionPicker.empty_search')}
</p>
</div>
) : (
<div className="txn-picker__list" role="list">
@@ -82,14 +97,25 @@ export default function TranscriptionPicker({ open, onClose, onPick }) {
type="button"
key={r.key}
className="txn-picker__row"
onClick={() => { onPick(r.entry); onClose(); }}
onClick={() => {
onPick(r.entry);
onClose();
}}
>
<span className="txn-picker__text">
{r.text.length > 120 ? `${r.text.slice(0, 120)}` : r.text}
</span>
<span className="txn-picker__meta">
{r.time && <span><Clock size={10} /> {r.time}</span>}
{r.language && <span><Languages size={10} /> {r.language}</span>}
{r.time && (
<span>
<Clock size={10} /> {r.time}
</span>
)}
{r.language && (
<span>
<Languages size={10} /> {r.language}
</span>
)}
{r.duration && <span>{r.duration.toFixed(1)}s</span>}
</span>
</button>
+51 -13
View File
@@ -25,11 +25,16 @@ export default function UpdatesPanel() {
const dismissUpdate = useAppStore((s) => s.dismissUpdate);
const dubStep = useAppStore((s) => s.dubStep);
useEffect(() => { loadReleases(channel); }, [channel, loadReleases]);
useEffect(() => {
loadReleases(channel);
}, [channel, loadReleases]);
const busy = dubStep === 'generating';
const onInstall = () => {
if (busy) { toast(t('update.busy'), { icon: '⏳' }); return; }
if (busy) {
toast(t('update.busy'), { icon: '⏳' });
return;
}
installUpdate(useAppStore.getState());
};
const rows = prepareReleases(releases, channel, appVersion);
@@ -39,13 +44,16 @@ export default function UpdatesPanel() {
<div className="updates-panel__live">
{status === 'available' && (
<button className="updates-panel__cta" onClick={onInstall}>
<Download size={13} /> {t('update.available', { version: version || '' })} · {t('update.install')}
<Download size={13} /> {t('update.available', { version: version || '' })} ·{' '}
{t('update.install')}
</button>
)}
{status === 'downloading' && (
<span className="updates-panel__progress">
{t('update.downloading', { pct: Math.round(progress) })}
<span className="updates-panel__bar"><span style={{ width: `${progress}%` }} /></span>
<span className="updates-panel__bar">
<span style={{ width: `${progress}%` }} />
</span>
</span>
)}
{status === 'ready' && (
@@ -56,14 +64,25 @@ export default function UpdatesPanel() {
{status === 'error' && (
<span className="updates-panel__err">
<AlertTriangle size={13} /> {error || t('update.failed')}
<button className="updates-panel__link" onClick={onInstall}>{t('update.retry')}</button>
<button className="updates-panel__icon" onClick={dismissUpdate} aria-label={t('update.dismiss')}><X size={13} /></button>
<button className="updates-panel__link" onClick={onInstall}>
{t('update.retry')}
</button>
<button
className="updates-panel__icon"
onClick={dismissUpdate}
aria-label={t('update.dismiss')}
>
<X size={13} />
</button>
</span>
)}
{(status === 'idle' || status === 'checking') && (
<span className="updates-panel__ok">
{t('updates.up_to_date', { version: appVersion || '' })}
<button className="updates-panel__link" onClick={() => checkForUpdate(useAppStore.getState())}>
<button
className="updates-panel__link"
onClick={() => checkForUpdate(useAppStore.getState())}
>
<RefreshCw size={12} /> {t('updates.check_now')}
</button>
</span>
@@ -72,7 +91,11 @@ export default function UpdatesPanel() {
<div className="updates-panel__channel">
<span>{t('about.update_channel')}</span>
<div className="updates-panel__seg" role="radiogroup" aria-label={t('about.update_channel')}>
<div
className="updates-panel__seg"
role="radiogroup"
aria-label={t('about.update_channel')}
>
{['stable', 'preview'].map((c) => (
<button
key={c}
@@ -80,7 +103,13 @@ export default function UpdatesPanel() {
role="radio"
aria-checked={channel === c}
className={`updates-panel__segbtn ${channel === c ? 'is-active' : ''}`}
onClick={() => setChannel(useAppStore.getState(), c).catch((e) => toast(t('settings.channel_set_failed', { message: e?.message || e }), { icon: '⚠️' }))}
onClick={() =>
setChannel(useAppStore.getState(), c).catch((e) =>
toast(t('settings.channel_set_failed', { message: e?.message || e }), {
icon: '⚠️',
}),
)
}
>
{t(`about.channel_${c}`)}
</button>
@@ -93,19 +122,28 @@ export default function UpdatesPanel() {
{releasesStatus === 'error' && (
<div className="updates-panel__rel-empty">
{t('updates.load_error')}
<button className="updates-panel__link" onClick={() => loadReleases(channel)}>{t('updates.retry_load')}</button>
<button className="updates-panel__link" onClick={() => loadReleases(channel)}>
{t('updates.retry_load')}
</button>
</div>
)}
{releasesStatus === 'loading' && <div className="updates-panel__rel-empty">{t('updates.loading')}</div>}
{releasesStatus === 'loading' && (
<div className="updates-panel__rel-empty">{t('updates.loading')}</div>
)}
{releasesStatus === 'loaded' && rows.length === 0 && (
<div className="updates-panel__rel-empty">{t('updates.none')}</div>
)}
{rows.map((r) => (
<div key={r.name || r.version} className={`updates-panel__rel ${r.current ? 'is-current' : ''}`}>
<div
key={r.name || r.version}
className={`updates-panel__rel ${r.current ? 'is-current' : ''}`}
>
<div className="updates-panel__rel-row">
<span className="updates-panel__rel-ver">v{r.version}</span>
{r.current && <span className="updates-panel__rel-tag">{t('updates.current')}</span>}
{r.prerelease && <span className="updates-panel__rel-pre">{t('updates.prerelease')}</span>}
{r.prerelease && (
<span className="updates-panel__rel-pre">{t('updates.prerelease')}</span>
)}
<span className="updates-panel__rel-date">{r.date}</span>
</div>
{r.notes && <pre className="updates-panel__rel-notes">{r.notes}</pre>}
+29 -18
View File
@@ -25,7 +25,7 @@ export default function VoicePreview({
fileToMediaUrl,
}) {
const { t } = useTranslation();
const autoPlayPreview = useAppStore(s => s.autoPlayPreview);
const autoPlayPreview = useAppStore((s) => s.autoPlayPreview);
const [text, setText] = useState(() => t('voicePreview.default_text'));
const [voiceId, setVoiceId] = useState(initialProfileId);
const [audioUrl, setAudioUrl] = useState(null);
@@ -48,7 +48,7 @@ export default function VoicePreview({
try {
const fd = new FormData();
fd.append('text', text);
fd.append('num_step', '8'); // fast preview
fd.append('num_step', '8'); // fast preview
fd.append('guidance_scale', '2.0');
fd.append('speed', '1.0');
fd.append('denoise', 'true');
@@ -58,13 +58,15 @@ export default function VoicePreview({
let instruct = '';
if (profileId.startsWith('preset:')) {
const pr = PRESETS.find(p => p.id === profileId.replace('preset:', ''));
const pr = PRESETS.find((p) => p.id === profileId.replace('preset:', ''));
if (pr) {
instruct = Object.values(pr.attrs).filter(v => v !== 'Auto').join(', ');
instruct = Object.values(pr.attrs)
.filter((v) => v !== 'Auto')
.join(', ');
}
profileId = '';
} else {
const match = profiles.find(p => p.id === profileId);
const match = profiles.find((p) => p.id === profileId);
if (match?.instruct) instruct = match.instruct;
}
@@ -115,27 +117,37 @@ export default function VoicePreview({
<select
className="input-base voice-preview__select"
value={voiceId}
onChange={e => setVoiceId(e.target.value)}
onChange={(e) => setVoiceId(e.target.value)}
>
<option value="">{t('voicePreview.default_voice')}</option>
{profiles.filter(p => !p.instruct).length > 0 && (
{profiles.filter((p) => !p.instruct).length > 0 && (
<optgroup label={t('voicePreview.clone_profiles')}>
{profiles.filter(p => !p.instruct).map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
{profiles
.filter((p) => !p.instruct)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</optgroup>
)}
{profiles.filter(p => !!p.instruct).length > 0 && (
{profiles.filter((p) => !!p.instruct).length > 0 && (
<optgroup label={t('voicePreview.designed_voices')}>
{profiles.filter(p => !!p.instruct).map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
{profiles
.filter((p) => !!p.instruct)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</optgroup>
)}
{PRESETS.length > 0 && (
<optgroup label={t('voicePreview.presets')}>
{PRESETS.map(p => (
<option key={p.id} value={`preset:${p.id}`}>{p.name}</option>
{PRESETS.map((p) => (
<option key={p.id} value={`preset:${p.id}`}>
{p.name}
</option>
))}
</optgroup>
)}
@@ -144,7 +156,7 @@ export default function VoicePreview({
<textarea
className="input-base voice-preview__text"
value={text}
onChange={e => setText(e.target.value)}
onChange={(e) => setText(e.target.value)}
rows={2}
placeholder={t('voicePreview.placeholder')}
spellCheck={false}
@@ -182,4 +194,3 @@ export default function VoicePreview({
</div>
);
}
+5 -3
View File
@@ -156,9 +156,11 @@ export default function VoiceSelector({
aria-label={t('voiceSelector.preview')}
title={t('voiceSelector.preview')}
>
{previewLoading
? <Loader size={13} className="voice-selector__spin" />
: <Play size={13} />}
{previewLoading ? (
<Loader size={13} className="voice-selector__spin" />
) : (
<Play size={13} />
)}
</button>
)}
{onJumpToGallery && (
+118 -36
View File
@@ -32,7 +32,7 @@ const fmt = (s) => {
export default function WaveformPlayer({
src,
source = 'output', // global-playback-manager label
source = 'output', // global-playback-manager label
autoPlay = false,
height = 44,
compact = false,
@@ -41,15 +41,17 @@ export default function WaveformPlayer({
}) {
const containerRef = useRef(null);
const nativeRef = useRef(null);
const mediaRef = useRef(null); // in-DOM <audio> driven by WaveSurfer
const mediaRef = useRef(null); // in-DOM <audio> driven by WaveSurfer
const wsRef = useRef(null);
const releaseRef = useRef(null);
const autoPlayRef = useRef(autoPlay);
useEffect(() => { autoPlayRef.current = autoPlay; }, [autoPlay]);
useEffect(() => {
autoPlayRef.current = autoPlay;
}, [autoPlay]);
const [resolvedUrl, setResolvedUrl] = useState(null);
const [, setReady] = useState(false);
const [failed, setFailed] = useState(false); // WaveSurfer unavailable native fallback
const [failed, setFailed] = useState(false); // WaveSurfer unavailable native fallback
const [missing, setMissing] = useState(false); // source 404s (stale history) inert notice
const [isPlaying, setIsPlaying] = useState(false);
const [duration, setDuration] = useState(0);
@@ -71,16 +73,29 @@ export default function WaveformPlayer({
import('../utils/aec/playbackTap')
.then(({ attachPlaybackTap }) => attachPlaybackTap(el))
.then((detach) => {
if (cancelled) { try { detach?.(); } catch { /* ignore */ } }
else { tapDetachRef.current = detach; }
if (cancelled) {
try {
detach?.();
} catch {
/* ignore */
}
} else {
tapDetachRef.current = detach;
}
})
.catch(() => { /* Web Audio unavailable — dictation still works sans AEC */ });
.catch(() => {
/* Web Audio unavailable — dictation still works sans AEC */
});
}
return () => {
cancelled = true;
const d = tapDetachRef.current;
tapDetachRef.current = null;
try { d?.(); } catch { /* ignore */ }
try {
d?.();
} catch {
/* ignore */
}
};
}, [aecEnabled, isPlaying]);
@@ -88,14 +103,26 @@ export default function WaveformPlayer({
// URLs don't play in WebKit media elements, so blobs are routed through the
// backend preview endpoint (same path the rest of the app uses).
useEffect(() => {
if (!src) { setResolvedUrl(null); return; }
if (typeof src === 'string') { setResolvedUrl(src); return; }
if (!src) {
setResolvedUrl(null);
return;
}
if (typeof src === 'string') {
setResolvedUrl(src);
return;
}
if (isTauri) {
let cancelled = false;
fileToMediaUrl(src, null)
.then(urls => { if (!cancelled) setResolvedUrl(urls.audioUrl); })
.catch(() => { if (!cancelled) setResolvedUrl(URL.createObjectURL(src)); });
return () => { cancelled = true; };
.then((urls) => {
if (!cancelled) setResolvedUrl(urls.audioUrl);
})
.catch(() => {
if (!cancelled) setResolvedUrl(URL.createObjectURL(src));
});
return () => {
cancelled = true;
};
}
const u = URL.createObjectURL(src);
setResolvedUrl(u);
@@ -106,21 +133,24 @@ export default function WaveformPlayer({
useEffect(() => {
setMissing(false); // a new url gets a fresh chance
if (!resolvedUrl || failed || !containerRef.current || !mediaRef.current) return;
setReady(false); setIsPlaying(false); setDuration(0); setCurrentTime(0);
setReady(false);
setIsPlaying(false);
setDuration(0);
setCurrentTime(0);
let ws;
try {
ws = WaveSurfer.create({
container: containerRef.current,
waveColor: 'rgba(168,153,132,0.45)',
container: containerRef.current,
waveColor: 'rgba(168,153,132,0.45)',
progressColor: 'rgba(211,134,155,0.75)',
cursorColor: '#d3869b',
cursorWidth: 2,
cursorColor: '#d3869b',
cursorWidth: 2,
height,
barWidth: 2,
barGap: 1,
barRadius: 2,
normalize: true,
barWidth: 2,
barGap: 1,
barRadius: 2,
normalize: true,
// Drive a REAL in-DOM <audio> element instead of letting WaveSurfer
// create a detached one: Tauri's WebKit decodes (peaks render) but
// won't actually output sound for detached/blob-backed media the
@@ -129,7 +159,7 @@ export default function WaveformPlayer({
// with an external `media`, wavesurfer only fetches `url` for peaks
// and never assigns it to the element, leaving play() with nothing
// to play (waveform drew, click did nothing).
media: mediaRef.current,
media: mediaRef.current,
});
} catch (initErr) {
console.warn('WaveformPlayer: WaveSurfer init failed, native fallback:', initErr);
@@ -150,24 +180,38 @@ export default function WaveformPlayer({
setReady(true);
if (autoPlayRef.current) ws.play().catch(() => {});
});
ws.on('timeupdate', (t) => { if (!stale) setCurrentTime(t); });
ws.on('timeupdate', (t) => {
if (!stale) setCurrentTime(t);
});
ws.on('play', () => {
if (stale) return;
setIsPlaying(true);
// Idempotent: duplicate 'play' events must not re-claim claiming
// stops the current owner, which would be this very element.
if (!releaseRef.current) {
releaseRef.current = claimPlayback(() => { try { ws.pause(); } catch { /* noop */ } }, source);
releaseRef.current = claimPlayback(() => {
try {
ws.pause();
} catch {
/* noop */
}
}, source);
}
});
ws.on('pause', () => {
if (stale) return;
setIsPlaying(false);
if (releaseRef.current) { releaseRef.current(); releaseRef.current = null; }
if (releaseRef.current) {
releaseRef.current();
releaseRef.current = null;
}
});
ws.on('finish', () => {
setIsPlaying(false);
if (releaseRef.current) { releaseRef.current(); releaseRef.current = null; }
if (releaseRef.current) {
releaseRef.current();
releaseRef.current = null;
}
if (onEnded) onEnded();
});
ws.on('error', (err) => {
@@ -188,11 +232,22 @@ export default function WaveformPlayer({
return () => {
stale = true;
if (releaseRef.current) { releaseRef.current(); releaseRef.current = null; }
if (releaseRef.current) {
releaseRef.current();
releaseRef.current = null;
}
// Detach our handlers BEFORE destroy if destroy throws mid-teardown
// (the swallowed catch below) the listeners must already be gone.
try { ws.unAll(); } catch { /* noop */ }
try { ws.destroy(); } catch { /* already gone */ }
try {
ws.unAll();
} catch {
/* noop */
}
try {
ws.destroy();
} catch {
/* already gone */
}
wsRef.current = null;
};
}, [resolvedUrl, failed, height, source, onEnded]);
@@ -202,7 +257,11 @@ export default function WaveformPlayer({
// AudioContext starts suspended until a user gesture. This click IS the
// gesture explicitly resume so the subsequent play() succeeds. No-op
// on macOS where the context was never blocked.
try { await unlockAudio(); } catch { /* ignore — play() will surface errors */ }
try {
await unlockAudio();
} catch {
/* ignore — play() will surface errors */
}
// playPause is async a swallowed rejection here is exactly how the
// "click does nothing" bug hid; log it so playback failures are visible.
try {
@@ -217,7 +276,9 @@ export default function WaveformPlayer({
// Source file no longer exists (stale history row) inert notice, no retries.
if (missing) {
return (
<div className={`wf-player wf-player--missing ${compact ? 'wf-player--compact' : ''} ${className}`}>
<div
className={`wf-player wf-player--missing ${compact ? 'wf-player--compact' : ''} ${className}`}
>
<span className="wf-player__missing-msg">audio file missing</span>
</div>
);
@@ -233,9 +294,28 @@ export default function WaveformPlayer({
controls
src={resolvedUrl}
autoPlay={autoPlay}
onPlay={() => { releaseRef.current = claimPlayback(() => { try { nativeRef.current?.pause(); } catch { /* noop */ } }, source); }}
onPause={() => { if (releaseRef.current) { releaseRef.current(); releaseRef.current = null; } }}
onEnded={() => { if (releaseRef.current) { releaseRef.current(); releaseRef.current = null; } if (onEnded) onEnded(); }}
onPlay={() => {
releaseRef.current = claimPlayback(() => {
try {
nativeRef.current?.pause();
} catch {
/* noop */
}
}, source);
}}
onPause={() => {
if (releaseRef.current) {
releaseRef.current();
releaseRef.current = null;
}
}}
onEnded={() => {
if (releaseRef.current) {
releaseRef.current();
releaseRef.current = null;
}
if (onEnded) onEnded();
}}
onError={() => setMissing(true)}
/>
);
@@ -255,7 +335,9 @@ export default function WaveformPlayer({
{isPlaying ? <Pause size={compact ? 13 : 15} /> : <Play size={compact ? 13 : 15} />}
</button>
<div className="wf-player__wave" ref={containerRef} style={{ height }} />
<span className="wf-player__time">{fmt(currentTime)} / {fmt(duration)}</span>
<span className="wf-player__time">
{fmt(currentTime)} / {fmt(duration)}
</span>
</div>
);
}
+314 -151
View File
@@ -1,8 +1,24 @@
import React, { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react';
import React, {
useEffect,
useRef,
useState,
useCallback,
forwardRef,
useImperativeHandle,
} from 'react';
import WaveSurfer from 'wavesurfer.js';
import MinimapPlugin from 'wavesurfer.js/dist/plugins/minimap.esm.js';
import TimelinePlugin from 'wavesurfer.js/dist/plugins/timeline.esm.js';
import { Play, Pause, ZoomIn, ZoomOut, SkipBack, Loader, Keyboard, AlertTriangle } from 'lucide-react';
import {
Play,
Pause,
ZoomIn,
ZoomOut,
SkipBack,
Loader,
Keyboard,
AlertTriangle,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAppStore } from '../store';
import { unlockAudio } from '../utils/audioUnlock';
@@ -32,48 +48,51 @@ import './WaveformErrorBoundary.css';
* onSegmentDelete (id) => void
* onPreviewSegment (seg) => void synthesize-and-play this segment's dub
*/
function WaveformTimeline({
audioSrc,
videoSrc,
segments = [],
disabled = false,
overlayContent,
onsets = [],
selectedSegId = null,
onSelectSeg,
incrementalPlan = null,
onSegmentCommit,
onSegmentDelete,
onPreviewSegment,
}, ref) {
function WaveformTimeline(
{
audioSrc,
videoSrc,
segments = [],
disabled = false,
overlayContent,
onsets = [],
selectedSegId = null,
onSelectSeg,
incrementalPlan = null,
onSegmentCommit,
onSegmentDelete,
onPreviewSegment,
},
ref,
) {
const { t } = useTranslation();
const waveContainerRef = useRef(null); // div WaveSurfer draws into
const waveContainerRef = useRef(null); // div WaveSurfer draws into
const videoContainerRef = useRef(null); // div we imperatively append the <video> into
const wsRef = useRef(null);
const mediaElRef = useRef(null); // fallback: direct media element if WaveSurfer unavailable
const wsRef = useRef(null);
const mediaElRef = useRef(null); // fallback: direct media element if WaveSurfer unavailable
const playRangeEndRef = useRef(null); // playRange() watcher pauses at this time
const [ready, setReady] = useState(false);
const [ready, setReady] = useState(false);
// WaveSurfer init threw (WebKit restriction) media element still works;
// the SegmentTrack then self-scrolls with a locally fixed pxPerSec.
const [fallbackMode, setFallbackMode] = useState(false);
// Single alignment source for the SegmentTrack, read off ws.getWrapper().
const [metrics, setMetrics] = useState({ pxPerSec: 0, scrollLeft: 0 });
const [loadError, setLoadError] = useState(false);
const [loadError, setLoadError] = useState(false);
// Specifically: the source returned a non-media response (typically 404
// HTML). Differentiates from a generic decode failure so the error UI
// can tell the user the file has moved/been deleted, not just "broken".
const [sourceMissing, setSourceMissing] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [zoom, setZoom] = useState(50);
const [duration, setDuration] = useState(0);
const [zoom, setZoom] = useState(50);
// Live "current segment" signal
// Derive which segment contains the playhead and write the id to the store
// *only when it changes*. This keeps DubSegmentTable re-renders bounded to
// segment-crossings instead of the 4-50Hz timeupdate cadence.
const setDubCurrentSegId = useAppStore(s => s.setDubCurrentSegId);
const setDubCurrentSegId = useAppStore((s) => s.setDubCurrentSegId);
const lastSegIdRef = useRef(null);
useEffect(() => {
if (!segments.length) {
@@ -87,7 +106,10 @@ function WaveformTimeline({
// switch to a binary search; keep it simple until needed.
let hit = null;
for (const s of segments) {
if (currentTime >= s.start && currentTime < s.end) { hit = s.id; break; }
if (currentTime >= s.start && currentTime < s.end) {
hit = s.id;
break;
}
}
if (hit !== lastSegIdRef.current) {
lastSegIdRef.current = hit;
@@ -95,7 +117,12 @@ function WaveformTimeline({
}
}, [currentTime, segments, setDubCurrentSegId]);
// Clear on unmount so a stale id can't outlive the editor.
useEffect(() => () => { setDubCurrentSegId(null); }, [setDubCurrentSegId]);
useEffect(
() => () => {
setDubCurrentSegId(null);
},
[setDubCurrentSegId],
);
// Core init only re-runs when src changes
useEffect(() => {
@@ -119,7 +146,11 @@ function WaveformTimeline({
while (c.firstChild) {
const child = c.firstChild;
if (child.tagName === 'VIDEO' || child.tagName === 'AUDIO') {
try { child.pause(); child.removeAttribute('src'); child.load?.(); } catch (_) {}
try {
child.pause();
child.removeAttribute('src');
child.load?.();
} catch (_) {}
}
c.removeChild(child);
}
@@ -129,7 +160,8 @@ function WaveformTimeline({
videoEl.playsInline = true;
// Load enough data to paint first frame as thumbnail preview.
videoEl.preload = 'auto';
videoEl.style.cssText = 'width:100%;height:100%;object-fit:contain;background:#000;display:block;';
videoEl.style.cssText =
'width:100%;height:100%;object-fit:contain;background:#000;display:block;';
// Decode and show the first frame as a thumbnail.
// WebKit won't paint a frame until currentTime is set past 0.
const showFirstFrame = () => {
@@ -138,7 +170,9 @@ function WaveformTimeline({
// Seek to the earliest decodable frame.
videoEl.currentTime = Math.min(0.1, videoEl.duration * 0.01);
}
} catch (_) { /* ignore */ }
} catch (_) {
/* ignore */
}
};
videoEl.addEventListener('loadedmetadata', showFirstFrame, { once: true });
// Fallback if loadedmetadata already fired before listener attached (cached).
@@ -163,7 +197,12 @@ function WaveformTimeline({
if ((code === 2 || code === 4) && videoRetries < 6) {
videoRetries += 1;
videoRetryTimer = setTimeout(() => {
try { videoEl.src = videoSrc; videoEl.load(); } catch (_) { /* ignore */ }
try {
videoEl.src = videoSrc;
videoEl.load();
} catch (_) {
/* ignore */
}
}, 1000 * videoRetries);
return;
}
@@ -179,18 +218,20 @@ function WaveformTimeline({
// 2. Create the media element WaveSurfer will control
// Use <video> if we have one (it has audio), otherwise <audio>.
// This avoids two media elements fighting each other.
const mediaEl = videoEl ?? (() => {
const a = document.createElement('audio');
a.src = audioSrc;
a.preload = 'auto';
return a;
})();
const mediaEl =
videoEl ??
(() => {
const a = document.createElement('audio');
a.src = audioSrc;
a.preload = 'auto';
return a;
})();
// For the audio-only case (server WAV), also set src on the media element
if (!videoEl) {
mediaEl.src = audioSrc;
}
mediaEl.crossOrigin = 'anonymous'; // helps in sandboxed WebViews
mediaElRef.current = mediaEl; // keep ref for fallback play/pause
mediaElRef.current = mediaEl; // keep ref for fallback play/pause
// 3. Init WaveSurfer with that single media element
let ws;
@@ -198,7 +239,10 @@ function WaveformTimeline({
// Start at the container's measured height; a ResizeObserver below
// keeps WaveSurfer in sync when the column resizes. Fallback to 200
// if layout hasn't settled yet so we never render a flat sliver.
const initialHeight = Math.max(80, Math.min(waveContainerRef.current.clientHeight || 120, 160));
const initialHeight = Math.max(
80,
Math.min(waveContainerRef.current.clientHeight || 120, 160),
);
const minimap = MinimapPlugin.create({
height: 20,
waveColor: 'rgba(168,153,132,0.25)',
@@ -215,18 +259,18 @@ function WaveformTimeline({
},
});
ws = WaveSurfer.create({
container: waveContainerRef.current,
waveColor: 'rgba(168,153,132,0.45)',
container: waveContainerRef.current,
waveColor: 'rgba(168,153,132,0.45)',
progressColor: 'rgba(211,134,155,0.75)',
cursorColor: '#d3869b',
cursorWidth: 2,
height: initialHeight,
barWidth: 2,
barGap: 1,
barRadius: 2,
normalize: true,
media: mediaEl,
plugins: [minimap, timeline],
cursorColor: '#d3869b',
cursorWidth: 2,
height: initialHeight,
barWidth: 2,
barGap: 1,
barRadius: 2,
normalize: true,
media: mediaEl,
plugins: [minimap, timeline],
});
} catch (initErr) {
console.warn('WaveSurfer init failed (WebKit restriction?):', initErr);
@@ -241,35 +285,56 @@ function WaveformTimeline({
mediaEl.addEventListener('timeupdate', () => {
setCurrentTime(mediaEl.currentTime);
// playRange watcher stop at the requested slot end.
if (playRangeEndRef.current != null && mediaEl.currentTime >= playRangeEndRef.current - 0.02) {
if (
playRangeEndRef.current != null &&
mediaEl.currentTime >= playRangeEndRef.current - 0.02
) {
playRangeEndRef.current = null;
try { mediaEl.pause(); } catch (_) { /* ignore */ }
try {
mediaEl.pause();
} catch (_) {
/* ignore */
}
}
});
mediaEl.addEventListener('play', () => setIsPlaying(true));
mediaEl.addEventListener('pause', () => { setIsPlaying(false); playRangeEndRef.current = null; });
mediaEl.addEventListener('play', () => setIsPlaying(true));
mediaEl.addEventListener('pause', () => {
setIsPlaying(false);
playRangeEndRef.current = null;
});
mediaEl.addEventListener('ended', () => setIsPlaying(false));
wsRef.current = null;
setFallbackMode(true);
return;
}
ws.on('ready', () => { setDuration(ws.getDuration()); setReady(true); });
ws.on('ready', () => {
setDuration(ws.getDuration());
setReady(true);
});
ws.on('timeupdate', (t) => {
setCurrentTime(t);
// playRange watcher pause when the requested slot finishes.
if (playRangeEndRef.current != null && t >= playRangeEndRef.current - 0.02) {
playRangeEndRef.current = null;
try { ws.pause(); } catch (_) { /* ignore */ }
try {
ws.pause();
} catch (_) {
/* ignore */
}
}
});
ws.on('play', () => setIsPlaying(true));
ws.on('pause', () => { setIsPlaying(false); playRangeEndRef.current = null; });
ws.on('finish', () => setIsPlaying(false));
ws.on('play', () => setIsPlaying(true));
ws.on('pause', () => {
setIsPlaying(false);
playRangeEndRef.current = null;
});
ws.on('finish', () => setIsPlaying(false));
// Handle errors (like Safari refusing to decode .mov in WebAudio)
ws.on('error', (err) => {
const errStr = typeof err === 'string' ? err.toLowerCase() : (err?.message || '').toLowerCase();
const errStr =
typeof err === 'string' ? err.toLowerCase() : (err?.message || '').toLowerCase();
const errName = err?.name || '';
if (errName === 'AbortError' || errStr.includes('abort')) {
return; // Ignore React cleanup aborts
@@ -290,17 +355,17 @@ function WaveformTimeline({
// we manually fetch the companion `audioSrc`, decode it, and supply raw peaks.
if (audioSrc) {
fetch(audioSrc)
.then(res => {
.then((res) => {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.arrayBuffer();
})
.then(buffer => {
.then((buffer) => {
const actx = new (window.AudioContext || window.webkitAudioContext)();
return actx.decodeAudioData(buffer);
})
.then(audioBuffer => {
const channelData = audioBuffer.getChannelData(0);
ws.load(undefined, [channelData], audioBuffer.duration);
.then((audioBuffer) => {
const channelData = audioBuffer.getChannelData(0);
ws.load(undefined, [channelData], audioBuffer.duration);
})
.catch((decodeErr) => {
// HTTP 404 on the companion audio means the source file is
@@ -335,15 +400,25 @@ function WaveformTimeline({
if (videoRetryTimer) clearTimeout(videoRetryTimer);
// Gracefully stop before destroy to avoid WaveSurfer's internal
// fetch progress handler logging "AbortError: Fetch is aborted".
try { ws.pause(); } catch (_) {}
try { ws.cancelAudioFetch?.(); } catch (_) {}
try {
ws.pause();
} catch (_) {}
try {
ws.cancelAudioFetch?.();
} catch (_) {}
// Empty the media source before destroy so any in-flight fetch
// resolves its AbortController without a stale reference.
if (mediaEl && !videoEl) {
try { mediaEl.pause(); mediaEl.removeAttribute('src'); mediaEl.load?.(); } catch (_) {}
try {
mediaEl.pause();
mediaEl.removeAttribute('src');
mediaEl.load?.();
} catch (_) {}
}
try { ws.destroy(); } catch (_) {}
wsRef.current = null;
try {
ws.destroy();
} catch (_) {}
wsRef.current = null;
mediaElRef.current = null;
// Clear the imperatively-created video element (release src so browser frees decoder)
const c = videoContainerRef.current;
@@ -351,14 +426,18 @@ function WaveformTimeline({
while (c.firstChild) {
const child = c.firstChild;
if (child.tagName === 'VIDEO' || child.tagName === 'AUDIO') {
try { child.pause(); child.removeAttribute('src'); child.load?.(); } catch (_) {}
try {
child.pause();
child.removeAttribute('src');
child.load?.();
} catch (_) {}
}
c.removeChild(child);
}
}
setReady(false);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [audioSrc, videoSrc]);
// Alignment metrics the single {pxPerSec, scrollLeft} source
@@ -373,7 +452,11 @@ function WaveformTimeline({
const ws = wsRef.current;
if (!ws) return;
let wrap = null;
try { wrap = ws.getWrapper?.(); } catch (_) { /* destroyed */ }
try {
wrap = ws.getWrapper?.();
} catch (_) {
/* destroyed */
}
if (!wrap) return;
const scrollEl = wrap.parentElement || wrap;
const dur = ws.getDuration?.() || 0;
@@ -381,10 +464,17 @@ function WaveformTimeline({
pxPerSec: dur > 0 ? wrap.scrollWidth / dur : 0,
scrollLeft: scrollEl.scrollLeft || 0,
};
setMetrics(m => (m.pxPerSec === next.pxPerSec && m.scrollLeft === next.scrollLeft) ? m : next);
setMetrics((m) =>
m.pxPerSec === next.pxPerSec && m.scrollLeft === next.scrollLeft ? m : next,
);
});
}, []);
useEffect(() => () => { if (metricsRafRef.current) cancelAnimationFrame(metricsRafRef.current); }, []);
useEffect(
() => () => {
if (metricsRafRef.current) cancelAnimationFrame(metricsRafRef.current);
},
[],
);
useEffect(() => {
if (!ready || !wsRef.current) return undefined;
@@ -393,14 +483,24 @@ function WaveformTimeline({
ws.on('zoom', syncMetrics);
ws.on('scroll', syncMetrics);
let wrap = null;
try { wrap = ws.getWrapper?.(); } catch (_) { /* ignore */ }
try {
wrap = ws.getWrapper?.();
} catch (_) {
/* ignore */
}
const scrollEl = wrap?.parentElement || null;
if (scrollEl) scrollEl.addEventListener('scroll', syncMetrics, { passive: true });
const ro = scrollEl ? new ResizeObserver(syncMetrics) : null;
if (ro && scrollEl) ro.observe(scrollEl);
syncMetrics();
return () => {
try { ws.un('redraw', syncMetrics); ws.un('zoom', syncMetrics); ws.un('scroll', syncMetrics); } catch (_) { /* destroyed */ }
try {
ws.un('redraw', syncMetrics);
ws.un('zoom', syncMetrics);
ws.un('scroll', syncMetrics);
} catch (_) {
/* destroyed */
}
if (scrollEl) scrollEl.removeEventListener('scroll', syncMetrics);
if (ro) ro.disconnect();
};
@@ -435,56 +535,69 @@ function WaveformTimeline({
// Imperative seek + scroll hooks used by the transcript table to jump the
// player to a clicked row, and by the mouse-wheel handler below.
useImperativeHandle(ref, () => ({
seekTo(time) {
const ws = wsRef.current;
if (ws && ready) {
try {
const d = ws.getDuration?.() || duration || 0;
const t = Math.max(0, Math.min(time, d || time));
if (typeof ws.setTime === 'function') ws.setTime(t);
else if (typeof ws.seekTo === 'function' && d > 0) ws.seekTo(t / d);
} catch (err) { console.warn('WaveSurfer seek failed:', err); }
return;
}
const el = mediaElRef.current;
if (el && Number.isFinite(time)) {
try { el.currentTime = Math.max(0, time); } catch (err) { console.warn('media seek failed:', err); }
}
},
}), [ready, duration]);
useImperativeHandle(
ref,
() => ({
seekTo(time) {
const ws = wsRef.current;
if (ws && ready) {
try {
const d = ws.getDuration?.() || duration || 0;
const t = Math.max(0, Math.min(time, d || time));
if (typeof ws.setTime === 'function') ws.setTime(t);
else if (typeof ws.seekTo === 'function' && d > 0) ws.seekTo(t / d);
} catch (err) {
console.warn('WaveSurfer seek failed:', err);
}
return;
}
const el = mediaElRef.current;
if (el && Number.isFinite(time)) {
try {
el.currentTime = Math.max(0, time);
} catch (err) {
console.warn('media seek failed:', err);
}
}
},
}),
[ready, duration],
);
// Horizontal mouse-wheel scroll the waveform. WaveSurfer doesn't bind this
// by default; users expect to spin the wheel over a long timeline. We also
// honour vertical wheel (most mice) as horizontal motion.
const onWaveWheel = useCallback((e) => {
const ws = wsRef.current;
if (!ws || !ready) return;
const wrap = ws.getWrapper?.();
if (!wrap) return;
const scrollEl = wrap.parentElement || wrap;
// Ctrl/Cmd + wheel = zoom centered on the cursor (#280, item 3).
if (e.ctrlKey || e.metaKey) {
const onWaveWheel = useCallback(
(e) => {
const ws = wsRef.current;
if (!ws || !ready) return;
const wrap = ws.getWrapper?.();
if (!wrap) return;
const scrollEl = wrap.parentElement || wrap;
// Ctrl/Cmd + wheel = zoom centered on the cursor (#280, item 3).
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const dur = ws.getDuration?.() || 0;
if (dur <= 0) return;
const rect = scrollEl.getBoundingClientRect();
const cursorX = e.clientX - rect.left;
const curPps = wrap.scrollWidth / dur;
const timeAt = (scrollEl.scrollLeft + cursorX) / curPps;
const factor = e.deltaY < 0 ? 1.25 : 0.8;
setZoom((z) => {
const nz = Math.min(300, Math.max(10, Math.round(z * factor)));
if (nz !== z) pendingZoomAnchorRef.current = { time: timeAt, cursorX };
return nz;
});
return;
}
const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
if (!dx) return;
e.preventDefault();
const dur = ws.getDuration?.() || 0;
if (dur <= 0) return;
const rect = scrollEl.getBoundingClientRect();
const cursorX = e.clientX - rect.left;
const curPps = wrap.scrollWidth / dur;
const timeAt = (scrollEl.scrollLeft + cursorX) / curPps;
const factor = e.deltaY < 0 ? 1.25 : 0.8;
setZoom(z => {
const nz = Math.min(300, Math.max(10, Math.round(z * factor)));
if (nz !== z) pendingZoomAnchorRef.current = { time: timeAt, cursorX };
return nz;
});
return;
}
const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
if (!dx) return;
e.preventDefault();
scrollEl.scrollLeft += dx;
}, [ready]);
scrollEl.scrollLeft += dx;
},
[ready],
);
// playRange seek + play, pausing automatically at `end` via the
// timeupdate watcher wired in the init effect. Used by the SegmentTrack
@@ -493,16 +606,30 @@ function WaveformTimeline({
const playRange = useCallback(async (start, end) => {
// Same autoplay-policy unlock as togglePlay (#595): resume the suspended
// AudioContext on this user gesture before kicking off playback.
try { await unlockAudio(); } catch { /* ignore */ }
try {
await unlockAudio();
} catch {
/* ignore */
}
playRangeEndRef.current = end;
const ws = wsRef.current;
if (ws) {
try { ws.setTime(start); ws.play(); } catch (_) { playRangeEndRef.current = null; }
try {
ws.setTime(start);
ws.play();
} catch (_) {
playRangeEndRef.current = null;
}
return;
}
const el = mediaElRef.current;
if (el) {
try { el.currentTime = start; el.play().catch(() => {}); } catch (_) { playRangeEndRef.current = null; }
try {
el.currentTime = start;
el.play().catch(() => {});
} catch (_) {
playRangeEndRef.current = null;
}
}
}, []);
@@ -528,10 +655,17 @@ function WaveformTimeline({
// sound and the dub video preview just sits there (#595, same class as
// #510 already fixed in WaveformPlayer). This click IS the gesture
// explicitly resume before play. No-op on macOS where it never blocked.
try { await unlockAudio(); } catch { /* play() will surface real errors */ }
try {
await unlockAudio();
} catch {
/* play() will surface real errors */
}
if (wsRef.current) {
try { await wsRef.current.playPause(); }
catch (e) { console.warn('WaveformTimeline: play failed:', e); }
try {
await wsRef.current.playPause();
} catch (e) {
console.warn('WaveformTimeline: play failed:', e);
}
} else if (mediaElRef.current) {
// Fallback: control the native media element directly
const el = mediaElRef.current;
@@ -585,17 +719,16 @@ function WaveformTimeline({
}
return (
<div className="waveform-timeline wfm-layout" role="region" aria-label="Audio waveform timeline">
<div
className="waveform-timeline wfm-layout"
role="region"
aria-label="Audio waveform timeline"
>
{/* Video + Waveform stacked vertically */}
<div className="wfm-stack">
{/* Video preview pinned to its aspect ratio so we don't letterbox
into huge black bars. Waveform gets the remaining height. */}
{videoSrc && (
<div
ref={videoContainerRef}
className="wfm-video-preview"
/>
)}
{videoSrc && <div ref={videoContainerRef} className="wfm-video-preview" />}
{/* Waveform — fills the rest. This is the actual editing surface. */}
<div className="wfm-wave-wrap">
@@ -608,17 +741,13 @@ function WaveformTimeline({
{/* Loading shimmer */}
{!ready && !loadError && (
<div className="wfm-loading">
<Loader className="spinner" size={12} color="#d3869b"/>
<Loader className="spinner" size={12} color="#d3869b" />
<span className="wfm-loading__text">Loading waveform</span>
</div>
)}
{/* Overlay slot — transcription / dubbing progress */}
{overlayContent && (
<div className="wfm-overlay">
{overlayContent}
</div>
)}
{overlayContent && <div className="wfm-overlay">{overlayContent}</div>}
</div>
{/* Segment editing lane pixel-aligned with the waveform via the
@@ -649,19 +778,53 @@ function WaveformTimeline({
{/* Controls */}
<div className="waveform-controls wfm-controls" role="toolbar" aria-label="Playback controls">
<div className="waveform-controls-left">
<button className="waveform-btn" onClick={() => seekTo(0)} title="Restart" aria-label="Restart playback"><SkipBack size={11}/></button>
<button className="waveform-btn waveform-btn-play" onClick={togglePlay} disabled={!ready} aria-label={isPlaying ? 'Pause' : 'Play'}>
{isPlaying ? <Pause size={11}/> : <Play size={11}/>}
<button
className="waveform-btn"
onClick={() => seekTo(0)}
title="Restart"
aria-label="Restart playback"
>
<SkipBack size={11} />
</button>
<span className="waveform-time" aria-live="off">{fmt(currentTime)} / {fmt(duration)}</span>
<span className="wfm-kbd-hint" title="J/K/L: rewind, play/pause, forward"><Keyboard size={10}/></span>
<button
className="waveform-btn waveform-btn-play"
onClick={togglePlay}
disabled={!ready}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause size={11} /> : <Play size={11} />}
</button>
<span className="waveform-time" aria-live="off">
{fmt(currentTime)} / {fmt(duration)}
</span>
<span className="wfm-kbd-hint" title="J/K/L: rewind, play/pause, forward">
<Keyboard size={10} />
</span>
</div>
<div className="waveform-controls-right">
<button className="waveform-btn" onClick={() => setZoom(z => Math.max(10, z - 20))} aria-label="Zoom out"><ZoomOut size={11}/></button>
<input type="range" min="10" max="300" value={zoom}
onChange={e => setZoom(Number(e.target.value))} className="waveform-zoom-slider"
aria-label="Zoom level" />
<button className="waveform-btn" onClick={() => setZoom(z => Math.min(300, z + 20))} aria-label="Zoom in"><ZoomIn size={11}/></button>
<button
className="waveform-btn"
onClick={() => setZoom((z) => Math.max(10, z - 20))}
aria-label="Zoom out"
>
<ZoomOut size={11} />
</button>
<input
type="range"
min="10"
max="300"
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
className="waveform-zoom-slider"
aria-label="Zoom level"
/>
<button
className="waveform-btn"
onClick={() => setZoom((z) => Math.min(300, z + 20))}
aria-label="Zoom in"
>
<ZoomIn size={11} />
</button>
</div>
</div>
</div>
@@ -31,8 +31,13 @@ describe('WaveformTimeline autoplay-unlock wiring (#595)', () => {
// Each playback handler must resume the context first. Grab the body of
// each handler and assert the unlock await precedes the play/playPause call.
const handlers = {
togglePlay: /const togglePlay = useCallback\(async \(\) => \{([\s\S]*?)\}, \[\]\);/.exec(src)?.[1],
playRange: /const playRange = useCallback\(async \(start, end\) => \{([\s\S]*?)\}, \[\]\);/.exec(src)?.[1],
togglePlay: /const togglePlay = useCallback\(async \(\) => \{([\s\S]*?)\}, \[\]\);/.exec(
src,
)?.[1],
playRange:
/const playRange = useCallback\(async \(start, end\) => \{([\s\S]*?)\}, \[\]\);/.exec(
src,
)?.[1],
};
for (const [name, body] of Object.entries(handlers)) {
expect(body, `${name} handler not found`).toBeTruthy();
+112 -51
View File
@@ -100,7 +100,11 @@ function Row({ led, name, chip, chipTone, size, action, sub }) {
<div className="frs-row__text">
<span className="frs-row__label">
{name}
{chip && <span className={`frs-opt__badge swiz-lib__chip swiz-lib__chip--${chipTone}`}>{chip}</span>}
{chip && (
<span className={`frs-opt__badge swiz-lib__chip swiz-lib__chip--${chipTone}`}>
{chip}
</span>
)}
</span>
{sub && <span className="swiz-lib__sub">{sub}</span>}
</div>
@@ -139,9 +143,13 @@ export default function WizardLibrary() {
try {
const all = await listEngines();
if (!cancelled) setEngines(all?.tts ?? null);
} catch { /* backend mid-boot — the wizard polls models anyway */ }
} catch {
/* backend mid-boot — the wizard polls models anyway */
}
})();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, []);
// One SSE stream for all rows (same channel the Settings store uses).
@@ -159,7 +167,8 @@ export default function WizardLibrary() {
// file-level 'done' must NOT clear the repo, multi-file snapshots
// finish files long before the repo's `install_done` arrives.
// (Full phase taxonomy: SetupProgressEvent in api/setup.ts.)
if (ev.phase === 'install_start') return { ...prev, [ev.repo_id]: { phase: 'active', files: {} } };
if (ev.phase === 'install_start')
return { ...prev, [ev.repo_id]: { phase: 'active', files: {} } };
if (ev.phase === 'install_done' || ev.phase === 'install_error') {
if (ev.phase === 'install_done') modelsQuery.refetch();
const next = { ...prev };
@@ -171,20 +180,35 @@ export default function WizardLibrary() {
// over summing per-file events, which is unreliable under parallel/
// segmented fetch (the source of the "8% · 1 KB/s · 0.0 MB left" bug).
if (ev.phase === 'aggregate') {
return { ...prev, [ev.repo_id]: { ...cur, agg: {
bytesDone: ev.bytes_done || 0,
totalBytes: ev.total_bytes || 0,
rate: ev.rate || 0,
etaSeconds: ev.eta_seconds ?? null,
filesDone: ev.files_done || 0,
filesTotal: ev.files_total || 0,
} } };
return {
...prev,
[ev.repo_id]: {
...cur,
agg: {
bytesDone: ev.bytes_done || 0,
totalBytes: ev.total_bytes || 0,
rate: ev.rate || 0,
etaSeconds: ev.eta_seconds ?? null,
filesDone: ev.files_done || 0,
filesTotal: ev.files_total || 0,
},
},
};
}
if (!ev.filename) return prev;
const files = { ...cur.files, [ev.filename]: { downloaded: ev.downloaded || 0, total: ev.total || 0, rate: ev.rate || 0 } };
const files = {
...cur.files,
[ev.filename]: {
downloaded: ev.downloaded || 0,
total: ev.total || 0,
rate: ev.rate || 0,
},
};
return { ...prev, [ev.repo_id]: { ...cur, files } };
});
} catch { /* keepalive */ }
} catch {
/* keepalive */
}
};
return () => es.close();
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -195,7 +219,11 @@ export default function WizardLibrary() {
installMutation.mutate(repoId, {
onError: (e) => {
toast.error(e?.message || 'install failed');
setProgress((p) => { const n = { ...p }; delete n[repoId]; return n; });
setProgress((p) => {
const n = { ...p };
delete n[repoId];
return n;
});
},
});
};
@@ -236,7 +264,9 @@ export default function WizardLibrary() {
const statParts = [
pct != null ? `${pct}%` : null,
rateStr || null,
remainStr ? t('firstrun.size_left', { size: remainStr, defaultValue: '{{size}} left' }) : null,
remainStr
? t('firstrun.size_left', { size: remainStr, defaultValue: '{{size}} left' })
: null,
etaStr ? t('firstrun.eta_left', { eta: etaStr, defaultValue: '~{{eta}} left' }) : null,
].filter(Boolean);
return (
@@ -247,20 +277,34 @@ export default function WizardLibrary() {
chip={chip}
chipTone={chipTone}
size={fmtGB(m.size_gb)}
sub={downloading ? (
<span className="swiz-lib__bar"><span style={{ width: `${pct ?? 4}%` }} /></span>
) : (note || null)}
action={m.installed ? (
<span className="swiz-lib__state"></span>
) : downloading ? (
<span className="swiz-lib__state swiz-lib__state--busy">
{statParts.length ? statParts.join(' · ') : t('firstrun.lib_downloading', 'downloading…')}
</span>
) : (
<button type="button" className="frs-btn frs-btn--quiet swiz-lib__act" onClick={() => install(m.repo_id)}>
{t('firstrun.lib_download', 'Download')}
</button>
)}
sub={
downloading ? (
<span className="swiz-lib__bar">
<span style={{ width: `${pct ?? 4}%` }} />
</span>
) : (
note || null
)
}
action={
m.installed ? (
<span className="swiz-lib__state"></span>
) : downloading ? (
<span className="swiz-lib__state swiz-lib__state--busy">
{statParts.length
? statParts.join(' · ')
: t('firstrun.lib_downloading', 'downloading…')}
</span>
) : (
<button
type="button"
className="frs-btn frs-btn--quiet swiz-lib__act"
onClick={() => install(m.repo_id)}
>
{t('firstrun.lib_download', 'Download')}
</button>
)
}
/>
);
};
@@ -271,7 +315,9 @@ export default function WizardLibrary() {
{/* Optional models tuned for THIS machine shown by default with the
catalog note explaining why (e.g. "5× faster on Apple Silicon"). */}
{platformPicks.map((m) => modelRow(m, t('firstrun.chip_recommended', 'recommended'), 'rec', m.note))}
{platformPicks.map((m) =>
modelRow(m, t('firstrun.chip_recommended', 'recommended'), 'rec', m.note),
)}
{(engines?.backends ?? []).map((b) => (
<Row
@@ -281,35 +327,50 @@ export default function WizardLibrary() {
chip={t('firstrun.chip_engine', 'engine')}
chipTone="eng"
size=""
action={b.id === engines.active ? (
<span className="swiz-lib__state swiz-lib__state--active">{t('firstrun.lib_active', 'active')}</span>
) : b.available ? (
<button
type="button"
className="frs-btn frs-btn--quiet swiz-lib__act"
disabled={switching === b.id}
// eslint-disable-next-line react-hooks/rules-of-hooks -- useEngine is an action fn, not a React hook
onClick={() => useEngine(b.id)}
>
{t('firstrun.lib_use', 'Use')}
</button>
) : (
<span className="swiz-lib__state" title={b.reason || undefined}>
{t('firstrun.lib_in_settings', 'install later in Settings')}
</span>
)}
action={
b.id === engines.active ? (
<span className="swiz-lib__state swiz-lib__state--active">
{t('firstrun.lib_active', 'active')}
</span>
) : b.available ? (
<button
type="button"
className="frs-btn frs-btn--quiet swiz-lib__act"
disabled={switching === b.id}
// eslint-disable-next-line react-hooks/rules-of-hooks -- useEngine is an action fn, not a React hook
onClick={() => useEngine(b.id)}
>
{t('firstrun.lib_use', 'Use')}
</button>
) : (
<span className="swiz-lib__state" title={b.reason || undefined}>
{t('firstrun.lib_in_settings', 'install later in Settings')}
</span>
)
}
/>
))}
{tail.length > 0 && !showTail && (
<button type="button" className="frs-btn frs-btn--quiet swiz-lib__more" onClick={() => setShowTail(true)}>
{t('firstrun.lib_show_all', { count: tail.length, defaultValue: 'Show {{count}} more models' })}
<button
type="button"
className="frs-btn frs-btn--quiet swiz-lib__more"
onClick={() => setShowTail(true)}
>
{' '}
{t('firstrun.lib_show_all', {
count: tail.length,
defaultValue: 'Show {{count}} more models',
})}
</button>
)}
{showTail && tail.map((m) => modelRow(m, t('firstrun.chip_optional', 'optional'), 'opt'))}
{Object.keys(progress).length > 0 && (
<p className="frs__trust">
{t('firstrun.resume_note', 'Interrupted downloads resume automatically — closing the app is safe.')}
{t(
'firstrun.resume_note',
'Interrupted downloads resume automatically — closing the app is safe.',
)}
</p>
)}
</div>
+157 -97
View File
@@ -14,8 +14,15 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
History, Fingerprint, Wand2, Film, Save, Lock, Download as DownloadIcon,
FolderOpen, Trash2,
History,
Fingerprint,
Wand2,
Film,
Save,
Lock,
Download as DownloadIcon,
FolderOpen,
Trash2,
} from 'lucide-react';
import WaveformPlayer from './WaveformPlayer';
import { API } from '../api/client';
@@ -50,7 +57,9 @@ function LazyWaveform({ height = 36, className = '', ...rest }) {
const node = ref.current;
if (!node || visible) return;
const obs = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) setVisible(true); },
([entry]) => {
if (entry.isIntersecting) setVisible(true);
},
{ rootMargin: '100px' },
);
obs.observe(node);
@@ -61,7 +70,7 @@ function LazyWaveform({ height = 36, className = '', ...rest }) {
}
export default function WorkspaceHistory({
variant = 'voice', // 'voice' (clone/design synth) | 'dub'
variant = 'voice', // 'voice' (clone/design synth) | 'dub'
history = [],
dubHistory = [],
restoreDubHistory,
@@ -77,8 +86,8 @@ export default function WorkspaceHistory({
// Voice workspace = clone + design generations (dub lives in its own workspace).
const items = useMemo(() => {
const synth = history.filter(h => h.mode === 'clone' || h.mode === 'design');
return filter === 'all' ? synth : synth.filter(h => h.mode === filter);
const synth = history.filter((h) => h.mode === 'clone' || h.mode === 'design');
return filter === 'all' ? synth : synth.filter((h) => h.mode === filter);
}, [history, filter]);
// Dub variant: a flat list of dub jobs, no clone/design filter.
@@ -95,32 +104,51 @@ export default function WorkspaceHistory({
<div className="wh__empty">
{t('history.empty_dub', { defaultValue: 'Your dubs will appear here.' })}
</div>
) : dubHistory.map(item => (
<div
key={`dub-${item.id}`}
className="history-item history-item--dub"
onClick={() => restoreDubHistory(item)}
>
<div className="history-row-head">
<span className="history-kind history-kind--audio">
<Film size={9} /> {t('sidebar.dub_label')}
</span>
<span className="history-meta">{item.segments_count} segs · {Math.round(item.duration || 0)}s</span>
) : (
dubHistory.map((item) => (
<div
key={`dub-${item.id}`}
className="history-item history-item--dub"
onClick={() => restoreDubHistory(item)}
>
<div className="history-row-head">
<span className="history-kind history-kind--audio">
<Film size={9} /> {t('sidebar.dub_label')}
</span>
<span className="history-meta">
{item.segments_count} segs · {Math.round(item.duration || 0)}s
</span>
</div>
<div className="history-title">{item.filename}</div>
<div className="history-subtitle">
{[item.language, item.language_code]
.filter((v) => v && v !== 'und' && v !== 'Auto')
.join(' · ') || 'Auto'}
</div>
<div className="history-actions">
<button
className="history-action-btn accent"
onClick={(e) => {
e.stopPropagation();
restoreDubHistory(item);
}}
>
<FolderOpen size={10} /> {t('sidebar.open')}
</button>
<button
className="history-action-btn danger history-action-icon"
onClick={(e) => {
e.stopPropagation();
deleteHistory(item.id, 'dub');
}}
title="Delete"
>
<Trash2 size={10} />
</button>
</div>
</div>
<div className="history-title">{item.filename}</div>
<div className="history-subtitle">
{[item.language, item.language_code].filter(v => v && v !== 'und' && v !== 'Auto').join(' · ') || 'Auto'}
</div>
<div className="history-actions">
<button className="history-action-btn accent" onClick={(e) => { e.stopPropagation(); restoreDubHistory(item); }}>
<FolderOpen size={10} /> {t('sidebar.open')}
</button>
<button className="history-action-btn danger history-action-icon" onClick={(e) => { e.stopPropagation(); deleteHistory(item.id, 'dub'); }} title="Delete">
<Trash2 size={10} />
</button>
</div>
</div>
))}
))
)}
</div>
</aside>
);
@@ -133,7 +161,7 @@ export default function WorkspaceHistory({
<History size={13} /> {t('history.title', { defaultValue: 'History' })}
</span>
<div className="wh__filters">
{FILTERS.map(f => (
{FILTERS.map((f) => (
<button
key={f.id}
type="button"
@@ -149,73 +177,105 @@ export default function WorkspaceHistory({
<div className="wh__scroll">
{items.length === 0 ? (
<div className="wh__empty">
{t('history.empty', { defaultValue: 'Nothing here yet — your generations will appear on the right.' })}
{t('history.empty', {
defaultValue: 'Nothing here yet — your generations will appear on the right.',
})}
</div>
) : items.map(item => {
const accent = item.mode === 'clone' ? '#d3869b' : '#b8bb26';
const KindIcon = item.mode === 'clone' ? Fingerprint : Wand2;
return (
<div key={item.id} className="history-item" style={{ '--row-accent': accent }}>
<div className="history-row-head">
<span className="history-kind" style={{ color: accent, borderColor: `${accent}40` }}>
<KindIcon size={9} /> {item.mode || 'synth'}
</span>
<span className="history-meta">
{item.language && item.language !== 'Auto' ? `${item.language} · ` : ''}
{item.generation_time ? `${item.generation_time}s` : ''}
</span>
</div>
<div
className={`history-title history-title--clamp ${expanded === item.id ? 'history-title--expanded' : ''}`}
title={item.text}
onClick={() => setExpanded(e => (e === item.id ? null : item.id))}
>
{displayTitle(item.text)}
</div>
{item.seed != null && String(item.seed) !== ''
? <div className="history-subtitle history-subtitle--seed">seed {item.seed}</div>
: null}
{item.audio_path ? (
<LazyWaveform
src={`${API}/audio/${item.audio_path}`}
source="history"
height={36}
compact
className="history-audio"
/>
) : null}
{item.audio_path ? (
<div className="history-actions">
<button className="history-action-btn accent" onClick={(e) => { e.stopPropagation(); handleSaveHistoryAsProfile(item); }}>
<Save size={10} /> {t('sidebar.save_label')}
</button>
{item.profile_id ? (
<button className="history-action-btn accent history-action-icon"
onClick={(e) => { e.stopPropagation(); handleLockProfile(item.profile_id, item.id, item.seed); }}
title={t('sidebar.lock_identity')}>
<Lock size={10} />
</button>
) : null}
<button className="history-action-btn history-action-icon"
onClick={(e) => handleNativeExport(e, item.audio_path, item.audio_path, item.mode)}
title="Export">
<DownloadIcon size={10} />
</button>
<button className="history-action-btn history-action-icon"
onClick={(e) => { e.stopPropagation(); restoreHistory(item); }}
title="Load config">
<FolderOpen size={10} />
</button>
<button className="history-action-btn danger history-action-icon"
onClick={(e) => { e.stopPropagation(); deleteHistory(item.id, 'synth'); }}
title="Delete">
<Trash2 size={10} />
</button>
) : (
items.map((item) => {
const accent = item.mode === 'clone' ? '#d3869b' : '#b8bb26';
const KindIcon = item.mode === 'clone' ? Fingerprint : Wand2;
return (
<div key={item.id} className="history-item" style={{ '--row-accent': accent }}>
<div className="history-row-head">
<span
className="history-kind"
style={{ color: accent, borderColor: `${accent}40` }}
>
<KindIcon size={9} /> {item.mode || 'synth'}
</span>
<span className="history-meta">
{item.language && item.language !== 'Auto' ? `${item.language} · ` : ''}
{item.generation_time ? `${item.generation_time}s` : ''}
</span>
</div>
) : null}
</div>
);
})}
<div
className={`history-title history-title--clamp ${expanded === item.id ? 'history-title--expanded' : ''}`}
title={item.text}
onClick={() => setExpanded((e) => (e === item.id ? null : item.id))}
>
{displayTitle(item.text)}
</div>
{item.seed != null && String(item.seed) !== '' ? (
<div className="history-subtitle history-subtitle--seed">seed {item.seed}</div>
) : null}
{item.audio_path ? (
<LazyWaveform
src={`${API}/audio/${item.audio_path}`}
source="history"
height={36}
compact
className="history-audio"
/>
) : null}
{item.audio_path ? (
<div className="history-actions">
<button
className="history-action-btn accent"
onClick={(e) => {
e.stopPropagation();
handleSaveHistoryAsProfile(item);
}}
>
<Save size={10} /> {t('sidebar.save_label')}
</button>
{item.profile_id ? (
<button
className="history-action-btn accent history-action-icon"
onClick={(e) => {
e.stopPropagation();
handleLockProfile(item.profile_id, item.id, item.seed);
}}
title={t('sidebar.lock_identity')}
>
<Lock size={10} />
</button>
) : null}
<button
className="history-action-btn history-action-icon"
onClick={(e) =>
handleNativeExport(e, item.audio_path, item.audio_path, item.mode)
}
title="Export"
>
<DownloadIcon size={10} />
</button>
<button
className="history-action-btn history-action-icon"
onClick={(e) => {
e.stopPropagation();
restoreHistory(item);
}}
title="Load config"
>
<FolderOpen size={10} />
</button>
<button
className="history-action-btn danger history-action-icon"
onClick={(e) => {
e.stopPropagation();
deleteHistory(item.id, 'synth');
}}
title="Delete"
>
<Trash2 size={10} />
</button>
</div>
) : null}
</div>
);
})
)}
</div>
</aside>
);
+118 -66
View File
@@ -43,8 +43,14 @@ export default function WorkspaceProjects({
const [editingId, setEditingId] = useState(null);
const [draftName, setDraftName] = useState('');
const startRename = (proj) => { setEditingId(proj.id); setDraftName(proj.name || ''); };
const cancelRename = () => { setEditingId(null); setDraftName(''); };
const startRename = (proj) => {
setEditingId(proj.id);
setDraftName(proj.name || '');
};
const cancelRename = () => {
setEditingId(null);
setDraftName('');
};
const commitRename = (proj) => {
const next = draftName.trim();
if (next && next !== proj.name) renameProject?.(proj.id, next);
@@ -53,13 +59,15 @@ export default function WorkspaceProjects({
const items = useMemo(() => {
if (!qLower) return projects;
return projects.filter(p => (p.name || '').toLowerCase().includes(qLower));
return projects.filter((p) => (p.name || '').toLowerCase().includes(qLower));
}, [projects, qLower]);
return (
<section className={`wv ${items.length === 0 ? 'wv--collapsed' : ''}`}>
<div className="wv__head">
<span className="wv__title">{t('sidebar.dub_projects', { defaultValue: 'Dub projects' })}</span>
<span className="wv__title">
{t('sidebar.dub_projects', { defaultValue: 'Dub projects' })}
</span>
{canSave && (
<Button
variant="subtle"
@@ -77,7 +85,7 @@ export default function WorkspaceProjects({
className="input-base wv__search-input"
placeholder={t('sidebar.search', { defaultValue: 'Search…' })}
value={q}
onChange={e => setQ(e.target.value)}
onChange={(e) => setQ(e.target.value)}
/>
</div>
</div>
@@ -87,71 +95,115 @@ export default function WorkspaceProjects({
<div className="wv__empty">
{t('sidebar.no_dub_projects', { defaultValue: 'No dub projects yet' })}
</div>
) : items.map(proj => (
<div
key={proj.id}
className={`history-item history-item--dub ${activeProjectId === proj.id ? 'project-active' : ''}`}
onClick={() => loadProject(proj.id)}
>
<div className="history-row-head">
<span className="history-kind history-kind--audio">
<Film size={9} /> {t('sidebar.dub_label')}
</span>
<span className="history-meta" title={new Date(proj.updated_at * 1000).toLocaleString()}>
{timeAgo(proj.updated_at * 1000)}
</span>
</div>
{editingId === proj.id ? (
<input
className="input-base wv__rename-input"
autoFocus
value={draftName}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.stopPropagation(); commitRename(proj); }
else if (e.key === 'Escape') { e.stopPropagation(); cancelRename(); }
}}
onBlur={() => commitRename(proj)}
/>
) : (
<div className="history-title">{proj.name}</div>
)}
<div className="history-subtitle">
{proj.duration ? `${Math.round(proj.duration)}s` : 'audio'}
{(() => {
const basename = proj.video_path ? proj.video_path.split(/[\\/]/).pop() : '';
return basename && basename !== proj.name ? ` · ${basename}` : '';
})()}
</div>
<div className="history-actions">
) : (
items.map((proj) => (
<div
key={proj.id}
className={`history-item history-item--dub ${activeProjectId === proj.id ? 'project-active' : ''}`}
onClick={() => loadProject(proj.id)}
>
<div className="history-row-head">
<span className="history-kind history-kind--audio">
<Film size={9} /> {t('sidebar.dub_label')}
</span>
<span
className="history-meta"
title={new Date(proj.updated_at * 1000).toLocaleString()}
>
{timeAgo(proj.updated_at * 1000)}
</span>
</div>
{editingId === proj.id ? (
<>
<button className="history-action-btn accent" onClick={(e) => { e.stopPropagation(); commitRename(proj); }} title={t('sidebar.rename_save', { defaultValue: 'Save name' })}>
<Check size={10} /> {t('common.save', { defaultValue: 'Save' })}
</button>
<button className="history-action-btn history-action-icon" onClick={(e) => { e.stopPropagation(); cancelRename(); }} title={t('common.cancel', { defaultValue: 'Cancel' })}>
<X size={10} />
</button>
</>
<input
className="input-base wv__rename-input"
autoFocus
value={draftName}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation();
commitRename(proj);
} else if (e.key === 'Escape') {
e.stopPropagation();
cancelRename();
}
}}
onBlur={() => commitRename(proj)}
/>
) : (
<>
<button className="history-action-btn accent" onClick={(e) => { e.stopPropagation(); loadProject(proj.id); }}>
<FolderOpen size={10} /> {t('sidebar.open')}
</button>
{renameProject && (
<button className="history-action-btn history-action-icon" onClick={(e) => { e.stopPropagation(); startRename(proj); }} title={t('sidebar.rename', { defaultValue: 'Rename' })}>
<Pencil size={10} />
</button>
)}
<button className="history-action-btn danger history-action-icon" onClick={(e) => { e.stopPropagation(); deleteProject(proj.id); }} title="Delete">
<Trash2 size={10} />
</button>
</>
<div className="history-title">{proj.name}</div>
)}
<div className="history-subtitle">
{proj.duration ? `${Math.round(proj.duration)}s` : 'audio'}
{(() => {
const basename = proj.video_path ? proj.video_path.split(/[\\/]/).pop() : '';
return basename && basename !== proj.name ? ` · ${basename}` : '';
})()}
</div>
<div className="history-actions">
{editingId === proj.id ? (
<>
<button
className="history-action-btn accent"
onClick={(e) => {
e.stopPropagation();
commitRename(proj);
}}
title={t('sidebar.rename_save', { defaultValue: 'Save name' })}
>
<Check size={10} /> {t('common.save', { defaultValue: 'Save' })}
</button>
<button
className="history-action-btn history-action-icon"
onClick={(e) => {
e.stopPropagation();
cancelRename();
}}
title={t('common.cancel', { defaultValue: 'Cancel' })}
>
<X size={10} />
</button>
</>
) : (
<>
<button
className="history-action-btn accent"
onClick={(e) => {
e.stopPropagation();
loadProject(proj.id);
}}
>
<FolderOpen size={10} /> {t('sidebar.open')}
</button>
{renameProject && (
<button
className="history-action-btn history-action-icon"
onClick={(e) => {
e.stopPropagation();
startRename(proj);
}}
title={t('sidebar.rename', { defaultValue: 'Rename' })}
>
<Pencil size={10} />
</button>
)}
<button
className="history-action-btn danger history-action-icon"
onClick={(e) => {
e.stopPropagation();
deleteProject(proj.id);
}}
title="Delete"
>
<Trash2 size={10} />
</button>
</>
)}
</div>
</div>
</div>
))}
))
)}
</div>
</section>
);
+154 -59
View File
@@ -14,7 +14,17 @@
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Search, Fingerprint, Wand2, Lock, Unlock, Play, Loader, Check, Volume2, Trash2, Plus,
Search,
Fingerprint,
Wand2,
Lock,
Unlock,
Play,
Loader,
Check,
Volume2,
Trash2,
Plus,
} from 'lucide-react';
import WaveformPlayer from './WaveformPlayer';
import { API } from '../api/client';
@@ -35,20 +45,23 @@ export default function WorkspaceVoices({
onOpenVoicePreview,
}) {
const { t } = useTranslation();
const setDefineMethod = useAppStore(s => s.setDefineMethod);
const setDefineMethod = useAppStore((s) => s.setDefineMethod);
const [q, setQ] = useState('');
const qLower = q.trim().toLowerCase();
// ACTIVE VOICE card (10x §2): always answer "who speaks when I press
// Synthesize". Recipe = instruct (designed) or "your reference clip".
const active = profiles.find(p => p.id === selectedProfile) || null;
const active = profiles.find((p) => p.id === selectedProfile) || null;
const items = useMemo(() => {
const byMethod = profiles.filter(p => (defineMethod === 'audio' ? !p.instruct : !!p.instruct));
const byMethod = profiles.filter((p) =>
defineMethod === 'audio' ? !p.instruct : !!p.instruct,
);
if (!qLower) return byMethod;
return byMethod.filter(p =>
(p.name || '').toLowerCase().includes(qLower) ||
(p.instruct || '').toLowerCase().includes(qLower)
return byMethod.filter(
(p) =>
(p.name || '').toLowerCase().includes(qLower) ||
(p.instruct || '').toLowerCase().includes(qLower),
);
}, [profiles, defineMethod, qLower]);
@@ -58,17 +71,28 @@ export default function WorkspaceVoices({
<section className={`wv ${items.length === 0 ? 'wv--collapsed' : ''}`}>
{/* ── ACTIVE VOICE ─────────────────────────────────────────────── */}
<div className="wv__active">
<div className="wv__active-kicker">{t('voices.active', { defaultValue: 'Active voice' })}</div>
<div className="wv__active-kicker">
{t('voices.active', { defaultValue: 'Active voice' })}
</div>
{active ? (
<div className="wv__active-card">
<div className="wv__active-row">
<span className="wv__active-name">{active.name}</span>
<span className="history-kind" style={{ color: active.instruct ? '#8ec07c' : '#d3869b', borderColor: active.instruct ? '#8ec07c40' : '#d3869b40' }}>
<span
className="history-kind"
style={{
color: active.instruct ? '#8ec07c' : '#d3869b',
borderColor: active.instruct ? '#8ec07c40' : '#d3869b40',
}}
>
{active.instruct ? t('sidebar.design_label') : t('sidebar.clone_label')}
</span>
</div>
<div className="wv__active-recipe">
{active.instruct || t('voices.active_clone_recipe', { defaultValue: 'Cloned from your reference clip' })}
{active.instruct ||
t('voices.active_clone_recipe', {
defaultValue: 'Cloned from your reference clip',
})}
</div>
{active.ref_audio_path ? (
<WaveformPlayer
@@ -79,14 +103,20 @@ export default function WorkspaceVoices({
/>
) : null}
<div className="wv__active-actions">
<button type="button" className="history-action-btn" onClick={() => setSelectedProfile?.(null)}>
<button
type="button"
className="history-action-btn"
onClick={() => setSelectedProfile?.(null)}
>
<Plus size={10} /> {t('voices.new', { defaultValue: 'New voice' })}
</button>
</div>
</div>
) : (
<div className="wv__active-empty">
{t('voices.none_selected', { defaultValue: 'No voice selected — describe one, drop audio, or pick below.' })}
{t('voices.none_selected', {
defaultValue: 'No voice selected — describe one, drop audio, or pick below.',
})}
</div>
)}
</div>
@@ -99,7 +129,7 @@ export default function WorkspaceVoices({
className="input-base wv__search-input"
placeholder={t('sidebar.search', { defaultValue: 'Search…' })}
value={q}
onChange={e => setQ(e.target.value)}
onChange={(e) => setQ(e.target.value)}
/>
</div>
</div>
@@ -121,54 +151,119 @@ export default function WorkspaceVoices({
: t('voices.cta_design', { defaultValue: 'Describe one in Voice ← to design it' })}
</button>
</div>
) : items.map(proj => {
const accent = proj.is_locked ? '#b8bb26' : (defineMethod === 'audio' ? '#d3869b' : '#8ec07c');
const KindIcon = proj.is_locked ? Lock : (defineMethod === 'audio' ? Fingerprint : Wand2);
return (
<div
key={proj.id}
className={`history-item ${selectedProfile === proj.id ? 'project-active' : ''}`}
style={{ '--row-accent': accent }}
onClick={() => handleSelectProfile(proj)}
>
<div className="history-row-head">
<span className="history-kind" style={{ color: accent, borderColor: `${accent}40` }}>
<KindIcon size={9} /> {proj.is_locked ? t('sidebar.locked') : (defineMethod === 'audio' ? t('sidebar.clone_label') : t('sidebar.design_label'))}
</span>
{proj.is_locked ? <span className="history-meta history-meta--locked">{t('sidebar.consistent')}</span> : null}
</div>
<div className="history-title">{proj.name}</div>
{proj.instruct ? <div className="history-subtitle history-subtitle--italic">{proj.instruct}</div> : null}
<div className="history-actions">
<button className="history-action-btn history-action-icon" onClick={(e) => { e.stopPropagation(); handlePreviewVoice(proj, e); }} title="Preview">
{previewLoading === proj.id ? <Loader className="spinner" size={10} /> : <Play size={10} />}
</button>
{openVoiceProfile && (
<button className="history-action-btn" onClick={(e) => { e.stopPropagation(); openVoiceProfile(proj.id); }} title="Open full profile">
{t('sidebar.open')}
</button>
)}
<button className="history-action-btn" onClick={(e) => { e.stopPropagation(); handleSelectProfile(proj); }}>
<Check size={10} /> {t('sidebar.select')}
</button>
{onOpenVoicePreview && (
<button className="history-action-btn accent" onClick={(e) => { e.stopPropagation(); onOpenVoicePreview(proj.id); }} title="Open interactive voice preview">
<Volume2 size={10} /> {t('sidebar.try_voice')}
</button>
)}
{proj.is_locked ? (
<button className="history-action-btn accent history-action-icon" onClick={(e) => { e.stopPropagation(); handleUnlockProfile(proj.id); }} title="Unlock">
<Unlock size={10} />
</button>
) : (
items.map((proj) => {
const accent = proj.is_locked
? '#b8bb26'
: defineMethod === 'audio'
? '#d3869b'
: '#8ec07c';
const KindIcon = proj.is_locked ? Lock : defineMethod === 'audio' ? Fingerprint : Wand2;
return (
<div
key={proj.id}
className={`history-item ${selectedProfile === proj.id ? 'project-active' : ''}`}
style={{ '--row-accent': accent }}
onClick={() => handleSelectProfile(proj)}
>
<div className="history-row-head">
<span
className="history-kind"
style={{ color: accent, borderColor: `${accent}40` }}
>
<KindIcon size={9} />{' '}
{proj.is_locked
? t('sidebar.locked')
: defineMethod === 'audio'
? t('sidebar.clone_label')
: t('sidebar.design_label')}
</span>
{proj.is_locked ? (
<span className="history-meta history-meta--locked">
{t('sidebar.consistent')}
</span>
) : null}
</div>
<div className="history-title">{proj.name}</div>
{proj.instruct ? (
<div className="history-subtitle history-subtitle--italic">{proj.instruct}</div>
) : null}
<button className="history-action-btn danger history-action-icon" onClick={(e) => { e.stopPropagation(); handleDeleteProfile(proj.id); }} title="Delete">
<Trash2 size={10} />
</button>
<div className="history-actions">
<button
className="history-action-btn history-action-icon"
onClick={(e) => {
e.stopPropagation();
handlePreviewVoice(proj, e);
}}
title="Preview"
>
{previewLoading === proj.id ? (
<Loader className="spinner" size={10} />
) : (
<Play size={10} />
)}
</button>
{openVoiceProfile && (
<button
className="history-action-btn"
onClick={(e) => {
e.stopPropagation();
openVoiceProfile(proj.id);
}}
title="Open full profile"
>
{t('sidebar.open')}
</button>
)}
<button
className="history-action-btn"
onClick={(e) => {
e.stopPropagation();
handleSelectProfile(proj);
}}
>
<Check size={10} /> {t('sidebar.select')}
</button>
{onOpenVoicePreview && (
<button
className="history-action-btn accent"
onClick={(e) => {
e.stopPropagation();
onOpenVoicePreview(proj.id);
}}
title="Open interactive voice preview"
>
<Volume2 size={10} /> {t('sidebar.try_voice')}
</button>
)}
{proj.is_locked ? (
<button
className="history-action-btn accent history-action-icon"
onClick={(e) => {
e.stopPropagation();
handleUnlockProfile(proj.id);
}}
title="Unlock"
>
<Unlock size={10} />
</button>
) : null}
<button
className="history-action-btn danger history-action-icon"
onClick={(e) => {
e.stopPropagation();
handleDeleteProfile(proj.id);
}}
title="Delete"
>
<Trash2 size={10} />
</button>
</div>
</div>
</div>
);
})}
);
})
)}
</div>
</section>
);
+252 -131
View File
@@ -1,4 +1,12 @@
import { Globe, SlidersHorizontal, Settings2, ChevronUp, ChevronDown, Play, Square } from 'lucide-react';
import {
Globe,
SlidersHorizontal,
Settings2,
ChevronUp,
ChevronDown,
Play,
Square,
} from 'lucide-react';
import { Button, Progress } from '../../ui';
import SearchableSelect from '../SearchableSelect';
import ALL_LANGUAGES from '../../languages.json';
@@ -6,155 +14,268 @@ import { POPULAR_LANGS } from '../../utils/constants';
import { stopActivePlayback } from '../../utils/playback';
export default function ActionBar({
t, showOverrides, setShowOverrides,
cfg, setCfg, speed, setSpeed, tShift, setTShift, posTemp, setPosTemp,
classTemp, setClassTemp, layerPenalty, setLayerPenalty, duration, setDuration,
denoise, setDenoise, postprocess, setPostprocess,
language, setLanguage, steps, setSteps,
showHearDemo, playDemoOutput, demoAudioPlaying, demoAudioRef, demoReleaseRef, setDemoAudioPlaying,
outputPlaying, isGenerating, handleGenerate, generationTime, wasGeneratingRef,
t,
showOverrides,
setShowOverrides,
cfg,
setCfg,
speed,
setSpeed,
tShift,
setTShift,
posTemp,
setPosTemp,
classTemp,
setClassTemp,
layerPenalty,
setLayerPenalty,
duration,
setDuration,
denoise,
setDenoise,
postprocess,
setPostprocess,
language,
setLanguage,
steps,
setSteps,
showHearDemo,
playDemoOutput,
demoAudioPlaying,
demoAudioRef,
demoReleaseRef,
setDemoAudioPlaying,
outputPlaying,
isGenerating,
handleGenerate,
generationTime,
wasGeneratingRef,
}) {
return (
<div className="studio-action-bar clone-panel--overflow-visible">
{showOverrides && (
<div className="override-content">
<div className="grid-4">
<div>
<div className="label-row label-row--spread"><span>CFG</span><span className="val-bubble">{cfg}</span></div>
<input type="range" min="1.0" max="4.0" step="0.1" value={cfg} onChange={e => setCfg(Number(e.target.value))} />
{showOverrides && (
<div className="override-content">
<div className="grid-4">
<div>
<div className="label-row label-row--spread">
<span>CFG</span>
<span className="val-bubble">{cfg}</span>
</div>
<div>
<div className="label-row label-row--spread"><span>{t('clone.speed')}</span><span className="val-bubble">{speed}x</span></div>
<input type="range" min="0.5" max="2.0" step="0.1" value={speed} onChange={e => setSpeed(Number(e.target.value))} />
<input
type="range"
min="1.0"
max="4.0"
step="0.1"
value={cfg}
onChange={(e) => setCfg(Number(e.target.value))}
/>
</div>
<div>
<div className="label-row label-row--spread">
<span>{t('clone.speed')}</span>
<span className="val-bubble">{speed}x</span>
</div>
<div>
<div className="label-row label-row--spread"><span>{t('clone.tshift')}</span><span className="val-bubble">{tShift}</span></div>
<input type="range" min="0" max="1.0" step="0.05" value={tShift} onChange={e => setTShift(Number(e.target.value))} />
<input
type="range"
min="0.5"
max="2.0"
step="0.1"
value={speed}
onChange={(e) => setSpeed(Number(e.target.value))}
/>
</div>
<div>
<div className="label-row label-row--spread">
<span>{t('clone.tshift')}</span>
<span className="val-bubble">{tShift}</span>
</div>
<div>
<div className="label-row label-row--spread"><span>{t('clone.pos_temp')}</span><span className="val-bubble">{posTemp}</span></div>
<input type="range" min="0" max="10" step="0.5" value={posTemp} onChange={e => setPosTemp(Number(e.target.value))} />
<input
type="range"
min="0"
max="1.0"
step="0.05"
value={tShift}
onChange={(e) => setTShift(Number(e.target.value))}
/>
</div>
<div>
<div className="label-row label-row--spread">
<span>{t('clone.pos_temp')}</span>
<span className="val-bubble">{posTemp}</span>
</div>
<div>
<div className="label-row label-row--spread"><span>{t('clone.class_temp')}</span><span className="val-bubble">{classTemp}</span></div>
<input type="range" min="0" max="2" step="0.1" value={classTemp} onChange={e => setClassTemp(Number(e.target.value))} />
<input
type="range"
min="0"
max="10"
step="0.5"
value={posTemp}
onChange={(e) => setPosTemp(Number(e.target.value))}
/>
</div>
<div>
<div className="label-row label-row--spread">
<span>{t('clone.class_temp')}</span>
<span className="val-bubble">{classTemp}</span>
</div>
<div>
<div className="label-row label-row--spread"><span>{t('clone.layer_pen')}</span><span className="val-bubble">{layerPenalty}</span></div>
<input type="range" min="0" max="10" step="0.5" value={layerPenalty} onChange={e => setLayerPenalty(Number(e.target.value))} />
<input
type="range"
min="0"
max="2"
step="0.1"
value={classTemp}
onChange={(e) => setClassTemp(Number(e.target.value))}
/>
</div>
<div>
<div className="label-row label-row--spread">
<span>{t('clone.layer_pen')}</span>
<span className="val-bubble">{layerPenalty}</span>
</div>
<div>
<div className="label-row"><span>{t('clone.duration')}</span></div>
<input type="text" className="input-base clone-duration-input" value={duration} onChange={e => setDuration(e.target.value)} placeholder={t('clone.auto')} />
</div>
<div className="clone-prod-col">
<label className="clone-prod-check">
<input type="checkbox" checked={denoise} onChange={e => setDenoise(e.target.checked)} /> {t('clone.denoise')}
</label>
<label className="clone-prod-check">
<input type="checkbox" checked={postprocess} onChange={e => setPostprocess(e.target.checked)} /> {t('clone.postprocess')}
</label>
<input
type="range"
min="0"
max="10"
step="0.5"
value={layerPenalty}
onChange={(e) => setLayerPenalty(Number(e.target.value))}
/>
</div>
<div>
<div className="label-row">
<span>{t('clone.duration')}</span>
</div>
<input
type="text"
className="input-base clone-duration-input"
value={duration}
onChange={(e) => setDuration(e.target.value)}
placeholder={t('clone.auto')}
/>
</div>
<div className="clone-prod-col">
<label className="clone-prod-check">
<input
type="checkbox"
checked={denoise}
onChange={(e) => setDenoise(e.target.checked)}
/>{' '}
{t('clone.denoise')}
</label>
<label className="clone-prod-check">
<input
type="checkbox"
checked={postprocess}
onChange={(e) => setPostprocess(e.target.checked)}
/>{' '}
{t('clone.postprocess')}
</label>
</div>
</div>
)}
{/* Controls row: language · steps · overrides disclosure */}
<div className="studio-action-bar__row">
<div className="studio-action-bar__lang">
<Globe size={12} className="label-icon" />
<SearchableSelect
value={language}
options={ALL_LANGUAGES}
popular={POPULAR_LANGS}
recentsKey="omnivoice.recents.genLang"
onChange={setLanguage}
/>
</div>
<label className="studio-action-bar__steps" title={t('clone.steps')}>
<SlidersHorizontal size={12} className="label-icon" />
<input type="range" min="8" max="64" value={steps} onChange={e => setSteps(Number(e.target.value))} />
<span className="val-bubble">{steps}</span>
</label>
<button
type="button"
className="studio-action-bar__overrides"
onClick={() => setShowOverrides(!showOverrides)}
aria-expanded={showOverrides}
>
<Settings2 size={13} /> {t('clone.production_overrides')}
{showOverrides ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
</button>
</div>
)}
{showHearDemo ? (
<>
<Button
variant="primary"
block
onClick={playDemoOutput}
leading={<Play size={14} />}
className="clone-footer-cta"
>
{demoAudioPlaying ? t('demo.stop_demo') : t('demo.hear_demo')}
</Button>
<div className="clone-hear-demo-chip">
{t('demo.prerendered_chip')}
</div>
<audio
ref={demoAudioRef}
onEnded={() => {
setDemoAudioPlaying(false);
demoReleaseRef.current?.();
demoReleaseRef.current = null;
}}
preload="none"
/>
</>
) : outputPlaying && !isGenerating ? (
/* Synthesized output is playing the CTA becomes a Stop button
(#316) so playback can be halted immediately. */
<Button
variant="primary"
block
onClick={stopActivePlayback}
leading={<Square size={14} />}
className="clone-footer-cta"
>
{t('clone.stop_playback')}
</Button>
) : (
<Button
variant="primary"
block
loading={isGenerating}
onClick={handleGenerate}
leading={!isGenerating && <Play size={14} />}
className="clone-footer-cta"
>
{isGenerating ? t('clone.synthesizing', { seconds: generationTime }) : t('clone.synthesize')}
</Button>
)}
{isGenerating && (
<Progress
value={Math.min((generationTime / 8) * 100, 95)}
tone="brand"
size="sm"
className="clone-footer-cta"
{/* Controls row: language · steps · overrides disclosure */}
<div className="studio-action-bar__row">
<div className="studio-action-bar__lang">
<Globe size={12} className="label-icon" />
<SearchableSelect
value={language}
options={ALL_LANGUAGES}
popular={POPULAR_LANGS}
recentsKey="omnivoice.recents.genLang"
onChange={setLanguage}
/>
)}
{/* 10x P4 a11y (spec §3): persistent polite live region screen
</div>
<label className="studio-action-bar__steps" title={t('clone.steps')}>
<SlidersHorizontal size={12} className="label-icon" />
<input
type="range"
min="8"
max="64"
value={steps}
onChange={(e) => setSteps(Number(e.target.value))}
/>
<span className="val-bubble">{steps}</span>
</label>
<button
type="button"
className="studio-action-bar__overrides"
onClick={() => setShowOverrides(!showOverrides)}
aria-expanded={showOverrides}
>
<Settings2 size={13} /> {t('clone.production_overrides')}
{showOverrides ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
</button>
</div>
{showHearDemo ? (
<>
<Button
variant="primary"
block
onClick={playDemoOutput}
leading={<Play size={14} />}
className="clone-footer-cta"
>
{demoAudioPlaying ? t('demo.stop_demo') : t('demo.hear_demo')}
</Button>
<div className="clone-hear-demo-chip">{t('demo.prerendered_chip')}</div>
<audio
ref={demoAudioRef}
onEnded={() => {
setDemoAudioPlaying(false);
demoReleaseRef.current?.();
demoReleaseRef.current = null;
}}
preload="none"
/>
</>
) : outputPlaying && !isGenerating ? (
/* Synthesized output is playing the CTA becomes a Stop button
(#316) so playback can be halted immediately. */
<Button
variant="primary"
block
onClick={stopActivePlayback}
leading={<Square size={14} />}
className="clone-footer-cta"
>
{t('clone.stop_playback')}
</Button>
) : (
<Button
variant="primary"
block
loading={isGenerating}
onClick={handleGenerate}
leading={!isGenerating && <Play size={14} />}
className="clone-footer-cta"
>
{isGenerating
? t('clone.synthesizing', { seconds: generationTime })
: t('clone.synthesize')}
</Button>
)}
{isGenerating && (
<Progress
value={Math.min((generationTime / 8) * 100, 95)}
tone="brand"
size="sm"
className="clone-footer-cta"
/>
)}
{/* 10x P4 a11y (spec §3): persistent polite live region screen
readers hear generation start AND finish in-workspace, without
relying on the FloatingPill. sr-only keeps it out of the
action-bar flex flow; static text avoids per-second re-announces
from the ticking "Synthesizing… (Ns)" button label. */}
<div className="sr-only" role="status" aria-live="polite">
{isGenerating
? t('clone.generating_status', { defaultValue: 'Generating audio…' })
: wasGeneratingRef.current
? t('clone.generating_done_status', { defaultValue: 'Generation finished' })
: null}
</div>
<div className="sr-only" role="status" aria-live="polite">
{isGenerating
? t('clone.generating_status', { defaultValue: 'Generating audio…' })
: wasGeneratingRef.current
? t('clone.generating_done_status', { defaultValue: 'Generation finished' })
: null}
</div>
</div>
);
}
+195 -126
View File
@@ -3,140 +3,209 @@ import { Button, Input } from '../../ui';
import MicButton from './MicButton';
export default function AudioMethodPanel({
t, selectedProfile, setSelectedProfile, profiles, ingestRefAudio, refAudio,
isCleaning, isRecording, recordingTime, startRecording, stopRecording,
refText, setRefText, instruct, setInstruct,
defineMethod, designSeed, setDesignSeed, keepSeed, setKeepSeed,
showSaveProfile, setShowSaveProfile, profileName, setProfileName, handleSaveProfile,
t,
selectedProfile,
setSelectedProfile,
profiles,
ingestRefAudio,
refAudio,
isCleaning,
isRecording,
recordingTime,
startRecording,
stopRecording,
refText,
setRefText,
instruct,
setInstruct,
defineMethod,
designSeed,
setDesignSeed,
keepSeed,
setKeepSeed,
showSaveProfile,
setShowSaveProfile,
profileName,
setProfileName,
handleSaveProfile,
}) {
return (
<div>
{/* Saved voices now live in the right-side WorkspaceVoices panel. */}
<div>
{/* Saved voices now live in the right-side WorkspaceVoices panel. */}
{!selectedProfile && (
<div className="clone-drop-row">
<input
type="file"
accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg"
onChange={e => { const f = e.target.files[0]; ingestRefAudio(f); e.target.value = ''; }}
className="dub-hidden-file"
id="audio-upload"
/>
<label
htmlFor="audio-upload"
className="file-drag clone-drop-zone"
onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add('is-dragging'); }}
onDragLeave={e => { e.currentTarget.classList.remove('is-dragging'); }}
onDrop={e => {
e.preventDefault();
e.currentTarget.classList.remove('is-dragging');
const file = e.dataTransfer.files[0];
const okType = file && (file.type.startsWith('audio/') || /\.(mp3|wav|m4a|flac|ogg|aac|webm)$/i.test(file.name));
if (okType) ingestRefAudio(file);
}}
>
<UploadCloud color="#a89984" size={18} />
<p>{refAudio ? <span className="clone-drop-filename">{refAudio.name}</span> : t('clone.drop_audio')}</p>
</label>
{!selectedProfile && (
<div className="clone-drop-row">
<input
type="file"
accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg"
onChange={(e) => {
const f = e.target.files[0];
ingestRefAudio(f);
e.target.value = '';
}}
className="dub-hidden-file"
id="audio-upload"
/>
<label
htmlFor="audio-upload"
className="file-drag clone-drop-zone"
onDragOver={(e) => {
e.preventDefault();
e.currentTarget.classList.add('is-dragging');
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove('is-dragging');
}}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.classList.remove('is-dragging');
const file = e.dataTransfer.files[0];
const okType =
file &&
(file.type.startsWith('audio/') ||
/\.(mp3|wav|m4a|flac|ogg|aac|webm)$/i.test(file.name));
if (okType) ingestRefAudio(file);
}}
>
<UploadCloud color="#a89984" size={18} />
<p>
{refAudio ? (
<span className="clone-drop-filename">{refAudio.name}</span>
) : (
t('clone.drop_audio')
)}
</p>
</label>
<MicButton
isCleaning={isCleaning}
isRecording={isRecording}
recordingTime={recordingTime}
onStart={startRecording}
onStop={stopRecording}
/>
</div>
)}
<MicButton
isCleaning={isCleaning}
isRecording={isRecording}
recordingTime={recordingTime}
onStart={startRecording}
onStop={stopRecording}
/>
</div>
)}
{selectedProfile && (
<div className="clone-profile-banner">
<span className="clone-profile-banner__label">
{t('clone.using_profile', { name: profiles.find(p => p.id === selectedProfile)?.name })}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedProfile(null)}
leading={<X size={11} />}
>
{t('clone.clear')}
</Button>
</div>
)}
{selectedProfile && (
<div className="clone-profile-banner">
<span className="clone-profile-banner__label">
{t('clone.using_profile', {
name: profiles.find((p) => p.id === selectedProfile)?.name,
})}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedProfile(null)}
leading={<X size={11} />}
>
{t('clone.clear')}
</Button>
</div>
)}
<div className="grid-2 grid-2--indent">
<div>
<div className="label-row">{t('clone.transcript')}</div>
<input type="text" className="input-base" value={refText} onChange={e => setRefText(e.target.value)} placeholder={t('clone.optional')} />
</div>
<div>
<div className="label-row">{t('clone.style')}</div>
<input type="text" className="input-base" value={instruct} onChange={e => setInstruct(e.target.value)} placeholder={t('clone.style_placeholder')} />
</div>
</div>
<div className="grid-2 grid-2--indent">
<div>
<div className="label-row">{t('clone.transcript')}</div>
<input
type="text"
className="input-base"
value={refText}
onChange={(e) => setRefText(e.target.value)}
placeholder={t('clone.optional')}
/>
</div>
<div>
<div className="label-row">{t('clone.style')}</div>
<input
type="text"
className="input-base"
value={instruct}
onChange={(e) => setInstruct(e.target.value)}
placeholder={t('clone.style_placeholder')}
/>
</div>
</div>
{/* #526: voice-design seed show + pin + re-roll so tweaks can
{/* #526: voice-design seed show + pin + re-roll so tweaks can
stay on the same base timbre. Design mode only. */}
{defineMethod === 'design' && (
<div className="design-seed">
<div className="label-row">{t('clone.seed_label')}</div>
<div className="design-seed__row">
<input
type="number"
className="input-base design-seed__input"
value={designSeed ?? ''}
placeholder={t('clone.seed_placeholder')}
onChange={e => {
const v = e.target.value.trim();
if (v === '') { setDesignSeed(null); return; }
const n = parseInt(v, 10);
if (Number.isInteger(n)) { setDesignSeed(n); setKeepSeed(true); }
}}
/>
<Button
variant="subtle"
size="sm"
onClick={() => { setDesignSeed(Math.floor(Math.random() * 2147483647)); setKeepSeed(true); }}
leading={<Dice5 size={12} />}
title={t('clone.seed_reroll_hint')}
>
{t('clone.seed_reroll')}
</Button>
<label className="design-seed__keep">
<input type="checkbox" checked={keepSeed} onChange={e => setKeepSeed(e.target.checked)} />
<span>{t('clone.seed_keep')}</span>
</label>
</div>
</div>
)}
{/* Save as profile */}
{refAudio && !selectedProfile && (
<div className="clone-save-profile">
{!showSaveProfile ? (
<Button
variant="subtle"
size="sm"
onClick={() => setShowSaveProfile(true)}
leading={<Save size={12} />}
>
{t('clone.save_as_profile')}
</Button>
) : (
<div className="clone-save-profile__row">
<Input
size="sm"
placeholder={t('clone.profile_name')}
value={profileName}
onChange={e => setProfileName(e.target.value)}
/>
<Button variant="subtle" size="sm" onClick={handleSaveProfile}>{t('clone.save')}</Button>
<Button variant="ghost" size="sm" onClick={() => setShowSaveProfile(false)}>{t('clone.cancel')}</Button>
</div>
)}
</div>
)}
{defineMethod === 'design' && (
<div className="design-seed">
<div className="label-row">{t('clone.seed_label')}</div>
<div className="design-seed__row">
<input
type="number"
className="input-base design-seed__input"
value={designSeed ?? ''}
placeholder={t('clone.seed_placeholder')}
onChange={(e) => {
const v = e.target.value.trim();
if (v === '') {
setDesignSeed(null);
return;
}
const n = parseInt(v, 10);
if (Number.isInteger(n)) {
setDesignSeed(n);
setKeepSeed(true);
}
}}
/>
<Button
variant="subtle"
size="sm"
onClick={() => {
setDesignSeed(Math.floor(Math.random() * 2147483647));
setKeepSeed(true);
}}
leading={<Dice5 size={12} />}
title={t('clone.seed_reroll_hint')}
>
{t('clone.seed_reroll')}
</Button>
<label className="design-seed__keep">
<input
type="checkbox"
checked={keepSeed}
onChange={(e) => setKeepSeed(e.target.checked)}
/>
<span>{t('clone.seed_keep')}</span>
</label>
</div>
</div>
)}
{/* Save as profile */}
{refAudio && !selectedProfile && (
<div className="clone-save-profile">
{!showSaveProfile ? (
<Button
variant="subtle"
size="sm"
onClick={() => setShowSaveProfile(true)}
leading={<Save size={12} />}
>
{t('clone.save_as_profile')}
</Button>
) : (
<div className="clone-save-profile__row">
<Input
size="sm"
placeholder={t('clone.profile_name')}
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
/>
<Button variant="subtle" size="sm" onClick={handleSaveProfile}>
{t('clone.save')}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowSaveProfile(false)}>
{t('clone.cancel')}
</Button>
</div>
)}
</div>
)}
</div>
);
}
@@ -2,178 +2,236 @@ import { ChevronUp, ChevronDown, Save } from 'lucide-react';
import { Button, Input } from '../../ui';
import { PRESETS, CATEGORIES } from '../../utils/constants';
import {
PRESET_ICONS, PERSONALITY_ICONS, FALLBACK_VOICE_ICON, FALLBACK_PERSONALITY_ICON, stripVoiceEmoji,
PRESET_ICONS,
PERSONALITY_ICONS,
FALLBACK_VOICE_ICON,
FALLBACK_PERSONALITY_ICON,
stripVoiceEmoji,
} from '../../utils/voiceIcons';
import { buildDesignInstruct } from '../../utils/voiceInstruct';
export default function DesignMethodPanel({
t, describeText, onDescribeChange, describeMatchedAny, describeUnmatched,
chipPersonalities, activePersonality, applyPersonality, applyPreset,
identityOpen, setIdentityOpen, identityRecipe,
vdStates, setVdStates, onChipKeyDown,
showSaveProfile, setShowSaveProfile, profileName, setProfileName,
handleSaveDesignProfile, instruct, language,
t,
describeText,
onDescribeChange,
describeMatchedAny,
describeUnmatched,
chipPersonalities,
activePersonality,
applyPersonality,
applyPreset,
identityOpen,
setIdentityOpen,
identityRecipe,
vdStates,
setVdStates,
onChipKeyDown,
showSaveProfile,
setShowSaveProfile,
profileName,
setProfileName,
handleSaveDesignProfile,
instruct,
language,
}) {
return (
<div>
{/* Describe your voice (#317) free text drives the controls.
<div>
{/* Describe your voice (#317) free text drives the controls.
The placeholder explains itself; no extra header (10x §1.2). */}
<div className="describe-voice-block">
<textarea
className="input-base describe-voice-area"
rows={2}
placeholder={t('clone.describe_placeholder')}
value={describeText}
onChange={onDescribeChange}
/>
{describeText.trim() && !describeMatchedAny && (
<div className="describe-voice-feedback" role="status">
{t('clone.describe_no_match')}
</div>
)}
{describeMatchedAny && describeUnmatched.length > 0 && (
<div className="describe-voice-feedback" role="status">
{t('clone.describe_unmatched', { items: describeUnmatched.join(', ') })}
</div>
)}
<div className="describe-voice-hint">{t('clone.describe_hint')}</div>
</div>
<div className="describe-voice-block">
<textarea
className="input-base describe-voice-area"
rows={2}
placeholder={t('clone.describe_placeholder')}
value={describeText}
onChange={onDescribeChange}
/>
{describeText.trim() && !describeMatchedAny && (
<div className="describe-voice-feedback" role="status">
{t('clone.describe_no_match')}
</div>
)}
{describeMatchedAny && describeUnmatched.length > 0 && (
<div className="describe-voice-feedback" role="status">
{t('clone.describe_unmatched', { items: describeUnmatched.join(', ') })}
</div>
)}
<div className="describe-voice-hint">{t('clone.describe_hint')}</div>
</div>
{/* ONE preset system (10x §1.3): personalities + the old PROMPT
{/* ONE preset system (10x §1.3): personalities + the old PROMPT
presets share a single scrollable "Starting points" lane
both set vdStates + instruct; two widgets for one slot was
the confusion. */}
<div className="starting-points">
<div className="starting-points__label">{t('clone.starting_points', { defaultValue: 'Starting points' })}</div>
<div className="personality-strip starting-points__strip">
{chipPersonalities.map(p => {
const Icon = PERSONALITY_ICONS[p.id] || FALLBACK_PERSONALITY_ICON;
return (
<button
key={p.id}
type="button"
className={`personality-chip ${activePersonality === p.id ? 'active' : ''}`}
onClick={() => applyPersonality(p)}
>
<span className="personality-chip__icon"><Icon size={13} /></span>
{stripVoiceEmoji(t(`clone.personality_${p.id}`, { defaultValue: p.name }))}
</button>
);
})}
{PRESETS.map(p => {
const Icon = PRESET_ICONS[p.id] || FALLBACK_VOICE_ICON;
return (
<button key={p.id} type="button" className="personality-chip" onClick={() => applyPreset(p)}>
<span className="personality-chip__icon"><Icon size={13} /></span>
{stripVoiceEmoji(t(`clone.preset_${p.id}`, { defaultValue: p.name }))}
</button>
);
})}
</div>
</div>
{/* Identity recipe (10x §1.5): once any category is set, the
<div className="starting-points">
<div className="starting-points__label">
{t('clone.starting_points', { defaultValue: 'Starting points' })}
</div>
<div className="personality-strip starting-points__strip">
{chipPersonalities.map((p) => {
const Icon = PERSONALITY_ICONS[p.id] || FALLBACK_PERSONALITY_ICON;
return (
<button
key={p.id}
type="button"
className={`personality-chip ${activePersonality === p.id ? 'active' : ''}`}
onClick={() => applyPersonality(p)}
>
<span className="personality-chip__icon">
<Icon size={13} />
</span>
{stripVoiceEmoji(t(`clone.personality_${p.id}`, { defaultValue: p.name }))}
</button>
);
})}
{PRESETS.map((p) => {
const Icon = PRESET_ICONS[p.id] || FALLBACK_VOICE_ICON;
return (
<button
key={p.id}
type="button"
className="personality-chip"
onClick={() => applyPreset(p)}
>
<span className="personality-chip__icon">
<Icon size={13} />
</span>
{stripVoiceEmoji(t(`clone.preset_${p.id}`, { defaultValue: p.name }))}
</button>
);
})}
</div>
</div>
{/* Identity recipe (10x §1.5): once any category is set, the
chip groups collapse to one quiet line the current voice
recipe and the describe box rewrites it live. All-Auto
(first run) starts expanded. */}
<button
type="button"
className="identity-line"
onClick={() => setIdentityOpen(o => !o)}
aria-expanded={identityOpen}
>
<span className="identity-line__kicker">{t('clone.identity', { defaultValue: 'Identity' })}</span>
<span className="identity-line__recipe">{identityRecipe}</span>
{identityOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{identityOpen && (
<div className="clone-sliders-col">
{Object.entries(CATEGORIES).map(([key, options]) => {
const many = options.length > 6;
const optLabel = (val) => {
const tKey = `clone.opt_${val.replace(/[ -]/g, '_')}`;
const tl = t(tKey);
return tl !== tKey ? tl : val;
};
return (
<div key={key} className={`clone-cat ${many ? 'clone-cat--select' : 'clone-cat--chips'}`}>
<div className="label-row label-row--sm">
{t(`clone.cat_${key}`)}
<span className="clone-slider-kicker">
{vdStates[key] === 'Auto' ? t('clone.auto_kicker') : `· ${optLabel(vdStates[key])}`}
</span>
</div>
{many ? (
<select
className="input-base"
value={vdStates[key]}
onChange={e => setVdStates({ ...vdStates, [key]: e.target.value })}
>
{options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
) : (
<div className="chip-group" role="radiogroup" aria-label={t(`clone.cat_${key}`)}>
{options.map((opt, i) => {
const optTKey = `clone.opt_${opt.replace(/[ -]/g, '_')}`;
const optTl = t(optTKey);
const optLabel = optTl !== optTKey ? optTl : opt;
const checked = vdStates[key] === opt;
// Roving tabindex: the checked chip is the group's
// single tab stop (first chip if nothing matches).
const roving = checked || (!options.includes(vdStates[key]) && i === 0);
return (
<button
key={opt}
type="button"
role="radio"
aria-checked={checked}
tabIndex={roving ? 0 : -1}
className={`chip ${checked ? 'active' : ''}`}
onClick={() => setVdStates({ ...vdStates, [key]: opt })}
onKeyDown={e => onChipKeyDown(e, key, options)}
>
{opt === 'Auto'
? <span className="chip-auto"><FALLBACK_VOICE_ICON size={11} /> {stripVoiceEmoji(t('clone.opt_Auto'))}</span>
: optLabel}
</button>
);
})}
</div>
)}
<button
type="button"
className="identity-line"
onClick={() => setIdentityOpen((o) => !o)}
aria-expanded={identityOpen}
>
<span className="identity-line__kicker">
{t('clone.identity', { defaultValue: 'Identity' })}
</span>
<span className="identity-line__recipe">{identityRecipe}</span>
{identityOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{identityOpen && (
<div className="clone-sliders-col">
{Object.entries(CATEGORIES).map(([key, options]) => {
const many = options.length > 6;
const optLabel = (val) => {
const tKey = `clone.opt_${val.replace(/[ -]/g, '_')}`;
const tl = t(tKey);
return tl !== tKey ? tl : val;
};
return (
<div
key={key}
className={`clone-cat ${many ? 'clone-cat--select' : 'clone-cat--chips'}`}
>
<div className="label-row label-row--sm">
{t(`clone.cat_${key}`)}
<span className="clone-slider-kicker">
{vdStates[key] === 'Auto'
? t('clone.auto_kicker')
: `· ${optLabel(vdStates[key])}`}
</span>
</div>
{many ? (
<select
className="input-base"
value={vdStates[key]}
onChange={(e) => setVdStates({ ...vdStates, [key]: e.target.value })}
>
{options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
) : (
<div className="chip-group" role="radiogroup" aria-label={t(`clone.cat_${key}`)}>
{options.map((opt, i) => {
const optTKey = `clone.opt_${opt.replace(/[ -]/g, '_')}`;
const optTl = t(optTKey);
const optLabel = optTl !== optTKey ? optTl : opt;
const checked = vdStates[key] === opt;
// Roving tabindex: the checked chip is the group's
// single tab stop (first chip if nothing matches).
const roving = checked || (!options.includes(vdStates[key]) && i === 0);
return (
<button
key={opt}
type="button"
role="radio"
aria-checked={checked}
tabIndex={roving ? 0 : -1}
className={`chip ${checked ? 'active' : ''}`}
onClick={() => setVdStates({ ...vdStates, [key]: opt })}
onKeyDown={(e) => onChipKeyDown(e, key, options)}
>
{opt === 'Auto' ? (
<span className="chip-auto">
<FALLBACK_VOICE_ICON size={11} />{' '}
{stripVoiceEmoji(t('clone.opt_Auto'))}
</span>
) : (
optLabel
)}
</button>
);
})}
</div>
);
})}
</div>
)}
)}
</div>
);
})}
</div>
)}
{/* Save the current design as a reusable profile (0005): the
{/* Save the current design as a reusable profile (0005): the
backend renders a deterministic identity sample (seed 42)
and stores the slider picks for later re-editing. */}
<div className="clone-save-profile">
{!showSaveProfile ? (
<Button
variant="subtle"
size="sm"
onClick={() => setShowSaveProfile(true)}
leading={<Save size={12} />}
>
{t('clone.save_design_as_profile', { defaultValue: 'Save design as profile' })}
</Button>
) : (
<div className="clone-save-profile__row">
<Input
size="sm"
placeholder={t('clone.profile_name')}
value={profileName}
onChange={e => setProfileName(e.target.value)}
/>
<Button variant="subtle" size="sm"
onClick={() => handleSaveDesignProfile(vdStates, buildDesignInstruct(vdStates, instruct).instruct, language)}>
{t('clone.save')}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowSaveProfile(false)}>{t('clone.cancel')}</Button>
</div>
)}
</div>
<div className="clone-save-profile">
{!showSaveProfile ? (
<Button
variant="subtle"
size="sm"
onClick={() => setShowSaveProfile(true)}
leading={<Save size={12} />}
>
{t('clone.save_design_as_profile', { defaultValue: 'Save design as profile' })}
</Button>
) : (
<div className="clone-save-profile__row">
<Input
size="sm"
placeholder={t('clone.profile_name')}
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
/>
<Button
variant="subtle"
size="sm"
onClick={() =>
handleSaveDesignProfile(
vdStates,
buildDesignInstruct(vdStates, instruct).instruct,
language,
)
}
>
{t('clone.save')}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowSaveProfile(false)}>
{t('clone.cancel')}
</Button>
</div>
)}
</div>
</div>
);
}
+6 -1
View File
@@ -20,7 +20,12 @@ export default function MicButton({ isCleaning, isRecording, recordingTime, onSt
);
}
return (
<button type="button" onClick={onStart} className="mic-btn mic-btn--idle" title={t('clone.record')}>
<button
type="button"
onClick={onStart}
className="mic-btn mic-btn--idle"
title={t('clone.record')}
>
<Mic size={18} />
<span>{t('clone.record')}</span>
</button>
+93 -68
View File
@@ -3,86 +3,111 @@ import DemoPresetGrid from '../DemoPresetGrid';
import { TAGS } from '../../utils/constants';
export default function ScriptPanel({
t, defineMethod, text, setText, activePersonality,
demoPresets, applyDemoPreset,
showDemoCoachmark, setShowDemoCoachmark, selectedProfile, DEMO_PROFILE_ID,
textAreaRef, insertOpen, setInsertOpen, insertTag,
t,
defineMethod,
text,
setText,
activePersonality,
demoPresets,
applyDemoPreset,
showDemoCoachmark,
setShowDemoCoachmark,
selectedProfile,
DEMO_PROFILE_ID,
textAreaRef,
insertOpen,
setInsertOpen,
insertTag,
}) {
return (
<div className="studio-column">
{/* overflow-visible: the Insert popover opens above the textarea and
<div className="studio-column">
{/* overflow-visible: the Insert popover opens above the textarea and
must escape the panel's `overflow:auto` box instead of being clipped
into its scroll region (#481). */}
<div className="studio-panel clone-panel--overflow-visible">
<div className="label-row label-row--center">
<Command className="label-icon" size={14} /> {t('clone.script', { defaultValue: 'Script' })}
</div>
{/* Design-tab empty state: 7-card demo grid until the user
<div className="studio-panel clone-panel--overflow-visible">
<div className="label-row label-row--center">
<Command className="label-icon" size={14} />{' '}
{t('clone.script', { defaultValue: 'Script' })}
</div>
{/* Design-tab empty state: 7-card demo grid until the user
interacts; then it steps aside for the standard form. */}
{defineMethod === 'design' && !text && !activePersonality && demoPresets.length > 0 && (
<DemoPresetGrid presets={demoPresets} onUse={applyDemoPreset} />
{defineMethod === 'design' && !text && !activePersonality && demoPresets.length > 0 && (
<DemoPresetGrid presets={demoPresets} onUse={applyDemoPreset} />
)}
{showDemoCoachmark && defineMethod === 'audio' && selectedProfile === DEMO_PROFILE_ID && (
<div className="clone-coachmark" role="note">
<span className="clone-coachmark__icon">💡</span>
<span className="clone-coachmark__msg">{t('demo.clone_coachmark')}</span>
<button
type="button"
className="clone-coachmark__close"
onClick={() => setShowDemoCoachmark(false)}
aria-label="Dismiss coach mark"
>
×
</button>
</div>
)}
<div className="clone-script-wrap">
<textarea
ref={textAreaRef}
className="input-base clone-text-area"
placeholder={
defineMethod === 'audio'
? t('clone.prompt_placeholder')
: t('clone.design_placeholder')
}
value={text}
onChange={(e) => {
setText(e.target.value);
if (showDemoCoachmark) setShowDemoCoachmark(false);
}}
/>
{/* Expression tokens live behind a popover fourteen permanent
chips were renting the page's best pixels for an occasional
power feature (10x spec §1.4). */}
<button
type="button"
className={`clone-insert-btn ${insertOpen ? 'is-open' : ''}`}
onClick={() => setInsertOpen((o) => !o)}
aria-expanded={insertOpen}
aria-label={t('clone.insert_token', { defaultValue: 'Insert expression token' })}
>
<Plus size={11} /> {t('clone.insert', { defaultValue: 'Insert' })}{' '}
<ChevronDown size={10} />
</button>
{insertOpen && (
<div className="clone-insert-backdrop" onClick={() => setInsertOpen(false)} />
)}
{showDemoCoachmark && defineMethod === 'audio' && selectedProfile === DEMO_PROFILE_ID && (
<div className="clone-coachmark" role="note">
<span className="clone-coachmark__icon">💡</span>
<span className="clone-coachmark__msg">
{t('demo.clone_coachmark')}
</span>
{insertOpen && (
<div className="clone-insert-pop" role="menu">
{TAGS.map((tag) => (
<button
key={tag}
className="tag-btn"
role="menuitem"
onClick={() => {
insertTag(tag);
setInsertOpen(false);
}}
>
{tag}
</button>
))}
<button
type="button"
className="clone-coachmark__close"
onClick={() => setShowDemoCoachmark(false)}
aria-label="Dismiss coach mark"
className="tag-btn clone-auto-extract-btn"
role="menuitem"
onClick={() => {
insertTag('[B EY1 S]');
setInsertOpen(false);
}}
>
×
[CMU]
</button>
</div>
)}
<div className="clone-script-wrap">
<textarea
ref={textAreaRef}
className="input-base clone-text-area"
placeholder={defineMethod === 'audio' ? t('clone.prompt_placeholder') : t('clone.design_placeholder')}
value={text}
onChange={e => {
setText(e.target.value);
if (showDemoCoachmark) setShowDemoCoachmark(false);
}}
/>
{/* Expression tokens live behind a popover fourteen permanent
chips were renting the page's best pixels for an occasional
power feature (10x spec §1.4). */}
<button
type="button"
className={`clone-insert-btn ${insertOpen ? 'is-open' : ''}`}
onClick={() => setInsertOpen(o => !o)}
aria-expanded={insertOpen}
aria-label={t('clone.insert_token', { defaultValue: 'Insert expression token' })}
>
<Plus size={11} /> {t('clone.insert', { defaultValue: 'Insert' })} <ChevronDown size={10} />
</button>
{insertOpen && (
<div className="clone-insert-backdrop" onClick={() => setInsertOpen(false)} />
)}
{insertOpen && (
<div className="clone-insert-pop" role="menu">
{TAGS.map(tag => (
<button key={tag} className="tag-btn" role="menuitem"
onClick={() => { insertTag(tag); setInsertOpen(false); }}>
{tag}
</button>
))}
<button
className="tag-btn clone-auto-extract-btn" role="menuitem"
onClick={() => { insertTag('[B EY1 S]'); setInsertOpen(false); }}
>
[CMU]
</button>
</div>
)}
</div>
</div>
</div>
</div>
);
}
+12 -21
View File
@@ -1,12 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Pip from './Pip';
import {
BUNDLED_PROGRESS,
loadDonationProgress,
progressPct,
isGoalMet,
} from '../../api/donation';
import { BUNDLED_PROGRESS, loadDonationProgress, progressPct, isGoalMet } from '../../api/donation';
import './DonateGoal.css';
/**
@@ -44,10 +39,14 @@ export default function GoalBar({ mini = false, progress: injected = null, class
const [fetched, setFetched] = useState(BUNDLED_PROGRESS);
useEffect(() => {
if (injected) return undefined; // caller owns the data skip fetch
if (injected) return undefined; // caller owns the data skip fetch
let alive = true;
loadDonationProgress().then((p) => { if (alive) setFetched(p); });
return () => { alive = false; };
loadDonationProgress().then((p) => {
if (alive) setFetched(p);
});
return () => {
alive = false;
};
}, [injected]);
const data = injected || fetched;
@@ -61,12 +60,9 @@ export default function GoalBar({ mini = false, progress: injected = null, class
return (
<div
className={[
'goal',
mini ? 'goal--mini' : 'goal--page',
met ? 'goal--met' : '',
className,
].filter(Boolean).join(' ')}
className={['goal', mini ? 'goal--mini' : 'goal--page', met ? 'goal--met' : '', className]
.filter(Boolean)
.join(' ')}
style={{ '--goal-pct': pct }}
>
{!mini && (
@@ -111,12 +107,7 @@ export default function GoalBar({ mini = false, progress: injected = null, class
) : (
<>
<span className="goal__amounts">
<strong>{raisedStr}</strong>
{' '}
{t('donate.goal.of', { defaultValue: 'of' })}
{' '}
{goalStr}
{' '}
<strong>{raisedStr}</strong> {t('donate.goal.of', { defaultValue: 'of' })} {goalStr}{' '}
{t('donate.goal.per_month', { defaultValue: '/ month' })}
</span>
{!mini && (
+1 -7
View File
@@ -16,13 +16,7 @@ export default function Pip({ size = 22, className = '', waving = true }) {
aria-hidden="true"
style={{ width: size, height: size }}
>
<svg
viewBox="0 0 32 32"
width={size}
height={size}
fill="none"
role="presentation"
>
<svg viewBox="0 0 32 32" width={size} height={size} fill="none" role="presentation">
{/* soft glow halo */}
<ellipse cx="16" cy="17" rx="11" ry="10.5" fill="currentColor" opacity="0.16" />
{/* body */}
+28 -10
View File
@@ -26,14 +26,36 @@ const STAR_URL = 'https://github.com/debpalash/OmniVoice-Studio';
*/
function leadKey(milestone) {
switch (milestone) {
case 'first-clone': return { k: 'donate.postcard.lead_first_clone', d: 'Your first voice clone is done — nice! OmniVoice runs entirely on your machine, and your support keeps it that way.' };
case 'tenth-dub': return { k: 'donate.postcard.lead_tenth_dub', d: "Ten dubs in — you're clearly putting it to work. A small monthly chip-in funds the Claude Max that ships these features." };
case 'sustained-30d': return { k: 'donate.postcard.lead_sustained', d: "You've been with OmniVoice for a month. If it's earned a spot in your workflow, consider helping fund what's next." };
default: return { k: 'donate.postcard.lead_default', d: 'Glad that worked! OmniVoice is free and fully local. If it saves you time, a small monthly chip-in funds the Claude Max behind it.' };
case 'first-clone':
return {
k: 'donate.postcard.lead_first_clone',
d: 'Your first voice clone is done — nice! OmniVoice runs entirely on your machine, and your support keeps it that way.',
};
case 'tenth-dub':
return {
k: 'donate.postcard.lead_tenth_dub',
d: "Ten dubs in — you're clearly putting it to work. A small monthly chip-in funds the Claude Max that ships these features.",
};
case 'sustained-30d':
return {
k: 'donate.postcard.lead_sustained',
d: "You've been with OmniVoice for a month. If it's earned a spot in your workflow, consider helping fund what's next.",
};
default:
return {
k: 'donate.postcard.lead_default',
d: 'Glad that worked! OmniVoice is free and fully local. If it saves you time, a small monthly chip-in funds the Claude Max behind it.',
};
}
}
export default function Postcard({ t: tt, milestone = null, progress = null, onDismiss, onOptOut }) {
export default function Postcard({
t: tt,
milestone = null,
progress = null,
onDismiss,
onOptOut,
}) {
const { t } = useTranslation();
const lead = leadKey(milestone);
@@ -50,11 +72,7 @@ export default function Postcard({ t: tt, milestone = null, progress = null, onD
};
return (
<div
className={`postcard ${tt?.visible ? '' : 'is-leaving'}`}
role="status"
aria-live="polite"
>
<div className={`postcard ${tt?.visible ? '' : 'is-leaving'}`} role="status" aria-live="polite">
{/* dot-grain texture + perforation are pure CSS pseudo-elements */}
<span className="postcard__grain" aria-hidden="true" />
@@ -33,7 +33,9 @@ export function evaluateDonationPrompt(kind = 'generic', opts = {}) {
// Postcard falls back to the bundled snapshot if this is slow/offline.
let progress = null;
loadDonationProgress()
.then((p) => { progress = p; })
.then((p) => {
progress = p;
})
.catch(() => {});
toast.custom(
@@ -43,12 +45,15 @@ export function evaluateDonationPrompt(kind = 'generic', opts = {}) {
milestone={decision.milestone}
progress={progress}
onDismiss={() => toast.dismiss(id)}
onOptOut={() => { useAppStore.getState().optOutOfDonation(); toast.dismiss(id); }}
onOptOut={() => {
useAppStore.getState().optOutOfDonation();
toast.dismiss(id);
}}
/>
),
{
id,
duration: 12000, // auto-dismiss ~12s
duration: 12000, // auto-dismiss ~12s
position: 'bottom-right',
},
);
+97 -69
View File
@@ -2,77 +2,105 @@ import { Check, AlertCircle } from 'lucide-react';
import { Badge } from '../../ui';
import DubFailureNotice from './DubFailureNotice';
export default function DubFooter({ t, dubStep, dubTracks, incrementalPlan, dubError, dubFailure, exportTracks, setExportTracks, dubSegments, translateQuality }) {
export default function DubFooter({
t,
dubStep,
dubTracks,
incrementalPlan,
dubError,
dubFailure,
exportTracks,
setExportTracks,
dubSegments,
translateQuality,
}) {
return (
<div className="studio-panel dub-footer-panel">
{dubStep === 'done' && (
<div className="dub-footer-banner">
<Badge tone="success">
<Check size={11} /> {t('dub.tracks_done', { tracks: dubTracks.join(', ') })}
</Badge>
{incrementalPlan && incrementalPlan.stale?.length > 0 && (
<Badge tone="warn" className="dub-footer-banner__badge-gap">
{t('dub.segments_changed', { count: incrementalPlan.stale.length })}
</Badge>
)}
{incrementalPlan && incrementalPlan.stale?.length === 0 && incrementalPlan.fresh?.length > 0 && (
<Badge tone="neutral" className="dub-footer-banner__badge-gap">
{t('dub.all_up_to_date', { count: incrementalPlan.fresh.length })}
</Badge>
)}
</div>
<div className="studio-panel dub-footer-panel">
{dubStep === 'done' && (
<div className="dub-footer-banner">
<Badge tone="success">
<Check size={11} /> {t('dub.tracks_done', { tracks: dubTracks.join(', ') })}
</Badge>
{incrementalPlan && incrementalPlan.stale?.length > 0 && (
<Badge tone="warn" className="dub-footer-banner__badge-gap">
{t('dub.segments_changed', { count: incrementalPlan.stale.length })}
</Badge>
)}
{incrementalPlan &&
incrementalPlan.stale?.length === 0 &&
incrementalPlan.fresh?.length > 0 && (
<Badge tone="neutral" className="dub-footer-banner__badge-gap">
{t('dub.all_up_to_date', { count: incrementalPlan.fresh.length })}
</Badge>
)}
{dubError && (
<div className="dub-footer-banner">
<Badge tone="danger">
<AlertCircle size={11} /> {dubError}
</Badge>
<DubFailureNotice failure={dubFailure} />
</div>
)}
{/* Output options + Timing moved to the top of the right (transcript) section. */}
{dubTracks.length > 0 && (
<div className="dub-tracks-row">
<span className="dub-tracks-row__title">{t('dub.export_tracks')}</span>
<label className={exportTracks['original'] !== false ? 'is-on' : 'is-off'}>
<input type="checkbox" checked={exportTracks['original'] !== false} onChange={e => setExportTracks(prev => ({ ...prev, original: e.target.checked }))} />
<span>{t('dub.original_track')}</span>
</label>
{dubTracks.map(t => (
<label key={t} className={exportTracks[t] !== false ? 'is-on is-success' : 'is-off'}>
<input type="checkbox" checked={exportTracks[t] !== false} onChange={e => setExportTracks(prev => ({ ...prev, [t]: e.target.checked }))} />
<span className="code">{t}</span>
</label>
))}
</div>
)}
{(() => {
// Pre-generation compression warning. Predicted by the
// translate response (see services/speech_rate.rate_ratio
// + dub_translate._maybe_cinematic), populated whenever
// segments carry a slot_seconds and translated text.
// Surfaces here so the user can act (re-translate in
// Cinematic, edit text, allow longer slots) before
// committing to a full Generate Dub run.
const hot = dubSegments.filter(s => (s.rate_ratio || 0) > 1.3);
if (hot.length === 0 || !dubSegments.length) return null;
const pctHot = Math.round((hot.length / dubSegments.length) * 100);
if (pctHot < 10) return null;
const worst = hot.reduce((a, b) => (a.rate_ratio > b.rate_ratio ? a : b));
return (
<div className="dub-compression-warn" role="status">
<span className="dub-compression-warn__icon"></span>
<span className="dub-compression-warn__body">
<strong>{hot.length} of {dubSegments.length}</strong> segments need {'>'}1.3× compression
(worst: <span style={{ fontVariantNumeric: 'tabular-nums' }}>{worst.rate_ratio.toFixed(2)}×</span>).
Output will be intelligible (pitch-preserving stretch) but stressed
{translateQuality === 'fast' ? ' switch to Cinematic and Re-translate' : ' shorten the worst segments'}
{' '}for cleaner audio.
</span>
</div>
);
})()}
{/* Generate / Export / Stop actions moved to the header bar (dub-head__primary). */}
</div>
)}
{dubError && (
<div className="dub-footer-banner">
<Badge tone="danger">
<AlertCircle size={11} /> {dubError}
</Badge>
<DubFailureNotice failure={dubFailure} />
</div>
)}
{/* Output options + Timing moved to the top of the right (transcript) section. */}
{dubTracks.length > 0 && (
<div className="dub-tracks-row">
<span className="dub-tracks-row__title">{t('dub.export_tracks')}</span>
<label className={exportTracks['original'] !== false ? 'is-on' : 'is-off'}>
<input
type="checkbox"
checked={exportTracks['original'] !== false}
onChange={(e) => setExportTracks((prev) => ({ ...prev, original: e.target.checked }))}
/>
<span>{t('dub.original_track')}</span>
</label>
{dubTracks.map((t) => (
<label key={t} className={exportTracks[t] !== false ? 'is-on is-success' : 'is-off'}>
<input
type="checkbox"
checked={exportTracks[t] !== false}
onChange={(e) => setExportTracks((prev) => ({ ...prev, [t]: e.target.checked }))}
/>
<span className="code">{t}</span>
</label>
))}
</div>
)}
{(() => {
// Pre-generation compression warning. Predicted by the
// translate response (see services/speech_rate.rate_ratio
// + dub_translate._maybe_cinematic), populated whenever
// segments carry a slot_seconds and translated text.
// Surfaces here so the user can act (re-translate in
// Cinematic, edit text, allow longer slots) before
// committing to a full Generate Dub run.
const hot = dubSegments.filter((s) => (s.rate_ratio || 0) > 1.3);
if (hot.length === 0 || !dubSegments.length) return null;
const pctHot = Math.round((hot.length / dubSegments.length) * 100);
if (pctHot < 10) return null;
const worst = hot.reduce((a, b) => (a.rate_ratio > b.rate_ratio ? a : b));
return (
<div className="dub-compression-warn" role="status">
<span className="dub-compression-warn__icon"></span>
<span className="dub-compression-warn__body">
<strong>
{hot.length} of {dubSegments.length}
</strong>{' '}
segments need {'>'}1.3× compression (worst:{' '}
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
{worst.rate_ratio.toFixed(2)}×
</span>
). Output will be intelligible (pitch-preserving stretch) but stressed
{translateQuality === 'fast'
? ' switch to Cinematic and Re-translate'
: ' shorten the worst segments'}{' '}
for cleaner audio.
</span>
</div>
);
})()}
{/* Generate / Export / Stop actions moved to the header bar (dub-head__primary). */}
</div>
);
}
+139 -55
View File
@@ -1,63 +1,147 @@
import { FileText, Save, RotateCcw, Loader, Square, Play, Download, ShieldCheck } from 'lucide-react';
import {
FileText,
Save,
RotateCcw,
Loader,
Square,
Play,
Download,
ShieldCheck,
} from 'lucide-react';
import { Button } from '../../ui';
import FooterBtn from './FooterBtn';
import { formatTime } from '../../utils/format';
export default function DubHeader({ t, dubFilename, dubDuration, dubSegments, activeProjectName, saveProject, resetDub, dubStep, handleDubStop, dubProgress, onGenerateClick, multiLangMode, multiLangs, incrementalPlan, handleDubGenerate, qcRunning, handleDubQc, setExportOpen }) {
export default function DubHeader({
t,
dubFilename,
dubDuration,
dubSegments,
activeProjectName,
saveProject,
resetDub,
dubStep,
handleDubStop,
dubProgress,
onGenerateClick,
multiLangMode,
multiLangs,
incrementalPlan,
handleDubGenerate,
qcRunning,
handleDubQc,
setExportOpen,
}) {
return (
<div className="dub-head">
<div className="label-row dub-head__title">
<FileText className="label-icon" size={11} />
<span className="dub-head__filename">{dubFilename}</span>
<span className="dub-head__meta">· {formatTime(dubDuration)} · {dubSegments.length} {t('dub.segs')}</span>
{activeProjectName && activeProjectName !== dubFilename && (
<span className="dub-head__project"> {activeProjectName}</span>
)}
</div>
<div className="dub-head__actions">
{/* Icon-only secondary actions (tooltips carry the labels);
<div className="dub-head">
<div className="label-row dub-head__title">
<FileText className="label-icon" size={11} />
<span className="dub-head__filename">{dubFilename}</span>
<span className="dub-head__meta">
· {formatTime(dubDuration)} · {dubSegments.length} {t('dub.segs')}
</span>
{activeProjectName && activeProjectName !== dubFilename && (
<span className="dub-head__project"> {activeProjectName}</span>
)}
</div>
<div className="dub-head__actions">
{/* Icon-only secondary actions (tooltips carry the labels);
Generate Dub keeps its label as the primary verb. */}
<Button variant="subtle" size="sm" onClick={saveProject}
title={t('dub.save')} aria-label={t('dub.save')}><Save size={12} /></Button>
<Button variant="danger" size="sm" onClick={resetDub}
title={t('dub.reset')} aria-label={t('dub.reset')}><RotateCcw size={12} /></Button>
{/* Primary actions live on the header bar (compact) — moved up from the footer. */}
<div className="dub-head__primary">
{dubStep === 'stopping' ? (
<FooterBtn sm tone="stopping" disabled icon={<Loader className="spinner" size={9} />} label={t('dub.stopping')} />
) : dubStep === 'generating' ? (
<FooterBtn sm tone="danger" onClick={handleDubStop} icon={<Square size={9} />}
label={t('dub.stop_progress', { current: dubProgress.current, total: dubProgress.total })} />
) : (
<>
<FooterBtn sm tone={dubSegments.length ? 'pink' : 'idle'} onClick={onGenerateClick}
disabled={!dubSegments.length} icon={<Play size={11} />}
label={multiLangMode && multiLangs.length > 1
? t('dub.generate_dub_multi', { count: multiLangs.length, defaultValue: 'Generate {{count}} dubs' })
: t('dub.generate_dub')} />
{dubStep === 'done' && incrementalPlan && incrementalPlan.stale?.length > 0 && (
<FooterBtn sm tone="pink"
onClick={() => handleDubGenerate({ regenOnly: incrementalPlan.stale, preview: true })}
icon={<Play size={11} />}
label={t('dub.regen_changed', { count: incrementalPlan.stale.length })} />
)}
</>
)}
{dubStep === 'done' && (
<FooterBtn sm tone="idle"
disabled={qcRunning || !dubSegments.length}
onClick={handleDubQc}
icon={qcRunning ? <Loader className="spinner" size={11} /> : <ShieldCheck size={11} />}
title={t('dub.qc_btn', { defaultValue: 'Verify dub timing (second-pass check)' })}
aria-label={t('dub.qc_btn', { defaultValue: 'Verify dub timing (second-pass check)' })} />
)}
<FooterBtn sm tone={dubStep === 'done' ? 'green' : 'idle'}
disabled={dubStep !== 'done' && !dubSegments.length}
onClick={() => setExportOpen(true)}
icon={<Download size={12} />}
title={t('dub.export_btn')} aria-label={t('dub.export_btn')} />
</div>
</div>
</div>
<Button
variant="subtle"
size="sm"
onClick={saveProject}
title={t('dub.save')}
aria-label={t('dub.save')}
>
<Save size={12} />
</Button>
<Button
variant="danger"
size="sm"
onClick={resetDub}
title={t('dub.reset')}
aria-label={t('dub.reset')}
>
<RotateCcw size={12} />
</Button>
{/* Primary actions live on the header bar (compact) — moved up from the footer. */}
<div className="dub-head__primary">
{dubStep === 'stopping' ? (
<FooterBtn
sm
tone="stopping"
disabled
icon={<Loader className="spinner" size={9} />}
label={t('dub.stopping')}
/>
) : dubStep === 'generating' ? (
<FooterBtn
sm
tone="danger"
onClick={handleDubStop}
icon={<Square size={9} />}
label={t('dub.stop_progress', {
current: dubProgress.current,
total: dubProgress.total,
})}
/>
) : (
<>
<FooterBtn
sm
tone={dubSegments.length ? 'pink' : 'idle'}
onClick={onGenerateClick}
disabled={!dubSegments.length}
icon={<Play size={11} />}
label={
multiLangMode && multiLangs.length > 1
? t('dub.generate_dub_multi', {
count: multiLangs.length,
defaultValue: 'Generate {{count}} dubs',
})
: t('dub.generate_dub')
}
/>
{dubStep === 'done' && incrementalPlan && incrementalPlan.stale?.length > 0 && (
<FooterBtn
sm
tone="pink"
onClick={() =>
handleDubGenerate({ regenOnly: incrementalPlan.stale, preview: true })
}
icon={<Play size={11} />}
label={t('dub.regen_changed', { count: incrementalPlan.stale.length })}
/>
)}
</>
)}
{dubStep === 'done' && (
<FooterBtn
sm
tone="idle"
disabled={qcRunning || !dubSegments.length}
onClick={handleDubQc}
icon={
qcRunning ? <Loader className="spinner" size={11} /> : <ShieldCheck size={11} />
}
title={t('dub.qc_btn', { defaultValue: 'Verify dub timing (second-pass check)' })}
aria-label={t('dub.qc_btn', {
defaultValue: 'Verify dub timing (second-pass check)',
})}
/>
)}
<FooterBtn
sm
tone={dubStep === 'done' ? 'green' : 'idle'}
disabled={dubStep !== 'done' && !dubSegments.length}
onClick={() => setExportOpen(true)}
icon={<Download size={12} />}
title={t('dub.export_btn')}
aria-label={t('dub.export_btn')}
/>
</div>
</div>
</div>
);
}
+482 -306
View File
@@ -1,4 +1,13 @@
import { Sparkles, Loader, ChevronDown, ChevronUp, Globe, UserSquare2, Languages, Wand2 } from 'lucide-react';
import {
Sparkles,
Loader,
ChevronDown,
ChevronUp,
Globe,
UserSquare2,
Languages,
Wand2,
} from 'lucide-react';
import { Button, Segmented, Progress } from '../../ui';
import WaveformTimeline from '../WaveformTimeline';
import MultiLangPicker from '../MultiLangPicker';
@@ -9,328 +18,495 @@ import { POPULAR_LANGS, PRESETS } from '../../utils/constants';
import { dialectOptionsFor, dialectLabel, dialectMatchesLang } from '../../api/dialects';
import toast from 'react-hot-toast';
export default function DubLeftColumn({ hasDubbedTrack, t, previewMode, setPreviewMode, dubTracks, videoSrc, waveformRef, dubJobId, dubSegments, timelineOnsets, timelineSelSegId, setTimelineSelSegId, incrementalPlan, segmentMoveResize, segmentDelete, onTimelinePreviewSegment, dubStep, dubProgress, fmtDur, genElapsed, genRemaining, speakerClones, setDubSegments, profiles, settingsOpen, setSettingsOpen, dubLang, dubLangCode, translateQuality, activeEngineUnavailable, translateProvider, dubInstruct, setDubInstruct, handleTranslateAll, isTranslating, hasAnyTranslation, handleCleanupSegments, setDubLang, setDubLangCode, dubDialect, setDubDialect, i18n, enginesSandboxed, handleInstallEngine, engineInstalling, activeEngineEntry, engines, setTranslateProvider, setTranslateQuality, llmEndpoint, multiLangMode, setMultiLangMode, multiLangs, setMultiLangs, editSegments }) {
export default function DubLeftColumn({
hasDubbedTrack,
t,
previewMode,
setPreviewMode,
dubTracks,
videoSrc,
waveformRef,
dubJobId,
dubSegments,
timelineOnsets,
timelineSelSegId,
setTimelineSelSegId,
incrementalPlan,
segmentMoveResize,
segmentDelete,
onTimelinePreviewSegment,
dubStep,
dubProgress,
fmtDur,
genElapsed,
genRemaining,
speakerClones,
setDubSegments,
profiles,
settingsOpen,
setSettingsOpen,
dubLang,
dubLangCode,
translateQuality,
activeEngineUnavailable,
translateProvider,
dubInstruct,
setDubInstruct,
handleTranslateAll,
isTranslating,
hasAnyTranslation,
handleCleanupSegments,
setDubLang,
setDubLangCode,
dubDialect,
setDubDialect,
i18n,
enginesSandboxed,
handleInstallEngine,
engineInstalling,
activeEngineEntry,
engines,
setTranslateProvider,
setTranslateQuality,
llmEndpoint,
multiLangMode,
setMultiLangMode,
multiLangs,
setMultiLangs,
editSegments,
}) {
return (
<div className="studio-panel dub-panel-col">
{hasDubbedTrack && (
<div className="dub-lang-switch" role="radiogroup" aria-label={t('dub.preview_language', { defaultValue: 'Preview language' })}>
<button
type="button"
role="radio"
aria-checked={previewMode === 'original'}
className={`dub-lang-pill ${previewMode === 'original' ? 'is-active' : ''}`}
onClick={() => setPreviewMode('original')}
>
{t('dub.original_audio')}
</button>
{dubTracks.map(code => {
const label = LANG_CODES.find(lc => lc.code === code)?.label || code.toUpperCase();
return (
<button
key={code}
type="button"
role="radio"
aria-checked={previewMode === code}
className={`dub-lang-pill ${previewMode === code ? 'is-active' : ''}`}
onClick={() => setPreviewMode(code)}
>
{label}
</button>
);
})}
</div>
)}
<WaveformTimeline
key={videoSrc}
ref={waveformRef}
audioSrc={`${API}/dub/audio/${dubJobId}`}
videoSrc={videoSrc}
segments={dubSegments}
onsets={timelineOnsets}
selectedSegId={timelineSelSegId}
onSelectSeg={setTimelineSelSegId}
incrementalPlan={incrementalPlan}
onSegmentCommit={segmentMoveResize}
onSegmentDelete={segmentDelete}
onPreviewSegment={onTimelinePreviewSegment}
disabled={dubStep === 'generating' || dubStep === 'stopping'}
overlayContent={(dubStep === 'generating' || dubStep === 'stopping') ? (
<div className="dub-gen-overlay">
<div className="dub-gen-overlay__head">
{dubStep === 'stopping' ? <Loader className="spinner" size={14} color="#a89984" /> : <Sparkles className="spinner" size={14} color="#d3869b" />}
<span className={`dub-gen-overlay__title ${dubStep === 'stopping' ? 'is-stopping' : ''}`}>
{dubStep === 'stopping' ? t('dub.stopping') : t('dub.generate_dub') + ` ${dubProgress.current}/${dubProgress.total}`}
<div className="studio-panel dub-panel-col">
{hasDubbedTrack && (
<div
className="dub-lang-switch"
role="radiogroup"
aria-label={t('dub.preview_language', { defaultValue: 'Preview language' })}
>
<button
type="button"
role="radio"
aria-checked={previewMode === 'original'}
className={`dub-lang-pill ${previewMode === 'original' ? 'is-active' : ''}`}
onClick={() => setPreviewMode('original')}
>
{t('dub.original_audio')}
</button>
{dubTracks.map((code) => {
const label = LANG_CODES.find((lc) => lc.code === code)?.label || code.toUpperCase();
return (
<button
key={code}
type="button"
role="radio"
aria-checked={previewMode === code}
className={`dub-lang-pill ${previewMode === code ? 'is-active' : ''}`}
onClick={() => setPreviewMode(code)}
>
{label}
</button>
);
})}
</div>
)}
<WaveformTimeline
key={videoSrc}
ref={waveformRef}
audioSrc={`${API}/dub/audio/${dubJobId}`}
videoSrc={videoSrc}
segments={dubSegments}
onsets={timelineOnsets}
selectedSegId={timelineSelSegId}
onSelectSeg={setTimelineSelSegId}
incrementalPlan={incrementalPlan}
onSegmentCommit={segmentMoveResize}
onSegmentDelete={segmentDelete}
onPreviewSegment={onTimelinePreviewSegment}
disabled={dubStep === 'generating' || dubStep === 'stopping'}
overlayContent={
dubStep === 'generating' || dubStep === 'stopping' ? (
<div className="dub-gen-overlay">
<div className="dub-gen-overlay__head">
{dubStep === 'stopping' ? (
<Loader className="spinner" size={14} color="#a89984" />
) : (
<Sparkles className="spinner" size={14} color="#d3869b" />
)}
<span
className={`dub-gen-overlay__title ${dubStep === 'stopping' ? 'is-stopping' : ''}`}
>
{dubStep === 'stopping'
? t('dub.stopping')
: t('dub.generate_dub') + ` ${dubProgress.current}/${dubProgress.total}`}
</span>
</div>
{dubStep === 'generating' && (
<>
<div className="dub-gen-overlay__stats">
<span>
{fmtDur(genElapsed)} {t('dub.elapsed')}
</span>
{genRemaining !== null && (
<span>
~{fmtDur(genRemaining)} {t('dub.remaining')}
</span>
</div>
{dubStep === 'generating' && (
<>
<div className="dub-gen-overlay__stats">
<span> {fmtDur(genElapsed)} {t('dub.elapsed')}</span>
{genRemaining !== null && <span>~{fmtDur(genRemaining)} {t('dub.remaining')}</span>}
</div>
<div className="dub-gen-overlay__bar">
<Progress
value={dubProgress.total ? (dubProgress.current / dubProgress.total) * 100 : 0}
tone="brand"
size="sm"
/>
</div>
{dubProgress.text && <span className="dub-gen-overlay__text">{dubProgress.text}</span>}
</>
)}
</div>
) : null}
/>
<div className="dub-gen-overlay__bar">
<Progress
value={
dubProgress.total ? (dubProgress.current / dubProgress.total) * 100 : 0
}
tone="brand"
size="sm"
/>
</div>
{dubProgress.text && (
<span className="dub-gen-overlay__text">{dubProgress.text}</span>
)}
</>
)}
</div>
) : null
}
/>
{/* Cast per-speaker voice assignment. When the auto-clone
{/* Cast per-speaker voice assignment. When the auto-clone
extractor found a usable passage per speaker (5s from the
isolated vocals), that option becomes first-class in the
dropdown. It's also pre-selected on the segments so "new
language = same speaker's voice" works by default. */}
{dubSegments.some(s => s.speaker_id) && (
<div className="dub-cast">
<div className="dub-cast__row">
<span className="dub-cast__kicker" title={t('dub.cast_title')}>{t('dub.cast')}</span>
{[...new Set(dubSegments.map(s => s.speaker_id).filter(Boolean))].map(spk => {
const autoId = `auto:${(spk || '').toLowerCase().replace(/\s+/g, '_')}`;
const clone = speakerClones[spk];
return (
<div key={spk} className="dub-cast__pair">
<span className="dub-cast__label">{spk}:</span>
<select className="input-base dub-cast__select"
value={dubSegments.find(s => s.speaker_id === spk)?.profile_id || ''}
onChange={e => {
const val = e.target.value;
setDubSegments(dubSegments.map(s => s.speaker_id === spk ? { ...s, profile_id: val } : s));
}}>
{clone && (
<option value={autoId}>{t('dub.from_video', { duration: clone.duration.toFixed(1) })}</option>
)}
<option value="">{t('dub.default')}</option>
{profiles.length > 0 && (
<optgroup label={t('dub.clone_profiles')}>
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</optgroup>
)}
{PRESETS.length > 0 && (
<optgroup label={t('dub.design_presets')}>
{PRESETS.map(p => <option key={p.id} value={`preset:${p.id}`}>{p.name}</option>)}
</optgroup>
)}
</select>
</div>
{dubSegments.some((s) => s.speaker_id) && (
<div className="dub-cast">
<div className="dub-cast__row">
<span className="dub-cast__kicker" title={t('dub.cast_title')}>
{t('dub.cast')}
</span>
{[...new Set(dubSegments.map((s) => s.speaker_id).filter(Boolean))].map((spk) => {
const autoId = `auto:${(spk || '').toLowerCase().replace(/\s+/g, '_')}`;
const clone = speakerClones[spk];
return (
<div key={spk} className="dub-cast__pair">
<span className="dub-cast__label">{spk}:</span>
<select
className="input-base dub-cast__select"
value={dubSegments.find((s) => s.speaker_id === spk)?.profile_id || ''}
onChange={(e) => {
const val = e.target.value;
setDubSegments(
dubSegments.map((s) =>
s.speaker_id === spk ? { ...s, profile_id: val } : s,
),
);
})}
</div>
}}
>
{clone && (
<option value={autoId}>
{t('dub.from_video', { duration: clone.duration.toFixed(1) })}
</option>
)}
<option value="">{t('dub.default')}</option>
{profiles.length > 0 && (
<optgroup label={t('dub.clone_profiles')}>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</optgroup>
)}
{PRESETS.length > 0 && (
<optgroup label={t('dub.design_presets')}>
{PRESETS.map((p) => (
<option key={p.id} value={`preset:${p.id}`}>
{p.name}
</option>
))}
</optgroup>
)}
</select>
</div>
)}
);
})}
</div>
</div>
)}
{/* Translation settings — collapsed or expanded */}
{!settingsOpen && (
<div className="dub-settings-summary">
<button
type="button"
className="dub-settings-summary__trigger"
onClick={() => setSettingsOpen(true)}
title={t('dub.edit_settings')}
>
<ChevronDown size={10} />
<span><strong>{dubLang}</strong> · {dubLangCode} · {translateQuality} · <span style={{ color: activeEngineUnavailable ? '#fb4934' : '#b8bb26' }}></span> {translateProvider}</span>
{dubInstruct && <span className="dub-settings-summary__style">{t('dub.style_label_prefix')}{dubInstruct}</span>}
</button>
<Button
variant="subtle" size="sm"
onClick={handleTranslateAll}
disabled={isTranslating || !dubSegments.length}
loading={isTranslating}
leading={!isTranslating && <Languages size={10} />}
>
{isTranslating ? t('dub.translating') : hasAnyTranslation ? t('dub.retranslate') : t('dub.translate_all')}
</Button>
<Button
variant="subtle" size="sm"
onClick={handleCleanupSegments}
disabled={!dubSegments.length || !dubJobId}
title={t('dub.clean_up_title')}
leading={<Wand2 size={10} />}
>
{t('dub.clean_up')}
</Button>
</div>
)}
{settingsOpen && (
<div className="dub-settings-bar">
<div className="dub-settings-bar__fields">
<button
type="button"
className="dub-settings-summary__trigger dub-settings-close"
onClick={() => setSettingsOpen(false)}
title={t('dub.collapse_settings')}
>
<ChevronUp size={10} />
</button>
<div className="dub-settings-field dub-settings-field--lang">
<div className="label-row"><Globe className="label-icon" size={9} /> {t('dub.language')}</div>
<select
className="input-base dub-cast__select"
value={dubLang}
onChange={(e) => {
const lang = e.target.value;
setDubLang(lang);
const match = LANG_CODES.find(lc => lc.label.toLowerCase() === lang.toLowerCase());
if (match) {
setDubLangCode(match.code);
// #280: a dialect belongs to one language clear it
// whenever the new target doesn't match.
if (!dialectMatchesLang(dubDialect, match.code)) setDubDialect('');
}
}}
>
<optgroup label={t('dub.popular')}>
{POPULAR_LANGS.map(l => <option key={`p-${l}`} value={l}>{l}</option>)}
</optgroup>
<optgroup label={t('dub.all_languages')}>
{ALL_LANGUAGES
.filter(l => !POPULAR_LANGS.includes(l))
.map(l => <option key={l} value={l}>{l}</option>)}
</optgroup>
</select>
</div>
<div className="dub-settings-field dub-settings-field--iso">
<div className="label-row">{t('dub.iso_code')}</div>
<select
className="input-base dub-cast__select"
value={dubLangCode}
onChange={(e) => {
const code = e.target.value;
setDubLangCode(code);
if (!dialectMatchesLang(dubDialect, code)) setDubDialect('');
}}
>
{LANG_CODES.map(lc => (
<option key={lc.code} value={lc.code}>{lc.code} {lc.label}</option>
))}
</select>
</div>
{/* #280: regional dialect / vocabulary. Only rendered for
{/* Translation settings — collapsed or expanded */}
{!settingsOpen && (
<div className="dub-settings-summary">
<button
type="button"
className="dub-settings-summary__trigger"
onClick={() => setSettingsOpen(true)}
title={t('dub.edit_settings')}
>
<ChevronDown size={10} />
<span>
<strong>{dubLang}</strong> · {dubLangCode} · {translateQuality} ·{' '}
<span style={{ color: activeEngineUnavailable ? '#fb4934' : '#b8bb26' }}></span>{' '}
{translateProvider}
</span>
{dubInstruct && (
<span className="dub-settings-summary__style">
{t('dub.style_label_prefix')}
{dubInstruct}
</span>
)}
</button>
<Button
variant="subtle"
size="sm"
onClick={handleTranslateAll}
disabled={isTranslating || !dubSegments.length}
loading={isTranslating}
leading={!isTranslating && <Languages size={10} />}
>
{isTranslating
? t('dub.translating')
: hasAnyTranslation
? t('dub.retranslate')
: t('dub.translate_all')}
</Button>
<Button
variant="subtle"
size="sm"
onClick={handleCleanupSegments}
disabled={!dubSegments.length || !dubJobId}
title={t('dub.clean_up_title')}
leading={<Wand2 size={10} />}
>
{t('dub.clean_up')}
</Button>
</div>
)}
{settingsOpen && (
<div className="dub-settings-bar">
<div className="dub-settings-bar__fields">
<button
type="button"
className="dub-settings-summary__trigger dub-settings-close"
onClick={() => setSettingsOpen(false)}
title={t('dub.collapse_settings')}
>
<ChevronUp size={10} />
</button>
<div className="dub-settings-field dub-settings-field--lang">
<div className="label-row">
<Globe className="label-icon" size={9} /> {t('dub.language')}
</div>
<select
className="input-base dub-cast__select"
value={dubLang}
onChange={(e) => {
const lang = e.target.value;
setDubLang(lang);
const match = LANG_CODES.find(
(lc) => lc.label.toLowerCase() === lang.toLowerCase(),
);
if (match) {
setDubLangCode(match.code);
// #280: a dialect belongs to one language clear it
// whenever the new target doesn't match.
if (!dialectMatchesLang(dubDialect, match.code)) setDubDialect('');
}
}}
>
<optgroup label={t('dub.popular')}>
{POPULAR_LANGS.map((l) => (
<option key={`p-${l}`} value={l}>
{l}
</option>
))}
</optgroup>
<optgroup label={t('dub.all_languages')}>
{ALL_LANGUAGES.filter((l) => !POPULAR_LANGS.includes(l)).map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</optgroup>
</select>
</div>
<div className="dub-settings-field dub-settings-field--iso">
<div className="label-row">{t('dub.iso_code')}</div>
<select
className="input-base dub-cast__select"
value={dubLangCode}
onChange={(e) => {
const code = e.target.value;
setDubLangCode(code);
if (!dialectMatchesLang(dubDialect, code)) setDubDialect('');
}}
>
{LANG_CODES.map((lc) => (
<option key={lc.code} value={lc.code}>
{lc.code} {lc.label}
</option>
))}
</select>
</div>
{/* #280: regional dialect / vocabulary. Only rendered for
languages with curated variants; region names come from
Intl.DisplayNames so they localize with the UI for free. */}
{dialectOptionsFor(dubLangCode).length > 0 && (
<div className="dub-settings-field dub-settings-field--dialect">
<div className="label-row" title={t('dub.dialect_title')}>{t('dub.dialect_label')}</div>
<select
className="input-base dub-cast__select"
value={dialectMatchesLang(dubDialect, dubLangCode) ? dubDialect : ''}
onChange={(e) => setDubDialect(e.target.value)}
>
<option value="">{t('dub.dialect_default')}</option>
{dialectOptionsFor(dubLangCode).map(d => (
<option key={d} value={d}>{dialectLabel(d, i18n.language)}</option>
))}
</select>
</div>
)}
<div className="dub-settings-field dub-settings-field--engine">
<div className="label-row">
{t('dub.engine_label')}
{activeEngineUnavailable && !enginesSandboxed && (
<button
type="button"
className="dub-engine-install-chip"
onClick={() => handleInstallEngine(translateProvider)}
disabled={engineInstalling === translateProvider}
title={t('dub.install_engine')}
>
{engineInstalling === translateProvider ? t('dub.installing_engine') : `+ install ${activeEngineEntry?.pip_package || ''}`}
</button>
)}
{activeEngineUnavailable && enginesSandboxed && (
<span className="dub-engine-install-chip dub-engine-install-chip--disabled" title={t('dub.install_disabled_title')}>
{t('dub.needs_dev_install')}
</span>
)}
</div>
<select className="input-base dub-engine-select" value={translateProvider} onChange={e => setTranslateProvider(e.target.value)}>
{(engines.length ? engines : []).map(p => (
<option key={p.id} value={p.id}>
{p.installed ? p.display_name : `${p.display_name}${t('dub.needs_install_suffix')}`}
</option>
))}
</select>
</div>
<div className="dub-settings-field dub-settings-field--quality">
<div className="label-row" title={t('dub.quality_title')}>{t('dub.quality_label')}</div>
<Segmented
size="sm"
value={translateQuality}
onChange={(v) => {
// #372: picking Cinematic with no LLM configured used to
// bounce the user between two warnings forever. Block the
// pick at the source and point at the actual fix.
if (v === 'cinematic' && llmEndpoint && !llmEndpoint.available) {
toast(t('dub.cinematic_needs_llm_hint', { defaultValue: 'Cinematic needs an LLM. Configure one in Settings → Credentials → LLM endpoint (Ollama runs locally, no key needed).' }), { icon: '️', duration: 8000 });
return;
}
setTranslateQuality(v);
}}
items={[
{ value: 'fast', label: t('dub.fast_quality') },
{ value: 'cinematic', label: t('dub.cinematic_quality') },
]}
/>
</div>
<div className="dub-settings-field dub-settings-field--style">
<div className="label-row"><UserSquare2 className="label-icon" size={9} /> {t('dub.style')} <span className="dub-settings-field__hint">{t('dub.optional')}</span></div>
<input className="input-base input-base--xs" placeholder={t('dub.style_placeholder')} value={dubInstruct} onChange={e => setDubInstruct(e.target.value)} />
</div>
<div className="dub-settings-field dub-settings-field--multi">
<label className="dub-multi-toggle">
<input
type="checkbox"
checked={multiLangMode}
onChange={e => setMultiLangMode(e.target.checked)}
/>
<span>{t('dub.multi_lang')}</span>
</label>
{multiLangMode && (
<MultiLangPicker
selected={multiLangs}
onChange={setMultiLangs}
disabled={dubStep === 'generating'}
/>
)}
</div>
</div>
<div className="dub-settings-bar__actions">
<Button
variant="subtle" size="sm"
onClick={() => editSegments(dubSegments.map(s => ({ ...s, text: s.text_original || s.text, translate_error: undefined })))}
disabled={!dubSegments.some(s => s.text_original && s.text_original !== s.text)}
title={t('dub.restore_title')}
>
{t('dub.restore')}
</Button>
<Button
variant="subtle" size="sm"
onClick={handleCleanupSegments}
disabled={!dubSegments.length || !dubJobId}
title={t('dub.clean_up_title')}
leading={<Wand2 size={10} />}
>
{t('dub.clean_up')}
</Button>
<Button
variant="primary" size="sm"
onClick={handleTranslateAll}
disabled={isTranslating || !dubSegments.length}
loading={isTranslating}
leading={!isTranslating && <Languages size={10} />}
>
{isTranslating ? t('dub.translating') : t('dub.translate_all')}
</Button>
{dialectOptionsFor(dubLangCode).length > 0 && (
<div className="dub-settings-field dub-settings-field--dialect">
<div className="label-row" title={t('dub.dialect_title')}>
{t('dub.dialect_label')}
</div>
<select
className="input-base dub-cast__select"
value={dialectMatchesLang(dubDialect, dubLangCode) ? dubDialect : ''}
onChange={(e) => setDubDialect(e.target.value)}
>
<option value="">{t('dub.dialect_default')}</option>
{dialectOptionsFor(dubLangCode).map((d) => (
<option key={d} value={d}>
{dialectLabel(d, i18n.language)}
</option>
))}
</select>
</div>
)}
<div className="dub-settings-field dub-settings-field--engine">
<div className="label-row">
{t('dub.engine_label')}
{activeEngineUnavailable && !enginesSandboxed && (
<button
type="button"
className="dub-engine-install-chip"
onClick={() => handleInstallEngine(translateProvider)}
disabled={engineInstalling === translateProvider}
title={t('dub.install_engine')}
>
{engineInstalling === translateProvider
? t('dub.installing_engine')
: `+ install ${activeEngineEntry?.pip_package || ''}`}
</button>
)}
{activeEngineUnavailable && enginesSandboxed && (
<span
className="dub-engine-install-chip dub-engine-install-chip--disabled"
title={t('dub.install_disabled_title')}
>
{t('dub.needs_dev_install')}
</span>
)}
</div>
<select
className="input-base dub-engine-select"
value={translateProvider}
onChange={(e) => setTranslateProvider(e.target.value)}
>
{(engines.length ? engines : []).map((p) => (
<option key={p.id} value={p.id}>
{p.installed
? p.display_name
: `${p.display_name}${t('dub.needs_install_suffix')}`}
</option>
))}
</select>
</div>
<div className="dub-settings-field dub-settings-field--quality">
<div className="label-row" title={t('dub.quality_title')}>
{t('dub.quality_label')}
</div>
<Segmented
size="sm"
value={translateQuality}
onChange={(v) => {
// #372: picking Cinematic with no LLM configured used to
// bounce the user between two warnings forever. Block the
// pick at the source and point at the actual fix.
if (v === 'cinematic' && llmEndpoint && !llmEndpoint.available) {
toast(
t('dub.cinematic_needs_llm_hint', {
defaultValue:
'Cinematic needs an LLM. Configure one in Settings → Credentials → LLM endpoint (Ollama runs locally, no key needed).',
}),
{ icon: '️', duration: 8000 },
);
return;
}
setTranslateQuality(v);
}}
items={[
{ value: 'fast', label: t('dub.fast_quality') },
{ value: 'cinematic', label: t('dub.cinematic_quality') },
]}
/>
</div>
<div className="dub-settings-field dub-settings-field--style">
<div className="label-row">
<UserSquare2 className="label-icon" size={9} /> {t('dub.style')}{' '}
<span className="dub-settings-field__hint">{t('dub.optional')}</span>
</div>
<input
className="input-base input-base--xs"
placeholder={t('dub.style_placeholder')}
value={dubInstruct}
onChange={(e) => setDubInstruct(e.target.value)}
/>
</div>
<div className="dub-settings-field dub-settings-field--multi">
<label className="dub-multi-toggle">
<input
type="checkbox"
checked={multiLangMode}
onChange={(e) => setMultiLangMode(e.target.checked)}
/>
<span>{t('dub.multi_lang')}</span>
</label>
{multiLangMode && (
<MultiLangPicker
selected={multiLangs}
onChange={setMultiLangs}
disabled={dubStep === 'generating'}
/>
)}
</div>
</div>
<div className="dub-settings-bar__actions">
<Button
variant="subtle"
size="sm"
onClick={() =>
editSegments(
dubSegments.map((s) => ({
...s,
text: s.text_original || s.text,
translate_error: undefined,
})),
)
}
disabled={!dubSegments.some((s) => s.text_original && s.text_original !== s.text)}
title={t('dub.restore_title')}
>
{t('dub.restore')}
</Button>
<Button
variant="subtle"
size="sm"
onClick={handleCleanupSegments}
disabled={!dubSegments.length || !dubJobId}
title={t('dub.clean_up_title')}
leading={<Wand2 size={10} />}
>
{t('dub.clean_up')}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleTranslateAll}
disabled={isTranslating || !dubSegments.length}
loading={isTranslating}
leading={!isTranslating && <Languages size={10} />}
>
{isTranslating ? t('dub.translating') : t('dub.translate_all')}
</Button>
</div>
</div>
)}
</div>
);
}
@@ -1,33 +1,55 @@
import { useTranslation } from 'react-i18next';
import { UploadCloud, Wand2, FileText, Pencil, Sparkles, Download, Check, Loader } from 'lucide-react';
import {
UploadCloud,
Wand2,
FileText,
Pencil,
Sparkles,
Download,
Check,
Loader,
} from 'lucide-react';
// Pipeline stepper
// One legible spine for the whole dub journey so the user always knows where
// they are: Upload Prepare Transcribe Edit Generate Export.
const DUB_PIPELINE = [
{ id: 'upload', key: 'dub.phase_upload', fallback: 'Upload', Icon: UploadCloud },
{ id: 'prepare', key: 'dub.phase_prepare', fallback: 'Prepare', Icon: Wand2 },
{ id: 'upload', key: 'dub.phase_upload', fallback: 'Upload', Icon: UploadCloud },
{ id: 'prepare', key: 'dub.phase_prepare', fallback: 'Prepare', Icon: Wand2 },
{ id: 'transcribe', key: 'dub.phase_transcribe', fallback: 'Transcribe', Icon: FileText },
{ id: 'edit', key: 'dub.phase_edit', fallback: 'Edit', Icon: Pencil },
{ id: 'generate', key: 'dub.phase_generate', fallback: 'Generate', Icon: Sparkles },
{ id: 'export', key: 'dub.phase_export', fallback: 'Export', Icon: Download },
{ id: 'edit', key: 'dub.phase_edit', fallback: 'Edit', Icon: Pencil },
{ id: 'generate', key: 'dub.phase_generate', fallback: 'Generate', Icon: Sparkles },
{ id: 'export', key: 'dub.phase_export', fallback: 'Export', Icon: Download },
];
const DUB_PHASE_BY_STEP = {
idle: 0, uploading: 1, transcribing: 2, editing: 3, generating: 4, stopping: 4, done: 5,
idle: 0,
uploading: 1,
transcribing: 2,
editing: 3,
generating: 4,
stopping: 4,
done: 5,
};
function DubPipelineStepper({ dubStep }) {
const { t } = useTranslation();
const current = DUB_PHASE_BY_STEP[dubStep] ?? 0;
const busy = dubStep === 'uploading' || dubStep === 'transcribing'
|| dubStep === 'generating' || dubStep === 'stopping';
const busy =
dubStep === 'uploading' ||
dubStep === 'transcribing' ||
dubStep === 'generating' ||
dubStep === 'stopping';
return (
<div className="dub-stepper" role="list" aria-label={t('dub.pipeline', { defaultValue: 'Dubbing pipeline' })}>
<div
className="dub-stepper"
role="list"
aria-label={t('dub.pipeline', { defaultValue: 'Dubbing pipeline' })}
>
{DUB_PIPELINE.map((p, i) => {
const done = i < current;
const active = i === current;
const spinning = active && busy;
const Icon = done ? Check : (spinning ? Loader : p.Icon);
const Icon = done ? Check : spinning ? Loader : p.Icon;
return (
<div
key={p.id}
@@ -37,7 +59,9 @@ function DubPipelineStepper({ dubStep }) {
done ? 'is-done' : '',
active ? 'is-active' : '',
i <= current ? 'is-reached' : '',
].filter(Boolean).join(' ')}
]
.filter(Boolean)
.join(' ')}
>
<span className="dub-stepper__icon">
<Icon size={13} className={spinning ? 'dub-stepper__spin' : ''} />
+295 -150
View File
@@ -7,166 +7,311 @@ import { LANG_CODES } from '../../utils/languages';
const DubSegmentTable = lazy(() => import('../DubSegmentTable'));
const LazyFallback = () => (
<div className="dub-lazy-fallback">Loading</div>
);
const LazyFallback = () => <div className="dub-lazy-fallback">Loading</div>;
export default function DubRightColumn({ t, preserveBg, setPreserveBg, dualSubs, setDualSubs, burnSubs, setBurnSubs, defaultTrack, setDefaultTrack, dubLangCode, dubTracks, timingStrategy, setTimingStrategy, dubTranscript, showTranscript, setShowTranscript, dubJobId, glossaryVisible, setGlossaryOpen, setGlossaryHidden, glossaryTermCount, dubLang, dubSegments, onGlossaryChange, selectedSegIds, bulkApplyToSelected, speakerClones, profiles, clearSegSelection, bulkDeleteSelected, showCheckpoint, checkpointStage, onCheckpointContinue, onCheckpointDismiss, isTranslating, segmentPreviewLoading, toggleSegSelect, selectAllSegs, segmentEditField, segmentDelete, segmentRestoreOriginal, handleSegmentPreview, onDirectSegment, segmentSplit, segmentMerge, seekWaveform, timelineSelSegId, dubStep, dubProgress }) {
export default function DubRightColumn({
t,
preserveBg,
setPreserveBg,
dualSubs,
setDualSubs,
burnSubs,
setBurnSubs,
defaultTrack,
setDefaultTrack,
dubLangCode,
dubTracks,
timingStrategy,
setTimingStrategy,
dubTranscript,
showTranscript,
setShowTranscript,
dubJobId,
glossaryVisible,
setGlossaryOpen,
setGlossaryHidden,
glossaryTermCount,
dubLang,
dubSegments,
onGlossaryChange,
selectedSegIds,
bulkApplyToSelected,
speakerClones,
profiles,
clearSegSelection,
bulkDeleteSelected,
showCheckpoint,
checkpointStage,
onCheckpointContinue,
onCheckpointDismiss,
isTranslating,
segmentPreviewLoading,
toggleSegSelect,
selectAllSegs,
segmentEditField,
segmentDelete,
segmentRestoreOriginal,
handleSegmentPreview,
onDirectSegment,
segmentSplit,
segmentMerge,
seekWaveform,
timelineSelSegId,
dubStep,
dubProgress,
}) {
return (
<div className="studio-panel dub-panel-col">
{/* Output options + timing — moved to the top of the right section. */}
<div className="dub-right-outputs">
<div className="dub-outputs-row">
<span className="dub-outputs-title-strong">{t('dub.output_options')}</span>
<label>
<input type="checkbox" checked={preserveBg} onChange={e => setPreserveBg(e.target.checked)} /> {t('dub.mix_bg_audio')}
</label>
<label title={t('dub.dual_subs_title')}>
<input type="checkbox" checked={!!dualSubs} onChange={e => setDualSubs(e.target.checked)} /> {t('dub.dual_subs')}
</label>
<label title={t('dub.burn_subs_title')}>
<input type="checkbox" checked={!!burnSubs} onChange={e => setBurnSubs(e.target.checked)} /> {t('dub.burn_subs')}
</label>
<label>
{t('dub.default_track')}
<select className="input-base dub-outputs-default" value={defaultTrack} onChange={e => setDefaultTrack(e.target.value)}>
<option value="original">{t('dub.original_track')}</option>
{dubLangCode && <option value={dubLangCode}>{t('dub.selected_dub', { code: dubLangCode })}</option>}
{dubTracks.filter(tr => tr !== dubLangCode).map(tr => (
<option key={tr} value={tr}>{t('dub.dub_track', { code: tr })}</option>
))}
</select>
</label>
</div>
<div className="dub-outputs-row" title="Timing strategy — how the dub reconciles natural-rate TTS with the original timeline.">
<span className="dub-outputs-title-strong">Timing:</span>
<Segmented
value={timingStrategy}
onChange={setTimingStrategy}
items={[
{ value: 'concise', label: 'Concise', title: 'Translator trims text to fit at natural rate. Overflows surface in the row badge so you can shorten the segment.' },
{ value: 'smart_fit', label: t('dub.timing_smart_fit'), title: t('dub.timing_smart_fit_title') },
{ value: 'stretch_video', label: 'Stretch Video', title: 'Audio plays at natural rate; each segment of the video is stretched (per-segment ffmpeg setpts) to fit. Total video duration grows. Requires a re-encode pass.' },
{ value: 'strict_slot', label: 'Strict slot', title: 'Legacy: compress audio to fit the original timing. Can sound rushed/chipmunky on high-density target languages.' },
]}
/>
</div>
</div>
{dubTranscript && (
<div className="dub-transcript-toggle-wrap">
<div className="override-toggle dub-transcript-toggle__inner" onClick={() => setShowTranscript(!showTranscript)}>
<span><FileText size={10} className="dub-inline-icon" /> {t('dub.transcript')}</span>
{showTranscript ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</div>
{showTranscript && (
<div className="dub-transcript-body">
{dubTranscript}
</div>
)}
</div>
<div className="studio-panel dub-panel-col">
{/* Output options + timing — moved to the top of the right section. */}
<div className="dub-right-outputs">
<div className="dub-outputs-row">
<span className="dub-outputs-title-strong">{t('dub.output_options')}</span>
<label>
<input
type="checkbox"
checked={preserveBg}
onChange={(e) => setPreserveBg(e.target.checked)}
/>{' '}
{t('dub.mix_bg_audio')}
</label>
<label title={t('dub.dual_subs_title')}>
<input
type="checkbox"
checked={!!dualSubs}
onChange={(e) => setDualSubs(e.target.checked)}
/>{' '}
{t('dub.dual_subs')}
</label>
<label title={t('dub.burn_subs_title')}>
<input
type="checkbox"
checked={!!burnSubs}
onChange={(e) => setBurnSubs(e.target.checked)}
/>{' '}
{t('dub.burn_subs')}
</label>
<label>
{t('dub.default_track')}
<select
className="input-base dub-outputs-default"
value={defaultTrack}
onChange={(e) => setDefaultTrack(e.target.value)}
>
<option value="original">{t('dub.original_track')}</option>
{dubLangCode && (
<option value={dubLangCode}>{t('dub.selected_dub', { code: dubLangCode })}</option>
)}
{dubTracks
.filter((tr) => tr !== dubLangCode)
.map((tr) => (
<option key={tr} value={tr}>
{t('dub.dub_track', { code: tr })}
</option>
))}
</select>
</label>
</div>
<div
className="dub-outputs-row"
title="Timing strategy — how the dub reconciles natural-rate TTS with the original timeline."
>
<span className="dub-outputs-title-strong">Timing:</span>
<Segmented
value={timingStrategy}
onChange={setTimingStrategy}
items={[
{
value: 'concise',
label: 'Concise',
title:
'Translator trims text to fit at natural rate. Overflows surface in the row badge so you can shorten the segment.',
},
{
value: 'smart_fit',
label: t('dub.timing_smart_fit'),
title: t('dub.timing_smart_fit_title'),
},
{
value: 'stretch_video',
label: 'Stretch Video',
title:
'Audio plays at natural rate; each segment of the video is stretched (per-segment ffmpeg setpts) to fit. Total video duration grows. Requires a re-encode pass.',
},
{
value: 'strict_slot',
label: 'Strict slot',
title:
'Legacy: compress audio to fit the original timing. Can sound rushed/chipmunky on high-density target languages.',
},
]}
/>
</div>
</div>
{/* Phase 1.3 Project glossary. Hidden behind a chip until
{dubTranscript && (
<div className="dub-transcript-toggle-wrap">
<div
className="override-toggle dub-transcript-toggle__inner"
onClick={() => setShowTranscript(!showTranscript)}
>
<span>
<FileText size={10} className="dub-inline-icon" /> {t('dub.transcript')}
</span>
{showTranscript ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</div>
{showTranscript && <div className="dub-transcript-body">{dubTranscript}</div>}
</div>
)}
{/* Phase 1.3 Project glossary. Hidden behind a chip until
the user wants it (or terms already exist). */}
{dubJobId && !glossaryVisible && (
<button
type="button"
className="dub-glossary-chip"
onClick={() => { setGlossaryOpen(true); setGlossaryHidden(false); }}
title={t('dub.glossary_title')}
>
{t('dub.glossary_btn', { count: glossaryTermCount })}
</button>
)}
{dubJobId && glossaryVisible && (
<div className="dub-glossary-wrap">
<GlossaryPanel
projectId={dubJobId}
sourceLang={dubLangCode && dubLang ? (dubLang.slice(0, 2).toLowerCase() || 'en') : 'en'}
targetLang={dubLangCode}
segments={dubSegments}
onChange={onGlossaryChange}
onClose={() => { setGlossaryHidden(true); setGlossaryOpen(false); }}
/>
</div>
)}
{dubJobId && !glossaryVisible && (
<button
type="button"
className="dub-glossary-chip"
onClick={() => {
setGlossaryOpen(true);
setGlossaryHidden(false);
}}
title={t('dub.glossary_title')}
>
{t('dub.glossary_btn', { count: glossaryTermCount })}
</button>
)}
{dubJobId && glossaryVisible && (
<div className="dub-glossary-wrap">
<GlossaryPanel
projectId={dubJobId}
sourceLang={dubLangCode && dubLang ? dubLang.slice(0, 2).toLowerCase() || 'en' : 'en'}
targetLang={dubLangCode}
segments={dubSegments}
onChange={onGlossaryChange}
onClose={() => {
setGlossaryHidden(true);
setGlossaryOpen(false);
}}
/>
</div>
)}
{/* "Apply Voice to All" row removed 2026-04-21 redundant
{/* "Apply Voice to All" row removed 2026-04-21 redundant
with the CAST strip in the left column, which does the same
thing per-speaker (and handles the multi-speaker case cleanly). */}
{selectedSegIds.size > 0 && (
<div className="dub-bulk-row dub-bulk-row--select">
<span className="dub-bulk-row__label-brand">{t('dub.selected_count', { count: selectedSegIds.size })}</span>
<select className="input-base dub-bulk-select dub-bulk-select--voice"
value="" onChange={(e) => { const v = e.target.value; if (v === '__clear__') bulkApplyToSelected({ profile_id: '' }); else if (v) bulkApplyToSelected({ profile_id: v }); }}>
<option value="">{t('dub.set_voice')}</option>
<option value="__clear__">{t('dub.clear_voice')}</option>
{speakerClones && Object.keys(speakerClones).length > 0 && (
<optgroup label={t('dub.cast')}>
{Object.keys(speakerClones).map(spk => {
const autoId = `auto:${(spk || '').toLowerCase().replace(/\s+/g, '_')}`;
return <option key={autoId} value={autoId}>🎤 {spk}</option>;
})}
</optgroup>
)}
{profiles.filter(p => !p.instruct).length > 0 && (
<optgroup label={t('dub.clone_profiles')}>
{profiles.filter(p => !p.instruct).map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</optgroup>
)}
{profiles.filter(p => !!p.instruct).length > 0 && (
<optgroup label={t('dub.design_presets')}>
{profiles.filter(p => !!p.instruct).map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</optgroup>
)}
</select>
<select className="input-base dub-bulk-select dub-bulk-select--lang"
value="" onChange={(e) => { if (e.target.value === '__def__') bulkApplyToSelected({ target_lang: null }); else if (e.target.value) bulkApplyToSelected({ target_lang: e.target.value }); }}>
<option value="">{t('dub.set_lang')}</option>
<option value="__def__">{t('dub.default_lang')}</option>
{LANG_CODES.map(lc => <option key={lc.code} value={lc.code}>{lc.code.toUpperCase()}</option>)}
</select>
<Button variant="danger" size="sm" onClick={bulkDeleteSelected}>{t('dub.delete_selected')}</Button>
<Button variant="ghost" size="sm" onClick={clearSegSelection} className="dub-bulk-row__clear">{t('dub.clear_selection')}</Button>
</div>
)}
{selectedSegIds.size > 0 && (
<div className="dub-bulk-row dub-bulk-row--select">
<span className="dub-bulk-row__label-brand">
{t('dub.selected_count', { count: selectedSegIds.size })}
</span>
<select
className="input-base dub-bulk-select dub-bulk-select--voice"
value=""
onChange={(e) => {
const v = e.target.value;
if (v === '__clear__') bulkApplyToSelected({ profile_id: '' });
else if (v) bulkApplyToSelected({ profile_id: v });
}}
>
<option value="">{t('dub.set_voice')}</option>
<option value="__clear__">{t('dub.clear_voice')}</option>
{speakerClones && Object.keys(speakerClones).length > 0 && (
<optgroup label={t('dub.cast')}>
{Object.keys(speakerClones).map((spk) => {
const autoId = `auto:${(spk || '').toLowerCase().replace(/\s+/g, '_')}`;
return (
<option key={autoId} value={autoId}>
🎤 {spk}
</option>
);
})}
</optgroup>
)}
{profiles.filter((p) => !p.instruct).length > 0 && (
<optgroup label={t('dub.clone_profiles')}>
{profiles
.filter((p) => !p.instruct)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</optgroup>
)}
{profiles.filter((p) => !!p.instruct).length > 0 && (
<optgroup label={t('dub.design_presets')}>
{profiles
.filter((p) => !!p.instruct)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</optgroup>
)}
</select>
<select
className="input-base dub-bulk-select dub-bulk-select--lang"
value=""
onChange={(e) => {
if (e.target.value === '__def__') bulkApplyToSelected({ target_lang: null });
else if (e.target.value) bulkApplyToSelected({ target_lang: e.target.value });
}}
>
<option value="">{t('dub.set_lang')}</option>
<option value="__def__">{t('dub.default_lang')}</option>
{LANG_CODES.map((lc) => (
<option key={lc.code} value={lc.code}>
{lc.code.toUpperCase()}
</option>
))}
</select>
<Button variant="danger" size="sm" onClick={bulkDeleteSelected}>
{t('dub.delete_selected')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={clearSegSelection}
className="dub-bulk-row__clear"
>
{t('dub.clear_selection')}
</Button>
</div>
)}
{showCheckpoint && (
<CheckpointBanner
stage={checkpointStage}
count={dubSegments.length}
onContinue={checkpointStage === 'done' ? null : onCheckpointContinue}
onDismiss={onCheckpointDismiss}
continueLoading={isTranslating}
/>
)}
{showCheckpoint && (
<CheckpointBanner
stage={checkpointStage}
count={dubSegments.length}
onContinue={checkpointStage === 'done' ? null : onCheckpointContinue}
onDismiss={onCheckpointDismiss}
continueLoading={isTranslating}
/>
)}
<Suspense fallback={<LazyFallback />}>
<DubSegmentTable
segments={dubSegments}
profiles={profiles}
speakerClones={speakerClones}
dubStep={dubStep}
dubProgress={dubProgress}
previewLoadingId={segmentPreviewLoading}
selectedIds={selectedSegIds}
onSelect={toggleSegSelect}
onSelectAll={selectAllSegs}
onClearSelection={clearSegSelection}
onEditField={segmentEditField}
onDelete={segmentDelete}
onRestore={segmentRestoreOriginal}
onPreview={handleSegmentPreview}
onDirect={onDirectSegment}
onSplit={segmentSplit}
onMerge={segmentMerge}
onSeek={seekWaveform}
timelineSelectedId={timelineSelSegId}
/>
</Suspense>
</div>
<Suspense fallback={<LazyFallback />}>
<DubSegmentTable
segments={dubSegments}
profiles={profiles}
speakerClones={speakerClones}
dubStep={dubStep}
dubProgress={dubProgress}
previewLoadingId={segmentPreviewLoading}
selectedIds={selectedSegIds}
onSelect={toggleSegSelect}
onSelectAll={selectAllSegs}
onClearSelection={clearSegSelection}
onEditField={segmentEditField}
onDelete={segmentDelete}
onRestore={segmentRestoreOriginal}
onPreview={handleSegmentPreview}
onDirect={onDirectSegment}
onSplit={segmentSplit}
onMerge={segmentMerge}
onSeek={seekWaveform}
timelineSelectedId={timelineSelSegId}
/>
</Suspense>
</div>
);
}
+3 -1
View File
@@ -15,7 +15,9 @@ const FooterBtn = React.forwardRef(function FooterBtn(
'dub-footer-btn',
sm && 'dub-footer-btn--sm',
`dub-footer-btn--${tone}`,
].filter(Boolean).join(' ');
]
.filter(Boolean)
.join(' ');
return (
<button ref={ref} className={cls} disabled={disabled} onClick={onClick} {...rest}>
{icon} {label}
+497 -331
View File
@@ -1,4 +1,23 @@
import { Film, Save, RotateCcw, AlertCircle, Sparkles, FileText, Loader, Users, UploadCloud, Link2, Globe, ChevronUp, ChevronDown, UserSquare2, Languages, Trash2, Play, Download } from 'lucide-react';
import {
Film,
Save,
RotateCcw,
AlertCircle,
Sparkles,
FileText,
Loader,
Users,
UploadCloud,
Link2,
Globe,
ChevronUp,
ChevronDown,
UserSquare2,
Languages,
Trash2,
Play,
Download,
} from 'lucide-react';
import { Button, Badge } from '../../ui';
import WaveformTimeline from '../WaveformTimeline';
import DubbingDemo from '../DubbingDemo';
@@ -7,362 +26,509 @@ import PrepOverlay from './PrepOverlay';
import TranscribeOverlay from './TranscribeOverlay';
import { LANG_CODES } from '../../utils/languages';
export default function IdleSkeleton({ t, dubVideoFile, activeProjectName, dubFilename, dubError, dubJobId, dubStep, dubFailure, handleDubRetryTranscribe, handleDubImportSrt, dubLocalBlobUrl, dubPrepStage, dubPrepProgress, handleDubAbort, transcribeElapsed, dubDuration, dubNumSpeakers, setDubNumSpeakers, handleDubUpload, demoDismissed, dismissDubDemo, setDubVideoFile, setDubInputType, setDubStep, fileToMediaUrl, setDubLocalBlobUrl, ingestUrl, setIngestUrl, onIngestUrl, fetchYtSubs, setFetchYtSubs, dubLangCode, setDubLangCode, setDubLang, landingAdvOpen, setLandingAdvOpen, dubInstruct, setDubInstruct }) {
export default function IdleSkeleton({
t,
dubVideoFile,
activeProjectName,
dubFilename,
dubError,
dubJobId,
dubStep,
dubFailure,
handleDubRetryTranscribe,
handleDubImportSrt,
dubLocalBlobUrl,
dubPrepStage,
dubPrepProgress,
handleDubAbort,
transcribeElapsed,
dubDuration,
dubNumSpeakers,
setDubNumSpeakers,
handleDubUpload,
demoDismissed,
dismissDubDemo,
setDubVideoFile,
setDubInputType,
setDubStep,
fileToMediaUrl,
setDubLocalBlobUrl,
ingestUrl,
setIngestUrl,
onIngestUrl,
fetchYtSubs,
setFetchYtSubs,
dubLangCode,
setDubLangCode,
setDubLang,
landingAdvOpen,
setLandingAdvOpen,
dubInstruct,
setDubInstruct,
}) {
return (
<div className="dub-col">
{/* Header bar */}
<div className="dub-head">
<div className="label-row dub-head__title">
<Film className="label-icon" size={11} />
<span className="dub-head__filename">{dubVideoFile ? dubVideoFile.name : t('dub.video_dubbing_studio')}</span>
{dubVideoFile && <span className="dub-head__meta">· {(dubVideoFile.size / 1024 / 1024).toFixed(1)} MB</span>}
{activeProjectName && activeProjectName !== dubFilename && (
<span className="dub-head__project"> {activeProjectName}</span>
)}
</div>
<div className="dub-head__actions">
<Button variant="subtle" size="sm" disabled title={t('dub.save')} aria-label={t('dub.save')}><Save size={12} /></Button>
<Button variant="ghost" size="sm" disabled title={t('dub.reset')} aria-label={t('dub.reset')}><RotateCcw size={12} /></Button>
</div>
</div>
<div className="dub-col">
{/* Header bar */}
<div className="dub-head">
<div className="label-row dub-head__title">
<Film className="label-icon" size={11} />
<span className="dub-head__filename">
{dubVideoFile ? dubVideoFile.name : t('dub.video_dubbing_studio')}
</span>
{dubVideoFile && (
<span className="dub-head__meta">
· {(dubVideoFile.size / 1024 / 1024).toFixed(1)} MB
</span>
)}
{activeProjectName && activeProjectName !== dubFilename && (
<span className="dub-head__project"> {activeProjectName}</span>
)}
</div>
<div className="dub-head__actions">
<Button
variant="subtle"
size="sm"
disabled
title={t('dub.save')}
aria-label={t('dub.save')}
>
<Save size={12} />
</Button>
<Button
variant="ghost"
size="sm"
disabled
title={t('dub.reset')}
aria-label={t('dub.reset')}
>
<RotateCcw size={12} />
</Button>
</div>
</div>
{/* Transcription failure banner shown in the idle state when a
{/* Transcription failure banner shown in the idle state when a
job exists but transcription produced zero segments (or threw).
Surfaces the backend error detail and offers one-click retry,
which re-runs the ASR stream on the same job without re-uploading. */}
{dubError && dubJobId && dubStep === 'idle' && (
<div className="dub-footer-banner">
<Badge tone="danger">
<AlertCircle size={11} /> {dubError}
</Badge>
<DubFailureNotice failure={dubFailure} />
{handleDubRetryTranscribe && (
<Button
variant="subtle"
size="sm"
onClick={handleDubRetryTranscribe}
leading={<Sparkles size={10} />}
>
{t('dub.retry_transcription')}
</Button>
)}
{handleDubImportSrt && (
<label
htmlFor="srt-import-banner-input"
className="dub-idle-upload-label"
title={t('dub.import_srt')}
style={{ cursor: 'pointer' }}
>
<FileText size={11} /> {t('dub.import_srt_alt')}
{dubError && dubJobId && dubStep === 'idle' && (
<div className="dub-footer-banner">
<Badge tone="danger">
<AlertCircle size={11} /> {dubError}
</Badge>
<DubFailureNotice failure={dubFailure} />
{handleDubRetryTranscribe && (
<Button
variant="subtle"
size="sm"
onClick={handleDubRetryTranscribe}
leading={<Sparkles size={10} />}
>
{t('dub.retry_transcription')}
</Button>
)}
{handleDubImportSrt && (
<label
htmlFor="srt-import-banner-input"
className="dub-idle-upload-label"
title={t('dub.import_srt')}
style={{ cursor: 'pointer' }}
>
<FileText size={11} /> {t('dub.import_srt_alt')}
<input
id="srt-import-banner-input"
type="file"
accept=".srt,text/srt,text/plain"
hidden
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleDubImportSrt(f);
e.target.value = '';
}}
/>
</label>
)}
</div>
)}
{/* SPLIT LAYOUT skeleton */}
<div className={`dub-split-grid ${dubVideoFile ? 'dub-split-2' : 'dub-split-1'}`}>
{/* LEFT */}
<div className="studio-panel dub-panel-col">
{dubVideoFile ? (
<>
<WaveformTimeline
audioSrc={dubLocalBlobUrl?.audioUrl}
videoSrc={dubLocalBlobUrl?.videoUrl}
segments={[]}
disabled={true}
overlayContent={
dubStep === 'uploading' ? (
<PrepOverlay
stage={dubPrepStage}
progress={dubPrepProgress}
onAbort={handleDubAbort}
/>
) : dubStep === 'transcribing' ? (
<TranscribeOverlay
elapsed={transcribeElapsed}
duration={dubDuration}
onAbort={handleDubAbort}
/>
) : null
}
/>
<div className="dub-change-row">
<label htmlFor="video-upload" className="dub-idle-upload-label">
<Film size={13} /> {t('dub.change_file')}
</label>
{dubJobId && handleDubImportSrt && (
<label
htmlFor="srt-import-input"
className="dub-idle-upload-label"
title={t('dub.import_srt')}
style={{ cursor: 'pointer' }}
>
<FileText size={13} /> {t('dub.import_srt')}
<input
id="srt-import-input"
type="file"
accept=".srt,text/srt,text/plain"
hidden
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleDubImportSrt(f);
e.target.value = '';
}}
/>
</label>
)}
<label className="dub-speakers-hint" title={t('dub.num_speakers_help')}>
<Users size={13} /> {t('dub.num_speakers_label')}
<input
id="srt-import-banner-input"
type="file"
accept=".srt,text/srt,text/plain"
hidden
type="number"
min={1}
max={20}
step={1}
className="dub-speakers-input"
placeholder={t('dub.num_speakers_auto')}
value={dubNumSpeakers ?? ''}
disabled={dubStep === 'uploading' || dubStep === 'transcribing'}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleDubImportSrt(f);
e.target.value = '';
const v = parseInt(e.target.value, 10);
setDubNumSpeakers(Number.isFinite(v) && v > 0 ? Math.min(v, 20) : null);
}}
/>
</label>
)}
</div>
)}
{/* SPLIT LAYOUT skeleton */}
<div className={`dub-split-grid ${dubVideoFile ? 'dub-split-2' : 'dub-split-1'}`}>
{/* LEFT */}
<div className="studio-panel dub-panel-col">
{dubVideoFile ? (
<>
<WaveformTimeline
audioSrc={dubLocalBlobUrl?.audioUrl}
videoSrc={dubLocalBlobUrl?.videoUrl}
segments={[]}
disabled={true}
overlayContent={
dubStep === 'uploading' ? (
<PrepOverlay stage={dubPrepStage} progress={dubPrepProgress} onAbort={handleDubAbort} />
) : dubStep === 'transcribing' ? (
<TranscribeOverlay
elapsed={transcribeElapsed}
duration={dubDuration}
onAbort={handleDubAbort}
/>
) : null
}
/>
<div className="dub-change-row">
<label htmlFor="video-upload" className="dub-idle-upload-label">
<Film size={13} /> {t('dub.change_file')}
</label>
{dubJobId && handleDubImportSrt && (
<label
htmlFor="srt-import-input"
className="dub-idle-upload-label"
title={t('dub.import_srt')}
style={{ cursor: 'pointer' }}
>
<FileText size={13} /> {t('dub.import_srt')}
<input
id="srt-import-input"
type="file"
accept=".srt,text/srt,text/plain"
hidden
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleDubImportSrt(f);
e.target.value = '';
}}
/>
</label>
)}
<label className="dub-speakers-hint" title={t('dub.num_speakers_help')}>
<Users size={13} /> {t('dub.num_speakers_label')}
<input
type="number"
min={1}
max={20}
step={1}
className="dub-speakers-input"
placeholder={t('dub.num_speakers_auto')}
value={dubNumSpeakers ?? ''}
disabled={dubStep === 'uploading' || dubStep === 'transcribing'}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
setDubNumSpeakers(Number.isFinite(v) && v > 0 ? Math.min(v, 20) : null);
}}
/>
</label>
<button className="btn-primary dub-change-row__cta"
onClick={handleDubUpload}
disabled={dubStep === 'uploading' || dubStep === 'transcribing'}>
{dubStep === 'uploading' || dubStep === 'transcribing'
? <><Loader className="spinner" size={14} /> {t('common.loading')}</>
: <><Sparkles size={14} /> {t('dub.upload_transcribe')}</>}
</button>
</div>
</>
) : dubStep === 'uploading' ? (
<PrepOverlay stage={dubPrepStage} progress={dubPrepProgress} onAbort={handleDubAbort} large />
) : (
<>
{!demoDismissed && (
<DubbingDemo onDismiss={dismissDubDemo} />
<button
className="btn-primary dub-change-row__cta"
onClick={handleDubUpload}
disabled={dubStep === 'uploading' || dubStep === 'transcribing'}
>
{dubStep === 'uploading' || dubStep === 'transcribing' ? (
<>
<Loader className="spinner" size={14} /> {t('common.loading')}
</>
) : (
<>
<Sparkles size={14} /> {t('dub.upload_transcribe')}
</>
)}
<label htmlFor="video-upload" className="dub-idle-drop"
onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add('is-dragging'); }}
onDragLeave={e => { e.currentTarget.classList.remove('is-dragging'); }}
onDrop={e => {
e.preventDefault();
e.currentTarget.classList.remove('is-dragging');
const file = e.dataTransfer.files[0];
if (file && (file.type.startsWith('video/') || file.type.startsWith('audio/') || /\.(mp3|wav|flac|m4a|aac|ogg|opus|wma)$/i.test(file.name))) {
setDubVideoFile(file);
// #119: an audio file audio-only dubbing (skip video work, output audio).
setDubInputType(file.type.startsWith('audio/') || /\.(mp3|wav|flac|m4a|aac|ogg|opus|wma)$/i.test(file.name) ? 'audio' : 'video');
setDubStep('idle');
fileToMediaUrl(file, null).then(urls => setDubLocalBlobUrl(urls));
}
}}>
<div className="dub-idle-drop__puck">
<UploadCloud color="#d3869b" size={28} />
</div>
<div className="dub-idle-drop__lines">
<div className="dub-idle-drop__title">{t('dub.drop_here')}</div>
<div className="dub-idle-drop__sub">{t('dub.supported_formats')}</div>
</div>
<div
className="dub-ingest-row"
onClick={e => e.preventDefault()}
>
<Link2 size={13} color="#a89984" />
<input
type="text"
placeholder={t('dub.paste_url')}
value={ingestUrl}
onChange={e => setIngestUrl(e.target.value)}
onClick={e => { e.preventDefault(); e.stopPropagation(); }}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); onIngestUrl(); } }}
className="dub-ingest-row__input"
/>
<button
type="button"
onClick={e => { e.preventDefault(); e.stopPropagation(); onIngestUrl(); }}
disabled={!ingestUrl.trim()}
className={`dub-ingest-row__cta ${ingestUrl.trim() ? 'is-ready' : ''}`}
>
{t('dub.ingest')}
</button>
</div>
<label
className="dub-ingest-sub-opt"
title={t('dub.pull_captions_title')}
onClick={e => { e.stopPropagation(); }}
>
<input
type="checkbox"
checked={fetchYtSubs}
onChange={e => setFetchYtSubs(e.target.checked)}
onClick={e => e.stopPropagation()}
/>
<span>{t('dub.pull_captions')}</span>
</label>
</label>
{/* One decision up front: the target language. Everything else
(speakers, style) hides behind Advanced ElevenLabs-style
flow, OmniVoice chrome. The pick pre-seeds the editor. */}
<div className="dub-landing-opts">
<label className="dub-landing-opts__lang">
<Globe size={13} />
<span className="dub-landing-opts__label">{t('dub.target_language', { defaultValue: 'Dub into' })}</span>
<select
className="input-base input-base--xs"
value={dubLangCode}
onChange={(e) => {
const lc = LANG_CODES.find(l => l.code === e.target.value);
setDubLangCode(e.target.value);
if (lc) setDubLang(lc.label);
}}
>
{LANG_CODES.map(lc => (
<option key={lc.code} value={lc.code}>{lc.label} {lc.code}</option>
))}
</select>
</label>
</button>
</div>
</>
) : dubStep === 'uploading' ? (
<PrepOverlay
stage={dubPrepStage}
progress={dubPrepProgress}
onAbort={handleDubAbort}
large
/>
) : (
<>
{!demoDismissed && <DubbingDemo onDismiss={dismissDubDemo} />}
<label
htmlFor="video-upload"
className="dub-idle-drop"
onDragOver={(e) => {
e.preventDefault();
e.currentTarget.classList.add('is-dragging');
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove('is-dragging');
}}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.classList.remove('is-dragging');
const file = e.dataTransfer.files[0];
if (
file &&
(file.type.startsWith('video/') ||
file.type.startsWith('audio/') ||
/\.(mp3|wav|flac|m4a|aac|ogg|opus|wma)$/i.test(file.name))
) {
setDubVideoFile(file);
// #119: an audio file audio-only dubbing (skip video work, output audio).
setDubInputType(
file.type.startsWith('audio/') ||
/\.(mp3|wav|flac|m4a|aac|ogg|opus|wma)$/i.test(file.name)
? 'audio'
: 'video',
);
setDubStep('idle');
fileToMediaUrl(file, null).then((urls) => setDubLocalBlobUrl(urls));
}
}}
>
<div className="dub-idle-drop__puck">
<UploadCloud color="#d3869b" size={28} />
</div>
<div className="dub-idle-drop__lines">
<div className="dub-idle-drop__title">{t('dub.drop_here')}</div>
<div className="dub-idle-drop__sub">{t('dub.supported_formats')}</div>
</div>
<div className="dub-ingest-row" onClick={(e) => e.preventDefault()}>
<Link2 size={13} color="#a89984" />
<input
type="text"
placeholder={t('dub.paste_url')}
value={ingestUrl}
onChange={(e) => setIngestUrl(e.target.value)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
onIngestUrl();
}
}}
className="dub-ingest-row__input"
/>
<button
type="button"
className="dub-landing-opts__adv"
onClick={() => setLandingAdvOpen(o => !o)}
aria-expanded={landingAdvOpen}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onIngestUrl();
}}
disabled={!ingestUrl.trim()}
className={`dub-ingest-row__cta ${ingestUrl.trim() ? 'is-ready' : ''}`}
>
{t('dub.advanced', { defaultValue: 'Advanced' })}
{landingAdvOpen ? <ChevronUp size={11} /> : <ChevronDown size={11} />}
{t('dub.ingest')}
</button>
</div>
{landingAdvOpen && (
<div className="dub-landing-adv">
<label className="dub-landing-adv__field" title={t('dub.num_speakers_help')}>
<Users size={12} /> {t('dub.num_speakers_label')}
<input
type="number" min={1} max={20} step={1}
className="input-base input-base--xs dub-speakers-input"
placeholder={t('dub.num_speakers_auto')}
value={dubNumSpeakers ?? ''}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
setDubNumSpeakers(Number.isFinite(v) && v > 0 ? Math.min(v, 20) : null);
}}
/>
</label>
<label className="dub-landing-adv__field dub-landing-adv__field--grow">
<UserSquare2 size={12} /> {t('dub.style')}
<input
type="text"
className="input-base input-base--xs"
placeholder={t('dub.style_placeholder')}
value={dubInstruct}
onChange={(e) => setDubInstruct(e.target.value)}
/>
</label>
</div>
)}
</>
)}
<label
className="dub-ingest-sub-opt"
title={t('dub.pull_captions_title')}
onClick={(e) => {
e.stopPropagation();
}}
>
<input
type="checkbox"
checked={fetchYtSubs}
onChange={(e) => setFetchYtSubs(e.target.checked)}
onClick={(e) => e.stopPropagation()}
/>
<span>{t('dub.pull_captions')}</span>
</label>
</label>
<input type="file" accept="video/*,audio/*,.mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma" id="video-upload" className="dub-hidden-file"
onChange={e => {
const file = e.target.files[0];
if (!file) return;
setDubVideoFile(file);
// #119: an audio file audio-only dubbing (skip video work, output audio).
setDubInputType(file.type.startsWith('audio/') || /\.(mp3|wav|flac|m4a|aac|ogg|opus|wma)$/i.test(file.name) ? 'audio' : 'video');
setDubStep('idle');
setDubLocalBlobUrl(prev => { fileToMediaUrl(file, prev).then(urls => setDubLocalBlobUrl(urls)); return prev; });
}} />
{dubVideoFile && (
<div className="dub-cast dub-cast--muted">
<div className="dub-cast__row">
<span className="dub-cast__kicker">{t('dub.cast')}</span>
<span className="dub-cast__label">{t('dub.speaker', { n: 1 })}</span>
<span className="dub-cast--muted__chip">{t('dub.default')}</span>
</div>
</div>
)}
</div>
{/* RIGHT: Ghost settings + segment table (only when video loaded) */}
{dubVideoFile ? (
<div className="studio-panel dub-panel-col">
<div className="dub-skel-settings">
<div className="dub-skel-field">
<div className="label-row"><Globe className="label-icon" size={9} /> {t('dub.language')}</div>
<select className="input-base input-base--xs" disabled>
<option>{t('dub.auto')}</option>
{/* One decision up front: the target language. Everything else
(speakers, style) hides behind Advanced ElevenLabs-style
flow, OmniVoice chrome. The pick pre-seeds the editor. */}
<div className="dub-landing-opts">
<label className="dub-landing-opts__lang">
<Globe size={13} />
<span className="dub-landing-opts__label">
{t('dub.target_language', { defaultValue: 'Dub into' })}
</span>
<select
className="input-base input-base--xs"
value={dubLangCode}
onChange={(e) => {
const lc = LANG_CODES.find((l) => l.code === e.target.value);
setDubLangCode(e.target.value);
if (lc) setDubLang(lc.label);
}}
>
{LANG_CODES.map((lc) => (
<option key={lc.code} value={lc.code}>
{lc.label} {lc.code}
</option>
))}
</select>
</div>
<div className="dub-skel-field--sm">
<div className="label-row">{t('dub.iso_code')}</div>
<select className="input-base input-base--xs" disabled>
<option>en {t('dub.original_audio')}</option>
</select>
</div>
<div className="dub-skel-field">
<div className="label-row"><UserSquare2 className="label-icon" size={9} /> {t('dub.style')}</div>
<input className="input-base input-base--xs" disabled placeholder={t('dub.style_placeholder')} />
</div>
<button disabled className="dub-skel-translate-btn">
<Languages size={10} /> {t('dub.translate_all')}
</label>
<button
type="button"
className="dub-landing-opts__adv"
onClick={() => setLandingAdvOpen((o) => !o)}
aria-expanded={landingAdvOpen}
>
{t('dub.advanced', { defaultValue: 'Advanced' })}
{landingAdvOpen ? <ChevronUp size={11} /> : <ChevronDown size={11} />}
</button>
</div>
<div className="dub-skel-transcript-toggle">
<div className="override-toggle dub-skel-transcript-toggle__inner">
<span><FileText size={10} className="dub-inline-icon" /> {t('dub.transcript')}</span>
<ChevronDown size={10} />
{landingAdvOpen && (
<div className="dub-landing-adv">
<label className="dub-landing-adv__field" title={t('dub.num_speakers_help')}>
<Users size={12} /> {t('dub.num_speakers_label')}
<input
type="number"
min={1}
max={20}
step={1}
className="input-base input-base--xs dub-speakers-input"
placeholder={t('dub.num_speakers_auto')}
value={dubNumSpeakers ?? ''}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
setDubNumSpeakers(Number.isFinite(v) && v > 0 ? Math.min(v, 20) : null);
}}
/>
</label>
<label className="dub-landing-adv__field dub-landing-adv__field--grow">
<UserSquare2 size={12} /> {t('dub.style')}
<input
type="text"
className="input-base input-base--xs"
placeholder={t('dub.style_placeholder')}
value={dubInstruct}
onChange={(e) => setDubInstruct(e.target.value)}
/>
</label>
</div>
</div>
<div className="segment-table dub-skel-table">
<div className="segment-header">
<span className="dub-skel-header-time">{t('dub.time_col')}</span>
<span className="dub-skel-header-spkr">{t('dub.spkr_col')}</span>
<span className="dub-skel-header-text">{t('dub.text_col')}</span>
<span className="dub-skel-header-voice">{t('dub.voice_col')}</span>
<span className="dub-skel-header-acts"></span>
</div>
{[1, 2, 3, 4, 5, 6].map(i => (
<div key={i} className="segment-row dub-skel-row" style={{ opacity: 0.5 + (0.07 * (6 - i)) }}>
<span className="dub-skel-cell-time dub-skel-bar" />
<span className="dub-skel-cell-spkr dub-skel-bar" />
<div className="dub-skel-cell-text dub-skel-bar" />
<span className="dub-skel-cell-voice dub-skel-bar" />
<div className="dub-skel-cell-acts">
<span className="segment-del dub-skel-cell-acts__icon"><Trash2 size={9} /></span>
</div>
</div>
))}
<div className="dub-skel-hint">{t('dub.transcript_after_extract', { defaultValue: 'Transcript appears after extraction.' })}</div>
</div>
</div>
) : null}
</div>
)}
</>
)}
<input
type="file"
accept="video/*,audio/*,.mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma"
id="video-upload"
className="dub-hidden-file"
onChange={(e) => {
const file = e.target.files[0];
if (!file) return;
setDubVideoFile(file);
// #119: an audio file audio-only dubbing (skip video work, output audio).
setDubInputType(
file.type.startsWith('audio/') ||
/\.(mp3|wav|flac|m4a|aac|ogg|opus|wma)$/i.test(file.name)
? 'audio'
: 'video',
);
setDubStep('idle');
setDubLocalBlobUrl((prev) => {
fileToMediaUrl(file, prev).then((urls) => setDubLocalBlobUrl(urls));
return prev;
});
}}
/>
{/* Ghost footer only once a file is in play; the bare landing stays
clean. Generate is the lone primary, exports demoted to one menu. */}
{dubVideoFile && (
<div className="studio-panel dub-ghost-footer">
<div className="dub-skel-gen-row">
<button className="btn-primary dub-skel-gen-btn" disabled>
<Play size={11} /> {t('dub.generate_dub')}
</button>
<button className="dub-skel-gen-btn dub-skel-gen-btn--secondary" disabled>
<Download size={11} /> {t('dub.export_btn', { defaultValue: 'Export' })} <ChevronDown size={10} />
</button>
<div className="dub-cast dub-cast--muted">
<div className="dub-cast__row">
<span className="dub-cast__kicker">{t('dub.cast')}</span>
<span className="dub-cast__label">{t('dub.speaker', { n: 1 })}</span>
<span className="dub-cast--muted__chip">{t('dub.default')}</span>
</div>
</div>
)}
</div>
{/* RIGHT: Ghost settings + segment table (only when video loaded) */}
{dubVideoFile ? (
<div className="studio-panel dub-panel-col">
<div className="dub-skel-settings">
<div className="dub-skel-field">
<div className="label-row">
<Globe className="label-icon" size={9} /> {t('dub.language')}
</div>
<select className="input-base input-base--xs" disabled>
<option>{t('dub.auto')}</option>
</select>
</div>
<div className="dub-skel-field--sm">
<div className="label-row">{t('dub.iso_code')}</div>
<select className="input-base input-base--xs" disabled>
<option>en {t('dub.original_audio')}</option>
</select>
</div>
<div className="dub-skel-field">
<div className="label-row">
<UserSquare2 className="label-icon" size={9} /> {t('dub.style')}
</div>
<input
className="input-base input-base--xs"
disabled
placeholder={t('dub.style_placeholder')}
/>
</div>
<button disabled className="dub-skel-translate-btn">
<Languages size={10} /> {t('dub.translate_all')}
</button>
</div>
<div className="dub-skel-transcript-toggle">
<div className="override-toggle dub-skel-transcript-toggle__inner">
<span>
<FileText size={10} className="dub-inline-icon" /> {t('dub.transcript')}
</span>
<ChevronDown size={10} />
</div>
</div>
<div className="segment-table dub-skel-table">
<div className="segment-header">
<span className="dub-skel-header-time">{t('dub.time_col')}</span>
<span className="dub-skel-header-spkr">{t('dub.spkr_col')}</span>
<span className="dub-skel-header-text">{t('dub.text_col')}</span>
<span className="dub-skel-header-voice">{t('dub.voice_col')}</span>
<span className="dub-skel-header-acts"></span>
</div>
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="segment-row dub-skel-row"
style={{ opacity: 0.5 + 0.07 * (6 - i) }}
>
<span className="dub-skel-cell-time dub-skel-bar" />
<span className="dub-skel-cell-spkr dub-skel-bar" />
<div className="dub-skel-cell-text dub-skel-bar" />
<span className="dub-skel-cell-voice dub-skel-bar" />
<div className="dub-skel-cell-acts">
<span className="segment-del dub-skel-cell-acts__icon">
<Trash2 size={9} />
</span>
</div>
</div>
))}
<div className="dub-skel-hint">
{t('dub.transcript_after_extract', {
defaultValue: 'Transcript appears after extraction.',
})}
</div>
</div>
</div>
) : null}
</div>
{/* Ghost footer only once a file is in play; the bare landing stays
clean. Generate is the lone primary, exports demoted to one menu. */}
{dubVideoFile && (
<div className="studio-panel dub-ghost-footer">
<div className="dub-skel-gen-row">
<button className="btn-primary dub-skel-gen-btn" disabled>
<Play size={11} /> {t('dub.generate_dub')}
</button>
<button className="dub-skel-gen-btn dub-skel-gen-btn--secondary" disabled>
<Download size={11} /> {t('dub.export_btn', { defaultValue: 'Export' })}{' '}
<ChevronDown size={10} />
</button>
</div>
</div>
)}
</div>
);
}
+31 -21
View File
@@ -3,14 +3,18 @@ import { useTranslation } from 'react-i18next';
import { Loader, Square } from 'lucide-react';
import { Button } from '../../ui';
const PREP_FULL = ['download', 'extract', 'demucs', 'scene'];
const PREP_FULL = ['download', 'extract', 'demucs', 'scene'];
const PREP_CACHED = ['download', 'extract', 'cached'];
function fmtBytesRate(bps) {
if (!bps || bps <= 0) return null;
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
let v = bps, i = 0;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
let v = bps,
i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i += 1;
}
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
}
@@ -18,7 +22,8 @@ function fmtEta(seconds) {
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return null;
const s = Math.round(seconds);
if (s < 60) return `${s}s left`;
const m = Math.floor(s / 60), rem = s % 60;
const m = Math.floor(s / 60),
rem = s % 60;
return rem ? `${m}m ${rem}s left` : `${m}m left`;
}
@@ -30,10 +35,10 @@ function PrepOverlay({ stage, progress, onAbort, large = false }) {
const { t } = useTranslation();
const LABEL = {
download: t('dub.prep_download'),
extract: t('dub.prep_extract'),
demucs: t('dub.prep_demucs'),
scene: t('dub.prep_scene'),
cached: t('dub.prep_cached'),
extract: t('dub.prep_extract'),
demucs: t('dub.prep_demucs'),
scene: t('dub.prep_scene'),
cached: t('dub.prep_cached'),
};
const stages = stage === 'cached' ? PREP_CACHED : PREP_FULL;
// Elapsed-time ticker for the current stage. Reset whenever
@@ -41,7 +46,10 @@ function PrepOverlay({ stage, progress, onAbort, large = false }) {
const [elapsedS, setElapsedS] = useState(0);
const startedAt = progress?.stageStartedAt ?? null;
useEffect(() => {
if (!startedAt) { setElapsedS(0); return undefined; }
if (!startedAt) {
setElapsedS(0);
return undefined;
}
setElapsedS(Math.floor((Date.now() - startedAt) / 1000));
const iv = setInterval(() => {
setElapsedS(Math.floor((Date.now() - startedAt) / 1000));
@@ -52,17 +60,19 @@ function PrepOverlay({ stage, progress, onAbort, large = false }) {
const pct = progress?.percent;
const hasPct = typeof pct === 'number' && pct >= 0 && pct <= 100;
const speed = stage === 'download' ? fmtBytesRate(progress?.speedBps) : null;
const eta = fmtEta(progress?.etaS);
const elapsedLabel = startedAt ? (elapsedS < 60 ? `${elapsedS}s` : `${Math.floor(elapsedS / 60)}m ${elapsedS % 60}s`) : null;
const eta = fmtEta(progress?.etaS);
const elapsedLabel = startedAt
? elapsedS < 60
? `${elapsedS}s`
: `${Math.floor(elapsedS / 60)}m ${elapsedS % 60}s`
: null;
const detailBits = [
hasPct ? `${pct}%` : null,
elapsedLabel ? t('dub.prep_elapsed', { time: elapsedLabel }) : null,
speed,
eta,
].filter(Boolean);
const note = stage === 'demucs' && !hasPct
? t('dub.prep_demucs_note')
: null;
const note = stage === 'demucs' && !hasPct ? t('dub.prep_demucs_note') : null;
const body = (
<>
@@ -79,7 +89,7 @@ function PrepOverlay({ stage, progress, onAbort, large = false }) {
<span className="dub-prep-overlay__detail">{detailBits.join(' · ')}</span>
)}
<div className={`dub-prep-chips ${large ? 'dub-prep-chips--lg' : ''}`}>
{stages.map(s => (
{stages.map((s) => (
<span
key={s}
className={`dub-prep-chip ${stage === s ? 'is-active' : ''} ${s === 'cached' ? 'is-cached' : ''}`}
@@ -88,17 +98,17 @@ function PrepOverlay({ stage, progress, onAbort, large = false }) {
</span>
))}
</div>
{note && (
<span className="dub-prep-overlay__note">{note}</span>
)}
{note && <span className="dub-prep-overlay__note">{note}</span>}
<Button variant="danger" size="sm" onClick={onAbort} leading={<Square size={11} />}>
{t('dub.prep_stop')}
</Button>
</>
);
return large
? <div className="dub-prep-overlay dub-prep-overlay--large">{body}</div>
: <div className="dub-prep-overlay">{body}</div>;
return large ? (
<div className="dub-prep-overlay dub-prep-overlay--large">{body}</div>
) : (
<div className="dub-prep-overlay">{body}</div>
);
}
export default PrepOverlay;
@@ -17,8 +17,15 @@ function TranscribeOverlay({ elapsed, duration, onAbort }) {
<span className="dub-trans-overlay__title">{t('dub.transcribing')}</span>
</div>
<div className="dub-trans-overlay__stats">
<span> {mm}:{ss} {t('dub.elapsed')}</span>
{est > 0 && <span>~{Math.max(0, est - elapsed)}{t('dub.remaining')}</span>}
<span>
{mm}:{ss} {t('dub.elapsed')}
</span>
{est > 0 && (
<span>
~{Math.max(0, est - elapsed)}
{t('dub.remaining')}
</span>
)}
</div>
{duration > 0 && (
<div className="dub-trans-overlay__bar">
@@ -1,23 +1,42 @@
import React from 'react';
import { Play, Loader, Star, Wand2, UserPlus } from 'lucide-react';
import { ArchetypeAvatar, AccentFlag, NowPlaying, USE_CASE_COLOR } from '../../utils/archetypeIcons';
import {
ArchetypeAvatar,
AccentFlag,
NowPlaying,
USE_CASE_COLOR,
} from '../../utils/archetypeIcons';
import { facetLabel } from './constants';
// Archetype card
export default function ArchetypeCard({
a, t, viewMode, isFavorite, isPlaying, isLoadingPreview,
onPreview, onUse, onDesign, onToggleFavorite,
a,
t,
viewMode,
isFavorite,
isPlaying,
isLoadingPreview,
onPreview,
onUse,
onDesign,
onToggleFavorite,
}) {
const color = USE_CASE_COLOR[a.use_case] || '#83a598';
const sub = [a.facets.gender, a.facets.age, a.facets.pitch]
.filter(Boolean).map(facetLabel).join(' · ');
const dialect = a.attrs?.ChineseDialect && a.attrs.ChineseDialect !== 'Auto' ? a.attrs.ChineseDialect : null;
.filter(Boolean)
.map(facetLabel)
.join(' · ');
const dialect =
a.attrs?.ChineseDialect && a.attrs.ChineseDialect !== 'Auto' ? a.attrs.ChineseDialect : null;
const accentLabel = a.facets.accent
? facetLabel(a.facets.accent)
: (dialect || (a.language === 'Chinese' ? 'Chinese' : null));
: dialect || (a.language === 'Chinese' ? 'Chinese' : null);
return (
<div className={`archetype-card ${viewMode} ${isPlaying ? 'playing' : ''}`} style={{ '--card-accent': color }}>
<div
className={`archetype-card ${viewMode} ${isPlaying ? 'playing' : ''}`}
style={{ '--card-accent': color }}
>
<div className="arch-head">
<ArchetypeAvatar item={a} />
<div className="arch-title">
@@ -43,19 +62,35 @@ export default function ArchetypeCard({
</span>
)}
{a.facets.whisper && (
<span className="facet-chip">{t('archetypes.facet_whisper', { defaultValue: 'Whisper' })}</span>
<span className="facet-chip">
{t('archetypes.facet_whisper', { defaultValue: 'Whisper' })}
</span>
)}
</div>
<div className="arch-foot">
<button className="preview-btn" onClick={() => onPreview(a)} title={t('gallery.preview', { defaultValue: 'Preview' })}>
{isLoadingPreview ? <Loader className="spin" size={15} /> : isPlaying ? <NowPlaying color={color} /> : <Play size={15} />}
<button
className="preview-btn"
onClick={() => onPreview(a)}
title={t('gallery.preview', { defaultValue: 'Preview' })}
>
{isLoadingPreview ? (
<Loader className="spin" size={15} />
) : isPlaying ? (
<NowPlaying color={color} />
) : (
<Play size={15} />
)}
<span>{t('gallery.preview', { defaultValue: 'Preview' })}</span>
</button>
<button className="use-btn" onClick={() => onUse(a)}>
<UserPlus size={14} /> {t('gallery.use_voice', { defaultValue: 'Use voice' })}
</button>
<button className="designer-btn" onClick={() => onDesign(a)} title={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}>
<button
className="designer-btn"
onClick={() => onDesign(a)}
title={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}
>
<Wand2 size={14} />
</button>
</div>
@@ -14,16 +14,32 @@ const FACETS = {
age: ['child', 'teenager', 'young adult', 'middle-aged', 'elderly'],
pitch: ['very low pitch', 'low pitch', 'moderate pitch', 'high pitch', 'very high pitch'],
accent: [
'american accent', 'british accent', 'australian accent', 'canadian accent',
'indian accent', 'chinese accent', 'japanese accent', 'korean accent',
'portuguese accent', 'russian accent',
'american accent',
'british accent',
'australian accent',
'canadian accent',
'indian accent',
'chinese accent',
'japanese accent',
'korean accent',
'portuguese accent',
'russian accent',
],
// English + Chinese come from the generated catalog; the rest are curated
// multilingual designed voices. Values must match the archetype `language`
// field (a languages.json entry) exactly that drives the backend filter.
lang: [
'English', 'Chinese', 'Spanish', 'French', 'German', 'Italian',
'Portuguese', 'Russian', 'Hindi', 'Japanese', 'Korean',
'English',
'Chinese',
'Spanish',
'French',
'German',
'Italian',
'Portuguese',
'Russian',
'Hindi',
'Japanese',
'Korean',
],
};
@@ -31,16 +47,31 @@ const hasActiveFilters = (f) => Object.values(f).some((v) => v !== null && v !==
// Archetypes zone
export default function ArchetypesZone({
t, filters, setFilter, resetFilters, favorites, toggleFavorite,
viewMode, setViewMode, playingId, loadingPreviewId, onPreview, onUse, onDesign,
t,
filters,
setFilter,
resetFilters,
favorites,
toggleFavorite,
viewMode,
setViewMode,
playingId,
loadingPreviewId,
onPreview,
onUse,
onDesign,
}) {
const [favOnly, setFavOnly] = useState(false);
const [offset, setOffset] = useState(0);
useEffect(() => { setOffset(0); }, [filters]);
useEffect(() => {
setOffset(0);
}, [filters]);
const cleanFilters = useMemo(() => {
const out = {};
Object.entries(filters).forEach(([k, v]) => { if (v !== null && v !== '') out[k] = v; });
Object.entries(filters).forEach(([k, v]) => {
if (v !== null && v !== '') out[k] = v;
});
return out;
}, [filters]);
@@ -71,11 +102,16 @@ export default function ArchetypesZone({
// NOTE: no `key` here React keys must be passed directly on the element,
// not spread in (spreading a `key` prop triggers a dev warning + is ignored).
const cardProps = (a) => ({
a, t, viewMode,
a,
t,
viewMode,
isFavorite: favSet.has(a.id),
isPlaying: playingId === a.id,
isLoadingPreview: loadingPreviewId === a.id,
onPreview, onUse, onDesign, onToggleFavorite: toggleFavorite,
onPreview,
onUse,
onDesign,
onToggleFavorite: toggleFavorite,
});
return (
@@ -84,7 +120,10 @@ export default function ArchetypesZone({
{/* Three filter lanes (categories · facets · toggles), each its own
horizontally-scrollable portion; the view toggle is pinned right. */}
<div className="facet-group facet-group--cats use-case-chips">
<button className={`category-chip ${!filters.use_case ? 'selected' : ''}`} onClick={() => setFilter('use_case', null)}>
<button
className={`category-chip ${!filters.use_case ? 'selected' : ''}`}
onClick={() => setFilter('use_case', null)}
>
{t('gallery.all', { defaultValue: 'All' })}
</button>
{categories.map((c) => (
@@ -108,8 +147,14 @@ export default function ArchetypesZone({
value={filters[dim] ?? ''}
onChange={(e) => setFilter(dim, e.target.value || null)}
>
<option value="">{t(`archetypes.facet_${dim}`, { defaultValue: titleCase(dim) })}</option>
{FACETS[dim].map((opt) => <option key={opt} value={opt}>{facetLabel(opt)}</option>)}
<option value="">
{t(`archetypes.facet_${dim}`, { defaultValue: titleCase(dim) })}
</option>
{FACETS[dim].map((opt) => (
<option key={opt} value={opt}>
{facetLabel(opt)}
</option>
))}
</select>
))}
</div>
@@ -124,25 +169,53 @@ export default function ArchetypesZone({
{t('archetypes.facet_whisper', { defaultValue: 'Whisper' })}
</label>
<label className="facet-toggle">
<input type="checkbox" checked={favOnly} onChange={(e) => setFavOnly(e.target.checked)} />
<input
type="checkbox"
checked={favOnly}
onChange={(e) => setFavOnly(e.target.checked)}
/>
<Star size={12} /> {t('gallery.favorites', { defaultValue: 'Favorites' })}
</label>
<button className="facet-reset" onClick={() => { resetFilters(); setFavOnly(false); }}>
<button
className="facet-reset"
onClick={() => {
resetFilters();
setFavOnly(false);
}}
>
<RotateCcw size={12} /> {t('gallery.reset', { defaultValue: 'Reset' })}
</button>
</div>
<div className="view-toggle">
<button className={viewMode === 'grid' ? 'active' : ''} onClick={() => setViewMode('grid')} title="Grid"><Grid size={14} /></button>
<button className={viewMode === 'list' ? 'active' : ''} onClick={() => setViewMode('list')} title="List"><List size={14} /></button>
<button
className={viewMode === 'grid' ? 'active' : ''}
onClick={() => setViewMode('grid')}
title="Grid"
>
<Grid size={14} />
</button>
<button
className={viewMode === 'list' ? 'active' : ''}
onClick={() => setViewMode('list')}
title="List"
>
<List size={14} />
</button>
</div>
</div>
{showFeatured && (
<section className="archetype-section">
<div className="content-header"><div className="content-title">{t('archetypes.featured', { defaultValue: 'Featured' })}</div></div>
<div className="content-header">
<div className="content-title">
{t('archetypes.featured', { defaultValue: 'Featured' })}
</div>
</div>
<div className={`archetype-grid ${viewMode}`}>
{applyFav(featured).map((a) => <ArchetypeCard key={a.id} {...cardProps(a)} />)}
{applyFav(featured).map((a) => (
<ArchetypeCard key={a.id} {...cardProps(a)} />
))}
</div>
</section>
)}
@@ -155,18 +228,28 @@ export default function ArchetypesZone({
</div>
</div>
{browseQ.isLoading ? (
<div className="loading"><Loader className="spin" size={18} /></div>
<div className="loading">
<Loader className="spin" size={18} />
</div>
) : (
<>
<div className={`archetype-grid ${viewMode}`}>
{applyFav(browse).map((a) => <ArchetypeCard key={a.id} {...cardProps(a)} />)}
{applyFav(browse).map((a) => (
<ArchetypeCard key={a.id} {...cardProps(a)} />
))}
</div>
{applyFav(browse).length === 0 && (
<div className="empty">{t('gallery.no_matches', { defaultValue: 'No voices match these filters.' })}</div>
<div className="empty">
{t('gallery.no_matches', { defaultValue: 'No voices match these filters.' })}
</div>
)}
{offset + BROWSE_PAGE < total && !favOnly && (
<div className="load-more">
<Button variant="ghost" onClick={() => setOffset(offset + BROWSE_PAGE)} disabled={browseQ.isFetching}>
<Button
variant="ghost"
onClick={() => setOffset(offset + BROWSE_PAGE)}
disabled={browseQ.isFetching}
>
{browseQ.isFetching ? <Loader className="spin" size={14} /> : null}
{t('gallery.load_more', { defaultValue: 'Load more' })}
</Button>
@@ -6,7 +6,16 @@ import { openExternal } from '../../api/external';
import ArchetypeCard from './ArchetypeCard';
// Community zone (marketplace)
export default function CommunityZone({ t, playingId, loadingPreviewId, favorites, toggleFavorite, onPlayAudio, flash, onDesign }) {
export default function CommunityZone({
t,
playingId,
loadingPreviewId,
favorites,
toggleFavorite,
onPlayAudio,
flash,
onDesign,
}) {
const itemsQ = useCommunityItems({ limit: 100 });
const items = itemsQ.data?.items || [];
const favSet = useMemo(() => new Set(favorites), [favorites]);
@@ -23,7 +32,12 @@ export default function CommunityZone({ t, playingId, loadingPreviewId, favorite
return (
<div className="gallery-content gallery-scroll">
<div className="import-explainer community-explainer">
<span>{t('gallery.community_explainer', { defaultValue: 'Designed presets and recorded voices shared by the community, loaded from the omnivoice-gallery.' })}</span>
<span>
{t('gallery.community_explainer', {
defaultValue:
'Designed presets and recorded voices shared by the community, loaded from the omnivoice-gallery.',
})}
</span>
<div className="submit-actions">
<button className="submit-btn" onClick={() => submit('preset')}>
<Send size={13} /> {t('gallery.submit_preset', { defaultValue: 'Submit a preset' })}
@@ -35,10 +49,15 @@ export default function CommunityZone({ t, playingId, loadingPreviewId, favorite
</div>
{itemsQ.isLoading ? (
<div className="loading"><Loader className="spin" size={18} /></div>
<div className="loading">
<Loader className="spin" size={18} />
</div>
) : items.length === 0 ? (
<div className="empty">
{t('gallery.community_empty', { defaultValue: 'No community voices loaded yet — connect to the internet and reopen, or be the first to submit one.' })}
{t('gallery.community_empty', {
defaultValue:
'No community voices loaded yet — connect to the internet and reopen, or be the first to submit one.',
})}
</div>
) : (
<div className="archetype-grid grid">
@@ -52,20 +71,37 @@ export default function CommunityZone({ t, playingId, loadingPreviewId, favorite
isPlaying={playingId === it.id}
isLoadingPreview={loadingPreviewId === it.id}
onToggleFavorite={toggleFavorite}
onPreview={(item) => (item.audio?.url
? onPlayAudio(item.audio.url, item.id)
: flash(t('gallery.no_preview', { defaultValue: 'No preview — add it with "Use voice" to hear it.' })))}
onPreview={(item) =>
item.audio?.url
? onPlayAudio(item.audio.url, item.id)
: flash(
t('gallery.no_preview', {
defaultValue: 'No preview — add it with "Use voice" to hear it.',
}),
)
}
onUse={async (item) => {
try {
const r = await addCommunityItem(item.id, item.name);
flash(t('gallery.saved_as_profile', { defaultValue: 'Added "{{name}}" to your voices.', name: r.name }));
flash(
t('gallery.saved_as_profile', {
defaultValue: 'Added "{{name}}" to your voices.',
name: r.name,
}),
);
} catch {
flash(t('gallery.use_failed', { defaultValue: 'Could not add that voice.' }));
}
}}
onDesign={(item) => (item.instruct
? onDesign(item.instruct)
: flash(t('gallery.no_designer', { defaultValue: 'Recorded voice — use "Use voice" instead.' })))}
onDesign={(item) =>
item.instruct
? onDesign(item.instruct)
: flash(
t('gallery.no_designer', {
defaultValue: 'Recorded voice — use "Use voice" instead.',
}),
)
}
/>
))}
</div>
+156 -34
View File
@@ -1,13 +1,27 @@
import React, { useState, useRef } from 'react';
import {
Search, Download, Play, Pause, Trash2, X, Loader, UserPlus, Upload, Scissors, Package,
Search,
Download,
Play,
Pause,
Trash2,
X,
Loader,
UserPlus,
Upload,
Scissors,
Package,
} from 'lucide-react';
import { Button, Input } from '../../ui';
import { useGalleryVoices } from '../../api/hooks';
import { importPersona } from '../../api/profiles';
import {
searchYoutube, downloadYoutubeClip, deleteGalleryVoice,
saveVoiceAsProfile, uploadVoiceClip, previewVoiceUrl,
searchYoutube,
downloadYoutubeClip,
deleteGalleryVoice,
saveVoiceAsProfile,
uploadVoiceClip,
previewVoiceUrl,
} from '../../api/gallery';
import AudioTrimmer from '../AudioTrimmer';
import { apiFetch } from '../../api/client';
@@ -38,15 +52,22 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
fd.append('file', file);
const res = await importPersona(fd);
reload();
flash(t('gallery.persona_imported', {
defaultValue: 'Imported "{{name}}"{{unverified}}.', name: res.name,
unverified: res.verified_own_voice ? '' : t('gallery.persona_unverified_suffix', { defaultValue: ' (unverified)' }),
}));
flash(
t('gallery.persona_imported', {
defaultValue: 'Imported "{{name}}"{{unverified}}.',
name: res.name,
unverified: res.verified_own_voice
? ''
: t('gallery.persona_unverified_suffix', { defaultValue: ' (unverified)' }),
}),
);
} catch (err) {
const code = String(err?.message || err);
const msg = code.includes('413')
? t('gallery.persona_too_large', { defaultValue: 'That bundle is too large (max 100 MB).' })
: t('gallery.persona_import_failed', { defaultValue: 'Could not import that persona bundle.' });
: t('gallery.persona_import_failed', {
defaultValue: 'Could not import that persona bundle.',
});
flash(msg);
} finally {
setImportingPersona(false);
@@ -63,14 +84,22 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
setIsDownloading(true);
try {
await downloadYoutubeClip({
video_url: q, start_time: 0, duration: 15,
video_url: q,
start_time: 0,
duration: 15,
character_name: t('gallery.imported_clip', { defaultValue: 'Imported clip' }),
category: 'import', description: q,
category: 'import',
description: q,
});
reload();
setQuery('');
} catch (e) {
flash(t('gallery.download_failed', { defaultValue: 'Download failed: {{msg}}', msg: e.message }));
flash(
t('gallery.download_failed', {
defaultValue: 'Download failed: {{msg}}',
msg: e.message,
}),
);
} finally {
setIsDownloading(false);
}
@@ -101,7 +130,9 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
reload();
setResults([]);
} catch (e) {
flash(t('gallery.download_failed', { defaultValue: 'Download failed: {{msg}}', msg: e.message }));
flash(
t('gallery.download_failed', { defaultValue: 'Download failed: {{msg}}', msg: e.message }),
);
} finally {
setIsDownloading(false);
}
@@ -127,15 +158,30 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
const handleSaveProfile = async (v) => {
try {
await saveVoiceAsProfile(v.id, v.name);
flash(t('gallery.saved_as_profile', { defaultValue: 'Added "{{name}}" to your voices.', name: v.name }));
flash(
t('gallery.saved_as_profile', {
defaultValue: 'Added "{{name}}" to your voices.',
name: v.name,
}),
);
} catch (e) {
flash(t('gallery.save_failed', { defaultValue: 'Could not save profile.' }));
}
};
const handleDelete = async (v) => {
if (!(await askConfirm(t('gallery.confirm_delete', { defaultValue: 'Delete "{{name}}"?', name: v.name })))) return;
try { await deleteGalleryVoice(v.id); reload(); } catch { /* noop */ }
if (
!(await askConfirm(
t('gallery.confirm_delete', { defaultValue: 'Delete "{{name}}"?', name: v.name }),
))
)
return;
try {
await deleteGalleryVoice(v.id);
reload();
} catch {
/* noop */
}
};
const handleTrimClick = async (v) => {
@@ -158,7 +204,11 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
fd.append('category', 'import');
fd.append('description', voice.description || '');
fd.append('audio', trimmedFile);
try { await uploadVoiceClip(fd); reload(); setTrimming(null); } catch (e) {
try {
await uploadVoiceClip(fd);
reload();
setTrimming(null);
} catch (e) {
flash(t('gallery.upload_failed', { defaultValue: 'Upload failed.' }));
}
};
@@ -167,29 +217,63 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
<div className="gallery-content">
<div className="import-explainer">
{t('gallery.import_explainer', {
defaultValue: 'Paste a URL you have the rights to (or upload a file), trim the part you need, and save it as a voice. You are responsible for the licensing of anything you import.',
defaultValue:
'Paste a URL you have the rights to (or upload a file), trim the part you need, and save it as a voice. You are responsible for the licensing of anything you import.',
})}
</div>
<div className="gallery-search">
<div className="search-row">
<Input
placeholder={t('gallery.import_placeholder', { defaultValue: 'Paste a video/audio URL, or type to search…' })}
placeholder={t('gallery.import_placeholder', {
defaultValue: 'Paste a video/audio URL, or type to search…',
})}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSearch();
}}
/>
<Button onClick={handleSearch} disabled={isSearching || isDownloading} size="sm">
{isSearching || isDownloading ? <Loader size={14} className="spin" /> : isUrl ? <Download size={14} /> : <Search size={14} />}
{isSearching || isDownloading ? (
<Loader size={14} className="spin" />
) : isUrl ? (
<Download size={14} />
) : (
<Search size={14} />
)}
</Button>
<input ref={fileRef} type="file" accept="audio/*,video/*" hidden onChange={handleUpload} />
<Button variant="ghost" size="sm" onClick={() => fileRef.current?.click()} title={t('gallery.upload', { defaultValue: 'Upload file' })}>
<input
ref={fileRef}
type="file"
accept="audio/*,video/*"
hidden
onChange={handleUpload}
/>
<Button
variant="ghost"
size="sm"
onClick={() => fileRef.current?.click()}
title={t('gallery.upload', { defaultValue: 'Upload file' })}
>
<Upload size={14} />
</Button>
<input ref={personaRef} type="file" accept=".ovsvoice,.omnivoice" hidden onChange={handlePersonaImport} />
<Button variant="ghost" size="sm" disabled={importingPersona}
<input
ref={personaRef}
type="file"
accept=".ovsvoice,.omnivoice"
hidden
onChange={handlePersonaImport}
/>
<Button
variant="ghost"
size="sm"
disabled={importingPersona}
onClick={() => personaRef.current?.click()}
title={t('gallery.import_persona', { defaultValue: 'Import a .ovsvoice persona bundle' })}>
title={t('gallery.import_persona', {
defaultValue: 'Import a .ovsvoice persona bundle',
})}
>
{importingPersona ? <Loader size={14} className="spin" /> : <Package size={14} />}
</Button>
</div>
@@ -198,8 +282,15 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
{results.length > 0 && (
<div className="search-results-panel">
<div className="panel-header">
<span>{t('gallery.search_results', { defaultValue: '{{count}} results', count: results.length })}</span>
<button className="close-btn" onClick={() => setResults([])}><X size={14} /></button>
<span>
{t('gallery.search_results', {
defaultValue: '{{count}} results',
count: results.length,
})}
</span>
<button className="close-btn" onClick={() => setResults([])}>
<X size={14} />
</button>
</div>
<div className="results-list">
{results.map((r, i) => (
@@ -219,29 +310,60 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
<div className="content-header">
<div className="content-title">
{t('gallery.my_imports', { defaultValue: 'My Imports' })}<span className="count-badge">{voices.length}</span>
{t('gallery.my_imports', { defaultValue: 'My Imports' })}
<span className="count-badge">{voices.length}</span>
</div>
</div>
{voicesQ.isLoading ? (
<div className="loading"><Loader className="spin" size={18} /></div>
<div className="loading">
<Loader className="spin" size={18} />
</div>
) : voices.length === 0 ? (
<div className="empty">{t('gallery.no_imports', { defaultValue: 'Nothing imported yet. Paste a URL above to get started.' })}</div>
<div className="empty">
{t('gallery.no_imports', {
defaultValue: 'Nothing imported yet. Paste a URL above to get started.',
})}
</div>
) : (
<div className="voice-list">
{voices.map((v) => (
<div key={v.id} className="voice-card">
<button className="voice-play" onClick={() => onPlayGallery(v)}>
{loadingPreviewId === v.id ? <Loader className="spin" size={16} /> : playingId === v.id ? <Pause size={16} /> : <Play size={16} />}
{loadingPreviewId === v.id ? (
<Loader className="spin" size={16} />
) : playingId === v.id ? (
<Pause size={16} />
) : (
<Play size={16} />
)}
</button>
<div className="voice-info">
<span className="voice-name">{v.name}</span>
<span className="voice-meta">{Math.round(v.duration || 0)}s</span>
</div>
<div className="voice-actions">
<button className="action-btn" onClick={() => handleTrimClick(v)} title={t('gallery.trim', { defaultValue: 'Trim' })}><Scissors size={14} /></button>
<button className="action-btn" onClick={() => handleSaveProfile(v)} title={t('gallery.use_voice', { defaultValue: 'Use voice' })}><UserPlus size={14} /></button>
<button className="action-btn danger" onClick={() => handleDelete(v)} title={t('gallery.delete', { defaultValue: 'Delete' })}><Trash2 size={14} /></button>
<button
className="action-btn"
onClick={() => handleTrimClick(v)}
title={t('gallery.trim', { defaultValue: 'Trim' })}
>
<Scissors size={14} />
</button>
<button
className="action-btn"
onClick={() => handleSaveProfile(v)}
title={t('gallery.use_voice', { defaultValue: 'Use voice' })}
>
<UserPlus size={14} />
</button>
<button
className="action-btn danger"
onClick={() => handleDelete(v)}
title={t('gallery.delete', { defaultValue: 'Delete' })}
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
@@ -7,8 +7,15 @@ import WaveformPlayer from '../WaveformPlayer';
* page. Pure presentation; state/handlers live in the parent VoiceProfile.
*/
export default function ProfileActivity({
t, testText, setTestText, testGenerating, runTest, testAudioUrl,
autoPlayPreview, usage, onOpenProject,
t,
testText,
setTestText,
testGenerating,
runTest,
testAudioUrl,
autoPlayPreview,
usage,
onOpenProject,
}) {
return (
<>
@@ -16,16 +23,17 @@ export default function ProfileActivity({
<Panel
variant="flat"
padding="md"
title={<><Play size={13} /> {t('voice_profile.try_voice')}</>}
title={
<>
<Play size={13} /> {t('voice_profile.try_voice')}
</>
}
>
<Field
label={t('voice_profile.test_phrase')}
hint={t('voice_profile.test_help')}
>
<Field label={t('voice_profile.test_phrase')} hint={t('voice_profile.test_help')}>
<Textarea
rows={2}
value={testText}
onChange={e => setTestText(e.target.value)}
onChange={(e) => setTestText(e.target.value)}
placeholder={t('voice_profile.test_placeholder')}
/>
</Field>
@@ -54,9 +62,7 @@ export default function ProfileActivity({
{/* Usage */}
<Panel variant="flat" padding="md" title={<>{t('voice_profile.used_title')}</>}>
{!usage || (!usage.synth_total && !usage.projects?.length) ? (
<div className="voice-profile__usage-empty">
{t('voice_profile.used_empty')}
</div>
<div className="voice-profile__usage-empty">{t('voice_profile.used_empty')}</div>
) : (
<>
<div className="voice-profile__usage-counts">
@@ -72,7 +78,7 @@ export default function ProfileActivity({
</div>
{usage.projects.length > 0 && (
<ul className="voice-profile__usage-list">
{usage.projects.slice(0, 10).map(p => (
{usage.projects.slice(0, 10).map((p) => (
<li key={p.project_id}>
<button
type="button"
@@ -6,8 +6,19 @@ import { Panel, Button, Input, Textarea, Field, Badge } from '../../ui';
* VoiceProfile page. Pure presentation; state/handlers live in the parent.
*/
export default function ProfileDetails({
profile, editing, draft, setDraft, saving, cancelEdits, saveEdits,
onUnlock, onRevokeConsent, consentStatement, consentRec, consentSubmitting, t,
profile,
editing,
draft,
setDraft,
saving,
cancelEdits,
saveEdits,
onUnlock,
onRevokeConsent,
consentStatement,
consentRec,
consentSubmitting,
t,
}) {
return (
<>
@@ -16,12 +27,24 @@ export default function ProfileDetails({
variant="flat"
padding="md"
title={<>{t('voice_profile.details')}</>}
actions={editing ? (
<>
<Button variant="ghost" size="sm" onClick={cancelEdits} leading={<X size={12} />}>{t('common.cancel')}</Button>
<Button variant="primary" size="sm" onClick={saveEdits} loading={saving} leading={!saving && <Check size={12} />}>{t('common.save')}</Button>
</>
) : null}
actions={
editing ? (
<>
<Button variant="ghost" size="sm" onClick={cancelEdits} leading={<X size={12} />}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
size="sm"
onClick={saveEdits}
loading={saving}
leading={!saving && <Check size={12} />}
>
{t('common.save')}
</Button>
</>
) : null
}
>
<div className="voice-profile__grid-2">
<Field label={t('voice_profile.style_instruct')}>
@@ -29,20 +52,18 @@ export default function ProfileDetails({
<Textarea
rows={2}
value={draft.instruct}
onChange={e => setDraft({ ...draft, instruct: e.target.value })}
onChange={(e) => setDraft({ ...draft, instruct: e.target.value })}
placeholder={t('voice_profile.style_placeholder')}
/>
) : (
<div className="voice-profile__readonly">
{profile.instruct || <em> none </em>}
</div>
<div className="voice-profile__readonly">{profile.instruct || <em> none </em>}</div>
)}
</Field>
<Field label={t('voice_profile.language')}>
{editing ? (
<Input
value={draft.language}
onChange={e => setDraft({ ...draft, language: e.target.value })}
onChange={(e) => setDraft({ ...draft, language: e.target.value })}
placeholder={t('clone.auto')}
/>
) : (
@@ -55,7 +76,7 @@ export default function ProfileDetails({
<Textarea
rows={2}
value={draft.ref_text}
onChange={e => setDraft({ ...draft, ref_text: e.target.value })}
onChange={(e) => setDraft({ ...draft, ref_text: e.target.value })}
placeholder={t('clone.optional')}
/>
) : (
@@ -66,11 +87,13 @@ export default function ProfileDetails({
</Field>
{profile.is_locked && !editing && (
<div className="voice-profile__lock-row">
<Badge tone="warn" dot><Lock size={10} /> {t('voice_profile.locked')}</Badge>
<span className="voice-profile__lock-hint">
{t('voice_profile.locked_explain')}
</span>
<Button variant="subtle" size="sm" onClick={onUnlock} leading={<Unlock size={12} />}>{t('voice_profile.unlock')}</Button>
<Badge tone="warn" dot>
<Lock size={10} /> {t('voice_profile.locked')}
</Badge>
<span className="voice-profile__lock-hint">{t('voice_profile.locked_explain')}</span>
<Button variant="subtle" size="sm" onClick={onUnlock} leading={<Unlock size={12} />}>
{t('voice_profile.unlock')}
</Button>
</div>
)}
</Panel>
@@ -79,11 +102,17 @@ export default function ProfileDetails({
<Panel
variant="flat"
padding="md"
title={<><ShieldCheck size={12} /> {t('voice_profile.consent_title')}</>}
title={
<>
<ShieldCheck size={12} /> {t('voice_profile.consent_title')}
</>
}
>
{profile.verified_own_voice ? (
<div className="voice-profile__lock-row">
<Badge tone="success" dot><ShieldCheck size={10} /> {t('voice_profile.verified')}</Badge>
<Badge tone="success" dot>
<ShieldCheck size={10} /> {t('voice_profile.verified')}
</Badge>
<span className="voice-profile__lock-hint">
{t('voice_profile.consent_verified_explain', {
date: profile.consent_recorded_at
@@ -102,7 +131,12 @@ export default function ProfileDetails({
{consentStatement}
</blockquote>
{consentRec.isRecording ? (
<Button variant="danger" size="sm" onClick={consentRec.stopRecording} leading={<Square size={12} />}>
<Button
variant="danger"
size="sm"
onClick={consentRec.stopRecording}
leading={<Square size={12} />}
>
{t('voice_profile.consent_stop')} ({consentRec.recordingTime}s)
</Button>
) : (
@@ -1,5 +1,12 @@
import {
ArrowLeft, Pencil, Download, Trash2, ShieldCheck, Lock, Clock, Volume2,
ArrowLeft,
Pencil,
Download,
Trash2,
ShieldCheck,
Lock,
Clock,
Volume2,
} from 'lucide-react';
import { Panel, Button, Input, Badge } from '../../ui';
import WaveformPlayer from '../WaveformPlayer';
@@ -9,9 +16,22 @@ import WaveformPlayer from '../WaveformPlayer';
* Pure presentation; all state/handlers live in the parent VoiceProfile.
*/
export default function ProfileHeader({
profile, isDesign, TypeIcon, onBack, editing, setEditing,
includeReference, setIncludeReference, onExportPersona, exporting,
onDelete, draft, setDraft, createdDate, audioUrl, t,
profile,
isDesign,
TypeIcon,
onBack,
editing,
setEditing,
includeReference,
setIncludeReference,
onExportPersona,
exporting,
onDelete,
draft,
setDraft,
createdDate,
audioUrl,
t,
}) {
return (
<>
@@ -21,27 +41,45 @@ export default function ProfileHeader({
{t('common.back')}
</Button>
<span className="voice-profile__crumb">
<TypeIcon size={12} /> {isDesign ? t('voice_profile.designed') : t('voice_profile.cloned')} voice
<TypeIcon size={12} />{' '}
{isDesign ? t('voice_profile.designed') : t('voice_profile.cloned')} voice
</span>
<div className="voice-profile__bar-spacer" />
{!editing && (
<Button variant="subtle" size="sm" onClick={() => setEditing(true)} leading={<Pencil size={12} />}>
<Button
variant="subtle"
size="sm"
onClick={() => setEditing(true)}
leading={<Pencil size={12} />}
>
{t('voice_profile.edit')}
</Button>
)}
{!editing && (
<label
className="voice-profile__persona-privacy"
title={t('voice_profile.persona_include_ref_hint', { defaultValue: 'Include the raw reference clip. Off = share only a watermarked preview (recommended).' })}
title={t('voice_profile.persona_include_ref_hint', {
defaultValue:
'Include the raw reference clip. Off = share only a watermarked preview (recommended).',
})}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11 }}
>
<input type="checkbox" checked={includeReference} onChange={(e) => setIncludeReference(e.target.checked)} />
<input
type="checkbox"
checked={includeReference}
onChange={(e) => setIncludeReference(e.target.checked)}
/>
{t('voice_profile.persona_include_ref', { defaultValue: 'Include voice clip' })}
</label>
)}
{!editing && (
<Button variant="subtle" size="sm" onClick={onExportPersona} loading={exporting}
leading={!exporting && <Download size={12} />}>
<Button
variant="subtle"
size="sm"
onClick={onExportPersona}
loading={exporting}
leading={!exporting && <Download size={12} />}
>
{t('voice_profile.persona_export', { defaultValue: 'Export persona' })}
</Button>
)}
@@ -61,7 +99,7 @@ export default function ProfileHeader({
<Input
size="lg"
value={draft.name}
onChange={e => setDraft({ ...draft, name: e.target.value })}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder={t('voice_profile.name_placeholder')}
autoFocus
/>
@@ -70,11 +108,17 @@ export default function ProfileHeader({
)}
<div className="voice-profile__badges">
{!!profile.verified_own_voice && (
<Badge tone="success" dot><ShieldCheck size={10} /> {t('voice_profile.verified')}</Badge>
<Badge tone="success" dot>
<ShieldCheck size={10} /> {t('voice_profile.verified')}
</Badge>
)}
{profile.is_locked ? (
<Badge tone="warn" dot>
<Lock size={10} /> {t('voice_profile.locked')}
</Badge>
) : (
<Badge tone="neutral">{t('voice_profile.free')}</Badge>
)}
{profile.is_locked
? <Badge tone="warn" dot><Lock size={10} /> {t('voice_profile.locked')}</Badge>
: <Badge tone="neutral">{t('voice_profile.free')}</Badge>}
{profile.language && profile.language !== 'Auto' && (
<Badge tone="info">{profile.language}</Badge>
)}
@@ -82,7 +126,9 @@ export default function ProfileHeader({
<Clock size={9} /> {createdDate}
</Badge>
{profile.seed != null && (
<Badge tone="violet" size="xs">seed {profile.seed}</Badge>
<Badge tone="violet" size="xs">
seed {profile.seed}
</Badge>
)}
</div>
</div>
@@ -91,9 +137,14 @@ export default function ProfileHeader({
{(profile.ref_audio_path || profile.locked_audio_path) && (
<div className="voice-profile__audio">
<div className="voice-profile__audio-label">
<Volume2 size={11} /> {profile.is_locked ? t('voice_profile.locked_ref') : t('voice_profile.ref_audio')}
<Volume2 size={11} />{' '}
{profile.is_locked ? t('voice_profile.locked_ref') : t('voice_profile.ref_audio')}
</div>
<WaveformPlayer src={audioUrl} source="profile-ref" className="voice-profile__audio-el" />
<WaveformPlayer
src={audioUrl}
source="profile-ref"
className="voice-profile__audio-el"
/>
</div>
)}
</Panel>
+84 -38
View File
@@ -1,6 +1,13 @@
import React from 'react';
import {
Info, CheckCircle, AlertCircle, Download, Activity, Copy, ExternalLink, Building2,
Info,
CheckCircle,
AlertCircle,
Download,
Activity,
Copy,
ExternalLink,
Building2,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { openExternal } from '../../api/external';
@@ -32,26 +39,65 @@ export default function AboutTab({
return (
<SettingsSection icon={Info} title={t('settings.about')}>
<Row label={t('about.app')} value="OmniVoice Studio" />
<Row label={t('about.version')} value={resolveAboutVersion(appVersion, info)} mono />
<Row label={t('about.tauri_runtime')} value={tauriVersion || (isTauri() ? '—' : t('about.web_preview'))} mono />
<Row label={t('about.platform')} value={info?.platform || '—'} />
<Row label={t('about.architecture')} value={info?.arch || '—'} mono />
<Row label={t('about.python')} value={info?.python || '—'} mono />
<Row label={t('about.compute_device')} value={info?.device || '—'} mono />
<Row label={t('about.gpu_active')} value={hw?.gpu_active
? <Badge tone="success"><CheckCircle size={11} /> {t('about.yes')}</Badge>
: <Badge tone="neutral">{t('about.no')}</Badge>} />
<Row label={t('about.ram')} value={hw ? `${hw.ram?.toFixed(2)} / ${hw.total_ram?.toFixed(2)} GB` : '—'} mono />
<Row label={t('about.vram')} value={hw ? `${hw.vram?.toFixed(2)} GB` : '—'} mono />
<Row label={t('about.backend')} value={<Badge tone={status?.status === 'ready' ? 'success' : status?.status === 'loading' ? 'warn' : 'neutral'}>{status?.status || 'unknown'}</Badge>} />
<Row label={t('about.active_model')} value={status?.repo_id || info?.model_checkpoint || '—'} mono />
<Row label={t('about.asr_model')} value={info?.asr_model || '—'} mono />
<Row label={t('about.translator')} value={info?.translate_provider || '—'} />
<Row label={t('about.hf_token')} value={info?.has_hf_token ? t('about.yes') : t('about.no')} />
<Row label={t('about.data_dir')} value={info?.data_dir || '—'} mono />
<Row label={t('about.outputs')} value={info?.outputs_dir || '—'} mono />
<Row label={t('about.crash_log')} value={info?.crash_log_path || '—'} mono />
<Row label={t('about.app')} value="OmniVoice Studio" />
<Row label={t('about.version')} value={resolveAboutVersion(appVersion, info)} mono />
<Row
label={t('about.tauri_runtime')}
value={tauriVersion || (isTauri() ? '—' : t('about.web_preview'))}
mono
/>
<Row label={t('about.platform')} value={info?.platform || '—'} />
<Row label={t('about.architecture')} value={info?.arch || '—'} mono />
<Row label={t('about.python')} value={info?.python || '—'} mono />
<Row label={t('about.compute_device')} value={info?.device || '—'} mono />
<Row
label={t('about.gpu_active')}
value={
hw?.gpu_active ? (
<Badge tone="success">
<CheckCircle size={11} /> {t('about.yes')}
</Badge>
) : (
<Badge tone="neutral">{t('about.no')}</Badge>
)
}
/>
<Row
label={t('about.ram')}
value={hw ? `${hw.ram?.toFixed(2)} / ${hw.total_ram?.toFixed(2)} GB` : '—'}
mono
/>
<Row label={t('about.vram')} value={hw ? `${hw.vram?.toFixed(2)} GB` : '—'} mono />
<Row
label={t('about.backend')}
value={
<Badge
tone={
status?.status === 'ready'
? 'success'
: status?.status === 'loading'
? 'warn'
: 'neutral'
}
>
{status?.status || 'unknown'}
</Badge>
}
/>
<Row
label={t('about.active_model')}
value={status?.repo_id || info?.model_checkpoint || '—'}
mono
/>
<Row label={t('about.asr_model')} value={info?.asr_model || '—'} mono />
<Row label={t('about.translator')} value={info?.translate_provider || '—'} />
<Row
label={t('about.hf_token')}
value={info?.has_hf_token ? t('about.yes') : t('about.no')}
/>
<Row label={t('about.data_dir')} value={info?.data_dir || '—'} mono />
<Row label={t('about.outputs')} value={info?.outputs_dir || '—'} mono />
<Row label={t('about.crash_log')} value={info?.crash_log_path || '—'} mono />
{/* Auto-updater + channel toggle are desktop-only (Tauri). The Docker
web build updates by pulling a new image tag, so hide these rows
there to avoid a non-functional control (issue #249). */}
@@ -66,7 +112,7 @@ export default function AboutTab({
value={updateChannel}
onChange={changeChannel}
items={[
{ value: 'stable', label: t('about.channel_stable') },
{ value: 'stable', label: t('about.channel_stable') },
{ value: 'preview', label: t('about.channel_preview') },
]}
/>
@@ -74,9 +120,11 @@ export default function AboutTab({
/>
<Row
label={t('about.update_endpoint')}
value={updateChannel === 'preview'
? 'releases/download/preview/latest.json'
: 'releases/latest/download/latest.json'}
value={
updateChannel === 'preview'
? 'releases/download/preview/latest.json'
: 'releases/latest/download/latest.json'
}
mono
/>
</>
@@ -111,12 +159,7 @@ export default function AboutTab({
>
{t('about.save_bundle')}
</Button>
<Button
variant="subtle"
size="md"
leading={<Copy size={12} />}
onClick={copyDiagnostics}
>
<Button variant="subtle" size="md" leading={<Copy size={12} />} onClick={copyDiagnostics}>
{t('about.copy_diagnostics')}
</Button>
<Button
@@ -139,7 +182,9 @@ export default function AboutTab({
variant="subtle"
size="md"
leading={<Building2 size={12} />}
onClick={() => { useAppStore.getState().setMode?.('enterprise'); }}
onClick={() => {
useAppStore.getState().setMode?.('enterprise');
}}
>
{t('about.commercial_license')}
</Button>
@@ -152,12 +197,13 @@ export default function AboutTab({
label={c.label}
value={
<span>
<Badge tone={c.status === 'ok' ? 'success' : c.status === 'warn' ? 'warn' : 'danger'}>
{c.status === 'ok'
? <CheckCircle size={11} />
: <AlertCircle size={11} />} {t(`about.self_check_${c.status}`)}
</Badge>
{' '}{c.detail}
<Badge
tone={c.status === 'ok' ? 'success' : c.status === 'warn' ? 'warn' : 'danger'}
>
{c.status === 'ok' ? <CheckCircle size={11} /> : <AlertCircle size={11} />}{' '}
{t(`about.self_check_${c.status}`)}
</Badge>{' '}
{c.detail}
{c.hint && <span className="settings-muted"> {c.hint}</span>}
</span>
}
@@ -29,10 +29,10 @@ export default function AecPanel() {
subtitle="experimental"
hint={
<>
Cancels OmniVoice's own playback out of the microphone so you can
dictate while a preview, dub, or video is playing without the
transcript picking up what the app is saying. Adds a small amount of
audio processing; leave it off if you never dictate over playback.
Cancels OmniVoice's own playback out of the microphone so you can dictate while a
preview, dub, or video is playing without the transcript picking up what the app is
saying. Adds a small amount of audio processing; leave it off if you never dictate over
playback.
</>
}
control={
@@ -33,7 +33,7 @@ const SOURCE_LABELS = {
};
const SOURCE_HELP = {
app: 'Stored encrypted in OmniVoice\'s local SQLite store. Set or clear here.',
app: "Stored encrypted in OmniVoice's local SQLite store. Set or clear here.",
env: 'Set via HF_TOKEN in your shell. Read-only from the UI.',
'hf-cli': 'Written by `huggingface-cli login`. Read-only from the UI.',
};
@@ -122,7 +122,9 @@ export default function ApiKeysPanel() {
onClick={refresh}
disabled={loading}
aria-label={testNowLabel}
title={t('settings.hf_token_test_now_title', { defaultValue: 'Re-run whoami for every source' })}
title={t('settings.hf_token_test_now_title', {
defaultValue: 'Re-run whoami for every source',
})}
>
<RefreshCw size={12} /> {testNowLabel}
</button>
@@ -134,7 +136,11 @@ export default function ApiKeysPanel() {
</div>
)}
<div className="apikeys-rows" role="table" aria-label={t('settings.hf_token_sources', { defaultValue: 'HF token sources' })}>
<div
className="apikeys-rows"
role="table"
aria-label={t('settings.hf_token_sources', { defaultValue: 'HF token sources' })}
>
{state.sources.map((row) => {
const isActive = state.active === row.source;
return (
@@ -149,9 +155,7 @@ export default function ApiKeysPanel() {
{SOURCE_LABELS[row.source]}
<InfoHint>{SOURCE_HELP[row.source]}</InfoHint>
</span>
{isActive && (
<span className="apikeys-badge apikeys-badge--active">Active</span>
)}
{isActive && <span className="apikeys-badge apikeys-badge--active">Active</span>}
</div>
<div className="apikeys-row__meta">
{row.set ? (
@@ -159,9 +163,7 @@ export default function ApiKeysPanel() {
<span className="apikeys-row__set" aria-label="set">
<CheckCircle2 size={12} /> set
</span>
{row.masked && (
<code className="apikeys-row__masked">{row.masked}</code>
)}
{row.masked && <code className="apikeys-row__masked">{row.masked}</code>}
{row.whoami_ok ? (
<span className="apikeys-row__whoami apikeys-row__whoami--ok">
<CheckCircle2 size={12} /> {row.whoami_user || 'verified'}
@@ -219,15 +221,24 @@ export default function ApiKeysPanel() {
</div>
{clearOpen && (
<div className="apikeys-clear-dialog" role="dialog" aria-label={t('settings.hf_token_clear_dialog', { defaultValue: 'Clear token' })}>
<p>{t('settings.hf_token_clear_confirm', { defaultValue: 'Clear the App-source HuggingFace token?' })}</p>
<div
className="apikeys-clear-dialog"
role="dialog"
aria-label={t('settings.hf_token_clear_dialog', { defaultValue: 'Clear token' })}
>
<p>
{t('settings.hf_token_clear_confirm', {
defaultValue: 'Clear the App-source HuggingFace token?',
})}
</p>
<label className="apikeys-checkbox">
<input
type="checkbox"
checked={alsoClearCli}
onChange={(e) => setAlsoClearCli(e.target.checked)}
/>{' '}
{t('settings.hf_token_also_clear', { defaultValue: 'Also clear' })} <code>~/.cache/huggingface/token</code>
{t('settings.hf_token_also_clear', { defaultValue: 'Also clear' })}{' '}
<code>~/.cache/huggingface/token</code>
</label>
<div className="apikeys-clear-dialog__actions">
<button
@@ -246,7 +257,8 @@ export default function ApiKeysPanel() {
onClick={onClear}
disabled={saving}
>
<Trash2 size={12} /> {t('settings.hf_token_clear_btn', { defaultValue: 'Clear token' })}
<Trash2 size={12} />{' '}
{t('settings.hf_token_clear_btn', { defaultValue: 'Clear token' })}
</button>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More