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 = {