From a0b4a3764e8ae1d30d3d4f73b5f741f7184bef00 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Fri, 3 Jul 2026 22:26:00 -0500 Subject: [PATCH] Support youtube embeds --- package.json | 2 +- src/css/style.css | 54 ++++++ src/img/icons/custom/play.svg | 1 + src/js/components/youtube-embed.js | 74 ++++++++ src/js/templates/icons/playIcon.template.js | 12 ++ src/js/templates/postEmbed.template.js | 71 ++++++++ tests/e2e/mockServer.js | 9 + tests/e2e/specs/concerns/postEmbeds.test.js | 55 ++++++ .../specs/components/youtube-embed.test.js | 94 ++++++++++ .../templates/postEmbed.template.test.js | 170 ++++++++++++++++++ 10 files changed, 541 insertions(+), 1 deletion(-) create mode 100644 src/img/icons/custom/play.svg create mode 100644 src/js/components/youtube-embed.js create mode 100644 src/js/templates/icons/playIcon.template.js create mode 100644 tests/unit/specs/components/youtube-embed.test.js diff --git a/package.json b/package.json index 0842be98..5824348c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.17.93", + "version": "0.17.94", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/css/style.css b/src/css/style.css index 9dfe3a8a..b542facb 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -36,6 +36,7 @@ body, html { /* colors */ --white: #ffffff; + --black: #000000; --less-white: #fcfcfc; --off-white: rgb(241, 243, 245); --dark-gray: #1a1a1a; @@ -2360,6 +2361,59 @@ animated-button { object-fit: contain; } +.youtube-embed { + display: block; + background: var(--black); +} + +.youtube-embed-play-button { + display: block; + position: relative; + width: 100%; + height: 100%; + padding: 0; + border: none; + background: none; + cursor: pointer; +} + +.youtube-embed-thumb { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.youtube-embed-play-button .play-icon { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 56px; + height: 56px; + color: var(--black); + background: color-mix(in srgb, var(--white) 70%, transparent); + border-radius: 50%; + transition: background 0.15s ease; +} + +.youtube-embed-play-button:hover .play-icon { + background: color-mix(in srgb, var(--white) 85%, transparent); +} + +.youtube-embed-play-button .play-icon svg { + width: 100%; + height: 100%; + display: block; +} + +.youtube-embed-iframe { + display: block; + width: 100%; + height: 100%; + border: none; +} + .alt-indicator { pointer-events: none; position: absolute; diff --git a/src/img/icons/custom/play.svg b/src/img/icons/custom/play.svg new file mode 100644 index 00000000..14ec2610 --- /dev/null +++ b/src/img/icons/custom/play.svg @@ -0,0 +1 @@ + diff --git a/src/js/components/youtube-embed.js b/src/js/components/youtube-embed.js new file mode 100644 index 00000000..4a2a14b4 --- /dev/null +++ b/src/js/components/youtube-embed.js @@ -0,0 +1,74 @@ +import { html, render } from "/js/lib/lit-html.js"; +import { Component } from "/js/components/component.js"; +import { playIconTemplate } from "/js/templates/icons/playIcon.template.js"; + +const YOUTUBE_EMBED_BASE_URL = "https://www.youtube-nocookie.com/embed"; + +class YoutubeEmbed extends Component { + connectedCallback() { + if (this._initialized) { + return; + } + this.videoId = this.getAttribute("video-id"); + this.start = this.getAttribute("start"); + this.thumb = this.getAttribute("thumb"); + this.videoTitle = this.getAttribute("video-title") ?? ""; + this.playing = false; + this.dataset.teststate = "preview"; + this.render(); + this._initialized = true; + } + + getPlayerSrc() { + const startSeconds = /^\d+$/.test(this.start ?? "") ? this.start : "0"; + return `${YOUTUBE_EMBED_BASE_URL}/${encodeURIComponent( + this.videoId, + )}?autoplay=1&start=${startSeconds}&rel=0&playsinline=1`; + } + + play() { + this.playing = true; + this.dataset.teststate = "playing"; + this.render(); + this.querySelector("iframe")?.focus(); + } + + render() { + render( + this.playing + ? html`` + : html``, + this, + ); + } +} + +YoutubeEmbed.register(); diff --git a/src/js/templates/icons/playIcon.template.js b/src/js/templates/icons/playIcon.template.js new file mode 100644 index 00000000..cd72dc3a --- /dev/null +++ b/src/js/templates/icons/playIcon.template.js @@ -0,0 +1,12 @@ +import { html } from "/js/lib/lit-html.js"; + +export function playIconTemplate() { + return html`
+ + + +
`; +} diff --git a/src/js/templates/postEmbed.template.js b/src/js/templates/postEmbed.template.js index be1e5169..effe6845 100644 --- a/src/js/templates/postEmbed.template.js +++ b/src/js/templates/postEmbed.template.js @@ -23,6 +23,7 @@ import "/js/components/lightbox-image-group.js"; import "/js/components/streaming-video.js"; import "/js/components/moderation-warning.js"; import "/js/components/image-carousel.js"; +import "/js/components/youtube-embed.js"; import { chatJoinLinkEmbedTemplate } from "/js/templates/chatJoinLinkEmbed.template.js"; function galleryItemsToImages(items) { @@ -367,6 +368,72 @@ function gifPlayerTemplate({ uri, alt, aspectRatio = null }) { `; } +const YOUTUBE_VIDEO_ID_REGEX = /^[A-Za-z0-9_-]+$/; +const YOUTUBE_HOSTNAMES = [ + "www.youtube.com", + "youtube.com", + "m.youtube.com", + "music.youtube.com", +]; + +function parseYouTubeVideoFromUrl(url) { + if (!url) return null; + let parsedUrl; + try { + parsedUrl = new URL(url); + } catch { + return null; + } + let videoId = null; + let isShort = false; + if (parsedUrl.hostname === "youtu.be") { + videoId = parsedUrl.pathname.split("/")[1]; + } else if (YOUTUBE_HOSTNAMES.includes(parsedUrl.hostname)) { + const [, page, pathVideoId] = parsedUrl.pathname.split("/"); + isShort = page === "shorts"; + if (isShort || page === "live") { + videoId = pathVideoId; + } else { + videoId = parsedUrl.searchParams.get("v"); + } + } + if (!videoId || !YOUTUBE_VIDEO_ID_REGEX.test(videoId)) return null; + const startTime = parseYouTubeStartTime(parsedUrl.searchParams.get("t")); + return { videoId, startTime, isShort }; +} + +function parseYouTubeStartTime(rawValue) { + if (!rawValue) return 0; + const match = rawValue.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/); + if (!match || !(match[1] || match[2] || match[3])) return 0; + const [, hours, minutes, seconds] = match; + return ( + Number(hours ?? 0) * 3600 + Number(minutes ?? 0) * 60 + Number(seconds ?? 0) + ); +} + +const YOUTUBE_VIDEO_ASPECT_RATIO = 16 / 9; +const YOUTUBE_SHORT_ASPECT_RATIO = 9 / 16; + +function youtubeEmbedTemplate({ youtubeVideo, external }) { + const aspectRatio = youtubeVideo.isShort + ? YOUTUBE_SHORT_ASPECT_RATIO + : YOUTUBE_VIDEO_ASPECT_RATIO; + return html` { + e.stopPropagation(); + e.preventDefault(); + }} + >`; +} + function isTenorGifUrl(url) { try { const parsedUrl = new URL(url); @@ -440,6 +507,10 @@ function externalTemplate({ external, lazyLoadImages }) { alt: external.description, }); } + const youtubeVideo = parseYouTubeVideoFromUrl(external.uri); + if (youtubeVideo) { + return youtubeEmbedTemplate({ youtubeVideo, external }); + } return externalLinkTemplate({ url: external.uri, title: external.title, diff --git a/tests/e2e/mockServer.js b/tests/e2e/mockServer.js index 3ab04f97..12c8cd1f 100644 --- a/tests/e2e/mockServer.js +++ b/tests/e2e/mockServer.js @@ -294,6 +294,15 @@ export class MockServer { }), ); + // Stub the YouTube embed player so tests don't hit the network. + await page.route("https://www.youtube-nocookie.com/**", (route) => + route.fulfill({ + status: 200, + contentType: "text/html", + body: "", + }), + ); + // Stub emoji-picker-element's CDN data fetch so tests don't hit the // network. Returns a tiny fixture with one emoji per group the library // requires (its assertEmojiData rejects an empty list). diff --git a/tests/e2e/specs/concerns/postEmbeds.test.js b/tests/e2e/specs/concerns/postEmbeds.test.js index 8f71f64c..41dd9c59 100644 --- a/tests/e2e/specs/concerns/postEmbeds.test.js +++ b/tests/e2e/specs/concerns/postEmbeds.test.js @@ -346,6 +346,61 @@ test.describe("Post embeds view — gif", () => { }); }); +test.describe("Post embeds view — YouTube", () => { + function buildYouTubePost(uri) { + return buildPost({ + embed: { + $type: "app.bsky.embed.external#view", + external: { + uri, + title: "A video", + description: "A description", + thumb: "", + }, + }, + }); + } + + test("renders a click-to-play embed and loads the player on click", async ({ + page, + }) => { + await setupSinglePostThread( + page, + buildYouTubePost("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=32s"), + ); + + const view = page.locator("#post-detail-view"); + const embed = view.locator('[data-testid="youtube-embed"]'); + await expect(embed).toBeVisible({ timeout: 10000 }); + await expect(embed).toHaveAttribute("data-teststate", "preview"); + await expect( + view.locator('[data-testid="youtube-embed-iframe"]'), + ).toHaveCount(0); + + await view.locator('[data-testid="youtube-embed-play"]').click(); + + const iframe = view.locator('[data-testid="youtube-embed-iframe"]'); + await expect(iframe).toBeVisible(); + await expect(iframe).toHaveAttribute( + "src", + /youtube-nocookie\.com\/embed\/dQw4w9WgXcQ\?autoplay=1&start=32/, + ); + await expect(embed).toHaveAttribute("data-teststate", "playing"); + }); + + test("renders a plain link card for non-video YouTube URLs", async ({ + page, + }) => { + await setupSinglePostThread(page, buildYouTubePost("https://youtube.com/")); + + const view = page.locator("#post-detail-view"); + await expect(view.locator('[data-testid="external-link"]')).toBeVisible({ + timeout: 10000, + }); + await expect(view.locator('[data-testid="youtube-embed"]')).toHaveCount(0); + }); +}); + test.describe("Post embeds view — external link", () => { test("renders title, description, and domain", async ({ page }) => { const post = buildPost({ diff --git a/tests/unit/specs/components/youtube-embed.test.js b/tests/unit/specs/components/youtube-embed.test.js new file mode 100644 index 00000000..7c318f6d --- /dev/null +++ b/tests/unit/specs/components/youtube-embed.test.js @@ -0,0 +1,94 @@ +import { TestSuite } from "../../testSuite.js"; +import { assert, assertEquals } from "../../testHelpers.js"; +import "/js/components/youtube-embed.js"; + +const t = new TestSuite("YoutubeEmbed"); + +t.beforeEach(() => { + document.body.innerHTML = ""; +}); + +function createEmbed({ start = "0", thumb = "" } = {}) { + const element = document.createElement("youtube-embed"); + element.setAttribute("video-id", "dQw4w9WgXcQ"); + element.setAttribute("start", start); + element.setAttribute("thumb", thumb); + element.setAttribute("video-title", "Test video"); + document.body.appendChild(element); + return element; +} + +t.describe("YoutubeEmbed - preview state", (it) => { + it("renders a play button and no iframe", () => { + const element = createEmbed(); + assert( + element.querySelector("[data-testid='youtube-embed-play']") !== null, + ); + assertEquals(element.querySelector("iframe"), null); + assertEquals(element.dataset.teststate, "preview"); + }); + + it("renders the thumbnail when provided", () => { + const element = createEmbed({ thumb: "https://example.com/thumb.jpg" }); + const thumb = element.querySelector(".youtube-embed-thumb"); + assert(thumb !== null); + assertEquals(thumb.getAttribute("src"), "https://example.com/thumb.jpg"); + }); + + it("renders no thumbnail image when thumb is empty", () => { + const element = createEmbed(); + assertEquals(element.querySelector(".youtube-embed-thumb"), null); + assert( + element.querySelector("[data-testid='youtube-embed-play']") !== null, + ); + }); + + it("labels the play button with the video title", () => { + const element = createEmbed(); + const button = element.querySelector("[data-testid='youtube-embed-play']"); + assertEquals( + button.getAttribute("aria-label"), + "Play YouTube video: Test video", + ); + }); +}); + +t.describe("YoutubeEmbed - playing state", (it) => { + it("swaps in the player iframe when the play button is clicked", () => { + const element = createEmbed({ start: "32" }); + element.querySelector("[data-testid='youtube-embed-play']").click(); + const iframe = element.querySelector( + "[data-testid='youtube-embed-iframe']", + ); + assert(iframe !== null); + assertEquals( + iframe.getAttribute("src"), + "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1&start=32&rel=0&playsinline=1", + ); + assertEquals(element.dataset.teststate, "playing"); + assertEquals( + element.querySelector("[data-testid='youtube-embed-play']"), + null, + ); + }); + + it("moves focus to the iframe when the play button is clicked", () => { + const element = createEmbed(); + element.querySelector("[data-testid='youtube-embed-play']").click(); + assertEquals( + document.activeElement, + element.querySelector("[data-testid='youtube-embed-iframe']"), + ); + }); + + it("falls back to start=0 for a non-numeric start attribute", () => { + const element = createEmbed({ start: "1m30s" }); + element.querySelector("[data-testid='youtube-embed-play']").click(); + const iframe = element.querySelector( + "[data-testid='youtube-embed-iframe']", + ); + assert(iframe.getAttribute("src").includes("start=0")); + }); +}); + +await t.run(); diff --git a/tests/unit/specs/templates/postEmbed.template.test.js b/tests/unit/specs/templates/postEmbed.template.test.js index 2b84e34a..93d0611c 100644 --- a/tests/unit/specs/templates/postEmbed.template.test.js +++ b/tests/unit/specs/templates/postEmbed.template.test.js @@ -374,6 +374,176 @@ t.describe("postEmbedTemplate - external links", (it) => { }); }); +t.describe("postEmbedTemplate - external YouTube", (it) => { + function youtubeExternalEmbed(uri) { + return { + $type: "app.bsky.embed.external#view", + external: { + uri, + title: "A video", + description: "A description", + thumb: "https://example.com/thumb.jpg", + }, + }; + } + + it("renders a youtube-embed for a watch URL", () => { + const container = renderEmbed( + youtubeExternalEmbed("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=32s"), + ); + const embedElement = container.querySelector( + "[data-testid='youtube-embed']", + ); + assert(embedElement !== null); + assertEquals(embedElement.getAttribute("video-id"), "dQw4w9WgXcQ"); + assertEquals(embedElement.getAttribute("start"), "32"); + assertEquals( + embedElement.getAttribute("thumb"), + "https://example.com/thumb.jpg", + ); + assertEquals( + container.querySelector("[data-testid='external-link']"), + null, + ); + }); + + it("uses a 16:9 aspect ratio for regular videos", () => { + const container = renderEmbed( + youtubeExternalEmbed("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), + ); + const embedElement = container.querySelector( + "[data-testid='youtube-embed']", + ); + assertEquals(embedElement.style.aspectRatio, String(16 / 9)); + }); + + it("uses a portrait aspect ratio for shorts", () => { + const container = renderEmbed( + youtubeExternalEmbed("https://youtube.com/shorts/dQw4w9WgXcQ"), + ); + const embedElement = container.querySelector( + "[data-testid='youtube-embed']", + ); + assertEquals(embedElement.style.aspectRatio, String(9 / 16)); + }); + + it("renders watch URLs on all YouTube hostnames", () => { + const hostnames = [ + "www.youtube.com", + "youtube.com", + "m.youtube.com", + "music.youtube.com", + ]; + for (const hostname of hostnames) { + const container = renderEmbed( + youtubeExternalEmbed(`https://${hostname}/watch?v=dQw4w9WgXcQ`), + ); + const embedElement = container.querySelector( + "[data-testid='youtube-embed']", + ); + assert(embedElement !== null); + assertEquals(embedElement.getAttribute("video-id"), "dQw4w9WgXcQ"); + } + }); + + it("renders youtu.be links", () => { + const container = renderEmbed( + youtubeExternalEmbed("https://youtu.be/dQw4w9WgXcQ"), + ); + const embedElement = container.querySelector( + "[data-testid='youtube-embed']", + ); + assert(embedElement !== null); + assertEquals(embedElement.getAttribute("video-id"), "dQw4w9WgXcQ"); + assertEquals(embedElement.getAttribute("start"), "0"); + }); + + it("renders live URLs as regular videos", () => { + const container = renderEmbed( + youtubeExternalEmbed("https://youtube.com/live/dQw4w9WgXcQ"), + ); + const embedElement = container.querySelector( + "[data-testid='youtube-embed']", + ); + assert(embedElement !== null); + assertEquals(embedElement.getAttribute("video-id"), "dQw4w9WgXcQ"); + assertEquals(embedElement.style.aspectRatio, String(16 / 9)); + }); + + it("converts hour/minute/second start times to seconds", () => { + const cases = [ + ["1m30s", "90"], + ["1h2m3s", "3723"], + ["1h", "3600"], + ["45s", "45"], + ]; + for (const [rawStartTime, expectedSeconds] of cases) { + const container = renderEmbed( + youtubeExternalEmbed(`https://youtu.be/dQw4w9WgXcQ?t=${rawStartTime}`), + ); + const embedElement = container.querySelector( + "[data-testid='youtube-embed']", + ); + assertEquals(embedElement.getAttribute("start"), expectedSeconds); + } + }); + + it("degrades unparseable start times to 0", () => { + for (const rawStartTime of ["abc", "1x30s", "m"]) { + const container = renderEmbed( + youtubeExternalEmbed(`https://youtu.be/dQw4w9WgXcQ?t=${rawStartTime}`), + ); + const embedElement = container.querySelector( + "[data-testid='youtube-embed']", + ); + assertEquals(embedElement.getAttribute("start"), "0"); + } + }); + + it("falls back to the external link card for non-video YouTube URLs", () => { + const uris = [ + "https://youtube.com/", + "https://youtube.com/shorts/", + "https://youtube.com/live/", + "https://youtube.com/random", + ]; + for (const uri of uris) { + const container = renderEmbed(youtubeExternalEmbed(uri)); + assert(container.querySelector("[data-testid='external-link']") !== null); + assertEquals( + container.querySelector("[data-testid='youtube-embed']"), + null, + ); + } + }); + + it("falls back to the external link card for non-YouTube and spoofed hosts", () => { + const uris = [ + "https://example.com/watch?v=dQw4w9WgXcQ", + "https://youtube.com.evil.com/watch?v=dQw4w9WgXcQ", + ]; + for (const uri of uris) { + const container = renderEmbed(youtubeExternalEmbed(uri)); + assert(container.querySelector("[data-testid='external-link']") !== null); + assertEquals( + container.querySelector("[data-testid='youtube-embed']"), + null, + ); + } + }); + + it("falls back to the external link card for video ids with illegal characters", () => { + const container = renderEmbed( + youtubeExternalEmbed('https://www.youtube.com/watch?v=abc"def'), + ); + assert(container.querySelector("[data-testid='external-link']") !== null); + assertEquals( + container.querySelector("[data-testid='youtube-embed']"), + null, + ); + }); +}); + t.describe("postEmbedTemplate - quoted posts", (it) => { it("should render quoted post embed", () => { const embed = { -- 2.51.2