From a62de4453dc4cb8e6cb7b08bb9b9ab3a936494f5 Mon Sep 17 00:00:00 2001 From: Aswin Asok Date: Tue, 6 Jan 2026 18:11:56 +0530 Subject: [PATCH 01/15] feat(zip):downloading-files-as-zip --- .../DownloadStatusNotifications.tsx | 11 +- .../base/locales/en-US/translation.json | 3 + .../gallery/components/utils/save-groups.ts | 9 + web/packages/gallery/package.json | 1 + web/packages/gallery/services/save.ts | 458 ++++++++++++++++-- 5 files changed, 453 insertions(+), 29 deletions(-) diff --git a/web/apps/photos/src/components/DownloadStatusNotifications.tsx b/web/apps/photos/src/components/DownloadStatusNotifications.tsx index 88df1a27741..c7ccf86b939 100644 --- a/web/apps/photos/src/components/DownloadStatusNotifications.tsx +++ b/web/apps/photos/src/components/DownloadStatusNotifications.tsx @@ -90,7 +90,16 @@ export const DownloadStatusNotifications: React.FC< return saveGroups.map((group, index) => { const hasErrors = isSaveCompleteWithErrors(group); const canRetry = hasErrors && !!group.retry; - const failedTitle = `${t("download_failed")} (${group.failed}/${group.total})`; + + // Show specific error message based on failure reason + let failedTitle: string; + if (group.failureReason === "network_offline") { + failedTitle = `${t("download_failed_network_offline")} (${group.failed}/${group.total})`; + } else if (group.failureReason === "file_error") { + failedTitle = `${t("download_failed_file_error")} (${group.failed}/${group.total})`; + } else { + failedTitle = `${t("download_failed")} (${group.failed}/${group.total})`; + } return ( { + if (cachedLimits) return cachedLimits; + + const ua = navigator.userAgent.toLowerCase(); + + // Detect iOS - all browsers on iOS use WebKit with strict memory limits. + // iPadOS 13+ reports as "Macintosh" in UA so we check maxTouchPoints. + const isIOS = + ua.includes("iphone") || + ua.includes("ipad") || + ua.includes("ipod") || + (ua.includes("macintosh") && navigator.maxTouchPoints > 1); + + // Detect Android + const isAndroid = ua.includes("android"); + + // Detect mobile (fallback for other mobile browsers) + const isMobile = + isIOS || isAndroid || ua.includes("mobile") || ua.includes("tablet"); + + // Detect Safari - Chrome/Firefox on macOS include "safari" in UA but also + // include their own name, so we exclude those. + const isSafari = + ua.includes("safari") && + !ua.includes("chrome") && + !ua.includes("chromium") && + !ua.includes("firefox") && + !ua.includes("edg"); + + // deviceMemory is available in Chrome-based browsers (in GB). + // Low memory devices (< 4GB) need more conservative limits. + const deviceMemory = (navigator as { deviceMemory?: number }).deviceMemory; + const isLowMemoryDevice = deviceMemory !== undefined && deviceMemory < 4; + + // iOS - most restrictive due to WebKit's aggressive memory management. + // All browsers on iOS (Chrome, Firefox, etc.) use WebKit under the hood. + if (isIOS) { + cachedLimits = { + concurrency: 3, + maxZipSize: 50 * 1024 * 1024, // 50MB + }; + return cachedLimits; + } + + // Android - moderate restrictions, varies by device memory + if (isAndroid) { + cachedLimits = isLowMemoryDevice + ? { concurrency: 3, maxZipSize: 50 * 1024 * 1024 } // 50MB + : { concurrency: 4, maxZipSize: 100 * 1024 * 1024 }; // 100MB + return cachedLimits; + } + + // Desktop Safari - WebKit has stricter blob handling than Chromium/Gecko + if (isSafari) { + cachedLimits = { + concurrency: 5, + maxZipSize: 100 * 1024 * 1024, // 100MB + }; + return cachedLimits; + } + + // Other mobile browsers (rare: Windows Phone, KaiOS, etc.) + if (isMobile) { + cachedLimits = { + concurrency: 4, + maxZipSize: 75 * 1024 * 1024, // 75MB + }; + return cachedLimits; + } + + // Desktop browsers (Chrome, Firefox, Edge) - most capable + cachedLimits = isLowMemoryDevice + ? { concurrency: 5, maxZipSize: 100 * 1024 * 1024 } // 100MB + : { concurrency: 6, maxZipSize: 150 * 1024 * 1024 }; // 150MB + + return cachedLimits; +}; + /** * Save the given {@link files} to the user's device. * @@ -118,11 +224,12 @@ const downloadAndSave = async ( let isDownloading = false; let updateSaveGroup: UpdateSaveGroup = () => undefined; - const downloadFiles = async ( + const downloadFilesDesktop = async ( filesToDownload: EnteFile[], resetFailedCount = false, ) => { if (!filesToDownload.length || isDownloading) return; + if (!electron || !downloadDirPath) return; isDownloading = true; if (resetFailedCount) { @@ -134,11 +241,7 @@ const downloadAndSave = async ( for (const file of filesToDownload) { if (canceller.signal.aborted) break; try { - if (electron && downloadDirPath) { - await saveFileDesktop(electron, file, downloadDirPath); - } else { - await saveAsFile(file); - } + await saveFileDesktop(electron, file, downloadDirPath); updateSaveGroup((g) => ({ ...g, success: g.success + 1 })); } catch (e) { log.error("File download failed", e); @@ -155,6 +258,48 @@ const downloadAndSave = async ( } }; + const downloadFilesWeb = async ( + filesToDownload: EnteFile[], + resetFailedCount = false, + ) => { + if (!filesToDownload.length || isDownloading) return; + + // Don't start download if already offline - wait for network + if (!navigator.onLine) { + log.info("Skipping download attempt - network is offline"); + return; + } + + isDownloading = true; + if (resetFailedCount) { + updateSaveGroup((g) => ({ ...g, failed: 0, failureReason: undefined })); + } + failedFiles.length = 0; + + try { + await saveAsZip( + filesToDownload, + title, + () => + updateSaveGroup((g) => ({ ...g, success: g.success + 1 })), + (file) => { + failedFiles.push(file); + updateSaveGroup((g) => ({ ...g, failed: g.failed + 1 })); + }, + canceller, + updateSaveGroup, + ); + + if (!failedFiles.length) { + updateSaveGroup((g) => ({ ...g, retry: undefined })); + } + } finally { + isDownloading = false; + } + }; + + const downloadFiles = electron ? downloadFilesDesktop : downloadFilesWeb; + const retry = () => { if (!failedFiles.length || isDownloading || canceller.signal.aborted) return; @@ -175,38 +320,295 @@ const downloadAndSave = async ( }; /** - * Save the given {@link EnteFile} as a file in the user's download folder. + * A helper class to accumulate files into ZIP batches and download them when + * the batch size limit is reached. + */ +class ZipBatcher { + private zip = new JSZip(); + private currentBatchSize = 0; + private currentFileCount = 0; + private batchIndex = 1; + private usedNames = new Set(); + private baseName: string; + private maxZipSize: number; + + constructor(baseName: string, maxZipSize: number) { + this.baseName = baseName; + this.maxZipSize = maxZipSize; + } + + /** + * Add file data to the current ZIP batch. If adding this file would exceed + * the batch size limit, the current batch is downloaded first. + */ + async addFile(data: Uint8Array | Blob, fileName: string): Promise { + const size = data instanceof Blob ? data.size : data.byteLength; + + // If adding this file would exceed the limit and we have files in the + // batch, download the current batch first. + if ( + this.currentBatchSize > 0 && + this.currentBatchSize + size > this.maxZipSize + ) { + await this.downloadCurrentBatch(); + } + + // Ensure unique file names within the ZIP + const uniqueName = this.getUniqueName(fileName); + this.usedNames.add(uniqueName); + this.zip.file(uniqueName, data); + this.currentBatchSize += size; + this.currentFileCount++; + } + + /** + * Download any remaining files in the current batch. + */ + async flush(): Promise { + if (this.currentBatchSize > 0) { + await this.downloadCurrentBatch(); + } + } + + private async downloadCurrentBatch(): Promise { + const zipBlob = await this.zip.generateAsync({ type: "blob" }); + const fileLabel = + this.currentFileCount === 1 + ? "1 file" + : `${this.currentFileCount} files`; + const zipName = + this.batchIndex === 1 + ? `${this.baseName} (${fileLabel}).zip` + : `${this.baseName} (${fileLabel})-${this.batchIndex}.zip`; + + const url = URL.createObjectURL(zipBlob); + saveAsFileAndRevokeObjectURL(url, zipName); + + // Reset for next batch + this.zip = new JSZip(); + this.currentBatchSize = 0; + this.currentFileCount = 0; + this.usedNames.clear(); + this.batchIndex++; + } + + /** + * Generate a unique file name within the ZIP by appending a suffix if the + * name already exists. + */ + private getUniqueName(fileName: string): string { + if (!this.usedNames.has(fileName)) { + return fileName; + } + + const [name, ext] = nameAndExtension(fileName); + let counter = 1; + let uniqueName: string; + do { + uniqueName = ext ? `${name}(${counter}).${ext}` : `${name}(${counter})`; + counter++; + } while (this.usedNames.has(uniqueName)); + + return uniqueName; + } +} + +/** Result of downloading and processing a single file for ZIP inclusion. */ +type DownloadedFileData = + | { type: "regular"; fileName: string; data: Uint8Array } + | { + type: "livePhoto"; + imageFileName: string; + imageData: Uint8Array; + videoFileName: string; + videoData: Uint8Array; + }; + +/** + * Download and process a single file, returning the data ready for ZIP. */ -const saveAsFile = async (file: EnteFile) => { +const downloadFileForZip = async ( + file: EnteFile, +): Promise => { const fileBlob = await downloadManager.fileBlob(file); const fileName = fileFileName(file); + if (file.metadata.fileType == FileType.livePhoto) { const { imageFileName, imageData, videoFileName, videoData } = await decodeLivePhoto(fileName, fileBlob); - - await saveBlobPartAsFile(imageData, imageFileName); - - // Downloading multiple works everywhere except, you guessed it, - // Safari. Make up for their incompetence by adding a setTimeout. - await wait(300) /* arbitrary constant, 300ms */; - await saveBlobPartAsFile(videoData, videoFileName); + return { type: "livePhoto", imageFileName, imageData, videoFileName, videoData }; } else { - await saveBlobPartAsFile(fileBlob, fileName); + const data = new Uint8Array(await fileBlob.arrayBuffer()); + return { type: "regular", fileName, data }; } }; /** - * Save the given {@link blob} as a file in the user's download folder. + * Save multiple files as ZIP archives to the user's download folder. + * + * Files are batched into ZIPs of up to 100MB each. If the total exceeds 100MB, + * multiple ZIP files will be downloaded. Downloads are performed concurrently + * (up to {@link CONCURRENT_DOWNLOADS} at a time) for better performance. + * + * @param files The files to download and add to the ZIP. + * @param baseName The base name for the ZIP file(s). + * @param onSuccess Callback invoked after each file is successfully added. + * @param onError Callback invoked when a file fails to download. + * @param canceller An AbortController to check for cancellation. */ -const saveBlobPartAsFile = async (blobPart: BlobPart, fileName: string) => - createTypedObjectURL(blobPart, fileName).then((url) => - saveAsFileAndRevokeObjectURL(url, fileName), - ); +const saveAsZip = async ( + files: EnteFile[], + baseName: string, + onSuccess: () => void, + onError: (file: EnteFile, error: unknown) => void, + canceller: AbortController, + updateSaveGroup: UpdateSaveGroup, +): Promise => { + const { concurrency, maxZipSize } = getDownloadLimits(); + const batcher = new ZipBatcher(baseName, maxZipSize); + + // Queue of files to process + let fileIndex = 0; + + // Track if we've gone offline to stop processing immediately. + // Using an object so the value can be mutated by event handlers and + // checked synchronously by the async workers. + const networkState = { isOffline: !navigator.onLine }; + const handleOffline = () => { + networkState.isOffline = true; + }; + const handleOnline = () => { + networkState.isOffline = false; + }; + window.addEventListener("offline", handleOffline); + window.addEventListener("online", handleOnline); + + // Mutex for serializing ZIP additions (download is concurrent, but adding + // to the ZIP must be serialized to avoid race conditions with batching) + let zipMutex: Promise = Promise.resolve(); + const withZipLock = async (fn: () => Promise): Promise => { + const prev = zipMutex; + let resolve: () => void; + zipMutex = new Promise((r) => (resolve = r)); + await prev; + try { + return await fn(); + } finally { + resolve!(); + } + }; + + // Process a single file: download, then add to ZIP + const processFile = async (): Promise => { + // Stop immediately if offline or cancelled + if (networkState.isOffline || canceller.signal.aborted) { + return false; + } + + // Get next file to process + const currentIndex = fileIndex++; + if (currentIndex >= files.length) { + return false; + } + + const file = files[currentIndex]!; + try { + // Check again before starting download (value can change via event handler) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (networkState.isOffline) { + // Put this file back for retry + onError(file, new Error("Network offline")); + return false; + } + + // Download happens concurrently + const downloadedData = await downloadFileForZip(file); + + // Adding to ZIP is serialized via mutex + await withZipLock(async () => { + if (downloadedData.type === "livePhoto") { + await batcher.addFile( + downloadedData.imageData, + downloadedData.imageFileName, + ); + await batcher.addFile( + downloadedData.videoData, + downloadedData.videoFileName, + ); + } else { + await batcher.addFile( + downloadedData.data, + downloadedData.fileName, + ); + } + }); + onSuccess(); + } catch (e) { + // Individual file failed - mark it for retry but continue with others + // Only log non-network errors to avoid log spam when offline + if (!networkState.isOffline) { + log.error(`Failed to download file ${file.id}, skipping`, e); + } + onError(file, e); -const createTypedObjectURL = async (blobPart: BlobPart, fileName: string) => { - const blob = blobPart instanceof Blob ? blobPart : new Blob([blobPart]); - const { mimeType } = await detectFileTypeInfo(new File([blob], fileName)); - return URL.createObjectURL(new Blob([blob], { type: mimeType })); + // Only stop all processing if we went offline (not for individual failures) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (networkState.isOffline) { + updateSaveGroup((g) => ({ + ...g, + failureReason: "network_offline", + })); + return false; + } + // Mark as file error for individual failures + updateSaveGroup((g) => ({ + ...g, + failureReason: g.failureReason ?? "file_error", + })); + // Continue processing remaining files even if this one failed + } + + return true; + }; + + // Worker that continuously processes files until done + const worker = async (): Promise => { + while (await processFile()) { + // Continue processing + } + }; + + try { + // Start concurrent workers + const workers = Array.from( + { length: Math.min(concurrency, files.length) }, + () => worker(), + ); + await Promise.all(workers); + + // If we went offline, mark remaining files as failed + if (networkState.isOffline) { + updateSaveGroup((g) => ({ + ...g, + failureReason: "network_offline", + })); + while (fileIndex < files.length) { + const file = files[fileIndex++]; + if (file) { + onError(file, new Error("Network offline")); + } + } + } + + // Flush whatever we have (even partial) unless cancelled + if (!canceller.signal.aborted) { + await batcher.flush(); + } + } finally { + // Clean up event listeners + window.removeEventListener("offline", handleOffline); + window.removeEventListener("online", handleOnline); + } }; /** From ad53435a89b85067456f06c426ffc971e8093cfc Mon Sep 17 00:00:00 2001 From: Aswin Asok Date: Wed, 7 Jan 2026 11:47:42 +0530 Subject: [PATCH 02/15] feat(download):optimize-the-download-limits --- web/packages/gallery/services/save.ts | 52 ++++++++++++++++----------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/web/packages/gallery/services/save.ts b/web/packages/gallery/services/save.ts index ca7294ce511..b505b7b2b0e 100644 --- a/web/packages/gallery/services/save.ts +++ b/web/packages/gallery/services/save.ts @@ -39,14 +39,20 @@ let cachedLimits: DownloadLimits | undefined; * - **Browser**: Safari/WebKit has stricter blob size handling * - **Available memory**: Uses `navigator.deviceMemory` when available * + * JSZip requires ~2-3x the final ZIP size in peak memory during generation. + * Limits are set to stay well within browser blob/memory limits: + * - Chrome desktop: 2GB in-memory blob limit + * - Android Chrome: RAM/100 blob limit (40-80MB on typical devices) + * - iOS Safari: ~2GB per-tab limit on modern devices + * * Limits by platform: - * - iOS (all browsers use WebKit): 3 concurrent, 50MB max - * - Android (low memory): 3 concurrent, 50MB max - * - Android (normal): 4 concurrent, 100MB max - * - Desktop Safari: 5 concurrent, 100MB max - * - Other mobile: 4 concurrent, 75MB max - * - Desktop (low memory): 5 concurrent, 100MB max - * - Desktop (normal): 6 concurrent, 150MB max + * - iOS (all browsers use WebKit): 4 concurrent, 150MB max + * - Android (low memory): 3 concurrent, 75MB max + * - Android (normal): 5 concurrent, 150MB max + * - Desktop Safari: 6 concurrent, 250MB max + * - Other mobile: 4 concurrent, 100MB max + * - Desktop (low memory): 6 concurrent, 200MB max + * - Desktop (normal): 8 concurrent, 400MB max */ const getDownloadLimits = (): DownloadLimits => { if (cachedLimits) return cachedLimits; @@ -82,29 +88,31 @@ const getDownloadLimits = (): DownloadLimits => { const deviceMemory = (navigator as { deviceMemory?: number }).deviceMemory; const isLowMemoryDevice = deviceMemory !== undefined && deviceMemory < 4; - // iOS - most restrictive due to WebKit's aggressive memory management. - // All browsers on iOS (Chrome, Firefox, etc.) use WebKit under the hood. + // iOS - WebKit's aggressive memory management, but modern devices have + // ~2GB per-tab limit. 150MB * 3 (peak memory multiplier) = 450MB, safe margin. if (isIOS) { cachedLimits = { - concurrency: 3, - maxZipSize: 50 * 1024 * 1024, // 50MB + concurrency: 4, + maxZipSize: 150 * 1024 * 1024, // 150MB }; return cachedLimits; } - // Android - moderate restrictions, varies by device memory + // Android - Chrome's blob limit is RAM/100, so keep conservative. + // Low memory: 75MB, Normal (6GB+ devices common): 150MB. if (isAndroid) { cachedLimits = isLowMemoryDevice - ? { concurrency: 3, maxZipSize: 50 * 1024 * 1024 } // 50MB - : { concurrency: 4, maxZipSize: 100 * 1024 * 1024 }; // 100MB + ? { concurrency: 3, maxZipSize: 75 * 1024 * 1024 } // 75MB + : { concurrency: 5, maxZipSize: 150 * 1024 * 1024 }; // 150MB return cachedLimits; } - // Desktop Safari - WebKit has stricter blob handling than Chromium/Gecko + // Desktop Safari - WebKit has "very high" limits per Apple's documentation. + // 250MB * 3 = 750MB peak, well within safe range. if (isSafari) { cachedLimits = { - concurrency: 5, - maxZipSize: 100 * 1024 * 1024, // 100MB + concurrency: 6, + maxZipSize: 250 * 1024 * 1024, // 250MB }; return cachedLimits; } @@ -113,15 +121,16 @@ const getDownloadLimits = (): DownloadLimits => { if (isMobile) { cachedLimits = { concurrency: 4, - maxZipSize: 75 * 1024 * 1024, // 75MB + maxZipSize: 100 * 1024 * 1024, // 100MB }; return cachedLimits; } - // Desktop browsers (Chrome, Firefox, Edge) - most capable + // Desktop browsers (Chrome, Firefox, Edge) - most capable. + // Chrome has 2GB in-memory blob limit. 400MB * 3 = 1.2GB peak, safe margin. cachedLimits = isLowMemoryDevice - ? { concurrency: 5, maxZipSize: 100 * 1024 * 1024 } // 100MB - : { concurrency: 6, maxZipSize: 150 * 1024 * 1024 }; // 150MB + ? { concurrency: 6, maxZipSize: 200 * 1024 * 1024 } // 200MB + : { concurrency: 8, maxZipSize: 400 * 1024 * 1024 }; // 400MB return cachedLimits; }; @@ -546,6 +555,7 @@ const saveAsZip = async ( } catch (e) { // Individual file failed - mark it for retry but continue with others // Only log non-network errors to avoid log spam when offline + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!networkState.isOffline) { log.error(`Failed to download file ${file.id}, skipping`, e); } From 8a6f3c306ed20f341804340c8ece08c78afacf73 Mon Sep 17 00:00:00 2001 From: Aswin Asok Date: Wed, 7 Jan 2026 12:16:23 +0530 Subject: [PATCH 03/15] refractor(download):improve-offline-handling-and-code-formatting --- web/packages/gallery/services/save.ts | 32 ++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/web/packages/gallery/services/save.ts b/web/packages/gallery/services/save.ts index b505b7b2b0e..3dadf5ee568 100644 --- a/web/packages/gallery/services/save.ts +++ b/web/packages/gallery/services/save.ts @@ -9,11 +9,11 @@ import type { EnteFile } from "ente-media/file"; import { fileFileName } from "ente-media/file-metadata"; import { FileType } from "ente-media/file-type"; import { decodeLivePhoto } from "ente-media/live-photo"; -import JSZip from "jszip"; import { safeDirectoryName, safeFileName, } from "ente-new/photos/utils/native-fs"; +import JSZip from "jszip"; import type { AddSaveGroup, UpdateSaveGroup, @@ -273,15 +273,27 @@ const downloadAndSave = async ( ) => { if (!filesToDownload.length || isDownloading) return; - // Don't start download if already offline - wait for network + // If already offline, mark all files as failed so retry is available if (!navigator.onLine) { - log.info("Skipping download attempt - network is offline"); + log.info("Download skipped - network is offline"); + for (const file of filesToDownload) { + failedFiles.push(file); + } + updateSaveGroup((g) => ({ + ...g, + failed: g.failed + filesToDownload.length, + failureReason: "network_offline", + })); return; } isDownloading = true; if (resetFailedCount) { - updateSaveGroup((g) => ({ ...g, failed: 0, failureReason: undefined })); + updateSaveGroup((g) => ({ + ...g, + failed: 0, + failureReason: undefined, + })); } failedFiles.length = 0; @@ -414,7 +426,9 @@ class ZipBatcher { let counter = 1; let uniqueName: string; do { - uniqueName = ext ? `${name}(${counter}).${ext}` : `${name}(${counter})`; + uniqueName = ext + ? `${name}(${counter}).${ext}` + : `${name}(${counter})`; counter++; } while (this.usedNames.has(uniqueName)); @@ -445,7 +459,13 @@ const downloadFileForZip = async ( if (file.metadata.fileType == FileType.livePhoto) { const { imageFileName, imageData, videoFileName, videoData } = await decodeLivePhoto(fileName, fileBlob); - return { type: "livePhoto", imageFileName, imageData, videoFileName, videoData }; + return { + type: "livePhoto", + imageFileName, + imageData, + videoFileName, + videoData, + }; } else { const data = new Uint8Array(await fileBlob.arrayBuffer()); return { type: "regular", fileName, data }; From 0ba2a6fa1cb909a98c16d043dfb8936bb3df6f4c Mon Sep 17 00:00:00 2001 From: Aswin Asok Date: Wed, 7 Jan 2026 18:10:58 +0530 Subject: [PATCH 04/15] fix(save):fixed-notification-state-changing-when-offline --- web/packages/gallery/services/save.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/web/packages/gallery/services/save.ts b/web/packages/gallery/services/save.ts index 3dadf5ee568..cfcffe6bd24 100644 --- a/web/packages/gallery/services/save.ts +++ b/web/packages/gallery/services/save.ts @@ -273,6 +273,16 @@ const downloadAndSave = async ( ) => { if (!filesToDownload.length || isDownloading) return; + // Reset counts first if this is a retry, before any other logic + if (resetFailedCount) { + updateSaveGroup((g) => ({ + ...g, + failed: 0, + failureReason: undefined, + })); + failedFiles.length = 0; + } + // If already offline, mark all files as failed so retry is available if (!navigator.onLine) { log.info("Download skipped - network is offline"); @@ -281,21 +291,17 @@ const downloadAndSave = async ( } updateSaveGroup((g) => ({ ...g, - failed: g.failed + filesToDownload.length, + failed: filesToDownload.length, failureReason: "network_offline", })); return; } isDownloading = true; - if (resetFailedCount) { - updateSaveGroup((g) => ({ - ...g, - failed: 0, - failureReason: undefined, - })); + // Only clear on first download, not retry (already cleared above) + if (!resetFailedCount) { + failedFiles.length = 0; } - failedFiles.length = 0; try { await saveAsZip( From 272e396ace22eb1cd2e2d270f0c825e257a6d6b8 Mon Sep 17 00:00:00 2001 From: Aswin Asok Date: Thu, 8 Jan 2026 14:07:49 +0530 Subject: [PATCH 05/15] chore(zip):simplify-memory-value-cross-device --- web/packages/gallery/services/save.ts | 98 +++------------------------ 1 file changed, 10 insertions(+), 88 deletions(-) diff --git a/web/packages/gallery/services/save.ts b/web/packages/gallery/services/save.ts index cfcffe6bd24..fe8c24f8f9b 100644 --- a/web/packages/gallery/services/save.ts +++ b/web/packages/gallery/services/save.ts @@ -32,105 +32,27 @@ interface DownloadLimits { let cachedLimits: DownloadLimits | undefined; /** - * Get download limits optimized for the current device and browser. + * Get download limits for the current device. * - * The limits are determined based on: - * - **Device type**: iOS devices have stricter memory limits, Android varies - * - **Browser**: Safari/WebKit has stricter blob size handling - * - **Available memory**: Uses `navigator.deviceMemory` when available - * - * JSZip requires ~2-3x the final ZIP size in peak memory during generation. - * Limits are set to stay well within browser blob/memory limits: - * - Chrome desktop: 2GB in-memory blob limit - * - Android Chrome: RAM/100 blob limit (40-80MB on typical devices) - * - iOS Safari: ~2GB per-tab limit on modern devices - * - * Limits by platform: - * - iOS (all browsers use WebKit): 4 concurrent, 150MB max - * - Android (low memory): 3 concurrent, 75MB max - * - Android (normal): 5 concurrent, 150MB max - * - Desktop Safari: 6 concurrent, 250MB max - * - Other mobile: 4 concurrent, 100MB max - * - Desktop (low memory): 6 concurrent, 200MB max - * - Desktop (normal): 8 concurrent, 400MB max + * - Mobile devices: 4 concurrent, 100MB max + * - Desktop: 8 concurrent, 250MB max */ const getDownloadLimits = (): DownloadLimits => { if (cachedLimits) return cachedLimits; const ua = navigator.userAgent.toLowerCase(); - - // Detect iOS - all browsers on iOS use WebKit with strict memory limits. - // iPadOS 13+ reports as "Macintosh" in UA so we check maxTouchPoints. - const isIOS = + const isMobile = ua.includes("iphone") || ua.includes("ipad") || ua.includes("ipod") || + ua.includes("android") || + ua.includes("mobile") || + ua.includes("tablet") || (ua.includes("macintosh") && navigator.maxTouchPoints > 1); - // Detect Android - const isAndroid = ua.includes("android"); - - // Detect mobile (fallback for other mobile browsers) - const isMobile = - isIOS || isAndroid || ua.includes("mobile") || ua.includes("tablet"); - - // Detect Safari - Chrome/Firefox on macOS include "safari" in UA but also - // include their own name, so we exclude those. - const isSafari = - ua.includes("safari") && - !ua.includes("chrome") && - !ua.includes("chromium") && - !ua.includes("firefox") && - !ua.includes("edg"); - - // deviceMemory is available in Chrome-based browsers (in GB). - // Low memory devices (< 4GB) need more conservative limits. - const deviceMemory = (navigator as { deviceMemory?: number }).deviceMemory; - const isLowMemoryDevice = deviceMemory !== undefined && deviceMemory < 4; - - // iOS - WebKit's aggressive memory management, but modern devices have - // ~2GB per-tab limit. 150MB * 3 (peak memory multiplier) = 450MB, safe margin. - if (isIOS) { - cachedLimits = { - concurrency: 4, - maxZipSize: 150 * 1024 * 1024, // 150MB - }; - return cachedLimits; - } - - // Android - Chrome's blob limit is RAM/100, so keep conservative. - // Low memory: 75MB, Normal (6GB+ devices common): 150MB. - if (isAndroid) { - cachedLimits = isLowMemoryDevice - ? { concurrency: 3, maxZipSize: 75 * 1024 * 1024 } // 75MB - : { concurrency: 5, maxZipSize: 150 * 1024 * 1024 }; // 150MB - return cachedLimits; - } - - // Desktop Safari - WebKit has "very high" limits per Apple's documentation. - // 250MB * 3 = 750MB peak, well within safe range. - if (isSafari) { - cachedLimits = { - concurrency: 6, - maxZipSize: 250 * 1024 * 1024, // 250MB - }; - return cachedLimits; - } - - // Other mobile browsers (rare: Windows Phone, KaiOS, etc.) - if (isMobile) { - cachedLimits = { - concurrency: 4, - maxZipSize: 100 * 1024 * 1024, // 100MB - }; - return cachedLimits; - } - - // Desktop browsers (Chrome, Firefox, Edge) - most capable. - // Chrome has 2GB in-memory blob limit. 400MB * 3 = 1.2GB peak, safe margin. - cachedLimits = isLowMemoryDevice - ? { concurrency: 6, maxZipSize: 200 * 1024 * 1024 } // 200MB - : { concurrency: 8, maxZipSize: 400 * 1024 * 1024 }; // 400MB + cachedLimits = isMobile + ? { concurrency: 4, maxZipSize: 100 * 1024 * 1024 } // 100MB + : { concurrency: 8, maxZipSize: 250 * 1024 * 1024 }; // 250MB return cachedLimits; }; From ec152d20af87ab879626a4376de1109695c99b98 Mon Sep 17 00:00:00 2001 From: Aswin Asok Date: Thu, 8 Jan 2026 14:45:33 +0530 Subject: [PATCH 06/15] chore(download):single-non-live-photos-are-downloaded-individually --- web/packages/gallery/services/save.ts | 46 ++++++++++++++++++++------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/web/packages/gallery/services/save.ts b/web/packages/gallery/services/save.ts index fe8c24f8f9b..50dc4c525aa 100644 --- a/web/packages/gallery/services/save.ts +++ b/web/packages/gallery/services/save.ts @@ -226,18 +226,42 @@ const downloadAndSave = async ( } try { - await saveAsZip( - filesToDownload, - title, - () => - updateSaveGroup((g) => ({ ...g, success: g.success + 1 })), - (file) => { - failedFiles.push(file); + // Single non-live-photo file: download directly without zipping + const singleFile = filesToDownload[0]; + if ( + filesToDownload.length === 1 && + singleFile && + singleFile.metadata.fileType !== FileType.livePhoto + ) { + try { + const fileBlob = await downloadManager.fileBlob(singleFile); + const fileName = fileFileName(singleFile); + const url = URL.createObjectURL(fileBlob); + saveAsFileAndRevokeObjectURL(url, fileName); + updateSaveGroup((g) => ({ ...g, success: g.success + 1 })); + } catch (e) { + log.error("File download failed", e); + failedFiles.push(singleFile); updateSaveGroup((g) => ({ ...g, failed: g.failed + 1 })); - }, - canceller, - updateSaveGroup, - ); + } + } else { + // Multiple files or live photo: use ZIP + await saveAsZip( + filesToDownload, + title, + () => + updateSaveGroup((g) => ({ + ...g, + success: g.success + 1, + })), + (file) => { + failedFiles.push(file); + updateSaveGroup((g) => ({ ...g, failed: g.failed + 1 })); + }, + canceller, + updateSaveGroup, + ); + } if (!failedFiles.length) { updateSaveGroup((g) => ({ ...g, retry: undefined })); From 00bcea096cb7cb65a488ff45b8161dcb07054097 Mon Sep 17 00:00:00 2001 From: Aswin Asok Date: Mon, 12 Jan 2026 08:48:17 +0530 Subject: [PATCH 07/15] fix(save):fixing-lint --- web/packages/gallery/services/save.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/packages/gallery/services/save.ts b/web/packages/gallery/services/save.ts index 50dc4c525aa..9848a47ce3d 100644 --- a/web/packages/gallery/services/save.ts +++ b/web/packages/gallery/services/save.ts @@ -256,7 +256,10 @@ const downloadAndSave = async ( })), (file) => { failedFiles.push(file); - updateSaveGroup((g) => ({ ...g, failed: g.failed + 1 })); + updateSaveGroup((g) => ({ + ...g, + failed: g.failed + 1, + })); }, canceller, updateSaveGroup, From fe411fe6e384d1585f63fe8907456a6f8acd28ea Mon Sep 17 00:00:00 2001 From: Aswin Asok Date: Mon, 12 Jan 2026 09:29:10 +0530 Subject: [PATCH 08/15] fix(save.ts):fixing-retry-part-number --- .../DownloadStatusNotifications.tsx | 4 ++- .../base/locales/en-US/translation.json | 1 + web/packages/gallery/services/save.ts | 34 ++++++++++++++----- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/web/apps/photos/src/components/DownloadStatusNotifications.tsx b/web/apps/photos/src/components/DownloadStatusNotifications.tsx index c7ccf86b939..e00714148b2 100644 --- a/web/apps/photos/src/components/DownloadStatusNotifications.tsx +++ b/web/apps/photos/src/components/DownloadStatusNotifications.tsx @@ -115,7 +115,9 @@ export const DownloadStatusNotifications: React.FC< ? failedTitle : isSaveComplete(group) ? t("download_complete") - : t("downloading_album", { name: group.title }), + : group.downloadDirPath + ? t("downloading_album", { name: group.title }) + : t("creating_zip", { name: group.title }), caption: isSaveComplete(group) ? group.title : t("download_progress", { diff --git a/web/packages/base/locales/en-US/translation.json b/web/packages/base/locales/en-US/translation.json index dbe0293d581..e6c761b1512 100644 --- a/web/packages/base/locales/en-US/translation.json +++ b/web/packages/base/locales/en-US/translation.json @@ -657,6 +657,7 @@ "unpreviewable_file_message": "This file could not be previewed", "download_complete": "Download complete", "downloading_album": "Downloading {{name}}", + "creating_zip": "Creating ZIP for {{name}}", "download_failed": "Download failed", "download_failed_network_offline": "Connection lost", "download_failed_file_error": "Some files failed", diff --git a/web/packages/gallery/services/save.ts b/web/packages/gallery/services/save.ts index 9848a47ce3d..449b9fd6ce4 100644 --- a/web/packages/gallery/services/save.ts +++ b/web/packages/gallery/services/save.ts @@ -154,6 +154,10 @@ const downloadAndSave = async ( const failedFiles: EnteFile[] = []; let isDownloading = false; let updateSaveGroup: UpdateSaveGroup = () => undefined; + // Track the next ZIP batch index across retries so part numbers continue + // sequentially (e.g., if initial download creates Parts 1-5 and fails, + // retry creates Part 6+ instead of starting over at Part 1). + let nextZipBatchIndex = 1; const downloadFilesDesktop = async ( filesToDownload: EnteFile[], @@ -246,7 +250,7 @@ const downloadAndSave = async ( } } else { // Multiple files or live photo: use ZIP - await saveAsZip( + nextZipBatchIndex = await saveAsZip( filesToDownload, title, () => @@ -263,6 +267,7 @@ const downloadAndSave = async ( }, canceller, updateSaveGroup, + nextZipBatchIndex, ); } @@ -303,14 +308,23 @@ class ZipBatcher { private zip = new JSZip(); private currentBatchSize = 0; private currentFileCount = 0; - private batchIndex = 1; + private batchIndex: number; private usedNames = new Set(); private baseName: string; private maxZipSize: number; - constructor(baseName: string, maxZipSize: number) { + constructor(baseName: string, maxZipSize: number, startingBatchIndex = 1) { this.baseName = baseName; this.maxZipSize = maxZipSize; + this.batchIndex = startingBatchIndex; + } + + /** + * Get the next batch index that would be used for the next ZIP file. + * This is useful for tracking progress across retries. + */ + getNextBatchIndex(): number { + return this.batchIndex; } /** @@ -352,10 +366,7 @@ class ZipBatcher { this.currentFileCount === 1 ? "1 file" : `${this.currentFileCount} files`; - const zipName = - this.batchIndex === 1 - ? `${this.baseName} (${fileLabel}).zip` - : `${this.baseName} (${fileLabel})-${this.batchIndex}.zip`; + const zipName = `${this.baseName} Part ${this.batchIndex} - ${fileLabel}.zip`; const url = URL.createObjectURL(zipBlob); saveAsFileAndRevokeObjectURL(url, zipName); @@ -439,6 +450,8 @@ const downloadFileForZip = async ( * @param onSuccess Callback invoked after each file is successfully added. * @param onError Callback invoked when a file fails to download. * @param canceller An AbortController to check for cancellation. + * @param startingBatchIndex The batch index to start from (for retries). + * @returns The next batch index to use for subsequent ZIPs (useful for retries). */ const saveAsZip = async ( files: EnteFile[], @@ -447,9 +460,10 @@ const saveAsZip = async ( onError: (file: EnteFile, error: unknown) => void, canceller: AbortController, updateSaveGroup: UpdateSaveGroup, -): Promise => { + startingBatchIndex = 1, +): Promise => { const { concurrency, maxZipSize } = getDownloadLimits(); - const batcher = new ZipBatcher(baseName, maxZipSize); + const batcher = new ZipBatcher(baseName, maxZipSize, startingBatchIndex); // Queue of files to process let fileIndex = 0; @@ -589,6 +603,8 @@ const saveAsZip = async ( if (!canceller.signal.aborted) { await batcher.flush(); } + + return batcher.getNextBatchIndex(); } finally { // Clean up event listeners window.removeEventListener("offline", handleOffline); From 3dbbfc6f845d81e071874390316b966badf76313 Mon Sep 17 00:00:00 2001 From: Aswin Asok Date: Mon, 12 Jan 2026 09:47:41 +0530 Subject: [PATCH 09/15] chore(livePhoto):update-log-message --- web/packages/media/live-photo.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/web/packages/media/live-photo.ts b/web/packages/media/live-photo.ts index 111d8a9e159..4830e23bd50 100644 --- a/web/packages/media/live-photo.ts +++ b/web/packages/media/live-photo.ts @@ -103,9 +103,7 @@ export const decodeLivePhoto = async ( ); if (!videoFileName || !videoData) - throw new Error( - `Decoded live photo ${fileName} does not have an image`, - ); + throw new Error(`Decoded live photo ${fileName} does not have a video`); return { imageFileName, imageData, videoFileName, videoData }; }; From b6c39d30fef9850ee4aa686aff6aa1d99eab5301 Mon Sep 17 00:00:00 2001 From: Aswin Asok Date: Mon, 12 Jan 2026 09:51:36 +0530 Subject: [PATCH 10/15] fix(DownloadStatusNotification):message-fixed-when-single-file --- .../photos/src/components/DownloadStatusNotifications.tsx | 2 +- web/packages/media/live-photo.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/web/apps/photos/src/components/DownloadStatusNotifications.tsx b/web/apps/photos/src/components/DownloadStatusNotifications.tsx index e00714148b2..30830bf9a15 100644 --- a/web/apps/photos/src/components/DownloadStatusNotifications.tsx +++ b/web/apps/photos/src/components/DownloadStatusNotifications.tsx @@ -115,7 +115,7 @@ export const DownloadStatusNotifications: React.FC< ? failedTitle : isSaveComplete(group) ? t("download_complete") - : group.downloadDirPath + : group.downloadDirPath || group.total === 1 ? t("downloading_album", { name: group.title }) : t("creating_zip", { name: group.title }), caption: isSaveComplete(group) diff --git a/web/packages/media/live-photo.ts b/web/packages/media/live-photo.ts index 111d8a9e159..4830e23bd50 100644 --- a/web/packages/media/live-photo.ts +++ b/web/packages/media/live-photo.ts @@ -103,9 +103,7 @@ export const decodeLivePhoto = async ( ); if (!videoFileName || !videoData) - throw new Error( - `Decoded live photo ${fileName} does not have an image`, - ); + throw new Error(`Decoded live photo ${fileName} does not have a video`); return { imageFileName, imageData, videoFileName, videoData }; }; From 8eab9931a03b1285f9da12ba21160f874be4a316 Mon Sep 17 00:00:00 2001 From: Aswin Asok Date: Mon, 12 Jan 2026 10:41:26 +0530 Subject: [PATCH 11/15] feat(DownloadStatusNotification):added-current-state-with-part-number --- .../DownloadStatusNotifications.tsx | 54 ++++++++++++++--- .../base/locales/en-US/translation.json | 6 ++ .../gallery/components/utils/save-groups.ts | 11 ++++ web/packages/gallery/services/save.ts | 58 +++++++++++++++---- 4 files changed, 108 insertions(+), 21 deletions(-) diff --git a/web/apps/photos/src/components/DownloadStatusNotifications.tsx b/web/apps/photos/src/components/DownloadStatusNotifications.tsx index 30830bf9a15..4e589cccf3d 100644 --- a/web/apps/photos/src/components/DownloadStatusNotifications.tsx +++ b/web/apps/photos/src/components/DownloadStatusNotifications.tsx @@ -1,4 +1,5 @@ import ReplayIcon from "@mui/icons-material/Replay"; +import { Typography } from "@mui/material"; import { useBaseContext } from "ente-base/context"; import { isSaveComplete, @@ -8,6 +9,15 @@ import { import { Notification } from "ente-new/photos/components/Notification"; import { t } from "i18next"; +/** Maximum characters for album name before truncation */ +const MAX_ALBUM_NAME_LENGTH = 20; + +/** Truncate album name with ellipsis if it exceeds max length */ +const truncateAlbumName = (name: string): string => { + if (name.length <= MAX_ALBUM_NAME_LENGTH) return name; + return name.slice(0, MAX_ALBUM_NAME_LENGTH) + "..."; +}; + interface DownloadStatusNotificationsProps { /** * A list of user-initiated downloads for which a status should be shown. @@ -101,6 +111,39 @@ export const DownloadStatusNotifications: React.FC< failedTitle = `${t("download_failed")} (${group.failed}/${group.total})`; } + // Determine if this is a ZIP download (web with multiple files or live photo) + const isZipDownload = !group.downloadDirPath && group.total > 1; + const isDesktopOrSingleFile = + !!group.downloadDirPath || group.total === 1; + + // Build the title based on download type + let progressTitle: string; + if (isZipDownload) { + const part = group.currentPart ?? 1; + progressTitle = group.isDownloadingZip + ? t("downloading_part", { part }) + : t("preparing_part", { part }); + } else if (isDesktopOrSingleFile) { + progressTitle = + group.total === 1 + ? t("downloading_file") + : t("downloading_files"); + } else { + progressTitle = t("downloading"); + } + + // Build caption: "X / Y files - Album Name" + const truncatedName = truncateAlbumName(group.title); + const progress = t("download_progress", { + count: group.success + group.failed, + total: group.total, + }); + const progressCaption = ( + + {progress} - {truncatedName} + + ); + return ( diff --git a/web/packages/base/locales/en-US/translation.json b/web/packages/base/locales/en-US/translation.json index e6c761b1512..cbd9c149388 100644 --- a/web/packages/base/locales/en-US/translation.json +++ b/web/packages/base/locales/en-US/translation.json @@ -658,6 +658,12 @@ "download_complete": "Download complete", "downloading_album": "Downloading {{name}}", "creating_zip": "Creating ZIP for {{name}}", + "preparing": "Preparing", + "downloading": "Downloading", + "preparing_part": "Preparing Part {{part}}", + "downloading_part": "Downloading Part {{part}}", + "downloading_file": "Downloading file", + "downloading_files": "Downloading files", "download_failed": "Download failed", "download_failed_network_offline": "Connection lost", "download_failed_file_error": "Some files failed", diff --git a/web/packages/gallery/components/utils/save-groups.ts b/web/packages/gallery/components/utils/save-groups.ts index d8861e68796..22a776bdf54 100644 --- a/web/packages/gallery/components/utils/save-groups.ts +++ b/web/packages/gallery/components/utils/save-groups.ts @@ -82,6 +82,17 @@ export interface SaveGroup { * - undefined: No specific reason (generic error) */ failureReason?: "network_offline" | "file_error"; + /** + * `true` when the ZIP file is being downloaded from memory to the user's + * device. This is only relevant for web downloads where files are first + * collected into a ZIP in memory, then saved to the device. + */ + isDownloadingZip?: boolean; + /** + * The current ZIP part number being processed. Only relevant for web + * downloads where files are batched into multiple ZIP parts. + */ + currentPart?: number; } /** diff --git a/web/packages/gallery/services/save.ts b/web/packages/gallery/services/save.ts index 449b9fd6ce4..961bce84cb3 100644 --- a/web/packages/gallery/services/save.ts +++ b/web/packages/gallery/services/save.ts @@ -312,11 +312,18 @@ class ZipBatcher { private usedNames = new Set(); private baseName: string; private maxZipSize: number; - - constructor(baseName: string, maxZipSize: number, startingBatchIndex = 1) { + private onStateChange?: (isDownloading: boolean, partNumber: number) => void; + + constructor( + baseName: string, + maxZipSize: number, + startingBatchIndex = 1, + onStateChange?: (isDownloading: boolean, partNumber: number) => void, + ) { this.baseName = baseName; this.maxZipSize = maxZipSize; this.batchIndex = startingBatchIndex; + this.onStateChange = onStateChange; } /** @@ -327,6 +334,13 @@ class ZipBatcher { return this.batchIndex; } + /** + * Get the current batch index being processed. + */ + getCurrentBatchIndex(): number { + return this.batchIndex; + } + /** * Add file data to the current ZIP batch. If adding this file would exceed * the batch size limit, the current batch is downloaded first. @@ -341,6 +355,8 @@ class ZipBatcher { this.currentBatchSize + size > this.maxZipSize ) { await this.downloadCurrentBatch(); + // Notify that we're now preparing a new part + this.onStateChange?.(false, this.batchIndex); } // Ensure unique file names within the ZIP @@ -361,15 +377,20 @@ class ZipBatcher { } private async downloadCurrentBatch(): Promise { - const zipBlob = await this.zip.generateAsync({ type: "blob" }); - const fileLabel = - this.currentFileCount === 1 - ? "1 file" - : `${this.currentFileCount} files`; - const zipName = `${this.baseName} Part ${this.batchIndex} - ${fileLabel}.zip`; - - const url = URL.createObjectURL(zipBlob); - saveAsFileAndRevokeObjectURL(url, zipName); + this.onStateChange?.(true, this.batchIndex); + try { + const zipBlob = await this.zip.generateAsync({ type: "blob" }); + const fileLabel = + this.currentFileCount === 1 + ? "1 file" + : `${this.currentFileCount} files`; + const zipName = `${this.baseName} Part ${this.batchIndex} - ${fileLabel}.zip`; + + const url = URL.createObjectURL(zipBlob); + saveAsFileAndRevokeObjectURL(url, zipName); + } finally { + this.onStateChange?.(false, this.batchIndex); + } // Reset for next batch this.zip = new JSZip(); @@ -463,7 +484,20 @@ const saveAsZip = async ( startingBatchIndex = 1, ): Promise => { const { concurrency, maxZipSize } = getDownloadLimits(); - const batcher = new ZipBatcher(baseName, maxZipSize, startingBatchIndex); + const batcher = new ZipBatcher( + baseName, + maxZipSize, + startingBatchIndex, + (isDownloading, partNumber) => + updateSaveGroup((g) => ({ + ...g, + isDownloadingZip: isDownloading, + currentPart: partNumber, + })), + ); + + // Set initial part number + updateSaveGroup((g) => ({ ...g, currentPart: startingBatchIndex })); // Queue of files to process let fileIndex = 0; From cdbb2d38476a8a7c2f31f9f8957d1d068514971c Mon Sep 17 00:00:00 2001 From: Aswin Asok Date: Mon, 12 Jan 2026 10:44:06 +0530 Subject: [PATCH 12/15] fix(DownloadStatusNotification):fixing-lint --- .../photos/src/components/DownloadStatusNotifications.tsx | 4 +++- web/packages/gallery/services/save.ts | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/web/apps/photos/src/components/DownloadStatusNotifications.tsx b/web/apps/photos/src/components/DownloadStatusNotifications.tsx index 4e589cccf3d..df49239be78 100644 --- a/web/apps/photos/src/components/DownloadStatusNotifications.tsx +++ b/web/apps/photos/src/components/DownloadStatusNotifications.tsx @@ -159,7 +159,9 @@ export const DownloadStatusNotifications: React.FC< : isSaveComplete(group) ? t("download_complete") : progressTitle, - caption: isSaveComplete(group) ? group.title : progressCaption, + caption: isSaveComplete(group) + ? group.title + : progressCaption, onClick: createOnClick(group), endIcon: canRetry ? ( diff --git a/web/packages/gallery/services/save.ts b/web/packages/gallery/services/save.ts index 961bce84cb3..6554beb7714 100644 --- a/web/packages/gallery/services/save.ts +++ b/web/packages/gallery/services/save.ts @@ -312,7 +312,10 @@ class ZipBatcher { private usedNames = new Set(); private baseName: string; private maxZipSize: number; - private onStateChange?: (isDownloading: boolean, partNumber: number) => void; + private onStateChange?: ( + isDownloading: boolean, + partNumber: number, + ) => void; constructor( baseName: string, From 763f4beaef01ea987970dcded9e4d00b2602cb19 Mon Sep 17 00:00:00 2001 From: Aswin Asok Date: Mon, 12 Jan 2026 11:41:13 +0530 Subject: [PATCH 13/15] style(DownloadStatusNotification):added-animated-ellipsis --- .../DownloadStatusNotifications.tsx | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/web/apps/photos/src/components/DownloadStatusNotifications.tsx b/web/apps/photos/src/components/DownloadStatusNotifications.tsx index df49239be78..41fe9e67c61 100644 --- a/web/apps/photos/src/components/DownloadStatusNotifications.tsx +++ b/web/apps/photos/src/components/DownloadStatusNotifications.tsx @@ -1,5 +1,5 @@ import ReplayIcon from "@mui/icons-material/Replay"; -import { Typography } from "@mui/material"; +import { keyframes, styled, Typography } from "@mui/material"; import { useBaseContext } from "ente-base/context"; import { isSaveComplete, @@ -18,6 +18,21 @@ const truncateAlbumName = (name: string): string => { return name.slice(0, MAX_ALBUM_NAME_LENGTH) + "..."; }; +/** CSS keyframes for animating ellipsis dots */ +const ellipsisAnimation = keyframes` + 0% { content: " ."; } + 33% { content: " .."; } + 66% { content: " ..."; } +`; + +/** Animated ellipsis using pure CSS - no React re-renders */ +const AnimatedEllipsis = styled("span")` + &::after { + content: " ."; + animation: ${ellipsisAnimation} 1.5s steps(1) infinite; + } +`; + interface DownloadStatusNotificationsProps { /** * A list of user-initiated downloads for which a status should be shown. @@ -117,12 +132,17 @@ export const DownloadStatusNotifications: React.FC< !!group.downloadDirPath || group.total === 1; // Build the title based on download type - let progressTitle: string; + let progressTitle: React.ReactNode; if (isZipDownload) { const part = group.currentPart ?? 1; - progressTitle = group.isDownloadingZip - ? t("downloading_part", { part }) - : t("preparing_part", { part }); + progressTitle = ( + <> + {group.isDownloadingZip + ? t("downloading_part", { part }) + : t("preparing_part", { part })} + + + ); } else if (isDesktopOrSingleFile) { progressTitle = group.total === 1 From 463dee3204936efdb87d46450ee552f7a93eaf61 Mon Sep 17 00:00:00 2001 From: Aswin Asok Date: Mon, 12 Jan 2026 16:42:32 +0530 Subject: [PATCH 14/15] feat(DownloadStatusNotification):styles-updated --- web/apps/photos/package.json | 2 + .../DownloadStatusNotifications.tsx | 139 +++++++++++------- web/yarn.lock | 10 ++ 3 files changed, 101 insertions(+), 50 deletions(-) diff --git a/web/apps/photos/package.json b/web/apps/photos/package.json index 4f343147587..ba799e138a1 100644 --- a/web/apps/photos/package.json +++ b/web/apps/photos/package.json @@ -3,6 +3,8 @@ "version": "0.0.0", "private": true, "dependencies": { + "@hugeicons/core-free-icons": "^1.1.0", + "@hugeicons/react": "^1.1.4", "chrono-node": "^2.9.0", "debounce": "^2.2.0", "ente-accounts": "*", diff --git a/web/apps/photos/src/components/DownloadStatusNotifications.tsx b/web/apps/photos/src/components/DownloadStatusNotifications.tsx index 41fe9e67c61..5e3f9d335fe 100644 --- a/web/apps/photos/src/components/DownloadStatusNotifications.tsx +++ b/web/apps/photos/src/components/DownloadStatusNotifications.tsx @@ -1,3 +1,10 @@ +import { + Download01Icon, + Loading03Icon, + Tick02Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline"; import ReplayIcon from "@mui/icons-material/Replay"; import { keyframes, styled, Typography } from "@mui/material"; import { useBaseContext } from "ente-base/context"; @@ -10,7 +17,7 @@ import { Notification } from "ente-new/photos/components/Notification"; import { t } from "i18next"; /** Maximum characters for album name before truncation */ -const MAX_ALBUM_NAME_LENGTH = 20; +const MAX_ALBUM_NAME_LENGTH = 30; /** Truncate album name with ellipsis if it exceeds max length */ const truncateAlbumName = (name: string): string => { @@ -18,21 +25,25 @@ const truncateAlbumName = (name: string): string => { return name.slice(0, MAX_ALBUM_NAME_LENGTH) + "..."; }; -/** CSS keyframes for animating ellipsis dots */ -const ellipsisAnimation = keyframes` - 0% { content: " ."; } - 33% { content: " .."; } - 66% { content: " ..."; } +/** CSS keyframes for spinning animation */ +const spinAnimation = keyframes` + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } `; -/** Animated ellipsis using pure CSS - no React re-renders */ -const AnimatedEllipsis = styled("span")` - &::after { - content: " ."; - animation: ${ellipsisAnimation} 1.5s steps(1) infinite; - } +/** Spinning loading icon wrapper */ +const SpinningIconWrapper = styled("span")` + display: inline-flex; + animation: ${spinAnimation} 2s linear infinite; `; +/** Spinning loading icon */ +const SpinningIcon: React.FC = () => ( + + + +); + interface DownloadStatusNotificationsProps { /** * A list of user-initiated downloads for which a status should be shown. @@ -114,77 +125,105 @@ export const DownloadStatusNotifications: React.FC< return saveGroups.map((group, index) => { const hasErrors = isSaveCompleteWithErrors(group); + const isComplete = isSaveComplete(group); const canRetry = hasErrors && !!group.retry; - // Show specific error message based on failure reason - let failedTitle: string; - if (group.failureReason === "network_offline") { - failedTitle = `${t("download_failed_network_offline")} (${group.failed}/${group.total})`; - } else if (group.failureReason === "file_error") { - failedTitle = `${t("download_failed_file_error")} (${group.failed}/${group.total})`; - } else { - failedTitle = `${t("download_failed")} (${group.failed}/${group.total})`; - } - // Determine if this is a ZIP download (web with multiple files or live photo) const isZipDownload = !group.downloadDirPath && group.total > 1; const isDesktopOrSingleFile = !!group.downloadDirPath || group.total === 1; - // Build the title based on download type - let progressTitle: React.ReactNode; - if (isZipDownload) { + // Build the status text for the caption + let statusText: React.ReactNode; + if (hasErrors) { + // Show specific error message based on failure reason + if (group.failureReason === "network_offline") { + statusText = t("download_failed_network_offline"); + } else if (group.failureReason === "file_error") { + statusText = t("download_failed_file_error"); + } else { + statusText = t("download_failed"); + } + } else if (isComplete) { + statusText = t("download_complete"); + } else if (isZipDownload) { const part = group.currentPart ?? 1; - progressTitle = ( - <> - {group.isDownloadingZip - ? t("downloading_part", { part }) - : t("preparing_part", { part })} - - - ); + statusText = group.isDownloadingZip + ? t("downloading_part", { part }) + : t("preparing_part", { part }); } else if (isDesktopOrSingleFile) { - progressTitle = + statusText = group.total === 1 ? t("downloading_file") : t("downloading_files"); } else { - progressTitle = t("downloading"); + statusText = t("downloading"); } - // Build caption: "X / Y files - Album Name" - const truncatedName = truncateAlbumName(group.title); + // Build caption: "Status • X / Y files" const progress = t("download_progress", { count: group.success + group.failed, total: group.total, }); - const progressCaption = ( - - {progress} - {truncatedName} + const caption = ( + + {statusText} + {!isComplete && <> • {progress}} ); + // Determine the start icon based on state + let startIcon: React.ReactNode; + if (hasErrors) { + startIcon = ; + } else if (isComplete) { + startIcon = ; + } else if (isZipDownload && !group.isDownloadingZip) { + // Preparing state - use loading icon + startIcon = ; + } else { + // Downloading state + startIcon = ; + } + + // Title is always the album name (truncated) + const truncatedName = truncateAlbumName(group.title); + return ( + ) : undefined, onEndIconClick: canRetry ? () => group.retry?.() diff --git a/web/yarn.lock b/web/yarn.lock index 40ab80e6678..3c6b475fc35 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -645,6 +645,16 @@ resolved "https://registry.yarnpkg.com/@fontsource-variable/inter/-/inter-5.2.8.tgz#29b11476f5149f6a443b4df6516e26002d87941a" integrity sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ== +"@hugeicons/core-free-icons@^1.1.0": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@hugeicons/core-free-icons/-/core-free-icons-1.2.1.tgz#c741eaa8bbf1453e9282a64c19a8bbcc8cef5f19" + integrity sha512-ho0QdGMkgL+kt+QsZocCsKvJou1rfyVQWARrxIhNLi+9tCKayUUtD9jlHgioaRphmskSl84TxrDm9Ae0G4Uu1g== + +"@hugeicons/react@^1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@hugeicons/react/-/react-1.1.4.tgz#1662f11ebb42c7e16fa7e16e04615db666813a06" + integrity sha512-gsc3eZyd2fGqRUThW9+lfjxxsOkz6KNVmRXRgJjP32GL0OnnLJnl3hytKt47CBbiQj2xE2kCw+rnP3UQCThcKw== + "@humanfs/core@^0.19.1": version "0.19.1" resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" From ec1e8cd344ce6e84a0c66990002c4637de5f59a6 Mon Sep 17 00:00:00 2001 From: Aswin Asok Date: Mon, 12 Jan 2026 17:05:58 +0530 Subject: [PATCH 15/15] chore(DownloadStatusNotification):update-styles-and-animations --- .../DownloadStatusNotifications.tsx | 90 ++++++++++++++----- 1 file changed, 67 insertions(+), 23 deletions(-) diff --git a/web/apps/photos/src/components/DownloadStatusNotifications.tsx b/web/apps/photos/src/components/DownloadStatusNotifications.tsx index 5e3f9d335fe..3004c21dab0 100644 --- a/web/apps/photos/src/components/DownloadStatusNotifications.tsx +++ b/web/apps/photos/src/components/DownloadStatusNotifications.tsx @@ -17,7 +17,7 @@ import { Notification } from "ente-new/photos/components/Notification"; import { t } from "i18next"; /** Maximum characters for album name before truncation */ -const MAX_ALBUM_NAME_LENGTH = 30; +const MAX_ALBUM_NAME_LENGTH = 25; /** Truncate album name with ellipsis if it exceeds max length */ const truncateAlbumName = (name: string): string => { @@ -25,25 +25,6 @@ const truncateAlbumName = (name: string): string => { return name.slice(0, MAX_ALBUM_NAME_LENGTH) + "..."; }; -/** CSS keyframes for spinning animation */ -const spinAnimation = keyframes` - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -`; - -/** Spinning loading icon wrapper */ -const SpinningIconWrapper = styled("span")` - display: inline-flex; - animation: ${spinAnimation} 2s linear infinite; -`; - -/** Spinning loading icon */ -const SpinningIcon: React.FC = () => ( - - - -); - interface DownloadStatusNotificationsProps { /** * A list of user-initiated downloads for which a status should be shown. @@ -168,7 +149,10 @@ export const DownloadStatusNotifications: React.FC< const caption = ( {statusText} {!isComplete && <> • {progress}} @@ -180,13 +164,21 @@ export const DownloadStatusNotifications: React.FC< if (hasErrors) { startIcon = ; } else if (isComplete) { - startIcon = ; + startIcon = ( + + + + ); } else if (isZipDownload && !group.isDownloadingZip) { // Preparing state - use loading icon startIcon = ; } else { // Downloading state - startIcon = ; + startIcon = ( + + + + ); } // Title is always the album name (truncated) @@ -233,3 +225,55 @@ export const DownloadStatusNotifications: React.FC< ); }); }; + +/** CSS keyframes for spinning animation */ +const spinAnimation = keyframes` + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +`; + +/** CSS keyframes for drop from top animation */ +const dropAnimation = keyframes` + 0% { transform: translateY(-100%); opacity: 0.15; } + 50% { transform: translateY(10%); opacity: 0.6; } + 100% { transform: translateY(0); opacity: 1; } +`; + +/** CSS keyframes for green glow animation */ +const glowAnimation = keyframes` + 0% { color: var(--mui-palette-fixed-success); } + 100% { color: inherit; } +`; + +/** CSS keyframes for fade in animation */ +const fadeInAnimation = keyframes` + 0% { opacity: 0; } + 100% { opacity: 1; } +`; + +/** Drop animation icon wrapper */ +const DroppingIconWrapper = styled("span")` + display: inline-flex; + animation: ${dropAnimation} 0.8s ease-out forwards; +`; + +/** Glowing icon wrapper for success state */ +const GlowingIconWrapper = styled("span")` + display: inline-flex; + animation: ${glowAnimation} 2s ease-out forwards; +`; + +/** Spinning loading icon wrapper */ +const SpinningIconWrapper = styled("span")` + display: inline-flex; + animation: + ${fadeInAnimation} 0.5s ease-out forwards, + ${spinAnimation} 3s linear infinite; +`; + +/** Spinning loading icon */ +const SpinningIcon: React.FC = () => ( + + + +);