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 { UrlPreviewCard, UrlPreviewHolder, YouTubeEmbed, isYouTubeUrl, TikTokEmbed, isTikTokUrl, GiphyEmbed, isGiphyUrl } from './url-preview';
|
||||||
import { Image, MediaControl, Video } from './media';
|
import { Image, MediaControl, Video } from './media';
|
||||||
import { ImageViewer } from './image-viewer';
|
import { ImageViewer } from './image-viewer';
|
||||||
|
import { VideoViewer } from './video-viewer';
|
||||||
import { PdfViewer } from './Pdf-viewer';
|
import { PdfViewer } from './Pdf-viewer';
|
||||||
import { TextViewer } from './text-viewer';
|
import { TextViewer } from './text-viewer';
|
||||||
import { testMatrixTo } from '../plugins/matrix-to';
|
import { testMatrixTo } from '../plugins/matrix-to';
|
||||||
@@ -240,7 +241,6 @@ export function RenderMessageContent({
|
|||||||
content={getContent()}
|
content={getContent()}
|
||||||
renderAsFile={renderFile}
|
renderAsFile={renderFile}
|
||||||
renderVideoContent={({ body, info, mimeType, url, encInfo, ...props }) => {
|
renderVideoContent={({ body, info, mimeType, url, encInfo, ...props }) => {
|
||||||
// Check if there's valid thumbnail metadata
|
|
||||||
const hasValidThumbnail =
|
const hasValidThumbnail =
|
||||||
(typeof info.thumbnail_file?.url === 'string' || typeof info.thumbnail_url === 'string') &&
|
(typeof info.thumbnail_file?.url === 'string' || typeof info.thumbnail_url === 'string') &&
|
||||||
typeof info.thumbnail_info?.mimetype === 'string';
|
typeof info.thumbnail_info?.mimetype === 'string';
|
||||||
@@ -257,7 +257,6 @@ export function RenderMessageContent({
|
|||||||
renderThumbnail={
|
renderThumbnail={
|
||||||
mediaAutoLoad
|
mediaAutoLoad
|
||||||
? () => hasValidThumbnail ? (
|
? () => hasValidThumbnail ? (
|
||||||
// Try to load the provided thumbnail first
|
|
||||||
<ThumbnailContent
|
<ThumbnailContent
|
||||||
info={info}
|
info={info}
|
||||||
renderImage={(src) => (
|
renderImage={(src) => (
|
||||||
@@ -265,7 +264,6 @@ export function RenderMessageContent({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// Fallback: Extract first frame from the video itself
|
|
||||||
<VideoFrameThumbnail
|
<VideoFrameThumbnail
|
||||||
mimeType={mimeType}
|
mimeType={mimeType}
|
||||||
url={url}
|
url={url}
|
||||||
@@ -277,6 +275,7 @@ export function RenderMessageContent({
|
|||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
renderViewer={(p) => <VideoViewer {...p} />}
|
||||||
renderVideo={(p) => <Video {...p} />}
|
renderVideo={(p) => <Video {...p} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,43 +1,20 @@
|
|||||||
import React, { VideoHTMLAttributes, forwardRef, useRef, useState, useEffect } from 'react';
|
import React, { VideoHTMLAttributes, forwardRef, useRef, useState, useEffect } from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Box, IconButton, Icon, Icons, Tooltip, TooltipProvider, Text } from 'folds';
|
import { Box, IconButton, Icon, Icons, Tooltip, TooltipProvider, Text } from 'folds';
|
||||||
import * as css from './media.css';
|
import * as css from './media.css';
|
||||||
import * as videoCss from './videoControls.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> & {
|
type VideoProps = VideoHTMLAttributes<HTMLVideoElement> & {
|
||||||
disableControls?: boolean;
|
disableControls?: boolean;
|
||||||
|
onExpand?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Video = forwardRef<HTMLVideoElement, VideoProps>(
|
export const Video = forwardRef<HTMLVideoElement, VideoProps>(
|
||||||
({ className, disableControls, ...props }, ref) => {
|
({ className, disableControls, onExpand, ...props }, ref) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const [showControls, setShowControls] = useState(false);
|
const [showControls, setShowControls] = useState(false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
||||||
|
|
||||||
// Combine refs
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof ref === 'function') {
|
if (typeof ref === 'function') {
|
||||||
ref(videoRef.current);
|
ref(videoRef.current);
|
||||||
@@ -46,74 +23,22 @@ export const Video = forwardRef<HTMLVideoElement, VideoProps>(
|
|||||||
}
|
}
|
||||||
}, [ref]);
|
}, [ref]);
|
||||||
|
|
||||||
// Track fullscreen state
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleFullscreenChange = () => {
|
if (!onExpand) return;
|
||||||
setIsFullscreen(!!document.fullscreenElement);
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
const handleFullscreenRequest = (evt: Event) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopImmediatePropagation();
|
||||||
|
onExpand();
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
video.addEventListener('webkitbeginfullscreen', handleFullscreenRequest);
|
||||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
return () => {
|
||||||
}, []);
|
video.removeEventListener('webkitbeginfullscreen', handleFullscreenRequest);
|
||||||
|
};
|
||||||
const handleFullscreen = async () => {
|
}, [onExpand]);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (disableControls) {
|
if (disableControls) {
|
||||||
return (
|
return (
|
||||||
@@ -131,7 +56,6 @@ export const Video = forwardRef<HTMLVideoElement, VideoProps>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
ref={containerRef}
|
|
||||||
className={videoCss.VideoContainer}
|
className={videoCss.VideoContainer}
|
||||||
onMouseEnter={() => setShowControls(true)}
|
onMouseEnter={() => setShowControls(true)}
|
||||||
onMouseLeave={() => setShowControls(false)}
|
onMouseLeave={() => setShowControls(false)}
|
||||||
@@ -143,15 +67,17 @@ export const Video = forwardRef<HTMLVideoElement, VideoProps>(
|
|||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
loop={props.autoPlay}
|
loop={props.autoPlay}
|
||||||
muted={props.autoPlay}
|
muted={props.autoPlay}
|
||||||
playsInline
|
playsInline
|
||||||
|
controlsList={onExpand ? 'nofullscreen' : undefined}
|
||||||
|
onDoubleClick={onExpand ? (e) => { e.stopPropagation(); onExpand(); } : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showControls && (
|
{onExpand && (
|
||||||
<Box className={videoCss.VideoControlsOverlay} gap="200">
|
<Box className={classNames(videoCss.VideoControlsOverlay, !showControls && videoCss.VideoControlsOverlayHidden)} gap="200">
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
tooltip={
|
tooltip={
|
||||||
<Tooltip variant="Secondary">
|
<Tooltip variant="Secondary">
|
||||||
<Text size="T200">{isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}</Text>
|
<Text size="T200">Expand</Text>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
@@ -163,36 +89,10 @@ export const Video = forwardRef<HTMLVideoElement, VideoProps>(
|
|||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
onClick={handleFullscreen}
|
onClick={onExpand}
|
||||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
aria-label="Expand video"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon size="50" src={Icons.Plus} />
|
||||||
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} />
|
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -24,5 +24,12 @@ export const VideoControlsOverlay = style([
|
|||||||
gap: config.space.S200,
|
gap: config.space.S200,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
|
opacity: 1,
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const VideoControlsOverlayHidden = style({
|
||||||
|
opacity: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
Chip,
|
Chip,
|
||||||
Icon,
|
Icon,
|
||||||
Icons,
|
Icons,
|
||||||
|
Modal,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -14,6 +18,7 @@ import {
|
|||||||
} from 'folds';
|
} from 'folds';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { BlurhashCanvas } from 'react-blurhash';
|
import { BlurhashCanvas } from 'react-blurhash';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
import {
|
import {
|
||||||
IThumbnailContent,
|
IThumbnailContent,
|
||||||
@@ -33,7 +38,14 @@ import {
|
|||||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { validBlurHash } from '../../../utils/blurHash';
|
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 = {
|
type RenderVideoProps = {
|
||||||
title: string;
|
title: string;
|
||||||
src: string;
|
src: string;
|
||||||
@@ -41,6 +53,7 @@ type RenderVideoProps = {
|
|||||||
onError: () => void;
|
onError: () => void;
|
||||||
autoPlay: boolean;
|
autoPlay: boolean;
|
||||||
controls: boolean;
|
controls: boolean;
|
||||||
|
onExpand?: () => void;
|
||||||
};
|
};
|
||||||
type VideoContentProps = {
|
type VideoContentProps = {
|
||||||
body: string;
|
body: string;
|
||||||
@@ -52,6 +65,7 @@ type VideoContentProps = {
|
|||||||
markedAsSpoiler?: boolean;
|
markedAsSpoiler?: boolean;
|
||||||
spoilerReason?: string;
|
spoilerReason?: string;
|
||||||
renderThumbnail?: () => ReactNode;
|
renderThumbnail?: () => ReactNode;
|
||||||
|
renderViewer?: (props: RenderViewerProps) => ReactNode;
|
||||||
renderVideo: (props: RenderVideoProps) => ReactNode;
|
renderVideo: (props: RenderVideoProps) => ReactNode;
|
||||||
};
|
};
|
||||||
export const VideoContent = as<'div', VideoContentProps>(
|
export const VideoContent = as<'div', VideoContentProps>(
|
||||||
@@ -67,6 +81,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
markedAsSpoiler,
|
markedAsSpoiler,
|
||||||
spoilerReason,
|
spoilerReason,
|
||||||
renderThumbnail,
|
renderThumbnail,
|
||||||
|
renderViewer,
|
||||||
renderVideo,
|
renderVideo,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
@@ -78,6 +93,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
|
|
||||||
const [load, setLoad] = useState(false);
|
const [load, setLoad] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
const [viewer, setViewer] = useState(false);
|
||||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||||
|
|
||||||
const [srcState, loadSrc] = useAsyncCallback(
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
@@ -113,6 +129,28 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
<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 && (
|
{typeof blurHash === 'string' && !load && (
|
||||||
<BlurhashCanvas
|
<BlurhashCanvas
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
@@ -154,6 +192,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
onError: handleError,
|
onError: handleError,
|
||||||
autoPlay: true,
|
autoPlay: true,
|
||||||
controls: true,
|
controls: true,
|
||||||
|
onExpand: renderViewer ? () => setViewer(true) : undefined,
|
||||||
})}
|
})}
|
||||||
</Box>
|
</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