Merge branch 'master' into trash

This commit is contained in:
Abhinav 2021-10-28 14:36:20 +05:30
commit 7ccc0b59f7
22 changed files with 883 additions and 308 deletions

View file

@ -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 ? (
<div style={{ marginBottom: '30px' }}>{message}</div>
) : (
<></>
);
}
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<number[]>(
[]
);
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 (
<MessageDialog
show={props.isOpen}
onHide={props.hide}
attributes={{
title: constants.FIX_LARGE_THUMBNAILS,
staticBackdrop: true,
}}>
<div
style={{
marginBottom: '20px',
padding: '0 5%',
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
}}>
<Message fixState={fixState} />
{fixState === FIX_STATE.RUNNING && (
<>
<div style={{ marginBottom: '10px' }}>
<ComfySpan>
{' '}
{progressTracker.current} /{' '}
{progressTracker.total}{' '}
</ComfySpan>{' '}
<span style={{ marginLeft: '10px' }}>
{' '}
{constants.THUMBNAIL_REPLACED}
</span>
</div>
<div
style={{
width: '100%',
marginTop: '10px',
marginBottom: '20px',
}}>
<ProgressBar
now={Math.round(
(progressTracker.current * 100) /
progressTracker.total
)}
animated={true}
variant="upload-progress-bar"
/>
</div>
</>
)}
<div
style={{
width: '100%',
display: 'flex',
justifyContent: 'space-around',
}}>
{fixState === FIX_STATE.NOT_STARTED ? (
<Button
block
variant={'outline-secondary'}
onClick={() => {
updateFixState(FIX_STATE.FIX_LATER);
props.hide();
}}>
{constants.FIX_LATER}
</Button>
) : (
<Button
block
variant={'outline-secondary'}
onClick={props.hide}>
{constants.CLOSE}
</Button>
)}
{(fixState === FIX_STATE.NOT_STARTED ||
fixState === FIX_STATE.FIX_LATER ||
fixState === FIX_STATE.COMPLETED_WITH_ERRORS) && (
<>
<div style={{ width: '30px' }} />
<Button
block
variant={'outline-success'}
onClick={() => startFix()}>
{constants.FIX}
</Button>
</>
)}
</div>
</div>
</MessageDialog>
);
}

View file

