Files
cinny/owl/REPORT-003-external-videos.md
2026-04-18 00:17:12 +02:00

16 KiB

OWL Report #003 - External Video Embeds (YouTube + Direct Video URLs)

Date: 2026-04-17

The Goal

Two README items merge naturally into one feature:

  • YouTube links should create embedded players (click-to-play).
  • 9gag-style direct video URLs (e.g. https://img-9gag-fun.9cache.com/photo/aYQnPXN_460svav1.mp4) should be playable inline.

Both are "external video in the timeline". They share the same wiring points (URL detection → card renderer in RenderMessageContent.tsx) and the same policy model ('always' | 'homeserver' | 'never', mirroring the external-images setting). Different rendering tech: YouTube needs an iframe, direct videos use the <video> tag — but that's an internal detail of the renderer.


Existing Plumbing We Will Reuse

The external-images work (Report #002, now shipped) gave us the exact pattern to follow.

Piece File Role
URL extraction src/app/utils/regex.tsURL_REG / HTTP_URL_PATTERN Already pulls every http(s) URL out of message text
URL classifier src/owl/utils/imageUrl.tsisImageUrl() Pattern we will mirror for isVideoUrl/isYouTubeUrl
Render dispatch src/app/components/RenderMessageContent.tsx:63-84renderUrlsPreview The single place we branch per URL kind
Card component src/owl/components/ExternalImageCard.tsx Exact shape to mirror
Policy setting src/app/state/settings.ts:44externalImages: ExternalImageMode Copy for externalVideos
Policy hook src/owl/hooks/useAllowExternalImages.ts Copy for useAllowExternalVideos
Settings UI src/app/features/settings/general/General.tsx:882-954 (SelectExternalImages) + mount at line 1043-1048 Copy for SelectExternalVideos
Matrix <video> element src/app/components/media/Video.tsx Already exists — reusable for direct video rendering

The key insight: renderUrlsPreview in RenderMessageContent.tsx is already where all URL-derived embeds dispatch. Today it routes images to ExternalImageCard and everything else to UrlPreviewCard. Adding video is one more branch in the same filter() chain. No changes to sanitize.ts or the HTML parser are needed — we are rendering from the URL list, not from user HTML.


Part A — YouTube Embeds

Detection

YouTube URL variants we want to catch:

https://www.youtube.com/watch?v=VIDEOID
https://youtube.com/watch?v=VIDEOID&t=42s
https://youtu.be/VIDEOID
https://youtu.be/VIDEOID?t=42
https://www.youtube.com/shorts/VIDEOID
https://www.youtube.com/embed/VIDEOID
https://m.youtube.com/watch?v=VIDEOID

Parse via URL rather than a mega-regex. That way a typo in the query string won't break detection:

// src/owl/utils/videoUrl.ts
export function getYouTubeId(url: string): string | null {
  try {
    const u = new URL(url);
    const host = u.hostname.replace(/^www\.|^m\./, '');
    if (host === 'youtu.be') return u.pathname.slice(1).split('/')[0] || null;
    if (host === 'youtube.com') {
      if (u.pathname === '/watch') return u.searchParams.get('v');
      const m = u.pathname.match(/^\/(shorts|embed|v)\/([^/]+)/);
      if (m) return m[2];
    }
    return null;
  } catch { return null; }
}

export function getYouTubeStart(url: string): number | null {
  try {
    const t = new URL(url).searchParams.get('t') ?? new URL(url).searchParams.get('start');
    if (!t) return null;
    // handle "42", "42s", "1m30s"
    const m = t.match(/(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?/);
    if (!m) return null;
    return (+(m[1] || 0)) * 3600 + (+(m[2] || 0)) * 60 + (+(m[3] || 0));
  } catch { return null; }
}

Rendering Strategy: "Facade" (Click-to-Load)

Don't render the iframe eagerly. Two reasons:

  1. Privacy: YouTube's iframe (even on youtube-nocookie.com) phones home as soon as it mounts. Loading a full iframe for every YouTube link in a room scrollback is a tracking and performance problem.
  2. Performance: A YouTube iframe is ~500KB of JS per instance. Ten YouTube links in a channel would pin the tab.

Instead: show a clickable thumbnail from https://i.ytimg.com/vi/<ID>/hqdefault.jpg with a play-button overlay. On click, swap to the iframe. This is the standard approach (used by Reddit, Discord, Mastodon front-ends).

┌───────────────────────────────────┐
│  [ YouTube thumbnail image ]      │
│       ▶  (big play button)        │
│                                   │
│  youtube.com/watch?v=dQw4w9WgXcQ  │
└───────────────────────────────────┘
   ↓ click
┌───────────────────────────────────┐
│  [ <iframe> YouTube player ]      │
└───────────────────────────────────┘

iframe Attributes

When the user clicks to load:

<iframe
  src={`https://www.youtube-nocookie.com/embed/${id}?autoplay=1${start ? `&start=${start}` : ''}`}
  title={`YouTube: ${id}`}
  loading="lazy"
  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen"
  allowFullScreen
  referrerPolicy="strict-origin-when-cross-origin"
  // no sandbox attr — the full YouTube embed API needs scripts+same-origin, and
  // youtube-nocookie is already an isolated origin. Sandbox breaks fullscreen.
/>

Notes:

  • youtube-nocookie.com is the reduced-tracking variant and should be the default.
  • Don't sandbox. The YouTube player relies on postMessage and same-origin window access for its controls; sandboxing breaks fullscreen and picture-in-picture. The iframe is already origin-isolated.
  • CSP: Cinny's vite.config.ts / public/config.json don't set CSP headers — the deployment host does. owl.cx must allow frame-src https://www.youtube-nocookie.com https://www.youtube.com (and the i.ytimg.com image host). Flag this for deployment.

Component sketch

// src/owl/components/YouTubeEmbedCard.tsx
export const YouTubeEmbedCard = as<'div', { url: string }>(({ url, ...p }, ref) => {
  const id = getYouTubeId(url);
  const start = getYouTubeStart(url);
  const [playing, setPlaying] = useState(false);
  if (!id) return null;

  return (
    <Box direction="Column" gap="100" {...p} ref={ref}>
      <div className={css.YouTubeFrame}>
        {playing ? (
          <iframe {...see above with id + start} />
        ) : (
          <button onClick={() => setPlaying(true)}>
            <img src={`https://i.ytimg.com/vi/${id}/hqdefault.jpg`} loading="lazy" />
            <PlayOverlay />
          </button>
        )}
      </div>
      <Text as="a" href={url} target="_blank" rel="noreferrer">{url}</Text>
    </Box>
  );
});

Aspect ratio: 16:9 (aspect-ratio: 16 / 9 in CSS) with a sensible max-width (e.g. 480px) so it doesn't dominate the timeline.


Part B — Direct Video URLs (9gag, etc.)

Detection

Mirror isImageUrl exactly:

// src/owl/utils/videoUrl.ts
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.m4v'];

export function isVideoUrl(url: string): boolean {
  try {
    const { pathname } = new URL(url);
    return VIDEO_EXTENSIONS.some((ext) => pathname.toLowerCase().endsWith(ext));
  } catch { return false; }
}

About 9gag specifically: their .webp links served under /photo/ are actually animated images (not video). The .mp4 variant (aYQnPXN_460svav1.mp4) is a true MP4 and needs <video>. The path-segment photo is misleading — extension is what matters. Our image-extension list already handles .webp, so animated WebP 9gag links will flow through ExternalImageCard today (the <img> tag will animate them natively). MP4 variants will flow through the new video card. No special 9gag detection needed.

Rendering

No facade needed — <video> with preload="metadata" only downloads the first few KB (metadata + poster frame). Users click play to stream.

// src/owl/components/ExternalVideoCard.tsx
export const ExternalVideoCard = as<'div', { url: string }>(({ url, ...p }, ref) => {
  const [error, setError] = useState(false);
  if (error) return null;
  return (
    <Box direction="Column" gap="100" {...p} ref={ref}>
      <video
        className={css.ExternalVideo}
        src={url}
        controls
        preload="metadata"
        playsInline
        onError={() => setError(true)}
      />
      <Text as="a" href={url} target="_blank" rel="noreferrer">{url}</Text>
    </Box>
  );
});

We can reuse the existing <Video> wrapper from src/app/components/media/Video.tsx if we want shared styling, but a new card mirroring ExternalImageCard is simpler and keeps all owl code in src/owl/.

Gotchas

  • CORS / Hotlinking: Some hosts (including Cloudflare sites) block hotlinked video with Referer checks. 9cache.com currently allows it — easy to verify empirically. If a host blocks us, <video onError> falls back (card hides itself). No leaking user IP because the threat model for owl is single-server — same as external images.
  • Autoplay: Do not autoplay. Browsers require muted for autoplay and it's user-hostile. preload="metadata" gives a poster frame without streaming the whole file.
  • Mobile data: preload="metadata" is the right default; don't change it to auto.

Part C — Unified Wiring

Setting

// src/app/state/settings.ts
export type ExternalVideoMode = 'always' | 'homeserver' | 'never';

// add to Settings interface:
externalVideos: ExternalVideoMode;

// add to defaultSettings:
externalVideos: 'never',   // upstream-safe default

owl.cx config.json overrides to 'homeserver' (or 'always') via the same defaultSettings mechanism that external-images already uses.

Open question: single setting or two? My recommendation is one setting externalVideos covering both YouTube and direct video, because:

  • They have the same privacy/trust model from the user's point of view ("do I trust external video hosts?").
  • Two toggles create decision fatigue for a feature that's largely "on or off".
  • If we later want finer control, splitting later is easier than merging later.

If we ever need YouTube-specific controls (e.g. "block YouTube but allow direct video" for a super-privacy-conscious user), we can add it then. Don't design for the hypothetical.

Hook

// src/owl/hooks/useAllowExternalVideos.ts  (one-to-one copy of useAllowExternalImages)
export function useAllowExternalVideos(mx: MatrixClient, room: Room): boolean {
  const [externalVideos] = useSetting(settingsAtom, 'externalVideos');
  return useMemo(() => {
    if (externalVideos === 'always') return true;
    if (externalVideos === 'never') return false;
    return room.roomId.split(':')[1] === mx.getDomain();
  }, [externalVideos, room.roomId, mx]);
}

Dispatch in RenderMessageContent.tsx

The current branch (lines 63-84):

const imageUrls = filteredUrls.filter(isImageUrl);
const otherUrls = filteredUrls.filter((url) => !isImageUrl(url));

Becomes:

const imageUrls   = filteredUrls.filter(isImageUrl);
const youtubeUrls = filteredUrls.filter((u) => !isImageUrl(u) && getYouTubeId(u));
const videoUrls   = filteredUrls.filter((u) => !isImageUrl(u) && !getYouTubeId(u) && isVideoUrl(u));
const otherUrls   = filteredUrls.filter(
  (u) => !isImageUrl(u) && !getYouTubeId(u) && !isVideoUrl(u)
);

And render each list with its card. Gating: RenderMessageContent doesn't currently take allowExternalVideos as a prop — we'd either add one (mirroring the eventual external-images gating if/when it lands here), or gate inside the new card component by reading the hook. Reading inside the card is simpler but requires the card to know its room context. Passing a prop down from RoomTimeline is cleaner and matches how urlPreview already flows. Recommendation: prop.

The prop flow would be:

  • RoomTimeline.tsx:451 — already calls useAllowExternalImages(mx, room). Add a sibling useAllowExternalVideos(mx, room).
  • RoomTimeline.tsx — pass allowExternalVideos into RenderMessageContent (need to find where RenderMessageContent is rendered in the timeline and thread it through; currently allowExternalImages goes into getReactCustomHtmlParser at line 533, not into RenderMessageContent, because images are gated inside HTML parsing. Video gating is different — it gates URL-derived embeds, not parsed HTML — so we pass directly to RenderMessageContent).

Settings UI

In General.tsx, copy SelectExternalImages (lines 882-954) to SelectExternalVideos, and add a SettingTile row mirroring lines 1043-1048:

<SequenceCard ...>
  <SettingTile title="External Videos" after={<SelectExternalVideos />} />
</SequenceCard>

Files Touched

File Kind Change size
src/owl/utils/videoUrl.ts NEW isVideoUrl, getYouTubeId, getYouTubeStart
src/owl/components/YouTubeEmbedCard.tsx NEW Facade + iframe
src/owl/components/YouTubeEmbedCard.css.ts NEW Aspect-ratio, thumbnail, play button
src/owl/components/ExternalVideoCard.tsx NEW <video> card
src/owl/components/ExternalVideoCard.css.ts NEW Sizing
src/owl/hooks/useAllowExternalVideos.ts NEW Mirror of images hook
src/app/state/settings.ts edit +2 lines (type + default)
src/app/components/RenderMessageContent.tsx edit ~10 lines: import cards, filter branches, render
src/app/features/room/RoomTimeline.tsx edit ~2 lines: add hook call, pass prop
src/app/features/settings/general/General.tsx edit ~80 lines: copy SelectExternalImagesSelectExternalVideos, add tile row

Upstream surface area: the three src/app/... edits. All feature logic (detection, rendering, policy hook, both cards, both CSS files) lives in src/owl/.

Merge risk: LOW. Pure additive change. No sanitizer or HTML-parser changes. If upstream touches renderUrlsPreview in RenderMessageContent.tsx, the branch addition is mechanical to re-apply.


Security Summary

Concern Mitigation
YouTube tracking youtube-nocookie.com, facade (no iframe until user click), referrerPolicy="strict-origin-when-cross-origin"
iframe XSS YouTube iframe is on a separate origin — no DOM access to our app. No sandbox needed (and it breaks fullscreen).
Video host IP leak Same as external images: single-server threat model, already accepted by owl. Setting defaults to 'never' for upstream.
Video MIME spoofing Browser validates MIME before playback; <video> rejects non-video payloads. onError hides the card.
CSP Deployment-side: owl.cx host config must permit frame-src youtube-nocookie.com, img-src i.ytimg.com, media-src * (or allowlist). Flag for deploy.
Malicious autoplay We never autoplay until user clicks.

Recommendation & Priority

Build this as one PR, in this order:

  1. src/owl/utils/videoUrl.ts + unit-style smoke test (throwaway URLs in dev console is fine — this is a client app, no test infra being set up for this).
  2. ExternalVideoCard (easier, mostly static markup).
  3. YouTubeEmbedCard with facade.
  4. Settings wiring + UI row.
  5. Manual test on owl.cx dev with: YouTube watch link, youtu.be short link, link with ?t= timestamp, youtube.com/shorts/, a 9gag mp4, an arbitrary .webm, and a broken URL.

Overall risk: LOW. The pattern is copy-paste from external images, the only genuinely new code is the YouTube facade and the setting row.

One thing to explicitly decide with the user before implementing: single externalVideos setting vs. split youtubeEmbed + directVideoEmbed. My recommendation is single; flagging it so we don't bikeshed after the PR is up.