mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
perf(app): play gif embeds from the viewport not a js decoder (#1877)
This commit is contained in:
1 parent
69e0086144
commit
1f0f7d1222
14 files changed
+550
-1837
No files matched your search
@@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,155 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const GIFVPosterState = Object.freeze({
|
||||
IDLE: 'idle',
|
||||
LOADING: 'loading',
|
||||
READY: 'ready',
|
||||
ERROR: 'error',
|
||||
} as const);
|
||||
|
||||
export type GIFVPosterState = (typeof GIFVPosterState)[keyof typeof GIFVPosterState];
|
||||
|
||||
export const GIFVVideoState = Object.freeze({
|
||||
IDLE: 'idle',
|
||||
LOADING: 'loading',
|
||||
READY: 'ready',
|
||||
PRESENTED: 'presented',
|
||||
ERROR: 'error',
|
||||
} as const);
|
||||
|
||||
export type GIFVVideoState = (typeof GIFVVideoState)[keyof typeof GIFVVideoState];
|
||||
|
||||
export const GIFVPlaybackState = Object.freeze({
|
||||
IDLE: 'idle',
|
||||
REQUESTED: 'requested',
|
||||
PLAYING: 'playing',
|
||||
PAUSED: 'paused',
|
||||
BLOCKED: 'blocked',
|
||||
} as const);
|
||||
|
||||
export type GIFVPlaybackState = (typeof GIFVPlaybackState)[keyof typeof GIFVPlaybackState];
|
||||
|
||||
export const GIFVRenderEventType = Object.freeze({
|
||||
SOURCE_CHANGED: 'SOURCE_CHANGED',
|
||||
POSTER_LOAD_STARTED: 'POSTER_LOAD_STARTED',
|
||||
POSTER_LOADED: 'POSTER_LOADED',
|
||||
POSTER_FAILED: 'POSTER_FAILED',
|
||||
VIDEO_LOAD_STARTED: 'VIDEO_LOAD_STARTED',
|
||||
VIDEO_READY: 'VIDEO_READY',
|
||||
VIDEO_FRAME_PRESENTED: 'VIDEO_FRAME_PRESENTED',
|
||||
VIDEO_FAILED: 'VIDEO_FAILED',
|
||||
PLAY_REQUESTED: 'PLAY_REQUESTED',
|
||||
PLAYING: 'PLAYING',
|
||||
PLAYBACK_PAUSED: 'PLAYBACK_PAUSED',
|
||||
PLAYBACK_BLOCKED: 'PLAYBACK_BLOCKED',
|
||||
} as const);
|
||||
|
||||
export type GIFVRenderEventType = (typeof GIFVRenderEventType)[keyof typeof GIFVRenderEventType];
|
||||
|
||||
export interface GIFVRenderState {
|
||||
sourceKey: string;
|
||||
poster: GIFVPosterState;
|
||||
video: GIFVVideoState;
|
||||
playback: GIFVPlaybackState;
|
||||
}
|
||||
|
||||
export interface GIFVSourceChangedEvent {
|
||||
type: typeof GIFVRenderEventType.SOURCE_CHANGED;
|
||||
sourceKey: string;
|
||||
posterCached: boolean;
|
||||
}
|
||||
|
||||
export type GIFVRenderEvent =
|
||||
| GIFVSourceChangedEvent
|
||||
| {type: typeof GIFVRenderEventType.POSTER_LOAD_STARTED}
|
||||
| {type: typeof GIFVRenderEventType.POSTER_LOADED}
|
||||
| {type: typeof GIFVRenderEventType.POSTER_FAILED}
|
||||
| {type: typeof GIFVRenderEventType.VIDEO_LOAD_STARTED}
|
||||
| {type: typeof GIFVRenderEventType.VIDEO_READY}
|
||||
| {type: typeof GIFVRenderEventType.VIDEO_FRAME_PRESENTED}
|
||||
| {type: typeof GIFVRenderEventType.VIDEO_FAILED}
|
||||
| {type: typeof GIFVRenderEventType.PLAY_REQUESTED}
|
||||
| {type: typeof GIFVRenderEventType.PLAYING}
|
||||
| {type: typeof GIFVRenderEventType.PLAYBACK_PAUSED}
|
||||
| {type: typeof GIFVRenderEventType.PLAYBACK_BLOCKED};
|
||||
|
||||
export interface GIFVRenderLayers {
|
||||
showPlaceholder: boolean;
|
||||
showPoster: boolean;
|
||||
showVideo: boolean;
|
||||
}
|
||||
|
||||
export interface InitialGIFVRenderStateRequest {
|
||||
posterCached: boolean;
|
||||
sourceKey: string;
|
||||
}
|
||||
|
||||
export function createInitialGIFVRenderState({
|
||||
sourceKey,
|
||||
posterCached,
|
||||
}: InitialGIFVRenderStateRequest): GIFVRenderState {
|
||||
let posterState: GIFVPosterState = GIFVPosterState.IDLE;
|
||||
if (posterCached) posterState = GIFVPosterState.READY;
|
||||
return {
|
||||
sourceKey,
|
||||
poster: posterState,
|
||||
video: GIFVVideoState.IDLE,
|
||||
playback: GIFVPlaybackState.IDLE,
|
||||
};
|
||||
}
|
||||
|
||||
function reduceGIFVSourceChange(state: GIFVRenderState, event: GIFVSourceChangedEvent): GIFVRenderState {
|
||||
if (state.sourceKey !== event.sourceKey) {
|
||||
return createInitialGIFVRenderState({
|
||||
sourceKey: event.sourceKey,
|
||||
posterCached: event.posterCached,
|
||||
});
|
||||
}
|
||||
if (!event.posterCached) return state;
|
||||
if (state.poster === GIFVPosterState.READY) return state;
|
||||
return {...state, poster: GIFVPosterState.READY};
|
||||
}
|
||||
|
||||
export function reduceGIFVRenderState(state: GIFVRenderState, event: GIFVRenderEvent): GIFVRenderState {
|
||||
switch (event.type) {
|
||||
case GIFVRenderEventType.SOURCE_CHANGED:
|
||||
return reduceGIFVSourceChange(state, event);
|
||||
case GIFVRenderEventType.POSTER_LOAD_STARTED:
|
||||
if (state.poster === GIFVPosterState.READY) return state;
|
||||
return {...state, poster: GIFVPosterState.LOADING};
|
||||
case GIFVRenderEventType.POSTER_LOADED:
|
||||
if (state.poster === GIFVPosterState.READY) return state;
|
||||
return {...state, poster: GIFVPosterState.READY};
|
||||
case GIFVRenderEventType.POSTER_FAILED:
|
||||
if (state.poster === GIFVPosterState.READY) return state;
|
||||
return {...state, poster: GIFVPosterState.ERROR};
|
||||
case GIFVRenderEventType.VIDEO_LOAD_STARTED:
|
||||
if (state.video === GIFVVideoState.PRESENTED) return state;
|
||||
return {...state, video: GIFVVideoState.LOADING};
|
||||
case GIFVRenderEventType.VIDEO_READY:
|
||||
if (state.video === GIFVVideoState.PRESENTED) return state;
|
||||
return {...state, video: GIFVVideoState.READY};
|
||||
case GIFVRenderEventType.VIDEO_FRAME_PRESENTED:
|
||||
return {...state, video: GIFVVideoState.PRESENTED};
|
||||
case GIFVRenderEventType.VIDEO_FAILED:
|
||||
return {...state, video: GIFVVideoState.ERROR, playback: GIFVPlaybackState.PAUSED};
|
||||
case GIFVRenderEventType.PLAY_REQUESTED:
|
||||
return {...state, playback: GIFVPlaybackState.REQUESTED};
|
||||
case GIFVRenderEventType.PLAYING:
|
||||
return {...state, playback: GIFVPlaybackState.PLAYING};
|
||||
case GIFVRenderEventType.PLAYBACK_PAUSED:
|
||||
return {...state, playback: GIFVPlaybackState.PAUSED};
|
||||
case GIFVRenderEventType.PLAYBACK_BLOCKED:
|
||||
return {...state, playback: GIFVPlaybackState.BLOCKED};
|
||||
}
|
||||
}
|
||||
|
||||
export function getGIFVRenderLayers(state: GIFVRenderState): GIFVRenderLayers {
|
||||
const showVideo = state.video === GIFVVideoState.PRESENTED;
|
||||
const showPoster = !showVideo && state.poster === GIFVPosterState.READY;
|
||||
return {
|
||||
showPlaceholder: !showVideo && !showPoster,
|
||||
showPoster,
|
||||
showVideo,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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<T extends Element>({
|
||||
shouldBlur: boolean;
|
||||
}): GifViewportGate & {ref: (node: T | null) => void} {
|
||||
const {ref, isNearViewport} = useNearViewport<T>({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})};
|
||||
}
|
||||
@@ -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<HTMLDivElement> | React.TouchEvent<HTMLDivElement>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const OVERLAY_ACTION_ITEM_SIZE = 28;
|
||||
const OVERLAY_ACTION_GAP = 2;
|
||||
const OVERLAY_ACTION_PADDING = 3;
|
||||
const OVERLAY_EDGE_INSET = 8;
|
||||
const MAX_OVERLAY_ACTIONS = 3;
|
||||
|
||||
const OVERLAY_TOOLBAR_WIDTH =
|
||||
OVERLAY_ACTION_PADDING * 2 +
|
||||
MAX_OVERLAY_ACTIONS * OVERLAY_ACTION_ITEM_SIZE +
|
||||
(MAX_OVERLAY_ACTIONS - 1) * OVERLAY_ACTION_GAP;
|
||||
const OVERLAY_TOOLBAR_HEIGHT = OVERLAY_ACTION_PADDING * 2 + OVERLAY_ACTION_ITEM_SIZE;
|
||||
|
||||
export const MIN_WIDTH_FOR_OVERLAYS = OVERLAY_TOOLBAR_WIDTH + OVERLAY_EDGE_INSET * 2;
|
||||
export const MIN_HEIGHT_FOR_OVERLAYS = OVERLAY_TOOLBAR_HEIGHT + OVERLAY_EDGE_INSET * 2;
|
||||
|
||||
export function shouldShowOverlays(renderedWidth?: number, renderedHeight?: number): boolean {
|
||||
if (renderedWidth === undefined || renderedHeight === undefined) {
|
||||
return true;
|
||||
}
|
||||
return renderedWidth >= MIN_WIDTH_FOR_OVERLAYS && renderedHeight >= MIN_HEIGHT_FOR_OVERLAYS;
|
||||
}
|
||||
+50
-1
@@ -1,7 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<VideoDimensions> | null): VideoDimensions | null {
|
||||
const width = dimensions?.width;
|
||||
@@ -39,6 +41,18 @@ export function normalizeVideoDimensions(dimensions?: Partial<VideoDimensions> |
|
||||
};
|
||||
}
|
||||
|
||||
export function isInlinePlayableVideoSize(dimensions?: Partial<VideoDimensions> | null): boolean {
|
||||
const source = normalizeVideoDimensions(dimensions);
|
||||
if (source === null) {
|
||||
return true;
|
||||
}
|
||||
const {width, height} = source;
|
||||
return (
|
||||
(width <= MAX_INLINE_PLAYBACK_LONG_EDGE && height <= MAX_INLINE_PLAYBACK_SHORT_EDGE) ||
|
||||
(width <= MAX_INLINE_PLAYBACK_SHORT_EDGE && height <= MAX_INLINE_PLAYBACK_LONG_EDGE)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeConstraint(value: number | undefined, fallback: number): number {
|
||||
return Number.isFinite(value) && value !== undefined && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user