perf(app): load voice background tiles only near the viewport (#1971)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:55:32 +02:00
1 parent 52282bfccf
commit 61bf8f6ad3
2 files changed
+348 -290

No files matched your search

@@ -4,6 +4,8 @@ import {showGenericErrorModal} from '@app/features/app/components/alerts/Generic
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal'; import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
import * as Modal from '@app/features/app/components/dialogs/Modal'; 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 {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 RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter'; import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter';
import {GifPickerSelectModal} from '@app/features/expressions/components/modals/GifPickerSelectModal'; import {GifPickerSelectModal} from '@app/features/expressions/components/modals/GifPickerSelectModal';
@@ -14,6 +16,7 @@ import {
TRY_AGAIN_DESCRIPTOR, TRY_AGAIN_DESCRIPTOR,
} from '@app/features/i18n/utils/CommonMessageDescriptors'; } from '@app/features/i18n/utils/CommonMessageDescriptors';
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils'; 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 {openFilePicker} from '@app/features/messaging/utils/FilePickerUtils';
import {Logger} from '@app/features/platform/utils/AppLogger'; import {Logger} from '@app/features/platform/utils/AppLogger';
import * as PremiumModalCommands from '@app/features/premium/commands/PremiumModalCommands'; 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 * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands';
import * as ToastCommands from '@app/features/ui/commands/ToastCommands'; 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 FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands'; 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.', comment: 'Button or menu action label in the background image gallery modal. Keep it concise.',
}); });
const logger = new Logger('BackgroundImageGalleryModal'); const logger = new Logger('BackgroundImageGalleryModal');
const TILE_PRELOAD_ROOT_MARGIN = '200px';
interface BackgroundImage { interface BackgroundImage {
id: string; id: string;
@@ -186,40 +191,46 @@ function isAllowedBackgroundFile(file: File): boolean {
} }
interface BackgroundItemProps { interface BackgroundItemProps {
background: BackgroundItemType; item: BackgroundItemType;
isSelected: boolean; isSelected: boolean;
onSelect: (background: BackgroundItemType) => void; onSelect: (item: BackgroundItemType) => void;
onContextMenu?: (event: React.MouseEvent, background: BackgroundImage) => void; onContextMenu?: (event: React.MouseEvent, item: BackgroundImage) => void;
onDelete?: (background: BackgroundImage) => void; onDelete?: (item: BackgroundImage) => void;
} }
const BackgroundItem: React.FC<BackgroundItemProps> = React.memo( const BackgroundItem: React.FC<BackgroundItemProps> = React.memo(
({background, isSelected, onSelect, onContextMenu, onDelete}) => { ({item, isSelected, onSelect, onContextMenu, onDelete}) => {
const {i18n} = useLingui(); const {i18n} = useLingui();
const isBuiltIn = 'type' in background; const isBuiltIn = 'type' in item;
const Icon = isBuiltIn ? background.icon : undefined; const Icon = isBuiltIn ? item.icon : undefined;
const [imageUrl, setImageUrl] = useState<string | null>(null); const isVideo = !isBuiltIn && item.mediaKind === 'video';
const [isLoading, setIsLoading] = useState(!isBuiltIn); const cachedImageUrl = isBuiltIn ? null : BackgroundImageDB.getCachedBackgroundImageURL(item.id);
const {ref: visibilityRef, isNearViewport} = useNearViewport<HTMLDivElement>({
disabled: isBuiltIn,
rememberKey: isBuiltIn ? null : item.id,
rootMargin: TILE_PRELOAD_ROOT_MARGIN,
});
const shouldAnimate = useShouldAnimate({kind: 'gif', isAnimated: isVideo});
const videoRef = useRef<HTMLVideoElement>(null);
const [imageUrl, setImageUrl] = useState<string | null>(cachedImageUrl);
const [isLoading, setIsLoading] = useState(!isBuiltIn && cachedImageUrl == null && isNearViewport);
const [hasError, setHasError] = useState(false); const [hasError, setHasError] = useState(false);
const objectUrlRef = useRef<string | null>(null);
const loadRequestIdRef = useRef(0); const loadRequestIdRef = useRef(0);
useAnimatedMediaVideoPlayback(videoRef, {
enabled: isNearViewport && isVideo && imageUrl != null,
shouldPlay: shouldAnimate,
isAnimated: isVideo,
});
const loadImage = useCallback(() => { const loadImage = useCallback(() => {
if (isBuiltIn) return; if (isBuiltIn) return;
const requestId = ++loadRequestIdRef.current; const requestId = ++loadRequestIdRef.current;
setIsLoading(true); const cached = BackgroundImageDB.getCachedBackgroundImageURL(item.id);
setImageUrl(cached);
setIsLoading(cached == null);
setHasError(false); setHasError(false);
BackgroundImageDB.getBackgroundImageURL(background.id) BackgroundImageDB.getBackgroundImageURL(item.id)
.then((url) => { .then((url) => {
if (requestId !== loadRequestIdRef.current) { if (requestId !== loadRequestIdRef.current) return;
if (url) {
URL.revokeObjectURL(url);
}
return;
}
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
}
objectUrlRef.current = url;
setImageUrl(url); setImageUrl(url);
setIsLoading(false); setIsLoading(false);
}) })
@@ -229,52 +240,50 @@ const BackgroundItem: React.FC<BackgroundItemProps> = React.memo(
setHasError(true); setHasError(true);
setIsLoading(false); setIsLoading(false);
}); });
}, [isBuiltIn, background.id]); }, [isBuiltIn, item.id]);
useEffect(() => { useEffect(() => {
if (!isNearViewport) return undefined;
loadImage(); loadImage();
return () => { return () => {
loadRequestIdRef.current += 1; loadRequestIdRef.current += 1;
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
}; };
}, [loadImage]); }, [isNearViewport, loadImage]);
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
onSelect(background); onSelect(item);
}, [background, onSelect]); }, [item, onSelect]);
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
if (isKeyboardActivationKey(e.key)) { if (isKeyboardActivationKey(e.key)) {
e.preventDefault(); e.preventDefault();
onSelect(background); onSelect(item);
} }
}, },
[background, onSelect], [item, onSelect],
); );
const handleContextMenu = useCallback( const handleContextMenu = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (!isBuiltIn) { if (!isBuiltIn) {
onContextMenu?.(e, background as BackgroundImage); onContextMenu?.(e, item as BackgroundImage);
} }
}, },
[isBuiltIn, background, onContextMenu], [isBuiltIn, item, onContextMenu],
); );
const handleDelete = useCallback( const handleDelete = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (!isBuiltIn) { if (!isBuiltIn) {
onDelete?.(background as BackgroundImage); onDelete?.(item as BackgroundImage);
} }
}, },
[isBuiltIn, background, onDelete], [isBuiltIn, item, onDelete],
); );
const handleRetry = useCallback(() => { const handleRetry = useCallback(() => {
loadImage(); loadImage();
}, [loadImage]); }, [loadImage]);
return ( return (
<div <div
ref={visibilityRef}
className={styles.backgroundItem} className={styles.backgroundItem}
style={{ style={{
borderColor: isSelected ? 'var(--brand-primary)' : 'var(--background-modifier-accent)', borderColor: isSelected ? 'var(--brand-primary)' : 'var(--background-modifier-accent)',
@@ -312,7 +321,7 @@ const BackgroundItem: React.FC<BackgroundItemProps> = React.memo(
className={styles.backgroundItemName} className={styles.backgroundItemName}
data-flx="theme.background-image-gallery-modal.background-item.background-item-name" data-flx="theme.background-image-gallery-modal.background-item.background-item-name"
> >
{background.name} {item.name}
</div> </div>
</div> </div>
</div> </div>
@@ -360,17 +369,19 @@ const BackgroundItem: React.FC<BackgroundItemProps> = React.memo(
</button> </button>
</FocusRing> </FocusRing>
</div> </div>
) : imageUrl && background.mediaKind === 'video' ? ( ) : isNearViewport && imageUrl && isVideo ? (
<video <video
src={imageUrl} ref={videoRef}
className={styles.backgroundImage} className={styles.backgroundImage}
muted muted
loop loop
playsInline playsInline
autoPlay preload={shouldAnimate ? 'auto' : 'metadata'}
autoPlay={shouldAnimate}
src={imageUrl}
data-flx="theme.background-image-gallery-modal.background-item.background-video" data-flx="theme.background-image-gallery-modal.background-item.background-video"
/> />
) : imageUrl ? ( ) : isNearViewport && imageUrl ? (
<img <img
src={imageUrl} src={imageUrl}
alt={i18n._(BACKGROUND_DESCRIPTOR)} alt={i18n._(BACKGROUND_DESCRIPTOR)}
@@ -433,6 +444,8 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
const voiceSettings = VoiceSettings; const voiceSettings = VoiceSettings;
const {backgroundImageId, backgroundImages = []} = voiceSettings; const {backgroundImageId, backgroundImages = []} = voiceSettings;
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
const contentScrollerRef = useRef<ScrollerHandle>(null);
const resolveContentScrollSurface = useMemo(() => () => contentScrollerRef.current?.getViewportElement() ?? null, []);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const dragCounterRef = useRef(0); const dragCounterRef = useRef(0);
const maxBackgroundImages = useMemo(() => LimitResolver.resolve({key: 'max_custom_backgrounds', fallback: 1}), []); const maxBackgroundImages = useMemo(() => LimitResolver.resolve({key: 'max_custom_backgrounds', fallback: 1}), []);
@@ -744,251 +757,259 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
title={i18n._(CHOOSE_BACKGROUND_DESCRIPTOR)} title={i18n._(CHOOSE_BACKGROUND_DESCRIPTOR)}
data-flx="theme.background-image-gallery-modal.modal-header" data-flx="theme.background-image-gallery-modal.modal-header"
/> />
<Modal.Content data-flx="theme.background-image-gallery-modal.modal-content"> <Modal.Content ref={contentScrollerRef} data-flx="theme.background-image-gallery-modal.modal-content">
<section <NearViewportSurfaceContext.Provider value={resolveContentScrollSurface}>
className={styles.selectionSection} <section
onDrop={handleDrop} className={styles.selectionSection}
onDragEnter={handleDragEnter} onDrop={handleDrop}
onDragLeave={handleDragLeave} onDragEnter={handleDragEnter}
onDragOver={handleDragOver} onDragLeave={handleDragLeave}
aria-label={i18n._(BACKGROUND_SELECTION_AREA_WITH_DRAG_AND_DROP_SUPPORT_DESCRIPTOR)} onDragOver={handleDragOver}
data-flx="theme.background-image-gallery-modal.selection-section" aria-label={i18n._(BACKGROUND_SELECTION_AREA_WITH_DRAG_AND_DROP_SUPPORT_DESCRIPTOR)}
> data-flx="theme.background-image-gallery-modal.selection-section"
{isDragging && ( >
<div className={styles.dragOverlay} data-flx="theme.background-image-gallery-modal.drag-overlay"> {isDragging && (
<div className={styles.dragContent} data-flx="theme.background-image-gallery-modal.drag-content"> <div className={styles.dragOverlay} data-flx="theme.background-image-gallery-modal.drag-overlay">
<PlusIcon <div className={styles.dragContent} data-flx="theme.background-image-gallery-modal.drag-content">
size={remFromPx(48)} <PlusIcon
weight="bold" size={remFromPx(48)}
className={styles.dragIcon} weight="bold"
data-flx="theme.background-image-gallery-modal.drag-icon" className={styles.dragIcon}
/> data-flx="theme.background-image-gallery-modal.drag-icon"
<div className={styles.dragText} data-flx="theme.background-image-gallery-modal.drag-text">
<Trans>Drop to upload background</Trans>
</div>
</div>
</div>
)}
{maxBackgroundImages === 1 ? (
<div
className={styles.freeUserContainer}
data-flx="theme.background-image-gallery-modal.free-user-container"
>
{sortedImages.length > 0 ? (
<div
className={styles.customBackgroundWrapper}
data-flx="theme.background-image-gallery-modal.custom-background-wrapper"
>
<BackgroundItem
key={sortedImages[0].id}
background={sortedImages[0]}
isSelected={backgroundImageId === sortedImages[0].id}
onSelect={handleBackgroundSelect}
onDelete={undefined}
data-flx="theme.background-image-gallery-modal.background-item.background-select"
/> />
<div className={styles.actionButtons} data-flx="theme.background-image-gallery-modal.action-buttons"> <div className={styles.dragText} data-flx="theme.background-image-gallery-modal.drag-text">
<Tooltip <Trans>Drop to upload background</Trans>
text={i18n._(REPLACE_BACKGROUND_2_DESCRIPTOR)}
data-flx="theme.background-image-gallery-modal.tooltip"
>
<FocusRing offset={-2} data-flx="theme.background-image-gallery-modal.focus-ring">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleUploadClick(true);
}}
className={styles.actionButton}
aria-label={i18n._(REPLACE_BACKGROUND_2_DESCRIPTOR)}
data-flx="theme.background-image-gallery-modal.action-button.stop-propagation"
>
<ArrowsClockwiseIcon
size={remFromPx(16)}
weight="bold"
className={styles.actionButtonIcon}
data-flx="theme.background-image-gallery-modal.action-button-icon"
/>
</button>
</FocusRing>
</Tooltip>
<Tooltip
text={i18n._(REPLACE_WITH_A_GIF_DESCRIPTOR)}
data-flx="theme.background-image-gallery-modal.tooltip--gif"
>
<FocusRing offset={-2} data-flx="theme.background-image-gallery-modal.focus-ring--gif-replace">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handlePickGifClick(true);
}}
className={styles.actionButton}
aria-label={i18n._(REPLACE_WITH_A_GIF_DESCRIPTOR)}
data-flx="theme.background-image-gallery-modal.action-button.pick-gif-click"
>
<GifIcon
size={remFromPx(16)}
weight="bold"
className={styles.actionButtonIcon}
data-flx="theme.background-image-gallery-modal.action-button-icon--gif"
/>
</button>
</FocusRing>
</Tooltip>
<Tooltip
text={i18n._(REMOVE_BACKGROUND_DESCRIPTOR)}
data-flx="theme.background-image-gallery-modal.tooltip--2"
>
<FocusRing offset={-2} data-flx="theme.background-image-gallery-modal.focus-ring--2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveImage(sortedImages[0]);
}}
className={styles.actionButton}
aria-label={i18n._(REMOVE_BACKGROUND_DESCRIPTOR)}
data-flx="theme.background-image-gallery-modal.action-button.stop-propagation--2"
>
<TrashIcon
size={remFromPx(16)}
weight="bold"
className={styles.actionButtonIcon}
data-flx="theme.background-image-gallery-modal.action-button-icon--2"
/>
</button>
</FocusRing>
</Tooltip>
</div> </div>
</div> </div>
) : ( </div>
<div )}
className={styles.uploadPlaceholder} {maxBackgroundImages === 1 ? (
onClick={() => handleUploadClick(false)} <div
onKeyDown={(e) => { className={styles.freeUserContainer}
if (isKeyboardActivationKey(e.key)) { data-flx="theme.background-image-gallery-modal.free-user-container"
e.preventDefault(); >
handleUploadClick(false); {sortedImages.length > 0 ? (
}
}}
role="button"
tabIndex={0}
aria-label={i18n._(UPLOAD_CUSTOM_BACKGROUND_DESCRIPTOR)}
data-flx="theme.background-image-gallery-modal.upload-placeholder.upload-click"
>
<div <div
className={styles.uploadPlaceholderContent} className={styles.customBackgroundWrapper}
data-flx="theme.background-image-gallery-modal.upload-placeholder-content" data-flx="theme.background-image-gallery-modal.custom-background-wrapper"
> >
<PlusIcon <BackgroundItem
size={remFromPx(48)} key={sortedImages[0].id}
weight="regular" item={sortedImages[0]}
className={styles.uploadIcon} isSelected={backgroundImageId === sortedImages[0].id}
data-flx="theme.background-image-gallery-modal.upload-icon" onSelect={handleBackgroundSelect}
onDelete={undefined}
data-flx="theme.background-image-gallery-modal.background-item.background-select"
/> />
<div <div
className={styles.uploadTextContainer} className={styles.actionButtons}
data-flx="theme.background-image-gallery-modal.upload-text-container" data-flx="theme.background-image-gallery-modal.action-buttons"
> >
<div className={styles.uploadTitle} data-flx="theme.background-image-gallery-modal.upload-title"> <Tooltip
<Trans>Upload custom background</Trans> text={i18n._(REPLACE_BACKGROUND_2_DESCRIPTOR)}
</div> data-flx="theme.background-image-gallery-modal.tooltip"
<FocusRing offset={-2} data-flx="theme.background-image-gallery-modal.focus-ring--gif"> >
<button <FocusRing offset={-2} data-flx="theme.background-image-gallery-modal.focus-ring">
type="button" <button
className={styles.gifLinkButton} type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handlePickGifClick(false); handleUploadClick(true);
}} }}
data-flx="theme.background-image-gallery-modal.gif-link-button.pick-gif-click" className={styles.actionButton}
> aria-label={i18n._(REPLACE_BACKGROUND_2_DESCRIPTOR)}
{i18n._(OR_PICK_A_GIF_FROM_PROVIDER_DESCRIPTOR, { data-flx="theme.background-image-gallery-modal.action-button.stop-propagation"
gifProviderName: RuntimeConfig.gifProviderDisplayName, >
})} <ArrowsClockwiseIcon
</button> size={remFromPx(16)}
</FocusRing> weight="bold"
className={styles.actionButtonIcon}
data-flx="theme.background-image-gallery-modal.action-button-icon"
/>
</button>
</FocusRing>
</Tooltip>
<Tooltip
text={i18n._(REPLACE_WITH_A_GIF_DESCRIPTOR)}
data-flx="theme.background-image-gallery-modal.tooltip--gif"
>
<FocusRing offset={-2} data-flx="theme.background-image-gallery-modal.focus-ring--gif-replace">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handlePickGifClick(true);
}}
className={styles.actionButton}
aria-label={i18n._(REPLACE_WITH_A_GIF_DESCRIPTOR)}
data-flx="theme.background-image-gallery-modal.action-button.pick-gif-click"
>
<GifIcon
size={remFromPx(16)}
weight="bold"
className={styles.actionButtonIcon}
data-flx="theme.background-image-gallery-modal.action-button-icon--gif"
/>
</button>
</FocusRing>
</Tooltip>
<Tooltip
text={i18n._(REMOVE_BACKGROUND_DESCRIPTOR)}
data-flx="theme.background-image-gallery-modal.tooltip--2"
>
<FocusRing offset={-2} data-flx="theme.background-image-gallery-modal.focus-ring--2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveImage(sortedImages[0]);
}}
className={styles.actionButton}
aria-label={i18n._(REMOVE_BACKGROUND_DESCRIPTOR)}
data-flx="theme.background-image-gallery-modal.action-button.stop-propagation--2"
>
<TrashIcon
size={remFromPx(16)}
weight="bold"
className={styles.actionButtonIcon}
data-flx="theme.background-image-gallery-modal.action-button-icon--2"
/>
</button>
</FocusRing>
</Tooltip>
</div> </div>
</div> </div>
) : (
<div
className={styles.uploadPlaceholder}
onClick={() => handleUploadClick(false)}
onKeyDown={(e) => {
if (isKeyboardActivationKey(e.key)) {
e.preventDefault();
handleUploadClick(false);
}
}}
role="button"
tabIndex={0}
aria-label={i18n._(UPLOAD_CUSTOM_BACKGROUND_DESCRIPTOR)}
data-flx="theme.background-image-gallery-modal.upload-placeholder.upload-click"
>
<div
className={styles.uploadPlaceholderContent}
data-flx="theme.background-image-gallery-modal.upload-placeholder-content"
>
<PlusIcon
size={remFromPx(48)}
weight="regular"
className={styles.uploadIcon}
data-flx="theme.background-image-gallery-modal.upload-icon"
/>
<div
className={styles.uploadTextContainer}
data-flx="theme.background-image-gallery-modal.upload-text-container"
>
<div
className={styles.uploadTitle}
data-flx="theme.background-image-gallery-modal.upload-title"
>
<Trans>Upload custom background</Trans>
</div>
<FocusRing offset={-2} data-flx="theme.background-image-gallery-modal.focus-ring--gif">
<button
type="button"
className={styles.gifLinkButton}
onClick={(e) => {
e.stopPropagation();
handlePickGifClick(false);
}}
data-flx="theme.background-image-gallery-modal.gif-link-button.pick-gif-click"
>
{i18n._(OR_PICK_A_GIF_FROM_PROVIDER_DESCRIPTOR, {
gifProviderName: RuntimeConfig.gifProviderDisplayName,
})}
</button>
</FocusRing>
</div>
</div>
</div>
)}
<div className={styles.builtInGrid} data-flx="theme.background-image-gallery-modal.built-in-grid">
{builtInBackgrounds
.filter((bg) => bg.type !== 'upload' && bg.type !== 'gif')
.map((background) => (
<BackgroundItem
key={background.id}
item={background}
isSelected={backgroundImageId === background.id}
onSelect={handleBackgroundSelect}
data-flx="theme.background-image-gallery-modal.background-item.background-select--2"
/>
))}
</div> </div>
)}
<div className={styles.builtInGrid} data-flx="theme.background-image-gallery-modal.built-in-grid">
{builtInBackgrounds
.filter((bg) => bg.type !== 'upload' && bg.type !== 'gif')
.map((background) => (
<BackgroundItem
key={background.id}
background={background}
isSelected={backgroundImageId === background.id}
onSelect={handleBackgroundSelect}
data-flx="theme.background-image-gallery-modal.background-item.background-select--2"
/>
))}
</div> </div>
</div> ) : (
) : ( <div className={styles.premiumGrid} data-flx="theme.background-image-gallery-modal.premium-grid">
<div className={styles.premiumGrid} data-flx="theme.background-image-gallery-modal.premium-grid"> {builtInBackgrounds.map((background) => (
{builtInBackgrounds.map((background) => ( <BackgroundItem
<BackgroundItem key={background.id}
key={background.id} item={background}
background={background} isSelected={backgroundImageId === background.id}
isSelected={backgroundImageId === background.id} onSelect={handleBackgroundSelect}
onSelect={handleBackgroundSelect} data-flx="theme.background-image-gallery-modal.background-item.background-select--3"
data-flx="theme.background-image-gallery-modal.background-item.background-select--3" />
/> ))}
))} {sortedImages.map((image) => (
{sortedImages.map((image) => ( <BackgroundItem
<BackgroundItem key={image.id}
key={image.id} item={image}
background={image} isSelected={backgroundImageId === image.id}
isSelected={backgroundImageId === image.id} onSelect={handleBackgroundSelect}
onSelect={handleBackgroundSelect} onContextMenu={handleBackgroundContextMenu}
onContextMenu={handleBackgroundContextMenu} onDelete={handleRemoveImage}
onDelete={handleRemoveImage} data-flx="theme.background-image-gallery-modal.background-item.background-select--4"
data-flx="theme.background-image-gallery-modal.background-item.background-select--4" />
/> ))}
))}
</div>
)}
<div className={styles.statsText} data-flx="theme.background-image-gallery-modal.stats-text">
{backgroundCount === 1
? i18n._(CUSTOM_BACKGROUND_DESCRIPTOR, {backgroundCount, maxBackgroundImages})
: i18n._(CUSTOM_BACKGROUNDS_DESCRIPTOR, {backgroundCount, maxBackgroundImages})}
</div>
<div className={styles.infoText} data-flx="theme.background-image-gallery-modal.info-text">
<Trans>Max size: {BACKGROUND_MEDIA_MAX_SIZE_LABEL}.</Trans>
</div>
{maxBackgroundImages === 1 && shouldShowPremiumFeatures() && (
<div className={styles.premiumUpsell} data-flx="theme.background-image-gallery-modal.premium-upsell">
<div className={styles.premiumHeader} data-flx="theme.background-image-gallery-modal.premium-header">
<CrownIcon
weight="fill"
size={remFromPx(18)}
className={styles.premiumIcon}
data-flx="theme.background-image-gallery-modal.premium-icon"
/>
<span className={styles.premiumTitle} data-flx="theme.background-image-gallery-modal.premium-title">
{i18n._(UNLOCK_MORE_BACKGROUNDS_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
</span>
</div> </div>
<p className={styles.premiumDesc} data-flx="theme.background-image-gallery-modal.premium-desc"> )}
<Trans> <div className={styles.statsText} data-flx="theme.background-image-gallery-modal.stats-text">
Upgrade to store up to 15 custom backgrounds and unlock HD video quality, higher frame rates, and {backgroundCount === 1
more. ? i18n._(CUSTOM_BACKGROUND_DESCRIPTOR, {backgroundCount, maxBackgroundImages})
</Trans> : i18n._(CUSTOM_BACKGROUNDS_DESCRIPTOR, {backgroundCount, maxBackgroundImages})}
</p>
<Button
variant="secondary"
small={true}
onClick={() => PremiumModalCommands.open()}
data-flx="theme.background-image-gallery-modal.button.open"
>
{i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
</Button>
</div> </div>
)} <div className={styles.infoText} data-flx="theme.background-image-gallery-modal.info-text">
</section> <Trans>Max size: {BACKGROUND_MEDIA_MAX_SIZE_LABEL}.</Trans>
</div>
{maxBackgroundImages === 1 && shouldShowPremiumFeatures() && (
<div className={styles.premiumUpsell} data-flx="theme.background-image-gallery-modal.premium-upsell">
<div className={styles.premiumHeader} data-flx="theme.background-image-gallery-modal.premium-header">
<CrownIcon
weight="fill"
size={remFromPx(18)}
className={styles.premiumIcon}
data-flx="theme.background-image-gallery-modal.premium-icon"
/>
<span className={styles.premiumTitle} data-flx="theme.background-image-gallery-modal.premium-title">
{i18n._(UNLOCK_MORE_BACKGROUNDS_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
</span>
</div>
<p className={styles.premiumDesc} data-flx="theme.background-image-gallery-modal.premium-desc">
<Trans>
Upgrade to store up to 15 custom backgrounds and unlock HD video quality, higher frame rates, and
more.
</Trans>
</p>
<Button
variant="secondary"
small={true}
onClick={() => PremiumModalCommands.open()}
data-flx="theme.background-image-gallery-modal.button.open"
>
{i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
</Button>
</div>
)}
</section>
</NearViewportSurfaceContext.Provider>
</Modal.Content> </Modal.Content>
</Modal.Root> </Modal.Root>
); );
@@ -8,12 +8,30 @@ export interface NativeBackgroundMediaSource {
mediaKind: VoiceBackgroundMediaKind; mediaKind: VoiceBackgroundMediaKind;
} }
interface BackgroundImageRead {
cancelled: boolean;
promise: Promise<string | null>;
}
const resolvedUrls = new Map<string, string>();
const pendingReads = new Map<string, BackgroundImageRead>();
async function readBackgroundImageObjectURL(id: string): Promise<string | null> {
const readVoiceBackgroundMedia = getElectronAPI()?.readVoiceBackgroundMedia;
if (!readVoiceBackgroundMedia) return null;
const media = await readVoiceBackgroundMedia(id);
if (!media?.dataUrl) return null;
const response = await fetch(media.dataUrl);
return URL.createObjectURL(await response.blob());
}
export async function saveBackgroundImage(id: string, blob: Blob): Promise<NativeBackgroundMediaSource> { export async function saveBackgroundImage(id: string, blob: Blob): Promise<NativeBackgroundMediaSource> {
const cacheVoiceBackgroundMedia = getElectronAPI()?.cacheVoiceBackgroundMedia; const cacheVoiceBackgroundMedia = getElectronAPI()?.cacheVoiceBackgroundMedia;
if (!cacheVoiceBackgroundMedia) { if (!cacheVoiceBackgroundMedia) {
throw new Error('Native background media cache unavailable'); throw new Error('Native background media cache unavailable');
} }
const fileName = blob instanceof File ? blob.name : undefined; const fileName = blob instanceof File ? blob.name : undefined;
releaseBackgroundImageURL(id);
return cacheVoiceBackgroundMedia({ return cacheVoiceBackgroundMedia({
id, id,
mimeType: blob.type, mimeType: blob.type,
@@ -27,29 +45,48 @@ export async function deleteBackgroundImage(id: string): Promise<void> {
if (!deleteVoiceBackgroundMedia) { if (!deleteVoiceBackgroundMedia) {
throw new Error('Native background media cache unavailable'); throw new Error('Native background media cache unavailable');
} }
releaseBackgroundImageURL(id);
await deleteVoiceBackgroundMedia(id); await deleteVoiceBackgroundMedia(id);
} }
export function dataUrlToObjectUrl(dataUrl: string): string | null { export function getCachedBackgroundImageURL(id: string): string | null {
const match = dataUrl.match(/^data:([^;,]+)?(;base64)?,([\s\S]*)$/); return resolvedUrls.get(id) ?? null;
if (!match) return null; }
const mimeType = match[1] || 'application/octet-stream';
const isBase64 = match[2] === ';base64'; export function releaseBackgroundImageURL(id: string): void {
const payload = match[3] ?? ''; const resolved = resolvedUrls.get(id);
try { if (resolved != null) {
const bytes = isBase64 URL.revokeObjectURL(resolved);
? Uint8Array.from(atob(payload), (char) => char.charCodeAt(0)) resolvedUrls.delete(id);
: new TextEncoder().encode(decodeURIComponent(payload)); }
return URL.createObjectURL(new Blob([bytes], {type: mimeType})); const pending = pendingReads.get(id);
} catch { if (pending != null) {
return null; pending.cancelled = true;
pendingReads.delete(id);
} }
} }
export async function getBackgroundImageURL(id: string): Promise<string | null> { export async function getBackgroundImageURL(id: string): Promise<string | null> {
const readVoiceBackgroundMedia = getElectronAPI()?.readVoiceBackgroundMedia; const resolved = resolvedUrls.get(id);
if (!readVoiceBackgroundMedia) return null; if (resolved != null) return resolved;
const media = await readVoiceBackgroundMedia(id); const inFlight = pendingReads.get(id);
if (!media?.dataUrl) return null; if (inFlight != null) return inFlight.promise;
return dataUrlToObjectUrl(media.dataUrl); const read: BackgroundImageRead = {cancelled: false, promise: Promise.resolve(null)};
read.promise = readBackgroundImageObjectURL(id).then(
(url) => {
if (read.cancelled) {
if (url != null) URL.revokeObjectURL(url);
return null;
}
pendingReads.delete(id);
if (url != null) resolvedUrls.set(id, url);
return url;
},
(error: unknown) => {
if (!read.cancelled) pendingReads.delete(id);
throw error;
},
);
pendingReads.set(id, read);
return read.promise;
} }