diff --git a/fluxer_app/src/features/theme/components/modals/BackgroundImageGalleryModal.tsx b/fluxer_app/src/features/theme/components/modals/BackgroundImageGalleryModal.tsx index 6bee40515..82d0802ae 100644 --- a/fluxer_app/src/features/theme/components/modals/BackgroundImageGalleryModal.tsx +++ b/fluxer_app/src/features/theme/components/modals/BackgroundImageGalleryModal.tsx @@ -4,6 +4,8 @@ import {showGenericErrorModal} from '@app/features/app/components/alerts/Generic import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal'; import * as Modal from '@app/features/app/components/dialogs/Modal'; import {BACKGROUND_MEDIA_MAX_SIZE_LABEL, PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants'; +import {useAnimatedMediaVideoPlayback} from '@app/features/app/hooks/useAnimatedMediaPlayback'; +import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate'; import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter'; import {GifPickerSelectModal} from '@app/features/expressions/components/modals/GifPickerSelectModal'; @@ -14,6 +16,7 @@ import { TRY_AGAIN_DESCRIPTOR, } from '@app/features/i18n/utils/CommonMessageDescriptors'; import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils'; +import {NearViewportSurfaceContext, useNearViewport} from '@app/features/messaging/hooks/useNearViewport'; import {openFilePicker} from '@app/features/messaging/utils/FilePickerUtils'; import {Logger} from '@app/features/platform/utils/AppLogger'; import * as PremiumModalCommands from '@app/features/premium/commands/PremiumModalCommands'; @@ -27,6 +30,7 @@ import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuComma import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; import * as ToastCommands from '@app/features/ui/commands/ToastCommands'; +import type {ScrollerHandle} from '@app/features/ui/components/Scroller'; import FocusRing from '@app/features/ui/focus_ring/FocusRing'; import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands'; @@ -157,6 +161,7 @@ const REPLACE_WITH_A_GIF_DESCRIPTOR = msg({ comment: 'Button or menu action label in the background image gallery modal. Keep it concise.', }); const logger = new Logger('BackgroundImageGalleryModal'); +const TILE_PRELOAD_ROOT_MARGIN = '200px'; interface BackgroundImage { id: string; @@ -186,40 +191,46 @@ function isAllowedBackgroundFile(file: File): boolean { } interface BackgroundItemProps { - background: BackgroundItemType; + item: BackgroundItemType; isSelected: boolean; - onSelect: (background: BackgroundItemType) => void; - onContextMenu?: (event: React.MouseEvent, background: BackgroundImage) => void; - onDelete?: (background: BackgroundImage) => void; + onSelect: (item: BackgroundItemType) => void; + onContextMenu?: (event: React.MouseEvent, item: BackgroundImage) => void; + onDelete?: (item: BackgroundImage) => void; } const BackgroundItem: React.FC = React.memo( - ({background, isSelected, onSelect, onContextMenu, onDelete}) => { + ({item, isSelected, onSelect, onContextMenu, onDelete}) => { const {i18n} = useLingui(); - const isBuiltIn = 'type' in background; - const Icon = isBuiltIn ? background.icon : undefined; - const [imageUrl, setImageUrl] = useState(null); - const [isLoading, setIsLoading] = useState(!isBuiltIn); + const isBuiltIn = 'type' in item; + const Icon = isBuiltIn ? item.icon : undefined; + const isVideo = !isBuiltIn && item.mediaKind === 'video'; + const cachedImageUrl = isBuiltIn ? null : BackgroundImageDB.getCachedBackgroundImageURL(item.id); + const {ref: visibilityRef, isNearViewport} = useNearViewport({ + disabled: isBuiltIn, + rememberKey: isBuiltIn ? null : item.id, + rootMargin: TILE_PRELOAD_ROOT_MARGIN, + }); + const shouldAnimate = useShouldAnimate({kind: 'gif', isAnimated: isVideo}); + const videoRef = useRef(null); + const [imageUrl, setImageUrl] = useState(cachedImageUrl); + const [isLoading, setIsLoading] = useState(!isBuiltIn && cachedImageUrl == null && isNearViewport); const [hasError, setHasError] = useState(false); - const objectUrlRef = useRef(null); const loadRequestIdRef = useRef(0); + useAnimatedMediaVideoPlayback(videoRef, { + enabled: isNearViewport && isVideo && imageUrl != null, + shouldPlay: shouldAnimate, + isAnimated: isVideo, + }); const loadImage = useCallback(() => { if (isBuiltIn) return; const requestId = ++loadRequestIdRef.current; - setIsLoading(true); + const cached = BackgroundImageDB.getCachedBackgroundImageURL(item.id); + setImageUrl(cached); + setIsLoading(cached == null); setHasError(false); - BackgroundImageDB.getBackgroundImageURL(background.id) + BackgroundImageDB.getBackgroundImageURL(item.id) .then((url) => { - if (requestId !== loadRequestIdRef.current) { - if (url) { - URL.revokeObjectURL(url); - } - return; - } - if (objectUrlRef.current) { - URL.revokeObjectURL(objectUrlRef.current); - } - objectUrlRef.current = url; + if (requestId !== loadRequestIdRef.current) return; setImageUrl(url); setIsLoading(false); }) @@ -229,52 +240,50 @@ const BackgroundItem: React.FC = React.memo( setHasError(true); setIsLoading(false); }); - }, [isBuiltIn, background.id]); + }, [isBuiltIn, item.id]); useEffect(() => { + if (!isNearViewport) return undefined; loadImage(); return () => { loadRequestIdRef.current += 1; - if (objectUrlRef.current) { - URL.revokeObjectURL(objectUrlRef.current); - objectUrlRef.current = null; - } }; - }, [loadImage]); + }, [isNearViewport, loadImage]); const handleClick = useCallback(() => { - onSelect(background); - }, [background, onSelect]); + onSelect(item); + }, [item, onSelect]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (isKeyboardActivationKey(e.key)) { e.preventDefault(); - onSelect(background); + onSelect(item); } }, - [background, onSelect], + [item, onSelect], ); const handleContextMenu = useCallback( (e: React.MouseEvent) => { if (!isBuiltIn) { - onContextMenu?.(e, background as BackgroundImage); + onContextMenu?.(e, item as BackgroundImage); } }, - [isBuiltIn, background, onContextMenu], + [isBuiltIn, item, onContextMenu], ); const handleDelete = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!isBuiltIn) { - onDelete?.(background as BackgroundImage); + onDelete?.(item as BackgroundImage); } }, - [isBuiltIn, background, onDelete], + [isBuiltIn, item, onDelete], ); const handleRetry = useCallback(() => { loadImage(); }, [loadImage]); return (
= React.memo( className={styles.backgroundItemName} data-flx="theme.background-image-gallery-modal.background-item.background-item-name" > - {background.name} + {item.name}
@@ -360,17 +369,19 @@ const BackgroundItem: React.FC = React.memo( - ) : imageUrl && background.mediaKind === 'video' ? ( + ) : isNearViewport && imageUrl && isVideo ? (