diff --git a/configUtil.js b/configUtil.js index 4bab3d5f0..274c3d786 100644 --- a/configUtil.js +++ b/configUtil.js @@ -17,8 +17,10 @@ module.exports = { }, CSP_DIRECTIVES: { - 'default-src': "'none'", - 'img-src': "'self' blob:", + // self is safe enough + 'default-src': "'self'", + // data to allow two factor qr code + 'img-src': "'self' blob: data:", 'media-src': "'self' blob:", 'manifest-src': "'self'", 'style-src': "'self' 'unsafe-inline'", @@ -26,10 +28,13 @@ module.exports = { 'connect-src': "'self' https://*.ente.io http://localhost:8080 data: blob: https://ente-prod-eu.s3.eu-central-003.backblazeb2.com ", 'base-uri ': "'self'", + // to allow worker + 'child-src': "'self' blob:", + 'object-src': "'none'", 'frame-ancestors': " 'none'", 'form-action': "'none'", - 'report-uri': ' https://csp-reporter.ente.io', - 'report-to': ' https://csp-reporter.ente.io', + 'report-uri': ' https://csp-reporter.ente.io/local', + 'report-to': ' https://csp-reporter.ente.io/local', }, WORKBOX_CONFIG: { diff --git a/package.json b/package.json index 5a32eef78..746cf4ecc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bada-frame", - "version": "0.7.0", + "version": "0.8.0", "private": true, "scripts": { "dev": "next dev", diff --git a/public/_headers b/public/_headers index 18af99761..388d4038c 100644 --- a/public/_headers +++ b/public/_headers @@ -8,5 +8,5 @@ X-Frame-Options: deny X-XSS-Protection: 1; mode=block Referrer-Policy: same-origin - Content-Security-Policy-Report-Only: default-src 'none'; img-src 'self' blob:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self' 'unsafe-eval' blob:; manifest-src 'self'; connect-src 'self' https://*.ente.io data: blob: https://ente-prod-eu.s3.eu-central-003.backblazeb2.com ; base-uri 'self'; frame-ancestors 'none'; form-action 'none'; report-uri https://csp-reporter.ente.io; report-to https://csp-reporter.ente.io; + Content-Security-Policy-Report-Only: default-src 'self'; img-src 'self' blob: data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self' 'unsafe-eval' blob:; manifest-src 'self'; child-src 'self' blob:; object-src 'none'; connect-src 'self' https://*.ente.io data: blob: https://ente-prod-eu.s3.eu-central-003.backblazeb2.com ; base-uri 'self'; frame-ancestors 'none'; form-action 'none'; report-uri https://csp-reporter.ente.io; report-to https://csp-reporter.ente.io; diff --git a/src/components/LivePhotoBtn.tsx b/src/components/LivePhotoBtn.tsx new file mode 100644 index 000000000..bb1976db9 --- /dev/null +++ b/src/components/LivePhotoBtn.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +export const livePhotoBtnHTML = ( + + + + +); diff --git a/src/components/PhotoFrame.tsx b/src/components/PhotoFrame.tsx index e54d4938b..160b074cc 100644 --- a/src/components/PhotoFrame.tsx +++ b/src/components/PhotoFrame.tsx @@ -72,6 +72,11 @@ interface Props { enableDownload: boolean; } +type SourceURL = { + imageURL?: string; + videoURL?: string; +}; + const PhotoFrame = ({ files, setFiles, @@ -103,6 +108,7 @@ const PhotoFrame = ({ const filteredDataRef = useRef([]); const filteredData = filteredDataRef?.current ?? []; const router = useRouter(); + const [isSourceLoaded, setIsSourceLoaded] = useState(false); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Shift') { @@ -244,18 +250,15 @@ const PhotoFrame = ({ }, [open]); const updateURL = (index: number) => (url: string) => { - files[index] = { - ...files[index], - msrc: url, - src: files[index].src ? files[index].src : url, - w: window.innerWidth, - h: window.innerHeight, - }; - if ( - files[index].metadata.fileType === FILE_TYPE.VIDEO && - !files[index].html - ) { - files[index].html = ` + const updateFile = (file: EnteFile) => { + file = { + ...file, + msrc: url, + w: window.innerWidth, + h: window.innerHeight, + }; + if (file.metadata.fileType === FILE_TYPE.VIDEO && !file.html) { + file.html = `
@@ -263,46 +266,94 @@ const PhotoFrame = ({
`; - delete files[index].src; - } - if ( - files[index].metadata.fileType === FILE_TYPE.IMAGE && - !files[index].src - ) { - files[index].src = url; - } - setFiles(files); + } else if ( + file.metadata.fileType === FILE_TYPE.LIVE_PHOTO && + !file.html + ) { + file.html = ` +
+ +
+ Loading... +
+
+ `; + } else if ( + file.metadata.fileType === FILE_TYPE.IMAGE && + !file.src + ) { + file.src = url; + } + return file; + }; + setFiles((files) => { + files[index] = updateFile(files[index]); + return [...files]; + }); + return updateFile(files[index]); }; - const updateSrcURL = async (index: number, url: string) => { - files[index] = { - ...files[index], - w: window.innerWidth, - h: window.innerHeight, - }; - if (files[index].metadata.fileType === FILE_TYPE.VIDEO) { - if (await isPlaybackPossible(url)) { - files[index].html = ` - + const updateSrcURL = async (index: number, srcURL: SourceURL) => { + const { videoURL, imageURL } = srcURL; + const isPlayable = videoURL && (await isPlaybackPossible(videoURL)); + const updateFile = (file: EnteFile) => { + file = { + ...file, + w: window.innerWidth, + h: window.innerHeight, + }; + if (file.metadata.fileType === FILE_TYPE.VIDEO) { + if (isPlayable) { + file.html = ` + + `; + } else { + file.html = ` +
+ +
+ ${constants.VIDEO_PLAYBACK_FAILED_DOWNLOAD_INSTEAD} + Download +
+
`; - } else { - files[index].html = ` + } + } else if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { + if (isPlayable) { + file.html = ` +
+ + +
+ `; + } else { + file.html = `
- -
+ +
`; + } + } else { + file.src = imageURL; } - } else { - files[index].src = url; - } - setFiles(files); + return file; + }; + setFiles((files) => { + files[index] = updateFile(files[index]); + return [...files]; + }); + setIsSourceLoaded(true); + return updateFile(files[index]); }; const handleClose = (needUpdate) => { @@ -418,13 +469,13 @@ const PhotoFrame = ({ } galleryContext.thumbs.set(item.id, url); } - updateURL(item.dataIndex)(url); - item.msrc = url; - if (!item.src) { - item.src = url; - } - item.w = window.innerWidth; - item.h = window.innerHeight; + const newFile = updateURL(item.dataIndex)(url); + item.msrc = newFile.msrc; + item.html = newFile.html; + item.src = newFile.src; + item.w = newFile.w; + item.h = newFile.h; + try { instance.invalidateCurrItems(); instance.updateSize(true); @@ -438,29 +489,47 @@ const PhotoFrame = ({ if (!fetching[item.dataIndex]) { try { fetching[item.dataIndex] = true; - let url: string; + let urls: string[]; if (galleryContext.files.has(item.id)) { - url = galleryContext.files.get(item.id); + const mergedURL = galleryContext.files.get(item.id); + urls = mergedURL.split(','); } else { + galleryContext.startLoading(); if ( publicCollectionGalleryContext.accessedThroughSharedURL ) { - url = await PublicCollectionDownloadManager.getFile( + urls = await PublicCollectionDownloadManager.getFile( item, publicCollectionGalleryContext.token, publicCollectionGalleryContext.passwordToken, true ); } else { - url = await DownloadManager.getFile(item, true); + urls = await DownloadManager.getFile(item, true); } - galleryContext.files.set(item.id, url); + galleryContext.finishLoading(); + const mergedURL = urls.join(','); + galleryContext.files.set(item.id, mergedURL); } - await updateSrcURL(item.dataIndex, url); - item.html = files[item.dataIndex].html; - item.src = files[item.dataIndex].src; - item.w = files[item.dataIndex].w; - item.h = files[item.dataIndex].h; + let imageURL; + let videoURL; + if (item.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { + [imageURL, videoURL] = urls; + } else if (item.metadata.fileType === FILE_TYPE.VIDEO) { + [videoURL] = urls; + } else { + [imageURL] = urls; + } + setIsSourceLoaded(false); + const newFile = await updateSrcURL(item.dataIndex, { + imageURL, + videoURL, + }); + item.msrc = newFile.msrc; + item.html = newFile.html; + item.src = newFile.src; + item.w = newFile.w; + item.h = newFile.h; try { instance.invalidateCurrItems(); instance.updateSize(true); @@ -524,6 +593,7 @@ const PhotoFrame = ({ isSharedCollection={isSharedCollection} isTrashCollection={activeCollection === TRASH_SECTION} enableDownload={enableDownload} + isSourceLoaded={isSourceLoaded} /> )} diff --git a/src/components/PhotoSwipe/PhotoSwipe.tsx b/src/components/PhotoSwipe/PhotoSwipe.tsx index 8757378a3..f67ce1c6b 100644 --- a/src/components/PhotoSwipe/PhotoSwipe.tsx +++ b/src/components/PhotoSwipe/PhotoSwipe.tsx @@ -20,7 +20,6 @@ import { changeFileName, downloadFile, formatDateTime, - isLivePhoto, splitFilenameAndExtension, updateExistingFilePubMetadata, } from 'utils/file'; @@ -35,6 +34,7 @@ import { Row, Value, } from 'components/Container'; +import { livePhotoBtnHTML } from 'components/LivePhotoBtn'; import { logError } from 'utils/sentry'; import CloseIcon from 'components/icons/CloseIcon'; @@ -43,14 +43,11 @@ import { Formik } from 'formik'; import * as Yup from 'yup'; import EnteSpinner from 'components/EnteSpinner'; import EnteDateTimePicker from 'components/EnteDateTimePicker'; -import { MAX_EDITED_FILE_NAME_LENGTH } from 'constants/file'; +import { MAX_EDITED_FILE_NAME_LENGTH, FILE_TYPE } from 'constants/file'; import { sleep } from 'utils/common'; +import { playVideo, pauseVideo } from 'utils/photoFrame'; import { PublicCollectionGalleryContext } from 'utils/publicCollectionGallery'; import { GalleryContext } from 'pages/gallery'; -import { - getLivePhotoInfoShownCount, - setLivePhotoInfoShownCount, -} from 'utils/storage'; const SmallLoadingSpinner = () => ( (props.disabled ? 'not-allowed' : 'pointer')}; + } +`; + +const livePhotoDefaultOptions = { + click: () => {}, + hide: () => {}, + show: () => {}, + loading: false, + visible: false, +}; + const renderInfoItem = (label: string, value: string | JSX.Element) => ( @@ -490,13 +511,17 @@ function InfoModal({ function PhotoSwipe(props: Iprops) { const pswpElement = useRef(); - const [photoSwipe, setPhotoSwipe] = useState>(); + const [photoSwipe, setPhotoSwipe] = + useState>(); - const { isOpen, items } = props; + const { isOpen, items, isSourceLoaded } = props; const [isFav, setIsFav] = useState(false); const [showInfo, setShowInfo] = useState(false); const [metadata, setMetaData] = useState(null); const [exif, setExif] = useState(null); + const [livePhotoBtnOptions, setLivePhotoBtnOptions] = useState( + livePhotoDefaultOptions + ); const needUpdate = useRef(false); const publicCollectionGalleryContext = useContext( PublicCollectionGalleryContext @@ -527,23 +552,75 @@ function PhotoSwipe(props: Iprops) { } }, [showInfo]); + useEffect(() => { + if (!isOpen) return; + const item = items[photoSwipe?.getCurrentIndex()]; + if (item && item.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { + const getVideoAndImage = () => { + const video = document.getElementById( + `live-photo-video-${item.id}` + ); + const image = document.getElementById( + `live-photo-image-${item.id}` + ); + return { video, image }; + }; + + const { video, image } = getVideoAndImage(); + + if (video && image) { + setLivePhotoBtnOptions({ + click: async () => { + await playVideo(video, image); + }, + hide: async () => { + await pauseVideo(video, image); + }, + show: async () => { + await playVideo(video, image); + }, + visible: true, + loading: false, + }); + } else { + setLivePhotoBtnOptions({ + ...livePhotoDefaultOptions, + visible: true, + loading: true, + }); + } + + const downloadLivePhotoBtn = document.getElementById( + `download-btn-${item.id}` + ) as HTMLButtonElement; + if (downloadLivePhotoBtn) { + const downloadLivePhoto = () => { + downloadFileHelper(photoSwipe.currItem); + }; + + downloadLivePhotoBtn.addEventListener( + 'click', + downloadLivePhoto + ); + return () => { + downloadLivePhotoBtn.removeEventListener( + 'click', + downloadLivePhoto + ); + setLivePhotoBtnOptions(livePhotoDefaultOptions); + }; + } + + return () => { + setLivePhotoBtnOptions(livePhotoDefaultOptions); + }; + } + }, [photoSwipe?.currItem, isOpen, isSourceLoaded]); + function updateFavButton() { setIsFav(isInFav(this?.currItem)); } - function handleLivePhotoNotification() { - if (isLivePhoto(this?.currItem)) { - const infoShownCount = getLivePhotoInfoShownCount(); - if (infoShownCount < 3) { - galleryContext.setNotificationAttributes({ - message: constants.PLAYBACK_SUPPORT_COMING, - title: constants.LIVE_PHOTO, - }); - setLivePhotoInfoShownCount(infoShownCount + 1); - } - } - } - const openPhotoSwipe = () => { const { items, currentIndex } = props; const options = { @@ -606,7 +683,6 @@ function PhotoSwipe(props: Iprops) { photoSwipe.listen('beforeChange', function () { updateInfo.call(this); updateFavButton.call(this); - handleLivePhotoNotification.call(this); }); photoSwipe.listen('resize', checkExifAvailable); photoSwipe.init(); @@ -726,6 +802,18 @@ function PhotoSwipe(props: Iprops) { ref={pswpElement}>
+ + {livePhotoBtnHTML} {constants.LIVE} +
diff --git a/src/components/pages/gallery/PlanSelector.tsx b/src/components/pages/gallery/PlanSelector.tsx index 15533acfb..a5ad2c342 100644 --- a/src/components/pages/gallery/PlanSelector.tsx +++ b/src/components/pages/gallery/PlanSelector.tsx @@ -16,6 +16,8 @@ import { hasPaidSubscription, isOnFreePlan, planForSubscription, + hasMobileSubscription, + hasPaypalSubscription, } from 'utils/billing'; import { reverseString } from 'utils/common'; import { SetDialogMessage } from 'components/MessageDialog'; @@ -143,8 +145,7 @@ function PlanSelector(props: Props) { async function onPlanSelect(plan: Plan) { if ( - hasPaidSubscription(subscription) && - !hasStripeSubscription(subscription) && + hasMobileSubscription(subscription) && !isSubscriptionCancelled(subscription) ) { props.setDialogMessage({ @@ -152,6 +153,15 @@ function PlanSelector(props: Props) { content: constants.CANCEL_SUBSCRIPTION_ON_MOBILE, close: { variant: 'danger' }, }); + } else if ( + hasPaypalSubscription(subscription) && + !isSubscriptionCancelled(subscription) + ) { + props.setDialogMessage({ + title: constants.MANAGE_PLAN, + content: constants.PAYPAL_MANAGE_NOT_SUPPORTED_MESSAGE(), + close: { variant: 'danger' }, + }); } else if (hasStripeSubscription(subscription)) { props.setDialogMessage({ title: `${constants.CONFIRM} ${reverseString( diff --git a/src/components/pages/gallery/PreviewCard.tsx b/src/components/pages/gallery/PreviewCard.tsx index 1a4810528..51a100226 100644 --- a/src/components/pages/gallery/PreviewCard.tsx +++ b/src/components/pages/gallery/PreviewCard.tsx @@ -164,7 +164,7 @@ const Cont = styled.div<{ disabled: boolean; selected: boolean }>` export default function PreviewCard(props: IProps) { const [imgSrc, setImgSrc] = useState(); - const { thumbs, files } = useContext(GalleryContext); + const { thumbs } = useContext(GalleryContext); const { file, onClick, @@ -203,10 +203,6 @@ export default function PreviewCard(props: IProps) { if (isMounted.current) { setImgSrc(url); thumbs.set(file.id, url); - file.msrc = url; - if (!file.src) { - file.src = url; - } updateURL(url); } } catch (e) { @@ -218,13 +214,6 @@ export default function PreviewCard(props: IProps) { const thumbImgSrc = thumbs.get(file.id); setImgSrc(thumbImgSrc); file.msrc = thumbImgSrc; - if (!file.src) { - if (files.has(file.id)) { - file.src = files.get(file.id); - } else { - file.src = thumbImgSrc; - } - } } else { main(); } diff --git a/src/constants/upload/index.ts b/src/constants/upload/index.ts index 2109e566b..fb9f0a15d 100644 --- a/src/constants/upload/index.ts +++ b/src/constants/upload/index.ts @@ -1,6 +1,6 @@ import { ENCRYPTION_CHUNK_SIZE } from 'constants/crypto'; import { FILE_TYPE } from 'constants/file'; -import { Location } from 'types/upload'; +import { Location, ParsedExtractedMetadata } from 'types/upload'; // list of format that were missed by type-detection for some files. export const FORMAT_MISSED_BY_FILE_TYPE_LIB = [ @@ -43,3 +43,8 @@ export enum FileUploadResults { export const MAX_FILE_SIZE_SUPPORTED = 5 * 1024 * 1024 * 1024; // 5 GB export const LIVE_PHOTO_ASSET_SIZE_LIMIT = 20 * 1024 * 1024; // 20MB + +export const NULL_EXTRACTED_METADATA: ParsedExtractedMetadata = { + location: NULL_LOCATION, + creationTime: null, +}; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 031791c39..a9c8e7eae 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -69,6 +69,35 @@ const GlobalStyles = createGlobalStyle` height: 100%; } + .live-photo-container{ + width: 100%; + height: 100%; + position: relative; + object-fit: contain; + } + + .live-photo-container > img{ + opacity: 1; + max-width: 100%; + max-height: 100%; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + transition: opacity 1s ease; + } + + .live-photo-container > video{ + opacity: 0; + max-width: 100%; + max-height: 100%; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + transition: opacity 1s ease; + } + .video-loading { width: 100%; height: 100%; diff --git a/src/services/downloadManager.ts b/src/services/downloadManager.ts index 5752b1262..6f53f79ac 100644 --- a/src/services/downloadManager.ts +++ b/src/services/downloadManager.ts @@ -14,7 +14,7 @@ import { FILE_TYPE } from 'constants/file'; import { CustomError } from 'utils/error'; class DownloadManager { - private fileObjectURLPromise = new Map>(); + private fileObjectURLPromise = new Map>(); private thumbnailObjectURLPromise = new Map>(); public async getThumbnail(file: EnteFile) { @@ -90,11 +90,17 @@ class DownloadManager { try { const getFilePromise = async (convert: boolean) => { const fileStream = await this.downloadFile(file); - let fileBlob = await new Response(fileStream).blob(); + const fileBlob = await new Response(fileStream).blob(); if (convert) { - fileBlob = await convertForPreview(file, fileBlob); + const convertedBlobs = await convertForPreview( + file, + fileBlob + ); + return convertedBlobs.map((blob) => + URL.createObjectURL(blob) + ); } - return URL.createObjectURL(fileBlob); + return [URL.createObjectURL(fileBlob)]; }; if (!this.fileObjectURLPromise.get(fileKey)) { this.fileObjectURLPromise.set( @@ -102,8 +108,8 @@ class DownloadManager { getFilePromise(shouldBeConverted) ); } - const fileURL = await this.fileObjectURLPromise.get(fileKey); - return fileURL; + const fileURLs = await this.fileObjectURLPromise.get(fileKey); + return fileURLs; } catch (e) { this.fileObjectURLPromise.delete(fileKey); logError(e, 'Failed to get File'); diff --git a/src/services/ffmpegService.ts b/src/services/ffmpegService.ts index 3914cfea2..59a03d4b4 100644 --- a/src/services/ffmpegService.ts +++ b/src/services/ffmpegService.ts @@ -2,14 +2,17 @@ import { createFFmpeg, FFmpeg } from '@ffmpeg/ffmpeg'; import { CustomError } from 'utils/error'; import { logError } from 'utils/sentry'; import QueueProcessor from './queueProcessor'; +import { ParsedExtractedMetadata } from 'types/upload'; + import { getUint8ArrayView } from './upload/readFileService'; +import { parseFFmpegExtractedMetadata } from './upload/videoMetadataService'; class FFmpegService { private ffmpeg: FFmpeg = null; private isLoading = null; private fileReader: FileReader = null; - private generateThumbnailProcessor = new QueueProcessor(1); + private ffmpegTaskQueue = new QueueProcessor(1); async init() { try { this.ffmpeg = createFFmpeg({ @@ -26,7 +29,7 @@ class FFmpegService { } } - async generateThumbnail(file: File) { + async generateThumbnail(file: File): Promise { if (!this.ffmpeg) { await this.init(); } @@ -36,7 +39,7 @@ class FFmpegService { if (this.isLoading) { await this.isLoading; } - const response = this.generateThumbnailProcessor.queueUpRequest( + const response = this.ffmpegTaskQueue.queueUpRequest( generateThumbnailHelper.bind( null, this.ffmpeg, @@ -56,6 +59,65 @@ class FFmpegService { } } } + + async extractMetadata(file: File): Promise { + if (!this.ffmpeg) { + await this.init(); + } + if (!this.fileReader) { + this.fileReader = new FileReader(); + } + if (this.isLoading) { + await this.isLoading; + } + const response = this.ffmpegTaskQueue.queueUpRequest( + extractVideoMetadataHelper.bind( + null, + this.ffmpeg, + this.fileReader, + file + ) + ); + try { + return await response.promise; + } catch (e) { + if (e.message === CustomError.REQUEST_CANCELLED) { + // ignore + return null; + } else { + logError(e, 'ffmpeg metadata extraction failed'); + throw e; + } + } + } + + async convertToMP4( + file: Uint8Array, + fileName: string + ): Promise { + if (!this.ffmpeg) { + await this.init(); + } + if (this.isLoading) { + await this.isLoading; + } + + const response = this.ffmpegTaskQueue.queueUpRequest( + convertToMP4Helper.bind(null, this.ffmpeg, file, fileName) + ); + + try { + return await response.promise; + } catch (e) { + if (e.message === CustomError.REQUEST_CANCELLED) { + // ignore + return null; + } else { + logError(e, 'ffmpeg MP4 conversion failed'); + throw e; + } + } + } } async function generateThumbnailHelper( @@ -101,4 +163,68 @@ async function generateThumbnailHelper( } } +async function extractVideoMetadataHelper( + ffmpeg: FFmpeg, + reader: FileReader, + file: File +) { + try { + const inputFileName = `${Date.now().toString()}-${file.name}`; + const outFileName = `${Date.now().toString()}-metadata.txt`; + ffmpeg.FS( + 'writeFile', + inputFileName, + await getUint8ArrayView(reader, file) + ); + let metadata = null; + + // https://stackoverflow.com/questions/9464617/retrieving-and-saving-media-metadata-using-ffmpeg + // -c [short for codex] copy[(stream_specifier)[ffmpeg.org/ffmpeg.html#Stream-specifiers]] => copies all the stream without re-encoding + // -map_metadata [http://ffmpeg.org/ffmpeg.html#Advanced-options search for map_metadata] => copies all stream metadata to the out + // -f ffmetadata [https://ffmpeg.org/ffmpeg-formats.html#Metadata-1] => dump metadata from media files into a simple UTF-8-encoded INI-like text file + await ffmpeg.run( + '-i', + inputFileName, + '-c', + 'copy', + '-map_metadata', + '0', + '-f', + 'ffmetadata', + outFileName + ); + metadata = ffmpeg.FS('readFile', outFileName); + ffmpeg.FS('unlink', outFileName); + ffmpeg.FS('unlink', inputFileName); + return parseFFmpegExtractedMetadata(metadata); + } catch (e) { + logError(e, 'ffmpeg metadata extraction failed'); + throw e; + } +} + +async function convertToMP4Helper( + ffmpeg: FFmpeg, + file: Uint8Array, + inputFileName: string +) { + try { + ffmpeg.FS('writeFile', inputFileName, file); + await ffmpeg.run( + '-i', + inputFileName, + '-preset', + 'ultrafast', + 'output.mp4' + ); + const convertedFile = ffmpeg.FS('readFile', 'output.mp4'); + ffmpeg.FS('unlink', inputFileName); + ffmpeg.FS('unlink', 'output.mp4'); + return convertedFile; + } catch (e) { + logError(e, 'ffmpeg MP4 conversion failed'); + throw e; + } +} + export default new FFmpegService(); diff --git a/src/services/fileService.ts b/src/services/fileService.ts index 8d1ad4944..540f0dbfe 100644 --- a/src/services/fileService.ts +++ b/src/services/fileService.ts @@ -6,9 +6,15 @@ import { EncryptionResult } from 'types/upload'; import { Collection } from 'types/collection'; import HTTPService from './HTTPService'; import { logError } from 'utils/sentry'; -import { decryptFile, mergeMetadata, sortFiles } from 'utils/file'; +import { + decryptFile, + mergeMetadata, + preservePhotoswipeProps, + sortFiles, +} from 'utils/file'; import CryptoWorker from 'utils/crypto'; import { EnteFile, TrashRequest, UpdateMagicMetadataRequest } from 'types/file'; +import { SetFiles } from 'types/gallery'; const ENDPOINT = getEndpoint(); const FILES_TABLE = 'files'; @@ -28,13 +34,13 @@ const getCollectionLastSyncTime = async (collection: Collection) => export const syncFiles = async ( collections: Collection[], - setFiles: (files: EnteFile[]) => void + setFiles: SetFiles ) => { const localFiles = await getLocalFiles(); let files = await removeDeletedCollectionFiles(collections, localFiles); if (files.length !== localFiles.length) { await setLocalFiles(files); - setFiles([...sortFiles(mergeMetadata(files))]); + setFiles(preservePhotoswipeProps([...sortFiles(mergeMetadata(files))])); } for (const collection of collections) { if (!getToken()) { @@ -70,7 +76,7 @@ export const syncFiles = async ( `${collection.id}-time`, collection.updationTime ); - setFiles([...sortFiles(mergeMetadata(files))]); + setFiles(preservePhotoswipeProps([...sortFiles(mergeMetadata(files))])); } return sortFiles(mergeMetadata(files)); }; @@ -79,7 +85,7 @@ export const getFiles = async ( collection: Collection, sinceTime: number, files: EnteFile[], - setFiles: (files: EnteFile[]) => void + setFiles: SetFiles ): Promise => { try { const decryptedFiles: EnteFile[] = []; @@ -116,10 +122,12 @@ export const getFiles = async ( time = resp.data.diff.slice(-1)[0].updationTime; } setFiles( - sortFiles( - mergeMetadata( - [...(files || []), ...decryptedFiles].filter( - (item) => !item.isDeleted + preservePhotoswipeProps( + sortFiles( + mergeMetadata( + [...(files || []), ...decryptedFiles].filter( + (item) => !item.isDeleted + ) ) ) ) diff --git a/src/services/publicCollectionDownloadManager.ts b/src/services/publicCollectionDownloadManager.ts index 0326c9e7c..70a4c7b38 100644 --- a/src/services/publicCollectionDownloadManager.ts +++ b/src/services/publicCollectionDownloadManager.ts @@ -16,7 +16,7 @@ import { FILE_TYPE } from 'constants/file'; import { CustomError } from 'utils/error'; class PublicCollectionDownloadManager { - private fileObjectURLPromise = new Map>(); + private fileObjectURLPromise = new Map>(); private thumbnailObjectURLPromise = new Map>(); public async getThumbnail( @@ -116,11 +116,17 @@ class PublicCollectionDownloadManager { passwordToken, file ); - let fileBlob = await new Response(fileStream).blob(); + const fileBlob = await new Response(fileStream).blob(); if (convert) { - fileBlob = await convertForPreview(file, fileBlob); + const convertedBlobs = await convertForPreview( + file, + fileBlob + ); + return convertedBlobs.map((blob) => + URL.createObjectURL(blob) + ); } - return URL.createObjectURL(fileBlob); + return [URL.createObjectURL(fileBlob)]; }; if (!this.fileObjectURLPromise.get(fileKey)) { this.fileObjectURLPromise.set( @@ -128,8 +134,8 @@ class PublicCollectionDownloadManager { getFilePromise(shouldBeConverted) ); } - const fileURL = await this.fileObjectURLPromise.get(fileKey); - return fileURL; + const fileURLs = await this.fileObjectURLPromise.get(fileKey); + return fileURLs; } catch (e) { this.fileObjectURLPromise.delete(fileKey); logError(e, 'Failed to get File'); diff --git a/src/services/trashService.ts b/src/services/trashService.ts index c53413074..4fba7c05e 100644 --- a/src/services/trashService.ts +++ b/src/services/trashService.ts @@ -2,7 +2,12 @@ import { SetFiles } from 'types/gallery'; import { Collection } from 'types/collection'; import { getEndpoint } from 'utils/common/apiUtil'; import { getToken } from 'utils/common/key'; -import { decryptFile, mergeMetadata, sortFiles } from 'utils/file'; +import { + decryptFile, + mergeMetadata, + preservePhotoswipeProps, + sortFiles, +} from 'utils/file'; import { logError } from 'utils/sentry'; import localForage from 'utils/storage/localForage'; import { getCollection } from './collectionService'; @@ -120,7 +125,12 @@ export const updateTrash = async ( updatedTrash = removeRestoredOrDeletedTrashItems(updatedTrash); setFiles( - sortFiles([...(files ?? []), ...getTrashedFiles(updatedTrash)]) + preservePhotoswipeProps( + sortFiles([ + ...(files ?? []), + ...getTrashedFiles(updatedTrash), + ]) + ) ); await localForage.setItem(TRASH, updatedTrash); await localForage.setItem(TRASH_TIME, time); diff --git a/src/services/updateCreationTimeWithExif.ts b/src/services/updateCreationTimeWithExif.ts index e34f889c0..64b989fdc 100644 --- a/src/services/updateCreationTimeWithExif.ts +++ b/src/services/updateCreationTimeWithExif.ts @@ -10,9 +10,10 @@ import downloadManager from './downloadManager'; import { updatePublicMagicMetadata } from './fileService'; import { EnteFile } from 'types/file'; -import { getRawExif, getUNIXTime } from './upload/exifService'; +import { getRawExif } from './upload/exifService'; import { getFileType } from './upload/readFileService'; import { FILE_TYPE } from 'constants/file'; +import { getUnixTimeInMicroSeconds } from 'utils/time'; export async function updateCreationTimeWithExif( filesToBeUpdated: EnteFile[], @@ -33,19 +34,21 @@ export async function updateCreationTimeWithExif( } let correctCreationTime: number; if (fixOption === FIX_OPTIONS.CUSTOM_TIME) { - correctCreationTime = getUNIXTime(customTime); + correctCreationTime = getUnixTimeInMicroSeconds(customTime); } else { - const fileURL = await downloadManager.getFile(file); + const fileURL = await downloadManager.getFile(file)[0]; const fileObject = await getFileFromURL(fileURL); const reader = new FileReader(); const fileTypeInfo = await getFileType(reader, fileObject); const exifData = await getRawExif(fileObject, fileTypeInfo); if (fixOption === FIX_OPTIONS.DATE_TIME_ORIGINAL) { - correctCreationTime = getUNIXTime( + correctCreationTime = getUnixTimeInMicroSeconds( exifData?.DateTimeOriginal ); } else { - correctCreationTime = getUNIXTime(exifData?.CreateDate); + correctCreationTime = getUnixTimeInMicroSeconds( + exifData?.CreateDate + ); } } if ( diff --git a/src/services/upload/exifService.ts b/src/services/upload/exifService.ts index 685994235..ee81da515 100644 --- a/src/services/upload/exifService.ts +++ b/src/services/upload/exifService.ts @@ -1,9 +1,11 @@ -import { NULL_LOCATION } from 'constants/upload'; +import { NULL_EXTRACTED_METADATA, NULL_LOCATION } from 'constants/upload'; import { Location } from 'types/upload'; import exifr from 'exifr'; import piexif from 'piexifjs'; import { FileTypeInfo } from 'types/upload'; import { logError } from 'utils/sentry'; +import { ParsedExtractedMetadata } from 'types/upload'; +import { getUnixTimeInMicroSeconds } from 'utils/time'; const EXIF_TAGS_NEEDED = [ 'DateTimeOriginal', @@ -23,37 +25,29 @@ interface Exif { GPSLatitudeRef?: number; GPSLongitudeRef?: number; } -interface ParsedEXIFData { - location: Location; - creationTime: number; -} export async function getExifData( receivedFile: File, fileTypeInfo: FileTypeInfo -): Promise { - const nullExifData: ParsedEXIFData = { - location: NULL_LOCATION, - creationTime: null, - }; +): Promise { + let parsedEXIFData = NULL_EXTRACTED_METADATA; try { const exifData = await getRawExif(receivedFile, fileTypeInfo); if (!exifData) { - return nullExifData; + return parsedEXIFData; } - const parsedEXIFData = { + parsedEXIFData = { location: getEXIFLocation(exifData), - creationTime: getUNIXTime( + creationTime: getUnixTimeInMicroSeconds( exifData.DateTimeOriginal ?? exifData.CreateDate ?? exifData.ModifyDate ), }; - return parsedEXIFData; } catch (e) { logError(e, 'getExifData failed'); - return nullExifData; } + return parsedEXIFData; } export async function updateFileCreationDateInEXIF( @@ -131,22 +125,6 @@ export async function getRawExif( return exifData; } -export function getUNIXTime(dateTime: Date) { - try { - if (!dateTime) { - return null; - } - const unixTime = dateTime.getTime() * 1000; - if (unixTime <= 0) { - return null; - } else { - return unixTime; - } - } catch (e) { - logError(e, 'getUNIXTime failed', { dateTime }); - } -} - function getEXIFLocation(exifData): Location { if (!exifData.latitude || !exifData.longitude) { return NULL_LOCATION; diff --git a/src/services/upload/metadataService.ts b/src/services/upload/metadataService.ts index 89564aee5..51985e460 100644 --- a/src/services/upload/metadataService.ts +++ b/src/services/upload/metadataService.ts @@ -6,9 +6,11 @@ import { ParsedMetadataJSON, Location, FileTypeInfo, + ParsedExtractedMetadata, } from 'types/upload'; -import { NULL_LOCATION } from 'constants/upload'; +import { NULL_EXTRACTED_METADATA, NULL_LOCATION } from 'constants/upload'; import { splitFilenameAndExtension } from 'utils/file'; +import { getVideoMetadata } from './videoMetadataService'; interface ParsedMetadataJSONWithTitle { title: string; @@ -25,23 +27,25 @@ export async function extractMetadata( receivedFile: File, fileTypeInfo: FileTypeInfo ) { - let exifData = null; + let extractedMetadata: ParsedExtractedMetadata = NULL_EXTRACTED_METADATA; if (fileTypeInfo.fileType === FILE_TYPE.IMAGE) { - exifData = await getExifData(receivedFile, fileTypeInfo); + extractedMetadata = await getExifData(receivedFile, fileTypeInfo); + } else if (fileTypeInfo.fileType === FILE_TYPE.VIDEO) { + extractedMetadata = await getVideoMetadata(receivedFile); } - const extractedMetadata: Metadata = { + const metadata: Metadata = { title: `${splitFilenameAndExtension(receivedFile.name)[0]}.${ fileTypeInfo.exactType }`, creationTime: - exifData?.creationTime ?? receivedFile.lastModified * 1000, + extractedMetadata.creationTime ?? receivedFile.lastModified * 1000, modificationTime: receivedFile.lastModified * 1000, - latitude: exifData?.location?.latitude, - longitude: exifData?.location?.longitude, + latitude: extractedMetadata.location.latitude, + longitude: extractedMetadata.location.longitude, fileType: fileTypeInfo.fileType, }; - return extractedMetadata; + return metadata; } export const getMetadataJSONMapKey = ( diff --git a/src/services/upload/thumbnailService.ts b/src/services/upload/thumbnailService.ts index b6f1d3903..baa1a9c61 100644 --- a/src/services/upload/thumbnailService.ts +++ b/src/services/upload/thumbnailService.ts @@ -15,7 +15,7 @@ const MAX_THUMBNAIL_SIZE = 100 * 1024; const MIN_QUALITY = 0.5; const MAX_QUALITY = 0.7; -const WAIT_TIME_THUMBNAIL_GENERATION = 10 * 1000; +const WAIT_TIME_THUMBNAIL_GENERATION = 30 * 1000; interface Dimension { width: number; diff --git a/src/services/upload/uploadManager.ts b/src/services/upload/uploadManager.ts index eeeceda0f..e40e83fca 100644 --- a/src/services/upload/uploadManager.ts +++ b/src/services/upload/uploadManager.ts @@ -5,7 +5,7 @@ import { getDedicatedCryptoWorker } from 'utils/crypto'; import { sortFilesIntoCollections, sortFiles, - removeUnnecessaryFileProps, + preservePhotoswipeProps, } from 'utils/file'; import { logError } from 'utils/sentry'; import { getMetadataJSONMapKey, parseMetadataJSON } from './metadataService'; @@ -239,10 +239,8 @@ class UploadManager { if (fileUploadResult === FileUploadResults.UPLOADED) { this.existingFiles.push(file); this.existingFiles = sortFiles(this.existingFiles); - await setLocalFiles( - removeUnnecessaryFileProps(this.existingFiles) - ); - this.setFiles(this.existingFiles); + await setLocalFiles(this.existingFiles); + this.setFiles(preservePhotoswipeProps(this.existingFiles)); if (!this.existingFilesCollectionWise.has(file.collectionID)) { this.existingFilesCollectionWise.set(file.collectionID, []); } diff --git a/src/services/upload/videoMetadataService.ts b/src/services/upload/videoMetadataService.ts new file mode 100644 index 000000000..0a13a5dc9 --- /dev/null +++ b/src/services/upload/videoMetadataService.ts @@ -0,0 +1,76 @@ +import { NULL_EXTRACTED_METADATA, NULL_LOCATION } from 'constants/upload'; +import ffmpegService from 'services/ffmpegService'; +import { getUnixTimeInMicroSeconds } from 'utils/time'; +import { ParsedExtractedMetadata } from 'types/upload'; +import { logError } from 'utils/sentry'; + +enum VideoMetadata { + CREATION_TIME = 'creation_time', + APPLE_CONTENT_IDENTIFIER = 'com.apple.quicktime.content.identifier', + APPLE_LIVE_PHOTO_IDENTIFIER = 'com.apple.quicktime.live-photo.auto', + APPLE_CREATION_DATE = 'com.apple.quicktime.creationdate', + APPLE_LOCATION_ISO = 'com.apple.quicktime.location.ISO6709', + LOCATION = 'location', +} + +export async function getVideoMetadata(file: File) { + let videoMetadata = NULL_EXTRACTED_METADATA; + try { + videoMetadata = await ffmpegService.extractMetadata(file); + } catch (e) { + logError(e, 'failed to get video metadata'); + } + + return videoMetadata; +} + +export function parseFFmpegExtractedMetadata(encodedMetadata: Uint8Array) { + const metadataString = new TextDecoder().decode(encodedMetadata); + const metadataPropertyArray = metadataString.split('\n'); + const metadataKeyValueArray = metadataPropertyArray.map((property) => + property.split('=') + ); + const validKeyValuePairs = metadataKeyValueArray.filter( + (keyValueArray) => keyValueArray.length === 2 + ) as Array<[string, string]>; + + const metadataMap = Object.fromEntries(validKeyValuePairs); + + const location = parseAppleISOLocation( + metadataMap[VideoMetadata.APPLE_LOCATION_ISO] ?? + metadataMap[VideoMetadata.LOCATION] + ); + + const creationTime = parseCreationTime( + metadataMap[VideoMetadata.APPLE_CREATION_DATE] ?? + metadataMap[VideoMetadata.CREATION_TIME] + ); + const parsedMetadata: ParsedExtractedMetadata = { + creationTime, + location: { + latitude: location.latitude, + longitude: location.longitude, + }, + }; + return parsedMetadata; +} + +function parseAppleISOLocation(isoLocation: string) { + let location = NULL_LOCATION; + if (isoLocation) { + const [latitude, longitude] = isoLocation + .match(/(\+|-)\d+\.*\d+/g) + .map((x) => parseFloat(x)); + + location = { latitude, longitude }; + } + return location; +} + +function parseCreationTime(creationTime: string) { + let dateTime = null; + if (creationTime) { + dateTime = getUnixTimeInMicroSeconds(new Date(creationTime)); + } + return dateTime; +} diff --git a/src/types/upload/index.ts b/src/types/upload/index.ts index a49facddf..35a4692a6 100644 --- a/src/types/upload/index.ts +++ b/src/types/upload/index.ts @@ -130,3 +130,8 @@ export interface UploadFile extends BackupedFile { encryptedKey: string; keyDecryptionNonce: string; } + +export interface ParsedExtractedMetadata { + location: Location; + creationTime: number; +} diff --git a/src/utils/billing/index.ts b/src/utils/billing/index.ts index e8043d61d..b696665b7 100644 --- a/src/utils/billing/index.ts +++ b/src/utils/billing/index.ts @@ -9,6 +9,9 @@ import { CustomError } from '../error'; import { logError } from '../sentry'; const PAYMENT_PROVIDER_STRIPE = 'stripe'; +const PAYMENT_PROVIDER_APPSTORE = 'appstore'; +const PAYMENT_PROVIDER_PLAYSTORE = 'playstore'; +const PAYMENT_PROVIDER_PAYPAL = 'paypal'; const FREE_PLAN = 'free'; enum FAILURE_REASON { @@ -96,6 +99,23 @@ export function hasStripeSubscription(subscription: Subscription) { ); } +export function hasMobileSubscription(subscription: Subscription) { + return ( + hasPaidSubscription(subscription) && + subscription.paymentProvider.length > 0 && + (subscription.paymentProvider === PAYMENT_PROVIDER_APPSTORE || + subscription.paymentProvider === PAYMENT_PROVIDER_PLAYSTORE) + ); +} + +export function hasPaypalSubscription(subscription: Subscription) { + return ( + hasPaidSubscription(subscription) && + subscription.paymentProvider.length > 0 && + subscription.paymentProvider === PAYMENT_PROVIDER_PAYPAL + ); +} + export async function updateSubscription( plan: Plan, setDialogMessage: SetDialogMessage, diff --git a/src/utils/file/index.ts b/src/utils/file/index.ts index 9666a9c45..5ad062ed8 100644 --- a/src/utils/file/index.ts +++ b/src/utils/file/index.ts @@ -24,6 +24,7 @@ import { } from 'constants/file'; import PublicCollectionDownloadManager from 'services/publicCollectionDownloadManager'; import HEICConverter from 'services/HEICConverter'; +import ffmpegService from 'services/ffmpegService'; export function downloadAsFile(filename: string, content: string) { const file = new Blob([content], { @@ -52,7 +53,7 @@ export async function downloadFile( if (accessedThroughSharedURL) { fileURL = await PublicCollectionDownloadManager.getCachedOriginalFile( file - ); + )[0]; tempURL; if (!fileURL) { tempURL = URL.createObjectURL( @@ -68,7 +69,7 @@ export async function downloadFile( fileURL = tempURL; } } else { - fileURL = await DownloadManager.getCachedOriginalFile(file); + fileURL = await DownloadManager.getCachedOriginalFile(file)[0]; if (!fileURL) { tempURL = URL.createObjectURL( await new Response( @@ -281,20 +282,18 @@ export async function decryptFile(file: EnteFile, collectionKey: string) { } } -export function removeUnnecessaryFileProps(files: EnteFile[]): EnteFile[] { - const stripedFiles = files.map((file) => { - delete file.src; - delete file.msrc; - delete file.file.objectKey; - delete file.thumbnail.objectKey; - delete file.h; - delete file.html; - delete file.w; - - return file; - }); - return stripedFiles; -} +export const preservePhotoswipeProps = + (newFiles: EnteFile[]) => + (currentFiles: EnteFile[]): EnteFile[] => { + const currentFilesMap = Object.fromEntries( + currentFiles.map((file) => [file.id, file]) + ); + const fileWithPreservedProperty = newFiles.map((file) => { + const currentFile = currentFilesMap[file.id]; + return { ...currentFile, ...file }; + }); + return fileWithPreservedProperty; + }; export function fileNameWithoutExtension(filename) { const lastDotPosition = filename.lastIndexOf('.'); @@ -331,23 +330,42 @@ export function generateStreamFromArrayBuffer(data: Uint8Array) { }); } -export async function convertForPreview(file: EnteFile, fileBlob: Blob) { +export async function convertForPreview( + file: EnteFile, + fileBlob: Blob +): Promise { + const convertIfHEIC = async (fileName: string, fileBlob: Blob) => { + const typeFromExtension = getFileExtension(fileName); + const reader = new FileReader(); + const mimeType = + (await getFileTypeFromBlob(reader, fileBlob))?.mime ?? + typeFromExtension; + if (isFileHEIC(mimeType)) { + fileBlob = await HEICConverter.convert(fileBlob); + } + return fileBlob; + }; + if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { const originalName = fileNameWithoutExtension(file.metadata.title); const motionPhoto = await decodeMotionPhoto(fileBlob, originalName); - fileBlob = new Blob([motionPhoto.image]); + let image = new Blob([motionPhoto.image]); + + // can run conversion in parellel as video and image + // have different processes + const convertedVideo = ffmpegService.convertToMP4( + motionPhoto.video, + motionPhoto.videoNameTitle + ); + + image = await convertIfHEIC(motionPhoto.imageNameTitle, image); + const video = new Blob([await convertedVideo]); + + return [image, video]; } - const typeFromExtension = getFileExtension(file.metadata.title); - const reader = new FileReader(); - - const mimeType = - (await getFileTypeFromBlob(reader, fileBlob))?.mime ?? - typeFromExtension; - if (isFileHEIC(mimeType)) { - fileBlob = await HEICConverter.convert(fileBlob); - } - return fileBlob; + fileBlob = await convertIfHEIC(file.metadata.title, fileBlob); + return [fileBlob]; } export function fileIsArchived(file: EnteFile) { diff --git a/src/utils/photoFrame/index.ts b/src/utils/photoFrame/index.ts index fdaeb8557..fe5161440 100644 --- a/src/utils/photoFrame/index.ts +++ b/src/utils/photoFrame/index.ts @@ -13,3 +13,22 @@ export async function isPlaybackPossible(url: string): Promise { video.src = url; }); } + +export async function playVideo(livePhotoVideo, livePhotoImage) { + const videoPlaying = !livePhotoVideo.paused; + if (videoPlaying) return; + livePhotoVideo.style.opacity = 1; + livePhotoImage.style.opacity = 0; + livePhotoVideo.load(); + livePhotoVideo.play().catch(() => { + pauseVideo(livePhotoVideo, livePhotoImage); + }); +} + +export async function pauseVideo(livePhotoVideo, livePhotoImage) { + const videoPlaying = !livePhotoVideo.paused; + if (!videoPlaying) return; + livePhotoVideo.pause(); + livePhotoVideo.style.opacity = 0; + livePhotoImage.style.opacity = 1; +} diff --git a/src/utils/strings/englishConstants.tsx b/src/utils/strings/englishConstants.tsx index 2096c570b..40d2fe5a0 100644 --- a/src/utils/strings/englishConstants.tsx +++ b/src/utils/strings/englishConstants.tsx @@ -104,7 +104,7 @@ const englishConstants = { UPLOAD: { 0: 'preparing to upload', 1: 'reading google metadata files', - 2: 'reading file metadata to organize file', + 2: 'reading file metadata', 3: (fileCounter) => `${fileCounter.finished} / ${fileCounter.total} files backed up`, 4: 'backup complete', @@ -332,6 +332,14 @@ const englishConstants = { SUBSCRIPTION_PURCHASE_SUCCESS_TITLE: 'thank you', CANCEL_SUBSCRIPTION_ON_MOBILE: 'please cancel your subscription from the mobile app to activate a subscription here', + PAYPAL_MANAGE_NOT_SUPPORTED_MESSAGE: () => ( + <> + please contact us at{' '} + paypal@ente.io to manage your + subscription + + ), + PAYPAL_MANAGE_NOT_SUPPORTED: 'manage paypal plan', RENAME: 'rename', RENAME_COLLECTION: 'rename album', CONFIRM_DELETE_COLLECTION: 'confirm album deletion', @@ -674,6 +682,7 @@ const englishConstants = { ENTE_IO: 'ente.io', PLAYBACK_SUPPORT_COMING: 'playback support coming soon...', LIVE_PHOTO: 'this is a live photo', + LIVE: 'LIVE', }; export default englishConstants; diff --git a/src/utils/time/index.ts b/src/utils/time/index.ts index 320adab8f..65e15fbce 100644 --- a/src/utils/time/index.ts +++ b/src/utils/time/index.ts @@ -1,7 +1,11 @@ -export function getUTCMicroSecondsSinceEpoch(): number { - const now = new Date(); - const utcMilllisecondsSinceEpoch = - now.getTime() + now.getTimezoneOffset() * 60 * 1000; - const utcSecondsSinceEpoch = Math.round(utcMilllisecondsSinceEpoch * 1000); - return utcSecondsSinceEpoch; +export function getUnixTimeInMicroSeconds(dateTime: Date) { + if (!dateTime || isNaN(dateTime.getTime())) { + return null; + } + const unixTime = dateTime.getTime() * 1000; + if (unixTime <= 0) { + return null; + } else { + return unixTime; + } }