import React, { ReactNode, useCallback, useEffect, useState } from 'react'; import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds'; import { Icon, Icons } from '../../icons'; import classNames from 'classnames'; import { Blurhash } from 'react-blurhash'; import FocusTrap from 'focus-trap-react'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { IThumbnailContent, IVideoInfo, MATRIX_BLUR_HASH_PROPERTY_NAME, } from '../../../../types/matrix/common'; import * as css from './style.css'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { bytesToSize, millisecondsToMinutesAndSeconds } from '../../../utils/common'; import { decryptFile, downloadEncryptedMedia, downloadMedia, mxcUrlToHttp, } from '../../../utils/matrix'; 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'; import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache'; type RenderViewerProps = { src: string; alt: string; requestClose: () => void; }; type RenderVideoProps = { title: string; src: string; onLoadedMetadata: (event?: React.SyntheticEvent) => void; onError: () => void; autoPlay: boolean; controls: boolean; onExpand?: () => void; }; type VideoContentProps = { body: string; mimeType: string; url: string; info: IVideoInfo & IThumbnailContent; encInfo?: EncryptedAttachmentInfo; autoPlay?: boolean; markedAsSpoiler?: boolean; spoilerReason?: string; renderThumbnail?: () => ReactNode; renderViewer?: (props: RenderViewerProps) => ReactNode; renderVideo: (props: RenderVideoProps) => ReactNode; }; export const VideoContent = as<'div', VideoContentProps>( ( { className, body, mimeType, url, info, encInfo, autoPlay, markedAsSpoiler, spoilerReason, renderThumbnail, renderViewer, renderVideo, ...props }, ref ) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const eventBlurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]); const cachedBlurHash = getMediaBlurHash(url); const blurHash = eventBlurHash ?? cachedBlurHash; const cachedDims = getMediaDimensions(url); const blurResolutionX = 32; const blurW = info.w ?? info.thumbnail_info?.w ?? cachedDims?.w; const blurH = info.h ?? info.thumbnail_info?.h ?? cachedDims?.h; const blurResolutionY = blurW && blurH && blurW > 0 ? Math.max(1, Math.round(blurResolutionX * (blurH / blurW))) : 32; const [load, setLoad] = useState(false); const [error, setError] = useState(false); const [viewer, setViewer] = useState(false); const [blurred, setBlurred] = useState(markedAsSpoiler ?? false); const [showPlaceholder, setShowPlaceholder] = useState(true); const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; // Always use current session's token to avoid stale tokens during account switches const accessToken = getCurrentAccessToken(); const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken ) : await downloadMedia(mediaUrl, accessToken); return URL.createObjectURL(fileContent); }, [mx, url, useAuthentication, mimeType, encInfo]) ); const handleLoad = (video?: HTMLVideoElement) => { if (video?.videoWidth && video?.videoHeight) { setMediaDimensions(url, video.videoWidth, video.videoHeight); } if (video) rememberMediaBlurHash(url, video); setLoad(true); }; const handleError = () => { setLoad(false); setError(true); setShowPlaceholder(true); }; const handleRetry = () => { setError(false); loadSrc(); }; useEffect(() => { if (autoPlay) loadSrc(); }, [autoPlay, loadSrc]); useEffect(() => { if (!load) { setShowPlaceholder(true); return undefined; } const timer = window.setTimeout(() => setShowPlaceholder(false), 540); return () => window.clearTimeout(timer); }, [load]); return ( {showPlaceholder && (typeof blurHash === 'string' ? (
) : (
))} {renderViewer && srcState.status === AsyncStatus.Success && ( }> setViewer(false), clickOutsideDeactivates: true, escapeDeactivates: stopPropagation, }} > {renderViewer({ src: srcState.data, alt: body, requestClose: () => setViewer(false), })} )} {renderThumbnail && !load && ( {renderThumbnail()} )} {!autoPlay && !blurred && srcState.status === AsyncStatus.Idle && ( )} {srcState.status === AsyncStatus.Success && ( {renderVideo({ title: body, src: srcState.data, onLoadedMetadata: (e?: React.SyntheticEvent) => { handleLoad(e?.currentTarget); }, onError: handleError, autoPlay: true, controls: true, onExpand: renderViewer ? () => setViewer(true) : undefined, })} )} {blurred && !error && srcState.status !== AsyncStatus.Error && ( {spoilerReason} ) } position="Top" align="Center" > {(triggerRef) => ( { setBlurred(false); }} > Spoiler )} )} {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) && !load && !blurred && typeof blurHash !== 'string' && ( )} {(error || srcState.status === AsyncStatus.Error) && ( Failed to load video! } position="Top" align="Center" > {(triggerRef) => ( )} )} {!load && typeof info.size === 'number' && ( {millisecondsToMinutesAndSeconds(info.duration ?? 0)} {bytesToSize(info.size)} )} ); } );