/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import * as ContextMenuActionCreators from '@app/actions/ContextMenuActionCreators'; import * as ModalActionCreators from '@app/actions/ModalActionCreators'; import {modal} from '@app/actions/ModalActionCreators'; import * as PremiumModalActionCreators from '@app/actions/PremiumModalActionCreators'; import * as ToastActionCreators from '@app/actions/ToastActionCreators'; import * as VoiceSettingsActionCreators from '@app/actions/VoiceSettingsActionCreators'; import styles from '@app/components/modals/BackgroundImageGalleryModal.module.css'; import {ConfirmModal} from '@app/components/modals/ConfirmModal'; import * as Modal from '@app/components/modals/Modal'; import {Button} from '@app/components/uikit/button/Button'; import {MenuItem} from '@app/components/uikit/context_menu/MenuItem'; import FocusRing from '@app/components/uikit/focus_ring/FocusRing'; import {Tooltip} from '@app/components/uikit/tooltip/Tooltip'; import {Logger} from '@app/lib/Logger'; import VoiceSettingsStore, {BLUR_BACKGROUND_ID, NONE_BACKGROUND_ID} from '@app/stores/VoiceSettingsStore'; import * as BackgroundImageDB from '@app/utils/BackgroundImageDB'; import {openFilePicker} from '@app/utils/FilePickerUtils'; import {LimitResolver} from '@app/utils/limits/LimitResolverAdapter'; import {shouldShowPremiumFeatures} from '@app/utils/PremiumUtils'; import {Trans, useLingui} from '@lingui/react/macro'; import type {IconProps} from '@phosphor-icons/react'; import { ArrowsClockwiseIcon, CheckIcon, CrownIcon, EyeSlashIcon, PlusIcon, SparkleIcon, TrashIcon, WarningCircleIcon, } from '@phosphor-icons/react'; import {observer} from 'mobx-react-lite'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; const logger = new Logger('BackgroundImageGalleryModal'); interface BackgroundImage { id: string; createdAt: number; } interface BuiltInBackground { id: string; type: 'none' | 'blur' | 'upload'; name: string; icon: React.ComponentType; description: string; } type BackgroundItemType = BuiltInBackground | BackgroundImage; const MAX_FILE_SIZE = 10 * 1024 * 1024; const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'video/mp4']; interface BackgroundItemProps { background: BackgroundItemType; isSelected: boolean; onSelect: (background: BackgroundItemType) => void; onContextMenu?: (event: React.MouseEvent, background: BackgroundImage) => void; onDelete?: (background: BackgroundImage) => void; } const BackgroundItem: React.FC = React.memo( ({background, isSelected, onSelect, onContextMenu, onDelete}) => { const {t} = useLingui(); const isBuiltIn = 'type' in background; const Icon = isBuiltIn ? background.icon : undefined; const [imageUrl, setImageUrl] = useState(null); const [isLoading, setIsLoading] = useState(!isBuiltIn); const [hasError, setHasError] = useState(false); useEffect(() => { if (isBuiltIn) return; let objectUrl: string | null = null; setIsLoading(true); setHasError(false); BackgroundImageDB.getBackgroundImageURL(background.id) .then((url) => { objectUrl = url; setImageUrl(url); setIsLoading(false); }) .catch((error) => { logger.error('Failed to load background image:', error); setHasError(true); setIsLoading(false); }); return () => { if (objectUrl) { URL.revokeObjectURL(objectUrl); } }; }, [isBuiltIn, background.id]); const handleClick = useCallback(() => { onSelect(background); }, [background, onSelect]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(background); } }, [background, onSelect], ); const handleContextMenu = useCallback( (e: React.MouseEvent) => { if (!isBuiltIn) { onContextMenu?.(e, background as BackgroundImage); } }, [isBuiltIn, background, onContextMenu], ); const handleDelete = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!isBuiltIn) { onDelete?.(background as BackgroundImage); } }, [isBuiltIn, background, onDelete], ); const handleRetry = useCallback(() => { setHasError(false); setIsLoading(true); BackgroundImageDB.getBackgroundImageURL(background.id) .then((url) => { setImageUrl(url); setIsLoading(false); }) .catch((error) => { logger.error('Failed to load background image:', error); setHasError(true); setIsLoading(false); }); }, [background.id]); return (
{isBuiltIn ? (
{Icon && ( )}
{background.name}
{background.description}
) : ( <> {isLoading ? (
) : hasError ? (
Failed to load
) : imageUrl ? ( {t`Background`} ) : null}
{!isBuiltIn && onDelete && !isLoading && !hasError && ( )} )} {isSelected && (
)}
); }, ); BackgroundItem.displayName = 'BackgroundItem'; const BackgroundImageGalleryModal: React.FC = observer(() => { const {t} = useLingui(); const voiceSettings = VoiceSettingsStore; const {backgroundImageId, backgroundImages = []} = voiceSettings; const isMountedRef = useRef(true); const [isDragging, setIsDragging] = useState(false); const dragCounterRef = useRef(0); const maxBackgroundImages = useMemo(() => LimitResolver.resolve({key: 'max_custom_backgrounds', fallback: 1}), []); const canAddMoreImages = backgroundImages.length < maxBackgroundImages; const backgroundCount = backgroundImages.length; const shouldShowReplace = maxBackgroundImages === 1 && backgroundImages.length >= 1; const builtInBackgrounds = useMemo( (): ReadonlyArray => [ { id: NONE_BACKGROUND_ID, type: 'none', name: t`No Background`, icon: EyeSlashIcon, description: t`Show your actual background`, }, { id: BLUR_BACKGROUND_ID, type: 'blur', name: t`Blur`, icon: SparkleIcon, description: t`Blur your background`, }, { id: 'upload', type: 'upload', name: shouldShowReplace ? t`Replace` : t`Upload`, icon: PlusIcon, description: shouldShowReplace ? t`Replace your custom background` : t`Add a custom background`, }, ], [shouldShowReplace], ); const sortedImages = useMemo( () => [...backgroundImages].sort((a, b) => b.createdAt - a.createdAt), [backgroundImages], ); useEffect(() => { return () => { isMountedRef.current = false; }; }, []); const processFileUpload = useCallback( async (file: File | null) => { if (!file) return; try { if (!ALLOWED_MIME_TYPES.includes(file.type)) { ToastActionCreators.createToast({ type: 'error', children: t`Unsupported file format. Please use JPG, PNG, GIF, WebP, or MP4.`, }); return; } if (file.size > MAX_FILE_SIZE) { ToastActionCreators.createToast({ type: 'error', children: t`Background image is too large. Please choose a file smaller than 10MB.`, }); return; } const newImage: BackgroundImage = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, createdAt: Date.now(), }; await BackgroundImageDB.saveBackgroundImage(newImage.id, file); if (isMountedRef.current) { let updatedImages = [...backgroundImages]; let oldImageToDelete: string | null = null; if (backgroundImages.length >= maxBackgroundImages) { const oldImage = backgroundImages[0]; oldImageToDelete = oldImage.id; updatedImages = []; } updatedImages.push(newImage); VoiceSettingsActionCreators.update({ backgroundImages: updatedImages, backgroundImageId: newImage.id, }); if (oldImageToDelete) { BackgroundImageDB.deleteBackgroundImage(oldImageToDelete).catch((error) => { logger.error('Failed to delete old background image:', error); }); } ToastActionCreators.createToast({ type: 'success', children: oldImageToDelete ? t`Background image replaced successfully.` : t`Background image uploaded successfully.`, }); ModalActionCreators.pop(); } } catch (error) { logger.error('File upload failed:', error); ToastActionCreators.createToast({ type: 'error', children: t`Failed to upload background image. Please try again.`, }); } }, [backgroundImages, maxBackgroundImages], ); const handleUploadClick = useCallback( (showReplaceWarning: boolean = false) => { if (!canAddMoreImages) { ToastActionCreators.createToast({ type: 'error', children: t`You've reached the maximum of ${maxBackgroundImages} backgrounds. Remove one to add a new background.`, }); return; } const pickAndProcess = async () => { const [file] = await openFilePicker({accept: ALLOWED_MIME_TYPES.join(',')}); await processFileUpload(file ?? null); }; if (showReplaceWarning && backgroundImages.length >= maxBackgroundImages) { ModalActionCreators.push( modal(() => ( You can only have one custom background on the free tier. Uploading a new one will replace your existing background. } primaryText={t`Replace`} primaryVariant="primary" onPrimary={pickAndProcess} /> )), ); return; } void pickAndProcess(); }, [canAddMoreImages, maxBackgroundImages, backgroundImages.length, processFileUpload], ); const handleBackgroundSelect = useCallback( (background: BackgroundItemType) => { if ('type' in background) { if (background.type === 'upload') { handleUploadClick(true); return; } VoiceSettingsActionCreators.update({ backgroundImageId: background.id, }); } else { VoiceSettingsActionCreators.update({ backgroundImageId: background.id, }); } ModalActionCreators.pop(); }, [handleUploadClick], ); const handleRemoveImage = useCallback( async (image: BackgroundImage) => { try { await BackgroundImageDB.deleteBackgroundImage(image.id); const updatedImages = backgroundImages.filter((img) => img.id !== image.id); const updates: {backgroundImages: Array; backgroundImageId?: string} = { backgroundImages: updatedImages, }; if (backgroundImageId === image.id) { updates.backgroundImageId = NONE_BACKGROUND_ID; } VoiceSettingsActionCreators.update(updates); ToastActionCreators.createToast({ type: 'success', children: t`Background image removed.`, }); } catch (error) { logger.error('Failed to delete background image:', error); ToastActionCreators.createToast({ type: 'error', children: t`Failed to remove background image. Please try again.`, }); } }, [backgroundImageId, backgroundImages], ); const handleBackgroundContextMenu = useCallback( (event: React.MouseEvent, image: BackgroundImage) => { event.preventDefault(); event.stopPropagation(); ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
{ handleRemoveImage(image); onClose(); }} > {t`Remove Background`}
)); }, [handleRemoveImage], ); const handleDrop = useCallback( async (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); dragCounterRef.current = 0; const file = e.dataTransfer.files?.[0]; if (!file) return; await processFileUpload(file); }, [processFileUpload], ); const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragCounterRef.current++; if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { setIsDragging(true); } }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragCounterRef.current--; if (dragCounterRef.current === 0) { setIsDragging(false); } }, []); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }, []); return (
{isDragging && (
Drop to upload background
)} {maxBackgroundImages === 1 ? (
{sortedImages.length > 0 ? (
) : (
handleUploadClick(false)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleUploadClick(false); } }} role="button" tabIndex={0} aria-label={t`Upload custom background`} >
Upload Custom Background
Click or drag and drop
)}
{builtInBackgrounds .filter((bg) => bg.type !== 'upload') .map((background) => ( ))}
) : (
{builtInBackgrounds.map((background) => ( ))} {sortedImages.map((image) => ( ))}
)}
{backgroundCount === 1 ? t`${backgroundCount} / ${maxBackgroundImages} custom background` : t`${backgroundCount} / ${maxBackgroundImages} custom backgrounds`}
Supported: JPG, PNG, GIF, WebP, MP4. Max size: 10MB.
{maxBackgroundImages === 1 && shouldShowPremiumFeatures() && (
Unlock More Backgrounds with Plutonium

Upgrade to store up to 15 custom backgrounds and unlock HD video quality, higher frame rates, and more.

)}
); }); export default BackgroundImageGalleryModal;