UX: Pseudo-virtualize mosaic, cards and list view (#2292)

Related / Follow-Up Issues:

- #85
- #152
- #307
- #583
- #1582
- #1623
This commit is contained in:
Heiko Mathes 2022-06-17 04:15:20 +02:00 committed by GitHub
parent 0402b8d397
commit d776e9cf83
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 471 additions and 147 deletions

View file

@ -58,6 +58,7 @@
"karma-webpack": "^5.0.0",
"luxon": "^2.4.0",
"maplibre-gl": "^2.1.9",
"memoize-one": "^6.0.0",
"mini-css-extract-plugin": "^2.6.1",
"minimist": ">=1.2.5",
"mocha": "^10.0.0",
@ -8109,6 +8110,11 @@
"node": ">= 0.6"
}
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
},
"node_modules/merge-descriptors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
@ -18761,6 +18767,11 @@
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="
},
"memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
},
"merge-descriptors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",

View file

@ -73,6 +73,7 @@
"karma-webpack": "^5.0.0",
"luxon": "^2.4.0",
"maplibre-gl": "^2.1.9",
"memoize-one": "^6.0.0",
"mini-css-extract-plugin": "^2.6.1",
"minimist": ">=1.2.5",
"mocha": "^10.0.0",

View file

@ -0,0 +1,59 @@
export const virtualizationTools = {
updateVisibleElementIndices: (visibleElementIndices, entries, elementIndexFromEntry) => {
entries.forEach((entry) => {
const inView = entry.isIntersecting && entry.intersectionRatio >= 0;
const elementIndex = elementIndexFromEntry(entry);
if (elementIndex === undefined || elementIndex < 0) {
return;
}
if (inView) {
visibleElementIndices.add(elementIndex);
} else {
/**
* If the target has no parent-node, it's no longer in the dom-tree.
* If the element is no longer inView because it was removed from the
* dom-tree, then this says nothing about the visible indices.
* If you remove a picture from the grid, the space where the picture
* was is still visible.
*
* We therefore must ignore entries that became invisible that no longer
* exists
*/
const entryIsStillMounted = entry.target.parentNode !== null;
if (entryIsStillMounted) {
visibleElementIndices.delete(elementIndex);
}
}
});
/**
* There are many things that can influence what elements are currently
* visible on the screen, like scrolling, resizing, menu-opening etc.
*
* We therefore cannot make assumptions about our new first- and last
* visible index, even if it is tempting to initialize these values
* with this.firstVisibleElementIndex and this.lastVisibleElementIndex.
*
* Doing so would break the virtualization though. this.firstVisibleElementIndex
* would for example always stay at 0
*/
let firstVisibleElementIndex, lastVisibileElementIndex;
for (const visibleElementIndex of visibleElementIndices.values()) {
if (
firstVisibleElementIndex === undefined ||
visibleElementIndex < firstVisibleElementIndex
) {
firstVisibleElementIndex = visibleElementIndex;
}
if (
lastVisibileElementIndex === undefined ||
visibleElementIndex > lastVisibileElementIndex
) {
lastVisibileElementIndex = visibleElementIndex;
}
}
return [firstVisibleElementIndex, lastVisibileElementIndex];
},
};

View file

