/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {useLingui} from '@lingui/react/macro'; import {PlayIcon} from '@phosphor-icons/react'; import {AnimatePresence, motion} from 'framer-motion'; import {observer} from 'mobx-react-lite'; import type {FC} from 'react'; import {useCallback, useEffect, useState} from 'react'; import {thumbHashToDataURL} from 'thumbhash'; import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators'; import * as MediaViewerActionCreators from '~/actions/MediaViewerActionCreators'; import {deriveDefaultNameFromMessage} from '~/components/channel/embeds/EmbedUtils'; import {OverlayPlayButton} from '~/components/channel/embeds/media/MediaButtons'; import {getMediaButtonVisibility} from '~/components/channel/embeds/media/MediaButtonUtils'; import {MediaContainer} from '~/components/channel/embeds/media/MediaContainer'; import type {BaseMediaProps} from '~/components/channel/embeds/media/MediaTypes'; import {NSFWBlurOverlay} from '~/components/channel/embeds/NSFWBlurOverlay'; import {VideoPlayer} from '~/components/media-player/components/VideoPlayer'; import {MediaContextMenu} from '~/components/uikit/ContextMenu/MediaContextMenu'; import {useDeleteAttachment} from '~/hooks/useDeleteAttachment'; import {useMediaFavorite} from '~/hooks/useMediaFavorite'; import {useNSFWMedia} from '~/hooks/useNSFWMedia'; import type {MessageAttachment} from '~/records/MessageRecord'; import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore'; import MobileLayoutStore from '~/stores/MobileLayoutStore'; import {createCalculator} from '~/utils/DimensionUtils'; import {createSaveHandler} from '~/utils/FileDownloadUtils'; import * as ImageCacheUtils from '~/utils/ImageCacheUtils'; import {buildMediaProxyURL} from '~/utils/MediaProxyUtils'; import styles from './EmbedVideo.module.css'; const VIDEO_CONFIG = { MAX_WIDTH: 400, } as const; const videoCalculator = createCalculator({ maxWidth: VIDEO_CONFIG.MAX_WIDTH, responsive: true, }); type EmbedVideoProps = BaseMediaProps & { src: string; width: number; height: number; placeholder?: string; title?: string; duration?: number; embedUrl?: string; fillContainer?: boolean; mediaAttachments?: ReadonlyArray; }; const MobileVideoOverlay: FC<{ thumbHashURL?: string; posterSrc: string | null; posterLoaded: boolean; onTap: () => void; title?: string; }> = observer(({thumbHashURL, posterSrc, posterLoaded, onTap, title}) => { const {t} = useLingui(); return ( ); }); const EmbedVideo: FC = observer( ({ src, width, height, placeholder, title, duration, nsfw, channelId, messageId, attachmentId, embedIndex, embedUrl, message, contentHash, onDelete, fillContainer = false, mediaAttachments = [], }) => { const {enabled: isMobile} = MobileLayoutStore; const effectiveSrc = buildMediaProxyURL(src); const isBlob = src.startsWith('blob:'); const posterSrc = isBlob ? null : buildMediaProxyURL(src, {format: 'webp'}); const [posterLoaded, setPosterLoaded] = useState(posterSrc ? ImageCacheUtils.hasImage(posterSrc) : false); const {shouldBlur, gateReason} = useNSFWMedia(nsfw, channelId); const defaultName = title || deriveDefaultNameFromMessage({message, attachmentId, embedIndex, url: embedUrl || src, proxyUrl: src}); const { isFavorited, toggleFavorite: handleFavoriteClick, canFavorite, } = useMediaFavorite({ channelId, messageId, attachmentId, embedIndex, defaultName, contentHash, }); const handleDownloadClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); createSaveHandler(src, 'video')(); }, [src], ); const handleDeleteClick = useDeleteAttachment(message, attachmentId); const handleContextMenu = useCallback( (e: React.MouseEvent) => { if (!message) return; e.preventDefault(); e.stopPropagation(); ContextMenuActionCreators.openFromEvent(e, ({onClose}) => ( {})} /> )); }, [message, src, contentHash, attachmentId, defaultName, onDelete], ); const thumbHashUrl = placeholder ? thumbHashToDataURL(Uint8Array.from(atob(placeholder), (c) => c.charCodeAt(0))) : undefined; const {dimensions} = useCallback(() => { return videoCalculator.calculate({width, height}, {responsive: true}); }, [width, height])(); const aspectRatio = `${dimensions.width} / ${dimensions.height}`; useEffect(() => { if (!posterSrc) return; if (DeveloperOptionsStore.forceRenderPlaceholders || DeveloperOptionsStore.forceMediaLoading) { return; } ImageCacheUtils.loadImage( posterSrc, () => setPosterLoaded(true), () => setPosterLoaded(false), ); }, [posterSrc]); const handleMobileTap = useCallback(() => { const currentIndex = mediaAttachments.findIndex((a) => a.id === attachmentId); const videoItems = mediaAttachments .filter((att) => att.content_type?.startsWith('video/')) .map((att) => ({ src: buildMediaProxyURL(att.proxy_url ?? att.url ?? ''), originalSrc: att.url ?? '', naturalWidth: att.width || 0, naturalHeight: att.height || 0, type: 'video' as const, contentHash: att.content_hash, attachmentId: att.id, embedIndex: undefined, filename: att.filename, fileSize: att.size, duration: att.duration, expiresAt: att.expires_at ?? null, expired: att.expired ?? false, })); MediaViewerActionCreators.openMediaViewer(videoItems, currentIndex, { channelId, messageId, message, }); }, [channelId, messageId, message, mediaAttachments, attachmentId]); const containerStyles: React.CSSProperties = isMobile ? { aspectRatio, width: '100%', maxWidth: '100%', } : fillContainer ? { width: '100%', height: '100%', } : { width: dimensions.width, maxWidth: '100%', aspectRatio, }; if (shouldBlur) { return (
{thumbHashUrl && ( )}
); } const {showFavoriteButton, showDownloadButton, showDeleteButton} = getMediaButtonVisibility( canFavorite, message, attachmentId, ); if (isMobile) { return (
); } return ( ); }, ); export default EmbedVideo;