fix(ui): dub play button + designer script resize on Windows (#595) (#607)

Two frontend bugs reported on v0.3.7, both Windows/Chromium-flavoured:

1. The PLAY button on the dubbed-video preview did nothing. WaveSurfer
   builds its AudioContext at mount (before any user gesture), so on
   Windows WebView2 / Linux FF/Chrome it stays "suspended" and
   playPause() resolves with no sound. This is the same autoplay-policy
   trap #510 fixed for WaveformPlayer, but the dub timeline player was
   missed. togglePlay and the per-segment playRange now await the shared
   unlockAudio() on the click before starting playback, and swallowed
   play() rejections are logged. A source-contract regression test pins
   the invariant (fail-before/pass-after verified).

2. The designer Script text field couldn't be expanded. It was a
   `flex: 1` item in a flex column, so flex-grow recomputed its height
   each reflow and snapped the resize-drag back — `resize: vertical` is
   ignored on a flex-grown item in Chromium/WebView2. The textarea now
   owns its height (flex: 0 1 auto + a taller min-height) so the corner
   grip grows it reliably on every platform.

Gates: `bun run build` and `bunx vitest run` (563 tests) both pass.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-22 05:10:49 +05:30
committed by GitHub
co-authored by mergetest Claude Opus 4.8
parent 2767e2995d
commit 2ad83f37fd
4 changed files with 89 additions and 9 deletions
+18
View File
@@ -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
+16 -4
View File
@@ -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();
}
@@ -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);
}
});
});
+9 -5
View File
@@ -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; }