diff --git a/src/components/FixLargeThumbnail.tsx b/src/components/FixLargeThumbnail.tsx new file mode 100644 index 000000000..12de7731b --- /dev/null +++ b/src/components/FixLargeThumbnail.tsx @@ -0,0 +1,220 @@ +import constants from 'utils/strings/constants'; +import MessageDialog from './MessageDialog'; +import React, { useEffect, useState } from 'react'; +import { ProgressBar, Button } from 'react-bootstrap'; +import { ComfySpan } from './ExportInProgress'; +import { + getLargeThumbnailFiles, + replaceThumbnail, +} from 'services/migrateThumbnailService'; +import { getData, LS_KEYS, setData } from 'utils/storage/localStorage'; +import { logError } from 'utils/sentry'; + +export type SetProgressTracker = React.Dispatch< + React.SetStateAction<{ + current: number; + total: number; + }> +>; +interface Props { + isOpen: boolean; + show: () => void; + hide: () => void; +} +export enum FIX_STATE { + NOT_STARTED, + FIX_LATER, + RUNNING, + COMPLETED, + COMPLETED_WITH_ERRORS, +} +function Message(props: { fixState: FIX_STATE }) { + let message = null; + switch (props.fixState) { + case FIX_STATE.NOT_STARTED: + case FIX_STATE.FIX_LATER: + message = constants.REPLACE_THUMBNAIL_NOT_STARTED(); + break; + case FIX_STATE.COMPLETED: + message = constants.REPLACE_THUMBNAIL_COMPLETED(); + break; + case FIX_STATE.COMPLETED_WITH_ERRORS: + message = constants.REPLACE_THUMBNAIL_COMPLETED_WITH_ERROR(); + break; + } + return message ? ( +
{message}
+ ) : ( + <> + ); +} +export default function FixLargeThumbnails(props: Props) { + const [fixState, setFixState] = useState(FIX_STATE.NOT_STARTED); + const [progressTracker, setProgressTracker] = useState({ + current: 0, + total: 0, + }); + const [largeThumbnailFiles, setLargeThumbnailFiles] = useState( + [] + ); + + const init = (): FIX_STATE => { + let fixState = getData(LS_KEYS.THUMBNAIL_FIX_STATE)?.state; + if (!fixState || fixState === FIX_STATE.RUNNING) { + fixState = FIX_STATE.NOT_STARTED; + updateFixState(fixState); + } + setFixState(fixState); + return fixState; + }; + + const fetchLargeThumbnail = async () => { + const largeThumbnailFiles = (await getLargeThumbnailFiles()) ?? []; + setLargeThumbnailFiles(largeThumbnailFiles); + return largeThumbnailFiles; + }; + + const main = async () => { + const largeThumbnailFiles = await fetchLargeThumbnail(); + if ( + fixState === FIX_STATE.NOT_STARTED && + largeThumbnailFiles.length > 0 + ) { + props.show(); + } + if ( + fixState === FIX_STATE.COMPLETED && + largeThumbnailFiles.length > 0 + ) { + updateFixState(FIX_STATE.NOT_STARTED); + logError(Error(), 'large thumbnail files left after migration'); + } + if (largeThumbnailFiles.length === 0) { + updateFixState(FIX_STATE.COMPLETED); + } + }; + useEffect(() => { + if (props.isOpen && fixState !== FIX_STATE.RUNNING) { + main(); + } + }, [props.isOpen]); + + useEffect(() => { + const fixState = init(); + if (fixState === FIX_STATE.NOT_STARTED) { + main(); + } + }, []); + const startFix = async (newlyFetchedLargeThumbnailFiles?: number[]) => { + updateFixState(FIX_STATE.RUNNING); + const completedWithError = await replaceThumbnail( + setProgressTracker, + new Set( + newlyFetchedLargeThumbnailFiles ?? largeThumbnailFiles ?? [] + ) + ); + if (typeof completedWithError !== 'undefined') { + updateFixState( + completedWithError + ? FIX_STATE.COMPLETED_WITH_ERRORS + : FIX_STATE.COMPLETED + ); + } + await fetchLargeThumbnail(); + }; + + const updateFixState = (fixState: FIX_STATE) => { + setFixState(fixState); + setData(LS_KEYS.THUMBNAIL_FIX_STATE, { state: fixState }); + }; + return ( + +
+ + + {fixState === FIX_STATE.RUNNING && ( + <> +
+ + {' '} + {progressTracker.current} /{' '} + {progressTracker.total}{' '} + {' '} + + {' '} + {constants.THUMBNAIL_REPLACED} + +
+
+ +
+ + )} +
+ {fixState === FIX_STATE.NOT_STARTED ? ( + + ) : ( + + )} + {(fixState === FIX_STATE.NOT_STARTED || + fixState === FIX_STATE.FIX_LATER || + fixState === FIX_STATE.COMPLETED_WITH_ERRORS) && ( + <> +
+ + + + )} +
+
+ + ); +} diff --git a/src/components/PhotoFrame.tsx b/src/components/PhotoFrame.tsx index fb67aa03f..17de5d863 100644 --- a/src/components/PhotoFrame.tsx +++ b/src/components/PhotoFrame.tsx @@ -18,7 +18,6 @@ import { VariableSizeList as List } from 'react-window'; import PhotoSwipe from 'components/PhotoSwipe/PhotoSwipe'; import { isInsideBox, isSameDay as isSameDayAnyYear } from 'utils/search'; import { SetDialogMessage } from './MessageDialog'; -import { CustomError } from 'utils/common/errorUtil'; import { GAP_BTW_TILES, DATE_CONTAINER_HEIGHT, @@ -34,10 +33,10 @@ import { TRASH_SECTION, } from './pages/gallery/Collections'; import { isSharedFile } from 'utils/file'; +import { isPlaybackPossible } from 'utils/photoFrame'; const NO_OF_PAGES = 2; const A_DAY = 24 * 60 * 60 * 1000; -const WAIT_FOR_VIDEO_PLAYBACK = 1 * 1000; interface TimeStampListItem { itemType: ITEM_TYPE; @@ -150,7 +149,7 @@ interface Props { isFirstLoad; openFileUploader; loadingBar; - searchMode: boolean; + isInSearchMode: boolean; search: Search; setSearchStats: setSearchStats; deleted?: number[]; @@ -169,11 +168,10 @@ const PhotoFrame = ({ isFirstLoad, openFileUploader, loadingBar, - searchMode, + isInSearchMode, search, setSearchStats, deleted, - setDialogMessage, activeCollection, isSharedCollection, }: Props) => { @@ -185,12 +183,20 @@ const PhotoFrame = ({ const listRef = useRef(null); useEffect(() => { - if (searchMode) { + if (isInSearchMode) { setSearchStats({ resultCount: filteredData.length, timeTaken: (Date.now() - startTime) / 1000, }); } + if (search.fileIndex || search.fileIndex === 0) { + const filteredDataIdx = filteredData.findIndex( + (data) => data.dataIndex === search.fileIndex + ); + if (filteredDataIdx || filteredDataIdx === 0) { + onThumbnailClick(filteredDataIdx)(); + } + } }, [search]); useEffect(() => { @@ -229,21 +235,33 @@ const PhotoFrame = ({ setFiles(files); }; - const updateSrcUrl = (index: number, url: string) => { + const updateSrcUrl = async (index: number, url: string) => { files[index] = { ...files[index], - src: url, w: window.innerWidth, h: window.innerHeight, }; if (files[index].metadata.fileType === FILE_TYPE.VIDEO) { - files[index].html = ` + if (await isPlaybackPossible(url)) { + files[index].html = ` `; - delete files[index].src; + } else { + files[index].html = ` +
+ +
+ ${constants.VIDEO_PLAYBACK_FAILED_DOWNLOAD_INSTEAD} + Download +
+
+ `; + } + } else { + files[index].src = url; } setFiles(files); }; @@ -287,101 +305,56 @@ const PhotoFrame = ({ const getSlideData = async (instance: any, index: number, item: File) => { if (!item.msrc) { - let url: string; - if (galleryContext.thumbs.has(item.id)) { - url = galleryContext.thumbs.get(item.id); - } else { - url = await DownloadManager.getPreview(item); - 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; try { - instance.invalidateCurrItems(); - instance.updateSize(true); + let url: string; + if (galleryContext.thumbs.has(item.id)) { + url = galleryContext.thumbs.get(item.id); + } else { + url = await DownloadManager.getPreview(item); + 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; + try { + instance.invalidateCurrItems(); + instance.updateSize(true); + } catch (e) { + // ignore + } } catch (e) { - // ignore + // no-op } } if (!fetching[item.dataIndex]) { - fetching[item.dataIndex] = true; - let url: string; - if (galleryContext.files.has(item.id)) { - url = galleryContext.files.get(item.id); - } else { - url = await DownloadManager.getFile(item, true); - galleryContext.files.set(item.id, url); - } - updateSrcUrl(item.dataIndex, url); - if (item.metadata.fileType === FILE_TYPE.VIDEO) { - try { - await new Promise((resolve, reject) => { - const video = document.createElement('video'); - video.addEventListener('timeupdate', function () { - clearTimeout(t); - resolve(null); - }); - video.preload = 'metadata'; - video.src = url; - video.currentTime = 3; - const t = setTimeout(() => { - reject( - Error( - `${CustomError.VIDEO_PLAYBACK_FAILED} err: wait time exceeded` - ) - ); - }, WAIT_FOR_VIDEO_PLAYBACK); - }); - item.html = ` - - `; - delete item.src; - } catch (e) { - const downloadFile = async () => { - const a = document.createElement('a'); - a.style.display = 'none'; - a.href = url; - a.download = item.metadata.title; - document.body.appendChild(a); - a.click(); - a.remove(); - setOpen(false); - }; - setDialogMessage({ - title: constants.VIDEO_PLAYBACK_FAILED, - content: - constants.VIDEO_PLAYBACK_FAILED_DOWNLOAD_INSTEAD, - staticBackdrop: true, - proceed: { - text: constants.DOWNLOAD, - action: downloadFile, - variant: 'success', - }, - close: { - text: constants.CLOSE, - action: () => setOpen(false), - }, - }); - return; - } - } else { - item.src = url; - } - item.w = window.innerWidth; - item.h = window.innerHeight; try { - instance.invalidateCurrItems(); - instance.updateSize(true); + fetching[item.dataIndex] = true; + let url: string; + if (galleryContext.files.has(item.id)) { + url = galleryContext.files.get(item.id); + } else { + url = await DownloadManager.getFile(item, true); + galleryContext.files.set(item.id, url); + } + 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; + try { + instance.invalidateCurrItems(); + instance.updateSize(true); + } catch (e) { + // ignore + } } catch (e) { - // ignore + // no-op + } finally { + fetching[item.dataIndex] = false; } } }; @@ -531,7 +504,7 @@ const PhotoFrame = ({ return ( <> - {!isFirstLoad && files.length === 0 && !searchMode ? ( + {!isFirstLoad && files.length === 0 && !isInSearchMode ? (
@@ -651,7 +624,7 @@ const PhotoFrame = ({ return sum; })(); files.length < 30 && - !searchMode && + !isInSearchMode && timeStampList.push({ itemType: ITEM_TYPE.BANNER, banner: ( diff --git a/src/components/PhotoSwipe/PhotoSwipe.tsx b/src/components/PhotoSwipe/PhotoSwipe.tsx index cffd5b679..7bcb3ad55 100644 --- a/src/components/PhotoSwipe/PhotoSwipe.tsx +++ b/src/components/PhotoSwipe/PhotoSwipe.tsx @@ -7,16 +7,15 @@ import { addToFavorites, removeFromFavorites, } from 'services/collectionService'; -import { File, FILE_TYPE } from 'services/fileService'; +import { File } from 'services/fileService'; import constants from 'utils/strings/constants'; -import DownloadManger from 'services/downloadManager'; import exifr from 'exifr'; import Modal from 'react-bootstrap/Modal'; import Button from 'react-bootstrap/Button'; import Form from 'react-bootstrap/Form'; import styled from 'styled-components'; import events from './events'; -import { fileNameWithoutExtension, formatDateTime } from 'utils/file'; +import { downloadFile, formatDateTime } from 'utils/file'; import { FormCheck } from 'react-bootstrap'; import { prettyPrintExif } from 'utils/exif'; @@ -297,21 +296,11 @@ function PhotoSwipe(props: Iprops) { setShowInfo(true); }; - const downloadFile = async (file) => { + const downloadFileHelper = async (file) => { const { loadingBar } = props; - const a = document.createElement('a'); - a.style.display = 'none'; loadingBar.current.continuousStart(); - a.href = await DownloadManger.getFile(file); + await downloadFile(file); loadingBar.current.complete(); - if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { - a.download = fileNameWithoutExtension(file.metadata.title) + '.zip'; - } else { - a.download = file.metadata.title; - } - document.body.appendChild(a); - a.click(); - a.remove(); }; const { id } = props; let { className } = props; @@ -345,7 +334,7 @@ function PhotoSwipe(props: Iprops) { className="pswp-custom download-btn" title={constants.DOWNLOAD} onClick={() => - downloadFile(photoSwipe.currItem) + downloadFileHelper(photoSwipe.currItem) } /> diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index e854e2639..2463ffa75 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -10,6 +10,7 @@ import { getYearSuggestion, parseHumanDate, searchCollection, + searchFiles, searchLocation, } from 'services/searchService'; import { getFormattedDate } from 'utils/search'; @@ -20,6 +21,9 @@ import SearchIcon from './icons/SearchIcon'; import CrossIcon from './icons/CrossIcon'; import { Collection } from 'services/collectionService'; import CollectionIcon from './icons/CollectionIcon'; +import { File, FILE_TYPE } from 'services/fileService'; +import ImageIcon from './icons/ImageIcon'; +import VideoIcon from './icons/VideoIcon'; const Wrapper = styled.div<{ isDisabled: boolean; isOpen: boolean }>` position: fixed; @@ -74,6 +78,8 @@ export enum SuggestionType { DATE, LOCATION, COLLECTION, + IMAGE, + VIDEO, } export interface DateValue { date?: number; @@ -94,6 +100,7 @@ interface Props { searchStats: SearchStats; collections: Collection[]; setActiveCollection: (id: number) => void; + files: File[]; } export default function SearchBar(props: Props) { const [value, setValue] = useState(null); @@ -112,14 +119,14 @@ export default function SearchBar(props: Props) { if (!searchPhrase?.length) { return []; } - const option = [ + const options = [ ...getHolidaySuggestion(searchPhrase), ...getYearSuggestion(searchPhrase), ]; const searchedDates = parseHumanDate(searchPhrase); - option.push( + options.push( ...searchedDates.map((searchedDate) => ({ type: SuggestionType.DATE, value: searchedDate, @@ -131,7 +138,7 @@ export default function SearchBar(props: Props) { searchPhrase, props.collections ); - option.push( + options.push( ...collectionResults.map( (searchResult) => ({ @@ -141,8 +148,20 @@ export default function SearchBar(props: Props) { } as Suggestion) ) ); + const fileResults = searchFiles(searchPhrase, props.files); + options.push( + ...fileResults.map((file) => ({ + type: + file.type === FILE_TYPE.IMAGE + ? SuggestionType.IMAGE + : SuggestionType.VIDEO, + value: file.index, + label: file.title, + })) + ); + const locationResults = await searchLocation(searchPhrase); - option.push( + options.push( ...locationResults.map( (searchResult) => ({ @@ -152,7 +171,7 @@ export default function SearchBar(props: Props) { } as Suggestion) ) ); - return option; + return options; }; const getOptions = debounce(getAutoCompleteSuggestions, 250); @@ -161,7 +180,6 @@ export default function SearchBar(props: Props) { if (!selectedOption) { return; } - switch (selectedOption.type) { case SuggestionType.DATE: props.setSearch({ @@ -177,12 +195,17 @@ export default function SearchBar(props: Props) { break; case SuggestionType.COLLECTION: props.setActiveCollection(selectedOption.value as number); - resetSearch(true); + setValue(null); + break; + case SuggestionType.IMAGE: + case SuggestionType.VIDEO: + props.setSearch({ fileIndex: selectedOption.value as number }); + setValue(null); break; } }; - const resetSearch = async (force?: boolean) => { - if (props.isOpen || force) { + const resetSearch = () => { + if (props.isOpen) { props.loadingBar.current?.continuousStart(); props.setSearch({}); setTimeout(() => { @@ -205,6 +228,10 @@ export default function SearchBar(props: Props) { return ; case SuggestionType.COLLECTION: return ; + case SuggestionType.IMAGE: + return ; + case SuggestionType.VIDEO: + return ; default: return ; } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index fb38ba5e0..8d7358048 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -36,6 +36,7 @@ import { ARCHIVE_SECTION, TRASH_SECTION, } from 'components/pages/gallery/Collections'; +import FixLargeThumbnails from './FixLargeThumbnail'; interface Props { collections: Collection[]; setDialogMessage: SetDialogMessage; @@ -53,6 +54,7 @@ export default function Sidebar(props: Props) { const [recoverModalView, setRecoveryModalView] = useState(false); const [twoFactorModalView, setTwoFactorModalView] = useState(false); const [exportModalView, setExportModalView] = useState(false); + const [fixLargeThumbsView, setFixLargeThumbsView] = useState(false); const galleryContext = useContext(GalleryContext); useEffect(() => { const main = async () => { @@ -278,6 +280,18 @@ export default function Sidebar(props: Props) { {constants.UPDATE_EMAIL} + <> + setFixLargeThumbsView(false)} + show={() => setFixLargeThumbsView(true)} + /> + setFixLargeThumbsView(true)}> + {constants.FIX_LARGE_THUMBNAILS} + + diff --git a/src/components/icons/ImageIcon.tsx b/src/components/icons/ImageIcon.tsx new file mode 100644 index 000000000..6e2d6f8de --- /dev/null +++ b/src/components/icons/ImageIcon.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +export default function ImageIcon(props) { + return ( + + + + + ); +} + +ImageIcon.defaultProps = { + height: 24, + width: 24, + viewBox: '0 0 24 24', +}; diff --git a/src/components/icons/VideoIcon.tsx b/src/components/icons/VideoIcon.tsx new file mode 100644 index 000000000..510367881 --- /dev/null +++ b/src/components/icons/VideoIcon.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +export default function VideoIcon(props) { + return ( + + + + ); +} + +VideoIcon.defaultProps = { + height: 24, + width: 24, + viewBox: '0 0 24 24', +}; diff --git a/src/components/pages/gallery/Collections.tsx b/src/components/pages/gallery/Collections.tsx index e0b2bb5a3..391eddd77 100644 --- a/src/components/pages/gallery/Collections.tsx +++ b/src/components/pages/gallery/Collections.tsx @@ -36,7 +36,7 @@ interface CollectionProps { syncWithRemote: () => Promise; setCollectionNamerAttributes: SetCollectionNamerAttributes; startLoadingBar: () => void; - searchMode: boolean; + isInSearchMode: boolean; collectionFilesCount: Map; } @@ -104,6 +104,11 @@ const SectionChipCreater = /> ); +const Hider = styled.div<{ hide: boolean }>` + opacity: ${(props) => (props.hide ? '0' : '100')}; + height: ${(props) => (props.hide ? '0' : 'auto')}; +`; + export default function Collections(props: CollectionProps) { const { activeCollection, collections, setActiveCollection } = props; const [selectedCollectionID, setSelectedCollectionID] = @@ -136,7 +141,7 @@ export default function Collections(props: CollectionProps) { useEffect(() => { updateScrollObj(); - }, [collectionWrapperRef.current]); + }, [collectionWrapperRef.current, props.isInSearchMode, collections]); useEffect(() => { if (!collectionWrapperRef?.current) { @@ -199,101 +204,94 @@ export default function Collections(props: CollectionProps) { const SectionChip = SectionChipCreater({ activeCollection, clickHandler }); return ( - !props.searchMode && ( - <> - setCollectionShareModalView(false)} - collection={getSelectedCollection( - selectedCollectionID, - props.collections + + setCollectionShareModalView(false)} + collection={getSelectedCollection( + selectedCollectionID, + props.collections + )} + syncWithRemote={props.syncWithRemote} + /> + + + {scrollObj.scrollLeft > 0 && ( + )} - syncWithRemote={props.syncWithRemote} - /> - - - {scrollObj.scrollLeft > 0 && ( - - )} - - - {sortCollections( - collections, - props.collectionAndTheirLatestFile, - collectionSortBy - ).map((item) => ( - - - {item.name} - {item.type !== - CollectionType.favorites && - item.owner.id === user?.id ? ( - - - setSelectedCollectionID( - item.id - ) - } - /> - - ) : ( -
+ + {sortCollections( + collections, + props.collectionAndTheirLatestFile, + collectionSortBy + ).map((item) => ( + + + {item.name} + {item.type !== CollectionType.favorites && + item.owner.id === user?.id ? ( + + + setSelectedCollectionID( + item.id + ) + } /> - )} - - - ))} - - - - {scrollObj.scrollLeft < - scrollObj.scrollWidth - scrollObj.clientWidth && ( - - )} - - - - - ) + + ) : ( +
+ )} + + + ))} + + + + {scrollObj.scrollLeft < + scrollObj.scrollWidth - scrollObj.clientWidth && ( + + )} + + + + ); } diff --git a/src/components/pages/gallery/PreviewCard.tsx b/src/components/pages/gallery/PreviewCard.tsx index 5c698ffb0..bacbff199 100644 --- a/src/components/pages/gallery/PreviewCard.tsx +++ b/src/components/pages/gallery/PreviewCard.tsx @@ -128,15 +128,19 @@ export default function PreviewCard(props: IProps) { useLayoutEffect(() => { if (file && !file.msrc) { const main = async () => { - const url = await DownloadManager.getPreview(file); - if (isMounted.current) { - setImgSrc(url); - thumbs.set(file.id, url); - file.msrc = url; - if (!file.src) { - file.src = url; + try { + const url = await DownloadManager.getPreview(file); + if (isMounted.current) { + setImgSrc(url); + thumbs.set(file.id, url); + file.msrc = url; + if (!file.src) { + file.src = url; + } + updateUrl(url); } - updateUrl(url); + } catch (e) { + // no-op } }; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index ac2a2d16b..0d5a52017 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -79,12 +79,33 @@ const GlobalStyles = createGlobalStyle` height: 100%; } - .video-loading > div { + .video-loading > div.spinner-border { position: relative; top: -50vh; left: 50vw; } + + + .video-loading > div.download-message { + position: relative; + top: -60vh; + left: 0; + height: 16vh; + padding:2vh 0; + background-color: #151414; + color:#ddd; + display: flex; + flex-direction:column; + align-items: center; + justify-content: space-around; + opacity: 0.8; + font-size:20px; + } + .download-message > a{ + width: 130px; + } + :root { --primary: #e26f99, }; diff --git a/src/pages/gallery/index.tsx b/src/pages/gallery/index.tsx index 2d0ddac7c..a28f361cb 100644 --- a/src/pages/gallery/index.tsx +++ b/src/pages/gallery/index.tsx @@ -120,6 +120,7 @@ export type setSearchStats = React.Dispatch>; export type Search = { date?: DateValue; location?: Bbox; + fileIndex?: number; }; export interface SearchStats { resultCount: number; @@ -173,6 +174,7 @@ export default function Gallery() { const [search, setSearch] = useState({ date: null, location: null, + fileIndex: null, }); const [uploadInProgress, setUploadInProgress] = useState(false); const { @@ -188,7 +190,7 @@ export default function Gallery() { }); const loadingBar = useRef(null); - const [searchMode, setSearchMode] = useState(false); + const [isInSearchMode, setIsInSearchMode] = useState(false); const [searchStats, setSearchStats] = useState(null); const syncInProgress = useRef(true); const resync = useRef(false); @@ -197,11 +199,6 @@ export default function Gallery() { const [collectionFilesCount, setCollectionFilesCount] = useState>(); const [activeCollection, setActiveCollection] = useState(undefined); - - const [isSharedCollectionActive, setIsSharedCollectionActive] = - useState(false); - - const [isFavCollectionActive, setIsFavCollectionActive] = useState(false); const [trash, setTrash] = useState([]); useEffect(() => { @@ -267,14 +264,6 @@ export default function Gallery() { } const href = `/gallery${collectionURL}`; router.push(href, undefined, { shallow: true }); - - setIsSharedCollectionActive( - isSharedCollection(activeCollection, collections) - ); - - setIsFavCollectionActive( - isFavoriteCollection(activeCollection, collections) - ); }, [activeCollection]); const syncWithRemote = async (force = false, silent = false) => { @@ -483,8 +472,9 @@ export default function Gallery() { } }; - const updateSearch = (search: Search) => { - setSearch(search); + const updateSearch = (newSearch: Search) => { + setActiveCollection(ALL_SECTION); + setSearch(newSearch); setSearchStats(null); }; @@ -563,11 +553,12 @@ export default function Gallery() { attributes={dialogMessage} /> {selected.count > 0 && selected.collectionID === activeCollection && ( @@ -688,7 +682,10 @@ export default function Gallery() { count={selected.count} clearSelection={clearSelection} activeCollection={activeCollection} - isFavoriteCollection={isFavCollectionActive} + isFavoriteCollection={isFavoriteCollection( + activeCollection, + collections + )} /> )} {activeCollection === TRASH_SECTION && trash?.length > 0 && ( diff --git a/src/services/collectionService.ts b/src/services/collectionService.ts index 86513c4a1..3a44d8936 100644 --- a/src/services/collectionService.ts +++ b/src/services/collectionService.ts @@ -23,7 +23,6 @@ export enum CollectionType { } const COLLECTION_UPDATION_TIME = 'collection-updation-time'; -const FAV_COLLECTION = 'fav-collection'; const COLLECTIONS = 'collections'; export interface Collection { @@ -365,7 +364,11 @@ export const addToFavorites = async (file: File) => { 'Favorites', CollectionType.favorites ); - await localForage.setItem(FAV_COLLECTION, favCollection); + const localCollections = await getLocalCollections(); + await localForage.setItem(COLLECTIONS, [ + ...localCollections, + favCollection, + ]); } await addToCollection(favCollection, [file]); } catch (e) { diff --git a/src/services/downloadManager.ts b/src/services/downloadManager.ts index af2c72e92..77b8f656d 100644 --- a/src/services/downloadManager.ts +++ b/src/services/downloadManager.ts @@ -24,7 +24,7 @@ class DownloadManager { return URL.createObjectURL(await cacheResp.blob()); } if (!this.thumbnailObjectUrlPromise.get(file.id)) { - const downloadPromise = this._downloadThumb( + const downloadPromise = this.downloadThumb( token, thumbnailCache, file @@ -35,14 +35,28 @@ class DownloadManager { } catch (e) { this.thumbnailObjectUrlPromise.delete(file.id); logError(e, 'get preview Failed'); + throw e; } } - _downloadThumb = async ( + 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) => { const resp = await HTTPService.get( getThumbnailUrl(file.id), null, @@ -50,43 +64,38 @@ class DownloadManager { { responseType: 'arraybuffer' } ); const worker = await new CryptoWorker(); - const decrypted: any = await worker.decryptThumbnail( + const decrypted: Uint8Array = await worker.decryptThumbnail( new Uint8Array(resp.data), await worker.fromB64(file.thumbnail.decryptionHeader), file.key ); - try { - await thumbnailCache.put( - file.id.toString(), - new Response(new Blob([decrypted])) - ); - } catch (e) { - // TODO: handle storage full exception. - } - return URL.createObjectURL(new Blob([decrypted])); + return decrypted; }; 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}`; + } try { - const getFilePromise = (async () => { + const getFilePromise = async () => { const fileStream = await this.downloadFile(file); let fileBlob = await new Response(fileStream).blob(); if (forPreview) { fileBlob = await convertForPreview(file, fileBlob); } return URL.createObjectURL(fileBlob); - })(); - if (!this.fileObjectUrlPromise.get(`${file.id}_${forPreview}`)) { - this.fileObjectUrlPromise.set( - `${file.id}_${forPreview}`, - getFilePromise - ); + }; + if (!this.fileObjectUrlPromise.get(fileUID)) { + this.fileObjectUrlPromise.set(fileUID, getFilePromise()); } - return await this.fileObjectUrlPromise.get( - `${file.id}_${forPreview}` - ); + return await this.fileObjectUrlPromise.get(fileUID); } catch (e) { + this.fileObjectUrlPromise.delete(fileUID); logError(e, 'Failed to get File'); + throw e; } }; diff --git a/src/services/ffmpegService.ts b/src/services/ffmpegService.ts index 52522a6af..4cfcdf9be 100644 --- a/src/services/ffmpegService.ts +++ b/src/services/ffmpegService.ts @@ -44,8 +44,8 @@ class FFmpegService { async function generateThumbnailHelper(ffmpeg: FFmpeg, file: File) { try { - const inputFileName = `${Date.now().toString}-${file.name}`; - const thumbFileName = `${Date.now().toString}-thumb.png`; + const inputFileName = `${Date.now().toString()}-${file.name}`; + const thumbFileName = `${Date.now().toString()}-thumb.jpeg`; ffmpeg.FS( 'writeFile', inputFileName, @@ -62,6 +62,8 @@ async function generateThumbnailHelper(ffmpeg: FFmpeg, file: File) { `00:00:0${seekTime.toFixed(3)}`, '-vframes', '1', + '-vf', + 'scale=-1:720', thumbFileName ); thumb = ffmpeg.FS('readFile', thumbFileName); diff --git a/src/services/migrateThumbnailService.ts b/src/services/migrateThumbnailService.ts new file mode 100644 index 000000000..672c7bb17 --- /dev/null +++ b/src/services/migrateThumbnailService.ts @@ -0,0 +1,141 @@ +import downloadManager from 'services/downloadManager'; +import { fileAttribute, FILE_TYPE, getLocalFiles } from 'services/fileService'; +import { generateThumbnail } from 'services/upload/thumbnailService'; +import { getToken } from 'utils/common/key'; +import { logError } from 'utils/sentry'; +import { getEndpoint } from 'utils/common/apiUtil'; +import HTTPService from 'services/HTTPService'; +import CryptoWorker from 'utils/crypto'; +import uploadHttpClient from 'services/upload/uploadHttpClient'; +import { EncryptionResult, UploadURL } from 'services/upload/uploadService'; +import { SetProgressTracker } from 'components/FixLargeThumbnail'; + +const ENDPOINT = getEndpoint(); +const REPLACE_THUMBNAIL_THRESHOLD = 500 * 1024; // 500KB +export async function getLargeThumbnailFiles() { + try { + const token = getToken(); + if (!token) { + return; + } + const resp = await HTTPService.get( + `${ENDPOINT}/files/large-thumbnails`, + { + threshold: REPLACE_THUMBNAIL_THRESHOLD, + }, + { + 'X-Auth-Token': token, + } + ); + return resp.data.largeThumbnailFiles as number[]; + } catch (e) { + logError(e, 'failed to get large thumbnail files'); + throw e; + } +} +export async function replaceThumbnail( + setProgressTracker: SetProgressTracker, + largeThumbnailFileIDs: Set +) { + let completedWithError = false; + try { + const token = getToken(); + const worker = await new CryptoWorker(); + const files = await getLocalFiles(); + const largeThumbnailFiles = files.filter((file) => + largeThumbnailFileIDs.has(file.id) + ); + + if (largeThumbnailFiles.length === 0) { + return completedWithError; + } + setProgressTracker({ current: 0, total: largeThumbnailFiles.length }); + const uploadURLs: UploadURL[] = []; + uploadHttpClient.fetchUploadURLs( + largeThumbnailFiles.length, + uploadURLs + ); + for (const [idx, file] of largeThumbnailFiles.entries()) { + try { + setProgressTracker({ + current: idx, + total: largeThumbnailFiles.length, + }); + const originalThumbnail = await downloadManager.getThumbnail( + token, + file + ); + const dummyImageFile = new globalThis.File( + [originalThumbnail], + file.metadata.title + ); + const { thumbnail: newThumbnail } = await generateThumbnail( + worker, + dummyImageFile, + FILE_TYPE.IMAGE, + false + ); + const newUploadedThumbnail = await uploadThumbnail( + worker, + file.key, + newThumbnail, + uploadURLs.pop() + ); + await updateThumbnail(file.id, newUploadedThumbnail); + } catch (e) { + logError(e, 'failed to replace a thumbnail'); + completedWithError = true; + } + } + } catch (e) { + logError(e, 'replace Thumbnail function failed'); + completedWithError = true; + } + return completedWithError; +} + +export async function uploadThumbnail( + worker, + fileKey: string, + updatedThumbnail: Uint8Array, + uploadURL: UploadURL +): Promise { + const { file: encryptedThumbnail }: EncryptionResult = + await worker.encryptThumbnail(updatedThumbnail, fileKey); + + const thumbnailObjectKey = await uploadHttpClient.putFile( + uploadURL, + encryptedThumbnail.encryptedData as Uint8Array, + () => {} + ); + return { + objectKey: thumbnailObjectKey, + decryptionHeader: encryptedThumbnail.decryptionHeader, + }; +} + +export async function updateThumbnail( + fileID: number, + newThumbnail: fileAttribute +) { + try { + const token = getToken(); + if (!token) { + return; + } + await HTTPService.put( + `${ENDPOINT}/files/thumbnail`, + { + fileID: fileID, + thumbnail: newThumbnail, + }, + null, + { + 'X-Auth-Token': token, + } + ); + } catch (e) { + logError(e, 'failed to update thumbnail'); + throw e; + } +} diff --git a/src/services/searchService.ts b/src/services/searchService.ts index d5832fd84..34c5c06b9 100644 --- a/src/services/searchService.ts +++ b/src/services/searchService.ts @@ -4,6 +4,9 @@ import { getToken } from 'utils/common/key'; import { DateValue, Suggestion, SuggestionType } from 'components/SearchBar'; import HTTPService from './HTTPService'; import { Collection } from './collectionService'; +import { File } from './fileService'; +import { User } from './userService'; +import { getData, LS_KEYS } from 'utils/storage/localStorage'; const ENDPOINT = getEndpoint(); const DIGITS = new Set(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']); @@ -110,3 +113,25 @@ export function searchCollection( collection.name.toLowerCase().includes(searchPhrase) ); } + +export function searchFiles(searchPhrase: string, files: File[]) { + const user: User = getData(LS_KEYS.USER) ?? {}; + const idSet = new Set(); + return files + .map((file, idx) => ({ + title: file.metadata.title, + index: idx, + type: file.metadata.fileType, + ownerID: file.ownerID, + id: file.id, + })) + .filter((file) => { + if (file.ownerID === user.id && !idSet.has(file.id)) { + idSet.add(file.id); + return true; + } + return false; + }) + .filter(({ title }) => title.toLowerCase().includes(searchPhrase)) + .slice(0, 4); +} diff --git a/src/services/upload/thumbnailService.ts b/src/services/upload/thumbnailService.ts index e63df1f8a..586a396af 100644 --- a/src/services/upload/thumbnailService.ts +++ b/src/services/upload/thumbnailService.ts @@ -3,13 +3,21 @@ import { CustomError, errorWithContext } from 'utils/common/errorUtil'; 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'; -const THUMBNAIL_HEIGHT = 720; -const MAX_ATTEMPTS = 3; -const MIN_THUMBNAIL_SIZE = 50000; +const MAX_THUMBNAIL_DIMENSION = 720; +const MIN_COMPRESSION_PERCENTAGE_SIZE_DIFF = 10; +export const MAX_THUMBNAIL_SIZE = 100 * 1024; +const MIN_QUALITY = 0.5; +const MAX_QUALITY = 0.7; const WAIT_TIME_THUMBNAIL_GENERATION = 10 * 1000; +interface Dimension { + width: number; + height: number; +} + export async function generateThumbnail( worker, file: globalThis.File, @@ -26,7 +34,12 @@ export async function generateThumbnail( } else { try { const thumb = await FFmpegService.generateThumbnail(file); - return { thumbnail: thumb, hasStaticThumbnail: false }; + const dummyImageFile = new File([thumb], file.name); + canvas = await generateImageThumbnail( + worker, + dummyImageFile, + isHEIC + ); } catch (e) { canvas = await generateVideoThumbnail(file); } @@ -74,16 +87,22 @@ export async function generateImageThumbnail( await new Promise((resolve, reject) => { image.onload = () => { try { - const thumbnailWidth = - (image.width * THUMBNAIL_HEIGHT) / image.height; - canvas.width = thumbnailWidth; - canvas.height = THUMBNAIL_HEIGHT; + const imageDimension = { + width: image.width, + height: image.height, + }; + const thumbnailDimension = calculateThumbnailDimension( + imageDimension, + MAX_THUMBNAIL_DIMENSION + ); + canvas.width = thumbnailDimension.width; + canvas.height = thumbnailDimension.height; canvasCTX.drawImage( image, 0, 0, - thumbnailWidth, - THUMBNAIL_HEIGHT + thumbnailDimension.width, + thumbnailDimension.height ); image = null; clearTimeout(timeout); @@ -126,16 +145,22 @@ export async function generateVideoThumbnail(file: globalThis.File) { if (!video) { throw Error('video load failed'); } - const thumbnailWidth = - (video.videoWidth * THUMBNAIL_HEIGHT) / video.videoHeight; - canvas.width = thumbnailWidth; - canvas.height = THUMBNAIL_HEIGHT; + const videoDimension = { + width: video.videoWidth, + height: video.videoHeight, + }; + const thumbnailDimension = calculateThumbnailDimension( + videoDimension, + MAX_THUMBNAIL_DIMENSION + ); + canvas.width = thumbnailDimension.width; + canvas.height = thumbnailDimension.height; canvasCTX.drawImage( video, 0, 0, - thumbnailWidth, - THUMBNAIL_HEIGHT + thumbnailDimension.width, + thumbnailDimension.height ); video = null; clearTimeout(timeout); @@ -166,11 +191,14 @@ export async function generateVideoThumbnail(file: globalThis.File) { } async function thumbnailCanvasToBlob(canvas: HTMLCanvasElement) { - let thumbnailBlob = null; - let attempts = 0; - let quality = 1; + let thumbnailBlob: Blob = null; + let prevSize = Number.MAX_SAFE_INTEGER; + let quality = MAX_QUALITY; do { + if (thumbnailBlob) { + prevSize = thumbnailBlob.size; + } thumbnailBlob = await new Promise((resolve) => { canvas.toBlob( function (blob) { @@ -181,12 +209,49 @@ async function thumbnailCanvasToBlob(canvas: HTMLCanvasElement) { ); }); thumbnailBlob = thumbnailBlob ?? new Blob([]); - attempts++; - quality /= 2; + quality -= 0.1; } while ( - thumbnailBlob.size > MIN_THUMBNAIL_SIZE && - attempts <= MAX_ATTEMPTS + quality >= MIN_QUALITY && + thumbnailBlob.size > MAX_THUMBNAIL_SIZE && + percentageSizeDiff(thumbnailBlob.size, prevSize) >= + MIN_COMPRESSION_PERCENTAGE_SIZE_DIFF ); + if (thumbnailBlob.size > MAX_THUMBNAIL_SIZE) { + logError( + Error('thumbnail_too_large'), + 'thumbnail greater than max limit', + { thumbnailSize: convertToHumanReadable(thumbnailBlob.size) } + ); + } return thumbnailBlob; } + +function percentageSizeDiff( + newThumbnailSize: number, + oldThumbnailSize: number +) { + return ((oldThumbnailSize - newThumbnailSize) * 100) / oldThumbnailSize; +} + +// method to calculate new size of image for limiting it to maximum width and height, maintaining aspect ratio +// returns {0,0} for invalid inputs +function calculateThumbnailDimension( + originalDimension: Dimension, + maxDimension: number +): Dimension { + if (originalDimension.height === 0 || originalDimension.width === 0) { + return { width: 0, height: 0 }; + } + const widthScaleFactor = maxDimension / originalDimension.width; + const heightScaleFactor = maxDimension / originalDimension.height; + const scaleFactor = Math.min(widthScaleFactor, heightScaleFactor); + const thumbnailDimension = { + width: Math.round(originalDimension.width * scaleFactor), + height: Math.round(originalDimension.height * scaleFactor), + }; + if (thumbnailDimension.width === 0 || thumbnailDimension.height === 0) { + return { width: 0, height: 0 }; + } + return thumbnailDimension; +} diff --git a/src/utils/file/index.ts b/src/utils/file/index.ts index 1d83d9ce6..07df6eea4 100644 --- a/src/utils/file/index.ts +++ b/src/utils/file/index.ts @@ -11,6 +11,7 @@ import { import { decodeMotionPhoto } from 'services/motionPhotoService'; import { getMimeTypeFromBlob } from 'services/upload/readFileService'; import { EncryptionResult } from 'services/upload/uploadService'; +import DownloadManger from 'services/downloadManager'; import { logError } from 'utils/sentry'; import { User } from 'services/userService'; import CryptoWorker from 'utils/crypto'; @@ -36,6 +37,20 @@ export function downloadAsFile(filename: string, content: string) { a.remove(); } +export async function downloadFile(file) { + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = await DownloadManger.getFile(file); + if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { + a.download = fileNameWithoutExtension(file.metadata.title) + '.zip'; + } else { + a.download = file.metadata.title; + } + document.body.appendChild(a); + a.click(); + a.remove(); +} + export function fileIsHEIC(mimeType: string) { return ( mimeType.toLowerCase().endsWith(TYPE_HEIC) || diff --git a/src/utils/photoFrame/index.ts b/src/utils/photoFrame/index.ts new file mode 100644 index 000000000..fdaeb8557 --- /dev/null +++ b/src/utils/photoFrame/index.ts @@ -0,0 +1,15 @@ +const WAIT_FOR_VIDEO_PLAYBACK = 1 * 1000; + +export async function isPlaybackPossible(url: string): Promise { + return await new Promise((resolve) => { + const t = setTimeout(() => { + resolve(false); + }, WAIT_FOR_VIDEO_PLAYBACK); + const video = document.createElement('video'); + video.addEventListener('canplay', function () { + clearTimeout(t); + resolve(true); + }); + video.src = url; + }); +} diff --git a/src/utils/sentry/index.ts b/src/utils/sentry/index.ts index fb889439d..aef44d4e5 100644 --- a/src/utils/sentry/index.ts +++ b/src/utils/sentry/index.ts @@ -9,7 +9,7 @@ export const logError = ( ) => { const err = errorWithContext(e, msg); if (!process.env.NEXT_PUBLIC_SENTRY_ENV) { - console.log(err); + console.log(e); } Sentry.captureException(err, { level: Sentry.Severity.Info, @@ -18,6 +18,7 @@ export const logError = ( ...(info && { info: info, }), + rootCause: { message: e?.message }, }, }); }; diff --git a/src/utils/storage/localStorage.ts b/src/utils/storage/localStorage.ts index 356bd6ab1..fddfe119e 100644 --- a/src/utils/storage/localStorage.ts +++ b/src/utils/storage/localStorage.ts @@ -12,6 +12,7 @@ export enum LS_KEYS { SHOW_BACK_BUTTON = 'showBackButton', EXPORT = 'export', AnonymizeUserID = 'anonymizedUserID', + THUMBNAIL_FIX_STATE = 'thumbnailFixState', } export const setData = (key: LS_KEYS, value: object) => { diff --git a/src/utils/strings/englishConstants.tsx b/src/utils/strings/englishConstants.tsx index b1bec342d..fbf1bca96 100644 --- a/src/utils/strings/englishConstants.tsx +++ b/src/utils/strings/englishConstants.tsx @@ -548,9 +548,6 @@ const englishConstants = { MOVE: 'move', ADD: 'add', SORT: 'sort', - SORT_BY_LATEST_PHOTO: 'most recent photo', - SORT_BY_MODIFICATION_TIME: 'last modified', - SORT_BY_COLLECTION_NAME: 'album title', REMOVE: 'remove', CONFIRM_REMOVE: 'confirm removal', TRASH: 'trash', @@ -571,14 +568,31 @@ const englishConstants = { CONFIRM_REMOVE_MESSAGE: () => ( <> -

- are you sure you want to remove these files from the collection? -

+

are you sure you want to remove these files from the album?

all files that are unique to this album will be moved to trash

), + SORT_BY_LATEST_PHOTO: 'recent photo', + SORT_BY_MODIFICATION_TIME: 'last updated', + SORT_BY_COLLECTION_NAME: 'album name', + FIX_LARGE_THUMBNAILS: 'compress thumbnails', + THUMBNAIL_REPLACED: 'thumbnails compressed', + FIX: 'compress', + FIX_LATER: 'compress later', + REPLACE_THUMBNAIL_NOT_STARTED: () => ( + <> + some of your videos thumbnails can be compressed to save space. + would you like ente to compress them? + + ), + REPLACE_THUMBNAIL_COMPLETED: () => ( + <>successfully compressed all thumbnails + ), + REPLACE_THUMBNAIL_COMPLETED_WITH_ERROR: () => ( + <>could not compress some of your thumbnails, please retry + ), }; export default englishConstants;