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
+124
-66
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,7 +757,8 @@ 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">
|
||||||
|
<NearViewportSurfaceContext.Provider value={resolveContentScrollSurface}>
|
||||||
<section
|
<section
|
||||||
className={styles.selectionSection}
|
className={styles.selectionSection}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
@@ -781,13 +795,16 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
|
|||||||
>
|
>
|
||||||
<BackgroundItem
|
<BackgroundItem
|
||||||
key={sortedImages[0].id}
|
key={sortedImages[0].id}
|
||||||
background={sortedImages[0]}
|
item={sortedImages[0]}
|
||||||
isSelected={backgroundImageId === sortedImages[0].id}
|
isSelected={backgroundImageId === sortedImages[0].id}
|
||||||
onSelect={handleBackgroundSelect}
|
onSelect={handleBackgroundSelect}
|
||||||
onDelete={undefined}
|
onDelete={undefined}
|
||||||
data-flx="theme.background-image-gallery-modal.background-item.background-select"
|
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.actionButtons}
|
||||||
|
data-flx="theme.background-image-gallery-modal.action-buttons"
|
||||||
|
>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
text={i18n._(REPLACE_BACKGROUND_2_DESCRIPTOR)}
|
text={i18n._(REPLACE_BACKGROUND_2_DESCRIPTOR)}
|
||||||
data-flx="theme.background-image-gallery-modal.tooltip"
|
data-flx="theme.background-image-gallery-modal.tooltip"
|
||||||
@@ -891,7 +908,10 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
|
|||||||
className={styles.uploadTextContainer}
|
className={styles.uploadTextContainer}
|
||||||
data-flx="theme.background-image-gallery-modal.upload-text-container"
|
data-flx="theme.background-image-gallery-modal.upload-text-container"
|
||||||
>
|
>
|
||||||
<div className={styles.uploadTitle} data-flx="theme.background-image-gallery-modal.upload-title">
|
<div
|
||||||
|
className={styles.uploadTitle}
|
||||||
|
data-flx="theme.background-image-gallery-modal.upload-title"
|
||||||
|
>
|
||||||
<Trans>Upload custom background</Trans>
|
<Trans>Upload custom background</Trans>
|
||||||
</div>
|
</div>
|
||||||
<FocusRing offset={-2} data-flx="theme.background-image-gallery-modal.focus-ring--gif">
|
<FocusRing offset={-2} data-flx="theme.background-image-gallery-modal.focus-ring--gif">
|
||||||
@@ -919,7 +939,7 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
|
|||||||
.map((background) => (
|
.map((background) => (
|
||||||
<BackgroundItem
|
<BackgroundItem
|
||||||
key={background.id}
|
key={background.id}
|
||||||
background={background}
|
item={background}
|
||||||
isSelected={backgroundImageId === background.id}
|
isSelected={backgroundImageId === background.id}
|
||||||
onSelect={handleBackgroundSelect}
|
onSelect={handleBackgroundSelect}
|
||||||
data-flx="theme.background-image-gallery-modal.background-item.background-select--2"
|
data-flx="theme.background-image-gallery-modal.background-item.background-select--2"
|
||||||
@@ -932,7 +952,7 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
|
|||||||
{builtInBackgrounds.map((background) => (
|
{builtInBackgrounds.map((background) => (
|
||||||
<BackgroundItem
|
<BackgroundItem
|
||||||
key={background.id}
|
key={background.id}
|
||||||
background={background}
|
item={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"
|
||||||
@@ -941,7 +961,7 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
|
|||||||
{sortedImages.map((image) => (
|
{sortedImages.map((image) => (
|
||||||
<BackgroundItem
|
<BackgroundItem
|
||||||
key={image.id}
|
key={image.id}
|
||||||
background={image}
|
item={image}
|
||||||
isSelected={backgroundImageId === image.id}
|
isSelected={backgroundImageId === image.id}
|
||||||
onSelect={handleBackgroundSelect}
|
onSelect={handleBackgroundSelect}
|
||||||
onContextMenu={handleBackgroundContextMenu}
|
onContextMenu={handleBackgroundContextMenu}
|
||||||
@@ -989,6 +1009,7 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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