feat(embeds): inline YouTube/Vimeo players + media-forward video tiles
Video link tiles (YouTube, Shorts, Vimeo) now play in place instead of only opening a browser tab. Adds a media-forward 16:9 (9:16 for Shorts) tile with a privacy-friendly click-to-play facade: the homeserver's cached og:image thumbnail + a play button, and only on click does it swap in the cookie-less youtube-nocookie / player.vimeo iframe — so nothing loads from Google/Vimeo until the user presses play. Gated by a new 'Inline Media Players' setting (default on); when off it falls back to a link that opens the video in a new tab. Also sources YouTube thumbnails from the homeserver og:image instead of img.youtube.com, which fixes the existing broken YouTube thumbnails on the web build (nginx img-src has no YouTube host) and removes the pre-click Google request. Pure URL parsing + embed-URL building moved to utils/videoEmbed.ts (unit-tested). Note: the desktop app's Tauri CSP frame-src must allow the video hosts (separate commit in cinny-desktop). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
getYouTubeVideoId,
|
||||
getYoutubeShortsId,
|
||||
isYouTubeShorts,
|
||||
getVimeoVideoId,
|
||||
parseVideoEmbed,
|
||||
buildVideoEmbedUrl,
|
||||
} from './videoEmbed';
|
||||
|
||||
test('getYouTubeVideoId: watch / youtu.be / embed / shorts', () => {
|
||||
assert.equal(getYouTubeVideoId('https://www.youtube.com/watch?v=dQw4w9WgXcQ'), 'dQw4w9WgXcQ');
|
||||
assert.equal(getYouTubeVideoId('https://youtu.be/dQw4w9WgXcQ'), 'dQw4w9WgXcQ');
|
||||
assert.equal(getYouTubeVideoId('https://youtu.be/dQw4w9WgXcQ?t=42'), 'dQw4w9WgXcQ');
|
||||
assert.equal(getYouTubeVideoId('https://www.youtube.com/embed/dQw4w9WgXcQ'), 'dQw4w9WgXcQ');
|
||||
assert.equal(getYouTubeVideoId('https://youtube.com/shorts/abc123DEF_-'), 'abc123DEF_-');
|
||||
});
|
||||
|
||||
test('getYouTubeVideoId: non-YouTube / malformed → null', () => {
|
||||
assert.equal(getYouTubeVideoId('https://vimeo.com/123'), null);
|
||||
assert.equal(getYouTubeVideoId('not a url'), null);
|
||||
assert.equal(getYouTubeVideoId('https://www.youtube.com/'), null);
|
||||
});
|
||||
|
||||
test('Shorts detection', () => {
|
||||
assert.equal(isYouTubeShorts('https://www.youtube.com/shorts/abc123'), true);
|
||||
assert.equal(isYouTubeShorts('https://www.youtube.com/watch?v=abc123'), false);
|
||||
assert.equal(getYoutubeShortsId('https://youtube.com/shorts/abc123'), 'abc123');
|
||||
});
|
||||
|
||||
test('getVimeoVideoId', () => {
|
||||
assert.equal(getVimeoVideoId('https://vimeo.com/123456789'), '123456789');
|
||||
assert.equal(getVimeoVideoId('https://vimeo.com/123456789/abcdef'), '123456789');
|
||||
assert.equal(getVimeoVideoId('https://vimeo.com/channels/staffpicks'), null);
|
||||
assert.equal(getVimeoVideoId('https://youtube.com/watch?v=x'), null);
|
||||
});
|
||||
|
||||
test('parseVideoEmbed: routes provider + portrait for shorts', () => {
|
||||
assert.deepEqual(parseVideoEmbed('https://youtube.com/shorts/abc'), {
|
||||
provider: 'youtube',
|
||||
id: 'abc',
|
||||
portrait: true,
|
||||
});
|
||||
assert.deepEqual(parseVideoEmbed('https://www.youtube.com/watch?v=xyz'), {
|
||||
provider: 'youtube',
|
||||
id: 'xyz',
|
||||
portrait: false,
|
||||
});
|
||||
assert.deepEqual(parseVideoEmbed('https://vimeo.com/42'), {
|
||||
provider: 'vimeo',
|
||||
id: '42',
|
||||
portrait: false,
|
||||
});
|
||||
assert.equal(parseVideoEmbed('https://example.com/video'), null);
|
||||
});
|
||||
|
||||
test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo, id encoded', () => {
|
||||
assert.equal(
|
||||
buildVideoEmbedUrl('youtube', 'dQw4w9WgXcQ'),
|
||||
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1&rel=0',
|
||||
);
|
||||
assert.equal(
|
||||
buildVideoEmbedUrl('vimeo', '123456789'),
|
||||
'https://player.vimeo.com/video/123456789?autoplay=1',
|
||||
);
|
||||
// id is URL-encoded (defense-in-depth; ids are normally already safe)
|
||||
assert.ok(buildVideoEmbedUrl('youtube', 'a/b').includes('a%2Fb'));
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// Pure helpers for detecting embeddable video URLs (YouTube, Shorts, Vimeo) and
|
||||
// building their privacy-friendly embed URLs. No React/DOM/CSS imports so this
|
||||
// stays unit-testable in isolation.
|
||||
|
||||
export type VideoEmbedProvider = 'youtube' | 'vimeo';
|
||||
|
||||
export type VideoEmbed = {
|
||||
provider: VideoEmbedProvider;
|
||||
id: string;
|
||||
portrait: boolean; // YouTube Shorts render 9:16
|
||||
};
|
||||
|
||||
export function getYouTubeVideoId(url: string): string | null {
|
||||
try {
|
||||
const { hostname, pathname, searchParams } = new URL(url);
|
||||
// youtu.be/<id>
|
||||
if (hostname === 'youtu.be') {
|
||||
const id = pathname.slice(1).split('/')[0];
|
||||
return id || null;
|
||||
}
|
||||
if (hostname === 'www.youtube.com' || hostname === 'youtube.com') {
|
||||
// youtube.com/watch?v=<id>
|
||||
if (pathname === '/watch') return searchParams.get('v');
|
||||
// youtube.com/embed/<id>
|
||||
const embedMatch = pathname.match(/^\/embed\/([A-Za-z0-9_-]+)/);
|
||||
if (embedMatch) return embedMatch[1];
|
||||
// youtube.com/shorts/<id> (also matched by getYoutubeShortsId)
|
||||
const shortsMatch = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
|
||||
if (shortsMatch) return shortsMatch[1];
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed URLs
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isYouTubeShorts(url: string): boolean {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return false;
|
||||
return /^\/shorts\/[A-Za-z0-9_-]+/.test(pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getYoutubeShortsId(url: string): string | null {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return null;
|
||||
const m = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
|
||||
return m ? m[1] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getVimeoVideoId(url: string): string | null {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
|
||||
const m = pathname.match(/^\/(\d+)/);
|
||||
return m ? m[1] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Detect an embeddable video from any URL, or null if it isn't one. */
|
||||
export function parseVideoEmbed(url: string): VideoEmbed | null {
|
||||
const shortsId = getYoutubeShortsId(url);
|
||||
if (shortsId) return { provider: 'youtube', id: shortsId, portrait: true };
|
||||
const ytId = getYouTubeVideoId(url);
|
||||
if (ytId) return { provider: 'youtube', id: ytId, portrait: false };
|
||||
const vimeoId = getVimeoVideoId(url);
|
||||
if (vimeoId) return { provider: 'vimeo', id: vimeoId, portrait: false };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the click-to-play embed URL. YouTube uses the cookie-less
|
||||
* youtube-nocookie host so nothing is set until the user presses play.
|
||||
*/
|
||||
export function buildVideoEmbedUrl(provider: VideoEmbedProvider, id: string): string {
|
||||
const safeId = encodeURIComponent(id);
|
||||
if (provider === 'vimeo') {
|
||||
return `https://player.vimeo.com/video/${safeId}?autoplay=1`;
|
||||
}
|
||||
return `https://www.youtube-nocookie.com/embed/${safeId}?autoplay=1&rel=0`;
|
||||
}
|
||||
Reference in New Issue
Block a user