🦉 video preview support

This commit is contained in:
2026-04-18 00:17:12 +02:00
parent 7f08ce6b10
commit 695218595a
12 changed files with 729 additions and 4 deletions

View File

@@ -0,0 +1,337 @@
# 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.ts``URL_REG` / `HTTP_URL_PATTERN` | Already pulls every http(s) URL out of message text |
| URL classifier | `src/owl/utils/imageUrl.ts``isImageUrl()` | Pattern we will mirror for `isVideoUrl`/`isYouTubeUrl` |
| Render dispatch | `src/app/components/RenderMessageContent.tsx:63-84``renderUrlsPreview` | 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:44``externalImages: 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:
```ts
// 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:
```tsx
<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
```tsx
// 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:
```ts
// 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.
```tsx
// 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
```ts
// 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
```ts
// 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):
```ts
const imageUrls = filteredUrls.filter(isImageUrl);
const otherUrls = filteredUrls.filter((url) => !isImageUrl(url));
```
Becomes:
```ts
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:
```tsx
<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 `SelectExternalImages``SelectExternalVideos`, 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.