feat(ui): organize dubbing controls and export drawer

This commit is contained in:
Palash Debnath
2026-09-05 23:17:56 +05:30
parent 81af253c7e
commit 0af98edb3e
17 changed files with 447 additions and 312 deletions
+4
View File
@@ -15,6 +15,10 @@ the frozen-backend fallback mirror it for their toolchains.
### Changed ### Changed
- Export uses grouped format settings, themed track menus and switches, with a pinned filename summary and download action (#1823)
- Dubbing output settings use icon-labelled switches, themed track and speaker menus, and clearer timing/transcript controls (#1823)
- Casting voice menus use searchable themed options with SVG preset icons instead of native dropdowns (#1823)
- Dubbing groups casting and translation controls with readable labels, SVG icons, searchable menus, and compact timeline spacing (#1823)
- Production Overrides use readable icon-labelled controls and accessible Denoise/Postprocess switches (#1823) - Production Overrides use readable icon-labelled controls and accessible Denoise/Postprocess switches (#1823)
- Expanded navigation uses a theme-accent tint with subtle static wave gradients (#1823) - Expanded navigation uses a theme-accent tint with subtle static wave gradients (#1823)
- Convert groups source audio, target voice, and timing options into clearer controls; design choices include theme-matched SVG icons (#1823) - Convert groups source audio, target voice, and timing options into clearer controls; design choices include theme-matched SVG icons (#1823)
+1 -1
View File
@@ -131,7 +131,7 @@ The desktop launcher configures Python dependencies on first run via `uv` automa
|---|---| |---|---|
| **Voice Cloning** | Zero-shot synthesis from a short reference clip ([guide](docs/engines/README.md)) | | **Voice Cloning** | Zero-shot synthesis from a short reference clip ([guide](docs/engines/README.md)) |
| **Voice Design** | Create a voice from age, accent, pitch, style, and delivery instructions ([expressive speech](docs/expressive-speech.md)) | | **Voice Design** | Create a voice from age, accent, pitch, style, and delivery instructions ([expressive speech](docs/expressive-speech.md)) |
| **Video Dubbing** | Transcribe, translate, preserve speakers, synthesize, and export video ([export guide](docs/dubbing/export.md)) | | **Video Dubbing** | Transcribe, translate, preserve speakers, synthesize, and export video; grouped casting and translation controls include searchable language and engine menus ([export guide](docs/dubbing/export.md)) |
| **Stories and audiobooks** | Multi-voice scripts · EPUB/PDF import · chapter rendering · `.m4b` export | | **Stories and audiobooks** | Multi-voice scripts · EPUB/PDF import · chapter rendering · `.m4b` export |
| **[Dictation Widget](docs/features/dictation.md)** | System-wide shortcut, live transcription, optional local-LLM cleanup | | **[Dictation Widget](docs/features/dictation.md)** | System-wide shortcut, live transcription, optional local-LLM cleanup |
| **Vocal Isolation** | Demucs speech/background separation | | **Vocal Isolation** | Demucs speech/background separation |
+21 -22
View File
@@ -2,7 +2,10 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { List } from 'react-window'; import { List } from 'react-window';
import DubSegmentRow from './DubSegmentRow'; import DubSegmentRow from './DubSegmentRow';
import { Table, Select } from '../ui'; import { Table } from '../ui';
import { Headphones } from 'lucide-react';
import SearchableSelect from './SearchableSelect';
import DubToggle from './dub/DubToggle';
import { useAppStore } from '../store'; import { useAppStore } from '../store';
import { visibleMergeAvailability } from '../utils/segmentParts'; import { visibleMergeAvailability } from '../utils/segmentParts';
import useDubLivePreview from '../hooks/useDubLivePreview'; import useDubLivePreview from '../hooks/useDubLivePreview';
@@ -315,29 +318,25 @@ export default function DubSegmentTable({
searchPlaceholder={t('segment.search_placeholder')} searchPlaceholder={t('segment.search_placeholder')}
meta={meta} meta={meta}
> >
<label className="dub-live-toggle" title={t('dub.live_preview_title')}> <DubToggle
<input label={t('dub.live_preview')}
type="checkbox" title={t('dub.live_preview_title')}
className="accent-[var(--color-brand)]" Icon={Headphones}
checked={!!livePreviewOn} checked={livePreviewOn}
onChange={(e) => setDubLivePreview(e.target.checked)} onChange={setDubLivePreview}
/> />
{t('dub.live_preview')}
</label>
{speakers.length > 1 && ( {speakers.length > 1 && (
<Select <SearchableSelect
size="sm" menuPortal
ariaLabel={t('segment.all_speakers')}
value={speakerFilter} value={speakerFilter}
onChange={(e) => setSpeakerFilter(e.target.value)} onChange={setSpeakerFilter}
className="dub-segment-table__spk-filter" buttonClassName="min-h-10 rounded-lg border-0 px-3 text-sm bg-[var(--chrome-hover-bg)] text-[var(--chrome-fg)]"
> options={[
<option value="">{t('segment.all_speakers')}</option> { value: '', label: t('segment.all_speakers') },
{speakers.map((s) => ( ...speakers.map((s) => ({ value: s, label: s })),
<option key={s} value={s}> ]}
{s} />
</option>
))}
</Select>
)} )}
</Table.Toolbar> </Table.Toolbar>
+18
View File
@@ -0,0 +1,18 @@
.export-drawer > header { padding: 18px 20px; border: 0; }
.export-drawer > header > span:not([aria-hidden]) { min-width: 0; }
.export-drawer > header > button { min-width: 36px; min-height: 36px; flex-shrink: 0; }
.export-tabs button { justify-content: center; min-height: 42px; border: 0; border-radius: 6px; }
.export-tabs button svg { width: 16px; height: 16px; }
.export-tabs .export-tab-active { background: var(--chrome-accent-bg); }
.export-fields > .grid { grid-template-columns: repeat(auto-fit,minmax(min(100%,260px),1fr)); gap: 12px; }
.export-field .ui-seg { flex-wrap: wrap; align-self: flex-start; border-radius: 8px; }
.export-field .ui-seg button { min-height: 34px; font-size: 12px; }
.export-field .dub-setting-toggle { text-align: left; line-height: 1.5; padding-inline: 0; }
.export-summary > div:first-child { flex: 1; flex-direction: column; align-items: flex-start; }
.export-summary code { max-width: 100%; white-space: normal; overflow-wrap: anywhere; font-size: 12px; }
.export-summary button { min-height: 40px; }
@media (max-width: 520px) {
.export-summary { flex-direction: column; align-items: stretch; }
.export-summary > div:last-child { justify-content: flex-end; }
.export-tabs button { padding-inline: 4px; font-size: 12px; gap: 4px; }
}
+92 -106
View File
@@ -16,17 +16,16 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { Button, Segmented } from '../ui'; import { Button, Segmented } from '../ui';
import TrackManager from './dub/TrackManager'; import TrackManager from './dub/TrackManager';
import DubToggle from './dub/DubToggle';
import SearchableSelect from './SearchableSelect';
import './ExportModal.css';
const TAB_BASE = const TAB_BASE =
'inline-flex items-center gap-[6px] px-[12px] py-[6px] bg-transparent border-0 border-b-2 cursor-pointer text-[length:var(--text-sm)] transition-[color,border-color] duration-[var(--dur-fast)]'; 'inline-flex items-center gap-[6px] px-[12px] py-[6px] bg-transparent border-0 border-b-2 cursor-pointer text-[length:var(--text-sm)] transition-[color,border-color] duration-[var(--dur-fast)]';
const tabCls = (active) => const tabCls = (active) =>
active active
? `${TAB_BASE} border-b-[var(--chrome-accent)] text-[var(--chrome-accent)]` ? `${TAB_BASE} export-tab-active border-b-[var(--chrome-accent)] text-[var(--chrome-accent)]`
: `${TAB_BASE} border-b-transparent text-[var(--chrome-fg-muted)] hover:text-[var(--chrome-fg)]`; : `${TAB_BASE} border-b-transparent text-[var(--chrome-fg-muted)] hover:text-[var(--chrome-fg)]`;
const TOGGLE_CLS =
'inline-flex items-center gap-[6px] text-[length:var(--text-sm)] text-[var(--chrome-fg)] cursor-pointer [&_input]:accent-[var(--color-brand)]';
const TOGGLE_INDENT_CLS =
'inline-flex items-center gap-[6px] ml-[var(--space-4)] text-[length:var(--text-sm)] text-[var(--chrome-fg-muted)] cursor-pointer [&_input]:accent-[var(--color-brand)]';
/** /**
* ExportModal — comprehensive export panel for the dubbing studio. * ExportModal — comprehensive export panel for the dubbing studio.
@@ -278,7 +277,7 @@ export default function ExportModal({
aria-label={t('exportModal.export_options')} aria-label={t('exportModal.export_options')}
> >
<div <div
className="pointer-events-auto flex w-[min(880px,calc(100vw-24px))] max-h-[min(70vh,560px)] flex-col overflow-hidden rounded-t-lg border border-b-0 border-transparent bg-[var(--chrome-bg)] shadow-[0_-8px_24px_rgba(0,0,0,0.45),0_-1px_0_var(--chrome-border)_inset] animate-in fade-in slide-in-from-bottom-full duration-200" className="export-drawer pointer-events-auto flex w-[min(880px,calc(100vw-24px))] max-h-[85vh] flex-col overflow-hidden rounded-t-xl border-0 bg-[var(--chrome-bg)] shadow-xl animate-in fade-in duration-200 motion-reduce:animate-none"
ref={drawerRef} ref={drawerRef}
> >
<header className="relative flex items-center gap-[var(--space-3)] p-[6px_var(--space-4)_10px] [border-bottom:1px_solid_var(--chrome-border)] [background:linear-gradient(180deg,rgba(255,255,255,0.02),transparent)]"> <header className="relative flex items-center gap-[var(--space-3)] p-[6px_var(--space-4)_10px] [border-bottom:1px_solid_var(--chrome-border)] [background:linear-gradient(180deg,rgba(255,255,255,0.02),transparent)]">
@@ -303,7 +302,7 @@ export default function ExportModal({
<X size={13} /> <X size={13} />
</button> </button>
</header> </header>
<div className="flex flex-col gap-[var(--space-4)] overflow-y-auto p-[var(--space-3)_var(--space-4)_var(--space-4)]"> <div className="export-body flex min-h-0 flex-col gap-4 overflow-y-auto p-4">
{/* Preset chips */} {/* Preset chips */}
<div className="flex flex-wrap items-center gap-[var(--space-2)] pb-[var(--space-3)] [border-bottom:1px_solid_var(--chrome-border)]"> <div className="flex flex-wrap items-center gap-[var(--space-2)] pb-[var(--space-3)] [border-bottom:1px_solid_var(--chrome-border)]">
<span className="inline-flex items-center gap-[4px] uppercase [font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]"> <span className="inline-flex items-center gap-[4px] uppercase [font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]">
@@ -332,7 +331,7 @@ export default function ExportModal({
/> />
{/* Tabs */} {/* Tabs */}
<div className="flex gap-[var(--space-1)] [border-bottom:1px_solid_var(--chrome-border)]"> <div className="export-tabs grid grid-cols-4 gap-1 rounded-lg bg-[var(--chrome-hover-bg)] p-1">
<button <button
type="button" type="button"
className={tabCls(tab === 'video')} className={tabCls(tab === 'video')}
@@ -356,7 +355,7 @@ export default function ExportModal({
</div> </div>
{/* Tab body */} {/* Tab body */}
<div className="min-h-[160px]"> <div className="export-fields">
{tab === 'video' && ( {tab === 'video' && (
<div className="grid grid-cols-2 gap-x-[var(--space-5)] gap-y-[var(--space-4)]"> <div className="grid grid-cols-2 gap-x-[var(--space-5)] gap-y-[var(--space-4)]">
<Field label={t('exportModal.container')}> <Field label={t('exportModal.container')}>
@@ -371,42 +370,38 @@ export default function ExportModal({
label={t('exportModal.default_audio_track')} label={t('exportModal.default_audio_track')}
hint={t('exportModal.default_audio_hint')} hint={t('exportModal.default_audio_hint')}
> >
<select <SearchableSelect
className="input-base input-base--xs" menuPortal
ariaLabel={t('exportModal.default_audio_track')}
value={defaultTrack} value={defaultTrack}
onChange={(e) => setDefaultTrack(e.target.value)} onChange={setDefaultTrack}
> buttonClassName="min-h-10 rounded-lg border-0 bg-[var(--chrome-hover-bg)] px-3 text-sm text-[var(--chrome-fg)]"
{exportTracks['original'] !== false && ( options={[
<option value="original">{t('exportModal.original')}</option> ...(exportTracks.original !== false
)} ? [{ value: 'original', label: t('exportModal.original') }]
{(dubTracks || []) : []),
.filter((code) => exportTracks[code] !== false) ...(dubTracks || [])
.map((code) => ( .filter((code) => exportTracks[code] !== false)
<option key={code} value={code}> .map((code) => ({
{code.toUpperCase()} {t('exportModal.dub_suffix')} value: code,
</option> label: code.toUpperCase() + ' ' + t('exportModal.dub_suffix'),
))} })),
</select> ]}
/>
</Field> </Field>
<Field label={t('exportModal.bg_audio')}> <Field label={t('exportModal.bg_audio')}>
<label className={TOGGLE_CLS}> <DubToggle
<input label={t('exportModal.mix_bg_video')}
type="checkbox" checked={preserveBg}
checked={preserveBg} onChange={setPreserveBg}
onChange={(e) => setPreserveBg(e.target.checked)} />
/>
{t('exportModal.mix_bg_video')}
</label>
</Field> </Field>
<Field label={t('exportModal.subs_in_video')}> <Field label={t('exportModal.subs_in_video')}>
<label className={TOGGLE_CLS}> <DubToggle
<input label={t('exportModal.hardsub')}
type="checkbox" checked={burnSubs}
checked={burnSubs} onChange={setBurnSubs}
onChange={(e) => setBurnSubs(e.target.checked)} />
/>
{t('exportModal.hardsub')}
</label>
{burnSubs && ( {burnSubs && (
<Segmented <Segmented
size="sm" size="sm"
@@ -425,14 +420,11 @@ export default function ExportModal({
/> />
)} )}
{burnSubs && ( {burnSubs && (
<label className={TOGGLE_INDENT_CLS}> <DubToggle
<input label={t('exportModal.dual_subs_video')}
type="checkbox" checked={!!dualSubs}
checked={!!dualSubs} onChange={setDualSubs}
onChange={(e) => setDualSubs(e.target.checked)} />
/>
{t('exportModal.dual_subs_video')}
</label>
)} )}
</Field> </Field>
{(timingStrategy === 'smart_fit' || timingStrategy === 'stretch_video') && ( {(timingStrategy === 'smart_fit' || timingStrategy === 'stretch_video') && (
@@ -482,28 +474,25 @@ export default function ExportModal({
]} ]}
/> />
{audioBatch === 'primary' && ( {audioBatch === 'primary' && (
<select <SearchableSelect
className="input-base input-base--xs mt-[6px]" menuPortal
ariaLabel={t('exportModal.languages')}
value={audioPrimaryLang} value={audioPrimaryLang}
onChange={(e) => setAudioPrimaryLang(e.target.value)} onChange={setAudioPrimaryLang}
> buttonClassName="min-h-10 rounded-lg border-0 bg-[var(--chrome-hover-bg)] px-3 text-sm text-[var(--chrome-fg)]"
{(dubTracks || []).map((code) => ( options={(dubTracks || []).map((code) => ({
<option key={code} value={code}> value: code,
{code.toUpperCase()} label: code.toUpperCase(),
</option> }))}
))} />
</select>
)} )}
</Field> </Field>
<Field label={t('exportModal.bg_audio')}> <Field label={t('exportModal.bg_audio')}>
<label className={TOGGLE_CLS}> <DubToggle
<input label={t('exportModal.mix_bg_audio')}
type="checkbox" checked={preserveBg}
checked={preserveBg} onChange={setPreserveBg}
onChange={(e) => setPreserveBg(e.target.checked)} />
/>
{t('exportModal.mix_bg_audio')}
</label>
</Field> </Field>
</div> </div>
)} )}
@@ -602,44 +591,43 @@ export default function ExportModal({
. .
</span> </span>
</div> </div>
</div>
{/* Summary footer */} {/* Summary footer stays visible while format settings scroll. */}
<div className="flex items-center justify-between gap-[var(--space-3)] pt-[var(--space-3)] [border-top:1px_solid_var(--chrome-border)]"> <div className="export-summary flex shrink-0 items-center justify-between gap-3 p-4 bg-[var(--chrome-hover-bg)]">
<div className="flex min-w-0 items-center gap-[var(--space-2)]"> <div className="flex min-w-0 items-center gap-[var(--space-2)]">
<span className="inline-flex items-center gap-[4px] uppercase [font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]"> <span className="inline-flex items-center gap-[4px] uppercase [font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]">
{t('exportModal.output')} {t('exportModal.output')}
</span> </span>
<code <code
className="max-w-[340px] overflow-hidden text-ellipsis whitespace-nowrap rounded-[2px] bg-[var(--chrome-hover-bg)] px-[8px] py-[3px] [font-family:var(--chrome-font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg)]" className="max-w-[340px] overflow-hidden text-ellipsis whitespace-nowrap rounded-[2px] bg-[var(--chrome-hover-bg)] px-[8px] py-[3px] [font-family:var(--chrome-font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg)]"
title={filenamePreview} title={filenamePreview}
> >
{filenamePreview} {filenamePreview}
</code> </code>
</div> </div>
<div className="inline-flex gap-[var(--space-2)]"> <div className="inline-flex gap-[var(--space-2)]">
{tab !== 'pkg' && ( {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}> <Button variant="ghost" size="sm" onClick={onClose}>
{t('common.close')} {t('common.cancel')}
</Button> </Button>
)} <Button
</div> 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>
@@ -650,11 +638,9 @@ export default function ExportModal({
function Field({ label, hint, children }) { function Field({ label, hint, children }) {
return ( return (
<div className="flex flex-col gap-[6px]"> <div className="export-field flex min-w-0 flex-col gap-3 rounded-lg bg-[var(--chrome-hover-bg)] p-4">
<div className="flex flex-col gap-[2px]"> <div className="flex flex-col gap-[2px]">
<span className="uppercase [font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]"> <span className="text-sm font-medium text-[var(--chrome-fg)]">{label}</span>
{label}
</span>
{hint && ( {hint && (
<span className="text-[length:var(--text-xs)] text-[var(--chrome-fg-dim)] leading-[1.4]"> <span className="text-[length:var(--text-xs)] text-[var(--chrome-fg-dim)] leading-[1.4]">
{hint} {hint}
+13 -2
View File
@@ -3,8 +3,8 @@
// inside `.map(t => …)` callbacks where `t` was the loop variable, shadowing the // inside `.map(t => …)` callbacks where `t` was the loop variable, shadowing the
// useTranslation `t`. Rendering with a dub track that equals the primary // useTranslation `t`. Rendering with a dub track that equals the primary
// dubLangCode exercises the exact crashing branch. // dubLangCode exercises the exact crashing branch.
import { describe, it, expect } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react'; import { render, screen, fireEvent } from '@testing-library/react';
import '../i18n'; import '../i18n';
import ExportModal from './ExportModal'; import ExportModal from './ExportModal';
@@ -41,6 +41,17 @@ function renderModal(extra = {}) {
} }
describe('ExportModal (regression #183)', () => { describe('ExportModal (regression #183)', () => {
it('keeps export actions outside the scrolling settings and uses themed track choices', () => {
const setDefaultTrack = vi.fn();
renderModal({ setDefaultTrack });
expect(document.querySelector('.export-body')).not.toContainElement(
document.querySelector('.export-summary'),
);
fireEvent.click(screen.getByRole('button', { name: /default audio track/i }));
fireEvent.mouseDown(screen.getByRole('option', { name: /^ES/ }));
expect(setDefaultTrack).toHaveBeenCalledWith('es');
expect(screen.getAllByRole('switch')).toHaveLength(2);
});
it('renders with dub tracks incl. the primary dub without throwing', () => { it('renders with dub tracks incl. the primary dub without throwing', () => {
expect(() => renderModal()).not.toThrow(); expect(() => renderModal()).not.toThrow();
}); });
+53 -34
View File
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { LayoutGrid } from 'lucide-react'; import { LayoutGrid, Users, UserRound, Fingerprint } from 'lucide-react';
import SearchableSelect from '../SearchableSelect';
import { PRESET_ICONS, FALLBACK_VOICE_ICON, stripVoiceEmoji } from '../../utils/voiceIcons';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { PRESETS } from '../../utils/constants'; import { PRESETS } from '../../utils/constants';
import { autoProfileId, assignSpeakerProfile, castParts, castSpeakers } from '../../utils/segments'; import { autoProfileId, assignSpeakerProfile, castParts, castSpeakers } from '../../utils/segments';
@@ -130,12 +132,13 @@ export default function CastingBoard({ t, dubSegments, setDubSegments, speakerCl
]; ];
return ( return (
<div className="mt-[2px] px-[var(--space-3)] py-[3px] bg-[var(--chrome-bg)] rounded-[var(--chrome-radius-pill)] border border-transparent"> <div className="dub-casting-controls my-3 p-4 bg-[var(--chrome-hover-bg)] rounded-lg border-0">
<div className="flex gap-[var(--space-2)] items-center flex-wrap"> <div className="flex gap-[var(--space-2)] items-center flex-wrap">
<span <span
className="font-[family-name:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] text-[var(--chrome-fg-muted)] tracking-[var(--chrome-label-track)] uppercase font-semibold" className="font-[family-name:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] text-[var(--chrome-fg-muted)] tracking-[var(--chrome-label-track)] uppercase font-semibold"
title={t('dub.cast_title')} title={t('dub.cast_title')}
> >
<Users size={16} aria-hidden="true" className="inline-block mr-2 align-text-bottom" />
{t('dub.cast')} {t('dub.cast')}
</span> </span>
{speakers.map((spk) => { {speakers.map((spk) => {
@@ -143,40 +146,56 @@ export default function CastingBoard({ t, dubSegments, setDubSegments, speakerCl
return ( return (
<div key={spk} className="dub-cast__pair"> <div key={spk} className="dub-cast__pair">
<span className="font-[family-name:var(--chrome-font-mono)] text-[0.62rem] text-[var(--chrome-fg)]"> <span className="font-[family-name:var(--chrome-font-mono)] text-[0.62rem] text-[var(--chrome-fg)]">
{spk}: <UserRound
size={14}
aria-hidden="true"
className="inline-block mr-1 align-text-bottom"
/>
{spk}
</span> </span>
<select <SearchableSelect
className="input-base dub-cast__select" ariaLabel={spk}
menuPortal
renderGroupHeaders
buttonClassName="dub-cast__select min-h-10 rounded-lg border-0 bg-[var(--chrome-hover-bg)] px-3 py-2 text-sm text-[var(--chrome-fg)] hover:bg-[var(--chrome-accent-bg)] focus-visible:outline-2 focus-visible:outline-[var(--chrome-accent)]"
value={currentVoice(spk)} value={currentVoice(spk)}
onChange={(e) => onChange={(value) => setDubSegments(assignSpeakerProfile(dubSegments, spk, value))}
setDubSegments(assignSpeakerProfile(dubSegments, spk, e.target.value)) options={[
} ...(clone
> ? [
{clone && ( {
<option value={autoProfileId(spk)}> value: autoProfileId(spk),
{t('dub.from_video', { duration: clone.duration.toFixed(1) })} label: t('dub.from_video', { duration: clone.duration.toFixed(1) }),
</option> Icon: UserRound,
)} },
<option value="">{t('dub.default')}</option> ]
{profiles.length > 0 && ( : []),
<optgroup label={t('dub.clone_profiles')}> { value: '', label: t('dub.default'), Icon: UserRound },
{profiles.map((p) => ( ...profiles.map((p) => ({
<option key={p.id} value={p.id}> value: p.id,
{p.name} label: p.name,
</option> group: 'clones',
))} groupLabel: t('dub.clone_profiles'),
</optgroup> Icon: Fingerprint,
)} })),
{PRESETS.length > 0 && ( ...PRESETS.map((p) => ({
<optgroup label={t('dub.design_presets')}> value: 'preset:' + p.id,
{PRESETS.map((p) => ( label: stripVoiceEmoji(p.name),
<option key={p.id} value={`preset:${p.id}`}> group: 'presets',
{p.name} groupLabel: t('dub.design_presets'),
</option> Icon: PRESET_ICONS[p.id] || FALLBACK_VOICE_ICON,
))} })),
</optgroup> ]}
)} renderOption={(option) => {
</select> const Icon = option.Icon;
return (
<span className="inline-flex items-center gap-2">
<Icon size={16} aria-hidden="true" className="shrink-0 opacity-75" />
<span>{option.label}</span>
</span>
);
}}
/>
</div> </div>
); );
})} })}
@@ -175,12 +175,12 @@ describe('CastingBoard ↔ dropdown sync', () => {
openBoard(); openBoard();
const selects = document.querySelectorAll('.dub-cast__select'); const selects = document.querySelectorAll('.dub-cast__select');
expect(selects[1].value).toBe(''); // SPEAKER_2 starts on Default expect(selects[1]).toHaveTextContent(t('dub.default'));
const row = screen.getByTestId('casting-board').querySelector('[data-speaker="SPEAKER_2"]'); const row = screen.getByTestId('casting-board').querySelector('[data-speaker="SPEAKER_2"]');
fireEvent.drop(row, { dataTransfer: { getData: () => 'voice-b' } }); fireEvent.drop(row, { dataTransfer: { getData: () => 'voice-b' } });
expect(document.querySelectorAll('.dub-cast__select')[1].value).toBe('voice-b'); expect(document.querySelectorAll('.dub-cast__select')[1]).toHaveTextContent('Ben');
expect(within(row).getByRole('button')).toHaveTextContent('Ben'); expect(within(row).getByRole('button')).toHaveTextContent('Ben');
}); });
}); });
@@ -220,7 +220,8 @@ describe('CastingBoard auto-clone chip', () => {
).toBeInTheDocument(); ).toBeInTheDocument();
const selects = document.querySelectorAll('.dub-cast__select'); const selects = document.querySelectorAll('.dub-cast__select');
fireEvent.change(selects[1], { target: { value: 'voice-b' } }); fireEvent.click(selects[1]);
fireEvent.mouseDown(screen.getByRole('option', { name: 'Ben', exact: true }));
const updated = props.setDubSegments.mock.calls[0][0]; const updated = props.setDubSegments.mock.calls[0][0];
expect(updated[0].profile_id).toBe('voice-a'); expect(updated[0].profile_id).toBe('voice-a');
@@ -0,0 +1,22 @@
.dub-panel-left .wfm-layout { flex: 0 0 auto; }
.dub-panel-left .wfm-stack { flex: 0 0 auto; }
.dub-panel-left .wfm-controls { padding: 10px 4px; gap: 8px; flex-wrap: wrap; }
.dub-panel-left .wfm-controls button { min-width: 32px; min-height: 32px; border: 0; border-radius: 6px; }
.dub-translation-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 16px; align-items: start; }
.dub-translation-fields > div { min-width: 0; }
.dub-translation-settings label { font-size: 12px; line-height: 1.5; gap: 8px; }
.dub-translation-settings input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--chrome-accent); }
.dub-translation-settings .label-icon { width: 15px; height: 15px; }
.dub-translation-actions { padding-top: 8px; }
.dub-translation-actions button { min-height: 36px; font-size: 12px; }
.dub-casting-controls { padding: 12px; container-type: inline-size; }
.dub-casting-controls > div:first-child { gap: 12px; align-items: center; }
.dub-casting-controls .dub-cast__pair { display: flex; flex: 1 1 250px; flex-direction: row; align-items: center; gap: 8px; min-width: 0; padding: 0; }
.dub-casting-controls .dub-cast__pair > span { display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0; font-family: var(--font-sans); font-size: 12px; }
.dub-casting-controls .dub-cast__pair > .ss-wrap { flex: 1; min-width: 0; }
.dub-casting-controls .dub-cast__select { width: 100%; height: 40px; min-height: 40px; font-size: 12px; padding: 8px 12px; }
.dub-casting-controls .dub-cast__board-toggle { height: 40px; min-height: 40px; font-size: 12px; margin: 0; padding: 8px 10px; white-space: nowrap; }
@container (max-width: 450px) {
.dub-casting-controls .dub-cast__pair { flex-basis: 100%; }
.dub-casting-controls .dub-cast__pair > span { flex-basis: 90px; }
}
+79 -76
View File
@@ -12,6 +12,11 @@ import {
Copy, Copy,
ExternalLink, ExternalLink,
ArrowRightLeft, ArrowRightLeft,
Cpu,
Gauge,
Hash,
MapPin,
RotateCcw,
} from 'lucide-react'; } from 'lucide-react';
import { Button, Segmented, Progress } from '../../ui'; import { Button, Segmented, Progress } from '../../ui';
import { useAppStore } from '../../store'; import { useAppStore } from '../../store';
@@ -21,7 +26,7 @@ import { API } from '../../api/client';
import { dubListTracks } from '../../api/dub'; import { dubListTracks } from '../../api/dub';
import { LANG_CODES } from '../../utils/languages'; import { LANG_CODES } from '../../utils/languages';
import ALL_LANGUAGES from '../../languages.json'; import ALL_LANGUAGES from '../../languages.json';
import { POPULAR_LANGS } from '../../utils/constants'; import SearchableSelect from '../SearchableSelect';
import { dialectOptionsFor, dialectLabel, dialectMatchesLang } from '../../api/dialects'; import { dialectOptionsFor, dialectLabel, dialectMatchesLang } from '../../api/dialects';
import { dubSegmentsText } from '../../api/dub'; import { dubSegmentsText } from '../../api/dub';
import { copyText } from '../../utils/copyText'; import { copyText } from '../../utils/copyText';
@@ -29,6 +34,7 @@ import { openExternal } from '../../api/external';
import { TRANSLATION_ENGINES_DOCS } from '../../utils/errorDocsMap'; import { TRANSLATION_ENGINES_DOCS } from '../../utils/errorDocsMap';
import CastingBoard from './CastingBoard'; import CastingBoard from './CastingBoard';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import './DubLeftColumn.css';
// ── Translation-settings bar utility class clusters ────────────────────── // ── Translation-settings bar utility class clusters ──────────────────────
const SETTINGS_SUMMARY = const SETTINGS_SUMMARY =
@@ -36,12 +42,11 @@ const SETTINGS_SUMMARY =
const SUMMARY_TRIGGER = const SUMMARY_TRIGGER =
'inline-flex items-center gap-[5px] flex-1 min-w-0 bg-transparent border-none text-fg-muted cursor-pointer py-[2px] px-0 [font:inherit] text-left'; 'inline-flex items-center gap-[5px] flex-1 min-w-0 bg-transparent border-none text-fg-muted cursor-pointer py-[2px] px-0 [font:inherit] text-left';
const SETTINGS_BAR = const SETTINGS_BAR =
'flex flex-col gap-[3px] max-[900px]:gap-[6px] mb-[4px] px-[8px] py-[4px] bg-[var(--chrome-bg)] border border-transparent rounded-[var(--chrome-radius-pill)]'; 'dub-translation-settings flex flex-col gap-4 mb-2 p-4 bg-[var(--chrome-hover-bg)] border-0 rounded-lg';
const FIELD = 'flex flex-col gap-[1px] min-w-0'; const FIELD = 'flex flex-col gap-2 min-w-0';
const FIELD_RESP = 'max-[960px]:basis-full max-[960px]:min-w-0'; const FIELD_RESP = '';
const FIELD_LABEL = const FIELD_LABEL = 'flex items-center gap-2 text-sm font-medium text-[var(--chrome-fg)]';
'label-row !text-[0.58rem] !text-fg-muted !m-0 whitespace-nowrap overflow-hidden text-ellipsis'; const FIELD_INPUT = 'input-base !w-full !text-sm !px-3 !py-2 min-h-10';
const FIELD_INPUT = 'input-base !w-full !text-[0.65rem] !px-[5px] !py-[3px]';
const ENGINE_CHIP = const ENGINE_CHIP =
'ml-[6px] px-[6px] py-[1px] text-[0.55rem] leading-[1.4] bg-[color-mix(in_srgb,var(--color-brand)_14%,transparent)] border border-transparent text-[var(--color-brand)] rounded-[var(--radius-pill)] whitespace-nowrap transition-colors'; 'ml-[6px] px-[6px] py-[1px] text-[0.55rem] leading-[1.4] bg-[color-mix(in_srgb,var(--color-brand)_14%,transparent)] border border-transparent text-[var(--color-brand)] rounded-[var(--radius-pill)] whitespace-nowrap transition-colors';
// Highlighted accent Install affordance — brand-filled pill, deliberately louder // Highlighted accent Install affordance — brand-filled pill, deliberately louder
@@ -420,69 +425,60 @@ export default function DubLeftColumn({
)} )}
{settingsOpen && ( {settingsOpen && (
<div className={SETTINGS_BAR}> <div className={SETTINGS_BAR}>
<div className="flex flex-wrap gap-x-[6px] gap-y-[4px] items-end"> <div className="dub-translation-fields">
<button <button
type="button" type="button"
className={`${SUMMARY_TRIGGER} flex-[0_0_auto] !px-[4px] self-center`} className={`${SUMMARY_TRIGGER} col-span-full !py-2`}
onClick={() => setSettingsOpen(false)} onClick={() => setSettingsOpen(false)}
title={t('dub.collapse_settings')} title={t('dub.collapse_settings')}
> >
<ChevronUp size={10} /> <Languages size={16} aria-hidden="true" />
{t('dub.edit_settings')}
<ChevronUp size={14} className="ml-auto" aria-hidden="true" />
</button> </button>
<div className={`${FIELD} flex-[1_1_100px] min-w-[70px] ${FIELD_RESP}`}> <div className={`${FIELD} flex-[1_1_100px] min-w-[70px] ${FIELD_RESP}`}>
<div className={FIELD_LABEL}> <div className={FIELD_LABEL}>
<Globe className="label-icon" size={9} /> {t('dub.language')} <Globe size={15} aria-hidden="true" /> {t('dub.language')}
</div> </div>
<select <MultiLangPicker
className={FIELD_INPUT} single
value={dubLang} ariaLabel={t('dub.language')}
onChange={(e) => { selected={[{ lang: dubLang, code: dubLangCode }]}
const lang = e.target.value; options={ALL_LANGUAGES.map((label) => ({
setDubLang(lang); label,
code: LANG_CODES.find((item) => item.label === label)?.code || label,
}))}
onChange={([item]) => {
setDubLang(item.lang);
const match = LANG_CODES.find( const match = LANG_CODES.find(
(lc) => lc.label.toLowerCase() === lang.toLowerCase(), (lc) => lc.label.toLowerCase() === item.lang.toLowerCase(),
); );
if (match) { if (match) {
setDubLangCode(match.code); 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(''); 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>
<div className={`${FIELD} flex-[0_1_72px] min-w-[52px] ${FIELD_RESP}`}> <div className={`${FIELD} flex-[0_1_72px] min-w-[52px] ${FIELD_RESP}`}>
<div className={FIELD_LABEL}>{t('dub.iso_code')}</div> <div className={FIELD_LABEL}>
<select <Hash size={15} aria-hidden="true" />
className={FIELD_INPUT} {t('dub.iso_code')}
</div>
<SearchableSelect
ariaLabel={t('dub.iso_code')}
menuPortal
buttonClassName={FIELD_INPUT}
value={dubLangCode} value={dubLangCode}
onChange={(e) => { options={LANG_CODES.map((lc) => ({
const code = e.target.value; value: lc.code,
label: lc.code + ' — ' + lc.label,
}))}
onChange={(code) => {
setDubLangCode(code); setDubLangCode(code);
if (!dialectMatchesLang(dubDialect, code)) setDubDialect(''); if (!dialectMatchesLang(dubDialect, code)) setDubDialect('');
}} }}
> />
{LANG_CODES.map((lc) => (
<option key={lc.code} value={lc.code}>
{lc.code} {lc.label}
</option>
))}
</select>
</div> </div>
{/* #280: regional dialect / vocabulary. Only rendered for {/* #280: regional dialect / vocabulary. Only rendered for
languages with curated variants; region names come from languages with curated variants; region names come from
@@ -490,24 +486,28 @@ export default function DubLeftColumn({
{dialectOptionsFor(dubLangCode).length > 0 && ( {dialectOptionsFor(dubLangCode).length > 0 && (
<div className={`${FIELD} flex-[0_1_110px] min-w-[80px] ${FIELD_RESP}`}> <div className={`${FIELD} flex-[0_1_110px] min-w-[80px] ${FIELD_RESP}`}>
<div className={FIELD_LABEL} title={t('dub.dialect_title')}> <div className={FIELD_LABEL} title={t('dub.dialect_title')}>
<MapPin size={15} aria-hidden="true" />
{t('dub.dialect_label')} {t('dub.dialect_label')}
</div> </div>
<select <SearchableSelect
className={FIELD_INPUT} ariaLabel={t('dub.dialect_label')}
menuPortal
buttonClassName={FIELD_INPUT}
value={dialectMatchesLang(dubDialect, dubLangCode) ? dubDialect : ''} value={dialectMatchesLang(dubDialect, dubLangCode) ? dubDialect : ''}
onChange={(e) => setDubDialect(e.target.value)} onChange={setDubDialect}
> options={[
<option value="">{t('dub.dialect_default')}</option> { value: '', label: t('dub.dialect_default') },
{dialectOptionsFor(dubLangCode).map((d) => ( ...dialectOptionsFor(dubLangCode).map((d) => ({
<option key={d} value={d}> value: d,
{dialectLabel(d, i18n.language)} label: dialectLabel(d, i18n.language),
</option> })),
))} ]}
</select> />
</div> </div>
)} )}
<div className={`${FIELD} flex-[1.4_1_130px] min-w-[90px] ${FIELD_RESP}`}> <div className={`${FIELD} flex-[1.4_1_130px] min-w-[90px] ${FIELD_RESP}`}>
<div className={`${FIELD_LABEL} !overflow-visible flex items-center`}> <div className={`${FIELD_LABEL} !overflow-visible flex items-center`}>
<Cpu size={15} aria-hidden="true" />
{t('dub.engine_label')} {t('dub.engine_label')}
{/* FROM-SOURCE lane: pip install works (uv pip install runs {/* FROM-SOURCE lane: pip install works (uv pip install runs
in-process). Promote the muted chip to a highlighted accent in-process). Promote the muted chip to a highlighted accent
@@ -602,26 +602,27 @@ export default function DubLeftColumn({
</span> </span>
)} )}
</div> </div>
<select <SearchableSelect
className={FIELD_INPUT} ariaLabel={t('dub.engine_label')}
menuPortal
buttonClassName={FIELD_INPUT}
value={translateProvider} value={translateProvider}
onChange={(e) => setTranslateProvider(e.target.value)} onChange={setTranslateProvider}
> options={engines.map((p) => ({
{(engines.length ? engines : []).map((p) => ( value: p.id,
<option key={p.id} value={p.id}> label: p.installed
{p.installed ? p.display_name
? p.display_name : p.display_name + t('dub.needs_install_suffix'),
: `${p.display_name}${t('dub.needs_install_suffix')}`} }))}
</option> />
))}
</select>
</div> </div>
<div className={`${FIELD} flex-[0_1_auto] min-w-[80px] ${FIELD_RESP}`}> <div className={`${FIELD} flex-[0_1_auto] min-w-[80px] ${FIELD_RESP}`}>
<div className={FIELD_LABEL} title={t('dub.quality_title')}> <div className={FIELD_LABEL} title={t('dub.quality_title')}>
<Gauge size={15} aria-hidden="true" />
{t('dub.quality_label')} {t('dub.quality_label')}
</div> </div>
<Segmented <Segmented
className="w-full" className="w-full [&_button]:min-h-8 [&_button]:text-xs"
size="sm" size="sm"
value={translateQuality} value={translateQuality}
onChange={setTranslateQuality} onChange={setTranslateQuality}
@@ -684,19 +685,20 @@ export default function DubLeftColumn({
)} )}
<div className={`${FIELD} flex-[1_1_90px] min-w-[64px] ${FIELD_RESP}`}> <div className={`${FIELD} flex-[1_1_90px] min-w-[64px] ${FIELD_RESP}`}>
<div className={FIELD_LABEL}> <div className={FIELD_LABEL}>
<UserSquare2 className="label-icon" size={9} /> {t('dub.style')}{' '} <UserSquare2 size={15} aria-hidden="true" /> {t('dub.style')}{' '}
<span className="text-[0.52rem] text-fg-subtle italic ml-[2px]"> <span className="text-[0.52rem] text-fg-subtle italic ml-[2px]">
{t('dub.optional')} {t('dub.optional')}
</span> </span>
</div> </div>
<input <input
className={FIELD_INPUT} className={FIELD_INPUT}
aria-label={t('dub.style')}
placeholder={t('dub.style_placeholder')} placeholder={t('dub.style_placeholder')}
value={dubInstruct} value={dubInstruct}
onChange={(e) => setDubInstruct(e.target.value)} onChange={(e) => setDubInstruct(e.target.value)}
/> />
</div> </div>
<div className={`${FIELD} basis-full pt-[3px] border-t border-transparent mt-[1px]`}> <div className={`${FIELD} col-span-full pt-2`}>
<label className="flex items-center gap-[6px] text-[0.65rem] text-[var(--chrome-fg-muted)] cursor-pointer mb-[2px]"> <label className="flex items-center gap-[6px] text-[0.65rem] text-[var(--chrome-fg-muted)] cursor-pointer mb-[2px]">
<input <input
type="checkbox" type="checkbox"
@@ -716,7 +718,7 @@ export default function DubLeftColumn({
)} )}
</div> </div>
</div> </div>
<div className="flex justify-end gap-[6px] flex-wrap"> <div className="dub-translation-actions flex justify-end gap-2 flex-wrap">
{failedTranslationCount > 0 && ( {failedTranslationCount > 0 && (
<> <>
<Button <Button
@@ -760,6 +762,7 @@ export default function DubLeftColumn({
} }
disabled={!dubSegments.some((s) => s.text_original && s.text_original !== s.text)} disabled={!dubSegments.some((s) => s.text_original && s.text_original !== s.text)}
title={t('dub.restore_title')} title={t('dub.restore_title')}
leading={<RotateCcw size={15} aria-hidden="true" />}
> >
{t('dub.restore')} {t('dub.restore')}
</Button> </Button>
@@ -769,7 +772,7 @@ export default function DubLeftColumn({
onClick={handleCleanupSegments} onClick={handleCleanupSegments}
disabled={!dubSegments.length || !dubJobId} disabled={!dubSegments.length || !dubJobId}
title={t('dub.clean_up_title')} title={t('dub.clean_up_title')}
leading={<Wand2 size={10} />} leading={<Wand2 size={15} aria-hidden="true" />}
> >
{t('dub.clean_up')} {t('dub.clean_up')}
</Button> </Button>
@@ -0,0 +1,13 @@
.dub-output-settings { padding: 16px; border-radius: 10px; background: var(--chrome-hover-bg); margin-bottom: 12px; }
.dub-output-grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(min(100%,190px),1fr)); gap: 12px; margin-bottom: 16px; align-items: end; }
.dub-output-settings > div:not(.dub-output-grid) { gap: 10px; margin-top: 12px; padding: 0; }
.dub-output-settings > div > span { display: inline-flex; align-items: center; gap: 8px; font-family: var(--font-sans); font-size: 12px; text-transform: none; letter-spacing: normal; }
.dub-output-settings .ui-seg { flex-wrap: wrap; border-radius: 8px; }
.dub-output-settings .ui-seg button { min-height: 34px; font-size: 12px; }
.dub-panel-right .dub-transcript-toggle__inner { width: 100%; min-height: 42px; border: 0; border-radius: 8px; padding: 10px 12px; background: var(--chrome-hover-bg); }
.dub-panel-right .dub-transcript-toggle__inner > span { display: inline-flex; align-items: center; gap: 6px; }
.dub-panel-right .ui-table-toolbar { flex-wrap: wrap; gap: 8px; padding-block: 10px; }
.dub-panel-right .ui-table-toolbar__search { flex: 1 1 160px; }
.dub-panel-right .ui-table-toolbar__search-input { min-height: 40px; font-size: 12px; }
.dub-panel-right .ui-table-toolbar > .ss-wrap { flex: 1 1 160px; }
.dub-panel-right .ui-table-toolbar .dub-setting-toggle { white-space: nowrap; flex-shrink: 0; }
+77 -57
View File
@@ -1,5 +1,21 @@
import { Suspense, lazy, useState } from 'react'; import { Suspense, lazy, useState } from 'react';
import { ChevronUp, ChevronDown, FileText, ClipboardPaste } from 'lucide-react'; import {
ChevronUp,
ChevronDown,
FileText,
ClipboardPaste,
AudioLines,
Languages,
Captions,
ListMusic,
Timer,
Fingerprint,
BookOpen,
Settings2,
} from 'lucide-react';
import DubToggle from './DubToggle';
import SearchableSelect from '../SearchableSelect';
import './DubRightColumn.css';
import { Button, Segmented } from '../../ui'; import { Button, Segmented } from '../../ui';
import GlossaryPanel from '../GlossaryPanel'; import GlossaryPanel from '../GlossaryPanel';
import CheckpointBanner from '../CheckpointBanner'; import CheckpointBanner from '../CheckpointBanner';
@@ -15,11 +31,8 @@ const LazyFallback = () => <div className="p-[12px] text-[#6b6657] text-[0.7rem]
// ── Output-options + bulk-select utility clusters ──────────────────────── // ── Output-options + bulk-select utility clusters ────────────────────────
const OUT_ROW = const OUT_ROW =
'flex items-center gap-[var(--space-3)] mb-[2px] px-[var(--space-2)] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] font-[family-name:var(--font-sans)] flex-wrap'; 'flex items-center gap-[var(--space-3)] mb-[2px] px-[var(--space-2)] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] font-[family-name:var(--font-sans)] flex-wrap';
const OUT_LABEL =
'flex items-center gap-[var(--space-2)] cursor-pointer hover:text-[var(--chrome-fg)]';
const OUT_TITLE = const OUT_TITLE =
'font-[family-name:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] uppercase text-[var(--chrome-fg-muted)] font-semibold'; 'font-[family-name:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] uppercase text-[var(--chrome-fg-muted)] font-semibold';
const CHK = 'accent-[var(--color-brand)]';
const BULK_SELECT = 'input-base !text-[0.62rem] !px-[4px] !py-[2px]'; const BULK_SELECT = 'input-base !text-[0.62rem] !px-[4px] !py-[2px]';
export default function DubRightColumn({ export default function DubRightColumn({
@@ -87,62 +100,63 @@ export default function DubRightColumn({
return ( return (
<div className="studio-panel dub-panel-col dub-panel-right"> <div className="studio-panel dub-panel-col dub-panel-right">
{/* Output options + timing — moved to the top of the right section. */} {/* Output options + timing — moved to the top of the right section. */}
<div> <div className="dub-output-settings">
<div className={OUT_ROW}> <div className="dub-output-grid">
<span className={OUT_TITLE}>{t('dub.output_options')}</span> <span className={`${OUT_TITLE} col-span-full inline-flex items-center gap-2`}>
<label className={OUT_LABEL}> <Settings2 size={16} aria-hidden="true" />
<input {t('dub.output_options')}
type="checkbox" </span>
className={CHK} <DubToggle
checked={preserveBg} label={t('dub.mix_bg_audio')}
onChange={(e) => setPreserveBg(e.target.checked)} Icon={AudioLines}
/>{' '} checked={preserveBg}
{t('dub.mix_bg_audio')} onChange={setPreserveBg}
</label> />
<label className={OUT_LABEL} title={t('dub.dual_subs_title')}> <DubToggle
<input label={t('dub.dual_subs')}
type="checkbox" title={t('dub.dual_subs_title')}
className={CHK} Icon={Languages}
checked={!!dualSubs} checked={dualSubs}
onChange={(e) => setDualSubs(e.target.checked)} onChange={setDualSubs}
/>{' '} />
{t('dub.dual_subs')} <DubToggle
</label> label={t('dub.burn_subs')}
<label className={OUT_LABEL} title={t('dub.burn_subs_title')}> title={t('dub.burn_subs_title')}
<input Icon={Captions}
type="checkbox" checked={burnSubs}
className={CHK} onChange={setBurnSubs}
checked={!!burnSubs} />
onChange={(e) => setBurnSubs(e.target.checked)} <div className="flex flex-col gap-2 min-w-0">
/>{' '} <span className="inline-flex items-center gap-2 text-sm">
{t('dub.burn_subs')} <ListMusic size={15} aria-hidden="true" />
</label> {t('dub.default_track')}
<label className={OUT_LABEL}> </span>
{t('dub.default_track')} <SearchableSelect
<select ariaLabel={t('dub.default_track')}
className="input-base !text-[0.6rem] !px-[4px] !py-[2px] !w-[120px]" menuPortal
value={resolveDubDefaultTrack(defaultTrack, dubLangCode, dubTracks)} value={resolveDubDefaultTrack(defaultTrack, dubLangCode, dubTracks)}
onChange={(e) => setDefaultTrack(e.target.value)} onChange={setDefaultTrack}
> buttonClassName="min-h-10 rounded-lg border-0 bg-[var(--chrome-hover-bg)] px-3 text-sm text-[var(--chrome-fg)]"
<option value="original">{t('dub.original_track')}</option> options={[
{dubLangCode && ( { value: 'original', label: t('dub.original_track') },
<option value={dubLangCode}>{t('dub.selected_dub', { code: dubLangCode })}</option> ...(dubLangCode
)} ? [{ value: dubLangCode, label: t('dub.selected_dub', { code: dubLangCode }) }]
{dubTracks : []),
.filter((tr) => tr !== dubLangCode) ...dubTracks
.map((tr) => ( .filter((tr) => tr !== dubLangCode)
<option key={tr} value={tr}> .map((tr) => ({ value: tr, label: t('dub.dub_track', { code: tr }) })),
{t('dub.dub_track', { code: tr })} ]}
</option> />
))} </div>
</select>
</label>
</div> </div>
<div <div
className={OUT_ROW} className={OUT_ROW}
title="Timing strategy — how the dub reconciles natural-rate TTS with the original timeline." title="Timing strategy — how the dub reconciles natural-rate TTS with the original timeline."
> >
<span className={OUT_TITLE}>Timing:</span> <span className={OUT_TITLE}>
<Timer size={16} aria-hidden="true" />
Timing:
</span>
<Segmented <Segmented
value={timingStrategy} value={timingStrategy}
onChange={setTimingStrategy} onChange={setTimingStrategy}
@@ -176,7 +190,10 @@ export default function DubRightColumn({
(best prosody, identity may drift) or every line of a speaker (best prosody, identity may drift) or every line of a speaker
shares ONE reference (steady identity). */} shares ONE reference (steady identity). */}
<div className={OUT_ROW} title={t('dub.voice_match_title')}> <div className={OUT_ROW} title={t('dub.voice_match_title')}>
<span className={OUT_TITLE}>{t('dub.voice_match')}</span> <span className={OUT_TITLE}>
<Fingerprint size={16} aria-hidden="true" />
{t('dub.voice_match')}
</span>
<Segmented <Segmented
value={voiceMatch} value={voiceMatch}
onChange={setVoiceMatch} onChange={setVoiceMatch}
@@ -198,7 +215,9 @@ export default function DubRightColumn({
{dubTranscript && ( {dubTranscript && (
<div className="mb-[4px]"> <div className="mb-[4px]">
<div <button
type="button"
aria-expanded={showTranscript}
className="override-toggle dub-transcript-toggle__inner" className="override-toggle dub-transcript-toggle__inner"
onClick={() => setShowTranscript(!showTranscript)} onClick={() => setShowTranscript(!showTranscript)}
> >
@@ -206,7 +225,7 @@ export default function DubRightColumn({
<FileText size={10} className="align-middle mr-[3px]" /> {t('dub.transcript')} <FileText size={10} className="align-middle mr-[3px]" /> {t('dub.transcript')}
</span> </span>
{showTranscript ? <ChevronUp size={10} /> : <ChevronDown size={10} />} {showTranscript ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</div> </button>
{showTranscript && ( {showTranscript && (
<div className="bg-[var(--chrome-bg)] border border-transparent border-t-0 rounded-b-[var(--chrome-radius-pill)] p-[var(--space-3)] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] leading-[1.5] max-h-[80px] overflow-y-auto"> <div className="bg-[var(--chrome-bg)] border border-transparent border-t-0 rounded-b-[var(--chrome-radius-pill)] p-[var(--space-3)] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] leading-[1.5] max-h-[80px] overflow-y-auto">
{dubTranscript} {dubTranscript}
@@ -227,6 +246,7 @@ export default function DubRightColumn({
}} }}
title={t('dub.glossary_title')} title={t('dub.glossary_title')}
> >
<BookOpen size={15} aria-hidden="true" className="mr-2" />
{t('dub.glossary_btn', { count: glossaryTermCount })} {t('dub.glossary_btn', { count: glossaryTermCount })}
</button> </button>
)} )}
@@ -94,6 +94,8 @@ describe('DubRightColumn language targets', () => {
}), }),
); );
expect(screen.getByRole('combobox', { name: 'dub.default_track' })).toHaveValue('es'); expect(screen.getByRole('button', { name: 'dub.default_track' })).toHaveTextContent(
'dub.dub_track',
);
}); });
}); });
+26
View File
@@ -0,0 +1,26 @@
export default function DubToggle({ label, title, checked, onChange, Icon }) {
return (
<button
type="button"
role="switch"
aria-checked={!!checked}
aria-label={label}
title={title}
onClick={() => onChange(!checked)}
className="dub-setting-toggle flex items-center justify-between gap-3 min-h-10 px-3 py-2 rounded-lg border-0 bg-[var(--chrome-hover-bg)] text-sm text-[var(--chrome-fg)] cursor-pointer hover:bg-[var(--chrome-accent-bg)] focus-visible:outline-2 focus-visible:outline-[var(--chrome-accent)]"
>
<span className="inline-flex items-center gap-2">
{Icon && <Icon size={15} aria-hidden="true" />}
{label}
</span>
<span
aria-hidden="true"
className={`relative shrink-0 w-8 h-[18px] rounded-full ${checked ? 'bg-[var(--chrome-accent)]' : 'bg-[var(--chrome-fg-dim)]'}`}
>
<span
className={`absolute top-0.5 left-0.5 size-3.5 rounded-full bg-[var(--color-bg)] transition-transform motion-reduce:transition-none ${checked ? 'translate-x-3.5' : ''}`}
/>
</span>
</button>
);
}
@@ -0,0 +1,15 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import DubToggle from './DubToggle';
describe('DubToggle', () => {
it('announces its state and updates through the supplied handler', () => {
const onChange = vi.fn();
const { rerender } = render(<DubToggle label="Preview" checked={false} onChange={onChange} />);
fireEvent.click(screen.getByRole('switch', { name: 'Preview', checked: false }));
expect(onChange).toHaveBeenCalledWith(true);
rerender(<DubToggle label="Preview" checked onChange={onChange} />);
expect(screen.getByRole('switch', { name: 'Preview' })).toHaveAttribute('aria-checked', 'true');
});
});
@@ -152,10 +152,8 @@ describe('DubLeftColumn — translation-engine install affordance', () => {
{ id: 'argos', display_name: 'Argos', installed: true, install_command: null }, { id: 'argos', display_name: 'Argos', installed: true, install_command: null },
]; ];
render(<DubLeftColumn {...makeProps({ engines, setTranslateProvider })} />); render(<DubLeftColumn {...makeProps({ engines, setTranslateProvider })} />);
// The engine <select> is the only combobox whose current value is 'google'. fireEvent.click(screen.getByRole('button', { name: 'Engine', exact: true }));
const select = screen.getAllByRole('combobox').find((el) => el.value === 'google'); fireEvent.mouseDown(screen.getByRole('option', { name: 'Argos', exact: true }));
expect(select).toBeTruthy();
fireEvent.change(select, { target: { value: 'argos' } });
expect(setTranslateProvider).toHaveBeenCalledWith('argos'); expect(setTranslateProvider).toHaveBeenCalledWith('argos');
}); });
}); });
@@ -156,9 +156,8 @@ describe('review round: partial translations + dialect guard', () => {
/>, />,
); );
fireEvent.change(document.querySelector('.dub-cast__select'), { fireEvent.click(document.querySelector('.dub-cast__select'));
target: { value: 'voice-new' }, fireEvent.mouseDown(screen.getByRole('option', { name: 'New voice', exact: true }));
});
const update = setDubSegments.mock.calls[0][0][0]; const update = setDubSegments.mock.calls[0][0][0];
expect(update.profile_id).toBe('voice-new'); expect(update.profile_id).toBe('voice-new');
@@ -188,9 +187,8 @@ describe('review round: partial translations + dialect guard', () => {
/>, />,
); );
fireEvent.change(document.querySelectorAll('.dub-cast__select')[1], { fireEvent.click(document.querySelectorAll('.dub-cast__select')[1]);
target: { value: 'voice-new' }, fireEvent.mouseDown(screen.getByRole('option', { name: 'New voice', exact: true }));
});
const update = setDubSegments.mock.calls[0][0]; const update = setDubSegments.mock.calls[0][0];
expect(update[0].profile_id).toBe('voice-anna'); expect(update[0].profile_id).toBe('voice-anna');