feat: add VideoViewer component for enhanced video playback experience; implement download functionality and viewer modal
This commit is contained in:
@@ -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} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
40
src/app/components/video-viewer/VideoViewer.css.ts
Normal file
40
src/app/components/video-viewer/VideoViewer.css.ts
Normal 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,
|
||||
},
|
||||
]);
|
||||
78
src/app/components/video-viewer/VideoViewer.tsx
Normal file
78
src/app/components/video-viewer/VideoViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
1
src/app/components/video-viewer/index.ts
Normal file
1
src/app/components/video-viewer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './VideoViewer';
|
||||
Reference in New Issue
Block a user