@ -1,30 +1,76 @@
<template>
<v-container grid-list-xs fluid class="pa-2 p-photos p-photo-cards">
<v-alert
:value="photos.length === 0"
color="secondary-dark" icon="lightbulb_outline" class="no-results ma-2 opacity-70" outline
>
<h3 v-if="filter.order === 'edited'" class="body-2 ma-0 pa-0">
<translate>No recently edited pictures</translate>
</h3>
<h3 v-else class="body-2 ma-0 pa-0">
<translate>No pictures found</translate>
</h3>
<p class="body-1 mt-2 mb-0 pa-0">
<translate>Try again using other filters or keywords.</translate>
<translate>In case pictures you expect are missing, please rescan your library and wait until indexing has been completed.</translate>
<template v-if="$config.feature('review')">
<translate>Non-photographic and low-quality images require a review before they appear in search results.</translate>
</template>
</p>
</v-alert>
<template v-if="photos.length === 0">
<v-alert
:value="true"
color="secondary-dark" icon="lightbulb_outline" class="no-results ma-2 opacity-70" outline
>
<h3 v-if="filter.order === 'edited'" class="body-2 ma-0 pa-0">
<translate>No recently edited pictures</translate>
</h3>
<h3 v-else class="body-2 ma-0 pa-0">
<translate>No pictures found</translate>
</h3>
<p class="body-1 mt-2 mb-0 pa-0">
<translate>Try again using other filters or keywords.</translate>
<translate>In case pictures you expect are missing, please rescan your library and wait until indexing has been completed.</translate>
<template v-if="$config.feature('review')">
<translate>Non-photographic and low-quality images require a review before they appear in search results.</translate>
</template>
</p>
</v-alert>
</template>
<v-layout row wrap class="search-results photo-results cards-view" :class="{'select-results': selectMode}">
<v-flex
v-for="(photo, index) in photos"
ref="items"
:key="photo.ID"
:data-index="index"
style="width: min-content"
xs12 sm6 md4 lg3 xlg2 xxxl1 d-flex
>
<v-card tile
<div v-if="index < firstVisibleElementIndex || index > lastVisibileElementIndex"
style="user-select: none"
class="accent lighten-3 result"
:class="photo.classes()"
>
<div class="accent lighten-2" style="aspect-ratio: 1" />
<div v-if="photo.Quality < 3 && context === 'review'" style="width: 100%; height: 34px"/>
<div class="v-card__title pa-3 card-details v-card__title--primary">
<div>
<h3 class="body-2 mb-2" :title="photo.Title">
{{ photo.Title | truncate(80) }}
</h3>
<div v-if="photo.Description" class="caption mb-2" style="hyphens: auto; word-break: break-word">
{{ photo.Description }}
</div>
<div class="caption" style="hyphens: auto; word-break: break-word">
<i style="display: inline-block; width: 14px" />
{{ photo.getDateString(true) }}
<br>
<i style="display: inline-block; width: 14px" />
<template v-if="photo.Type === 'video' || photo.Type === 'animated'">
{{ photo.getVideoInfo() }}
</template>
<template v-else>
{{ photo.getPhotoInfo() }}
</template>
<template v-if="filter.order === 'name' && $config.feature('download')">
<br>
<i style="display: inline-block; width: 14px" />
{{ photo.baseName() }}
</template>
<template v-if="featPlaces && photo.Country !== 'zz'">
<br>
<i style="display: inline-block; width: 14px" />
{{ photo.locationInfo() }}
</template>
</div>
</div>
</div>
</div>
<v-card v-if="index >= firstVisibleElementIndex && index <= lastVisibileElementIndex"
tile
:data-id="photo.ID"
:data-uid="photo.UID"
style="user-select: none"
@ -197,6 +243,7 @@
import download from "common/download";
import Notify from "common/notify";
import {Input, InputInvalid, ClickShort, ClickLong} from "common/input";
import {virtualizationTools} from 'common/virtualization-tools';
export default {
name: 'PPhotoCards',
@ -244,9 +291,53 @@ export default {
featPrivate,
debug,
input,
firstVisibleElementIndex: 0,
lastVisibileElementIndex: 0,
visibleElementIndices: new Set(),
};
},
watch: {
photos: {
handler() {
this.$nextTick(() => {
this.observeItems();
});
},
immediate: true,
}
},
beforeCreate() {
this.intersectionObserver = new IntersectionObserver((entries) => {
this.visibilitiesChanged(entries);
}, {
rootMargin: "50% 0px",
});
},
beforeDestroy() {
this.intersectionObserver.disconnect();
},
methods: {
observeItems() {
if (this.$refs.items === undefined) {
return;
}
for (const item of this.$refs.items) {
this.intersectionObserver.observe(item);
}
},
elementIndexFromIntersectionObserverEntry(entry) {
return parseInt(entry.target.getAttribute('data-index'));
},
visibilitiesChanged(entries) {
const [smallestIndex, largestIndex] = virtualizationTools.updateVisibleElementIndices(
this.visibleElementIndices,
entries,
this.elementIndexFromIntersectionObserverEntry,
);
this.firstVisibleElementIndex = smallestIndex;
this.lastVisibileElementIndex = largestIndex;
},
livePlayer(photo) {
return document.querySelector("#live-player-" + photo.ID);
},

View file

@ -21,6 +21,7 @@
</v-alert>
</div>
<v-data-table v-else
ref="dataTable"
v-model="selected"
:headers="listColumns"
:items="photos"
@ -30,32 +31,40 @@
disable-initial-sort
item-key="ID"
:no-data-text="notFoundMessage"
>
<template #items="props">
<td style="user-select: none;" :data-uid="props.item.UID" class="result" :class="props.item.classes()">
<v-img :key="props.item.Hash"
:src="props.item.thumbnailUrl('tile_50')"
:alt="props.item.Title"
:transition="false"
aspect-ratio="1"
style="user-select: none"
class="accent lighten-2 clickable"
@touchstart="onMouseDown($event, props.index)"
@touchend.stop.prevent="onClick($event, props.index)"
@mousedown="onMouseDown($event, props.index)"
@contextmenu.stop="onContextMenu($event, props.index)"
@click.stop.prevent="onClick($event, props.index)"
<div
v-if="props.index < firstVisibleElementIndex || props.index > lastVisibileElementIndex"
class="v-image accent lighten-2"
style="aspect-ratio: 1"
/>
<v-img
v-if="props.index >= firstVisibleElementIndex && props.index <= lastVisibileElementIndex"
:key="props.item.Hash"
:src="props.item.thumbnailUrl('tile_50')"
:alt="props.item.Title"
:transition="false"
aspect-ratio="1"
style="user-select: none"
class="accent lighten-2 clickable"
@touchstart="onMouseDown($event, props.index)"
@touchend.stop.prevent="onClick($event, props.index)"
@mousedown="onMouseDown($event, props.index)"
@contextmenu.stop="onContextMenu($event, props.index)"
@click.stop.prevent="onClick($event, props.index)"
>
<v-btn v-if="selectMode" :ripple="false"
flat icon large absolute
class="input-select">
flat icon large absolute
class="input-select">
<v-icon color="white" class="select-on">check_circle</v-icon>
<v-icon color="white" class="select-off">radio_button_off</v-icon>
</v-btn>
<v-btn v-else-if="props.item.Type === 'video' || props.item.Type === 'live' || props.item.Type === 'animated'"
:ripple="false"
flat icon large absolute class="input-open"
@click.stop.prevent="openPhoto(props.index, true)">
:ripple="false"
flat icon large absolute class="input-open"
@click.stop.prevent="openPhoto(props.index, true)">
<v-icon color="white" class="default-hidden action-live" :title="$gettext('Live')">$vuetify.icons.live_photo</v-icon>
<v-icon color="white" class="default-hidden action-animated" :title="$gettext('Animated')">gif</v-icon>
<v-icon color="white" class="default-hidden action-play" :title="$gettext('Video')">play_arrow</v-icon>
@ -92,18 +101,25 @@
</span>
</td>
<td class="text-xs-center">
<v-btn v-if="hidePrivate" class="input-private" icon small flat :ripple="false"
:data-uid="props.item.UID" @click.stop.prevent="props.item.togglePrivate()">
<v-icon v-if="props.item.Private" color="secondary-dark" class="select-on">lock</v-icon>
<v-icon v-else color="secondary" class="select-off">lock_open</v-icon>
</v-btn>
<v-btn class="input-like" icon small flat :ripple="false"
:data-uid="props.item.UID" @click.stop.prevent="props.item.toggleLike()">
<v-icon v-if="props.item.Favorite" color="pink lighten-3" :data-uid="props.item.UID" class="select-on">
favorite
</v-icon>
<v-icon v-else color="secondary" :data-uid="props.item.UID" class="select-off">favorite_border</v-icon>
</v-btn>
<template v-if="props.index < firstVisibleElementIndex || props.index > lastVisibileElementIndex">
<div v-if="hidePrivate" class="v-btn v-btn--icon v-btn--small" />
<div class="v-btn v-btn--icon v-btn--small" />
</template>
<template v-else>
<v-btn v-if="hidePrivate" class="input-private" icon small flat :ripple="false"
:data-uid="props.item.UID" @click.stop.prevent="props.item.togglePrivate()">
<v-icon v-if="props.item.Private" color="secondary-dark" class="select-on">lock</v-icon>
<v-icon v-else color="secondary" class="select-off">lock_open</v-icon>
</v-btn>
<v-btn class="input-like" icon small flat :ripple="false"
:data-uid="props.item.UID" @click.stop.prevent="props.item.toggleLike()">
<v-icon v-if="props.item.Favorite" color="pink lighten-3" :data-uid="props.item.UID" class="select-on">
favorite
</v-icon>
<v-icon v-else color="secondary" :data-uid="props.item.UID" class="select-off">favorite_border</v-icon>
</v-btn>
</template>
</td>
</template>
</v-data-table>
@ -112,6 +128,7 @@
<script>
import download from "common/download";
import Notify from "common/notify";
import {virtualizationTools} from 'common/virtualization-tools';
export default {
name: 'PPhotoList',
@ -184,9 +201,54 @@ export default {
scrollY: window.scrollY,
timeStamp: -1,
},
firstVisibleElementIndex: 0,
lastVisibileElementIndex: 0,
visibleElementIndices: new Set(),
};
},
watch: {
photos: {
handler() {
this.$nextTick(() => {
this.observeItems();
});
},
immediate: true,
}
},
beforeCreate() {
this.intersectionObserver = new IntersectionObserver((entries) => {
this.visibilitiesChanged(entries);
}, {
rootMargin: "100% 0px",
});
},
beforeDestroy() {
this.intersectionObserver.disconnect();
},
methods: {
observeItems() {
if (this.$refs.dataTable === undefined) {
return;
}
const rows = this.$refs.dataTable.$el.getElementsByTagName('tbody')[0].children;
for (const row of rows) {
this.intersectionObserver.observe(row);
}
},
elementIndexFromIntersectionObserverEntry(entry) {
return entry.target.rowIndex - 2;
},
visibilitiesChanged(entries) {
const [smallestIndex, largestIndex] = virtualizationTools.updateVisibleElementIndices(
this.visibleElementIndices,
entries,
this.elementIndexFromIntersectionObserverEntry,
);
this.firstVisibleElementIndex = smallestIndex;
this.lastVisibileElementIndex = largestIndex;
},
downloadFile(index) {
Notify.success(this.$gettext("Downloading…"));

View file

@ -1,43 +1,54 @@
<template>
<v-container grid-list-xs fluid class="pa-2 p-photos p-photo-mosaic">
<v-alert
:value="photos.length === 0"
color="secondary-dark" icon="lightbulb_outline" class="no-results ma-2 opacity-70" outline
>
<h3 v-if="filter.order === 'edited'" class="body-2 ma-0 pa-0">
<translate>No recently edited pictures</translate>
</h3>
<h3 v-else class="body-2 ma-0 pa-0">
<translate>No pictures found</translate>
</h3>
<p class="body-1 mt-2 mb-0 pa-0">
<translate>Try again using other filters or keywords.</translate>
<translate>In case pictures you expect are missing, please rescan your library and wait until indexing has been completed.</translate>
<template v-if="$config.feature('review')">
<translate>Non-photographic and low-quality images require a review before they appear in search results.</translate>
</template>
</p>
</v-alert>
<template v-if="photos.length === 0">
<v-alert
:value="true"
color="secondary-dark" icon="lightbulb_outline" class="no-results ma-2 opacity-70" outline
>
<h3 v-if="filter.order === 'edited'" class="body-2 ma-0 pa-0">
<translate>No recently edited pictures</translate>
</h3>
<h3 v-else class="body-2 ma-0 pa-0">
<translate>No pictures found</translate>
</h3>
<p class="body-1 mt-2 mb-0 pa-0">
<translate>Try again using other filters or keywords.</translate>
<translate>In case pictures you expect are missing, please rescan your library and wait until indexing has been completed.</translate>
<template v-if="$config.feature('review')">
<translate>Non-photographic and low-quality images require a review before they appear in search results.</translate>
</template>
</p>
</v-alert>
</template>
<v-layout row wrap class="search-results photo-results mosaic-view" :class="{'select-results': selectMode}">
<v-flex
v-for="(photo, index) in photos"
ref="items"
:key="photo.ID"
:data-index="index"
xs4 sm3 md2 lg1 d-flex
>
<v-card tile
<div v-if="index < firstVisibleElementIndex || index > lastVisibileElementIndex"
style="user-select: none; aspect-ratio: 1"
class="accent lighten-2 result"
:class="photo.classes()"/>
<v-card v-if="index >= firstVisibleElementIndex && index <= lastVisibileElementIndex"
tile
:data-id="photo.ID"
:data-uid="photo.UID"
style="user-select: none"
class="result"
style="user-select: none; aspect-ratio: 1"
class="accent lighten-2 result"
:class="photo.classes()"
@contextmenu.stop="onContextMenu($event, index)">
<v-img :key="photo.Hash"
<v-img
:key="photo.Hash"
:src="photo.thumbnailUrl('tile_224')"
:alt="photo.Title"
:title="photo.Title"
:transition="false"
aspect-ratio="1"
class="accent lighten-2 clickable"
class="clickable"
@touchstart.passive="input.touchStart($event, index)"
@touchend.stop.prevent="onClick($event, index)"
@mousedown.stop.prevent="input.mouseDown($event, index)"
@ -118,6 +129,7 @@
</template>
<script>
import {Input, InputInvalid, ClickShort, ClickLong} from "common/input";
import {virtualizationTools} from 'common/virtualization-tools';
export default {
name: 'PPhotoMosaic',
@ -152,9 +164,53 @@ export default {
return {
hidePrivate: this.$config.settings().features.private,
input: new Input(),
firstVisibleElementIndex: 0,
lastVisibileElementIndex: 0,
visibleElementIndices: new Set(),
};
},
watch: {
photos: {
handler() {
this.$nextTick(() => {
this.observeItems();
});
},
immediate: true,
}
},
beforeCreate() {
this.intersectionObserver = new IntersectionObserver((entries) => {
this.visibilitiesChanged(entries);
}, {
rootMargin: "50% 0px",
});
},
beforeDestroy() {
this.intersectionObserver.disconnect();
},
methods: {
observeItems() {
if (this.$refs.items === undefined) {
return;
}
for (const item of this.$refs.items) {
this.intersectionObserver.observe(item);
}
},
elementIndexFromIntersectionObserverEntry(entry) {
return parseInt(entry.target.getAttribute('data-index'));
},
visibilitiesChanged(entries) {
const [smallestIndex, largestIndex] = virtualizationTools.updateVisibleElementIndices(
this.visibleElementIndices,
entries,
this.elementIndexFromIntersectionObserverEntry,
);
this.firstVisibleElementIndex = smallestIndex;
this.lastVisibileElementIndex = largestIndex;
},
livePlayer(photo) {
return document.querySelector("#live-player-" + photo.ID);
},

View file

@ -23,6 +23,8 @@ Additional information can be found in our Developer Guide:
*/
import memoizeOne from 'memoize-one';
import RestModel from "model/rest";
import File from "model/file";
import Marker from "model/marker";
@ -179,17 +181,21 @@ export class Photo extends RestModel {
}
classes() {
return this.generateClasses(this.isPlayable(), Clipboard.has(this), this.Portrait, this.Favorite, this.Private, this.Files.length > 1)
}
generateClasses = memoizeOne((isPlayable, isInClipboard, portrait, favorite, isPrivate, hasMultipleFiles) => {
let classes = ["is-photo", "uid-" + this.UID, "type-" + this.Type];
if (this.isPlayable()) classes.push("is-playable");
if (Clipboard.has(this)) classes.push("is-selected");
if (this.Portrait) classes.push("is-portrait");
if (this.Favorite) classes.push("is-favorite");
if (this.Private) classes.push("is-private");
if (this.Files.length > 1) classes.push("is-stack");
if (isPlayable) classes.push("is-playable");
if (isInClipboard) classes.push("is-selected");
if (portrait) classes.push("is-portrait");
if (favorite) classes.push("is-favorite");
if (isPrivate) classes.push("is-private");
if (hasMultipleFiles) classes.push("is-stack");
return classes;
}
})
localDayString() {
if (!this.TakenAtLocal) {
@ -295,7 +301,7 @@ export class Photo extends RestModel {
let iso = this.localDateString(time);
let zone = this.getTimeZone();
if (this.getTimeZone() === "") {
if (zone === "") {
zone = "UTC";
}
@ -303,9 +309,13 @@ export class Photo extends RestModel {
}
utcDate() {
return DateTime.fromISO(this.TakenAt).toUTC();
return this.generateUtcDate(this.TakenAt);
}
generateUtcDate = memoizeOne((takenAt) => {
return DateTime.fromISO(takenAt).toUTC();
})
baseName(truncate) {
let result = this.fileBase(this.FileName ? this.FileName : this.mainFile().Name);
@ -352,14 +362,18 @@ export class Photo extends RestModel {
}
isPlayable() {
if (this.Type === MediaAnimated) {
return this.generateIsPlayable(this.Type, this.Files);
}
generateIsPlayable = memoizeOne((type, files) => {
if (type === MediaAnimated) {
return true;
} else if (!this.Files) {
} else if (!files) {
return false;
}
return this.Files.findIndex((f) => f.Video) !== -1;
}
return files.some((f) => f.Video);
})
videoParams() {
const uri = this.videoUrl();
@ -416,18 +430,22 @@ export class Photo extends RestModel {
}
videoFile() {
if (!this.Files) {
return this.getVideoFileFromFiles(this.Files);
}
getVideoFileFromFiles = memoizeOne((files) => {
if (!files) {
return false;
}
let file = this.Files.find((f) => f.Codec === CodecAvc1);
let file = files.find((f) => f.Codec === CodecAvc1);
if (!file) {
file = this.Files.find((f) => f.FileType === FormatMp4);
file = files.find((f) => f.FileType === FormatMp4);
}
if (!file) {
file = this.Files.find((f) => !!f.Video);
file = files.find((f) => !!f.Video);
}
if (!file) {
@ -435,7 +453,7 @@ export class Photo extends RestModel {
}
return file;
}
})
gifFile() {
if (!this.Files) {
@ -456,18 +474,22 @@ export class Photo extends RestModel {
}
mainFile() {
if (!this.Files) {
return this.getMainFileFromFiles(this.Files)
}
getMainFileFromFiles = memoizeOne((files) => {
if (!files) {
return this;
}
let file = this.Files.find((f) => !!f.Primary);
let file = files.find((f) => !!f.Primary);
if (file) {
return file;
}
return this.Files.find((f) => f.FileType === FormatJpeg);
}
return files.find((f) => f.FileType === FormatJpeg);
})
jpegFiles() {
if (!this.Files) {
@ -478,18 +500,20 @@ export class Photo extends RestModel {
}
mainFileHash() {
if (this.Files) {
let file = this.mainFile();
return this.generateMainFileHash(this.mainFile(), this.Hash);
}
if (file && file.Hash) {
return file.Hash;
generateMainFileHash = memoizeOne((mainFile, hash) => {
if (this.Files) {
if (mainFile && mainFile.Hash) {
return mainFile.Hash;
}
} else if (this.Hash) {
return this.Hash;
} else if (hash) {
return hash;
}
return "";
}
});
fileModels() {
let result = [];
@ -516,20 +540,22 @@ export class Photo extends RestModel {
}
thumbnailUrl(size) {
let hash = this.mainFileHash();
return this.generateThumbnailUrl(this.mainFileHash(), this.videoFile(), config.contentUri, config.previewToken(), size);
}
generateThumbnailUrl = memoizeOne((mainFileHash, videoFile, contentUri, previewToken, size) => {
let hash = mainFileHash;
if (!hash) {
let video = this.videoFile();
if (video && video.Hash) {
return `${config.contentUri}/t/${video.Hash}/${config.previewToken()}/${size}`;
if (videoFile && videoFile.Hash) {
return `${contentUri}/t/${videoFile.Hash}/${previewToken}/${size}`;
}
return `${config.contentUri}/svg/photo`;
return `${contentUri}/svg/photo`;
}
return `${config.contentUri}/t/${hash}/${config.previewToken()}/${size}`;
}
return `${contentUri}/t/${hash}/${previewToken}/${size}`;
})
getDownloadUrl() {
return `${config.apiUri}/dl/${this.mainFileHash()}?t=${config.downloadToken()}`;
@ -616,33 +642,41 @@ export class Photo extends RestModel {
}
getDateString(showTimeZone) {
if (!this.TakenAt || this.Year === YearUnknown) {
return this.generateDateString(showTimeZone, this.TakenAt, this.Year, this.Month, this.Day, this.TimeZone);
}
generateDateString = memoizeOne((showTimeZone, takenAt, year, month, day, timeZone) => {
if (!takenAt || year === YearUnknown) {
return $gettext("Unknown");
} else if (this.Month === MonthUnknown) {
} else if (month === MonthUnknown) {
return this.localYearString();
} else if (this.Day === DayUnknown) {
} else if (day === DayUnknown) {
return this.localDate().toLocaleString({
month: long,
year: num,
});
} else if (this.TimeZone) {
} else if (timeZone) {
return this.localDate().toLocaleString(showTimeZone ? DATE_FULL_TZ : DATE_FULL);
}
return this.localDate().toLocaleString(DateTime.DATE_HUGE);
}
})
shortDateString() {
if (!this.TakenAt || this.Year === YearUnknown) {
shortDateString = () => {
return this.generateShortDateString(this.TakenAt, this.Year, this.Month, this.Day)
}
generateShortDateString = memoizeOne((takenAt, year, month, day) => {
if (!takenAt || year === YearUnknown) {
return $gettext("Unknown");
} else if (this.Month === MonthUnknown) {
} else if (month === MonthUnknown) {
return this.localYearString();
} else if (this.Day === DayUnknown) {
} else if (day === DayUnknown) {
return this.localDate().toLocaleString({ month: "long", year: "numeric" });
}
return this.localDate().toLocaleString(DateTime.DATE_MED);
}
})
hasLocation() {
return this.Lat !== 0 || this.Lng !== 0;
@ -660,19 +694,23 @@ export class Photo extends RestModel {
return $gettext("Unknown");
}
locationInfo() {
if (this.PlaceID === "zz" && this.Country !== "zz") {
const country = countries.find((c) => c.Code === this.Country);
locationInfo = () => {
return this.generateLocationInfo(this.PlaceID, this.Country, this.Place, this.PlaceLabel)
}
generateLocationInfo = memoizeOne((placeId, countryCode, place, placeLabel) => {
if (placeId === "zz" && countryCode !== "zz") {
const country = countries.find((c) => c.Code === countryCode);
if (country) {
return country.Name;
}
} else if (this.Place && this.Place.Label) {
return this.Place.Label;
} else if (place && place.Label) {
return place.Label;
}
return this.PlaceLabel ? this.PlaceLabel : $gettext("Unknown");
}
return placeLabel ? placeLabel : $gettext("Unknown");
})
addSizeInfo(file, info) {
if (!file) {
@ -699,18 +737,19 @@ export class Photo extends RestModel {
}
}
getVideoInfo() {
let info = [];
let file = this.videoFile();
if (!file) {
file = this.mainFile();
}
getVideoInfo = () => {
let file = this.videoFile() || this.mainFile();
return this.generateVideoInfo(file)
}
generateVideoInfo = memoizeOne((file) => {
if (!file) {
return $gettext("Video");
}
const info = [];
if (file.Duration > 0) {
info.push(Util.duration(file.Duration));
}
@ -726,30 +765,35 @@ export class Photo extends RestModel {
}
return info.join(", ");
})
getPhotoInfo = () => {
let file = this.videoFile();
if (!file || !file.Width) {
file = this.mainFile();
}
return this.generatePhotoInfo(this.Camera, this.CameraModel, this.CameraMake, file);
}
getPhotoInfo() {
generatePhotoInfo = memoizeOne((camera, cameraModel, cameraMake, file) => {
let info = [];
if (this.Camera) {
if (this.Camera.Model.length > 7) {
info.push(this.Camera.Model);
if (camera) {
if (camera.Model.length > 7) {
info.push(camera.Model);
} else {
info.push(this.Camera.Make + " " + this.Camera.Model);
info.push(camera.Make + " " + camera.Model);
}
} else if (this.CameraModel && this.CameraMake) {
if (this.CameraModel.length > 7) {
info.push(this.CameraModel);
} else if (cameraModel && cameraMake) {
if (cameraModel.length > 7) {
info.push(cameraModel);
} else {
info.push(this.CameraMake + " " + this.CameraModel);
info.push(cameraMake + " " + cameraModel);
}
}
let file = this.videoFile();
if (!file || !file.Width) {
file = this.mainFile();
} else if (file.Codec) {
if (file && file.Width && file.Codec) {
info.push(file.Codec.toUpperCase());
}
@ -760,7 +804,7 @@ export class Photo extends RestModel {
}
return info.join(", ");
}
})
getCamera() {
if (this.Camera) {