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
5 changes: 5 additions & 0 deletions src/frontend/src/components/LivePip.astro
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ import LiveSvg from '@assets/icons/live.svg';

const doc = pipWindow.document;
const iframe = doc.createElement('iframe');
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
iframe.src = embed.url;
iframe.allow = embed.allow;
iframe.setAttribute('aria-label', embed.label);
Expand Down Expand Up @@ -370,6 +371,10 @@ import LiveSvg from '@assets/icons/live.svg';
width: 480,
height: 270,
});
// Give the initial about:blank document the opener's URL so embeds send a Referer.
// Do this before registering listeners: document.open() clears them.
pipWindow.document.open();
pipWindow.document.close();
Comment on lines +376 to +377

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a hack. Is this the real way to handle the problem?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair question. This is a standards-defined workaround for the missing referrer, not a YouTube- or Chrome-documented PiP recipe. I should distinguish those rather than imply this is the official PiP solution.

The relevant behavior is explicit in the HTML specification's document open steps:

  • It obtains entryDocument from the calling environment and requires the target document to be same-origin.
  • For a fully active target document, it says: “Let newURL be a copy of entryDocument's URL.” If the documents differ, it removes the fragment, then runs the URL and history update steps for the target document with that URL.
  • It sets the target document's “is initial about:blank” flag to false.
  • It clears the target document's contents and its DOM/window event listeners, then creates an HTML parser. document.close() closes that input stream; we subsequently create the iframe through the DOM API, without document.write() or HTML string injection.

That URL change is the part we need: PiP initially contains an about:blank document, and the direct YouTube embed request had no HTTP Referer. Calling open() from the opener gives the PiP document the opener's URL, allowing Chromium to generate the header normally. The explicit strict-origin-when-cross-origin policy sends only our origin to YouTube, not the documentation path or query. This satisfies YouTube's embedded-player identification requirement.

The destructive effects are why these calls run only on the newly created, empty PiP document, before registering the pagehide listener or adding the iframe—never during provider switches or reuse of an existing PiP window.

I compared alternatives in a real native PiP window against the livestream. Referrer policy alone, adding origin, a srcdoc wrapper, and a blob wrapper all still produced Error 153. A hosted same-origin wrapper also worked, but adds a page/request and nested iframe. The open/close initialization restored Referer: https://aspire.dev/ and actual playback without that extra structure.

The new tests use a real native PiP window and inspect the browser-generated request, stubbing only the provider response. Both video-ID and channel-fallback cases failed with a null referrer before the fix and pass after it; they also cover provider switching, client navigation, and close/reopen cleanup. On that evidence, I prefer this smaller, standards-defined workaround to the hosted wrapper.

Auto-replied by the GitHub Copilot app

state.pipWindow = pipWindow;
state.activeEmbedKey = '';
pipWindow.addEventListener(
Expand Down
105 changes: 105 additions & 0 deletions src/frontend/tests/e2e/live-pip.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { expect, test, type Request } from '@playwright/test';
import { dismissCookieConsentIfVisible } from '@tests/e2e/helpers';

// Native Document PiP needs the full browser rather than headless shell.
test.use({ channel: 'chromium' });

for (const videoId of ['DtdP0JBawPI', null]) {
test(`native YouTube PiP sends a referrer for the ${videoId ? 'video' : 'channel fallback'}`, async ({
page,
context,
isMobile,
}) => {
test.skip(isMobile, 'Native Document Picture-in-Picture requires a desktop browser.');

const snapshot = {
isLive: true,
primarySource: 'twitch',
twitch: { live: true, channel: 'aspiredotdev' },
youtube: { live: true, videoId },
updatedAt: new Date().toISOString(),
};
await page.route(/\/api\/live\/?$/, (route) => route.fulfill({ json: snapshot }));
await page.route(/\/api\/live\/stream\/?$/, (route) =>
route.fulfill({
contentType: 'text/event-stream',
body: `event: state\ndata: ${JSON.stringify(snapshot)}\n\n`,
})
);

// Stub only the provider response, not PiP: Chromium must generate the real request.
const embeds: Request[] = [];
await context.route(
/^https:\/\/(?:www\.youtube-nocookie\.com\/embed\/|player\.twitch\.tv\/)/,
async (route) => {
embeds.push(route.request());
await route.fulfill({
contentType: 'text/html',
body: '<!doctype html><title>Provider player</title><p>Provider player</p>',
});
}
);

await page.goto('/');
await dismissCookieConsentIfVisible(page);
expect(await page.evaluate(() => 'documentPictureInPicture' in window)).toBe(true);
const expectedReferrer = new URL(page.url()).origin + '/';
const liveButton = () => page.locator('.live-btn:visible').first();
const menu = page.getByRole('dialog', { name: 'Choose how to watch' });
const youtube = menu.getByRole('button', { name: /Open YouTube Picture-in-Picture/ });
const twitch = menu.getByRole('button', { name: /Open Twitch Picture-in-Picture/ });

await expect(liveButton()).toHaveAttribute('data-source', 'both');
await liveButton().click();
const opened = context.waitForEvent('page');
await youtube.click();
const pip = await opened;

await expect.poll(() => embeds.length).toBe(1);
expect(await embeds[0].headerValue('referer')).toBe(expectedReferrer);
const embedUrl = new URL(embeds[0].url());
expect(embedUrl.origin).toBe('https://www.youtube-nocookie.com');
expect(embedUrl.pathname).toBe(`/embed/${videoId ?? 'live_stream'}`);
if (!videoId) {
expect(embedUrl.searchParams.get('channel')).toBe('UCW_UJkc7RhM_NPcDXnOCfrQ');
}
expect(embedUrl.searchParams.get('autoplay')).toBe('1');
expect(embedUrl.searchParams.get('mute')).toBe('1');
await expect(liveButton()).toHaveAttribute('data-pip-open', 'true');

await page.locator('header a[href="/docs/"]:visible').click();
await expect(page).toHaveURL(/\/docs\/$/);
await expect(liveButton()).toHaveAttribute('data-pip-open', 'true');
expect(pip.isClosed()).toBe(false);
await liveButton().click();
await youtube.click();
expect(embeds).toHaveLength(1);

await liveButton().click();
await twitch.click();
await expect.poll(() => embeds.length).toBe(2);
const twitchUrl = new URL(embeds[1].url());
expect(twitchUrl.origin).toBe('https://player.twitch.tv');
expect(twitchUrl.searchParams.get('parent')).toBe(new URL(page.url()).hostname);

await liveButton().click();
await youtube.click();
await expect.poll(() => embeds.length).toBe(3);
expect(embeds[2].url()).toBe(embeds[0].url());
expect(await embeds[2].headerValue('referer')).toBe(expectedReferrer);

await pip.close();
await expect(liveButton()).toHaveAttribute('data-pip-open', 'false');
await expect(liveButton()).toHaveAttribute('data-live', 'true');
await expect(page).toHaveURL(/\/docs\/$/);

await liveButton().click();
const reopened = context.waitForEvent('page');
await youtube.click();
const nextPip = await reopened;
await expect.poll(() => embeds.length).toBe(4);
expect(await embeds[3].headerValue('referer')).toBe(expectedReferrer);
await nextPip.close();
await expect(liveButton()).toHaveAttribute('data-pip-open', 'false');
});
}
Loading