From 080df2d211a67fb8b40c631aaccd3e3068d1c04d Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Mon, 12 Oct 2026 13:55:13 +0200 Subject: [PATCH] Let the video tab's Play start at the playhead and reset its button Two faults in the video tab. A click into the ruler while a clip played stopped the playback, but the button kept showing "Stop". And Play always started at the start of the clip, wherever the playhead stood. - The tab now reports when the ruler stops the playback, and the button switches back to "Play". - Play starts at the playhead when it lies inside the clip. Without a selected clip the whole video counts. Outside or at the end, Play starts at the beginning. - So Stop and Play continue where the playback stopped. A clip that ran to its end without loop starts again at the beginning. Tests: gate on melektaus-atx green. e2e 66 ALL PASSED, 32 checks (2 new in 66e-i). With both fixes taken back, both checks are red with exactly the reported faults: the button shows "Stop", and Play runs from 0.71 s, i.e. from the clip start at 0.6 s. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/editor/debug/videoHooks.ts | 1 + src/editor/video/VideoCanvas.ts | 43 ++++++++++++++++++++++++--------- src/editor/video/VideoEditor.ts | 2 +- tools/e2e/66-video.mjs | 25 +++++++++++++++++++ 5 files changed, 60 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 8b3238ee..19993e94 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2039 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2041 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/debug/videoHooks.ts b/src/editor/debug/videoHooks.ts index de60cadd..8af935c9 100644 --- a/src/editor/debug/videoHooks.ts +++ b/src/editor/debug/videoHooks.ts @@ -28,6 +28,7 @@ export function videoHooks({ state, editor }: HookContext) { /** The middle of the clip strip, and of the track under it, at a second of the video. */ stripAt: (seconds: number) => videoEditor.canvas.stripAt(seconds), trackAt: (seconds: number) => videoEditor.canvas.trackAt(seconds), + rulerAt: (seconds: number) => videoEditor.canvas.rulerAt(seconds), /** The time's zoom, and whether a transition is on its way. */ zoom: () => videoEditor.canvas.zoomInfo(), }, diff --git a/src/editor/video/VideoCanvas.ts b/src/editor/video/VideoCanvas.ts index dbfb80e5..534e38b3 100644 --- a/src/editor/video/VideoCanvas.ts +++ b/src/editor/video/VideoCanvas.ts @@ -129,7 +129,7 @@ export class VideoCanvas extends Widget { /** The span the player is held to while it plays: the picked clip, else the whole video. */ private playing: CutSpan | null = null; onTogglePlay: () => void = () => {}; - /** The player stopped by itself (the end of a clip that does not repeat). */ + /** The player stopped by itself (the end of a clip that does not repeat) or by a click in the ruler. */ onStopped: () => void = () => {}; constructor(private readonly state: EditorState) { @@ -202,28 +202,43 @@ export class VideoCanvas extends Widget { return this.playing !== null; } - /** Plays the picked clip, else the whole video, from its start, with the video's volume and loop. */ + /** + * Plays the picked clip, else the whole video, with the video's volume and loop: from the + * playhead when it stands inside, else from the start. + */ play(): void { const asset = this.asset(); const span = this.shownSpan(); const element = asset && this.player(asset); - if (!asset || !span || !element) return; + const preview = this.preview; + if (!asset || !span || !element || !preview) return; this.playing = span; element.muted = false; element.volume = videoVolumeOf(asset.props); // A moment still wanted from scrubbing would send it back after the start. - if (this.preview) this.preview.chase.wanted = null; - element.currentTime = span.start; + preview.chase.wanted = null; + const head = preview.asked; + element.currentTime = head >= span.start && head < span.end - LEAST_SPAN ? head : span.start; void element.play().catch(() => {}); this.invalidate(); } stop(): void { + const preview = this.preview; + // The playhead stays where it stopped; the next Play goes on from there. + if (this.playing && preview) preview.asked = preview.element.currentTime; this.playing = null; - this.preview?.element.pause(); + preview?.element.pause(); this.invalidate(); } + /** Stops from inside the canvas, and tells the header so its button follows. */ + private halt(): void { + if (!this.isPlaying) return; + this.stop(); + this.onStopped(); + } + /** Where the tab's player stands, in seconds of the video; null while nothing is shown. */ get playhead(): number | null { return this.preview && this.preview.file === this.asset()?.file ? this.preview.element.currentTime : null; @@ -239,10 +254,7 @@ export class VideoCanvas extends Widget { if (videoLoopsOf(asset.props)) { element.currentTime = span.start; if (element.paused) void element.play().catch(() => {}); - } else { - this.stop(); - this.onStopped(); - } + } else this.halt(); } this.redraw(); } @@ -277,6 +289,15 @@ export class VideoCanvas extends Widget { }; } + /** The middle of the ruler at a second of the video, on screen (test hook). */ + rulerAt(seconds: number): Vec2 { + const origin = this.getGlobalPosition(); + return { + x: origin.x + secondsToX(this.view, seconds), + y: origin.y + this.timelineTop + RULER_HEIGHT / 2, + }; + } + /** The middle of the track at a second of the video, on screen (test hook). */ trackAt(seconds: number): Vec2 { const origin = this.getGlobalPosition(); @@ -418,7 +439,7 @@ export class VideoCanvas extends Widget { const from = Math.max(0, Math.min(asset.duration, xToSeconds(this.view, p.x))); if (y < RULER_HEIGHT) { this.gesture = { kind: 'seek' }; - this.stop(); + this.halt(); this.seekTo(from); return; } diff --git a/src/editor/video/VideoEditor.ts b/src/editor/video/VideoEditor.ts index 7629283a..9ec62437 100644 --- a/src/editor/video/VideoEditor.ts +++ b/src/editor/video/VideoEditor.ts @@ -24,7 +24,7 @@ export class VideoEditor extends AssetTabEditor { this.playButton = new Button('Play', { icon: TOOLBAR_ICON_SVG.play, minWidth: 72, - tooltip: 'Play the picked clip, else the whole video (Space)', + tooltip: 'Play the picked clip, else the whole video, from the playhead (Space)', onClick: () => this.togglePlay(), }); this.downloadButton = new Button('Download', { diff --git a/tools/e2e/66-video.mjs b/tools/e2e/66-video.mjs index a22420b0..62d8fbdb 100644 --- a/tools/e2e/66-video.mjs +++ b/tools/e2e/66-video.mjs @@ -159,6 +159,31 @@ export async function video(t) { await click(playButton.x, playButton.y); check('Stop holds it', (await json('__gm.videoTab.playing()')) === false); + // 66e-i. A click in the ruler while it plays stops it, and the header's button says Play again. + // Play then goes on from that moment, not from the clip's start. + const tabState = + '({ playing: __gm.videoTab.playing(), head: __gm.videoTab.playhead(), button: __gm.videoTab.playButton().text })'; + await click(playButton.x, playButton.y); + await sleep(150); + const ruler = await json('__gm.videoTab.rulerAt(1)'); + await click(ruler.x, ruler.y); + await sleep(150); + const halted = await json(tabState); + await click(playButton.x, playButton.y); + await sleep(150); + const resumed = await json(tabState); + await click(playButton.x, playButton.y); + check( + 'a click in the ruler stops the play and its button', + halted.playing === false && halted.button === 'Play' && Math.abs(halted.head - 1) < 0.05, + JSON.stringify(halted), + ); + check( + 'Play goes on from the playhead', + resumed.playing && resumed.button === 'Stop' && resumed.head >= 1 && resumed.head < 1.4, + JSON.stringify(resumed), + ); + // 66e-ii. With the clip picked (#403), a drag in the track off its handles or in the strip beside // its band changes nothing; one on its band moves it whole, one undo step. A click in the // track beside it picks the video, on it the clip again.