Skip to content
SlashClone

Embed YouTube videos without tracking cookies

@slashclone/privacy-friendly-youtube-embed

Embed YouTube videos on your site without loading anything from Google until the visitor presses Play or allows videos. A click-to-play placeholder with a local poster swaps in the youtube-nocookie.com player, with a keyboard-friendly button, no layout shift, CSP entries, and a Playwright test that proves no YouTube request happens first. React and HTML templates. Not legal advice.

SkillDevOpsv0.1.0
Activity
New: be the first to try it
  1. 1// Playwright test: no request to YouTube or Google before the visitor presses Play.
  2. 2// Set PAGE to a page of yours that renders one embed. Runs offline: requests to
  3. 3// the player are answered locally, so the test never reaches YouTube.
  4. 4
  5. 5import { expect, test, type Request } from "@playwright/test";
  6. 6
  7. 7const PAGE = "/video-example";
  8. 8
  9. 9// Hosts that belong to the video provider or its parent company.
  10. 10const PROVIDER =
  11. 11 /(^|\.)(youtube|youtube-nocookie|ytimg|googlevideo|ggpht|google|gstatic|doubleclick|googleapis)\.(com|net)$/;
  12. 13function providerRequests(requests: Request[]): string[] {
  13. 14 return requests.map((r) => r.url()).filter((url) => PROVIDER.test(new URL(url).hostname));
  14. 17test.beforeEach(async ({ context }) => {
  15. 18 // Start every test as a new visitor with no consent stored.
  16. 19 await context.clearCookies();
  17. 20 await context.route(
  18. 21 (url) => PROVIDER.test(url.hostname),
  19. 22 (route) =>
  20. 23 route.fulfill({
  21. 24 status: 200,
  22. 25 contentType: "text/html",
  23. 26 body: "<!doctype html><title>player</title>",
  24. 27 }),
  25. 28 );
  26. 29});
  27. 31test("nothing loads from YouTube before the click", async ({ page }) => {
  28. 32 const requests: Request[] = [];
  29. 33 page.on("request", (r) => requests.push(r));
  30. 35 await page.goto(PAGE);
  31. 36 await page.waitForLoadState("networkidle");
  32. 37 // Scroll the placeholder into view too, so lazy loading cannot hide a request.
  33. 38 await page.getByRole("button", { name: /^Play video/ }).scrollIntoViewIfNeeded();
  34. 39 await page.waitForLoadState("networkidle");
  35. 41 expect(providerRequests(requests)).toEqual([]);
  36. 42 await expect(page.locator("iframe")).toHaveCount(0);
  37. 43});
  38. 45test("pressing Play with the keyboard loads the privacy-enhanced player and moves focus", async ({
  39. 46 page,
  40. 47}) => {
  41. 48 await page.goto(PAGE);
  42. 49 const play = page.getByRole("button", { name: /^Play video/ });
  43. 50 await play.focus();
  44. 51 await page.keyboard.press("Enter");
  45. 53 const frame = page.locator("iframe");
  46. 54 await expect(frame).toHaveCount(1);
  47. 55 const src = new URL((await frame.getAttribute("src")) ?? "");
  48. 56 expect(src.hostname).toBe("www.youtube-nocookie.com");
  49. 57 expect(src.searchParams.get("autoplay")).toBe("1");
  50. 58 await expect(frame).toHaveAttribute("title", /.+/);
  51. 59 await expect(frame).toBeFocused();
  52. 60});
  53. 62test("the box keeps its size when the player replaces the placeholder", async ({ page }) => {
  54. 63 await page.goto(PAGE);
  55. 64 const play = page.getByRole("button", { name: /^Play video/ });
  56. 65 // The 16:9 box: [data-yt-box] in the React template, .yt-embed in the plain one.
  57. 66 const box = page.locator("[data-yt-box], .yt-embed").first();
  58. 67 const before = await box.boundingBox();
  59. 68 await play.click();
  60. 69 await expect(page.locator("iframe")).toHaveCount(1);
  61. 70 const after = await box.boundingBox();
  62. 71 expect(before && after).toBeTruthy();
  63. 72 expect(Math.abs((before?.height ?? 0) - (after?.height ?? 0))).toBeLessThan(2);
  64. 73});

Cookies that keep you signed in and remember these choices are always on. Everything else is off until you turn it on.