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:
co-authored by
mergetest
Claude Opus 4.8
parent
f293f10e7e
commit
5c9b7ca313
@@ -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
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": false,
|
||||
"ignorePatterns": [
|
||||
"**/*.css",
|
||||
"**/*.json",
|
||||
"**/*.toml",
|
||||
"**/*.html",
|
||||
"**/*.md",
|
||||
"src-tauri/**"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
])
|
||||
]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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
@@ -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`;
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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, 5–10 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, 5–10 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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 · 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (Oct–Nov)
|
||||
{ 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>
|
||||
))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' : ''} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user