feat: add VideoViewer component for enhanced video playback experience; implement download functionality and viewer modal

This commit is contained in:
2026-04-24 08:38:22 +10:00
parent 35ccdc0a22
commit 7b69ed6df8
7 changed files with 192 additions and 128 deletions

View File

@@ -28,6 +28,7 @@ import {
import { UrlPreviewCard, UrlPreviewHolder, YouTubeEmbed, isYouTubeUrl, TikTokEmbed, isTikTokUrl, GiphyEmbed, isGiphyUrl } from './url-preview';
import { Image, MediaControl, Video } from './media';
import { ImageViewer } from './image-viewer';
import { VideoViewer } from './video-viewer';
import { PdfViewer } from './Pdf-viewer';
import { TextViewer } from './text-viewer';
import { testMatrixTo } from '../plugins/matrix-to';
@@ -240,7 +241,6 @@ export function RenderMessageContent({
content={getContent()}
renderAsFile={renderFile}
renderVideoContent={({ body, info, mimeType, url, encInfo, ...props }) => {
// Check if there's valid thumbnail metadata
const hasValidThumbnail =
(typeof info.thumbnail_file?.url === 'string' || typeof info.thumbnail_url === 'string') &&
typeof info.thumbnail_info?.mimetype === 'string';
@@ -257,7 +257,6 @@ export function RenderMessageContent({
renderThumbnail={
mediaAutoLoad
? () => hasValidThumbnail ? (
// Try to load the provided thumbnail first
<ThumbnailContent
info={info}
renderImage={(src) => (
@@ -265,7 +264,6 @@ export function RenderMessageContent({
)}
/>
) : (
// Fallback: Extract first frame from the video itself
<VideoFrameThumbnail
mimeType={mimeType}
url={url}
@@ -277,6 +275,7 @@ export function RenderMessageContent({
)
: undefined
}
renderViewer={(p) => <VideoViewer {...p} />}
renderVideo={(p) => <Video {...p} />}
/>
);

View File

@@ -1,43 +1,20 @@
import React, { VideoHTMLAttributes, forwardRef, useRef, useState, useEffect } from 'react';
import classNames from 'classnames';
import { Box, IconButton, Icon, Icons, Tooltip, TooltipProvider, Text } from 'folds';
import * as css from './media.css';
import * as videoCss from './videoControls.css';
/**
* Fullscreen icon SVG
*/
function FullscreenIcon() {
return (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
</svg>
);
}
/**
* Exit fullscreen icon SVG
*/
function ExitFullscreenIcon() {
return (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" />
</svg>
);
}
type VideoProps = VideoHTMLAttributes<HTMLVideoElement> & {
disableControls?: boolean;
onExpand?: () => void;
};
export const Video = forwardRef<HTMLVideoElement, VideoProps>(
({ className, disableControls, ...props }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
({ className, disableControls, onExpand, ...props }, ref) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [showControls, setShowControls] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
// Combine refs
useEffect(() => {
if (typeof ref === 'function') {
ref(videoRef.current);
@@ -46,74 +23,22 @@ export const Video = forwardRef<HTMLVideoElement, VideoProps>(
}
}, [ref]);
// Track fullscreen state
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
if (!onExpand) return;
const video = videoRef.current;
if (!video) return;
const handleFullscreenRequest = (evt: Event) => {
evt.preventDefault();
evt.stopImmediatePropagation();
onExpand();
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
const handleFullscreen = async () => {
try {
if (!document.fullscreenElement && containerRef.current) {
await containerRef.current.requestFullscreen();
} else if (document.fullscreenElement) {
await document.exitFullscreen();
}
} catch (error) {
console.error('Error toggling fullscreen:', error);
}
};
const handlePopOut = () => {
const videoSrc = videoRef.current?.src;
const videoTitle = props.title || 'Video';
if (videoSrc) {
const popoutWindow = window.open(
'',
'_blank',
'width=800,height=600,menubar=no,toolbar=no,location=no,status=no'
);
if (popoutWindow) {
popoutWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>${videoTitle}</title>
<style>
body {
margin: 0;
padding: 0;
background: #000;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
video {
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<video controls autoplay src="${videoSrc}">
Your browser does not support the video tag.
</video>
</body>
</html>
`);
popoutWindow.document.close();
}
}
video.addEventListener('webkitbeginfullscreen', handleFullscreenRequest);
return () => {
video.removeEventListener('webkitbeginfullscreen', handleFullscreenRequest);
};
}, [onExpand]);
if (disableControls) {
return (
@@ -131,7 +56,6 @@ export const Video = forwardRef<HTMLVideoElement, VideoProps>(
return (
<Box
ref={containerRef}
className={videoCss.VideoContainer}
onMouseEnter={() => setShowControls(true)}
onMouseLeave={() => setShowControls(false)}
@@ -144,14 +68,16 @@ export const Video = forwardRef<HTMLVideoElement, VideoProps>(
loop={props.autoPlay}
muted={props.autoPlay}
playsInline
controlsList={onExpand ? 'nofullscreen' : undefined}
onDoubleClick={onExpand ? (e) => { e.stopPropagation(); onExpand(); } : undefined}
/>
{showControls && (
<Box className={videoCss.VideoControlsOverlay} gap="200">
{onExpand && (
<Box className={classNames(videoCss.VideoControlsOverlay, !showControls && videoCss.VideoControlsOverlayHidden)} gap="200">
<TooltipProvider
tooltip={
<Tooltip variant="Secondary">
<Text size="T200">{isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}</Text>
<Text size="T200">Expand</Text>
</Tooltip>
}
position="Bottom"
@@ -163,36 +89,10 @@ export const Video = forwardRef<HTMLVideoElement, VideoProps>(
variant="Secondary"
size="300"
radii="300"
onClick={handleFullscreen}
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
onClick={onExpand}
aria-label="Expand video"
>
<Icon
size="200"
src={isFullscreen ? ExitFullscreenIcon : FullscreenIcon}
/>
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
tooltip={
<Tooltip variant="Secondary">
<Text size="T200">Pop Out</Text>
</Tooltip>
}
position="Bottom"
align="Center"
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Secondary"
size="300"
radii="300"
onClick={handlePopOut}
aria-label="Open in new window"
>
<Icon size="200" src={Icons.External} />
<Icon size="50" src={Icons.Plus} />
</IconButton>
)}
</TooltipProvider>

View File

@@ -24,5 +24,12 @@ export const VideoControlsOverlay = style([
gap: config.space.S200,
zIndex: 10,
pointerEvents: 'auto',
opacity: 1,
transition: 'opacity 0.15s ease',
},
]);
export const VideoControlsOverlayHidden = style({
opacity: 0,
pointerEvents: 'none',
});

View File

@@ -6,6 +6,10 @@ import {
Chip,
Icon,
Icons,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Spinner,
Text,
Tooltip,
@@ -14,6 +18,7 @@ import {
} from 'folds';
import classNames from 'classnames';
import { BlurhashCanvas } from 'react-blurhash';
import FocusTrap from 'focus-trap-react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import {
IThumbnailContent,
@@ -33,7 +38,14 @@ import {
import { getCurrentAccessToken } from '../../../utils/auth';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { validBlurHash } from '../../../utils/blurHash';
import { ModalWide } from '../../../styles/Modal.css';
import { stopPropagation } from '../../../utils/keyboard';
type RenderViewerProps = {
src: string;
alt: string;
requestClose: () => void;
};
type RenderVideoProps = {
title: string;
src: string;
@@ -41,6 +53,7 @@ type RenderVideoProps = {
onError: () => void;
autoPlay: boolean;
controls: boolean;
onExpand?: () => void;
};
type VideoContentProps = {
body: string;
@@ -52,6 +65,7 @@ type VideoContentProps = {
markedAsSpoiler?: boolean;
spoilerReason?: string;
renderThumbnail?: () => ReactNode;
renderViewer?: (props: RenderViewerProps) => ReactNode;
renderVideo: (props: RenderVideoProps) => ReactNode;
};
export const VideoContent = as<'div', VideoContentProps>(
@@ -67,6 +81,7 @@ export const VideoContent = as<'div', VideoContentProps>(
markedAsSpoiler,
spoilerReason,
renderThumbnail,
renderViewer,
renderVideo,
...props
},
@@ -78,6 +93,7 @@ export const VideoContent = as<'div', VideoContentProps>(
const [load, setLoad] = useState(false);
const [error, setError] = useState(false);
const [viewer, setViewer] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const [srcState, loadSrc] = useAsyncCallback(
@@ -113,6 +129,28 @@ export const VideoContent = as<'div', VideoContentProps>(
return (
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
{renderViewer && srcState.status === AsyncStatus.Success && (
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setViewer(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal className={ModalWide} size="500">
{renderViewer({
src: srcState.data,
alt: body,
requestClose: () => setViewer(false),
})}
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
{typeof blurHash === 'string' && !load && (
<BlurhashCanvas
style={{ width: '100%', height: '100%' }}
@@ -154,6 +192,7 @@ export const VideoContent = as<'div', VideoContentProps>(
onError: handleError,
autoPlay: true,
controls: true,
onExpand: renderViewer ? () => setViewer(true) : undefined,
})}
</Box>
)}

View File

@@ -0,0 +1,40 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config } from 'folds';
export const VideoViewer = style([
DefaultReset,
{
height: '100%',
},
]);
export const VideoViewerHeader = style([
DefaultReset,
{
paddingLeft: config.space.S200,
paddingRight: config.space.S200,
borderBottomWidth: config.borderWidth.B300,
flexShrink: 0,
gap: config.space.S200,
},
]);
export const VideoViewerContent = style([
DefaultReset,
{
backgroundColor: color.Background.Container,
color: color.Background.OnContainer,
overflow: 'hidden',
},
]);
export const VideoViewerVideo = style([
DefaultReset,
{
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
backgroundColor: color.Surface.Container,
},
]);

View File

@@ -0,0 +1,78 @@
import React from 'react';
import FileSaver from 'file-saver';
import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
import * as css from './VideoViewer.css';
import { downloadMedia } from '../../utils/matrix';
import { getCurrentAccessToken } from '../../utils/auth';
export type VideoViewerProps = {
alt: string;
src: string;
requestClose: () => void;
};
export const VideoViewer = as<'div', VideoViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => { const handleDownload = async () => {
try {
const fileContent = await downloadMedia(src, getCurrentAccessToken());
FileSaver.saveAs(fileContent, alt);
} catch (error) {
console.warn('[VideoViewer] Failed to download media:', error);
try {
const response = await fetch(src);
if (response.ok) {
const blob = await response.blob();
FileSaver.saveAs(blob, alt);
}
} catch {
window.open(src, '_blank');
}
}
};
return (
<Box
className={classNames(css.VideoViewer, className)}
direction="Column"
{...props}
ref={ref}
>
<Header className={css.VideoViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>
{alt}
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<Chip
variant="Primary"
onClick={handleDownload}
radii="300"
before={<Icon size="50" src={Icons.Download} />}
>
<Text size="B300">Download</Text>
</Chip>
</Box>
</Header>
<Box
grow="Yes"
className={css.VideoViewerContent}
justifyContent="Center"
alignItems="Center"
>
<video
className={css.VideoViewerVideo}
src={src}
title={alt}
controls
autoPlay
/>
</Box>
</Box>
);
}
);

View File

@@ -0,0 +1 @@
export * from './VideoViewer';