feat: add TikTok embed support
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
downloadMedia,
|
||||
mxcUrlToHttp,
|
||||
} from '../../../utils/matrix';
|
||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
|
||||
const PLAY_TIME_THROTTLE_OPS = {
|
||||
|
||||
@@ -48,14 +48,14 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
// Check if we have valid thumbnail data
|
||||
const thumbInfo = info.thumbnail_info;
|
||||
const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
|
||||
const hasValidThumbnail = typeof thumbMxcUrl === 'string' && typeof thumbInfo?.mimetype === 'string';
|
||||
|
||||
const [thumbSrcState, loadThumbSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const thumbInfo = info.thumbnail_info;
|
||||
const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
|
||||
const encInfo = info.thumbnail_file;
|
||||
if (typeof thumbMxcUrl !== 'string' || typeof thumbInfo?.mimetype !== 'string') {
|
||||
throw new Error('Failed to load thumbnail');
|
||||
}
|
||||
|
||||
const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication) ?? thumbMxcUrl;
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
@@ -75,12 +75,15 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
|
||||
}
|
||||
|
||||
return mediaUrl;
|
||||
}, [mx, info, useAuthentication])
|
||||
}, [mx, thumbMxcUrl, thumbInfo, info.thumbnail_file, useAuthentication])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadThumbSrc();
|
||||
}, [loadThumbSrc]);
|
||||
// Only attempt to load thumbnail if we have valid data
|
||||
if (hasValidThumbnail) {
|
||||
loadThumbSrc();
|
||||
}
|
||||
}, [hasValidThumbnail, loadThumbSrc]);
|
||||
|
||||
return thumbSrcState.status === AsyncStatus.Success ? renderImage(thumbSrcState.data) : null;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
downloadMedia,
|
||||
mxcUrlToHttp,
|
||||
} from '../../../utils/matrix';
|
||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { validBlurHash } from '../../../utils/blurHash';
|
||||
|
||||
|
||||
132
src/app/components/message/content/VideoFrameThumbnail.tsx
Normal file
132
src/app/components/message/content/VideoFrameThumbnail.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { decryptFile, downloadEncryptedMedia, downloadMedia, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||
|
||||
/**
|
||||
* Extracts the first frame from a video file and renders it as a thumbnail.
|
||||
* This is used as a fallback when no thumbnail metadata is provided.
|
||||
*/
|
||||
export type VideoFrameThumbnailProps = {
|
||||
mimeType: string;
|
||||
url: string;
|
||||
encInfo?: EncryptedAttachmentInfo;
|
||||
renderImage: (src: string) => ReactNode;
|
||||
};
|
||||
|
||||
export function VideoFrameThumbnail({
|
||||
mimeType,
|
||||
url,
|
||||
encInfo,
|
||||
renderImage,
|
||||
}: VideoFrameThumbnailProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [thumbnailSrc, setThumbnailSrc] = useState<string | null>(null);
|
||||
|
||||
const [videoSrcState, loadVideoSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
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])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadVideoSrc();
|
||||
}, [loadVideoSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoSrcState.status !== AsyncStatus.Success) return undefined;
|
||||
|
||||
const videoUrl = videoSrcState.status === AsyncStatus.Success ? videoSrcState.data : null;
|
||||
if (!videoUrl) return undefined;
|
||||
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.crossOrigin = 'anonymous';
|
||||
video.src = videoUrl;
|
||||
|
||||
let isCleanedUp = false;
|
||||
const handlers: {
|
||||
loadedData?: () => void;
|
||||
seeked?: () => void;
|
||||
error?: () => void;
|
||||
} = {};
|
||||
|
||||
const cleanup = () => {
|
||||
if (isCleanedUp) return;
|
||||
isCleanedUp = true;
|
||||
|
||||
if (handlers.loadedData) video.removeEventListener('loadeddata', handlers.loadedData);
|
||||
if (handlers.seeked) video.removeEventListener('seeked', handlers.seeked);
|
||||
if (handlers.error) video.removeEventListener('error', handlers.error);
|
||||
video.src = '';
|
||||
video.load();
|
||||
};
|
||||
|
||||
handlers.loadedData = () => {
|
||||
try {
|
||||
video.currentTime = Math.min(0.1, video.duration / 2);
|
||||
} catch {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
handlers.seeked = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const thumbnailUrl = URL.createObjectURL(blob);
|
||||
setThumbnailSrc(thumbnailUrl);
|
||||
}
|
||||
cleanup();
|
||||
}, 'image/jpeg', 0.85);
|
||||
} catch {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
handlers.error = () => {
|
||||
cleanup();
|
||||
};
|
||||
|
||||
video.addEventListener('loadeddata', handlers.loadedData);
|
||||
video.addEventListener('seeked', handlers.seeked);
|
||||
video.addEventListener('error', handlers.error);
|
||||
|
||||
return () => {
|
||||
cleanup();
|
||||
if (thumbnailSrc) {
|
||||
URL.revokeObjectURL(thumbnailSrc);
|
||||
}
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
};
|
||||
}, [videoSrcState, thumbnailSrc]);
|
||||
|
||||
return thumbnailSrc ? renderImage(thumbnailSrc) : null;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './ThumbnailContent';
|
||||
export * from './ImageContent';
|
||||
export * from './VideoContent';
|
||||
export * from './VideoFrameThumbnail';
|
||||
export * from './AudioContent';
|
||||
export * from './FileContent';
|
||||
export * from './FallbackContent';
|
||||
|
||||
Reference in New Issue
Block a user