From ed5bf997b42066ba3682b14153e317b8d493b41e Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Tue, 20 Oct 2026 23:31:13 -0400 Subject: [PATCH] feat(embeds): Vimeo live events play inline (#200) vimeo.com/event/{id} (and /event/{id}/{hash} for unlisted events) now get the same click-to-play card as Vimeo videos, embedding Vimeo's live-event player (vimeo.com/event/{id}/embed[/{hash}], dnt=1). The issue assumed this needed vimeo.com in the CSP, but frame-src is already https: on web and desktop. Refs #200 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/utils/videoEmbed.test.ts | 32 ++++++++++++++++++++++++++++++++ src/app/utils/videoEmbed.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/app/utils/videoEmbed.test.ts b/src/app/utils/videoEmbed.test.ts index c2306cba1..586cce48f 100644 --- a/src/app/utils/videoEmbed.test.ts +++ b/src/app/utils/videoEmbed.test.ts @@ -33,6 +33,7 @@ import { spotifyEmbedHeight, parseMediaEmbed, isTwitterNoResults, + getVimeoEventParts, } from './videoEmbed'; const HOST = 'chat.lotusguild.org'; @@ -429,3 +430,34 @@ test('isTwitterNoResults: ignores normal embed traffic and junk', () => { assert.equal(isTwitterNoResults(null), false); assert.equal(isTwitterNoResults({ type: 'MEASURE' }), false); }); + +test('[#200] Vimeo live events embed from vimeo.com/event/{id}/embed', () => { + assert.deepEqual(getVimeoEventParts('https://vimeo.com/event/4321567'), { + id: '4321567', + hash: undefined, + }); + assert.deepEqual(getVimeoEventParts('https://vimeo.com/event/4321567/a1b2c3d4e5'), { + id: '4321567', + hash: 'a1b2c3d4e5', + }); + assert.equal( + getVimeoEventParts('https://vimeo.com/event/4321567/embed/a1b2c3d4e5')?.hash, + 'a1b2c3d4e5', + ); + assert.equal(getVimeoEventParts('https://vimeo.com/event/4321567/chat'), null); + assert.equal(getVimeoEventParts('https://vimeo.com/123456789'), null); + assert.equal(getVimeoEventParts('https://evil.example/event/4321567'), null); + assert.equal( + parseMediaEmbed('https://vimeo.com/event/4321567', 'h')?.embedUrl, + 'https://vimeo.com/event/4321567/embed?autoplay=1&dnt=1', + ); + assert.equal( + parseMediaEmbed('https://vimeo.com/event/4321567/a1b2c3d4e5', 'h')?.embedUrl, + 'https://vimeo.com/event/4321567/embed/a1b2c3d4e5?autoplay=1&dnt=1', + ); + // An ordinary video still goes to the player. + assert.match( + parseMediaEmbed('https://vimeo.com/123456789', 'h')?.embedUrl ?? '', + /^https:\/\/player\.vimeo\.com\/video\/123456789/, + ); +}); diff --git a/src/app/utils/videoEmbed.ts b/src/app/utils/videoEmbed.ts index cbb0510a4..780a6e3aa 100644 --- a/src/app/utils/videoEmbed.ts +++ b/src/app/utils/videoEmbed.ts @@ -80,6 +80,25 @@ export function getVimeoParts(url: string): { id: string; hash?: string } | null } } +/** + * [Gitea #200] Vimeo live events: vimeo.com/event/{id}, or /event/{id}/{hash} + * when unlisted. Embedded from vimeo.com itself (not player.vimeo.com); the + * web and desktop CSP `frame-src https:` already allow it. + */ +export function getVimeoEventParts(url: string): { id: string; hash?: string } | null { + try { + const { hostname, pathname } = new URL(url); + if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null; + const m = pathname.match(/^\/event\/(\d+)(?:\/(?:embed\/)?([0-9a-f]{6,}))?\/?$/); + return m ? { id: m[1], hash: m[2] } : null; + } catch { + return null; + } +} + +export const buildVimeoEventEmbedUrl = (id: string, hash?: string): string => + `https://vimeo.com/event/${enc(id)}/embed${hash ? `/${enc(hash)}` : ''}?autoplay=1&dnt=1`; + export function getVimeoVideoId(url: string): string | null { return getVimeoParts(url)?.id ?? null; } @@ -713,6 +732,14 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null { embedUrl: buildVideoEmbedUrl('youtube', ytId), }; + const vimeoEvent = getVimeoEventParts(url); + if (vimeoEvent) + return { + provider: 'vimeo', + kind: 'landscape', + embedUrl: buildVimeoEventEmbedUrl(vimeoEvent.id, vimeoEvent.hash), + }; + const vimeo = getVimeoParts(url); if (vimeo) return {