diff --git a/CHANGELOG.md b/CHANGELOG.md index 248d7d07..a286a377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,24 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently. contact — less wall-of-text, faster to act on. ### Fixed +- **Dubbing: the PLAY button on the dubbed-video preview did nothing.** Same + autoplay-policy trap that #510 fixed for the standalone audio player, but the + dub editor's timeline player was missed. WaveSurfer builds its `AudioContext` + at mount — before any user gesture — so on Windows WebView2 (and Linux + Firefox/Chrome, Android Chrome) it stays `"suspended"`; `playPause()` then + resolves with no sound and the preview just sits there. Every playback entry + point in the dub timeline (the toolbar Play button and the per-segment "play + this slot") now resumes the context via the shared `unlockAudio()` on the + click before starting playback, and swallowed play() rejections are logged + instead of hidden. A source-contract regression test pins the invariant so a + future refactor can't quietly reintroduce a silent play path. macOS is + unaffected (its context was never blocked). (#595) +- **Voice design: the script text field couldn't be expanded.** The Script + textarea was a `flex: 1` item inside a flex column, so flex-grow recomputed + its height on every reflow and snapped the user's drag back — `resize: + vertical` is silently ignored on a flex-grown item in Chromium/WebView2. The + field now owns its own height (starts taller, and the corner grip grows it + reliably on every platform). (#595) - **An interrupted model download now self-repairs instead of dead-ending.** When the OmniVoice TTS cache was missing weight shards (the usual aftermath of an interrupted first download), the next synthesize failed with a 500 and a diff --git a/frontend/src/components/WaveformTimeline.jsx b/frontend/src/components/WaveformTimeline.jsx index 8838233a..1bdb13bc 100644 --- a/frontend/src/components/WaveformTimeline.jsx +++ b/frontend/src/components/WaveformTimeline.jsx @@ -5,6 +5,7 @@ import TimelinePlugin from 'wavesurfer.js/dist/plugins/timeline.esm.js'; import { Play, Pause, ZoomIn, ZoomOut, SkipBack, Loader, Keyboard, AlertTriangle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useAppStore } from '../store'; +import { unlockAudio } from '../utils/audioUnlock'; import SegmentTrack from './SegmentTrack'; import './WaveformErrorBoundary.css'; @@ -489,7 +490,10 @@ function WaveformTimeline({ // timeupdate watcher wired in the init effect. Used by the SegmentTrack // ("play this slot") on whatever media the player currently holds, so it // respects the original/dubbed preview toggle for free. - const playRange = useCallback((start, end) => { + const playRange = useCallback(async (start, end) => { + // Same autoplay-policy unlock as togglePlay (#595): resume the suspended + // AudioContext on this user gesture before kicking off playback. + try { await unlockAudio(); } catch { /* ignore */ } playRangeEndRef.current = end; const ws = wsRef.current; if (ws) { @@ -517,14 +521,22 @@ function WaveformTimeline({ } }, []); - const togglePlay = useCallback(() => { + const togglePlay = useCallback(async () => { + // Browser autoplay policy (Linux FF/Chrome, Windows WebView2, Android + // Chrome): WaveSurfer's AudioContext is constructed at mount — before any + // user gesture — and stays "suspended", so playPause() resolves without a + // sound and the dub video preview just sits there (#595, same class as + // #510 already fixed in WaveformPlayer). This click IS the gesture — + // explicitly resume before play. No-op on macOS where it never blocked. + try { await unlockAudio(); } catch { /* play() will surface real errors */ } if (wsRef.current) { - wsRef.current.playPause(); + try { await wsRef.current.playPause(); } + catch (e) { console.warn('WaveformTimeline: play failed:', e); } } else if (mediaElRef.current) { // Fallback: control the native media element directly const el = mediaElRef.current; if (el.paused) { - el.play().catch(() => {}); + el.play().catch((e) => console.warn('WaveformTimeline: play failed:', e)); } else { el.pause(); } diff --git a/frontend/src/components/WaveformTimeline.unlock.test.js b/frontend/src/components/WaveformTimeline.unlock.test.js new file mode 100644 index 00000000..405ab793 --- /dev/null +++ b/frontend/src/components/WaveformTimeline.unlock.test.js @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +// Regression guard for #595 — the dubbed-video PLAY button did nothing. +// +// WaveSurfer constructs its AudioContext at mount (before any user gesture), so +// on Windows WebView2 / Linux FF / Android Chrome it stays "suspended" and +// playPause() resolves silently with no sound. WaveformPlayer was already fixed +// for this in #510 by awaiting unlockAudio() on the click; WaveformTimeline (the +// dub editor's player) was missed — that's this bug. +// +// Driving WaveSurfer + a real AudioContext through jsdom is brittle, so this is +// a source-level contract guard: every playback entry point in WaveformTimeline +// must resume the AudioContext via unlockAudio() before it kicks off playback. +// It fails-before (no import / no await) and passes-after the fix, and pins the +// invariant so a future refactor can't quietly reintroduce a silent play path. + +// Vitest runs with cwd = frontend/, so resolve the component from there. +const src = readFileSync( + path.resolve(process.cwd(), 'src/components/WaveformTimeline.jsx'), + 'utf8', +); + +describe('WaveformTimeline autoplay-unlock wiring (#595)', () => { + it('imports the shared unlockAudio helper', () => { + expect(src).toMatch(/import\s*\{\s*unlockAudio\s*\}\s*from\s*['"]\.\.\/utils\/audioUnlock['"]/); + }); + + it('awaits unlockAudio() before playing in every playback entry point', () => { + // Each playback handler must resume the context first. Grab the body of + // each handler and assert the unlock await precedes the play/playPause call. + const handlers = { + togglePlay: /const togglePlay = useCallback\(async \(\) => \{([\s\S]*?)\}, \[\]\);/.exec(src)?.[1], + playRange: /const playRange = useCallback\(async \(start, end\) => \{([\s\S]*?)\}, \[\]\);/.exec(src)?.[1], + }; + for (const [name, body] of Object.entries(handlers)) { + expect(body, `${name} handler not found`).toBeTruthy(); + const unlockAt = body.indexOf('await unlockAudio()'); + expect(unlockAt, `${name} must await unlockAudio()`).toBeGreaterThanOrEqual(0); + const playAt = body.search(/\.play(Pause)?\(/); + expect(playAt, `${name} must call play`).toBeGreaterThanOrEqual(0); + expect(unlockAt, `${name} must unlock before play`).toBeLessThan(playAt); + } + }); +}); diff --git a/frontend/src/pages/CloneDesignTab.css b/frontend/src/pages/CloneDesignTab.css index 0eb1c8ee..705d93bb 100644 --- a/frontend/src/pages/CloneDesignTab.css +++ b/frontend/src/pages/CloneDesignTab.css @@ -272,12 +272,16 @@ /* Text input + textarea tweaks */ .clone-text-area { - flex: 1; - /* Re-enable the corner grip (matches base textarea.input-base). The ⊕ Insert - button is lifted off the bottom-right so it no longer covers the handle - (#481). */ + /* #595: the script field "couldn't be expanded". It was `flex: 1` inside a + flex column, so flex-grow recomputed its height on every reflow and + snapped the user's drag back — `resize: vertical` is silently ignored on a + flex-grown item in Chromium/WebView2 (Windows). Drop flex-grow (`flex: 0 1 + auto`) so the textarea owns its height: it starts at min-height and the + corner grip now grows it reliably on every platform. The ⊕ Insert button is + lifted off the bottom-right so it doesn't cover the grip (#481). */ + flex: 0 1 auto; resize: vertical; - min-height: 60px; + min-height: 160px; margin-bottom: 6px; } .clone-auto-extract-btn { border-color: #b8bb26; color: #b8bb26; }