perf(app): play gif embeds from the viewport not a js decoder (#1877)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:36:38 +02:00
1 parent 69e0086144
commit 1f0f7d1222
14 files changed
+533 -1820

No files matched your search

@@ -1,55 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {getNextAnimatedImageFrame} from './useAnimatedImageDecoder';
describe('getNextAnimatedImageFrame', () => {
it('stops on the final frame when the image has no repetitions', () => {
let state = {frameIndex: 0, frameCount: 3, repetitionCount: 0, completedRepetitions: 0};
const secondFrame = getNextAnimatedImageFrame(state);
expect(secondFrame).toEqual({frameIndex: 1, completedRepetitions: 0});
state = {...state, ...secondFrame!};
const finalFrame = getNextAnimatedImageFrame(state);
expect(finalFrame).toEqual({frameIndex: 2, completedRepetitions: 0});
state = {...state, ...finalFrame!};
expect(getNextAnimatedImageFrame(state)).toBeNull();
});
it('allows the configured number of finite repetitions before stopping', () => {
let state = {frameIndex: 0, frameCount: 2, repetitionCount: 1, completedRepetitions: 0};
const finalFrame = getNextAnimatedImageFrame(state);
expect(finalFrame).toEqual({frameIndex: 1, completedRepetitions: 0});
state = {...state, ...finalFrame!};
const repeatedFirstFrame = getNextAnimatedImageFrame(state);
expect(repeatedFirstFrame).toEqual({frameIndex: 0, completedRepetitions: 1});
state = {...state, ...repeatedFirstFrame!};
const repeatedFinalFrame = getNextAnimatedImageFrame(state);
expect(repeatedFinalFrame).toEqual({frameIndex: 1, completedRepetitions: 1});
state = {...state, ...repeatedFinalFrame!};
expect(getNextAnimatedImageFrame(state)).toBeNull();
});
it('keeps advancing indefinitely for infinite repetitions', () => {
let state = {frameIndex: 1, frameCount: 2, repetitionCount: Infinity, completedRepetitions: 99};
const firstFrame = getNextAnimatedImageFrame(state);
expect(firstFrame).toEqual({frameIndex: 0, completedRepetitions: 100});
state = {...state, ...firstFrame!};
expect(getNextAnimatedImageFrame(state)).toEqual({frameIndex: 1, completedRepetitions: 100});
});
it('does not loop single-frame images or invalid repetition counts', () => {
expect(
getNextAnimatedImageFrame({
frameIndex: 0,
frameCount: 1,
repetitionCount: Infinity,
completedRepetitions: 0,
}),
).toBeNull();
expect(
getNextAnimatedImageFrame({
frameIndex: 1,
frameCount: 2,
repetitionCount: Number.NaN,
completedRepetitions: 0,
}),
).toBeNull();
});
});
@@ -1,730 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
drawVideoFrameToCanvas,
type FluxerImageDecoderConstructor,
type FluxerImageDecoderInstance,
getImageDecoderConstructor,
} from '@app/features/platform/utils/ImageDecoderInterop';
import {useEffect, useRef, useState} from 'react';
interface UseAnimatedImageDecoderOptions {
src: string | null;
playing: boolean;
canvas: HTMLCanvasElement | null;
maxCachedFrames?: number;
}
export interface AnimatedImageDecoderState {
supported: boolean;
loaded: boolean;
error: boolean;
naturalWidth: number;
naturalHeight: number;
}
interface DecoderSourceIdentity {
src: string | null;
canvas: HTMLCanvasElement | null;
maxCachedFrames: number | undefined;
}
export interface AnimatedImageFrameAdvanceState {
frameIndex: number;
frameCount: number;
repetitionCount: number;
completedRepetitions: number;
}
export interface AnimatedImageFrameAdvanceResult {
frameIndex: number;
completedRepetitions: number;
}
const DEFAULT_FRAME_DURATION_MS = 100;
const DEFAULT_MAX_CACHED_FRAMES = 24;
const MAX_CACHED_FRAME_BYTES = 64 * 1024 * 1024;
const MAXIMUM_FRAME_TIMER_DELAY_MS = 2_147_000_000;
const MAX_ANIMATED_IMAGE_ENCODED_BYTES = 16 * 1024 * 1024;
const MAX_ANIMATED_IMAGE_RESPONSE_CHUNKS = 4096;
const ANIMATED_IMAGE_REQUEST_TIMEOUT_MS = 30_000;
function createIdleDecoderState(): AnimatedImageDecoderState {
return {
supported: getImageDecoderConstructor() !== null,
loaded: false,
error: false,
naturalWidth: 0,
naturalHeight: 0,
};
}
function sameDecoderSourceIdentity(current: DecoderSourceIdentity, next: DecoderSourceIdentity): boolean {
return current.src === next.src && current.canvas === next.canvas && current.maxCachedFrames === next.maxCachedFrames;
}
interface CachedAnimatedImageFrame {
image: CanvasImageSource;
width: number;
height: number;
byteSize: number;
durationMs: number;
close: () => void;
}
interface AnimatedImageResponseData {
type: string;
encodedBytes: Uint8Array;
}
const guessMimeFromUrl = (url: string): string => {
const path = url.split('?')[0];
const lower = path === undefined ? '' : path.toLowerCase();
if (lower.endsWith('.webp')) return 'image/webp';
if (lower.endsWith('.gif')) return 'image/gif';
if (lower.endsWith('.apng') || lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.avif')) return 'image/avif';
return 'image/webp';
};
function resolveMimeFromResponse(contentType: string | null, source: string): string {
if (contentType === null) return guessMimeFromUrl(source);
const separator = contentType.indexOf(';');
const mediaType = separator < 0 ? contentType : contentType.slice(0, separator);
const normalized = mediaType.trim().toLowerCase();
return normalized.length === 0 ? guessMimeFromUrl(source) : normalized;
}
function decodedFrameByteSize(width: number, height: number): number | null {
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height)) return null;
if (width <= 0 || height <= 0) return null;
const byteSize = width * height * 4;
if (!Number.isSafeInteger(byteSize)) return null;
return byteSize;
}
function frameDurationMs(durationMicroseconds: number | null | undefined): number {
if (durationMicroseconds === null || durationMicroseconds === undefined) return DEFAULT_FRAME_DURATION_MS;
if (!Number.isFinite(durationMicroseconds) || durationMicroseconds <= 0) return DEFAULT_FRAME_DURATION_MS;
return Math.min(MAXIMUM_FRAME_TIMER_DELAY_MS, Math.max(16, durationMicroseconds / 1000));
}
function cancelResponseBody(response: Response): void {
const body = response.body;
if (body == null) return;
try {
void body.cancel().catch(() => {});
} catch {}
}
function closeVideoFrame(image: VideoFrame): void {
try {
image.close();
} catch {}
}
async function readBoundedResponseBytes(response: Response): Promise<Uint8Array> {
const body = response.body;
if (body == null) throw new Error('Animated image response did not provide a body');
const contentLength = response.headers.get('content-length');
if (contentLength !== null) {
const maximumContentLengthDigits = Number.MAX_SAFE_INTEGER.toString().length;
if (
contentLength.length === 0 ||
contentLength.length > maximumContentLengthDigits ||
!/^[0-9]+$/.test(contentLength)
) {
cancelResponseBody(response);
throw new Error('Animated image response has an invalid Content-Length header');
}
const declaredBytes = Number(contentLength);
if (!Number.isSafeInteger(declaredBytes) || declaredBytes > MAX_ANIMATED_IMAGE_ENCODED_BYTES) {
cancelResponseBody(response);
throw new Error('Animated image response exceeded its byte limit');
}
}
const reader = body.getReader();
const chunks: Array<Uint8Array> = [];
let totalBytes = 0;
try {
while (true) {
const result = await reader.read();
if (result.done) break;
const chunk = result.value;
if (chunk == null) continue;
if (chunks.length >= MAX_ANIMATED_IMAGE_RESPONSE_CHUNKS) {
throw new Error('Animated image response exceeded its chunk limit');
}
totalBytes += chunk.byteLength;
if (totalBytes > MAX_ANIMATED_IMAGE_ENCODED_BYTES) {
throw new Error('Animated image response exceeded its byte limit');
}
chunks.push(chunk);
}
} catch (error) {
try {
await reader.cancel();
} catch {}
throw error;
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
}
function normalizeRepetitionCount(repetitionCount: number): number {
if (repetitionCount === Infinity) return Infinity;
if (!Number.isFinite(repetitionCount)) return 0;
return Math.max(0, Math.floor(repetitionCount));
}
async function createCachedAnimatedImageFrame(
image: VideoFrame,
byteSize: number,
durationMs: number,
ownerWindow: Window,
): Promise<CachedAnimatedImageFrame> {
const width = image.displayWidth;
const height = image.displayHeight;
if (typeof ownerWindow.createImageBitmap === 'function') {
try {
const bitmap = await ownerWindow.createImageBitmap(image as unknown as ImageBitmapSource);
closeVideoFrame(image);
return {
image: bitmap,
width,
height,
byteSize,
durationMs,
close: () => bitmap.close(),
};
} catch {}
}
return {
image: image as unknown as CanvasImageSource,
width,
height,
byteSize,
durationMs,
close: () => image.close(),
};
}
export function getNextAnimatedImageFrame({
frameIndex,
frameCount,
repetitionCount,
completedRepetitions,
}: AnimatedImageFrameAdvanceState): AnimatedImageFrameAdvanceResult | null {
const normalizedFrameCount = Math.max(0, Math.floor(frameCount));
if (normalizedFrameCount <= 1) return null;
const currentFrameIndex = Math.max(0, Math.min(normalizedFrameCount - 1, Math.floor(frameIndex)));
const normalizedCompletedRepetitions = Math.max(0, Math.floor(completedRepetitions));
if (currentFrameIndex < normalizedFrameCount - 1) {
return {frameIndex: currentFrameIndex + 1, completedRepetitions: normalizedCompletedRepetitions};
}
const normalizedRepetitionCount = normalizeRepetitionCount(repetitionCount);
if (normalizedCompletedRepetitions >= normalizedRepetitionCount) return null;
return {frameIndex: 0, completedRepetitions: normalizedCompletedRepetitions + 1};
}
export function useAnimatedImageDecoder({
src,
playing,
canvas,
maxCachedFrames = DEFAULT_MAX_CACHED_FRAMES,
}: UseAnimatedImageDecoderOptions): AnimatedImageDecoderState {
const sourceIdentity: DecoderSourceIdentity = {src, canvas, maxCachedFrames};
const [state, setState] = useState<AnimatedImageDecoderState>(createIdleDecoderState);
const [stateIdentity, setStateIdentity] = useState<DecoderSourceIdentity>(sourceIdentity);
const runnerRef = useRef<{
kick: () => void;
pause: () => void;
cancelled: boolean;
} | null>(null);
const playingRef = useRef(playing);
playingRef.current = playing;
const stateIsCurrent = sameDecoderSourceIdentity(stateIdentity, sourceIdentity);
const visibleState = stateIsCurrent ? state : createIdleDecoderState();
useEffect(() => {
setStateIdentity(sourceIdentity);
const Ctor = getImageDecoderConstructor();
setState(createIdleDecoderState);
if (!Ctor || !src || !canvas) {
if (!Ctor) setState((prev) => ({...prev, supported: false}));
return;
}
const ownerWindow = canvas.ownerDocument.defaultView;
if (ownerWindow == null) {
setState((prev) => ({...prev, error: true, supported: false}));
return;
}
const ctx = canvas.getContext('2d', {alpha: true});
if (!ctx) {
setState((prev) => ({...prev, error: true}));
return;
}
const normalizedMaxCachedFrames = Number.isFinite(maxCachedFrames)
? Math.max(2, Math.floor(maxCachedFrames))
: DEFAULT_MAX_CACHED_FRAMES;
const runner = {cancelled: false, kick: () => {}, pause: () => {}};
runnerRef.current = runner;
const fetchController = new ownerWindow.AbortController();
let decoder: FluxerImageDecoderInstance | null = null;
let frameCount = Number.POSITIVE_INFINITY;
let repetitionCount = 0;
let completedRepetitions = 0;
let frameIndex = 0;
let timer: number | null = null;
let resolveTimer: (() => void) | null = null;
let advancing = false;
let cachedFrameBytes = 0;
const frameCache = new Map<number, CachedAnimatedImageFrame>();
const isCurrentRunner = () => !runner.cancelled && runnerRef.current === runner;
const publishState = (
nextState: AnimatedImageDecoderState | ((previous: AnimatedImageDecoderState) => AnimatedImageDecoderState),
) => {
if (!isCurrentRunner()) return;
setState(nextState);
};
const closeFrameCache = () => {
for (const frame of frameCache.values()) {
try {
frame.close();
} catch {}
}
frameCache.clear();
cachedFrameBytes = 0;
};
const closeDecoder = () => {
if (decoder == null) return;
try {
decoder.close();
} catch {}
decoder = null;
};
const failRunner = () => {
if (runner.cancelled) return;
publishState((prev) => ({...prev, error: true}));
runner.cancelled = true;
try {
fetchController.abort();
} catch {}
clearTimer();
closeFrameCache();
closeDecoder();
};
publishState((prev) => ({...prev, loaded: false, error: false, supported: true}));
const clearTimer = () => {
if (timer != null) {
ownerWindow.clearTimeout(timer);
timer = null;
}
const resolve = resolveTimer;
resolveTimer = null;
if (resolve != null) resolve();
};
const draw = (frame: CachedAnimatedImageFrame): boolean => {
if (!isCurrentRunner()) return false;
const w = frame.width;
const h = frame.height;
if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h;
try {
ctx.clearRect(0, 0, w, h);
ctx.drawImage(frame.image, 0, 0, w, h);
return true;
} catch {
return false;
}
};
const getFrame = async (index: number): Promise<CachedAnimatedImageFrame | null> => {
const cached = frameCache.get(index);
if (cached) {
frameCache.delete(index);
frameCache.set(index, cached);
return cached;
}
const activeDecoder = decoder;
if (activeDecoder == null) return null;
let decodedImage: VideoFrame | null = null;
try {
const result = await activeDecoder.decode({frameIndex: index, completeFramesOnly: true});
decodedImage = result.image;
if (!isCurrentRunner()) {
closeVideoFrame(decodedImage);
decodedImage = null;
return null;
}
const byteSize = decodedFrameByteSize(decodedImage.displayWidth, decodedImage.displayHeight);
if (byteSize == null || byteSize > MAX_CACHED_FRAME_BYTES) {
closeVideoFrame(decodedImage);
decodedImage = null;
failRunner();
return null;
}
const durationMs = frameDurationMs(decodedImage.duration);
const entry = await createCachedAnimatedImageFrame(decodedImage, byteSize, durationMs, ownerWindow);
decodedImage = null;
if (!isCurrentRunner()) {
entry.close();
return null;
}
frameCache.set(index, entry);
cachedFrameBytes += entry.byteSize;
while (frameCache.size > normalizedMaxCachedFrames || cachedFrameBytes > MAX_CACHED_FRAME_BYTES) {
const oldestIndex = frameCache.keys().next().value;
if (oldestIndex === undefined) break;
const oldest = frameCache.get(oldestIndex);
frameCache.delete(oldestIndex);
if (oldest != null) {
cachedFrameBytes -= oldest.byteSize;
try {
oldest.close();
} catch {}
}
}
return entry;
} catch {
if (decodedImage != null) {
closeVideoFrame(decodedImage);
}
failRunner();
return null;
}
};
const advance = async () => {
if (advancing || runner.cancelled) return;
advancing = true;
try {
while (!runner.cancelled && playingRef.current) {
const next = getNextAnimatedImageFrame({
frameIndex,
frameCount,
repetitionCount,
completedRepetitions,
});
if (!next) return;
const frame = await getFrame(next.frameIndex);
if (!frame || runner.cancelled || !playingRef.current) return;
frameIndex = next.frameIndex;
completedRepetitions = next.completedRepetitions;
if (!draw(frame)) {
failRunner();
return;
}
await new Promise<void>((resolve) => {
clearTimer();
resolveTimer = resolve;
timer = ownerWindow.setTimeout(
() => {
timer = null;
resolveTimer = null;
resolve();
},
Math.max(16, frame.durationMs),
);
});
if (runner.cancelled || !playingRef.current) return;
}
} finally {
advancing = false;
}
};
runner.kick = () => {
if (runner.cancelled) return;
void advance();
};
runner.pause = () => {
clearTimer();
};
const loadAnimatedImage = async (): Promise<AnimatedImageResponseData | null> => {
let response: Response | null = null;
let timeout: number | null = null;
const operation = (async (): Promise<AnimatedImageResponseData | null> => {
const fetchedResponse = await ownerWindow.fetch(src, {
cache: 'force-cache',
credentials: 'omit',
redirect: 'error',
referrerPolicy: 'no-referrer',
signal: fetchController.signal,
});
response = fetchedResponse;
if (!fetchedResponse.ok || fetchedResponse.body == null) {
cancelResponseBody(fetchedResponse);
throw new Error('Animated image request failed');
}
const type = resolveMimeFromResponse(fetchedResponse.headers.get('content-type'), src);
const isSupported = await Ctor.isTypeSupported(type).catch(() => false);
if (!isCurrentRunner()) return null;
if (!isSupported) {
cancelResponseBody(fetchedResponse);
return null;
}
const encodedBytes = await readBoundedResponseBytes(fetchedResponse);
return {type, encodedBytes};
})();
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timeout = ownerWindow.setTimeout(() => {
try {
fetchController.abort();
} catch {}
if (response != null) cancelResponseBody(response);
reject(new Error('Animated image request timed out'));
}, ANIMATED_IMAGE_REQUEST_TIMEOUT_MS);
});
try {
return await Promise.race([operation, timeoutPromise]);
} finally {
if (timeout != null) ownerWindow.clearTimeout(timeout);
}
};
const start = async () => {
try {
const animatedImage = await loadAnimatedImage();
if (!isCurrentRunner()) return;
if (animatedImage == null) {
publishState((prev) => ({...prev, supported: false}));
runner.cancelled = true;
return;
}
const {type, encodedBytes} = animatedImage;
const decoderBuffer = new ArrayBuffer(encodedBytes.byteLength);
new Uint8Array(decoderBuffer).set(encodedBytes);
const decoderBody = new ownerWindow.Response(decoderBuffer).body;
if (decoderBody == null) throw new Error('Animated image decoder body was unavailable');
const createdDecoder = new Ctor({data: decoderBody, type, preferAnimation: true});
decoder = createdDecoder;
await createdDecoder.completed;
if (!isCurrentRunner()) return;
const activeDecoder = decoder;
if (activeDecoder == null) throw new Error('Animated image decoder was closed before completion');
const track = activeDecoder.tracks.selectedTrack;
if (track == null || !Number.isSafeInteger(track.frameCount) || track.frameCount < 1) {
throw new Error('Animated image decoder returned an invalid frame count');
}
frameCount = track.frameCount;
if (track.repetitionCount === undefined) {
repetitionCount = 0;
} else if (
track.repetitionCount !== Infinity &&
(!Number.isSafeInteger(track.repetitionCount) || track.repetitionCount < 0)
) {
throw new Error('Animated image decoder returned an invalid repetition count');
} else {
repetitionCount = track.repetitionCount;
}
const first = await getFrame(0);
if (!first || !isCurrentRunner()) return;
if (!draw(first)) {
failRunner();
return;
}
publishState({
supported: true,
loaded: true,
error: false,
naturalWidth: first.width,
naturalHeight: first.height,
});
if (playingRef.current && frameCount > 1) {
void advance();
}
} catch {
if (!isCurrentRunner()) return;
failRunner();
}
};
void start();
return () => {
runner.cancelled = true;
try {
fetchController.abort();
} catch {}
clearTimer();
closeFrameCache();
closeDecoder();
if (runnerRef.current === runner) runnerRef.current = null;
};
}, [canvas, maxCachedFrames, src]);
useEffect(() => {
const runner = runnerRef.current;
if (runner == null) return;
if (playing) {
runner.kick();
return;
}
runner.pause();
}, [playing]);
return visibleState;
}
export interface DecodedImageFrames {
frames: Array<ImageData>;
delays: Array<number>;
width: number;
height: number;
ready: boolean;
error: Error | null;
}
interface UseDecodedImageFramesOptions {
bytes: Uint8Array | null;
mime: string | null;
}
const HEIC_MIMES = new Set(['image/heic', 'image/heif']);
export function useDecodedImageFrames({bytes, mime}: UseDecodedImageFramesOptions): DecodedImageFrames {
const [state, setState] = useState<DecodedImageFrames>(() => ({
frames: [],
delays: [],
width: 0,
height: 0,
ready: false,
error: null,
}));
useEffect(() => {
if (!bytes || !mime) return;
let cancelled = false;
(async () => {
try {
const result = await decodeAllFrames(bytes, mime);
if (cancelled) return;
setState({...result, ready: true, error: null});
} catch (err) {
if (cancelled) return;
setState({
frames: [],
delays: [],
width: 0,
height: 0,
ready: false,
error: err instanceof Error ? err : new Error(String(err)),
});
}
})();
return () => {
cancelled = true;
};
}, [bytes, mime]);
return state;
}
async function decodeAllFrames(
bytes: Uint8Array,
mime: string,
): Promise<{
frames: Array<ImageData>;
delays: Array<number>;
width: number;
height: number;
}> {
const Cls = getImageDecoderConstructor();
const lower = mime.toLowerCase();
if (HEIC_MIMES.has(lower)) {
const frame = await decodeStaticViaImage(bytes, lower);
return {frames: [frame.image], delays: [0], width: frame.width, height: frame.height};
}
if (Cls && (await Cls.isTypeSupported(lower).catch(() => false))) {
return decodeAllFramesViaImageDecoder(bytes, lower, Cls);
}
const frame = await decodeStaticViaImage(bytes, lower);
return {frames: [frame.image], delays: [0], width: frame.width, height: frame.height};
}
async function decodeAllFramesViaImageDecoder(
bytes: Uint8Array,
type: string,
Cls: FluxerImageDecoderConstructor,
): Promise<{
frames: Array<ImageData>;
delays: Array<number>;
width: number;
height: number;
}> {
const decoder = new Cls({data: bytes, type, preferAnimation: true});
try {
await decoder.completed;
const track = decoder.tracks.selectedTrack;
const trackFrameCount =
track == null || !Number.isSafeInteger(track.frameCount) || track.frameCount < 1 ? 1 : track.frameCount;
const count = Math.max(1, trackFrameCount);
const frames: Array<ImageData> = [];
const delays: Array<number> = [];
let width = 0;
let height = 0;
for (let i = 0; i < count; i++) {
const {image} = await decoder.decode({frameIndex: i, completeFramesOnly: true});
try {
const w = image.displayWidth;
const h = image.displayHeight;
width = w;
height = h;
const canvas = typeof OffscreenCanvas !== 'undefined' ? new OffscreenCanvas(w, h) : null;
if (!canvas) throw new Error('OffscreenCanvas unavailable for ImageDecoder fallback');
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('failed to acquire 2d context');
drawVideoFrameToCanvas(ctx, image);
const data = ctx.getImageData(0, 0, w, h);
frames.push(new ImageData(new Uint8ClampedArray(data.data), w, h));
const duration = image.duration;
delays.push(duration === null || duration === undefined ? 0 : duration / 1000);
} finally {
image.close();
}
}
return {frames, delays, width, height};
} finally {
decoder.close();
}
}
async function decodeStaticViaImage(
bytes: Uint8Array,
mime: string,
): Promise<{
image: ImageData;
width: number;
height: number;
}> {
if (typeof Image === 'undefined' || typeof URL === 'undefined') {
throw new Error('static decode unavailable: no Image/URL globals');
}
const blob = new Blob([new Uint8Array(bytes)], {type: mime});
const url = URL.createObjectURL(blob);
try {
const img = new Image();
img.src = url;
if (typeof img.decode === 'function') {
await img.decode();
} else {
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error(`failed to decode ${mime}`));
});
}
const w = img.naturalWidth;
const h = img.naturalHeight;
if (w === 0 || h === 0) throw new Error(`failed to decode ${mime}: zero dimensions`);
const canvas =
typeof OffscreenCanvas !== 'undefined' ? new OffscreenCanvas(w, h) : document.createElement('canvas');
if (canvas instanceof HTMLCanvasElement) {
canvas.width = w;
canvas.height = h;
}
const ctx = (canvas as OffscreenCanvas | HTMLCanvasElement).getContext('2d');
if (!ctx) throw new Error('failed to acquire 2d context for static decode');
(ctx as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D).drawImage(img, 0, 0);
const data = (ctx as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D).getImageData(0, 0, w, h);
return {image: new ImageData(new Uint8ClampedArray(data.data), w, h), width: w, height: h};
} finally {
URL.revokeObjectURL(url);
}
}
@@ -20,7 +20,6 @@ export interface UseShouldAnimateOptions {
isHovering?: boolean; isHovering?: boolean;
isFocused?: boolean; isFocused?: boolean;
entitlementOk?: boolean; entitlementOk?: boolean;
respectPlaybackAllowed?: boolean;
} }
function isKeptUnderReducedMotion(kind: ShouldAnimateKind): boolean { function isKeptUnderReducedMotion(kind: ShouldAnimateKind): boolean {
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {NearViewportSurfaceContext, resolveObserverRoot} from '@app/features/messaging/hooks/useNearViewport';
import {trackViewportPlayback} from '@app/features/platform/utils/ViewportPlaybackRegistry';
import {useContext, useEffect, useState} from 'react';
export function useViewportPlayback(element: Element | null): boolean {
const resolveScrollSurface = useContext(NearViewportSurfaceContext);
const [isPlaybackVisible, setIsPlaybackVisible] = useState(() => typeof IntersectionObserver === 'undefined');
useEffect(() => {
if (typeof IntersectionObserver === 'undefined') {
setIsPlaybackVisible(true);
return;
}
if (element == null) {
setIsPlaybackVisible(false);
return;
}
const untrack = trackViewportPlayback(
element,
setIsPlaybackVisible,
resolveObserverRoot(resolveScrollSurface, element),
);
return () => {
untrack();
setIsPlaybackVisible(false);
};
}, [element, resolveScrollSurface]);
return isPlaybackVisible;
}
@@ -62,7 +62,6 @@
max-width: 100%; max-width: 100%;
min-height: 0.0625rem; min-height: 0.0625rem;
min-width: 0; min-width: 0;
aspect-ratio: var(--embed-aspect-ratio, auto);
overflow: hidden; overflow: hidden;
} }
@@ -73,46 +72,39 @@
max-height: 100%; max-height: 100%;
max-width: 100%; max-width: 100%;
border-radius: var(--media-border-radius, 0px); border-radius: var(--media-border-radius, 0px);
transition: opacity 80ms ease-out;
object-fit: contain; object-fit: contain;
pointer-events: none; pointer-events: none;
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
.posterElement { .placeholder {
position: absolute; position: absolute;
inset: 0; inset: 0;
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover;
border-radius: var(--media-border-radius, 0px); border-radius: var(--media-border-radius, 0px);
object-fit: contain; background: var(--background-primary);
pointer-events: none; pointer-events: none;
transition: opacity 80ms ease-out; transition: none;
z-index: 2; z-index: 2;
} }
.videoOpacityHidden { :global(html:not(.reduced-motion)) .placeholder {
opacity: 0; transition: opacity 0.2s ease;
} }
.videoOpacityVisible { :global(html:not(.reduced-motion)) .placeholderFastFade {
transition: opacity 0.05s ease;
}
.placeholderVisible {
opacity: 1; opacity: 1;
} }
.gifFreezeFrame {
position: absolute; .placeholderHidden {
inset: 0; opacity: 0;
width: 100%;
height: 100%;
display: block;
border-radius: var(--media-border-radius, 0px);
object-fit: contain;
pointer-events: none; pointer-events: none;
opacity: 0;
z-index: 4;
}
.gifFreezeFrame[data-frozen='true'] {
opacity: 1;
} }
File diff suppressed because it is too large. Load diff
@@ -1,155 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const GIFVPosterState = Object.freeze({
IDLE: 'idle',
LOADING: 'loading',
READY: 'ready',
ERROR: 'error',
} as const);
export type GIFVPosterState = (typeof GIFVPosterState)[keyof typeof GIFVPosterState];
export const GIFVVideoState = Object.freeze({
IDLE: 'idle',
LOADING: 'loading',
READY: 'ready',
PRESENTED: 'presented',
ERROR: 'error',
} as const);
export type GIFVVideoState = (typeof GIFVVideoState)[keyof typeof GIFVVideoState];
export const GIFVPlaybackState = Object.freeze({
IDLE: 'idle',
REQUESTED: 'requested',
PLAYING: 'playing',
PAUSED: 'paused',
BLOCKED: 'blocked',
} as const);
export type GIFVPlaybackState = (typeof GIFVPlaybackState)[keyof typeof GIFVPlaybackState];
export const GIFVRenderEventType = Object.freeze({
SOURCE_CHANGED: 'SOURCE_CHANGED',
POSTER_LOAD_STARTED: 'POSTER_LOAD_STARTED',
POSTER_LOADED: 'POSTER_LOADED',
POSTER_FAILED: 'POSTER_FAILED',
VIDEO_LOAD_STARTED: 'VIDEO_LOAD_STARTED',
VIDEO_READY: 'VIDEO_READY',
VIDEO_FRAME_PRESENTED: 'VIDEO_FRAME_PRESENTED',
VIDEO_FAILED: 'VIDEO_FAILED',
PLAY_REQUESTED: 'PLAY_REQUESTED',
PLAYING: 'PLAYING',
PLAYBACK_PAUSED: 'PLAYBACK_PAUSED',
PLAYBACK_BLOCKED: 'PLAYBACK_BLOCKED',
} as const);
export type GIFVRenderEventType = (typeof GIFVRenderEventType)[keyof typeof GIFVRenderEventType];
export interface GIFVRenderState {
sourceKey: string;
poster: GIFVPosterState;
video: GIFVVideoState;
playback: GIFVPlaybackState;
}
export interface GIFVSourceChangedEvent {
type: typeof GIFVRenderEventType.SOURCE_CHANGED;
sourceKey: string;
posterCached: boolean;
}
export type GIFVRenderEvent =
| GIFVSourceChangedEvent
| {type: typeof GIFVRenderEventType.POSTER_LOAD_STARTED}
| {type: typeof GIFVRenderEventType.POSTER_LOADED}
| {type: typeof GIFVRenderEventType.POSTER_FAILED}
| {type: typeof GIFVRenderEventType.VIDEO_LOAD_STARTED}
| {type: typeof GIFVRenderEventType.VIDEO_READY}
| {type: typeof GIFVRenderEventType.VIDEO_FRAME_PRESENTED}
| {type: typeof GIFVRenderEventType.VIDEO_FAILED}
| {type: typeof GIFVRenderEventType.PLAY_REQUESTED}
| {type: typeof GIFVRenderEventType.PLAYING}
| {type: typeof GIFVRenderEventType.PLAYBACK_PAUSED}
| {type: typeof GIFVRenderEventType.PLAYBACK_BLOCKED};
export interface GIFVRenderLayers {
showPlaceholder: boolean;
showPoster: boolean;
showVideo: boolean;
}
export interface InitialGIFVRenderStateRequest {
posterCached: boolean;
sourceKey: string;
}
export function createInitialGIFVRenderState({
sourceKey,
posterCached,
}: InitialGIFVRenderStateRequest): GIFVRenderState {
let posterState: GIFVPosterState = GIFVPosterState.IDLE;
if (posterCached) posterState = GIFVPosterState.READY;
return {
sourceKey,
poster: posterState,
video: GIFVVideoState.IDLE,
playback: GIFVPlaybackState.IDLE,
};
}
function reduceGIFVSourceChange(state: GIFVRenderState, event: GIFVSourceChangedEvent): GIFVRenderState {
if (state.sourceKey !== event.sourceKey) {
return createInitialGIFVRenderState({
sourceKey: event.sourceKey,
posterCached: event.posterCached,
});
}
if (!event.posterCached) return state;
if (state.poster === GIFVPosterState.READY) return state;
return {...state, poster: GIFVPosterState.READY};
}
export function reduceGIFVRenderState(state: GIFVRenderState, event: GIFVRenderEvent): GIFVRenderState {
switch (event.type) {
case GIFVRenderEventType.SOURCE_CHANGED:
return reduceGIFVSourceChange(state, event);
case GIFVRenderEventType.POSTER_LOAD_STARTED:
if (state.poster === GIFVPosterState.READY) return state;
return {...state, poster: GIFVPosterState.LOADING};
case GIFVRenderEventType.POSTER_LOADED:
if (state.poster === GIFVPosterState.READY) return state;
return {...state, poster: GIFVPosterState.READY};
case GIFVRenderEventType.POSTER_FAILED:
if (state.poster === GIFVPosterState.READY) return state;
return {...state, poster: GIFVPosterState.ERROR};
case GIFVRenderEventType.VIDEO_LOAD_STARTED:
if (state.video === GIFVVideoState.PRESENTED) return state;
return {...state, video: GIFVVideoState.LOADING};
case GIFVRenderEventType.VIDEO_READY:
if (state.video === GIFVVideoState.PRESENTED) return state;
return {...state, video: GIFVVideoState.READY};
case GIFVRenderEventType.VIDEO_FRAME_PRESENTED:
return {...state, video: GIFVVideoState.PRESENTED};
case GIFVRenderEventType.VIDEO_FAILED:
return {...state, video: GIFVVideoState.ERROR, playback: GIFVPlaybackState.PAUSED};
case GIFVRenderEventType.PLAY_REQUESTED:
return {...state, playback: GIFVPlaybackState.REQUESTED};
case GIFVRenderEventType.PLAYING:
return {...state, playback: GIFVPlaybackState.PLAYING};
case GIFVRenderEventType.PLAYBACK_PAUSED:
return {...state, playback: GIFVPlaybackState.PAUSED};
case GIFVRenderEventType.PLAYBACK_BLOCKED:
return {...state, playback: GIFVPlaybackState.BLOCKED};
}
}
export function getGIFVRenderLayers(state: GIFVRenderState): GIFVRenderLayers {
const showVideo = state.video === GIFVVideoState.PRESENTED;
const showPoster = !showVideo && state.poster === GIFVPosterState.READY;
return {
showPlaceholder: !showVideo && !showPoster,
showPoster,
showVideo,
};
}
@@ -1,10 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {useViewportPlayback} from '@app/features/app/hooks/useViewportPlayback';
import {useNearViewport} from '@app/features/messaging/hooks/useNearViewport'; import {useNearViewport} from '@app/features/messaging/hooks/useNearViewport';
import {observeIntersection} from '@app/features/platform/utils/SharedIntersectionObserver';
import {useEffect, useState} from 'react';
const ANIMATION_ROOT_MARGIN = '600px 0px';
export interface GifViewportGate { export interface GifViewportGate {
loadMedia: boolean; loadMedia: boolean;
@@ -34,23 +31,6 @@ export function useGifViewportGate<T extends Element>({
shouldBlur: boolean; shouldBlur: boolean;
}): GifViewportGate & {ref: (node: T | null) => void} { }): GifViewportGate & {ref: (node: T | null) => void} {
const {ref, isNearViewport} = useNearViewport<T>({rememberKey}); const {ref, isNearViewport} = useNearViewport<T>({rememberKey});
const [isInViewport, setIsInViewport] = useState(() => typeof IntersectionObserver === 'undefined'); const isInViewport = useViewportPlayback(element);
useEffect(() => {
if (typeof IntersectionObserver === 'undefined') {
setIsInViewport(true);
return;
}
if (element == null) {
setIsInViewport(false);
return;
}
return observeIntersection(
element,
(entry) => {
setIsInViewport(entry.isIntersecting || entry.intersectionRatio > 0);
},
{rootMargin: ANIMATION_ROOT_MARGIN},
);
}, [element]);
return {ref, ...resolveGifViewportGate({isNearViewport, isInViewport, shouldBlur})}; return {ref, ...resolveGifViewportGate({isNearViewport, isInViewport, shouldBlur})};
} }
@@ -2,6 +2,7 @@
import {LongPressable} from '@app/features/app/components/LongPressable'; import {LongPressable} from '@app/features/app/components/LongPressable';
import mediaStyles from '@app/features/channel/components/embeds/media/MediaContainer.module.css'; import mediaStyles from '@app/features/channel/components/embeds/media/MediaContainer.module.css';
import {shouldShowOverlays} from '@app/features/channel/components/embeds/media/MediaOverlayFit';
import { import {
ADD_TO_FAVORITES_DESCRIPTOR, ADD_TO_FAVORITES_DESCRIPTOR,
DELETE_ATTACHMENT_DESCRIPTOR, DELETE_ATTACHMENT_DESCRIPTOR,
@@ -28,13 +29,6 @@ const DOWNLOAD_MEDIA_DESCRIPTOR = msg({
message: 'Download media', message: 'Download media',
comment: 'Button or menu action label in the channel and chat media container. Keep it concise.', comment: 'Button or menu action label in the channel and chat media container. Keep it concise.',
}); });
const MIN_SIZE_FOR_OVERLAYS = 120;
export const shouldShowOverlays = (renderedWidth?: number, renderedHeight?: number): boolean => {
if (renderedWidth === undefined || renderedHeight === undefined) {
return true;
}
return renderedWidth >= MIN_SIZE_FOR_OVERLAYS && renderedHeight >= MIN_SIZE_FOR_OVERLAYS;
};
type LongPressEvent = React.PointerEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>; type LongPressEvent = React.PointerEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>;
@@ -96,10 +90,7 @@ export const MediaContainer = observer(
e.stopPropagation(); e.stopPropagation();
onDeleteClick?.(e); onDeleteClick?.(e);
}; };
const isMediaTooSmall = const isMediaTooSmall = !shouldShowOverlays(renderedWidth, renderedHeight);
renderedWidth !== undefined &&
renderedHeight !== undefined &&
(renderedWidth < MIN_SIZE_FOR_OVERLAYS || renderedHeight < MIN_SIZE_FOR_OVERLAYS);
const shouldShowFavorite = showFavoriteButton && (forceShowFavoriteButton || !isMediaTooSmall); const shouldShowFavorite = showFavoriteButton && (forceShowFavoriteButton || !isMediaTooSmall);
const shouldShowDownload = showDownloadButton && !isMediaTooSmall; const shouldShowDownload = showDownloadButton && !isMediaTooSmall;
const shouldShowDelete = showDeleteButton && !isMediaTooSmall; const shouldShowDelete = showDeleteButton && !isMediaTooSmall;
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const OVERLAY_ACTION_ITEM_SIZE = 28;
const OVERLAY_ACTION_GAP = 2;
const OVERLAY_ACTION_PADDING = 3;
const OVERLAY_EDGE_INSET = 8;
const MAX_OVERLAY_ACTIONS = 3;
const OVERLAY_TOOLBAR_WIDTH =
OVERLAY_ACTION_PADDING * 2 +
MAX_OVERLAY_ACTIONS * OVERLAY_ACTION_ITEM_SIZE +
(MAX_OVERLAY_ACTIONS - 1) * OVERLAY_ACTION_GAP;
const OVERLAY_TOOLBAR_HEIGHT = OVERLAY_ACTION_PADDING * 2 + OVERLAY_ACTION_ITEM_SIZE;
export const MIN_WIDTH_FOR_OVERLAYS = OVERLAY_TOOLBAR_WIDTH + OVERLAY_EDGE_INSET * 2;
export const MIN_HEIGHT_FOR_OVERLAYS = OVERLAY_TOOLBAR_HEIGHT + OVERLAY_EDGE_INSET * 2;
export function shouldShowOverlays(renderedWidth?: number, renderedHeight?: number): boolean {
if (renderedWidth === undefined || renderedHeight === undefined) {
return true;
}
return renderedWidth >= MIN_WIDTH_FOR_OVERLAYS && renderedHeight >= MIN_HEIGHT_FOR_OVERLAYS;
}
@@ -1,7 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import {getEffectiveVideoLayoutDimensions, hasDifferentAspectRatio, resolveVideoLayout} from './VideoDimensionUtils'; import {
getEffectiveVideoLayoutDimensions,
hasDifferentAspectRatio,
isInlinePlayableVideoSize,
resolveVideoLayout,
} from './VideoDimensionUtils';
describe('getEffectiveVideoLayoutDimensions', () => { describe('getEffectiveVideoLayoutDimensions', () => {
it('prefers decoded dimensions over declared upload dimensions', () => { it('prefers decoded dimensions over declared upload dimensions', () => {
@@ -55,3 +60,47 @@ describe('resolveVideoLayout', () => {
expect(w / h).toBeCloseTo(1920 / 700, 1); expect(w / h).toBeCloseTo(1920 / 700, 1);
}); });
}); });
describe('isInlinePlayableVideoSize', () => {
it('accepts a landscape source sitting exactly on both edges', () => {
expect(isInlinePlayableVideoSize({width: 6016, height: 3384})).toBe(true);
});
it('accepts a portrait source sitting exactly on the swapped edges', () => {
expect(isInlinePlayableVideoSize({width: 3384, height: 6016})).toBe(true);
});
it('rejects one pixel past the landscape long edge', () => {
expect(isInlinePlayableVideoSize({width: 6017, height: 3384})).toBe(false);
});
it('rejects one pixel past the landscape short edge', () => {
expect(isInlinePlayableVideoSize({width: 6016, height: 3385})).toBe(false);
});
it('rejects one pixel past the portrait long edge', () => {
expect(isInlinePlayableVideoSize({width: 3384, height: 6017})).toBe(false);
});
it('rejects one pixel past the portrait short edge', () => {
expect(isInlinePlayableVideoSize({width: 3385, height: 6016})).toBe(false);
});
it('accepts a source that only fits the swapped clause', () => {
expect(isInlinePlayableVideoSize({width: 3000, height: 5000})).toBe(true);
});
it('rejects a square source larger than the short edge', () => {
expect(isInlinePlayableVideoSize({width: 4000, height: 4000})).toBe(false);
});
it('accepts a square source on the short edge', () => {
expect(isInlinePlayableVideoSize({width: 3384, height: 3384})).toBe(true);
});
it('accepts unknown dimensions so metadata gaps never silence playback', () => {
expect(isInlinePlayableVideoSize(null)).toBe(true);
expect(isInlinePlayableVideoSize({width: 0, height: 0})).toBe(true);
expect(isInlinePlayableVideoSize({width: Number.NaN, height: Number.NaN})).toBe(true);
});
});
@@ -26,6 +26,8 @@ const DEFAULT_INLINE_VIDEO_LAYOUT_CONSTRAINTS: VideoLayoutConstraints = {
maxWidth: 400, maxWidth: 400,
maxHeight: 400, maxHeight: 400,
}; };
const MAX_INLINE_PLAYBACK_LONG_EDGE = 6016;
const MAX_INLINE_PLAYBACK_SHORT_EDGE = 3384;
export function normalizeVideoDimensions(dimensions?: Partial<VideoDimensions> | null): VideoDimensions | null { export function normalizeVideoDimensions(dimensions?: Partial<VideoDimensions> | null): VideoDimensions | null {
const width = dimensions?.width; const width = dimensions?.width;
@@ -39,6 +41,18 @@ export function normalizeVideoDimensions(dimensions?: Partial<VideoDimensions> |
}; };
} }
export function isInlinePlayableVideoSize(dimensions?: Partial<VideoDimensions> | null): boolean {
const source = normalizeVideoDimensions(dimensions);
if (source === null) {
return true;
}
const {width, height} = source;
return (
(width <= MAX_INLINE_PLAYBACK_LONG_EDGE && height <= MAX_INLINE_PLAYBACK_SHORT_EDGE) ||
(width <= MAX_INLINE_PLAYBACK_SHORT_EDGE && height <= MAX_INLINE_PLAYBACK_LONG_EDGE)
);
}
function normalizeConstraint(value: number | undefined, fallback: number): number { function normalizeConstraint(value: number | undefined, fallback: number): number {
return Number.isFinite(value) && value !== undefined && value > 0 ? value : fallback; return Number.isFinite(value) && value !== undefined && value > 0 ? value : fallback;
} }
@@ -22,30 +22,24 @@ function thresholdKey(threshold: number | Array<number> | undefined): string {
return threshold.join(','); return threshold.join(',');
} }
const rootIds = new WeakMap<Element, string>();
let rootIdCounter = 0;
function rootKey(root: Element | null | undefined): string { function rootKey(root: Element | null | undefined): string {
if (!root) return 'document'; if (!root) return 'document';
const id = ( let id = rootIds.get(root);
root as Element & { if (id === undefined) {
dataset?: DOMStringMap; id = `r${++rootIdCounter}`;
rootIds.set(root, id);
} }
).dataset?.sharedIoId; return `el:${id}`;
if (id) return `el:${id}`;
const generated = `gen-${++rootIdCounter}`;
(
root as Element & {
dataset: DOMStringMap;
}
).dataset.sharedIoId = generated;
return `el:${generated}`;
} }
let rootIdCounter = 0;
function buildKey(options: ObserverOptions): string { function buildKey(options: ObserverOptions): string {
return `${rootKey(options.root ?? null)}|${options.rootMargin ?? '0px'}|${thresholdKey(options.threshold)}`; return `${rootKey(options.root ?? null)}|${options.rootMargin ?? '0px'}|${thresholdKey(options.threshold)}`;
} }
function getOrCreate(options: ObserverOptions): SharedObserverState { function acquirePooledObserver(options: ObserverOptions): SharedObserverState {
const key = buildKey(options); const key = buildKey(options);
let state = pool.get(key); let state = pool.get(key);
if (state) return state; if (state) return state;
@@ -77,7 +71,7 @@ function getOrCreate(options: ObserverOptions): SharedObserverState {
} }
export function observeIntersection(element: Element, callback: Callback, options: ObserverOptions = {}): () => void { export function observeIntersection(element: Element, callback: Callback, options: ObserverOptions = {}): () => void {
const state = getOrCreate(options); const state = acquirePooledObserver(options);
let handlers = state.callbacks.get(element); let handlers = state.callbacks.get(element);
if (!handlers) { if (!handlers) {
handlers = new Set(); handlers = new Set();
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {observeIntersection} from '@app/features/platform/utils/SharedIntersectionObserver';
type PlaybackToggle = (playing: boolean) => void;
const PLAYBACK_VISIBLE_RATIO = 0.7;
const PLAYBACK_MAX_ROOT_HEIGHTS = 8;
const MAX_CONCURRENT_PLAYBACK = 100;
function buildPlaybackRatioSteps(): Array<number> {
const steps = [0, PLAYBACK_VISIBLE_RATIO];
let ratio = PLAYBACK_VISIBLE_RATIO;
while (PLAYBACK_VISIBLE_RATIO / ratio < PLAYBACK_MAX_ROOT_HEIGHTS) {
ratio *= PLAYBACK_VISIBLE_RATIO;
steps.push(ratio);
}
return steps.sort((a, b) => a - b);
}
const PLAYBACK_RATIO_STEPS = buildPlaybackRatioSteps();
const trackedNodes = new WeakMap<Element, PlaybackToggle>();
const playingNodes = new Set<Element>();
let onScreenNodes = new Set<Element>();
function fillsPlaybackRoot(entry: IntersectionObserverEntry): boolean {
const rootBounds = entry.rootBounds;
if (rootBounds == null || rootBounds.height <= 0) return false;
return entry.intersectionRect.height >= rootBounds.height * PLAYBACK_VISIBLE_RATIO;
}
function isPlaybackVisible(entry: IntersectionObserverEntry): boolean {
if (!entry.isIntersecting) return false;
if (entry.intersectionRatio >= PLAYBACK_VISIBLE_RATIO) return true;
return fillsPlaybackRoot(entry);
}
function isNearLeadingEdge(entry: IntersectionObserverEntry): boolean {
const rootBounds = entry.rootBounds;
if (rootBounds == null) return false;
const bottomGap = Math.abs(entry.intersectionRect.bottom - rootBounds.bottom);
const topGap = Math.abs(entry.intersectionRect.top - rootBounds.top);
return bottomGap < topGap;
}
function stopTrailingPlayback(entered: Element): void {
const ordered = Array.from(onScreenNodes);
for (let index = ordered.length - 1; index >= 0; index--) {
const node = ordered[index];
if (node === entered) continue;
if (!playingNodes.has(node)) continue;
playingNodes.delete(node);
trackedNodes.get(node)?.(false);
return;
}
}
function startNextWaitingPlayback(): void {
if (playingNodes.size >= MAX_CONCURRENT_PLAYBACK) return;
if (onScreenNodes.size <= playingNodes.size) return;
for (const node of onScreenNodes) {
if (playingNodes.has(node)) continue;
playingNodes.add(node);
trackedNodes.get(node)?.(true);
return;
}
}
function handleEntered(entry: IntersectionObserverEntry, toggle: PlaybackToggle): void {
const node = entry.target;
if (onScreenNodes.has(node)) return;
const leading = isNearLeadingEdge(entry);
if (leading) {
onScreenNodes = new Set<Element>([node, ...onScreenNodes]);
} else {
onScreenNodes.add(node);
}
const shouldPlay = leading || playingNodes.size < MAX_CONCURRENT_PLAYBACK;
if (shouldPlay) {
playingNodes.add(node);
} else {
playingNodes.delete(node);
}
toggle(shouldPlay);
if (shouldPlay && playingNodes.size > MAX_CONCURRENT_PLAYBACK) {
stopTrailingPlayback(node);
}
}
function handleExited(node: Element, toggle: PlaybackToggle): void {
if (!onScreenNodes.has(node)) return;
onScreenNodes.delete(node);
playingNodes.delete(node);
toggle(false);
startNextWaitingPlayback();
}
export function trackViewportPlayback(
element: Element,
toggle: PlaybackToggle,
clipRoot: Element | null = null,
): () => void {
if (trackedNodes.has(element)) {
throw new Error('trackViewportPlayback: element is already tracked, untrack it before tracking it again');
}
trackedNodes.set(element, toggle);
const release = observeIntersection(
element,
(entry) => {
const current = trackedNodes.get(entry.target);
if (current == null) return;
if (isPlaybackVisible(entry)) {
handleEntered(entry, current);
} else {
handleExited(entry.target, current);
}
},
{root: clipRoot, threshold: PLAYBACK_RATIO_STEPS},
);
let untracked = false;
return () => {
if (untracked) return;
untracked = true;
release();
trackedNodes.delete(element);
onScreenNodes.delete(element);
if (playingNodes.delete(element)) {
startNextWaitingPlayback();
}
};
}