@ -18,7 +18,6 @@ import { VariableSizeList as List } from 'react-window';
import PhotoSwipe from 'components/PhotoSwipe/PhotoSwipe'; import PhotoSwipe from 'components/PhotoSwipe/PhotoSwipe';
import { isInsideBox, isSameDay as isSameDayAnyYear } from 'utils/search'; import { isInsideBox, isSameDay as isSameDayAnyYear } from 'utils/search';
import { SetDialogMessage } from './MessageDialog'; import { SetDialogMessage } from './MessageDialog';
import { CustomError } from 'utils/common/errorUtil';
import { import {
GAP_BTW_TILES, GAP_BTW_TILES,
DATE_CONTAINER_HEIGHT, DATE_CONTAINER_HEIGHT,
@ -34,10 +33,10 @@ import {
TRASH_SECTION, TRASH_SECTION,
} from './pages/gallery/Collections'; } from './pages/gallery/Collections';
import { isSharedFile } from 'utils/file'; import { isSharedFile } from 'utils/file';
import { isPlaybackPossible } from 'utils/photoFrame';
const NO_OF_PAGES = 2; const NO_OF_PAGES = 2;
const A_DAY = 24 * 60 * 60 * 1000; const A_DAY = 24 * 60 * 60 * 1000;
const WAIT_FOR_VIDEO_PLAYBACK = 1 * 1000;
interface TimeStampListItem { interface TimeStampListItem {
itemType: ITEM_TYPE; itemType: ITEM_TYPE;
@ -150,7 +149,7 @@ interface Props {
isFirstLoad; isFirstLoad;
openFileUploader; openFileUploader;
loadingBar; loadingBar;
searchMode: boolean; isInSearchMode: boolean;
search: Search; search: Search;
setSearchStats: setSearchStats; setSearchStats: setSearchStats;
deleted?: number[]; deleted?: number[];
@ -169,11 +168,10 @@ const PhotoFrame = ({
isFirstLoad, isFirstLoad,
openFileUploader, openFileUploader,
loadingBar, loadingBar,
searchMode, isInSearchMode,
search, search,
setSearchStats, setSearchStats,
deleted, deleted,
setDialogMessage,
activeCollection, activeCollection,
isSharedCollection, isSharedCollection,
}: Props) => { }: Props) => {
@ -185,12 +183,20 @@ const PhotoFrame = ({
const listRef = useRef(null); const listRef = useRef(null);
useEffect(() => { useEffect(() => {
if (searchMode) { if (isInSearchMode) {
setSearchStats({ setSearchStats({
resultCount: filteredData.length, resultCount: filteredData.length,
timeTaken: (Date.now() - startTime) / 1000, 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]); }, [search]);
useEffect(() => { useEffect(() => {
@ -229,21 +235,33 @@ const PhotoFrame = ({
setFiles(files); setFiles(files);
}; };
const updateSrcUrl = (index: number, url: string) => { const updateSrcUrl = async (index: number, url: string) => {
files[index] = { files[index] = {
...files[index], ...files[index],
src: url,
w: window.innerWidth, w: window.innerWidth,
h: window.innerHeight, h: window.innerHeight,
}; };
if (files[index].metadata.fileType === FILE_TYPE.VIDEO) { if (files[index].metadata.fileType === FILE_TYPE.VIDEO) {
if (await isPlaybackPossible(url)) {
files[index].html = ` files[index].html = `
<video controls> <video controls>
<source src="${url}" /> <source src="${url}" />
Your browser does not support the video tag. Your browser does not support the video tag.
</video> </video>
`; `;
delete files[index].src; } else {
files[index].html = `
<div class="video-loading">
<img src="${files[index].msrc}" />
<div class="download-message" >
${constants.VIDEO_PLAYBACK_FAILED_DOWNLOAD_INSTEAD}
<a class="btn btn-outline-success" href=${url} download="${files[index].metadata.title}"">Download</button>
</div>
</div>
`;
}
} else {
files[index].src = url;
} }
setFiles(files); setFiles(files);
}; };
@ -287,6 +305,7 @@ const PhotoFrame = ({
const getSlideData = async (instance: any, index: number, item: File) => { const getSlideData = async (instance: any, index: number, item: File) => {
if (!item.msrc) { if (!item.msrc) {
try {
let url: string; let url: string;
if (galleryContext.thumbs.has(item.id)) { if (galleryContext.thumbs.has(item.id)) {
url = galleryContext.thumbs.get(item.id); url = galleryContext.thumbs.get(item.id);
@ -307,8 +326,12 @@ const PhotoFrame = ({
} catch (e) { } catch (e) {
// ignore // ignore
} }
} catch (e) {
// no-op
}
} }
if (!fetching[item.dataIndex]) { if (!fetching[item.dataIndex]) {
try {
fetching[item.dataIndex] = true; fetching[item.dataIndex] = true;
let url: string; let url: string;
if (galleryContext.files.has(item.id)) { if (galleryContext.files.has(item.id)) {
@ -317,72 +340,22 @@ const PhotoFrame = ({
url = await DownloadManager.getFile(item, true); url = await DownloadManager.getFile(item, true);
galleryContext.files.set(item.id, url); galleryContext.files.set(item.id, url);
} }
updateSrcUrl(item.dataIndex, url); await updateSrcUrl(item.dataIndex, url);
if (item.metadata.fileType === FILE_TYPE.VIDEO) { item.html = files[item.dataIndex].html;
try { item.src = files[item.dataIndex].src;
await new Promise((resolve, reject) => { item.w = files[item.dataIndex].w;
const video = document.createElement('video'); item.h = files[item.dataIndex].h;
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 = `
<video width="320" height="240" controls>
<source src="${url}" />
Your browser does not support the video tag.
</video>
`;
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 { try {
instance.invalidateCurrItems(); instance.invalidateCurrItems();
instance.updateSize(true); instance.updateSize(true);
} catch (e) { } catch (e) {
// ignore // ignore
} }
} catch (e) {
// no-op
} finally {
fetching[item.dataIndex] = false;
}
} }
}; };
@ -531,7 +504,7 @@ const PhotoFrame = ({
return ( return (
<> <>
{!isFirstLoad && files.length === 0 && !searchMode ? ( {!isFirstLoad && files.length === 0 && !isInSearchMode ? (
<EmptyScreen> <EmptyScreen>
<img height={150} src="/images/gallery.png" /> <img height={150} src="/images/gallery.png" />
<div style={{ color: '#a6a6a6', marginTop: '16px' }}> <div style={{ color: '#a6a6a6', marginTop: '16px' }}>
@ -651,7 +624,7 @@ const PhotoFrame = ({
return sum; return sum;
})(); })();
files.length < 30 && files.length < 30 &&
!searchMode && !isInSearchMode &&
timeStampList.push({ timeStampList.push({
itemType: ITEM_TYPE.BANNER, itemType: ITEM_TYPE.BANNER,
banner: ( banner: (

View file

@ -7,16 +7,15 @@ import {
addToFavorites, addToFavorites,
removeFromFavorites, removeFromFavorites,
} from 'services/collectionService'; } from 'services/collectionService';
import { File, FILE_TYPE } from 'services/fileService'; import { File } from 'services/fileService';
import constants from 'utils/strings/constants'; import constants from 'utils/strings/constants';
import DownloadManger from 'services/downloadManager';
import exifr from 'exifr'; import exifr from 'exifr';
import Modal from 'react-bootstrap/Modal'; import Modal from 'react-bootstrap/Modal';
import Button from 'react-bootstrap/Button'; import Button from 'react-bootstrap/Button';
import Form from 'react-bootstrap/Form'; import Form from 'react-bootstrap/Form';
import styled from 'styled-components'; import styled from 'styled-components';
import events from './events'; import events from './events';
import { fileNameWithoutExtension, formatDateTime } from 'utils/file'; import { downloadFile, formatDateTime } from 'utils/file';
import { FormCheck } from 'react-bootstrap'; import { FormCheck } from 'react-bootstrap';
import { prettyPrintExif } from 'utils/exif'; import { prettyPrintExif } from 'utils/exif';
@ -297,21 +296,11 @@ function PhotoSwipe(props: Iprops) {
setShowInfo(true); setShowInfo(true);
}; };
const downloadFile = async (file) => { const downloadFileHelper = async (file) => {
const { loadingBar } = props; const { loadingBar } = props;
const a = document.createElement('a');
a.style.display = 'none';
loadingBar.current.continuousStart(); loadingBar.current.continuousStart();
a.href = await DownloadManger.getFile(file); await downloadFile(file);
loadingBar.current.complete(); 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; const { id } = props;
let { className } = props; let { className } = props;
@ -345,7 +334,7 @@ function PhotoSwipe(props: Iprops) {
className="pswp-custom download-btn" className="pswp-custom download-btn"
title={constants.DOWNLOAD} title={constants.DOWNLOAD}
onClick={() => onClick={() =>
downloadFile(photoSwipe.currItem) downloadFileHelper(photoSwipe.currItem)
} }
/> />

View file

@ -10,6 +10,7 @@ import {
getYearSuggestion, getYearSuggestion,
parseHumanDate, parseHumanDate,
searchCollection, searchCollection,
searchFiles,
searchLocation, searchLocation,
} from 'services/searchService'; } from 'services/searchService';
import { getFormattedDate } from 'utils/search'; import { getFormattedDate } from 'utils/search';
@ -20,6 +21,9 @@ import SearchIcon from './icons/SearchIcon';
import CrossIcon from './icons/CrossIcon'; import CrossIcon from './icons/CrossIcon';
import { Collection } from 'services/collectionService'; import { Collection } from 'services/collectionService';
import CollectionIcon from './icons/CollectionIcon'; 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 }>` const Wrapper = styled.div<{ isDisabled: boolean; isOpen: boolean }>`
position: fixed; position: fixed;
@ -74,6 +78,8 @@ export enum SuggestionType {
DATE, DATE,
LOCATION, LOCATION,
COLLECTION, COLLECTION,
IMAGE,
VIDEO,
} }
export interface DateValue { export interface DateValue {
date?: number; date?: number;
@ -94,6 +100,7 @@ interface Props {
searchStats: SearchStats; searchStats: SearchStats;
collections: Collection[]; collections: Collection[];
setActiveCollection: (id: number) => void; setActiveCollection: (id: number) => void;
files: File[];
} }
export default function SearchBar(props: Props) { export default function SearchBar(props: Props) {
const [value, setValue] = useState<Suggestion>(null); const [value, setValue] = useState<Suggestion>(null);
@ -112,14 +119,14 @@ export default function SearchBar(props: Props) {
if (!searchPhrase?.length) { if (!searchPhrase?.length) {
return []; return [];
} }
const option = [ const options = [
...getHolidaySuggestion(searchPhrase), ...getHolidaySuggestion(searchPhrase),
...getYearSuggestion(searchPhrase), ...getYearSuggestion(searchPhrase),
]; ];
const searchedDates = parseHumanDate(searchPhrase); const searchedDates = parseHumanDate(searchPhrase);
option.push( options.push(
...searchedDates.map((searchedDate) => ({ ...searchedDates.map((searchedDate) => ({
type: SuggestionType.DATE, type: SuggestionType.DATE,
value: searchedDate, value: searchedDate,
@ -131,7 +138,7 @@ export default function SearchBar(props: Props) {
searchPhrase, searchPhrase,
props.collections props.collections
); );
option.push( options.push(
...collectionResults.map( ...collectionResults.map(
(searchResult) => (searchResult) =>
({ ({
@ -141,8 +148,20 @@ export default function SearchBar(props: Props) {
} as Suggestion) } 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); const locationResults = await searchLocation(searchPhrase);
option.push( options.push(
...locationResults.map( ...locationResults.map(
(searchResult) => (searchResult) =>
({ ({
@ -152,7 +171,7 @@ export default function SearchBar(props: Props) {
} as Suggestion) } as Suggestion)
) )
); );
return option; return options;
}; };
const getOptions = debounce(getAutoCompleteSuggestions, 250); const getOptions = debounce(getAutoCompleteSuggestions, 250);
@ -161,7 +180,6 @@ export default function SearchBar(props: Props) {
if (!selectedOption) { if (!selectedOption) {
return; return;
} }
switch (selectedOption.type) { switch (selectedOption.type) {
case SuggestionType.DATE: case SuggestionType.DATE:
props.setSearch({ props.setSearch({
@ -177,12 +195,17 @@ export default function SearchBar(props: Props) {
break; break;
case SuggestionType.COLLECTION: case SuggestionType.COLLECTION:
props.setActiveCollection(selectedOption.value as number); 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; break;
} }
}; };
const resetSearch = async (force?: boolean) => { const resetSearch = () => {
if (props.isOpen || force) { if (props.isOpen) {
props.loadingBar.current?.continuousStart(); props.loadingBar.current?.continuousStart();
props.setSearch({}); props.setSearch({});
setTimeout(() => { setTimeout(() => {
@ -205,6 +228,10 @@ export default function SearchBar(props: Props) {
return <LocationIcon />; return <LocationIcon />;
case SuggestionType.COLLECTION: case SuggestionType.COLLECTION:
return <CollectionIcon />; return <CollectionIcon />;
case SuggestionType.IMAGE:
return <ImageIcon />;
case SuggestionType.VIDEO:
return <VideoIcon />;
default: default:
return <SearchIcon />; return <SearchIcon />;
} }

View file

@ -36,6 +36,7 @@ import {
ARCHIVE_SECTION, ARCHIVE_SECTION,
TRASH_SECTION, TRASH_SECTION,
} from 'components/pages/gallery/Collections'; } from 'components/pages/gallery/Collections';
import FixLargeThumbnails from './FixLargeThumbnail';
interface Props { interface Props {
collections: Collection[]; collections: Collection[];
setDialogMessage: SetDialogMessage; setDialogMessage: SetDialogMessage;
@ -53,6 +54,7 @@ export default function Sidebar(props: Props) {
const [recoverModalView, setRecoveryModalView] = useState(false); const [recoverModalView, setRecoveryModalView] = useState(false);
const [twoFactorModalView, setTwoFactorModalView] = useState(false); const [twoFactorModalView, setTwoFactorModalView] = useState(false);
const [exportModalView, setExportModalView] = useState(false); const [exportModalView, setExportModalView] = useState(false);
const [fixLargeThumbsView, setFixLargeThumbsView] = useState(false);
const galleryContext = useContext(GalleryContext); const galleryContext = useContext(GalleryContext);
useEffect(() => { useEffect(() => {
const main = async () => { const main = async () => {
@ -278,6 +280,18 @@ export default function Sidebar(props: Props) {
{constants.UPDATE_EMAIL} {constants.UPDATE_EMAIL}
</LinkButton> </LinkButton>
<Divider /> <Divider />
<>
<FixLargeThumbnails
isOpen={fixLargeThumbsView}
hide={() => setFixLargeThumbsView(false)}
show={() => setFixLargeThumbsView(true)}
/>
<LinkButton
style={{ marginTop: '30px' }}
onClick={() => setFixLargeThumbsView(true)}>
{constants.FIX_LARGE_THUMBNAILS}
</LinkButton>
</>
<LinkButton <LinkButton
style={{ marginTop: '30px' }} style={{ marginTop: '30px' }}
onClick={openFeedbackURL}> onClick={openFeedbackURL}>

View file

@ -0,0 +1,21 @@
import React from 'react';
export default function ImageIcon(props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height={props.height}
viewBox={props.viewBox}
width={props.width}
fill="currentColor">
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4.86 8.86l-3 3.87L9 13.14 6 17h12l-3.86-5.14z" />
</svg>
);
}
ImageIcon.defaultProps = {
height: 24,
width: 24,
viewBox: '0 0 24 24',
};

View file

@ -0,0 +1,20 @@
import React from 'react';
export default function VideoIcon(props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height={props.height}
viewBox={props.viewBox}
width={props.width}
fill="currentColor">
<path d="M4 6.47L5.76 10H20v8H4V6.47M22 4h-4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4z" />
</svg>
);
}
VideoIcon.defaultProps = {
height: 24,
width: 24,
viewBox: '0 0 24 24',
};

View file

@ -36,7 +36,7 @@ interface CollectionProps {
syncWithRemote: () => Promise<void>; syncWithRemote: () => Promise<void>;
setCollectionNamerAttributes: SetCollectionNamerAttributes; setCollectionNamerAttributes: SetCollectionNamerAttributes;
startLoadingBar: () => void; startLoadingBar: () => void;
searchMode: boolean; isInSearchMode: boolean;
collectionFilesCount: Map<number, number>; collectionFilesCount: Map<number, number>;
} }
@ -104,6 +104,11 @@ const SectionChipCreater =
/> />
</Chip> </Chip>
); );
const Hider = styled.div<{ hide: boolean }>`
opacity: ${(props) => (props.hide ? '0' : '100')};
height: ${(props) => (props.hide ? '0' : 'auto')};
`;
export default function Collections(props: CollectionProps) { export default function Collections(props: CollectionProps) {
const { activeCollection, collections, setActiveCollection } = props; const { activeCollection, collections, setActiveCollection } = props;
const [selectedCollectionID, setSelectedCollectionID] = const [selectedCollectionID, setSelectedCollectionID] =
@ -136,7 +141,7 @@ export default function Collections(props: CollectionProps) {
useEffect(() => { useEffect(() => {
updateScrollObj(); updateScrollObj();
}, [collectionWrapperRef.current]); }, [collectionWrapperRef.current, props.isInSearchMode, collections]);
useEffect(() => { useEffect(() => {
if (!collectionWrapperRef?.current) { if (!collectionWrapperRef?.current) {
@ -199,8 +204,7 @@ export default function Collections(props: CollectionProps) {
const SectionChip = SectionChipCreater({ activeCollection, clickHandler }); const SectionChip = SectionChipCreater({ activeCollection, clickHandler });
return ( return (
!props.searchMode && ( <Hider hide={props.isInSearchMode}>
<>
<CollectionShare <CollectionShare
show={collectionShareModalView} show={collectionShareModalView}
onHide={() => setCollectionShareModalView(false)} onHide={() => setCollectionShareModalView(false)}
@ -215,9 +219,7 @@ export default function Collections(props: CollectionProps) {
{scrollObj.scrollLeft > 0 && ( {scrollObj.scrollLeft > 0 && (
<NavigationButton <NavigationButton
scrollDirection={SCROLL_DIRECTION.LEFT} scrollDirection={SCROLL_DIRECTION.LEFT}
onClick={scrollCollection( onClick={scrollCollection(SCROLL_DIRECTION.LEFT)}
SCROLL_DIRECTION.LEFT
)}
/> />
)} )}
<Wrapper <Wrapper
@ -242,8 +244,7 @@ export default function Collections(props: CollectionProps) {
active={activeCollection === item.id} active={activeCollection === item.id}
onClick={clickHandler(item.id)}> onClick={clickHandler(item.id)}>
{item.name} {item.name}
{item.type !== {item.type !== CollectionType.favorites &&
CollectionType.favorites &&
item.owner.id === user?.id ? ( item.owner.id === user?.id ? (
<OverlayTrigger <OverlayTrigger
rootClose rootClose
@ -282,9 +283,7 @@ export default function Collections(props: CollectionProps) {
scrollObj.scrollWidth - scrollObj.clientWidth && ( scrollObj.scrollWidth - scrollObj.clientWidth && (
<NavigationButton <NavigationButton
scrollDirection={SCROLL_DIRECTION.RIGHT} scrollDirection={SCROLL_DIRECTION.RIGHT}
onClick={scrollCollection( onClick={scrollCollection(SCROLL_DIRECTION.RIGHT)}
SCROLL_DIRECTION.RIGHT
)}
/> />
)} )}
</CollectionContainer> </CollectionContainer>
@ -293,7 +292,6 @@ export default function Collections(props: CollectionProps) {
activeSortBy={collectionSortBy} activeSortBy={collectionSortBy}
/> />
</CollectionBar> </CollectionBar>
</> </Hider>
)
); );
} }

View file

@ -128,6 +128,7 @@ export default function PreviewCard(props: IProps) {
useLayoutEffect(() => { useLayoutEffect(() => {
if (file && !file.msrc) { if (file && !file.msrc) {
const main = async () => { const main = async () => {
try {
const url = await DownloadManager.getPreview(file); const url = await DownloadManager.getPreview(file);
if (isMounted.current) { if (isMounted.current) {
setImgSrc(url); setImgSrc(url);
@ -138,6 +139,9 @@ export default function PreviewCard(props: IProps) {
} }
updateUrl(url); updateUrl(url);
} }
} catch (e) {
// no-op
}
}; };
if (thumbs.has(file.id)) { if (thumbs.has(file.id)) {

View file

@ -79,12 +79,33 @@ const GlobalStyles = createGlobalStyle`
height: 100%; height: 100%;
} }
.video-loading > div { .video-loading > div.spinner-border {
position: relative; position: relative;
top: -50vh; top: -50vh;
left: 50vw; 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 { :root {
--primary: #e26f99, --primary: #e26f99,
}; };

View file

@ -120,6 +120,7 @@ export type setSearchStats = React.Dispatch<React.SetStateAction<SearchStats>>;
export type Search = { export type Search = {
date?: DateValue; date?: DateValue;
location?: Bbox; location?: Bbox;
fileIndex?: number;
}; };
export interface SearchStats { export interface SearchStats {
resultCount: number; resultCount: number;
@ -173,6 +174,7 @@ export default function Gallery() {
const [search, setSearch] = useState<Search>({ const [search, setSearch] = useState<Search>({
date: null, date: null,
location: null, location: null,
fileIndex: null,
}); });
const [uploadInProgress, setUploadInProgress] = useState(false); const [uploadInProgress, setUploadInProgress] = useState(false);
const { const {
@ -188,7 +190,7 @@ export default function Gallery() {
}); });
const loadingBar = useRef(null); const loadingBar = useRef(null);
const [searchMode, setSearchMode] = useState(false); const [isInSearchMode, setIsInSearchMode] = useState(false);
const [searchStats, setSearchStats] = useState(null); const [searchStats, setSearchStats] = useState(null);
const syncInProgress = useRef(true); const syncInProgress = useRef(true);
const resync = useRef(false); const resync = useRef(false);
@ -197,11 +199,6 @@ export default function Gallery() {
const [collectionFilesCount, setCollectionFilesCount] = const [collectionFilesCount, setCollectionFilesCount] =
useState<Map<number, number>>(); useState<Map<number, number>>();
const [activeCollection, setActiveCollection] = useState<number>(undefined); const [activeCollection, setActiveCollection] = useState<number>(undefined);
const [isSharedCollectionActive, setIsSharedCollectionActive] =
useState(false);
const [isFavCollectionActive, setIsFavCollectionActive] = useState(false);
const [trash, setTrash] = useState<Trash>([]); const [trash, setTrash] = useState<Trash>([]);
useEffect(() => { useEffect(() => {
@ -267,14 +264,6 @@ export default function Gallery() {
} }
const href = `/gallery${collectionURL}`; const href = `/gallery${collectionURL}`;
router.push(href, undefined, { shallow: true }); router.push(href, undefined, { shallow: true });
setIsSharedCollectionActive(
isSharedCollection(activeCollection, collections)
);
setIsFavCollectionActive(
isFavoriteCollection(activeCollection, collections)
);
}, [activeCollection]); }, [activeCollection]);
const syncWithRemote = async (force = false, silent = false) => { const syncWithRemote = async (force = false, silent = false) => {
@ -483,8 +472,9 @@ export default function Gallery() {
} }
}; };
const updateSearch = (search: Search) => { const updateSearch = (newSearch: Search) => {
setSearch(search); setActiveCollection(ALL_SECTION);
setSearch(newSearch);
setSearchStats(null); setSearchStats(null);
}; };
@ -563,11 +553,12 @@ export default function Gallery() {
attributes={dialogMessage} attributes={dialogMessage}
/> />
<SearchBar <SearchBar
isOpen={searchMode} isOpen={isInSearchMode}
setOpen={setSearchMode} setOpen={setIsInSearchMode}
loadingBar={loadingBar} loadingBar={loadingBar}
isFirstFetch={isFirstFetch} isFirstFetch={isFirstFetch}
collections={collections} collections={collections}
files={files}
setActiveCollection={setActiveCollection} setActiveCollection={setActiveCollection}
setSearch={updateSearch} setSearch={updateSearch}
searchStats={searchStats} searchStats={searchStats}
@ -575,7 +566,7 @@ export default function Gallery() {
<Collections <Collections
collections={collections} collections={collections}
collectionAndTheirLatestFile={collectionsAndTheirLatestFile} collectionAndTheirLatestFile={collectionsAndTheirLatestFile}
searchMode={searchMode} isInSearchMode={isInSearchMode}
activeCollection={activeCollection} activeCollection={activeCollection}
setActiveCollection={setActiveCollection} setActiveCollection={setActiveCollection}
syncWithRemote={syncWithRemote} syncWithRemote={syncWithRemote}
@ -639,13 +630,16 @@ export default function Gallery() {
isFirstLoad={isFirstLoad} isFirstLoad={isFirstLoad}
openFileUploader={openFileUploader} openFileUploader={openFileUploader}
loadingBar={loadingBar} loadingBar={loadingBar}
searchMode={searchMode} isInSearchMode={isInSearchMode}
search={search} search={search}
setSearchStats={setSearchStats} setSearchStats={setSearchStats}
deleted={deleted} deleted={deleted}
setDialogMessage={setDialogMessage} setDialogMessage={setDialogMessage}
activeCollection={activeCollection} activeCollection={activeCollection}
isSharedCollection={isSharedCollectionActive} isSharedCollection={isSharedCollection(
activeCollection,
collections
)}
/> />
{selected.count > 0 && {selected.count > 0 &&
selected.collectionID === activeCollection && ( selected.collectionID === activeCollection && (
@ -688,7 +682,10 @@ export default function Gallery() {
count={selected.count} count={selected.count}
clearSelection={clearSelection} clearSelection={clearSelection}
activeCollection={activeCollection} activeCollection={activeCollection}
isFavoriteCollection={isFavCollectionActive} isFavoriteCollection={isFavoriteCollection(
activeCollection,
collections
)}
/> />
)} )}
{activeCollection === TRASH_SECTION && trash?.length > 0 && ( {activeCollection === TRASH_SECTION && trash?.length > 0 && (

View file

@ -23,7 +23,6 @@ export enum CollectionType {
} }
const COLLECTION_UPDATION_TIME = 'collection-updation-time'; const COLLECTION_UPDATION_TIME = 'collection-updation-time';
const FAV_COLLECTION = 'fav-collection';
const COLLECTIONS = 'collections'; const COLLECTIONS = 'collections';
export interface Collection { export interface Collection {
@ -365,7 +364,11 @@ export const addToFavorites = async (file: File) => {
'Favorites', 'Favorites',
CollectionType.favorites CollectionType.favorites
); );
await localForage.setItem(FAV_COLLECTION, favCollection); const localCollections = await getLocalCollections();
await localForage.setItem(COLLECTIONS, [
...localCollections,
favCollection,
]);
} }
await addToCollection(favCollection, [file]); await addToCollection(favCollection, [file]);
} catch (e) { } catch (e) {

View file

@ -24,7 +24,7 @@ class DownloadManager {
return URL.createObjectURL(await cacheResp.blob()); return URL.createObjectURL(await cacheResp.blob());
} }
if (!this.thumbnailObjectUrlPromise.get(file.id)) { if (!this.thumbnailObjectUrlPromise.get(file.id)) {
const downloadPromise = this._downloadThumb( const downloadPromise = this.downloadThumb(
token, token,
thumbnailCache, thumbnailCache,
file file
@ -35,14 +35,28 @@ class DownloadManager {
} catch (e) { } catch (e) {
this.thumbnailObjectUrlPromise.delete(file.id); this.thumbnailObjectUrlPromise.delete(file.id);
logError(e, 'get preview Failed'); logError(e, 'get preview Failed');
throw e;
} }
} }
_downloadThumb = async ( private downloadThumb = async (
token: string, token: string,
thumbnailCache: Cache, thumbnailCache: Cache,
file: File 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( const resp = await HTTPService.get(
getThumbnailUrl(file.id), getThumbnailUrl(file.id),
null, null,
@ -50,43 +64,38 @@ class DownloadManager {
{ responseType: 'arraybuffer' } { responseType: 'arraybuffer' }
); );
const worker = await new CryptoWorker(); const worker = await new CryptoWorker();
const decrypted: any = await worker.decryptThumbnail( const decrypted: Uint8Array = await worker.decryptThumbnail(
new Uint8Array(resp.data), new Uint8Array(resp.data),
await worker.fromB64(file.thumbnail.decryptionHeader), await worker.fromB64(file.thumbnail.decryptionHeader),
file.key file.key
); );
try { return decrypted;
await thumbnailCache.put(
file.id.toString(),
new Response(new Blob([decrypted]))
);
} catch (e) {
// TODO: handle storage full exception.
}
return URL.createObjectURL(new Blob([decrypted]));
}; };
getFile = async (file: File, forPreview = false) => { 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 { try {
const getFilePromise = (async () => { const getFilePromise = async () => {
const fileStream = await this.downloadFile(file); const fileStream = await this.downloadFile(file);
let fileBlob = await new Response(fileStream).blob(); let fileBlob = await new Response(fileStream).blob();
if (forPreview) { if (forPreview) {
fileBlob = await convertForPreview(file, fileBlob); fileBlob = await convertForPreview(file, fileBlob);
} }
return URL.createObjectURL(fileBlob); return URL.createObjectURL(fileBlob);
})(); };
if (!this.fileObjectUrlPromise.get(`${file.id}_${forPreview}`)) { if (!this.fileObjectUrlPromise.get(fileUID)) {
this.fileObjectUrlPromise.set( this.fileObjectUrlPromise.set(fileUID, getFilePromise());
`${file.id}_${forPreview}`,
getFilePromise
);
} }
return await this.fileObjectUrlPromise.get( return await this.fileObjectUrlPromise.get(fileUID);
`${file.id}_${forPreview}`
);
} catch (e) { } catch (e) {
this.fileObjectUrlPromise.delete(fileUID);
logError(e, 'Failed to get File'); logError(e, 'Failed to get File');
throw e;
} }
}; };

View file

@ -44,8 +44,8 @@ class FFmpegService {
async function generateThumbnailHelper(ffmpeg: FFmpeg, file: File) { async function generateThumbnailHelper(ffmpeg: FFmpeg, file: File) {
try { try {
const inputFileName = `${Date.now().toString}-${file.name}`; const inputFileName = `${Date.now().toString()}-${file.name}`;
const thumbFileName = `${Date.now().toString}-thumb.png`; const thumbFileName = `${Date.now().toString()}-thumb.jpeg`;
ffmpeg.FS( ffmpeg.FS(
'writeFile', 'writeFile',
inputFileName, inputFileName,
@ -62,6 +62,8 @@ async function generateThumbnailHelper(ffmpeg: FFmpeg, file: File) {
`00:00:0${seekTime.toFixed(3)}`, `00:00:0${seekTime.toFixed(3)}`,
'-vframes', '-vframes',
'1', '1',
'-vf',
'scale=-1:720',
thumbFileName thumbFileName
); );
thumb = ffmpeg.FS('readFile', thumbFileName); thumb = ffmpeg.FS('readFile', thumbFileName);

View file

@ -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<number>
) {
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<fileAttribute> {
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;
}
}

View file

@ -4,6 +4,9 @@ import { getToken } from 'utils/common/key';
import { DateValue, Suggestion, SuggestionType } from 'components/SearchBar'; import { DateValue, Suggestion, SuggestionType } from 'components/SearchBar';
import HTTPService from './HTTPService'; import HTTPService from './HTTPService';
import { Collection } from './collectionService'; import { Collection } from './collectionService';
import { File } from './fileService';
import { User } from './userService';
import { getData, LS_KEYS } from 'utils/storage/localStorage';
const ENDPOINT = getEndpoint(); const ENDPOINT = getEndpoint();
const DIGITS = new Set(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']); 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) 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);
}

View file

@ -3,13 +3,21 @@ import { CustomError, errorWithContext } from 'utils/common/errorUtil';
import { logError } from 'utils/sentry'; import { logError } from 'utils/sentry';
import { BLACK_THUMBNAIL_BASE64 } from '../../../public/images/black-thumbnail-b64'; import { BLACK_THUMBNAIL_BASE64 } from '../../../public/images/black-thumbnail-b64';
import FFmpegService from 'services/ffmpegService'; import FFmpegService from 'services/ffmpegService';
import { convertToHumanReadable } from 'utils/billingUtil';
const THUMBNAIL_HEIGHT = 720; const MAX_THUMBNAIL_DIMENSION = 720;
const MAX_ATTEMPTS = 3; const MIN_COMPRESSION_PERCENTAGE_SIZE_DIFF = 10;
const MIN_THUMBNAIL_SIZE = 50000; export 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 = 10 * 1000;
interface Dimension {
width: number;
height: number;
}
export async function generateThumbnail( export async function generateThumbnail(
worker, worker,
file: globalThis.File, file: globalThis.File,
@ -26,7 +34,12 @@ export async function generateThumbnail(
} else { } else {
try { try {
const thumb = await FFmpegService.generateThumbnail(file); 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) { } catch (e) {
canvas = await generateVideoThumbnail(file); canvas = await generateVideoThumbnail(file);
} }
@ -74,16 +87,22 @@ export async function generateImageThumbnail(
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
image.onload = () => { image.onload = () => {
try { try {
const thumbnailWidth = const imageDimension = {
(image.width * THUMBNAIL_HEIGHT) / image.height; width: image.width,
canvas.width = thumbnailWidth; height: image.height,
canvas.height = THUMBNAIL_HEIGHT; };
const thumbnailDimension = calculateThumbnailDimension(
imageDimension,
MAX_THUMBNAIL_DIMENSION
);
canvas.width = thumbnailDimension.width;
canvas.height = thumbnailDimension.height;
canvasCTX.drawImage( canvasCTX.drawImage(
image, image,
0, 0,
0, 0,
thumbnailWidth, thumbnailDimension.width,
THUMBNAIL_HEIGHT thumbnailDimension.height
); );
image = null; image = null;
clearTimeout(timeout); clearTimeout(timeout);
@ -126,16 +145,22 @@ export async function generateVideoThumbnail(file: globalThis.File) {
if (!video) { if (!video) {
throw Error('video load failed'); throw Error('video load failed');
} }
const thumbnailWidth = const videoDimension = {
(video.videoWidth * THUMBNAIL_HEIGHT) / video.videoHeight; width: video.videoWidth,
canvas.width = thumbnailWidth; height: video.videoHeight,
canvas.height = THUMBNAIL_HEIGHT; };
const thumbnailDimension = calculateThumbnailDimension(
videoDimension,
MAX_THUMBNAIL_DIMENSION
);
canvas.width = thumbnailDimension.width;
canvas.height = thumbnailDimension.height;
canvasCTX.drawImage( canvasCTX.drawImage(
video, video,
0, 0,
0, 0,
thumbnailWidth, thumbnailDimension.width,
THUMBNAIL_HEIGHT thumbnailDimension.height
); );
video = null; video = null;
clearTimeout(timeout); clearTimeout(timeout);
@ -166,11 +191,14 @@ export async function generateVideoThumbnail(file: globalThis.File) {
} }
async function thumbnailCanvasToBlob(canvas: HTMLCanvasElement) { async function thumbnailCanvasToBlob(canvas: HTMLCanvasElement) {
let thumbnailBlob = null; let thumbnailBlob: Blob = null;
let attempts = 0; let prevSize = Number.MAX_SAFE_INTEGER;
let quality = 1; let quality = MAX_QUALITY;
do { do {
if (thumbnailBlob) {
prevSize = thumbnailBlob.size;
}
thumbnailBlob = await new Promise((resolve) => { thumbnailBlob = await new Promise((resolve) => {
canvas.toBlob( canvas.toBlob(
function (blob) { function (blob) {
@ -181,12 +209,49 @@ async function thumbnailCanvasToBlob(canvas: HTMLCanvasElement) {
); );
}); });
thumbnailBlob = thumbnailBlob ?? new Blob([]); thumbnailBlob = thumbnailBlob ?? new Blob([]);
attempts++; quality -= 0.1;
quality /= 2;
} while ( } while (
thumbnailBlob.size > MIN_THUMBNAIL_SIZE && quality >= MIN_QUALITY &&
attempts <= MAX_ATTEMPTS 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; 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;
}

View file

@ -11,6 +11,7 @@ import {
import { decodeMotionPhoto } from 'services/motionPhotoService'; import { decodeMotionPhoto } from 'services/motionPhotoService';
import { getMimeTypeFromBlob } from 'services/upload/readFileService'; import { getMimeTypeFromBlob } from 'services/upload/readFileService';
import { EncryptionResult } from 'services/upload/uploadService'; import { EncryptionResult } from 'services/upload/uploadService';
import DownloadManger from 'services/downloadManager';
import { logError } from 'utils/sentry'; import { logError } from 'utils/sentry';
import { User } from 'services/userService'; import { User } from 'services/userService';
import CryptoWorker from 'utils/crypto'; import CryptoWorker from 'utils/crypto';
@ -36,6 +37,20 @@ export function downloadAsFile(filename: string, content: string) {
a.remove(); 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) { export function fileIsHEIC(mimeType: string) {
return ( return (
mimeType.toLowerCase().endsWith(TYPE_HEIC) || mimeType.toLowerCase().endsWith(TYPE_HEIC) ||

View file

@ -0,0 +1,15 @@
const WAIT_FOR_VIDEO_PLAYBACK = 1 * 1000;
export async function isPlaybackPossible(url: string): Promise<boolean> {
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;
});
}

View file

@ -9,7 +9,7 @@ export const logError = (
) => { ) => {
const err = errorWithContext(e, msg); const err = errorWithContext(e, msg);
if (!process.env.NEXT_PUBLIC_SENTRY_ENV) { if (!process.env.NEXT_PUBLIC_SENTRY_ENV) {
console.log(err); console.log(e);
} }
Sentry.captureException(err, { Sentry.captureException(err, {
level: Sentry.Severity.Info, level: Sentry.Severity.Info,
@ -18,6 +18,7 @@ export const logError = (
...(info && { ...(info && {
info: info, info: info,
}), }),
rootCause: { message: e?.message },
}, },
}); });
}; };

View file

@ -12,6 +12,7 @@ export enum LS_KEYS {
SHOW_BACK_BUTTON = 'showBackButton', SHOW_BACK_BUTTON = 'showBackButton',
EXPORT = 'export', EXPORT = 'export',
AnonymizeUserID = 'anonymizedUserID', AnonymizeUserID = 'anonymizedUserID',
THUMBNAIL_FIX_STATE = 'thumbnailFixState',
} }
export const setData = (key: LS_KEYS, value: object) => { export const setData = (key: LS_KEYS, value: object) => {

View file

@ -548,9 +548,6 @@ const englishConstants = {
MOVE: 'move', MOVE: 'move',
ADD: 'add', ADD: 'add',
SORT: 'sort', SORT: 'sort',
SORT_BY_LATEST_PHOTO: 'most recent photo',
SORT_BY_MODIFICATION_TIME: 'last modified',
SORT_BY_COLLECTION_NAME: 'album title',
REMOVE: 'remove', REMOVE: 'remove',
CONFIRM_REMOVE: 'confirm removal', CONFIRM_REMOVE: 'confirm removal',
TRASH: 'trash', TRASH: 'trash',
@ -571,14 +568,31 @@ const englishConstants = {
CONFIRM_REMOVE_MESSAGE: () => ( CONFIRM_REMOVE_MESSAGE: () => (
<> <>
<p> <p>are you sure you want to remove these files from the album?</p>
are you sure you want to remove these files from the collection?
</p>
<p> <p>
all files that are unique to this album will be moved to trash all files that are unique to this album will be moved to trash
</p> </p>
</> </>
), ),
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; export default englishConstants;