import React, { ReactNode, useCallback, useEffect, useState } from 'react'; import { Badge, Box, Button, Chip, Icon, Icons, Spinner, Text, Tooltip, TooltipProvider, as, } from 'folds'; import classNames from 'classnames'; import { BlurhashCanvas } from 'react-blurhash'; 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 { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { validBlurHash } from '../../../utils/blurHash'; import { useMediaViewerHost } from '../../../features/room/mediaViewerHostContext'; import { useOpenMediaViewer } from '../../../state/hooks/mediaViewer'; type RenderVideoProps = { title: string; src: string; onLoadedMetadata: () => void; onError: () => void; autoPlay: boolean; controls: boolean; }; type VideoContentProps = { body: string; mimeType: string; url: string; info: IVideoInfo & IThumbnailContent; encInfo?: EncryptedAttachmentInfo; autoPlay?: boolean; markedAsSpoiler?: boolean; spoilerReason?: string; // When provided AND `MediaViewerHostContext` is non-null, tapping // the thumbnail opens the atom-driven horseshoe viewer for video // playback instead of loading + playing inline (which hands off to // the browser's native video-element fullscreen when the user hits // the controls' expand button — that's why the user used to see // Chrome's default video viewer). Non-Room surfaces leave the // host context as `null` and keep the inline player. eventId?: string; renderThumbnail?: () => ReactNode; renderVideo: (props: RenderVideoProps) => ReactNode; }; export const VideoContent = as<'div', VideoContentProps>( ( { className, body, mimeType, url, info, encInfo, autoPlay, markedAsSpoiler, spoilerReason, eventId, renderThumbnail, renderVideo, ...props }, ref ) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]); const host = useMediaViewerHost(); const openMediaViewer = useOpenMediaViewer(); const useAtomViewer = !!(host && eventId); const [load, setLoad] = useState(false); const [error, setError] = useState(false); const [blurred, setBlurred] = useState(markedAsSpoiler ?? false); const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); if (!mediaUrl) throw new Error('Invalid media URL'); const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo) ) : await downloadMedia(mediaUrl); return URL.createObjectURL(fileContent); }, [mx, url, useAuthentication, mimeType, encInfo]) ); const handleLoad = () => { setLoad(true); }; const handleError = () => { setLoad(false); setError(true); }; const handleRetry = () => { setError(false); loadSrc(); }; useEffect(() => { // Skip inline preload in atom-viewer mode — the user gets the // viewer's own resolve path on tap; preloading every visible // video in the timeline would burn bandwidth and decrypt CPU // for videos the user never opens. if (useAtomViewer) return; if (autoPlay) loadSrc(); }, [autoPlay, loadSrc, useAtomViewer]); const openAtomViewer = useCallback(() => { if (!host || !eventId) return; // No `resolvedSrc` — viewer body owns blob-URL lifecycle; see // the rationale in `ImageContent.handleOpen`. openMediaViewer({ roomId: host.roomId, eventId, kind: 'video', url, body, info, encInfo, mimeType, }); }, [host, eventId, openMediaViewer, url, body, info, encInfo, mimeType]); return ( {typeof blurHash === 'string' && !load && ( )} {renderThumbnail && !load && ( {renderThumbnail()} )} {useAtomViewer && !blurred && ( )} {!useAtomViewer && !autoPlay && !blurred && srcState.status === AsyncStatus.Idle && ( )} {!useAtomViewer && srcState.status === AsyncStatus.Success && ( {renderVideo({ title: body, src: srcState.data, onLoadedMetadata: handleLoad, onError: handleError, autoPlay: true, controls: true, })} )} {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 && ( )} {(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)} )} ); } );