mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
perf(app): load voice background tiles only near the viewport (#1971)
This commit is contained in:
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;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user