🦉 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
+21 -1
View File
@@ -26,7 +26,10 @@ import {
} from './message';
import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
import { ExternalImageCard } from '../../owl/components/ExternalImageCard';
import { ExternalVideoCard } from '../../owl/components/ExternalVideoCard';
import { YouTubeEmbedCard } from '../../owl/components/YouTubeEmbedCard';
import { isImageUrl } from '../../owl/utils/imageUrl';
import { getYouTubeId, isVideoUrl } from '../../owl/utils/videoUrl';
import { Image, MediaControl, Video } from './media';
import { ImageViewer } from './image-viewer';
import { PdfViewer } from './Pdf-viewer';
@@ -42,6 +45,7 @@ type RenderMessageContentProps = {
getContent: <T>() => T;
mediaAutoLoad?: boolean;
urlPreview?: boolean;
allowExternalVideos?: boolean;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
@@ -55,6 +59,7 @@ export function RenderMessageContent({
getContent,
mediaAutoLoad,
urlPreview,
allowExternalVideos,
highlightRegex,
htmlReactParserOptions,
linkifyOpts,
@@ -65,13 +70,28 @@ export function RenderMessageContent({
if (filteredUrls.length === 0) return undefined;
const imageUrls = filteredUrls.filter(isImageUrl);
const otherUrls = filteredUrls.filter((url) => !isImageUrl(url));
const nonImage = filteredUrls.filter((url) => !isImageUrl(url));
const youtubeUrls = allowExternalVideos
? nonImage.filter((url) => getYouTubeId(url) !== null)
: [];
const videoUrls = allowExternalVideos
? nonImage.filter((url) => getYouTubeId(url) === null && isVideoUrl(url))
: [];
const otherUrls = nonImage.filter(
(url) => !youtubeUrls.includes(url) && !videoUrls.includes(url)
);
return (
<>
{imageUrls.map((url) => (
<ExternalImageCard key={url} url={url} />
))}
{youtubeUrls.map((url) => (
<YouTubeEmbedCard key={url} url={url} />
))}
{videoUrls.map((url) => (
<ExternalVideoCard key={url} url={url} />
))}
{otherUrls.length > 0 && (
<UrlPreviewHolder>
{otherUrls.map((url) => (
+4
View File
@@ -117,6 +117,7 @@ import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useAllowExternalImages } from '../../../owl/hooks/useAllowExternalImages';
import { useAllowExternalVideos } from '../../../owl/hooks/useAllowExternalVideos';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useIsDirectRoom } from '../../hooks/useRoom';
@@ -449,6 +450,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const allowExternalImages = useAllowExternalImages(mx, room);
const allowExternalVideos = useAllowExternalVideos(mx, room);
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
@@ -1107,6 +1109,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
allowExternalVideos={allowExternalVideos}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
@@ -1213,6 +1216,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
allowExternalVideos={allowExternalVideos}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
+82 -1
View File
@@ -32,7 +32,7 @@ import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { useSetting } from '../../../state/hooks/settings';
import { DateFormat, ExternalImageMode, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
import { DateFormat, ExternalImageMode, ExternalVideoMode, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
import { SettingTile } from '../../../components/setting-tile';
import { KeySymbol } from '../../../utils/key-symbol';
import { isMacOS } from '../../../utils/user-agent';
@@ -953,6 +953,80 @@ function SelectExternalImages() {
);
}
const externalVideoItems: { mode: ExternalVideoMode; name: string }[] = [
{ mode: 'never', name: 'Never' },
{ mode: 'homeserver', name: 'Homeserver Only' },
{ mode: 'always', name: 'Always' },
];
function SelectExternalVideos() {
const [menuCords, setMenuCords] = useState<RectCords>();
const [externalVideos, setExternalVideos] = useSetting(settingsAtom, 'externalVideos');
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (mode: ExternalVideoMode) => {
setExternalVideos(mode);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Secondary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleMenu}
>
<Text size="T300">
{externalVideoItems.find((i) => i.mode === externalVideos)?.name ?? externalVideos}
</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{externalVideoItems.map((item) => (
<MenuItem
key={item.mode}
size="300"
variant={externalVideos === item.mode ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleSelect(item.mode)}
>
<Text size="T300">{item.name}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
function Messages() {
const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
settingsAtom,
@@ -1046,6 +1120,13 @@ function Messages() {
after={<SelectExternalImages />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="External Videos"
description="Embed YouTube links and play direct video URLs (mp4, webm) inline."
after={<SelectExternalVideos />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Show Hidden Events"
+3
View File
@@ -16,6 +16,7 @@ export enum MessageLayout {
}
export type ExternalImageMode = 'always' | 'homeserver' | 'never';
export type ExternalVideoMode = 'always' | 'homeserver' | 'never';
export interface Settings {
themeId?: string;
@@ -42,6 +43,7 @@ export interface Settings {
showHiddenEvents: boolean;
legacyUsernameColor: boolean;
externalImages: ExternalImageMode;
externalVideos: ExternalVideoMode;
showNotifications: boolean;
isNotificationSounds: boolean;
@@ -77,6 +79,7 @@ const defaultSettings: Settings = {
showHiddenEvents: false,
legacyUsernameColor: false,
externalImages: 'never',
externalVideos: 'never',
showNotifications: true,
isNotificationSounds: true,
@@ -0,0 +1,24 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const ExternalVideoCard = style([
DefaultReset,
{
display: 'inline-block',
maxWidth: toRem(480),
borderRadius: config.radii.R300,
overflow: 'hidden',
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
backgroundColor: color.SurfaceVariant.Container,
},
]);
export const ExternalVideo = style([
DefaultReset,
{
display: 'block',
width: '100%',
maxHeight: toRem(400),
backgroundColor: '#000',
},
]);
+40
View File
@@ -0,0 +1,40 @@
import React, { useState } from 'react';
import { Box, Text, as, color } from 'folds';
import * as css from './ExternalVideoCard.css';
import { tryDecodeURIComponent } from '../../app/utils/dom';
const linkStyles = { color: color.Success.Main };
export const ExternalVideoCard = as<'div', { url: string }>(({ url, ...props }, ref) => {
const [error, setError] = useState(false);
if (error) return null;
return (
<Box direction="Column" gap="100" {...props} ref={ref}>
<div className={css.ExternalVideoCard}>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
className={css.ExternalVideo}
src={url}
controls
preload="metadata"
playsInline
onError={() => setError(true)}
/>
</div>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
{tryDecodeURIComponent(url)}
</Text>
</Box>
);
});
@@ -0,0 +1,83 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const YouTubeCard = style([
DefaultReset,
{
display: 'inline-block',
width: '100%',
maxWidth: toRem(480),
borderRadius: config.radii.R300,
overflow: 'hidden',
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
backgroundColor: '#000',
},
]);
export const YouTubeFrame = style([
DefaultReset,
{
position: 'relative',
width: '100%',
aspectRatio: '16 / 9',
},
]);
export const YouTubeThumbButton = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
padding: 0,
border: 'none',
background: '#000',
cursor: 'pointer',
overflow: 'hidden',
':hover': {
filter: 'brightness(1.05)',
},
},
]);
export const YouTubeThumbImg = style([
DefaultReset,
{
display: 'block',
width: '100%',
height: '100%',
objectFit: 'cover',
},
]);
export const YouTubePlayOverlay = style([
DefaultReset,
{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: toRem(64),
height: toRem(64),
borderRadius: '50%',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
pointerEvents: 'none',
},
]);
export const YouTubeIframe = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
border: 'none',
},
]);
+73
View File
@@ -0,0 +1,73 @@
import React, { useState } from 'react';
import { Box, Icon, Icons, Text, as, color } from 'folds';
import * as css from './YouTubeEmbedCard.css';
import { tryDecodeURIComponent } from '../../app/utils/dom';
import { getYouTubeId, getYouTubeStart } from '../utils/videoUrl';
const linkStyles = { color: color.Success.Main };
export const YouTubeEmbedCard = as<'div', { url: string }>(({ url, ...props }, ref) => {
const id = getYouTubeId(url);
const start = getYouTubeStart(url);
const [playing, setPlaying] = useState(false);
const [thumbError, setThumbError] = useState(false);
if (!id) return null;
const embedSrc =
`https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}` +
`?autoplay=1&rel=0${start ? `&start=${start}` : ''}`;
return (
<Box direction="Column" gap="100" {...props} ref={ref}>
<div className={css.YouTubeCard}>
<div className={css.YouTubeFrame}>
{playing ? (
<iframe
className={css.YouTubeIframe}
src={embedSrc}
title={`YouTube: ${id}`}
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen"
allowFullScreen
referrerPolicy="strict-origin-when-cross-origin"
/>
) : (
<button
type="button"
className={css.YouTubeThumbButton}
onClick={() => setPlaying(true)}
aria-label="Play YouTube video"
>
{!thumbError && (
<img
className={css.YouTubeThumbImg}
src={`https://i.ytimg.com/vi/${encodeURIComponent(id)}/hqdefault.jpg`}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setThumbError(true)}
/>
)}
<span className={css.YouTubePlayOverlay}>
<Icon size="400" src={Icons.Play} filled />
</span>
</button>
)}
</div>
</div>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
{tryDecodeURIComponent(url)}
</Text>
</Box>
);
});
+14
View File
@@ -0,0 +1,14 @@
import { useMemo } from 'react';
import { MatrixClient, Room } from 'matrix-js-sdk';
import { useSetting } from '../../app/state/hooks/settings';
import { settingsAtom } from '../../app/state/settings';
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;
const roomDomain = room.roomId.split(':')[1];
return roomDomain === mx.getDomain();
}, [externalVideos, room.roomId, mx]);
}
+46
View File
@@ -0,0 +1,46 @@
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;
}
}
export function getYouTubeId(url: string): string | null {
try {
const u = new URL(url);
const host = u.hostname.replace(/^www\.|^m\./, '');
if (host === 'youtu.be') {
const id = u.pathname.slice(1).split('/')[0];
return id || null;
}
if (host === 'youtube.com') {
if (u.pathname === '/watch') return u.searchParams.get('v');
const m = u.pathname.match(/^\/(shorts|embed|v|live)\/([^/]+)/);
if (m) return m[2];
}
return null;
} catch {
return null;
}
}
export function getYouTubeStart(url: string): number | null {
try {
const u = new URL(url);
const raw = u.searchParams.get('t') ?? u.searchParams.get('start');
if (!raw) return null;
if (/^\d+$/.test(raw)) return parseInt(raw, 10);
const m = raw.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/);
if (!m) return null;
const total = (parseInt(m[1] || '0', 10)) * 3600
+ (parseInt(m[2] || '0', 10)) * 60
+ (parseInt(m[3] || '0', 10));
return total > 0 ? total : null;
} catch {
return null;
}
}