From 95471ecae191422a24bcb74b9c8b7e699283320d Mon Sep 17 00:00:00 2001 From: John-Kim Murphy <381217+Leftium@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:16:55 +0900 Subject: [PATCH 1/2] chore: bootstrap preserved startup playback PR From 49ff6626e396e9425e453154066fce70408861ac Mon Sep 17 00:00:00 2001 From: John-Kim Murphy <381217+Leftium@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:28:53 +0900 Subject: [PATCH 2/2] fix(player): restore clip start before first playback --- docs/player.md | 7 +++- src/lib/player/Player.svelte | 56 +++++++++++++++++++------- tests/browser/editor-workspace.spec.ts | 18 ++++++++- tests/browser/signed-ranges.spec.ts | 5 +++ 4 files changed, 70 insertions(+), 16 deletions(-) diff --git a/docs/player.md b/docs/player.md index e6a5f15..958ed07 100644 --- a/docs/player.md +++ b/docs/player.md @@ -116,7 +116,12 @@ Out-of-duration bounds clamp to a nonempty range using the actual provider duration. Invalid links show an error without starting a different video. Default clips repeat; `once` pauses at B. `mute` and `speed` apply to real media -after the existing silent first-frame bootstrap. Browser autoplay policy and +after the existing silent first-frame bootstrap. Its temporary preview seek does +not advance the logical playhead: first Play seeks to resolved A before unmuting +and starting playback. A deliberate seek, scrub, frame step, or scope change +consumes that startup restoration. The clean preview stays paused at the +bootstrap frame until then; YouTube seeking remains approximate. +Browser autoplay policy and YouTube's supported playback rates still govern playback. Fit/Fill and orientation retain automatic detection unless overridden. SSR social cards use canonical video paths and clip bounds, omit playback/framing flags, and fall back safely diff --git a/src/lib/player/Player.svelte b/src/lib/player/Player.svelte index 4b5aad1..99d9cfa 100644 --- a/src/lib/player/Player.svelte +++ b/src/lib/player/Player.svelte @@ -189,7 +189,8 @@ cancelVideoGesture?.(); if (!range.restricted || !player) return; endEndpointPreview(); - const position = frameStepper.target ?? player.currentTime; + const position = frameStepper.target ?? (startupPending ? transportTime : player.currentTime); + startupPending = false; resetFrameStepping(); playbackScope = scopeChoice.toggle(); const target = scopeSwitchTarget(position, activeBounds); @@ -212,6 +213,8 @@ let boundsError = $state(''); let pauseAfterSeek = false; let initialFramePending = false; + let startupPending = false; + let sourceGeneration = 0; let bootstrapMuted = false; let metadataReceived = $state(false); let firstFrameReady = false; @@ -253,7 +256,8 @@ ) endEndpointPreview(); if (duration !== undefined && !validateBounds()) return; - if (navigationScrub || endpointPreview || (!minimal && requestedPaused)) return; + if (startupPending || navigationScrub || endpointPreview || (!minimal && requestedPaused)) + return; if (player.currentTime < activeBounds.start || player.currentTime > activeBounds.end) { seek(activeBounds.start); } @@ -586,6 +590,7 @@ } function setMediaTime(time: number, stayPaused: boolean, preview = false, frameStep = false) { + startupPending = false; if (!frameStep) resetFrameStepping(); pauseAfterSeek = stayPaused; currentTime = Math.max(0, Math.min(duration ?? player.duration, time)); @@ -610,6 +615,7 @@ const bounds = minimal ? { ...activeBounds } : { start: 0, end: duration }; return { begin() { + startupPending = false; resetFrameStepping(); endEndpointPreview(); navigationScrub = saved; @@ -707,8 +713,10 @@ duration = nextDuration; ondurationresolved?.(duration); if (!validateBounds()) return; + if (startupPending) currentTime = transportTime = repeatA; if (minimal) playbackScope = scopeChoice.choose(nextDuration, repeatA, repeatB); if ( + !startupPending && !navigationScrub && !endpointPreview && (minimal || firstDuration || !requestedPaused) && @@ -750,7 +758,8 @@ scheduleDuration(); } - function handlePause() { + function handlePause(event?: Event) { + if (event && event.currentTarget !== player) return; paused = true; // pauseVideo() is asynchronous. Keep the bootstrap silent until the // provider confirms it has stopped, including its final audio samples. @@ -771,7 +780,8 @@ } } - function handleMetadata() { + function handleMetadata(event?: Event) { + if (event && event.currentTarget !== player) return; readVideoTitle(); if (!Number.isFinite(player.duration) || player.duration <= 0) return; if (metadataReceived) { @@ -779,6 +789,7 @@ return; } metadataReceived = true; + startupPending = true; initialFramePending = true; bootstrapMuted = true; player.muted = true; @@ -787,15 +798,16 @@ // An out-of-duration A cannot render a first frame. Bootstrap inside the // provider first; settled duration then clamps the requested range. pauseAfterSeek = true; - currentTime = + const previewTime = !resolveClipRange(requestedBounds, player.duration).ok || repeatA >= player.duration - 1 ? Math.min(1, player.duration / 2) : Math.max(repeatA, Math.min(1, repeatB)); - transportTime = currentTime; - player.currentTime = currentTime; - const version = sourceVersion; + // Keep the rendered preview separate from the logical resume position. + // Rewinding a paused YouTube provider can restore its cued thumbnail. + player.currentTime = previewTime; + const generation = sourceGeneration; void player.play().catch((error) => { - if (version !== sourceVersion) return; + if (generation !== sourceGeneration) return; initialFramePending = false; pause(); if (player.paused) restoreBootstrapMute(); @@ -803,7 +815,8 @@ }); } - function handlePlaying() { + function handlePlaying(event?: Event) { + if (event && event.currentTarget !== player) return; if (navigationScrub || endpointPreview || frameStepper.target !== undefined) { player.pause(); return; @@ -838,7 +851,8 @@ } } - function handleSeeked() { + function handleSeeked(event?: Event) { + if (event && event.currentTarget !== player) return; if (initialFramePending) { if (youtubePlayerState() === YOUTUBE_STATE_PLAYING) handlePlaying(); } else if (pauseAfterSeek) { @@ -852,8 +866,10 @@ handleTimeUpdate(); } - function handleTimeUpdate() { + function handleTimeUpdate(event?: Event) { + if (event && event.currentTarget !== player) return; if (!videoTitle) readVideoTitle(); + if (startupPending) return; if (duration === undefined || boundsError || player.seeking) return; // YouTube observations can arrive from the previous seek. Keep requested // display time until acknowledgement, with a bounded recovery window. @@ -898,6 +914,13 @@ async function play() { resetFrameStepping(); if (boundsError) return; + // Seek before unmuting/play, in the same user activation on mobile. + // Explicit navigation consumes this source-scoped restoration instead. + if (startupPending) { + const bounds = resolveClipRange(requestedBounds, duration ?? player.duration); + if (!bounds.ok) return; + setMediaTime(bounds.start, false); + } requestedPaused = false; pauseAfterSeek = false; initialFramePending = false; @@ -921,8 +944,11 @@ providerStalled = true; retrySeekTime = recoverySeekTarget(); } - const position = frameStepper.target ?? player.currentTime; - if (player.ended || position < activeBounds.start || position >= activeBounds.end) { + const position = frameStepper.target ?? (startupPending ? repeatA : player.currentTime); + if ( + !startupPending && + (player.ended || position < activeBounds.start || position >= activeBounds.end) + ) { seek(activeBounds.start); } else if (frameStepper.target !== undefined) { setMediaTime(position, true); @@ -1009,6 +1035,8 @@ } function resetSource() { + sourceGeneration += 1; + startupPending = false; resetFrameStepping(); // A new provider owns bootstrap; never resume the old source from teardown. discardNavigationScrub(); diff --git a/tests/browser/editor-workspace.spec.ts b/tests/browser/editor-workspace.spec.ts index 8134148..21df807 100644 --- a/tests/browser/editor-workspace.spec.ts +++ b/tests/browser/editor-workspace.spec.ts @@ -277,10 +277,26 @@ mockedTest( { tag: '@youtube-mock' }, async ({ page }) => { await page.setViewportSize({ width: 1280, height: 800 }); - await openEditor(page); + await openEditor(page, `/?v=${id}&t=0~50&mute&speed=1.5&fill=0`); await expectMockBootstrap(page); + await expect(page.getByRole('slider', { name: 'Current time', exact: true })).toHaveAttribute( + 'aria-valuenow', + '0' + ); + // Observe the provider at Play, before timer ticks can conceal a skipped opening. + await page.locator('youtube-video').evaluate((el) => { + const engine = ( + el as HTMLElement & { engine: { playVideo(): void; getCurrentTime(): number } } + ).engine; + const play = engine.playVideo.bind(engine); + engine.playVideo = () => { + el.setAttribute('data-first-play-time', String(engine.getCurrentTime())); + play(); + }; + }); const originalUrl = page.url(); await page.getByRole('button', { name: 'Play', exact: true }).click(); + await expect(page.locator('youtube-video')).toHaveAttribute('data-first-play-time', '0'); await page.waitForFunction( () => ( diff --git a/tests/browser/signed-ranges.spec.ts b/tests/browser/signed-ranges.spec.ts index 1cf5740..ac25ea7 100644 --- a/tests/browser/signed-ranges.spec.ts +++ b/tests/browser/signed-ranges.spec.ts @@ -27,9 +27,14 @@ test('signed fractions survive watch/editor/copy/reload/history and explicit bou await settled(page); const duration = (await state(page)).duration; await expect(page.locator('.watch-timeline')).toHaveAttribute('aria-valuemax', '25.25'); + // The paused bootstrap frame can differ from the intended signed start. + await expect(page.locator('.watch-timeline')).toHaveAttribute('aria-valuenow', '0'); + await page.getByRole('button', { name: 'Play video', exact: true }).press('Enter'); await expect .poll(async () => Math.abs((await state(page)).time - (duration - 30.5))) .toBeLessThan(0.5); + await page.getByRole('button', { name: 'Pause video', exact: true }).press('Enter'); + await settled(page); await expect( page.getByRole('link', { name: 'Switch to edit-first mode', exact: true }) ).toHaveAttribute('href', editor);