Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/player.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 42 additions & 14 deletions src/lib/player/Player.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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));
Expand All @@ -610,6 +615,7 @@
const bounds = minimal ? { ...activeBounds } : { start: 0, end: duration };
return {
begin() {
startupPending = false;
resetFrameStepping();
endEndpointPreview();
navigationScrub = saved;
Expand Down Expand Up @@ -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) &&
Expand Down Expand Up @@ -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.
Expand All @@ -771,14 +780,16 @@
}
}

function handleMetadata() {
function handleMetadata(event?: Event) {
if (event && event.currentTarget !== player) return;
readVideoTitle();
if (!Number.isFinite(player.duration) || player.duration <= 0) return;
if (metadataReceived) {
scheduleDuration();
return;
}
metadataReceived = true;
startupPending = true;
initialFramePending = true;
bootstrapMuted = true;
player.muted = true;
Expand All @@ -787,23 +798,25 @@
// 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();
playerError = `First frame unavailable: ${String(error)}`;
});
}

function handlePlaying() {
function handlePlaying(event?: Event) {
if (event && event.currentTarget !== player) return;
if (navigationScrub || endpointPreview || frameStepper.target !== undefined) {
player.pause();
return;
Expand Down Expand Up @@ -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) {
Expand All @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -1009,6 +1035,8 @@
}

function resetSource() {
sourceGeneration += 1;
startupPending = false;
resetFrameStepping();
// A new provider owns bootstrap; never resume the old source from teardown.
discardNavigationScrub();
Expand Down
18 changes: 17 additions & 1 deletion tests/browser/editor-workspace.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
() =>
(
Expand Down
5 changes: 5 additions & 0 deletions tests/browser/signed-ranges.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading