mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
perf(app): play gif embeds from the viewport not a js decoder (#1877)
This commit is contained in:
1 parent
69e0086144
commit
1f0f7d1222
14 files changed
+550
-1837
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;
|
||||||
|
}
|
||||||
+50
-1
@@ -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;
|
}
|
||||||
if (id) return `el:${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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user