diff --git a/src/components/PhotoFrame.tsx b/src/components/PhotoFrame.tsx index d75947e9f..b91ea03ca 100644 --- a/src/components/PhotoFrame.tsx +++ b/src/components/PhotoFrame.tsx @@ -354,7 +354,7 @@ const PhotoFrame = ({ if (galleryContext.thumbs.has(item.id)) { url = galleryContext.thumbs.get(item.id); } else { - url = await DownloadManager.getPreview(item); + url = await DownloadManager.getThumbnail(item); galleryContext.thumbs.set(item.id, url); } updateUrl(item.dataIndex)(url); diff --git a/src/components/icons/DownloadIcon.tsx b/src/components/icons/DownloadIcon.tsx new file mode 100644 index 000000000..0172c6dc2 --- /dev/null +++ b/src/components/icons/DownloadIcon.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +export default function DownloadIcon(props) { + return ( + + + + + + + + + ); +} + +DownloadIcon.defaultProps = { + height: 24, + width: 24, + viewBox: '0 0 24 24', +}; diff --git a/src/components/pages/gallery/PreviewCard.tsx b/src/components/pages/gallery/PreviewCard.tsx index b04963826..ddb4f7de0 100644 --- a/src/components/pages/gallery/PreviewCard.tsx +++ b/src/components/pages/gallery/PreviewCard.tsx @@ -177,7 +177,7 @@ export default function PreviewCard(props: IProps) { if (file && !file.msrc) { const main = async () => { try { - const url = await DownloadManager.getPreview(file); + const url = await DownloadManager.getThumbnail(file); if (isMounted.current) { setImgSrc(url); thumbs.set(file.id, url); diff --git a/src/components/pages/gallery/SelectedFileOptions.tsx b/src/components/pages/gallery/SelectedFileOptions.tsx index 5581fe7e5..7985e5a1c 100644 --- a/src/components/pages/gallery/SelectedFileOptions.tsx +++ b/src/components/pages/gallery/SelectedFileOptions.tsx @@ -23,6 +23,7 @@ import { FIX_CREATION_TIME_VISIBLE_TO_USER_IDS, User, } from 'services/userService'; +import DownloadIcon from 'components/icons/DownloadIcon'; interface Props { addToCollectionHelper: (collection: Collection) => void; @@ -34,6 +35,7 @@ interface Props { deleteFileHelper: (permanent?: boolean) => void; removeFromCollectionHelper: () => void; fixTimeHelper: () => void; + downloadHelper: () => void; count: number; clearSelection: () => void; archiveFilesHelper: () => void; @@ -79,6 +81,7 @@ const SelectedFileOptions = ({ setDialogMessage, setCollectionSelectorAttributes, deleteFileHelper, + downloadHelper, count, clearSelection, archiveFilesHelper, @@ -190,6 +193,11 @@ const SelectedFileOptions = ({ )} + + + + + diff --git a/src/pages/gallery/index.tsx b/src/pages/gallery/index.tsx index 1b2372cb0..3f3976357 100644 --- a/src/pages/gallery/index.tsx +++ b/src/pages/gallery/index.tsx @@ -50,6 +50,7 @@ import { LoadingOverlay } from 'components/LoadingOverlay'; import PhotoFrame from 'components/PhotoFrame'; import { changeFilesVisibility, + downloadFiles, getNonTrashedUniqueUserFiles, getSelectedFiles, mergeMetadata, @@ -545,6 +546,14 @@ export default function Gallery() { clearSelection(); }; + const downloadHelper = async () => { + const selectedFiles = getSelectedFiles(selected, files); + clearSelection(); + !syncInProgress.current && loadingBar.current?.continuousStart(); + await downloadFiles(selectedFiles); + !syncInProgress.current && loadingBar.current.complete(); + }; + return ( >(); private thumbnailObjectUrlPromise = new Map>(); - public async getPreview(file: File) { + public async getThumbnail(file: File) { try { const token = getToken(); if (!token) { return null; } - const thumbnailCache = await caches.open('thumbs'); - const cacheResp: Response = await thumbnailCache.match( - file.id.toString() - ); - if (cacheResp) { - return URL.createObjectURL(await cacheResp.blob()); - } if (!this.thumbnailObjectUrlPromise.get(file.id)) { - const downloadPromise = this.downloadThumb( - token, - thumbnailCache, - file - ); - this.thumbnailObjectUrlPromise.set(file.id, downloadPromise); + const downloadPromise = async () => { + const thumbnailCache = await caches.open('thumbs'); + const cacheResp: Response = await thumbnailCache.match( + file.id.toString() + ); + if (cacheResp) { + return URL.createObjectURL(await cacheResp.blob()); + } + const thumb = await this.downloadThumb(token, file); + const thumbBlob = new Blob([thumb]); + try { + await thumbnailCache.put( + file.id.toString(), + new Response(thumbBlob) + ); + } catch (e) { + // TODO: handle storage full exception. + } + return URL.createObjectURL(thumbBlob); + }; + this.thumbnailObjectUrlPromise.set(file.id, downloadPromise()); } + return await this.thumbnailObjectUrlPromise.get(file.id); } catch (e) { this.thumbnailObjectUrlPromise.delete(file.id); @@ -39,24 +52,7 @@ class DownloadManager { } } - private downloadThumb = async ( - token: string, - thumbnailCache: Cache, - file: File - ) => { - const thumb = await this.getThumbnail(token, file); - try { - await thumbnailCache.put( - file.id.toString(), - new Response(new Blob([thumb])) - ); - } catch (e) { - // TODO: handle storage full exception. - } - return URL.createObjectURL(new Blob([thumb])); - }; - - getThumbnail = async (token: string, file: File) => { + downloadThumb = async (token: string, file: File) => { const resp = await HTTPService.get( getThumbnailUrl(file.id), null, @@ -73,32 +69,38 @@ class DownloadManager { }; getFile = async (file: File, forPreview = false) => { - let fileUID: string; - if (file.metadata.fileType === FILE_TYPE.VIDEO) { - fileUID = file.id.toString(); - } else { - fileUID = `${file.id}_forPreview=${forPreview}`; - } + const shouldBeConverted = forPreview && needsConversionForPreview(file); + const fileKey = shouldBeConverted + ? `${file.id}_converted` + : `${file.id}`; try { - const getFilePromise = async () => { + const getFilePromise = async (convert: boolean) => { const fileStream = await this.downloadFile(file); let fileBlob = await new Response(fileStream).blob(); - if (forPreview) { + if (convert) { fileBlob = await convertForPreview(file, fileBlob); } return URL.createObjectURL(fileBlob); }; - if (!this.fileObjectUrlPromise.get(fileUID)) { - this.fileObjectUrlPromise.set(fileUID, getFilePromise()); + if (!this.fileObjectUrlPromise.get(fileKey)) { + this.fileObjectUrlPromise.set( + fileKey, + getFilePromise(shouldBeConverted) + ); } - return await this.fileObjectUrlPromise.get(fileUID); + const fileURL = await this.fileObjectUrlPromise.get(fileKey); + return fileURL; } catch (e) { - this.fileObjectUrlPromise.delete(fileUID); + this.fileObjectUrlPromise.delete(fileKey); logError(e, 'Failed to get File'); throw e; } }; + public async getCachedOriginalFile(file: File) { + return await this.fileObjectUrlPromise.get(file.id.toString()); + } + async downloadFile(file: File) { const worker = await new CryptoWorker(); const token = getToken(); diff --git a/src/services/migrateThumbnailService.ts b/src/services/migrateThumbnailService.ts index d11286ec5..4245a4538 100644 --- a/src/services/migrateThumbnailService.ts +++ b/src/services/migrateThumbnailService.ts @@ -67,7 +67,7 @@ export async function replaceThumbnail( current: idx, total: largeThumbnailFiles.length, }); - const originalThumbnail = await downloadManager.getThumbnail( + const originalThumbnail = await downloadManager.downloadThumb( token, file ); diff --git a/src/services/upload/thumbnailService.ts b/src/services/upload/thumbnailService.ts index dd79222f3..8872150df 100644 --- a/src/services/upload/thumbnailService.ts +++ b/src/services/upload/thumbnailService.ts @@ -4,7 +4,7 @@ import { logError } from 'utils/sentry'; import { BLACK_THUMBNAIL_BASE64 } from '../../../public/images/black-thumbnail-b64'; import FFmpegService from 'services/ffmpegService'; import { convertToHumanReadable } from 'utils/billingUtil'; -import { fileIsHEIC } from 'utils/file'; +import { isFileHEIC } from 'utils/file'; import { FileTypeInfo } from './readFileService'; const MAX_THUMBNAIL_DIMENSION = 720; @@ -31,7 +31,7 @@ export async function generateThumbnail( let thumbnail: Uint8Array; try { if (fileTypeInfo.fileType === FILE_TYPE.IMAGE) { - const isHEIC = fileIsHEIC(fileTypeInfo.exactType); + const isHEIC = isFileHEIC(fileTypeInfo.exactType); canvas = await generateImageThumbnail(worker, file, isHEIC); } else { try { diff --git a/src/utils/file/index.ts b/src/utils/file/index.ts index d0fb842bc..ea911e08a 100644 --- a/src/utils/file/index.ts +++ b/src/utils/file/index.ts @@ -11,7 +11,7 @@ import { } from 'services/fileService'; import { decodeMotionPhoto } from 'services/motionPhotoService'; import { getMimeTypeFromBlob } from 'services/upload/readFileService'; -import DownloadManger from 'services/downloadManager'; +import DownloadManager from 'services/downloadManager'; import { logError } from 'utils/sentry'; import { User } from 'services/userService'; import CryptoWorker from 'utils/crypto'; @@ -40,18 +40,35 @@ export function downloadAsFile(filename: string, content: string) { a.remove(); } -export async function downloadFile(file) { +export async function downloadFile(file: File) { const a = document.createElement('a'); a.style.display = 'none'; - const fileURL = await DownloadManger.getFile(file); - let fileBlob = await (await fetch(fileURL)).blob(); - if (file.pubMagicMetadata?.data.editedTime) { + let fileURL = await DownloadManager.getCachedOriginalFile(file); + let tempURL; + if (!fileURL) { + tempURL = URL.createObjectURL( + await new Response(await DownloadManager.downloadFile(file)).blob() + ); + fileURL = tempURL; + } + const fileType = getFileExtension(file.metadata.title); + let tempEditedFileURL; + if ( + file.pubMagicMetadata?.data.editedTime && + (fileType === TYPE_JPEG || fileType === TYPE_JPG) + ) { + let fileBlob = await (await fetch(fileURL)).blob(); + fileBlob = await updateFileCreationDateInEXIF( fileBlob, new Date(file.pubMagicMetadata.data.editedTime / 1000) ); + tempEditedFileURL = URL.createObjectURL(fileBlob); + fileURL = tempEditedFileURL; } - a.href = URL.createObjectURL(fileBlob); + + a.href = fileURL; + if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { a.download = fileNameWithoutExtension(file.metadata.title) + '.zip'; } else { @@ -60,12 +77,15 @@ export async function downloadFile(file) { document.body.appendChild(a); a.click(); a.remove(); + tempURL && URL.revokeObjectURL(tempURL); + tempEditedFileURL && URL.revokeObjectURL(tempEditedFileURL); } -export function fileIsHEIC(mimeType: string) { +export function isFileHEIC(mimeType: string) { return ( - mimeType.toLowerCase().endsWith(TYPE_HEIC) || - mimeType.toLowerCase().endsWith(TYPE_HEIF) + mimeType && + (mimeType.toLowerCase().endsWith(TYPE_HEIC) || + mimeType.toLowerCase().endsWith(TYPE_HEIF)) ); } @@ -286,7 +306,7 @@ export async function convertForPreview(file: File, fileBlob: Blob) { const mimeType = (await getMimeTypeFromBlob(worker, fileBlob)) ?? typeFromExtension; - if (fileIsHEIC(mimeType)) { + if (isFileHEIC(mimeType)) { fileBlob = await worker.convertHEIC2JPEG(fileBlob); } return fileBlob; @@ -481,3 +501,26 @@ export function getNonTrashedUniqueUserFiles(files: File[]) { ) ); } + +export async function downloadFiles(files: File[]) { + for (const file of files) { + try { + await downloadFile(file); + } catch (e) { + logError(e, 'download fail for file'); + } + } +} + +export function needsConversionForPreview(file: File) { + const fileExtension = splitFilenameAndExtension(file.metadata.title)[1]; + if ( + file.metadata.fileType === FILE_TYPE.LIVE_PHOTO || + (file.metadata.fileType === FILE_TYPE.IMAGE && + isFileHEIC(fileExtension)) + ) { + return true; + } else { + return false; + } +}