فهرست منبع

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

Related / Follow-Up Issues:

- #85
- #152
- #307
- #583
- #1582
- #1623
Heiko Mathes 3 سال پیش
والد
کامیت
d776e9cf83

+ 11 - 0
frontend/package-lock.json

@@ -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",

+ 1 - 0
frontend/package.json

@@ -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",

+ 59 - 0
frontend/src/common/virtualization-tools.js

@@ -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];
+  },
+};

+ 110 - 19
frontend/src/component/photo/cards.vue

@@ -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);
     },

+ 91 - 29
frontend/src/component/photo/list.vue

@@ -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…"));
 

+ 79 - 23
frontend/src/component/photo/mosaic.vue

@@ -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);
     },

+ 120 - 76
frontend/src/model/photo.js

@@ -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);
+  }
 
-    if (!hash) {
-      let video = this.videoFile();
+  generateThumbnailUrl = memoizeOne((mainFileHash, videoFile, contentUri, previewToken, size) => {
+    let hash = mainFileHash;
 
-      if (video && video.Hash) {
-        return `${config.contentUri}/t/${video.Hash}/${config.previewToken()}/${size}`;
+    if (!hash) {
+      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) {