diff --git a/fluxer_app/src/features/app/hooks/useAnimatedImageDecoder.test.ts b/fluxer_app/src/features/app/hooks/useAnimatedImageDecoder.test.ts deleted file mode 100644 index 41dec5ceb..000000000 --- a/fluxer_app/src/features/app/hooks/useAnimatedImageDecoder.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/fluxer_app/src/features/app/hooks/useAnimatedImageDecoder.ts b/fluxer_app/src/features/app/hooks/useAnimatedImageDecoder.ts deleted file mode 100644 index c3087137e..000000000 --- a/fluxer_app/src/features/app/hooks/useAnimatedImageDecoder.ts +++ /dev/null @@ -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 { - 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 = []; - 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 { - 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(createIdleDecoderState); - const [stateIdentity, setStateIdentity] = useState(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(); - 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 => { - 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((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 => { - let response: Response | null = null; - let timeout: number | null = null; - const operation = (async (): Promise => { - 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((_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; - delays: Array; - 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(() => ({ - 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; - delays: Array; - 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; - delays: Array; - 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 = []; - const delays: Array = []; - 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((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); - } -} diff --git a/fluxer_app/src/features/app/hooks/useShouldAnimate.ts b/fluxer_app/src/features/app/hooks/useShouldAnimate.ts index adfb440b4..f9a361773 100644 --- a/fluxer_app/src/features/app/hooks/useShouldAnimate.ts +++ b/fluxer_app/src/features/app/hooks/useShouldAnimate.ts @@ -20,7 +20,6 @@ export interface UseShouldAnimateOptions { isHovering?: boolean; isFocused?: boolean; entitlementOk?: boolean; - respectPlaybackAllowed?: boolean; } function isKeptUnderReducedMotion(kind: ShouldAnimateKind): boolean { diff --git a/fluxer_app/src/features/app/hooks/useViewportPlayback.ts b/fluxer_app/src/features/app/hooks/useViewportPlayback.ts new file mode 100644 index 000000000..36372f26a --- /dev/null +++ b/fluxer_app/src/features/app/hooks/useViewportPlayback.ts @@ -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; +} diff --git a/fluxer_app/src/features/channel/components/embeds/media/EmbedGifv.module.css b/fluxer_app/src/features/channel/components/embeds/media/EmbedGifv.module.css index e1d4b4752..225535b29 100644 --- a/fluxer_app/src/features/channel/components/embeds/media/EmbedGifv.module.css +++ b/fluxer_app/src/features/channel/components/embeds/media/EmbedGifv.module.css @@ -62,7 +62,6 @@ max-width: 100%; min-height: 0.0625rem; min-width: 0; - aspect-ratio: var(--embed-aspect-ratio, auto); overflow: hidden; } @@ -73,46 +72,39 @@ max-height: 100%; max-width: 100%; border-radius: var(--media-border-radius, 0px); - transition: opacity 80ms ease-out; object-fit: contain; pointer-events: none; position: relative; z-index: 1; } -.posterElement { +.placeholder { position: absolute; inset: 0; display: block; width: 100%; height: 100%; + object-fit: cover; border-radius: var(--media-border-radius, 0px); - object-fit: contain; + background: var(--background-primary); pointer-events: none; - transition: opacity 80ms ease-out; + transition: none; z-index: 2; } -.videoOpacityHidden { - opacity: 0; +:global(html:not(.reduced-motion)) .placeholder { + transition: opacity 0.2s ease; } -.videoOpacityVisible { +:global(html:not(.reduced-motion)) .placeholderFastFade { + transition: opacity 0.05s ease; +} + +.placeholderVisible { opacity: 1; } -.gifFreezeFrame { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - display: block; - border-radius: var(--media-border-radius, 0px); - object-fit: contain; + +.placeholderHidden { + opacity: 0; pointer-events: none; - opacity: 0; - z-index: 4; -} - -.gifFreezeFrame[data-frozen='true'] { - opacity: 1; } diff --git a/fluxer_app/src/features/channel/components/embeds/media/EmbedGifv.tsx b/fluxer_app/src/features/channel/components/embeds/media/EmbedGifv.tsx index 502d574e5..0cf119184 100644 --- a/fluxer_app/src/features/channel/components/embeds/media/EmbedGifv.tsx +++ b/fluxer_app/src/features/channel/components/embeds/media/EmbedGifv.tsx @@ -1,30 +1,20 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import Accessibility from '@app/features/accessibility/state/Accessibility'; -import {useAnimatedImageDecoder} from '@app/features/app/hooks/useAnimatedImageDecoder'; -import { - getAnimatedMediaPlaybackAllowed, - useAnimatedMediaPlaybackAllowed, -} from '@app/features/app/hooks/useAnimatedMediaPlayback'; +import {useAnimatedMediaPlaybackAllowed} from '@app/features/app/hooks/useAnimatedMediaPlayback'; import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate'; import {AltTextBadge} from '@app/features/channel/components/embeds/AltTextBadge'; import embedStyles from '@app/features/channel/components/embeds/ChannelEmbed.module.css'; import {deriveDefaultNameFromMessage} from '@app/features/channel/components/embeds/EmbedUtils'; import {MatureMediaBlurOverlay} from '@app/features/channel/components/embeds/MatureMediaBlurOverlay'; import styles from '@app/features/channel/components/embeds/media/EmbedGifv.module.css'; -import { - createInitialGIFVRenderState, - GIFVPlaybackState, - GIFVRenderEventType, - GIFVVideoState, - getGIFVRenderLayers, - reduceGIFVRenderState, -} from '@app/features/channel/components/embeds/media/GIFVRenderStateMachine'; import {GifIndicator} from '@app/features/channel/components/embeds/media/GifIndicator'; import {useGifViewportGate} from '@app/features/channel/components/embeds/media/GifViewportGate'; import {getMediaButtonVisibility} from '@app/features/channel/components/embeds/media/MediaButtonUtils'; -import {MediaContainer, shouldShowOverlays} from '@app/features/channel/components/embeds/media/MediaContainer'; +import {MediaContainer} from '@app/features/channel/components/embeds/media/MediaContainer'; +import {shouldShowOverlays} from '@app/features/channel/components/embeds/media/MediaOverlayFit'; import type {BaseMediaProps} from '@app/features/channel/components/embeds/media/MediaTypes'; +import {isInlinePlayableVideoSize} from '@app/features/channel/components/embeds/media/VideoDimensionUtils'; import {safePause, safePlay} from '@app/features/channel/components/GifVideoPool'; import {useMaybeMessageViewContext} from '@app/features/channel/components/MessageViewContext'; import type {Channel} from '@app/features/channel/models/Channel'; @@ -33,30 +23,30 @@ import {useDeleteAttachment} from '@app/features/messaging/hooks/useDeleteAttach import {useMatureMedia} from '@app/features/messaging/hooks/useMatureMedia'; import {useMediaFavorite} from '@app/features/messaging/hooks/useMediaFavorite'; import {useMediaLoading} from '@app/features/messaging/hooks/useMediaLoading'; -import {useNearViewport} from '@app/features/messaging/hooks/useNearViewport'; +import {useMediaViewerHoverWarm} from '@app/features/messaging/hooks/useMediaViewerHoverWarm'; import {useOpenInBrowserOnMiddleClick} from '@app/features/messaging/hooks/useOpenInBrowserOnMiddleClick'; import type {Message} from '@app/features/messaging/models/MessagingMessage'; import {createDownloadHandler} from '@app/features/messaging/utils/FileDownloadUtils'; import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils'; import {getEmbedMediaDimensions} from '@app/features/messaging/utils/MediaDimensionConfig'; +import {resolveProxyRequestSize} from '@app/features/messaging/utils/MediaProxyRequestSize'; import { buildFittedAnimatedImageProxyURL, buildFittedStaticGifPreviewURL, stripMediaProxyParams, } from '@app/features/messaging/utils/MediaProxyUtils'; +import {decodeThumbHashDataURL} from '@app/features/messaging/utils/ThumbHashUtils'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import {MediaContextMenu} from '@app/features/ui/action_menu/MediaContextMenu'; import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands'; import * as MediaViewerCommands from '@app/features/ui/commands/MediaViewerCommands'; -import MediaViewer from '@app/features/ui/state/MediaViewer'; -import MobileLayout from '@app/features/ui/state/MobileLayout'; +import MediaViewer, {type MediaViewerItem} from '@app/features/ui/state/MediaViewer'; import {createCalculator} from '@app/features/ui/utils/DimensionUtils'; import {msg} from '@lingui/core/macro'; import {useLingui} from '@lingui/react/macro'; import {clsx} from 'clsx'; -import {motion} from 'framer-motion'; import {observer} from 'mobx-react-lite'; -import {type FC, useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState} from 'react'; +import {type FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; const OPEN_ANIMATED_GIF_VIDEO_IN_FULL_VIEW_DESCRIPTOR = msg({ message: 'Open animated GIF video in full view', @@ -70,10 +60,6 @@ const OPEN_IMAGE_IN_FULL_VIEW_DESCRIPTOR = msg({ message: 'Open image in full view', comment: 'Button or menu action label in the channel and chat embed gifv. Keep it concise.', }); -const LOADING_PLACEHOLDER_DESCRIPTOR = msg({ - message: 'Loading placeholder', - comment: 'Placeholder text in the channel and chat embed gifv. Keep it concise.', -}); const ANIMATED_GIF_VIDEO_DESCRIPTOR = msg({ message: 'Animated GIF video', comment: 'Short label in the channel and chat embed gifv. Keep it concise.', @@ -82,64 +68,7 @@ const ANIMATED_GIF_DESCRIPTOR = msg({ message: 'Animated GIF', comment: 'Short label in the channel and chat embed gifv. Keep it concise.', }); -const EMBED_MEDIA_FADE_DURATION_SECONDS = 0.08; -const DECODER_MAX_CACHED_FRAMES = 24; - -type AnimatedImagePreloadStatus = 'idle' | 'loading' | 'ready' | 'failed'; - -interface AnimatedImagePreloadState { - url: string; - status: AnimatedImagePreloadStatus; -} - -function watchPresentedVideoFrame(video: HTMLVideoElement, onPresented: () => void): () => void { - let cancelled = false; - const requestVideoFrameCallback = video.requestVideoFrameCallback; - if (typeof requestVideoFrameCallback === 'function') { - const callbackHandle = requestVideoFrameCallback.call(video, () => { - if (!cancelled) onPresented(); - }); - return () => { - cancelled = true; - const cancelVideoFrameCallback = video.cancelVideoFrameCallback; - if (typeof cancelVideoFrameCallback === 'function') { - cancelVideoFrameCallback.call(video, callbackHandle); - } - }; - } - const ownerWindow = video.ownerDocument.defaultView; - if (ownerWindow == null) return () => {}; - let secondFrameHandle: number | null = null; - const firstFrameHandle = ownerWindow.requestAnimationFrame(() => { - secondFrameHandle = ownerWindow.requestAnimationFrame(() => { - if (!cancelled) onPresented(); - }); - }); - return () => { - cancelled = true; - ownerWindow.cancelAnimationFrame(firstFrameHandle); - if (secondFrameHandle != null) ownerWindow.cancelAnimationFrame(secondFrameHandle); - }; -} - -function mediaElementHasSource(element: HTMLImageElement | HTMLVideoElement, source: string): boolean { - if (element.currentSrc.length > 0) { - try { - return element.currentSrc === new URL(source, element.ownerDocument.baseURI).href; - } catch { - return element.currentSrc === source; - } - } - const attributeSource = element.getAttribute('src'); - if (attributeSource === source) return true; - return element.src === source; -} - -function completeImageHasSource(image: HTMLImageElement | null, source: string): image is HTMLImageElement { - if (image == null) return false; - if (!mediaElementHasSource(image, source)) return false; - return image.complete && image.naturalWidth > 0 && image.naturalHeight > 0; -} +const PLACEHOLDER_FAST_FADE_WINDOW_MS = 200; type GifvEmbedProps = BaseMediaProps & { embedURL: string; @@ -174,6 +103,70 @@ function useEmbedMediaCalculator(constraints?: {maxWidth: number; maxHeight: num ); } +function useAspectRatioStyle(width: number, height: number): React.CSSProperties | undefined { + return useMemo(() => { + if (width <= 0 || height <= 0) return undefined; + return {aspectRatio: `${width} / ${height}`}; + }, [height, width]); +} + +function useMediaSurfaceInteraction(container: HTMLDivElement | null): { + isPointerInside: boolean; + hasFocusInside: boolean; +} { + const [isPointerInside, setIsPointerInside] = useState(false); + const [hasFocusInside, setHasFocusInside] = useState(false); + useEffect(() => { + if (container == null) return; + const handleMouseEnter = () => setIsPointerInside(true); + const handleMouseLeave = () => setIsPointerInside(false); + const handleFocusIn = () => setHasFocusInside(true); + const handleFocusOut = (event: FocusEvent) => { + const nextTarget = event.relatedTarget; + if (nextTarget != null && container.contains(nextTarget as Node)) return; + setHasFocusInside(false); + }; + container.addEventListener('mouseenter', handleMouseEnter); + container.addEventListener('mouseleave', handleMouseLeave); + container.addEventListener('focusin', handleFocusIn); + container.addEventListener('focusout', handleFocusOut); + return () => { + container.removeEventListener('mouseenter', handleMouseEnter); + container.removeEventListener('mouseleave', handleMouseLeave); + container.removeEventListener('focusin', handleFocusIn); + container.removeEventListener('focusout', handleFocusOut); + }; + }, [container]); + return {isPointerInside, hasFocusInside}; +} + +interface ThumbHashCurtainState { + shouldMount: boolean; + curtainLifted: boolean; + fastFade: boolean; + posterRevealed: boolean; +} + +function useThumbHashCurtain(source: string, enabled: boolean): ThumbHashCurtainState { + const [mountedAt] = useState(() => Date.now()); + const [cachedAtMount] = useState(() => ImageCacheUtils.hasImage(source)); + const [curtainLifted, setCurtainLifted] = useState(false); + const [fastFade, setFastFade] = useState(false); + useLayoutEffect(() => { + if (!enabled || source.length === 0) return; + return ImageCacheUtils.loadImage(source, () => { + setFastFade(Date.now() - mountedAt < PLACEHOLDER_FAST_FADE_WINDOW_MS); + setCurtainLifted(true); + }); + }, [enabled, mountedAt, source]); + return { + shouldMount: !cachedAtMount, + curtainLifted, + fastFade, + posterRevealed: source.length === 0 || cachedAtMount || curtainLifted, + }; +} + const useImagePreview = ({ proxyUrl, embedUrl, @@ -202,54 +195,39 @@ const useImagePreview = ({ message?: Message; sourceChannel?: Channel | null; providerName?: string; -}) => { - return useCallback( +}): {viewerItem: MediaViewerItem; openPreview: (event: React.MouseEvent | React.KeyboardEvent) => void} => { + const viewerItem = useMemo( + () => ({ + src: proxyUrl, + originalSrc: embedUrl, + naturalWidth, + naturalHeight, + type, + contentHash, + attachmentId, + embedIndex, + animated: true, + providerName, + }), + [proxyUrl, embedUrl, naturalWidth, naturalHeight, type, contentHash, attachmentId, embedIndex, providerName], + ); + const openPreview = useCallback( (event: React.MouseEvent | React.KeyboardEvent) => { if (event.type === 'click' && (event as React.MouseEvent).button !== 0) { return; } event.preventDefault(); event.stopPropagation(); - MediaViewerCommands.openMediaViewer( - [ - { - src: proxyUrl, - originalSrc: embedUrl, - naturalWidth, - naturalHeight, - type, - contentHash, - attachmentId, - embedIndex, - animated: true, - providerName, - }, - ], - 0, - { - channelId, - messageId, - message, - sourceChannel, - }, - ); + MediaViewerCommands.openMediaViewer([viewerItem], 0, { + channelId, + messageId, + message, + sourceChannel, + }); }, - [ - proxyUrl, - embedUrl, - naturalWidth, - naturalHeight, - type, - channelId, - messageId, - attachmentId, - embedIndex, - contentHash, - message, - sourceChannel, - providerName, - ], + [viewerItem, channelId, messageId, message, sourceChannel], ); + return {viewerItem, openPreview}; }; interface ImagePreviewHandlerProps { @@ -266,6 +244,8 @@ interface ImagePreviewHandlerProps { contentHash?: string | null; message?: Message; sourceChannel?: Channel | null; + onViewerWarmEnter?: () => void; + onViewerWarmLeave?: () => void; children: React.ReactNode; } @@ -284,6 +264,8 @@ const ImagePreviewHandler: FC = observer( contentHash, message, sourceChannel, + onViewerWarmEnter, + onViewerWarmLeave, children, }) => { const {i18n} = useLingui(); @@ -360,6 +342,8 @@ const ImagePreviewHandler: FC = observer( onMouseDown={openInBrowser.onMouseDown} onAuxClick={openInBrowser.onAuxClick} onKeyDown={openImagePreview} + onMouseEnter={onViewerWarmEnter} + onMouseLeave={onViewerWarmLeave} data-flx="channel.embeds.media.embed-gifv.image-preview-handler.image-preview-handler.open-image-preview.button" > {children} @@ -402,147 +386,39 @@ export const EmbedGifv: FC< const messageViewContext = useMaybeMessageViewContext(); const mediaCalculator = useEmbedMediaCalculator(); const videoRef = useRef(null); - const posterElementRef = useRef(null); - const containerRef = useRef(null); - const savedTimeRef = useRef(0); - const frameCleanupRef = useRef<(() => void) | null>(null); - const playbackRequestIdRef = useRef(0); const [containerElement, setContainerElement] = useState(null); - const [isHoveredState, setIsHoveredState] = useState(false); - const [isFocusWithin, setIsFocusWithin] = useState(false); - const {ref: visibilityRef, isNearViewport} = useNearViewport({rememberKey: videoProxyURL}); + const {isPointerInside, hasFocusInside} = useMediaSurfaceInteraction(containerElement); + const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId); + const { + ref: visibilityRef, + loadMedia: shouldLoadMedia, + animate: shouldAnimate, + } = useGifViewportGate({ + element: containerElement, + rememberKey: videoProxyURL, + shouldBlur, + }); const setContainerRef = useCallback( (node: HTMLDivElement | null) => { - containerRef.current = node; setContainerElement(node); visibilityRef(node); }, [visibilityRef], ); - const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId); - const shouldLoadVideo = isNearViewport && !shouldBlur; - const {dimensions} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight}, {forceScale: true}); + const {dimensions, style} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight}); + const canPlayInline = isInlinePlayableVideoSize({width: naturalWidth, height: naturalHeight}); const posterSource = thumbnailProxyURL && thumbnailProxyURL.length > 0 ? thumbnailProxyURL : videoProxyURL; - const posterURL = useMemo( - () => - buildFittedStaticGifPreviewURL( - stripMediaProxyParams(posterSource), - Math.max(1, Math.round(dimensions.width * 2)), - Math.max(1, Math.round(dimensions.height * 2)), - ), - [dimensions.height, dimensions.width, posterSource], - ); - const posterCachedForPaint = ImageCacheUtils.hasImage(posterURL); - const shouldLoadPoster = (isNearViewport || posterCachedForPaint) && !shouldBlur; - const { - loaded, - error, - cached, - cachedOnMount, - thumbHashURL, - ref: mediaRef, - onLoad: handleMediaLoad, - onError: handleMediaError, - } = useMediaLoading(posterURL, placeholder, {enabled: shouldLoadPoster}); - const renderSourceKey = `${videoProxyURL}\n${posterURL}\n${shouldLoadVideo ? 'active' : 'inactive'}`; - const [renderState, dispatchRenderEvent] = useReducer( - reduceGIFVRenderState, - { - sourceKey: renderSourceKey, - posterCached: ImageCacheUtils.hasImage(posterURL), - }, - createInitialGIFVRenderState, - ); - const currentRenderSourceKeyRef = useRef(renderSourceKey); - const setVideoRef = useCallback( - (node: HTMLVideoElement | null) => { - if (videoRef.current !== node && frameCleanupRef.current != null) { - frameCleanupRef.current(); - frameCleanupRef.current = null; - } - videoRef.current = node; - if (node == null) return; - if (currentRenderSourceKeyRef.current !== renderSourceKey) return; - if (!mediaElementHasSource(node, videoProxyURL)) return; - if (node.readyState < 2) return; - dispatchRenderEvent({type: GIFVRenderEventType.VIDEO_READY}); - }, - [renderSourceKey, videoProxyURL], - ); - const setPosterRef = useCallback( - (node: HTMLImageElement | null) => { - posterElementRef.current = node; - mediaRef(node); - }, - [mediaRef], - ); - useLayoutEffect(() => { - currentRenderSourceKeyRef.current = renderSourceKey; - if (frameCleanupRef.current != null) { - frameCleanupRef.current(); - frameCleanupRef.current = null; - } - dispatchRenderEvent({ - type: GIFVRenderEventType.SOURCE_CHANGED, - sourceKey: renderSourceKey, - posterCached: ImageCacheUtils.hasImage(posterURL), - }); - if (!shouldLoadVideo) return; - const video = videoRef.current; - if (video == null) return; - if (!mediaElementHasSource(video, videoProxyURL)) return; - if (video.readyState < 2) return; - dispatchRenderEvent({type: GIFVRenderEventType.VIDEO_READY}); - }, [posterURL, renderSourceKey, shouldLoadVideo, videoProxyURL]); - const renderStateIsCurrent = renderState.sourceKey === renderSourceKey; - const currentRenderState = renderStateIsCurrent - ? renderState - : createInitialGIFVRenderState({ - sourceKey: renderSourceKey, - posterCached: ImageCacheUtils.hasImage(posterURL), - }); - useEffect(() => { - if (!shouldLoadPoster) return; - if (loaded) { - dispatchRenderEvent({type: GIFVRenderEventType.POSTER_LOADED}); - return; - } - if (error) { - dispatchRenderEvent({type: GIFVRenderEventType.POSTER_FAILED}); - return; - } - dispatchRenderEvent({type: GIFVRenderEventType.POSTER_LOAD_STARTED}); - }, [error, loaded, shouldLoadPoster]); - useEffect(() => { - if (shouldLoadVideo) dispatchRenderEvent({type: GIFVRenderEventType.VIDEO_LOAD_STARTED}); - }, [renderSourceKey, shouldLoadVideo]); - useEffect(() => { - if (frameCleanupRef.current != null) { - frameCleanupRef.current(); - frameCleanupRef.current = null; - } - if (currentRenderState.video === GIFVVideoState.PRESENTED) return; - if (currentRenderState.playback !== GIFVPlaybackState.PLAYING) return; - const video = videoRef.current; - if (video == null || video.readyState < 2) return; - const sourceKey = renderSourceKey; - const cancelFrameWatch = watchPresentedVideoFrame(video, () => { - if (currentRenderSourceKeyRef.current !== sourceKey) return; - if (videoRef.current !== video) return; - if (!mediaElementHasSource(video, videoProxyURL)) return; - dispatchRenderEvent({type: GIFVRenderEventType.VIDEO_FRAME_PRESENTED}); - }); - frameCleanupRef.current = cancelFrameWatch; - return () => { - cancelFrameWatch(); - if (frameCleanupRef.current === cancelFrameWatch) frameCleanupRef.current = null; - }; - }, [currentRenderState.playback, currentRenderState.video, renderSourceKey, videoProxyURL]); - useEffect(() => { - return () => { - if (frameCleanupRef.current != null) frameCleanupRef.current(); - }; - }, []); + const posterURL = useMemo(() => { + const requestedSize = resolveProxyRequestSize(dimensions.width, dimensions.height, naturalWidth, naturalHeight); + return buildFittedStaticGifPreviewURL( + stripMediaProxyParams(posterSource), + requestedSize?.width, + requestedSize?.height, + ); + }, [dimensions.width, naturalHeight, naturalWidth, posterSource]); + const thumbHashURL = useMemo(() => decodeThumbHashDataURL(placeholder), [placeholder]); + const thumbHashCurtain = useThumbHashCurtain(posterURL, shouldLoadMedia); + const aspectRatioStyle = useAspectRatioStyle(dimensions.width, dimensions.height); const defaultName = deriveDefaultNameFromMessage({ message, attachmentId, @@ -564,10 +440,14 @@ export const EmbedGifv: FC< naturalWidth, naturalHeight, }); - const gifAutoPlay = useShouldAnimate({kind: 'gif', respectPlaybackAllowed: false}); + const animationPolicyAllowed = useShouldAnimate({ + kind: 'gif', + isHovering: isPointerInside, + isFocused: hasFocusInside, + }); const animatedMediaPlaybackAllowed = useAnimatedMediaPlaybackAllowed(); const isMediaViewerOpen = MediaViewer.isOpen; - const openImagePreview = useImagePreview({ + const {openPreview: openImagePreview} = useImagePreview({ proxyUrl: videoProxyURL, embedUrl: embedURL, naturalWidth, @@ -632,154 +512,24 @@ export const EmbedGifv: FC< snapshotIndex, ], ); - const handlePosterLoad = useCallback( - (event: React.SyntheticEvent) => { - if (currentRenderSourceKeyRef.current !== renderSourceKey) return; - if (posterElementRef.current !== event.currentTarget) return; - if (!mediaElementHasSource(event.currentTarget, posterURL)) return; - handleMediaLoad(event); - dispatchRenderEvent({type: GIFVRenderEventType.POSTER_LOADED}); - }, - [handleMediaLoad, posterURL, renderSourceKey], - ); - const handlePosterError = useCallback( - (event: React.SyntheticEvent) => { - if (currentRenderSourceKeyRef.current !== renderSourceKey) return; - if (posterElementRef.current !== event.currentTarget) return; - if (!mediaElementHasSource(event.currentTarget, posterURL)) return; - handleMediaError(event); - dispatchRenderEvent({type: GIFVRenderEventType.POSTER_FAILED}); - }, - [handleMediaError, posterURL, renderSourceKey], - ); - const handleVideoReady = useCallback( - (event: React.SyntheticEvent) => { - if (currentRenderSourceKeyRef.current !== renderSourceKey) return; - if (videoRef.current !== event.currentTarget) return; - if (!mediaElementHasSource(event.currentTarget, videoProxyURL)) return; - dispatchRenderEvent({type: GIFVRenderEventType.VIDEO_READY}); - }, - [renderSourceKey, videoProxyURL], - ); - const handleVideoPlaying = useCallback( - (event: React.SyntheticEvent) => { - if (currentRenderSourceKeyRef.current !== renderSourceKey) return; - if (videoRef.current !== event.currentTarget) return; - if (!mediaElementHasSource(event.currentTarget, videoProxyURL)) return; - dispatchRenderEvent({type: GIFVRenderEventType.PLAYING}); - }, - [renderSourceKey, videoProxyURL], - ); - const handleVideoPause = useCallback( - (event: React.SyntheticEvent) => { - if (currentRenderSourceKeyRef.current !== renderSourceKey) return; - if (videoRef.current !== event.currentTarget) return; - if (!mediaElementHasSource(event.currentTarget, videoProxyURL)) return; - dispatchRenderEvent({type: GIFVRenderEventType.PLAYBACK_PAUSED}); - }, - [renderSourceKey, videoProxyURL], - ); - const handleVideoError = useCallback( - (event: React.SyntheticEvent) => { - if (currentRenderSourceKeyRef.current !== renderSourceKey) return; - if (videoRef.current !== event.currentTarget) return; - if (!mediaElementHasSource(event.currentTarget, videoProxyURL)) return; - dispatchRenderEvent({type: GIFVRenderEventType.VIDEO_FAILED}); - }, - [renderSourceKey, videoProxyURL], - ); - const requestVideoPlayback = useCallback( - (video: HTMLVideoElement) => { - const sourceKey = renderSourceKey; - const requestId = playbackRequestIdRef.current; - if (currentRenderSourceKeyRef.current !== sourceKey) return; - if (videoRef.current !== video) return; - if (!mediaElementHasSource(video, videoProxyURL)) return; - dispatchRenderEvent({type: GIFVRenderEventType.PLAY_REQUESTED}); - if (!getAnimatedMediaPlaybackAllowed()) { - if (currentRenderSourceKeyRef.current === sourceKey) { - dispatchRenderEvent({type: GIFVRenderEventType.PLAYBACK_BLOCKED}); - } - return; - } - const playPromise = safePlay(video); - void playPromise.then(() => { - if (playbackRequestIdRef.current !== requestId) return; - if (currentRenderSourceKeyRef.current !== sourceKey) return; - if (videoRef.current !== video) return; - if (!mediaElementHasSource(video, videoProxyURL)) return; - if (video.paused) dispatchRenderEvent({type: GIFVRenderEventType.PLAYBACK_BLOCKED}); - }); - }, - [renderSourceKey, videoProxyURL], - ); - useEffect(() => { - const container = containerElement; - if (container == null) return; - const saveCurrentTime = () => { - const video = videoRef.current; - if (video != null && Number.isFinite(video.currentTime)) savedTimeRef.current = video.currentTime; - }; - const handleMouseEnter = () => { - setIsHoveredState(true); - }; - const handleMouseLeave = () => { - setIsHoveredState(false); - saveCurrentTime(); - }; - const handleFocusIn = () => { - setIsFocusWithin(true); - }; - const handleFocusOut = (event: FocusEvent) => { - const nextTarget = event.relatedTarget; - if (nextTarget != null && container.contains(nextTarget as Node)) return; - setIsFocusWithin(false); - saveCurrentTime(); - }; - container.addEventListener('mouseenter', handleMouseEnter); - container.addEventListener('mouseleave', handleMouseLeave); - container.addEventListener('focusin', handleFocusIn); - container.addEventListener('focusout', handleFocusOut); - return () => { - container.removeEventListener('mouseenter', handleMouseEnter); - container.removeEventListener('mouseleave', handleMouseLeave); - container.removeEventListener('focusin', handleFocusIn); - container.removeEventListener('focusout', handleFocusOut); - }; - }, [containerElement]); + const shouldPlay = + shouldAnimate && + animationPolicyAllowed && + animatedMediaPlaybackAllowed && + !isMediaViewerOpen && + thumbHashCurtain.posterRevealed; useEffect(() => { const video = videoRef.current; if (video == null) return; - playbackRequestIdRef.current += 1; - const playbackWanted = shouldLoadVideo && !isMediaViewerOpen && (gifAutoPlay || isHoveredState || isFocusWithin); - if (!playbackWanted || !animatedMediaPlaybackAllowed) { - video.autoplay = false; - if (playbackWanted && currentRenderSourceKeyRef.current === renderSourceKey) { - dispatchRenderEvent({type: GIFVRenderEventType.PLAYBACK_BLOCKED}); - } - safePause(video); + if (shouldPlay) { + video.autoplay = true; + void safePlay(video); return; } - video.autoplay = true; - const target = savedTimeRef.current; - if (Number.isFinite(target) && Math.abs(video.currentTime - target) > 0.01) { - try { - video.currentTime = target; - } catch {} - } - requestVideoPlayback(video); - }, [ - animatedMediaPlaybackAllowed, - gifAutoPlay, - isFocusWithin, - isHoveredState, - isMediaViewerOpen, - renderSourceKey, - requestVideoPlayback, - shouldLoadVideo, - ]); + video.autoplay = false; + safePause(video); + }, [shouldPlay]); if (shouldBlur) { - const {style} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight}, {forceScale: true}); const {width: _width, height: _height, ...styleWithoutDimensions} = style; const blurContainerStyle = {...styleWithoutDimensions, maxWidth: '100%', width: '100%'}; return ( @@ -799,6 +549,7 @@ export const EmbedGifv: FC< src={thumbHashURL} className={styles.thumbHashPlaceholder} alt="" + aria-hidden={true} style={{filter: 'blur(40px)'}} data-flx="channel.embeds.media.embed-gifv.thumb-hash-placeholder" /> @@ -814,49 +565,18 @@ export const EmbedGifv: FC< ); } - const {style} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight}, {forceScale: true}); - const { - showFavoriteButton, - showDownloadButton: _showDownloadButton, - showDeleteButton, - } = getMediaButtonVisibility(canFavorite, isPreview ? undefined : message, attachmentId, { - disableDelete: !!isPreview || snapshotIndex !== undefined, - }); - const showDownloadButton = false; + const {showFavoriteButton, showDeleteButton} = getMediaButtonVisibility( + canFavorite, + isPreview ? undefined : message, + attachmentId, + {disableDelete: !!isPreview || snapshotIndex !== undefined}, + ); const showGifIndicator = Accessibility.showGifIndicator && shouldShowOverlays(dimensions.width, dimensions.height); - const {width} = style; - const aspectRatio = - dimensions.width > 0 && dimensions.height > 0 ? `${dimensions.width} / ${dimensions.height}` : ''; - const containerStyle = { - '--embed-aspect-ratio': aspectRatio || 'auto', - '--embed-height': remFromPx(dimensions.height), - '--embed-width': typeof width === 'number' ? remFromPx(width) : remFromPx(dimensions.width), + const containerStyle: React.CSSProperties = { maxWidth: '100%', width: remFromPx(dimensions.width), - ...(aspectRatio ? {aspectRatio} : {}), - } as React.CSSProperties; - let effectivePreload: 'none' | 'metadata' | 'auto' = 'none'; - if (shouldLoadVideo) { - if (videoConfig && videoConfig.preload !== undefined) { - effectivePreload = videoConfig.preload; - } else { - effectivePreload = gifAutoPlay ? 'auto' : 'metadata'; - } - } - const renderLayers = getGIFVRenderLayers(currentRenderState); - const posterReady = loaded && !error; - const showPoster = shouldLoadPoster && posterReady && renderLayers.showPoster; - const showVideo = shouldLoadVideo && renderLayers.showVideo; - let videoControls = false; - let videoPlaysInline = true; - let videoLoop = true; - let videoMuted = true; - if (videoConfig) { - if (videoConfig.controls !== undefined) videoControls = videoConfig.controls; - if (videoConfig.playsInline !== undefined) videoPlaysInline = videoConfig.playsInline; - if (videoConfig.loop !== undefined) videoLoop = videoConfig.loop; - if (videoConfig.muted !== undefined) videoMuted = videoConfig.muted; - } + ...aspectRatioStyle, + }; return ( -
- {(renderLayers.showPlaceholder || !loaded || error) && thumbHashURL && ( +
+ {canPlayInline ? ( +
{ const {i18n} = useLingui(); const messageViewContext = useMaybeMessageViewContext(); - const isMobile = MobileLayout.enabled; const mediaCalculator = useEmbedMediaCalculator(layoutConstraints); - const containerRef = useRef(null); const [containerElement, setContainerElement] = useState(null); - const imgRef = useRef(null); - const animatedImageRef = useRef(null); - const freezeCanvasRef = useRef(null); + const {isPointerInside, hasFocusInside} = useMediaSurfaceInteraction(containerElement); const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId); const { ref: visibilityRef, @@ -998,120 +710,57 @@ export const EmbedGif: FC< }); const setContainerRef = useCallback( (node: HTMLDivElement | null) => { - containerRef.current = node; setContainerElement(node); visibilityRef(node); }, [visibilityRef], ); - const {dimensions} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight}, {forceScale: true}); + const {dimensions, style} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight}); const {width: displayWidth, height: displayHeight} = dimensions; - const gifAutoPlay = useShouldAnimate({kind: 'gif', respectPlaybackAllowed: false}); - const animatedMediaPlaybackAllowed = useAnimatedMediaPlaybackAllowed(); + const gifAutoPlay = useShouldAnimate({kind: 'gif'}); + const animationPolicyAllowed = useShouldAnimate({ + kind: 'gif', + isHovering: isPointerInside, + isFocused: hasFocusInside, + }); const baseProxyURL = stripMediaProxyParams(proxyURL); - const animatedTargetWidth = Math.min(naturalWidth, Math.round(displayWidth * 2)); - const animatedTargetHeight = Math.min(naturalHeight, Math.round(displayHeight * 2)); - const naturalSizeIsKnown = naturalWidth > 0 && naturalHeight > 0; - const shouldResizeAnimated = - naturalSizeIsKnown && (animatedTargetWidth < naturalWidth || animatedTargetHeight < naturalHeight); + const requestedSize = resolveProxyRequestSize(displayWidth, displayHeight, naturalWidth, naturalHeight); const optimizedAnimatedURL = buildFittedAnimatedImageProxyURL( baseProxyURL, - shouldResizeAnimated ? animatedTargetWidth : undefined, - shouldResizeAnimated ? animatedTargetHeight : undefined, + requestedSize?.width, + requestedSize?.height, ); const optimizedStaticURL = buildFittedStaticGifPreviewURL( baseProxyURL, - Math.round(displayWidth * 2), - Math.round(displayHeight * 2), + requestedSize?.width, + requestedSize?.height, ); - const animatedImageSourceRef = useRef(optimizedAnimatedURL); + const shouldShowAnimated = shouldAnimate && animationPolicyAllowed; + const activeSource = shouldShowAnimated ? optimizedAnimatedURL : optimizedStaticURL; const { loaded, - error, - cached, cachedOnMount, thumbHashURL, ref: mediaRef, onLoad: handleImageLoad, onError: handleImageError, - } = useMediaLoading(optimizedStaticURL, placeholder, { - enabled: shouldLoadMedia, - }); - const setImgRef = useCallback( - (node: HTMLImageElement | null) => { - imgRef.current = node; - mediaRef(node); - }, - [mediaRef], - ); - const [decoderCanvas, setDecoderCanvas] = useState(null); - const isHoveredRef = useRef(false); - const [hasStartedAnimating, setHasStartedAnimating] = useState(gifAutoPlay); - const [isHoveredState, setIsHoveredState] = useState(false); - const [renderedAnimatedURL, setRenderedAnimatedURL] = useState(''); - const animatedPreloadStateRef = useRef({ - url: optimizedAnimatedURL, - status: ImageCacheUtils.hasImage(optimizedAnimatedURL) ? 'ready' : 'idle', - }); - const [decoderRequested, setDecoderRequested] = useState(() => getAnimatedMediaPlaybackAllowed()); - const shouldUseDecoder = shouldAnimate && hasStartedAnimating; - const decoderPlaying = - shouldUseDecoder && decoderRequested && animatedMediaPlaybackAllowed && (gifAutoPlay || isHoveredState); - const decoderState = useAnimatedImageDecoder({ - src: shouldUseDecoder && decoderRequested ? optimizedAnimatedURL : null, - playing: decoderPlaying, - canvas: decoderCanvas, - maxCachedFrames: DECODER_MAX_CACHED_FRAMES, - }); - const useDecoder = - shouldUseDecoder && decoderRequested && decoderState.supported && hasStartedAnimating && !decoderState.error; - const decoderCanvasVisible = useDecoder && decoderState.loaded; - const shouldRenderFreezeFrame = shouldAnimate && !decoderCanvasVisible && (gifAutoPlay || hasStartedAnimating); - const setAnimatedImageRef = useCallback( - (node: HTMLImageElement | null) => { - animatedImageRef.current = node; - if (animatedImageSourceRef.current !== optimizedAnimatedURL) return; - if (completeImageHasSource(node, optimizedAnimatedURL)) setRenderedAnimatedURL(optimizedAnimatedURL); - }, - [optimizedAnimatedURL], - ); - useLayoutEffect(() => { - animatedImageSourceRef.current = optimizedAnimatedURL; - setRenderedAnimatedURL(''); - if (completeImageHasSource(animatedImageRef.current, optimizedAnimatedURL)) { - setRenderedAnimatedURL(optimizedAnimatedURL); - } - }, [optimizedAnimatedURL]); - useLayoutEffect(() => { - const nextState: AnimatedImagePreloadState = { - url: optimizedAnimatedURL, - status: ImageCacheUtils.hasImage(optimizedAnimatedURL) ? 'ready' : 'idle', - }; - animatedPreloadStateRef.current = nextState; - }, [optimizedAnimatedURL]); - const handleAnimatedImageLoad = useCallback( - (event: React.SyntheticEvent) => { - const element = event.currentTarget; - if (animatedImageSourceRef.current !== optimizedAnimatedURL) return; - if (animatedImageRef.current !== element) return; - if (!completeImageHasSource(element, optimizedAnimatedURL)) return; - ImageCacheUtils.rememberImage(optimizedAnimatedURL, element); - animatedPreloadStateRef.current = {url: optimizedAnimatedURL, status: 'ready'}; - setRenderedAnimatedURL(optimizedAnimatedURL); - }, - [optimizedAnimatedURL], - ); - const handleAnimatedImageError = useCallback( - (event: React.SyntheticEvent) => { - if (animatedImageSourceRef.current !== optimizedAnimatedURL) return; - if (animatedImageRef.current !== event.currentTarget) return; - if (!mediaElementHasSource(event.currentTarget, optimizedAnimatedURL)) return; - setRenderedAnimatedURL(''); - ImageCacheUtils.forgetImage(optimizedAnimatedURL); - animatedPreloadStateRef.current = {url: optimizedAnimatedURL, status: 'failed'}; - }, - [optimizedAnimatedURL], - ); + } = useMediaLoading(activeSource, placeholder, {enabled: shouldLoadMedia}); + const [mountedAt] = useState(() => Date.now()); + const [placeholderMounted] = useState(!cachedOnMount); + const [placeholderHidden, setPlaceholderHidden] = useState(loaded); + const [placeholderFastFade, setPlaceholderFastFade] = useState(false); + const aspectRatioStyle = useAspectRatioStyle(dimensions.width, dimensions.height); + useEffect(() => { + if (!loaded) return; + setPlaceholderFastFade(Date.now() - mountedAt < PLACEHOLDER_FAST_FADE_WINDOW_MS); + setPlaceholderHidden(true); + }, [loaded, mountedAt]); + useEffect(() => { + if (!shouldLoadMedia) return; + if (!animationPolicyAllowed) return; + if (activeSource === optimizedAnimatedURL) return; + return ImageCacheUtils.pinImage(optimizedAnimatedURL); + }, [activeSource, animationPolicyAllowed, optimizedAnimatedURL, shouldLoadMedia]); const defaultName = deriveDefaultNameFromMessage({ message, attachmentId, @@ -1133,7 +782,7 @@ export const EmbedGif: FC< naturalWidth, naturalHeight, }); - const openImagePreview = useImagePreview({ + const {viewerItem, openPreview: openImagePreview} = useImagePreview({ proxyUrl: optimizedAnimatedURL, embedUrl: embedURL, naturalWidth, @@ -1147,6 +796,10 @@ export const EmbedGif: FC< message, sourceChannel: messageViewContext?.channel, }); + const {scheduleViewerWarm, cancelViewerWarm} = useMediaViewerHoverWarm(viewerItem, { + allowAnimated: gifAutoPlay, + enabled: !shouldBlur, + }); const handleDeleteClick = useDeleteAttachment(message, attachmentId); const handleDownloadClickGif = useCallback( (e: React.MouseEvent) => { @@ -1197,157 +850,7 @@ export const EmbedGif: FC< snapshotIndex, ], ); - useEffect(() => { - if (gifAutoPlay) setHasStartedAnimating(true); - }, [gifAutoPlay]); - useEffect(() => { - if (!shouldUseDecoder) { - setDecoderRequested(false); - return; - } - if (animatedMediaPlaybackAllowed) { - setDecoderRequested(true); - } - }, [animatedMediaPlaybackAllowed, shouldUseDecoder]); - useEffect(() => { - const shouldPreloadAnimated = shouldLoadMedia && animatedMediaPlaybackAllowed && optimizedAnimatedURL.length > 0; - if (!shouldPreloadAnimated) return; - const preloadState = animatedPreloadStateRef.current; - if (preloadState.url !== optimizedAnimatedURL || preloadState.status !== 'idle') return; - const loadingState: AnimatedImagePreloadState = {url: optimizedAnimatedURL, status: 'loading'}; - animatedPreloadStateRef.current = loadingState; - let active = true; - const cleanup = ImageCacheUtils.loadImage( - optimizedAnimatedURL, - () => { - if (!active) return; - const readyState: AnimatedImagePreloadState = {url: optimizedAnimatedURL, status: 'ready'}; - animatedPreloadStateRef.current = readyState; - }, - () => { - if (!active) return; - const failedState: AnimatedImagePreloadState = {url: optimizedAnimatedURL, status: 'failed'}; - animatedPreloadStateRef.current = failedState; - }, - ); - return () => { - active = false; - cleanup(); - if (animatedPreloadStateRef.current !== loadingState) return; - const idleState: AnimatedImagePreloadState = {url: optimizedAnimatedURL, status: 'idle'}; - animatedPreloadStateRef.current = idleState; - }; - }, [animatedMediaPlaybackAllowed, optimizedAnimatedURL, shouldLoadMedia]); - const showFreezeFrame = useCallback(() => { - const canvas = freezeCanvasRef.current; - if (canvas == null) return; - canvas.removeAttribute('data-frozen'); - const animatedImage = animatedImageRef.current; - const staticImage = imgRef.current; - let image: HTMLImageElement; - if (completeImageHasSource(animatedImage, optimizedAnimatedURL)) { - image = animatedImage; - } else if (completeImageHasSource(staticImage, optimizedStaticURL)) { - image = staticImage; - } else { - return; - } - const sourceWidth = image.naturalWidth; - const sourceHeight = image.naturalHeight; - if (canvas.width !== sourceWidth) canvas.width = sourceWidth; - if (canvas.height !== sourceHeight) canvas.height = sourceHeight; - const ctx = canvas.getContext('2d'); - if (ctx == null) return; - try { - ctx.clearRect(0, 0, sourceWidth, sourceHeight); - ctx.drawImage(image, 0, 0, sourceWidth, sourceHeight); - } catch { - return; - } - canvas.dataset.frozen = 'true'; - }, [optimizedAnimatedURL, optimizedStaticURL]); - const hideFreezeFrame = useCallback(() => { - const canvas = freezeCanvasRef.current; - if (canvas == null) return; - canvas.removeAttribute('data-frozen'); - }, []); - const resetFreezeFrame = useCallback(() => { - const canvas = freezeCanvasRef.current; - if (canvas == null) return; - canvas.removeAttribute('data-frozen'); - canvas.width = 0; - canvas.height = 0; - }, []); - useLayoutEffect(() => { - resetFreezeFrame(); - }, [optimizedAnimatedURL, optimizedStaticURL, resetFreezeFrame]); - useEffect(() => { - if (!shouldLoadMedia || gifAutoPlay) return; - const container = containerElement; - if (container == null) return; - const handleMouseEnter = () => { - isHoveredRef.current = true; - setIsHoveredState(true); - if (!getAnimatedMediaPlaybackAllowed()) return; - if (!hasStartedAnimating) { - setHasStartedAnimating(true); - } - hideFreezeFrame(); - }; - const handleMouseLeave = () => { - isHoveredRef.current = false; - setIsHoveredState(false); - if (!hasStartedAnimating) return; - showFreezeFrame(); - }; - container.addEventListener('mouseenter', handleMouseEnter); - container.addEventListener('mouseleave', handleMouseLeave); - return () => { - container.removeEventListener('mouseenter', handleMouseEnter); - container.removeEventListener('mouseleave', handleMouseLeave); - }; - }, [containerElement, gifAutoPlay, hasStartedAnimating, hideFreezeFrame, shouldLoadMedia, showFreezeFrame]); - useEffect(() => { - if (!shouldLoadMedia || gifAutoPlay) return; - if (!animatedMediaPlaybackAllowed) { - if (isHoveredRef.current && hasStartedAnimating) { - showFreezeFrame(); - } - return; - } - if (isHoveredRef.current) { - if (!hasStartedAnimating) { - setHasStartedAnimating(true); - } - hideFreezeFrame(); - } - }, [ - animatedMediaPlaybackAllowed, - gifAutoPlay, - hasStartedAnimating, - hideFreezeFrame, - shouldLoadMedia, - showFreezeFrame, - ]); - useEffect(() => { - if (!shouldLoadMedia || !shouldRenderFreezeFrame) return; - if (!animatedMediaPlaybackAllowed) { - showFreezeFrame(); - return; - } - if (gifAutoPlay || isHoveredRef.current) { - hideFreezeFrame(); - } - }, [ - animatedMediaPlaybackAllowed, - gifAutoPlay, - hideFreezeFrame, - shouldLoadMedia, - shouldRenderFreezeFrame, - showFreezeFrame, - ]); if (shouldBlur) { - const {style} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight}, {forceScale: true}); const {width: _width, height: _height, ...styleWithoutDimensions} = style; const blurContainerStyle = {...styleWithoutDimensions, maxWidth: '100%', width: '100%'}; return ( @@ -1370,6 +873,7 @@ export const EmbedGif: FC< src={thumbHashURL} className={styles.thumbHashPlaceholder} alt="" + aria-hidden={true} style={{filter: 'blur(40px)'}} data-flx="channel.embeds.media.embed-gifv.embed-gif.thumb-hash-placeholder" /> @@ -1385,37 +889,18 @@ export const EmbedGif: FC<
); } - const {style, dimensions: renderedDimensions} = mediaCalculator.calculate( - {width: naturalWidth, height: naturalHeight}, - {forceScale: true}, - ); const {showFavoriteButton, showDownloadButton, showDeleteButton} = getMediaButtonVisibility( canFavorite, isPreview ? undefined : message, attachmentId, {disableDelete: !!isPreview || snapshotIndex !== undefined}, ); - const showGifIndicator = - Accessibility.showGifIndicator && shouldShowOverlays(renderedDimensions.width, renderedDimensions.height); - const {width} = style; - const aspectRatio = - renderedDimensions.width > 0 && renderedDimensions.height > 0 - ? `${renderedDimensions.width} / ${renderedDimensions.height}` - : ''; - const containerStyle = { - '--embed-aspect-ratio': aspectRatio || 'auto', - '--embed-height': remFromPx(renderedDimensions.height), - '--embed-width': typeof width === 'number' ? remFromPx(width) : remFromPx(renderedDimensions.width), + const showGifIndicator = Accessibility.showGifIndicator && shouldShowOverlays(dimensions.width, dimensions.height); + const containerStyle: React.CSSProperties = { maxWidth: '100%', - width: remFromPx(renderedDimensions.width), - ...(aspectRatio ? {aspectRatio} : {}), - } as React.CSSProperties; - const shouldUseAnimatedImage = - shouldAnimate && - animatedMediaPlaybackAllowed && - !decoderCanvasVisible && - (gifAutoPlay || (hasStartedAnimating && isHoveredState)); - const animatedImageVisible = shouldUseAnimatedImage && renderedAnimatedURL === optimizedAnimatedURL; + width: remFromPx(dimensions.width), + ...aspectRatioStyle, + }; return ( @@ -1449,77 +934,40 @@ export const EmbedGif: FC< contentHash={contentHash} message={message} sourceChannel={messageViewContext?.channel} + onViewerWarmEnter={scheduleViewerWarm} + onViewerWarmLeave={cancelViewerWarm} data-flx="channel.embeds.media.embed-gifv.embed-gif.image-preview-handler.gif" > -
- {(!loaded || error) && thumbHashURL && ( - {i18n._(LOADING_PLACEHOLDER_DESCRIPTOR)} - )} - + {i18n._(ANIMATED_GIF_DESCRIPTOR)} - {shouldUseAnimatedImage && ( - - )} - - {shouldRenderFreezeFrame && ( -
diff --git a/fluxer_app/src/features/channel/components/embeds/media/GIFVRenderStateMachine.ts b/fluxer_app/src/features/channel/components/embeds/media/GIFVRenderStateMachine.ts deleted file mode 100644 index a651c0ed7..000000000 --- a/fluxer_app/src/features/channel/components/embeds/media/GIFVRenderStateMachine.ts +++ /dev/null @@ -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, - }; -} diff --git a/fluxer_app/src/features/channel/components/embeds/media/GifViewportGate.ts b/fluxer_app/src/features/channel/components/embeds/media/GifViewportGate.ts index d4fa6eb32..d19e93fa1 100644 --- a/fluxer_app/src/features/channel/components/embeds/media/GifViewportGate.ts +++ b/fluxer_app/src/features/channel/components/embeds/media/GifViewportGate.ts @@ -1,10 +1,7 @@ // 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 {observeIntersection} from '@app/features/platform/utils/SharedIntersectionObserver'; -import {useEffect, useState} from 'react'; - -const ANIMATION_ROOT_MARGIN = '600px 0px'; export interface GifViewportGate { loadMedia: boolean; @@ -34,23 +31,6 @@ export function useGifViewportGate({ shouldBlur: boolean; }): GifViewportGate & {ref: (node: T | null) => void} { const {ref, isNearViewport} = useNearViewport({rememberKey}); - const [isInViewport, setIsInViewport] = useState(() => typeof IntersectionObserver === 'undefined'); - 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]); + const isInViewport = useViewportPlayback(element); return {ref, ...resolveGifViewportGate({isNearViewport, isInViewport, shouldBlur})}; } diff --git a/fluxer_app/src/features/channel/components/embeds/media/MediaContainer.tsx b/fluxer_app/src/features/channel/components/embeds/media/MediaContainer.tsx index 09e5f5a8e..e52f10713 100644 --- a/fluxer_app/src/features/channel/components/embeds/media/MediaContainer.tsx +++ b/fluxer_app/src/features/channel/components/embeds/media/MediaContainer.tsx @@ -2,6 +2,7 @@ import {LongPressable} from '@app/features/app/components/LongPressable'; import mediaStyles from '@app/features/channel/components/embeds/media/MediaContainer.module.css'; +import {shouldShowOverlays} from '@app/features/channel/components/embeds/media/MediaOverlayFit'; import { ADD_TO_FAVORITES_DESCRIPTOR, DELETE_ATTACHMENT_DESCRIPTOR, @@ -28,13 +29,6 @@ const DOWNLOAD_MEDIA_DESCRIPTOR = msg({ message: 'Download media', 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 | React.TouchEvent; @@ -96,10 +90,7 @@ export const MediaContainer = observer( e.stopPropagation(); onDeleteClick?.(e); }; - const isMediaTooSmall = - renderedWidth !== undefined && - renderedHeight !== undefined && - (renderedWidth < MIN_SIZE_FOR_OVERLAYS || renderedHeight < MIN_SIZE_FOR_OVERLAYS); + const isMediaTooSmall = !shouldShowOverlays(renderedWidth, renderedHeight); const shouldShowFavorite = showFavoriteButton && (forceShowFavoriteButton || !isMediaTooSmall); const shouldShowDownload = showDownloadButton && !isMediaTooSmall; const shouldShowDelete = showDeleteButton && !isMediaTooSmall; diff --git a/fluxer_app/src/features/channel/components/embeds/media/MediaOverlayFit.ts b/fluxer_app/src/features/channel/components/embeds/media/MediaOverlayFit.ts new file mode 100644 index 000000000..742c1b638 --- /dev/null +++ b/fluxer_app/src/features/channel/components/embeds/media/MediaOverlayFit.ts @@ -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; +} diff --git a/fluxer_app/src/features/channel/components/embeds/media/VideoDimensionUtils.test.ts b/fluxer_app/src/features/channel/components/embeds/media/VideoDimensionUtils.test.ts index d5df1d4ba..06e0c6d5e 100644 --- a/fluxer_app/src/features/channel/components/embeds/media/VideoDimensionUtils.test.ts +++ b/fluxer_app/src/features/channel/components/embeds/media/VideoDimensionUtils.test.ts @@ -1,7 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {describe, expect, it} from 'vitest'; -import {getEffectiveVideoLayoutDimensions, hasDifferentAspectRatio, resolveVideoLayout} from './VideoDimensionUtils'; +import { + getEffectiveVideoLayoutDimensions, + hasDifferentAspectRatio, + isInlinePlayableVideoSize, + resolveVideoLayout, +} from './VideoDimensionUtils'; describe('getEffectiveVideoLayoutDimensions', () => { it('prefers decoded dimensions over declared upload dimensions', () => { @@ -55,3 +60,47 @@ describe('resolveVideoLayout', () => { 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); + }); +}); diff --git a/fluxer_app/src/features/channel/components/embeds/media/VideoDimensionUtils.ts b/fluxer_app/src/features/channel/components/embeds/media/VideoDimensionUtils.ts index ca43e54cc..f038f6045 100644 --- a/fluxer_app/src/features/channel/components/embeds/media/VideoDimensionUtils.ts +++ b/fluxer_app/src/features/channel/components/embeds/media/VideoDimensionUtils.ts @@ -26,6 +26,8 @@ const DEFAULT_INLINE_VIDEO_LAYOUT_CONSTRAINTS: VideoLayoutConstraints = { maxWidth: 400, maxHeight: 400, }; +const MAX_INLINE_PLAYBACK_LONG_EDGE = 6016; +const MAX_INLINE_PLAYBACK_SHORT_EDGE = 3384; export function normalizeVideoDimensions(dimensions?: Partial | null): VideoDimensions | null { const width = dimensions?.width; @@ -39,6 +41,18 @@ export function normalizeVideoDimensions(dimensions?: Partial | }; } +export function isInlinePlayableVideoSize(dimensions?: Partial | 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 { return Number.isFinite(value) && value !== undefined && value > 0 ? value : fallback; } diff --git a/fluxer_app/src/features/platform/utils/SharedIntersectionObserver.ts b/fluxer_app/src/features/platform/utils/SharedIntersectionObserver.ts index 6382022d2..75107a734 100644 --- a/fluxer_app/src/features/platform/utils/SharedIntersectionObserver.ts +++ b/fluxer_app/src/features/platform/utils/SharedIntersectionObserver.ts @@ -22,30 +22,24 @@ function thresholdKey(threshold: number | Array | undefined): string { return threshold.join(','); } +const rootIds = new WeakMap(); +let rootIdCounter = 0; + function rootKey(root: Element | null | undefined): string { if (!root) return 'document'; - const id = ( - root as Element & { - dataset?: DOMStringMap; - } - ).dataset?.sharedIoId; - if (id) return `el:${id}`; - const generated = `gen-${++rootIdCounter}`; - ( - root as Element & { - dataset: DOMStringMap; - } - ).dataset.sharedIoId = generated; - return `el:${generated}`; + let id = rootIds.get(root); + if (id === undefined) { + id = `r${++rootIdCounter}`; + rootIds.set(root, id); + } + return `el:${id}`; } -let rootIdCounter = 0; - function buildKey(options: ObserverOptions): string { 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); let state = pool.get(key); if (state) return state; @@ -77,7 +71,7 @@ function getOrCreate(options: ObserverOptions): SharedObserverState { } export function observeIntersection(element: Element, callback: Callback, options: ObserverOptions = {}): () => void { - const state = getOrCreate(options); + const state = acquirePooledObserver(options); let handlers = state.callbacks.get(element); if (!handlers) { handlers = new Set(); diff --git a/fluxer_app/src/features/platform/utils/ViewportPlaybackRegistry.ts b/fluxer_app/src/features/platform/utils/ViewportPlaybackRegistry.ts new file mode 100644 index 000000000..8564ebfc5 --- /dev/null +++ b/fluxer_app/src/features/platform/utils/ViewportPlaybackRegistry.ts @@ -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 { + 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(); +const playingNodes = new Set(); + +let onScreenNodes = new Set(); + +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([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(); + } + }; +}