fix(app): backport rendering improvements (#1580)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-13 19:52:00 +02:00
1 parent 0c291a01da
commit 42cdc1f877
513 files changed
+98743 -70397

No files matched your search

@@ -4,7 +4,6 @@ import Accessibility from '@app/features/accessibility/state/Accessibility';
import {useAnimatedImageDecoder} from '@app/features/app/hooks/useAnimatedImageDecoder';
import {
getAnimatedMediaPlaybackAllowed,
subscribeAnimatedMediaPlaybackChange,
useAnimatedMediaPlaybackAllowed,
} from '@app/features/app/hooks/useAnimatedMediaPlayback';
import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
@@ -13,6 +12,14 @@ import embedStyles from '@app/features/channel/components/embeds/ChannelEmbed.mo
import {deriveDefaultNameFromMessage} from '@app/features/channel/components/embeds/EmbedUtils';
import {MatureMediaBlurOverlay} from '@app/features/channel/components/embeds/MatureMediaBlurOverlay';
import styles from '@app/features/channel/components/embeds/media/EmbedGifv.module.css';
import {
createInitialGIFVRenderState,
GIFVPlaybackState,
GIFVRenderEventType,
GIFVVideoState,
getGIFVRenderLayers,
reduceGIFVRenderState,
} from '@app/features/channel/components/embeds/media/GIFVRenderStateMachine';
import {GifIndicator} from '@app/features/channel/components/embeds/media/GifIndicator';
import {getMediaButtonVisibility} from '@app/features/channel/components/embeds/media/MediaButtonUtils';
import {MediaContainer, shouldShowOverlays} from '@app/features/channel/components/embeds/media/MediaContainer';
@@ -29,11 +36,11 @@ import {useNearViewport} from '@app/features/messaging/hooks/useNearViewport';
import {useOpenInBrowserOnMiddleClick} from '@app/features/messaging/hooks/useOpenInBrowserOnMiddleClick';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {createDownloadHandler} from '@app/features/messaging/utils/FileDownloadUtils';
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
import {getEmbedMediaDimensions} from '@app/features/messaging/utils/MediaDimensionConfig';
import {
buildFittedAnimatedImageProxyURL,
buildFittedStaticGifPreviewURL,
buildMediaProxyURL,
stripMediaProxyParams,
} from '@app/features/messaging/utils/MediaProxyUtils';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
@@ -43,13 +50,12 @@ import * as MediaViewerCommands from '@app/features/ui/commands/MediaViewerComma
import MediaViewer from '@app/features/ui/state/MediaViewer';
import MobileLayout from '@app/features/ui/state/MobileLayout';
import {createCalculator} from '@app/features/ui/utils/DimensionUtils';
import KlipyWatermarkSvg from '@app/media/images/klipy-watermark.svg?react';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
import {motion} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import {type FC, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {type FC, useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState} from 'react';
const OPEN_ANIMATED_GIF_VIDEO_IN_FULL_VIEW_DESCRIPTOR = msg({
message: 'Open animated GIF video in full view',
@@ -78,11 +84,68 @@ const ANIMATED_GIF_DESCRIPTOR = msg({
const EMBED_MEDIA_FADE_DURATION_SECONDS = 0.08;
const DECODER_MAX_CACHED_FRAMES = 24;
type AnimatedImagePreloadStatus = 'idle' | 'loading' | 'ready' | 'failed';
interface AnimatedImagePreloadState {
url: string;
status: AnimatedImagePreloadStatus;
}
function watchPresentedVideoFrame(video: HTMLVideoElement, onPresented: () => void): () => void {
let cancelled = false;
const requestVideoFrameCallback = video.requestVideoFrameCallback;
if (typeof requestVideoFrameCallback === 'function') {
const callbackHandle = requestVideoFrameCallback.call(video, () => {
if (!cancelled) onPresented();
});
return () => {
cancelled = true;
const cancelVideoFrameCallback = video.cancelVideoFrameCallback;
if (typeof cancelVideoFrameCallback === 'function') {
cancelVideoFrameCallback.call(video, callbackHandle);
}
};
}
const ownerWindow = video.ownerDocument.defaultView;
if (ownerWindow == null) return () => {};
let secondFrameHandle: number | null = null;
const firstFrameHandle = ownerWindow.requestAnimationFrame(() => {
secondFrameHandle = ownerWindow.requestAnimationFrame(() => {
if (!cancelled) onPresented();
});
});
return () => {
cancelled = true;
ownerWindow.cancelAnimationFrame(firstFrameHandle);
if (secondFrameHandle != null) ownerWindow.cancelAnimationFrame(secondFrameHandle);
};
}
function mediaElementHasSource(element: HTMLImageElement | HTMLVideoElement, source: string): boolean {
if (element.currentSrc.length > 0) {
try {
return element.currentSrc === new URL(source, element.ownerDocument.baseURI).href;
} catch {
return element.currentSrc === source;
}
}
const attributeSource = element.getAttribute('src');
if (attributeSource === source) return true;
return element.src === source;
}
function completeImageHasSource(image: HTMLImageElement | null, source: string): image is HTMLImageElement {
if (image == null) return false;
if (!mediaElementHasSource(image, source)) return false;
return image.complete && image.naturalWidth > 0 && image.naturalHeight > 0;
}
type GifvEmbedProps = BaseMediaProps & {
embedURL: string;
naturalWidth: number;
naturalHeight: number;
placeholder?: string;
thumbnailProxyURL?: string;
alt?: string | null;
};
@@ -316,6 +379,7 @@ export const EmbedGifv: FC<
({
embedURL,
videoProxyURL,
thumbnailProxyURL,
alt,
naturalWidth,
naturalHeight,
@@ -337,19 +401,38 @@ export const EmbedGifv: FC<
const messageViewContext = useMaybeMessageViewContext();
const mediaCalculator = useEmbedMediaCalculator();
const videoRef = useRef<HTMLVideoElement>(null);
const posterElementRef = useRef<HTMLImageElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const savedTimeRef = useRef(0);
const frameCleanupRef = useRef<(() => void) | null>(null);
const playbackRequestIdRef = useRef(0);
const [containerElement, setContainerElement] = useState<HTMLDivElement | null>(null);
const [isHoveredState, setIsHoveredState] = useState(false);
const [isFocusWithin, setIsFocusWithin] = useState(false);
const {ref: visibilityRef, isNearViewport} = useNearViewport<HTMLDivElement>({rememberKey: videoProxyURL});
const setContainerRef = useCallback(
(node: HTMLDivElement | null) => {
containerRef.current = node;
setContainerElement(node);
visibilityRef(node);
},
[visibilityRef],
);
const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId);
const shouldLoadMedia = isNearViewport && !shouldBlur;
const posterURL = useMemo(() => buildMediaProxyURL(videoProxyURL, {format: 'webp'}), [videoProxyURL]);
const shouldLoadVideo = isNearViewport && !shouldBlur;
const {dimensions} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight}, {forceScale: true});
const posterSource = thumbnailProxyURL && thumbnailProxyURL.length > 0 ? thumbnailProxyURL : videoProxyURL;
const posterURL = useMemo(
() =>
buildFittedStaticGifPreviewURL(
stripMediaProxyParams(posterSource),
Math.max(1, Math.round(dimensions.width * 2)),
Math.max(1, Math.round(dimensions.height * 2)),
),
[dimensions.height, dimensions.width, posterSource],
);
const posterCachedForPaint = ImageCacheUtils.hasImage(posterURL);
const shouldLoadPoster = (isNearViewport || posterCachedForPaint) && !shouldBlur;
const {
loaded,
error,
@@ -359,14 +442,106 @@ export const EmbedGifv: FC<
ref: mediaRef,
onLoad: handleMediaLoad,
onError: handleMediaError,
} = useMediaLoading(videoProxyURL, placeholder, {enabled: shouldLoadMedia});
} = useMediaLoading(posterURL, placeholder, {enabled: shouldLoadPoster});
const renderSourceKey = `${videoProxyURL}\n${posterURL}\n${shouldLoadVideo ? 'active' : 'inactive'}`;
const [renderState, dispatchRenderEvent] = useReducer(
reduceGIFVRenderState,
{
sourceKey: renderSourceKey,
posterCached: ImageCacheUtils.hasImage(posterURL),
},
createInitialGIFVRenderState,
);
const currentRenderSourceKeyRef = useRef(renderSourceKey);
const setVideoRef = useCallback(
(node: HTMLVideoElement | null) => {
if (videoRef.current !== node && frameCleanupRef.current != null) {
frameCleanupRef.current();
frameCleanupRef.current = null;
}
videoRef.current = node;
if (node == null) return;
if (currentRenderSourceKeyRef.current !== renderSourceKey) return;
if (!mediaElementHasSource(node, videoProxyURL)) return;
if (node.readyState < 2) return;
dispatchRenderEvent({type: GIFVRenderEventType.VIDEO_READY});
},
[renderSourceKey, videoProxyURL],
);
const setPosterRef = useCallback(
(node: HTMLImageElement | null) => {
posterElementRef.current = node;
mediaRef(node);
},
[mediaRef],
);
useLayoutEffect(() => {
currentRenderSourceKeyRef.current = renderSourceKey;
if (frameCleanupRef.current != null) {
frameCleanupRef.current();
frameCleanupRef.current = null;
}
dispatchRenderEvent({
type: GIFVRenderEventType.SOURCE_CHANGED,
sourceKey: renderSourceKey,
posterCached: ImageCacheUtils.hasImage(posterURL),
});
if (!shouldLoadVideo) return;
const video = videoRef.current;
if (video == null) return;
if (!mediaElementHasSource(video, videoProxyURL)) return;
if (video.readyState < 2) return;
dispatchRenderEvent({type: GIFVRenderEventType.VIDEO_READY});
}, [posterURL, renderSourceKey, shouldLoadVideo, videoProxyURL]);
const renderStateIsCurrent = renderState.sourceKey === renderSourceKey;
const currentRenderState = renderStateIsCurrent
? renderState
: createInitialGIFVRenderState({
sourceKey: renderSourceKey,
posterCached: ImageCacheUtils.hasImage(posterURL),
});
useEffect(() => {
if (!shouldLoadPoster) return;
if (loaded) {
dispatchRenderEvent({type: GIFVRenderEventType.POSTER_LOADED});
return;
}
if (error) {
dispatchRenderEvent({type: GIFVRenderEventType.POSTER_FAILED});
return;
}
dispatchRenderEvent({type: GIFVRenderEventType.POSTER_LOAD_STARTED});
}, [error, loaded, shouldLoadPoster]);
useEffect(() => {
if (shouldLoadVideo) dispatchRenderEvent({type: GIFVRenderEventType.VIDEO_LOAD_STARTED});
}, [renderSourceKey, shouldLoadVideo]);
useEffect(() => {
if (frameCleanupRef.current != null) {
frameCleanupRef.current();
frameCleanupRef.current = null;
}
if (currentRenderState.video === GIFVVideoState.PRESENTED) return;
if (currentRenderState.playback !== GIFVPlaybackState.PLAYING) return;
const video = videoRef.current;
if (video == null || video.readyState < 2) return;
const sourceKey = renderSourceKey;
const cancelFrameWatch = watchPresentedVideoFrame(video, () => {
if (currentRenderSourceKeyRef.current !== sourceKey) return;
if (videoRef.current !== video) return;
if (!mediaElementHasSource(video, videoProxyURL)) return;
dispatchRenderEvent({type: GIFVRenderEventType.VIDEO_FRAME_PRESENTED});
});
frameCleanupRef.current = cancelFrameWatch;
return () => {
cancelFrameWatch();
if (frameCleanupRef.current === cancelFrameWatch) frameCleanupRef.current = null;
};
}, [currentRenderState.playback, currentRenderState.video, renderSourceKey, videoProxyURL]);
useEffect(() => {
return () => {
if (frameCleanupRef.current != null) frameCleanupRef.current();
};
}, []);
const defaultName = deriveDefaultNameFromMessage({
message,
attachmentId,
@@ -456,91 +631,152 @@ export const EmbedGifv: FC<
snapshotIndex,
],
);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handlePlaying = () => {
if (video.hasAttribute('poster')) {
video.removeAttribute('poster');
}
};
video.addEventListener('playing', handlePlaying);
return () => {
video.removeEventListener('playing', handlePlaying);
};
}, []);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (!shouldLoadMedia) {
video.autoplay = false;
safePause(video);
return;
}
if (isMediaViewerOpen) {
video.autoplay = false;
safePause(video);
return;
}
const shouldPlay = gifAutoPlay && animatedMediaPlaybackAllowed;
if (shouldPlay) {
video.autoplay = true;
void safePlay(video);
} else {
video.autoplay = false;
safePause(video);
}
}, [animatedMediaPlaybackAllowed, videoConfig, gifAutoPlay, isMediaViewerOpen, shouldLoadMedia]);
useEffect(() => {
if (!shouldLoadMedia || gifAutoPlay || isMediaViewerOpen) return;
const video = videoRef.current;
const container = containerRef.current;
if (!video || !container) return;
let isHovered = false;
const handleMouseEnter = () => {
isHovered = true;
if (!getAnimatedMediaPlaybackAllowed()) return;
const target = savedTimeRef.current;
if (Number.isFinite(target) && Math.abs(video.currentTime - target) > 0.01) {
try {
video.currentTime = target;
} catch {}
}
void safePlay(video);
};
const handleMouseLeave = () => {
isHovered = false;
if (Number.isFinite(video.currentTime)) {
savedTimeRef.current = video.currentTime;
}
safePause(video);
};
const handlePlaybackAllowedChange = () => {
if (getAnimatedMediaPlaybackAllowed()) {
if (!isHovered) return;
const target = savedTimeRef.current;
if (Number.isFinite(target) && Math.abs(video.currentTime - target) > 0.01) {
try {
video.currentTime = target;
} catch {}
const handlePosterLoad = useCallback(
(event: React.SyntheticEvent<HTMLImageElement>) => {
if (currentRenderSourceKeyRef.current !== renderSourceKey) return;
if (posterElementRef.current !== event.currentTarget) return;
if (!mediaElementHasSource(event.currentTarget, posterURL)) return;
handleMediaLoad(event);
dispatchRenderEvent({type: GIFVRenderEventType.POSTER_LOADED});
},
[handleMediaLoad, posterURL, renderSourceKey],
);
const handlePosterError = useCallback(
(event: React.SyntheticEvent<HTMLImageElement>) => {
if (currentRenderSourceKeyRef.current !== renderSourceKey) return;
if (posterElementRef.current !== event.currentTarget) return;
if (!mediaElementHasSource(event.currentTarget, posterURL)) return;
handleMediaError(event);
dispatchRenderEvent({type: GIFVRenderEventType.POSTER_FAILED});
},
[handleMediaError, posterURL, renderSourceKey],
);
const handleVideoReady = useCallback(
(event: React.SyntheticEvent<HTMLVideoElement>) => {
if (currentRenderSourceKeyRef.current !== renderSourceKey) return;
if (videoRef.current !== event.currentTarget) return;
if (!mediaElementHasSource(event.currentTarget, videoProxyURL)) return;
dispatchRenderEvent({type: GIFVRenderEventType.VIDEO_READY});
},
[renderSourceKey, videoProxyURL],
);
const handleVideoPlaying = useCallback(
(event: React.SyntheticEvent<HTMLVideoElement>) => {
if (currentRenderSourceKeyRef.current !== renderSourceKey) return;
if (videoRef.current !== event.currentTarget) return;
if (!mediaElementHasSource(event.currentTarget, videoProxyURL)) return;
dispatchRenderEvent({type: GIFVRenderEventType.PLAYING});
},
[renderSourceKey, videoProxyURL],
);
const handleVideoPause = useCallback(
(event: React.SyntheticEvent<HTMLVideoElement>) => {
if (currentRenderSourceKeyRef.current !== renderSourceKey) return;
if (videoRef.current !== event.currentTarget) return;
if (!mediaElementHasSource(event.currentTarget, videoProxyURL)) return;
dispatchRenderEvent({type: GIFVRenderEventType.PLAYBACK_PAUSED});
},
[renderSourceKey, videoProxyURL],
);
const handleVideoError = useCallback(
(event: React.SyntheticEvent<HTMLVideoElement>) => {
if (currentRenderSourceKeyRef.current !== renderSourceKey) return;
if (videoRef.current !== event.currentTarget) return;
if (!mediaElementHasSource(event.currentTarget, videoProxyURL)) return;
dispatchRenderEvent({type: GIFVRenderEventType.VIDEO_FAILED});
},
[renderSourceKey, videoProxyURL],
);
const requestVideoPlayback = useCallback(
(video: HTMLVideoElement) => {
const sourceKey = renderSourceKey;
const requestId = playbackRequestIdRef.current;
if (currentRenderSourceKeyRef.current !== sourceKey) return;
if (videoRef.current !== video) return;
if (!mediaElementHasSource(video, videoProxyURL)) return;
dispatchRenderEvent({type: GIFVRenderEventType.PLAY_REQUESTED});
if (!getAnimatedMediaPlaybackAllowed()) {
if (currentRenderSourceKeyRef.current === sourceKey) {
dispatchRenderEvent({type: GIFVRenderEventType.PLAYBACK_BLOCKED});
}
void safePlay(video);
return;
}
if (Number.isFinite(video.currentTime)) {
savedTimeRef.current = video.currentTime;
}
safePause(video);
const playPromise = safePlay(video);
void playPromise.then(() => {
if (playbackRequestIdRef.current !== requestId) return;
if (currentRenderSourceKeyRef.current !== sourceKey) return;
if (videoRef.current !== video) return;
if (!mediaElementHasSource(video, videoProxyURL)) return;
if (video.paused) dispatchRenderEvent({type: GIFVRenderEventType.PLAYBACK_BLOCKED});
});
},
[renderSourceKey, videoProxyURL],
);
useEffect(() => {
const container = containerElement;
if (container == null) return;
const saveCurrentTime = () => {
const video = videoRef.current;
if (video != null && Number.isFinite(video.currentTime)) savedTimeRef.current = video.currentTime;
};
const handleMouseEnter = () => {
setIsHoveredState(true);
};
const handleMouseLeave = () => {
setIsHoveredState(false);
saveCurrentTime();
};
const handleFocusIn = () => {
setIsFocusWithin(true);
};
const handleFocusOut = (event: FocusEvent) => {
const nextTarget = event.relatedTarget;
if (nextTarget != null && container.contains(nextTarget as Node)) return;
setIsFocusWithin(false);
saveCurrentTime();
};
container.addEventListener('mouseenter', handleMouseEnter);
container.addEventListener('mouseleave', handleMouseLeave);
const unsubscribe = subscribeAnimatedMediaPlaybackChange(handlePlaybackAllowedChange);
container.addEventListener('focusin', handleFocusIn);
container.addEventListener('focusout', handleFocusOut);
return () => {
container.removeEventListener('mouseenter', handleMouseEnter);
container.removeEventListener('mouseleave', handleMouseLeave);
unsubscribe();
container.removeEventListener('focusin', handleFocusIn);
container.removeEventListener('focusout', handleFocusOut);
};
}, [gifAutoPlay, isMediaViewerOpen, shouldLoadMedia]);
}, [containerElement]);
useEffect(() => {
const video = videoRef.current;
if (video == null) return;
playbackRequestIdRef.current += 1;
const playbackWanted = shouldLoadVideo && !isMediaViewerOpen && (gifAutoPlay || isHoveredState || isFocusWithin);
if (!playbackWanted || !animatedMediaPlaybackAllowed) {
video.autoplay = false;
if (playbackWanted && currentRenderSourceKeyRef.current === renderSourceKey) {
dispatchRenderEvent({type: GIFVRenderEventType.PLAYBACK_BLOCKED});
}
safePause(video);
return;
}
video.autoplay = true;
const target = savedTimeRef.current;
if (Number.isFinite(target) && Math.abs(video.currentTime - target) > 0.01) {
try {
video.currentTime = target;
} catch {}
}
requestVideoPlayback(video);
}, [
animatedMediaPlaybackAllowed,
gifAutoPlay,
isFocusWithin,
isHoveredState,
isMediaViewerOpen,
renderSourceKey,
requestVideoPlayback,
shouldLoadVideo,
]);
if (shouldBlur) {
const {style} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight}, {forceScale: true});
const {width: _width, height: _height, ...styleWithoutDimensions} = style;
@@ -577,10 +813,7 @@ export const EmbedGifv: FC<
</div>
);
}
const {style, dimensions} = mediaCalculator.calculate(
{width: naturalWidth, height: naturalHeight},
{forceScale: true},
);
const {style} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight}, {forceScale: true});
const {
showFavoriteButton,
showDownloadButton: _showDownloadButton,
@@ -601,7 +834,28 @@ export const EmbedGifv: FC<
width: remFromPx(dimensions.width),
...(aspectRatio ? {aspectRatio} : {}),
} as React.CSSProperties;
const effectivePreload = shouldLoadMedia ? (videoConfig?.preload ?? (gifAutoPlay ? 'auto' : 'metadata')) : 'none';
let effectivePreload: 'none' | 'metadata' | 'auto' = 'none';
if (shouldLoadVideo) {
if (videoConfig && videoConfig.preload !== undefined) {
effectivePreload = videoConfig.preload;
} else {
effectivePreload = gifAutoPlay ? 'auto' : 'metadata';
}
}
const renderLayers = getGIFVRenderLayers(currentRenderState);
const posterReady = loaded && !error;
const showPoster = shouldLoadPoster && posterReady && renderLayers.showPoster;
const showVideo = shouldLoadVideo && renderLayers.showVideo;
let videoControls = false;
let videoPlaysInline = true;
let videoLoop = true;
let videoMuted = true;
if (videoConfig) {
if (videoConfig.controls !== undefined) videoControls = videoConfig.controls;
if (videoConfig.playsInline !== undefined) videoPlaysInline = videoConfig.playsInline;
if (videoConfig.loop !== undefined) videoLoop = videoConfig.loop;
if (videoConfig.muted !== undefined) videoMuted = videoConfig.muted;
}
return (
<MediaContainer
ref={setContainerRef}
@@ -621,11 +875,6 @@ export const EmbedGifv: FC<
data-flx="channel.embeds.media.embed-gifv.media-container.context-menu"
>
{showGifIndicator && <GifIndicator data-flx="channel.embeds.media.embed-gifv.gif-indicator" />}
{providerName === 'KLIPY' && (
<div className={styles.klipyWatermark} data-flx="channel.embeds.media.embed-gifv.klipy-watermark">
<KlipyWatermarkSvg data-flx="channel.embeds.media.embed-gifv.klipy-watermark-svg" />
</div>
)}
<ImagePreviewHandler
src={videoProxyURL}
originalSrc={embedURL}
@@ -636,7 +885,7 @@ export const EmbedGifv: FC<
data-flx="channel.embeds.media.embed-gifv.image-preview-handler.gifv"
>
<div className={styles.videoWrapper} data-flx="channel.embeds.media.embed-gifv.video-wrapper">
{(!loaded || error) && thumbHashURL && (
{(renderLayers.showPlaceholder || !loaded || error) && thumbHashURL && (
<img
src={thumbHashURL}
className={styles.thumbHashPlaceholder}
@@ -644,29 +893,48 @@ export const EmbedGifv: FC<
data-flx="channel.embeds.media.embed-gifv.thumb-hash-placeholder--2"
/>
)}
<motion.img
ref={setPosterRef}
className={styles.posterElement}
alt={i18n._(LOADING_PLACEHOLDER_DESCRIPTOR)}
src={shouldLoadPoster ? posterURL : undefined}
loading="lazy"
width={dimensions.width}
height={dimensions.height}
tabIndex={-1}
onLoad={handlePosterLoad}
onError={handlePosterError}
initial={{opacity: cached || cachedOnMount ? 1 : 0}}
animate={{opacity: showPoster ? 1 : 0}}
transition={{
duration:
cached || cachedOnMount || Accessibility.useReducedMotion ? 0 : EMBED_MEDIA_FADE_DURATION_SECONDS,
}}
data-flx="channel.embeds.media.embed-gifv.poster-element"
/>
<motion.video
className={styles.videoElement}
controls={videoConfig?.controls ?? false}
playsInline={videoConfig?.playsInline ?? true}
loop={videoConfig?.loop ?? true}
muted={videoConfig?.muted ?? true}
poster={shouldLoadMedia ? posterURL : thumbHashURL}
controls={videoControls}
playsInline={videoPlaysInline}
loop={videoLoop}
muted={videoMuted}
poster={shouldLoadPoster ? posterURL : thumbHashURL}
preload={effectivePreload}
src={shouldLoadMedia ? videoProxyURL : undefined}
src={shouldLoadVideo ? videoProxyURL : undefined}
ref={setVideoRef}
aria-label={i18n._(ANIMATED_GIF_VIDEO_DESCRIPTOR)}
data-embed-media="gifv"
tabIndex={-1}
width={dimensions.width}
height={dimensions.height}
onLoadedData={handleMediaLoad}
onError={handleMediaError}
initial={{opacity: cached || cachedOnMount ? 1 : 0}}
animate={{opacity: !loaded && !error ? 0 : 1}}
transition={{
duration:
cached || cachedOnMount || Accessibility.useReducedMotion ? 0 : EMBED_MEDIA_FADE_DURATION_SECONDS,
}}
onLoadedData={handleVideoReady}
onCanPlay={handleVideoReady}
onPlaying={handleVideoPlaying}
onPause={handleVideoPause}
onError={handleVideoError}
initial={{opacity: 0}}
animate={{opacity: showVideo ? 1 : 0}}
transition={{duration: Accessibility.useReducedMotion ? 0 : EMBED_MEDIA_FADE_DURATION_SECONDS}}
data-flx="channel.embeds.media.embed-gifv.video-element"
/>
</div>
@@ -713,7 +981,9 @@ export const EmbedGif: FC<
const isMobile = MobileLayout.enabled;
const mediaCalculator = useEmbedMediaCalculator(layoutConstraints);
const containerRef = useRef<HTMLDivElement>(null);
const [containerElement, setContainerElement] = useState<HTMLDivElement | null>(null);
const imgRef = useRef<HTMLImageElement>(null);
const animatedImageRef = useRef<HTMLImageElement>(null);
const freezeCanvasRef = useRef<HTMLCanvasElement>(null);
const {ref: visibilityRef, isNearViewport} = useNearViewport<HTMLDivElement>({
disabled: !isMobile,
@@ -722,6 +992,7 @@ export const EmbedGif: FC<
const setContainerRef = useCallback(
(node: HTMLDivElement | null) => {
containerRef.current = node;
setContainerElement(node);
visibilityRef(node);
},
[visibilityRef],
@@ -733,7 +1004,9 @@ export const EmbedGif: FC<
const baseProxyURL = stripMediaProxyParams(proxyURL);
const animatedTargetWidth = Math.min(naturalWidth, Math.round(displayWidth * 2));
const animatedTargetHeight = Math.min(naturalHeight, Math.round(displayHeight * 2));
const shouldResizeAnimated = animatedTargetWidth < naturalWidth || animatedTargetHeight < naturalHeight;
const naturalSizeIsKnown = naturalWidth > 0 && naturalHeight > 0;
const shouldResizeAnimated =
naturalSizeIsKnown && (animatedTargetWidth < naturalWidth || animatedTargetHeight < naturalHeight);
const optimizedAnimatedURL = buildFittedAnimatedImageProxyURL(
baseProxyURL,
shouldResizeAnimated ? animatedTargetWidth : undefined,
@@ -744,6 +1017,7 @@ export const EmbedGif: FC<
Math.round(displayWidth * 2),
Math.round(displayHeight * 2),
);
const animatedImageSourceRef = useRef(optimizedAnimatedURL);
const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId);
const shouldLoadMedia = isNearViewport && !shouldBlur;
const {
@@ -769,6 +1043,11 @@ export const EmbedGif: FC<
const isHoveredRef = useRef(false);
const [hasStartedAnimating, setHasStartedAnimating] = useState(gifAutoPlay);
const [isHoveredState, setIsHoveredState] = useState(false);
const [renderedAnimatedURL, setRenderedAnimatedURL] = useState('');
const animatedPreloadStateRef = useRef<AnimatedImagePreloadState>({
url: optimizedAnimatedURL,
status: ImageCacheUtils.hasImage(optimizedAnimatedURL) ? 'ready' : 'idle',
});
const [decoderRequested, setDecoderRequested] = useState(() => getAnimatedMediaPlaybackAllowed());
const shouldUseDecoder = shouldLoadMedia && hasStartedAnimating;
const decoderPlaying =
@@ -781,7 +1060,53 @@ export const EmbedGif: FC<
});
const useDecoder =
shouldUseDecoder && decoderRequested && decoderState.supported && hasStartedAnimating && !decoderState.error;
const shouldRenderFreezeFrame = !useDecoder && (gifAutoPlay || hasStartedAnimating);
const decoderCanvasVisible = useDecoder && decoderState.loaded;
const shouldRenderFreezeFrame = !decoderCanvasVisible && (gifAutoPlay || hasStartedAnimating);
const setAnimatedImageRef = useCallback(
(node: HTMLImageElement | null) => {
animatedImageRef.current = node;
if (animatedImageSourceRef.current !== optimizedAnimatedURL) return;
if (completeImageHasSource(node, optimizedAnimatedURL)) setRenderedAnimatedURL(optimizedAnimatedURL);
},
[optimizedAnimatedURL],
);
useLayoutEffect(() => {
animatedImageSourceRef.current = optimizedAnimatedURL;
setRenderedAnimatedURL('');
if (completeImageHasSource(animatedImageRef.current, optimizedAnimatedURL)) {
setRenderedAnimatedURL(optimizedAnimatedURL);
}
}, [optimizedAnimatedURL]);
useLayoutEffect(() => {
const nextState: AnimatedImagePreloadState = {
url: optimizedAnimatedURL,
status: ImageCacheUtils.hasImage(optimizedAnimatedURL) ? 'ready' : 'idle',
};
animatedPreloadStateRef.current = nextState;
}, [optimizedAnimatedURL]);
const handleAnimatedImageLoad = useCallback(
(event: React.SyntheticEvent<HTMLImageElement>) => {
const element = event.currentTarget;
if (animatedImageSourceRef.current !== optimizedAnimatedURL) return;
if (animatedImageRef.current !== element) return;
if (!completeImageHasSource(element, optimizedAnimatedURL)) return;
ImageCacheUtils.rememberImage(optimizedAnimatedURL, element);
animatedPreloadStateRef.current = {url: optimizedAnimatedURL, status: 'ready'};
setRenderedAnimatedURL(optimizedAnimatedURL);
},
[optimizedAnimatedURL],
);
const handleAnimatedImageError = useCallback(
(event: React.SyntheticEvent<HTMLImageElement>) => {
if (animatedImageSourceRef.current !== optimizedAnimatedURL) return;
if (animatedImageRef.current !== event.currentTarget) return;
if (!mediaElementHasSource(event.currentTarget, optimizedAnimatedURL)) return;
setRenderedAnimatedURL('');
ImageCacheUtils.forgetImage(optimizedAnimatedURL);
animatedPreloadStateRef.current = {url: optimizedAnimatedURL, status: 'failed'};
},
[optimizedAnimatedURL],
);
const defaultName = deriveDefaultNameFromMessage({
message,
attachmentId,
@@ -880,39 +1205,81 @@ export const EmbedGif: FC<
}
}, [animatedMediaPlaybackAllowed, shouldUseDecoder]);
useEffect(() => {
if (!shouldLoadMedia || gifAutoPlay || !animatedMediaPlaybackAllowed) return;
if (!optimizedAnimatedURL) return;
const preloader = new Image();
preloader.src = optimizedAnimatedURL;
}, [animatedMediaPlaybackAllowed, gifAutoPlay, optimizedAnimatedURL, shouldLoadMedia]);
const shouldPreloadAnimated = shouldLoadMedia && animatedMediaPlaybackAllowed && optimizedAnimatedURL.length > 0;
if (!shouldPreloadAnimated) return;
const preloadState = animatedPreloadStateRef.current;
if (preloadState.url !== optimizedAnimatedURL || preloadState.status !== 'idle') return;
const loadingState: AnimatedImagePreloadState = {url: optimizedAnimatedURL, status: 'loading'};
animatedPreloadStateRef.current = loadingState;
let active = true;
const cleanup = ImageCacheUtils.loadImage(
optimizedAnimatedURL,
() => {
if (!active) return;
const readyState: AnimatedImagePreloadState = {url: optimizedAnimatedURL, status: 'ready'};
animatedPreloadStateRef.current = readyState;
},
() => {
if (!active) return;
const failedState: AnimatedImagePreloadState = {url: optimizedAnimatedURL, status: 'failed'};
animatedPreloadStateRef.current = failedState;
},
);
return () => {
active = false;
cleanup();
if (animatedPreloadStateRef.current !== loadingState) return;
const idleState: AnimatedImagePreloadState = {url: optimizedAnimatedURL, status: 'idle'};
animatedPreloadStateRef.current = idleState;
};
}, [animatedMediaPlaybackAllowed, optimizedAnimatedURL, shouldLoadMedia]);
const showFreezeFrame = useCallback(() => {
const img = imgRef.current;
const canvas = freezeCanvasRef.current;
if (!img || !canvas) return;
const sourceWidth = img.naturalWidth || img.width;
const sourceHeight = img.naturalHeight || img.height;
if (sourceWidth === 0 || sourceHeight === 0) return;
if (canvas == null) return;
canvas.removeAttribute('data-frozen');
const animatedImage = animatedImageRef.current;
const staticImage = imgRef.current;
let image: HTMLImageElement;
if (completeImageHasSource(animatedImage, optimizedAnimatedURL)) {
image = animatedImage;
} else if (completeImageHasSource(staticImage, optimizedStaticURL)) {
image = staticImage;
} else {
return;
}
const sourceWidth = image.naturalWidth;
const sourceHeight = image.naturalHeight;
if (canvas.width !== sourceWidth) canvas.width = sourceWidth;
if (canvas.height !== sourceHeight) canvas.height = sourceHeight;
const ctx = canvas.getContext('2d');
if (!ctx) return;
if (ctx == null) return;
try {
ctx.clearRect(0, 0, sourceWidth, sourceHeight);
ctx.drawImage(img, 0, 0, sourceWidth, sourceHeight);
ctx.drawImage(image, 0, 0, sourceWidth, sourceHeight);
} catch {
return;
}
canvas.dataset.frozen = 'true';
}, []);
}, [optimizedAnimatedURL, optimizedStaticURL]);
const hideFreezeFrame = useCallback(() => {
const canvas = freezeCanvasRef.current;
if (!canvas) return;
canvas.dataset.frozen = 'false';
if (canvas == null) return;
canvas.removeAttribute('data-frozen');
}, []);
const resetFreezeFrame = useCallback(() => {
const canvas = freezeCanvasRef.current;
if (canvas == null) return;
canvas.removeAttribute('data-frozen');
canvas.width = 0;
canvas.height = 0;
}, []);
useLayoutEffect(() => {
resetFreezeFrame();
}, [optimizedAnimatedURL, optimizedStaticURL, resetFreezeFrame]);
useEffect(() => {
if (!shouldLoadMedia || gifAutoPlay) return;
const container = containerRef.current;
if (!container) return;
const container = containerElement;
if (container == null) return;
const handleMouseEnter = () => {
isHoveredRef.current = true;
setIsHoveredState(true);
@@ -934,7 +1301,7 @@ export const EmbedGif: FC<
container.removeEventListener('mouseenter', handleMouseEnter);
container.removeEventListener('mouseleave', handleMouseLeave);
};
}, [gifAutoPlay, hasStartedAnimating, hideFreezeFrame, shouldLoadMedia, showFreezeFrame]);
}, [containerElement, gifAutoPlay, hasStartedAnimating, hideFreezeFrame, shouldLoadMedia, showFreezeFrame]);
useEffect(() => {
if (!shouldLoadMedia || gifAutoPlay) return;
if (!animatedMediaPlaybackAllowed) {
@@ -1041,8 +1408,9 @@ export const EmbedGif: FC<
const shouldUseAnimatedImage =
shouldLoadMedia &&
animatedMediaPlaybackAllowed &&
!useDecoder &&
!decoderCanvasVisible &&
(gifAutoPlay || (hasStartedAnimating && isHoveredState));
const animatedImageVisible = shouldUseAnimatedImage && renderedAnimatedURL === optimizedAnimatedURL;
return (
<MediaContainer
ref={setContainerRef}
@@ -1090,7 +1458,7 @@ export const EmbedGif: FC<
<motion.img
ref={setImgRef}
alt={i18n._(ANIMATED_GIF_DESCRIPTOR)}
src={shouldLoadMedia ? (shouldUseAnimatedImage ? optimizedAnimatedURL : optimizedStaticURL) : undefined}
src={shouldLoadMedia ? optimizedStaticURL : undefined}
className={styles.videoElement}
data-embed-media="gif"
loading={isMobile ? 'lazy' : 'eager'}
@@ -1100,21 +1468,41 @@ export const EmbedGif: FC<
onLoad={handleImageLoad}
onError={handleImageError}
initial={{opacity: cached || cachedOnMount ? 1 : 0}}
animate={{
opacity: (!loaded && !error) || (useDecoder && decoderState.loaded) ? 0 : 1,
}}
animate={{opacity: loaded && !decoderCanvasVisible ? 1 : 0}}
transition={{
duration:
cached || cachedOnMount || Accessibility.useReducedMotion ? 0 : EMBED_MEDIA_FADE_DURATION_SECONDS,
}}
data-flx="channel.embeds.media.embed-gifv.embed-gif.video-element"
/>
{shouldUseAnimatedImage && (
<motion.img
ref={setAnimatedImageRef}
alt={i18n._(ANIMATED_GIF_DESCRIPTOR)}
src={optimizedAnimatedURL}
className={styles.posterElement}
data-embed-media="gif-animated"
loading={isMobile ? 'lazy' : 'eager'}
tabIndex={-1}
width={renderedDimensions.width}
height={renderedDimensions.height}
onLoad={handleAnimatedImageLoad}
onError={handleAnimatedImageError}
initial={{opacity: 0}}
animate={{opacity: animatedImageVisible ? 1 : 0}}
transition={{duration: Accessibility.useReducedMotion ? 0 : EMBED_MEDIA_FADE_DURATION_SECONDS}}
data-flx="channel.embeds.media.embed-gifv.embed-gif.animated-image"
/>
)}
<canvas
ref={setDecoderCanvas}
className={clsx(styles.videoElement, useDecoder ? styles.videoOpacityVisible : styles.videoOpacityHidden)}
style={{position: 'absolute', inset: 0, pointerEvents: 'none'}}
className={clsx(
styles.videoElement,
decoderCanvasVisible ? styles.videoOpacityVisible : styles.videoOpacityHidden,
)}
style={{position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: decoderCanvasVisible ? 3 : 0}}
tabIndex={-1}
aria-hidden={useDecoder ? undefined : true}
aria-hidden={decoderCanvasVisible ? undefined : true}
width={renderedDimensions.width}
height={renderedDimensions.height}
data-flx="channel.embeds.media.embed-gifv.embed-gif.video-element--2"