feat(ui): organize dubbing controls and export drawer
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user