瀏覽代碼

UX: Ssearch view render performance improvements (#2433)

* virtualize mosaic-view

* start virtualizing parts of the list view

* drastically improve mosaic component updating performance by not rendering cards if not necessary

* speed up list view virtualization by preventing the rendering of vue-components

* start virtualizing cards view

* continue virtualizing card view

* finish virtualizing cards

* start moving common virtualization logic into shared file

* try speeding up access to calculated photo details

* remove console.log

* improve accuracy of cards-placeholder elements

* remove console.log

* start fixing memoized values not updating on change

* fixing memoized values not updating on change

* remove console.logs

* fix getting location info after memoizing the function

* remove obsolete comment

* start rendering of only visible icons in mosaic-view

* continue rendering of only visible icons in mosaic-view

* implement rendering of only visible icons in cards-view

* implement rendering of only visible icons in list-view

* memoize some photo-model functions that are called when rendering mosaic- or cards-view

* fix cards sometimes getting smaller when scrolling

* improve fix for cards sometimes getting smaller when scrolling

* prevent rendering of no-photos-alert if photos are present

* fix selection issues after implementing conditional button-rendering

* speedup mosaic rendering by replacing vue components with html components

* speedup cards rendering by replacing vue components with html components

* speedup list rendering by replacing vue components with html components

* fix removed elements leaving behind placeholders in view

* speedup photo view rendering by replacing vue buttons and icons with regular html components

* fix positioning of card-title in placeholder elements

* fix missing icons after multiselect or select via touch

* prevent flickering of favourite button on click

* prevent flickering of favourite button on click

* use div instead of v-flex

* replace inline styles with css classes

* re-add actually necessary css-classes

* add size-containment to mosaic-elements to reduce re-layouting costs when virtualization replaces children

* fix typo in comment

* use plain html instead of components and css-classes instead of inline styles in cards.vue

* improve list scrolling by not using v-data-table for a static table

* fix icon-color on card-details in light-theme

* fix card-details showing wrong icons with wrong height on pixel 3

* fix animated gif-previews getting out of image-bounds when hovering in firefox

* fix closing brackets in mosaic view not matching opening brackets

* fix live-photo icon in mosaic- cards and list components

* improve render performance by replacing v-hover vue-component with css-hidden button

* prevent unnecessary rerenders

* prevent unnecessary rerenders

* undo "prevent unnecessary rerender" because the real-world-effect is negligable

* load next batch earlier to reduce change of scrolling to the end before the load finished

* add explanation on why the selection-button isnt removed via v-if

* remove console.log

* speed up rendering by reducing amount of observed items

* fix favourite-buttons in non-search views

* prevent unnecessary observeItems-calls by only observing items when photos changed

Co-authored-by: Heiko Mathes <heiko@pop-os.localdomain>
Heiko Mathes 3 年之前
父節點
當前提交
580de58346

+ 135 - 114
frontend/src/component/photo/cards.vue

@@ -21,34 +21,29 @@
       </v-alert>
     </template>
     <v-layout row wrap class="search-results photo-results cards-view" :class="{'select-results': selectMode}">
-      <v-flex
+      <div
           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
+          class="flex xs12 sm6 md4 lg3 xlg2 xxxl1 d-flex"
       >
-        <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="index < firstVisibleElementIndex || index > lastVisibileElementIndex" class="accent lighten-3 result placeholder">
+          <div class="accent lighten-2 image"/>
           <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 class="pa-3 card-details">
             <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">
+              <div v-if="photo.Description" class="caption mb-2">
                 {{ photo.Description }}
               </div>
-              <div class="caption" style="hyphens: auto; word-break: break-word">
-                  <i style="display: inline-block; width: 14px" />
+              <div class="caption">
+                  <i/>
                   {{ photo.getDateString(true) }}
                 <br>
-                <i style="display: inline-block; width: 14px" />
+                <i/>
                 <template v-if="photo.Type === 'video' || photo.Type === 'animated'">
                   {{ photo.getVideoInfo() }}
                 </template>
@@ -57,130 +52,125 @@
                 </template>
                 <template v-if="filter.order === 'name' && $config.feature('download')">
                   <br>
-                  <i style="display: inline-block; width: 14px" />
+                  <i/>
                   {{ photo.baseName() }}
                 </template>
                 <template v-if="featPlaces && photo.Country !== 'zz'">
                   <br>
-                  <i style="display: inline-block; width: 14px" />
+                  <i/>
                   {{ 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"
-                class="result accent lighten-3"
-                :class="photo.classes()"
-                @contextmenu.stop="onContextMenu($event, index)">
+        <div v-else
+              tile
+              :data-id="photo.ID"
+              :data-uid="photo.UID"
+              class="result accent lighten-3"
+              :class="photo.classes()"
+              @contextmenu.stop="onContextMenu($event, index)">
           <div class="card-background accent lighten-3"></div>
-          <v-img :key="photo.Hash"
-                 :src="photo.thumbnailUrl('tile_500')"
-                 :alt="photo.Title"
-                 :title="photo.Title"
-                 :transition="false"
-                 aspect-ratio="1"
-                 class="accent lighten-2 clickable"
-                 @touchstart.passive="input.touchStart($event, index)"
-                 @touchend.stop.prevent="onClick($event, index)"
-                 @mousedown.stop.prevent="input.mouseDown($event, index)"
-                 @click.stop.prevent="onClick($event, index)"
-                 @mouseover="playLive(photo)"
-                 @mouseleave="pauseLive(photo)"
+          <div :key="photo.Hash"
+                :alt="photo.Title"
+                :title="photo.Title"
+                class="accent lighten-2 clickable image"
+                :style="`background-image: url(${photo.thumbnailUrl('tile_500')})`"
+                @touchstart.passive="input.touchStart($event, index)"
+                @touchend.stop.prevent="onClick($event, index)"
+                @mousedown.stop.prevent="input.mouseDown($event, index)"
+                @click.stop.prevent="onClick($event, index)"
+                @mouseover="playLive(photo)"
+                @mouseleave="pauseLive(photo)"
           >
             <v-layout v-if="photo.Type === 'live' || photo.Type === 'animated'" class="live-player">
               <video :id="'live-player-' + photo.ID" :key="photo.ID" width="500" height="500" preload="none"
-                     loop muted playsinline>
+                    loop muted playsinline>
                 <source :src="photo.videoUrl()">
               </video>
             </v-layout>
 
-            <v-btn :ripple="false" :depressed="false" class="input-open"
-                   icon flat absolute
-                   @touchstart.stop.prevent="input.touchStart($event, index)"
-                   @touchend.stop.prevent="onOpen($event, index, true)"
-                   @touchmove.stop.prevent
-                   @click.stop.prevent="onOpen($event, index, true)">
-              <v-icon color="white" class="default-hidden action-raw" :title="$gettext('RAW')">photo_camera</v-icon>
-              <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>
-              <v-icon color="white" class="default-hidden action-stack" :title="$gettext('Stack')">burst_mode</v-icon>
-            </v-btn>
+            <button v-if="photo.Type !== 'image' || photo.Files.length > 1"
+                  class="input-open"
+                  @touchstart.stop.prevent="input.touchStart($event, index)"
+                  @touchend.stop.prevent="onOpen($event, index, true)"
+                  @touchmove.stop.prevent
+                  @click.stop.prevent="onOpen($event, index, true)">
+                <i v-if="photo.Type === 'raw'" class="action-raw" :title="$gettext('RAW')">photo_camera</i>
+                <i v-if="photo.Type === 'live'" class="action-live" :title="$gettext('Live')"><icon-live-photo/></i>
+                <i v-if="photo.Type === 'animated'" class="action-animated" :title="$gettext('Animated')">gif</i>
+                <i v-if="photo.Type === 'video'" class="action-play" :title="$gettext('Video')">play_arrow</i>
+                <i v-if="photo.Type === 'image'" class="action-stack" :title="$gettext('Stack')">burst_mode</i>
+            </button>
 
-            <v-btn :ripple="false" :depressed="false" class="input-view"
-                   icon flat absolute :title="$gettext('View')"
-                   @touchstart.stop.prevent="input.touchStart($event, index)"
-                   @touchend.stop.prevent="onOpen($event, index, false)"
-                   @touchmove.stop.prevent
-                   @click.stop.prevent="onOpen($event, index, false)">
-              <v-icon color="white" class="action-fullscreen">zoom_in</v-icon>
-            </v-btn>
+            <button v-if="photo.Type === 'image' && selectMode"
+                  class="input-view"
+                  :title="$gettext('View')"
+                  @touchstart.stop.prevent="input.touchStart($event, index)"
+                  @touchend.stop.prevent="onOpen($event, index, false)"
+                  @touchmove.stop.prevent
+                  @click.stop.prevent="onOpen($event, index, false)">
+              <i class="action-fullscreen">zoom_in</i>
+            </button>
 
-            <v-btn :ripple="false" :depressed="false" color="white" class="input-play"
-                   outline fab large absolute :title="$gettext('Play')"
-                   @touchstart.stop.prevent="input.touchStart($event, index)"
-                   @touchend.stop.prevent="onOpen($event, index, true)"
-                   @touchmove.stop.prevent
-                   @click.stop.prevent="onOpen($event, index, true)">
-              <v-icon color="white" class="action-play">play_arrow</v-icon>
-            </v-btn>
+            <button v-if="featPrivate && photo.Private" class="input-private">
+              <i class="select-on">lock</i>
+            </button>
 
-            <v-btn v-if="featPrivate" :ripple="false"
-                   icon flat absolute
-                   class="input-private">
-              <v-icon color="white" class="select-on">lock</v-icon>
-            </v-btn>
+            <!--
+              We'd usually use v-if here to only render the button if needed.
+              Because the button is supposed to be visible when the result is
+              being hovered over, implementing the v-if would require the use of
+              a <v-hover> element around the result.
 
-            <v-btn :ripple="false"
-                   icon flat absolute
-                   class="input-select"
-                   @touchstart.stop.prevent="input.touchStart($event, index)"
-                   @touchend.stop.prevent="onSelect($event, index)"
-                   @touchmove.stop.prevent
-                   @click.stop.prevent="onSelect($event, index)">
-              <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>
+              Because rendering the plain HTML-Button is faster than rendering
+              the v-hover component we instead hide the button by default and
+              use css to show it when it is being hovered.
+            -->
+            <button
+                  class="input-select"
+                  @touchstart.stop.prevent="input.touchStart($event, index)"
+                  @touchend.stop.prevent="onSelect($event, index)"
+                  @touchmove.stop.prevent
+                  @click.stop.prevent="onSelect($event, index)">
+              <i class="select-on">check_circle</i>
+              <i class="select-off">radio_button_off</i>
+            </button>
 
-            <v-btn :ripple="false"
-                   icon flat absolute
-                   class="input-favorite"
-                   @touchstart.stop.prevent="input.touchStart($event, index)"
-                   @touchend.stop.prevent="toggleLike($event, index)"
-                   @touchmove.stop.prevent
-                   @click.stop.prevent="toggleLike($event, index)">
-              <v-icon color="white" class="select-on">favorite</v-icon>
-              <v-icon color="white" class="select-off">favorite_border</v-icon>
-            </v-btn>
-          </v-img>
+            <button
+                  class="input-favorite"
+                  @touchstart.stop.prevent="input.touchStart($event, index)"
+                  @touchend.stop.prevent="toggleLike($event, index)"
+                  @touchmove.stop.prevent
+                  @click.stop.prevent="toggleLike($event, index)">
+              <i v-if="photo.Favorite">favorite</i>
+              <i v-else>favorite_border</i>
+            </button>
+          </div>
 
           <v-card-actions v-if="photo.Quality < 3 && context === 'review'" class="card-details pa-0">
             <v-layout row wrap align-center>
               <v-flex xs6 class="text-xs-center pa-1">
                 <v-btn color="accent lighten-2"
-                       small depressed dark block :round="false"
-                       class="action-archive text-xs-center"
-                       :title="$gettext('Archive')" @click.stop="photo.archive()">
+                      small depressed dark block :round="false"
+                      class="action-archive text-xs-center"
+                      :title="$gettext('Archive')" @click.stop="photo.archive()">
                   <v-icon dark>clear</v-icon>
                 </v-btn>
               </v-flex>
               <v-flex xs6 class="text-xs-center pa-1">
                 <v-btn color="accent lighten-2"
-                       small depressed dark block :round="false"
-                       class="action-approve text-xs-center"
-                       :title="$gettext('Approve')" @click.stop="photo.approve()">
+                      small depressed dark block :round="false"
+                      class="action-approve text-xs-center"
+                      :title="$gettext('Approve')" @click.stop="photo.approve()">
                   <v-icon dark>check</v-icon>
                 </v-btn>
               </v-flex>
             </v-layout>
           </v-card-actions>
 
-          <v-card-title primary-title class="pa-3 card-details" style="user-select: none;">
+          <div class="pa-3 card-details">
             <div>
               <h3 class="body-2 mb-2" :title="photo.Title">
                 <button class="action-title-edit" :data-uid="photo.UID"
@@ -196,30 +186,30 @@
               <div class="caption">
                 <button class="action-date-edit" :data-uid="photo.UID"
                         @click.exact="editPhoto(index)">
-                  <v-icon size="14" :title="$gettext('Taken')">date_range</v-icon>
+                  <i :title="$gettext('Taken')">date_range</i>
                   {{ photo.getDateString(true) }}
                 </button>
                 <br>
                 <button v-if="photo.Type === 'video'" :title="$gettext('Video')"
                         @click.exact="openPhoto(index, true)">
-                  <v-icon size="14">movie</v-icon>
+                  <i>movie</i>
                   {{ photo.getVideoInfo() }}
                 </button>
                 <button v-else-if="photo.Type === 'animated'" :title="$gettext('Animated')+' GIF'"
                         @click.exact="openPhoto(index, true)">
-                  <v-icon size="14">gif_box</v-icon>
+                  <i>gif_box</i>
                   {{ photo.getVideoInfo() }}
                 </button>
                 <button v-else :title="$gettext('Camera')" class="action-camera-edit"
                         :data-uid="photo.UID" @click.exact="editPhoto(index)">
-                  <v-icon size="14">photo_camera</v-icon>
+                  <i>photo_camera</i>
                   {{ photo.getPhotoInfo() }}
                 </button>
                 <template v-if="filter.order === 'name' && $config.feature('download')">
                   <br>
                   <button :title="$gettext('Name')"
                           @click.exact="downloadFile(index)">
-                    <v-icon size="14">insert_drive_file</v-icon>
+                    <i>insert_drive_file</i>
                     {{ photo.baseName() }}
                   </button>
                 </template>
@@ -227,15 +217,15 @@
                   <br>
                   <button :title="$gettext('Location')" class="action-location"
                           :data-uid="photo.UID" @click.exact="openLocation(index)">
-                    <v-icon size="14">location_on</v-icon>
+                    <i>location_on</i>
                     {{ photo.locationInfo() }}
                   </button>
                 </template>
               </div>
             </div>
-          </v-card-title>
-        </v-card>
-      </v-flex>
+          </div>
+        </div>
+      </div>
     </v-layout>
   </v-container>
 </template>
@@ -244,9 +234,13 @@ import download from "common/download";
 import Notify from "common/notify";
 import {Input, InputInvalid, ClickShort, ClickLong} from "common/input";
 import {virtualizationTools} from 'common/virtualization-tools';
+import IconLivePhoto from "component/icon/live_photo.vue";
 
 export default {
   name: 'PPhotoCards',
+  components: {
+    IconLivePhoto,
+  },
   props: {
     photos: {
       type: Array,
@@ -321,8 +315,16 @@ export default {
       if (this.$refs.items === undefined) {
         return;
       }
-      for (const item of this.$refs.items) {
-        this.intersectionObserver.observe(item);
+
+      /**
+       * observing only every 5th item reduces the amount of time
+       * spent computing intersection by 80%. me might render up to
+       * 8 items more than required, but the time saved computing
+       * intersections is far greater than the time lost rendering
+       * a couple more items
+       */
+      for (let i = 0; i < this.$refs.items.length; i += 5) {
+        this.intersectionObserver.observe(this.$refs.items[i]);
       }
     },
     elementIndexFromIntersectionObserverEntry(entry) {
@@ -335,8 +337,10 @@ export default {
         this.elementIndexFromIntersectionObserverEntry,
       );
 
-      this.firstVisibleElementIndex = smallestIndex;
-      this.lastVisibileElementIndex = largestIndex;
+      // we observe only every 5th item, so we increase the rendered
+      // range here by 4 items in every directio just to be safe
+      this.firstVisibleElementIndex = smallestIndex - 4;
+      this.lastVisibileElementIndex = largestIndex + 4;
     },
     livePlayer(photo) {
       return document.querySelector("#live-player-" + photo.ID);
@@ -388,9 +392,19 @@ export default {
       if (ev.shiftKey) {
         this.selectRange(index);
       } else {
-        this.$clipboard.toggle(this.photos[index]);
+        this.toggle(this.photos[index]);
       }
     },
+    toggle(photo) {
+      this.$clipboard.toggle(photo);
+      /**
+       * updating the clipboard does not rerender this component. Because of that
+       * there can be scenarios where the select-icon is missing after a change,
+       * for example when using touch and no hover-state changes.We therefore
+       * force an update to fix that.
+       */
+      this.$forceUpdate();
+    },
     onOpen(ev, index, showMerged) {
       const inputType = this.input.eval(ev, index);
 
@@ -412,7 +426,7 @@ export default {
         if (longClick || ev.shiftKey) {
           this.selectRange(index);
         } else {
-          this.$clipboard.toggle(this.photos[index]);
+          this.toggle(this.photos[index]);
         }
       } else {
         this.openPhoto(index, false);
@@ -427,6 +441,13 @@ export default {
     },
     selectRange(index) {
       this.$clipboard.addRange(index, this.photos);
+      /**
+       * updating the clipboard does not rerender this component. Because of that
+       * there can be scenarios where the select-icon is missing after a change,
+       * for example when selecting mutliple elements at once. We therefore
+       * force an update to fix that.
+       */
+      this.$forceUpdate();
     },
   }
 };

+ 126 - 126
frontend/src/component/photo/list.vue

@@ -20,118 +20,127 @@
         </p>
       </v-alert>
     </div>
-    <v-data-table v-else
-                  ref="dataTable"
-                  v-model="selected"
-                  :headers="listColumns"
-                  :items="photos"
-                  hide-actions
-                  class="search-results photo-results list-view"
-                  :class="{'select-results': selectMode}"
-                  disable-initial-sort
-                  item-key="ID"
-                  :no-data-text="notFoundMessage"
+    <div v-else class="search-results photo-results list-view">
+      <div class="v-table__overflow">
+        <table class="v-datatable v-table theme--light">
+          <thead>
+            <tr>
+              <th class="p-col-select" />
+              <th :class="!$rtl ? 'text-xs-left' : 'text-xs-right'">
+                {{$gettext('Title')}}
+              </th>
+              <th :class="!$rtl ? 'text-xs-left' : 'text-xs-right'" class="hidden-xs-only">
+                {{$gettext('Taken')}}
+              </th>
+              <th :class="!$rtl ? 'text-xs-left' : 'text-xs-right'" class="hidden-sm-and-down">
+                {{$gettext('Camera')}}
+              </th>
+              <th :class="!$rtl ? 'text-xs-left' : 'text-xs-right'" class="hidden-xs-only">
+                {{showName ? $gettext('Name') : $gettext('Location')}}
+              </th>
+              <th class="text-xs-center hidden-xs-only" />
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="(photo, index) in photos" :key="photo.ID" ref="items" :data-index="index">
+              <td :data-uid="photo.UID" class="result" :class="photo.classes()">
+                <div
+                    v-if="index < firstVisibleElementIndex || index > lastVisibileElementIndex"
+                    :key="photo.Hash"
+                    class="image accent lighten-2"
+                />
+                <div
+                      v-else
+                      :key="photo.Hash"
+                      :alt="photo.Title"
+                      :style="`background-image: url(${photo.thumbnailUrl('tile_50')})`"
+                      class="accent lighten-2 clickable image"
+                      @touchstart="onMouseDown($event, index)"
+                      @touchend.stop.prevent="onClick($event, index)"
+                      @mousedown="onMouseDown($event, index)"
+                      @contextmenu.stop="onContextMenu($event, index)"
+                      @click.stop.prevent="onClick($event, index)"
+                >
+                  <button v-if="selectMode" class="input-select">
+                    <i class="select-on">check_circle</i>
+                    <i class="select-off">radio_button_off</i>
+                  </button>
+                  <button v-else-if="photo.Type === 'video' || photo.Type === 'live' || photo.Type === 'animated'"
+                        class="input-open"
+                        @click.stop.prevent="openPhoto(index, true)">
+                    <i v-if="photo.Type === 'live'" class="action-live" :title="$gettext('Live')"><icon-live-photo/></i>
+                    <i v-if="photo.Type === 'animated'" class="action-animated" :title="$gettext('Animated')">gif</i>
+                    <i v-if="photo.Type === 'video'" class="action-play" :title="$gettext('Video')">play_arrow</i>
+                  </button>
+                </div>
+              </td>
 
-    >
-      <template #items="props">
-        <td style="user-select: none;" :data-uid="props.item.UID" class="result" :class="props.item.classes()">
-          <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">
-              <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)">
-              <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>
-            </v-btn>
-          </v-img>
-        </td>
-
-        <td class="p-photo-desc clickable" :data-uid="props.item.UID" style="user-select: none;"
-            @click.exact="editPhoto(props.index)">
-          {{ props.item.Title }}
-        </td>
-        <td class="p-photo-desc hidden-xs-only" :title="props.item.getDateString()">
-          <button style="user-select: none;" @click.stop.prevent="editPhoto(props.index)">
-            {{ props.item.shortDateString() }}
-          </button>
-        </td>
-        <td class="p-photo-desc hidden-sm-and-down" style="user-select: none;">
-          <button @click.stop.prevent="editPhoto(props.index)">
-            {{ props.item.CameraMake }} {{ props.item.CameraModel }}
-          </button>
-        </td>
-        <td class="p-photo-desc hidden-xs-only">
-          <button v-if="filter.order === 'name'"
-                  :title="$gettext('Name')" @click.exact="downloadFile(props.index)">
-            {{ props.item.FileName }}
-          </button>
-          <button v-else-if="props.item.Country !== 'zz' && showLocation"
-                  style="user-select: none;"
-                  @click.stop.prevent="openLocation(props.index)">
-            {{ props.item.locationInfo() }}
-          </button>
-          <span v-else>
-                    {{ props.item.locationInfo() }}
+              <td class="p-photo-desc clickable" :data-uid="photo.UID"
+                  @click.exact="editPhoto(index)">
+                {{ photo.Title }}
+              </td>
+              <td class="p-photo-desc hidden-xs-only" :title="photo.getDateString()">
+                <button @click.stop.prevent="editPhoto(index)">
+                  {{ photo.shortDateString() }}
+                </button>
+              </td>
+              <td class="p-photo-desc hidden-sm-and-down">
+                <button @click.stop.prevent="editPhoto(index)">
+                  {{ photo.CameraMake }} {{ photo.CameraModel }}
+                </button>
+              </td>
+              <td class="p-photo-desc hidden-xs-only">
+                <button v-if="filter.order === 'name'"
+                        :title="$gettext('Name')" @click.exact="downloadFile(index)">
+                  {{ photo.FileName }}
+                </button>
+                <button v-else-if="photo.Country !== 'zz' && showLocation"
+                        @click.stop.prevent="openLocation(index)">
+                  {{ photo.locationInfo() }}
+                </button>
+                <span v-else>
+                  {{ photo.locationInfo() }}
                 </span>
-        </td>
-        <td class="text-xs-center">
-          <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>
+              </td>
+              <td class="text-xs-center">
+                <template v-if="index < firstVisibleElementIndex || 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>
+                <template v-else>
+                  <v-btn v-if="hidePrivate" class="input-private" icon small flat :ripple="false"
+                        :data-uid="photo.UID" @click.stop.prevent="photo.togglePrivate()">
+                    <v-icon v-if="photo.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="photo.UID" @click.stop.prevent="photo.toggleLike()">
+                    <v-icon v-if="photo.Favorite" color="pink lighten-3" :data-uid="photo.UID" class="select-on">
+                      favorite
+                    </v-icon>
+                    <v-icon v-else color="secondary" :data-uid="photo.UID" class="select-off">favorite_border</v-icon>
+                  </v-btn>
+                </template>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
   </div>
 </template>
 <script>
 import download from "common/download";
 import Notify from "common/notify";
 import {virtualizationTools} from 'common/virtualization-tools';
+import IconLivePhoto from "component/icon/live_photo.vue";
 
 export default {
   name: 'PPhotoList',
+  components: {
+    IconLivePhoto,
+  },
   props: {
     photos: {
       type: Array,
@@ -172,28 +181,10 @@ export default {
       m += " " + this.$gettext("Non-photographic and low-quality images require a review before they appear in search results.");
     }
 
-    let showName = this.filter.order === 'name';
-
-    const align = !this.$rtl ? 'left' : 'right';
     return {
       config: this.$config.values,
       notFoundMessage: m,
-      'selected': [],
-      'listColumns': [
-        {text: '', value: '', align: 'center', class: 'p-col-select', sortable: false},
-        {text: this.$gettext('Title'), align, value: 'Title', sortable: false},
-        {text: this.$gettext('Taken'), align, class: 'hidden-xs-only', value: 'TakenAt', sortable: false},
-        {text: this.$gettext('Camera'), align, class: 'hidden-sm-and-down', value: 'CameraModel', sortable: false},
-        {
-          text: showName ? this.$gettext('Name') : this.$gettext('Location'),
-          align,
-          class: 'hidden-xs-only',
-          value: showName ? 'FileName' : 'PlaceLabel',
-          sortable: false
-        },
-        {text: '', value: '', align: 'center', sortable: false},
-      ],
-      showName: showName,
+      showName: this.filter.order === 'name',
       showLocation: this.$config.values.settings.features.places,
       hidePrivate: this.$config.values.settings.features.private,
       mouseDown: {
@@ -228,16 +219,23 @@ export default {
   },
   methods: {
     observeItems() {
-      if (this.$refs.dataTable === undefined) {
+      if (this.$refs.items === undefined) {
         return;
       }
-      const rows = this.$refs.dataTable.$el.getElementsByTagName('tbody')[0].children;
-      for (const row of rows) {
-        this.intersectionObserver.observe(row);
+
+      /**
+       * observing only every 5th item reduces the amount of time
+       * spent computing intersection by 80%. me might render up to
+       * 8 items more than required, but the time saved computing
+       * intersections is far greater than the time lost rendering
+       * a couple more items
+       */
+      for (let i = 0; i < this.$refs.items.length; i += 5) {
+        this.intersectionObserver.observe(this.$refs.items[i]);
       }
     },
     elementIndexFromIntersectionObserverEntry(entry) {
-      return entry.target.rowIndex - 2;
+      return parseInt(entry.target.getAttribute('data-index'));
     },
     visibilitiesChanged(entries) {
       const [smallestIndex, largestIndex] = virtualizationTools.updateVisibleElementIndices(
@@ -246,8 +244,10 @@ export default {
         this.elementIndexFromIntersectionObserverEntry,
       );
 
-      this.firstVisibleElementIndex = smallestIndex;
-      this.lastVisibileElementIndex = largestIndex;
+      // we observe only every 5th item, so we increase the rendered
+      // range here by 4 items in every directio just to be safe
+      this.firstVisibleElementIndex = smallestIndex - 4;
+      this.lastVisibileElementIndex = largestIndex + 4;
     },
     downloadFile(index) {
       Notify.success(this.$gettext("Downloading…"));

+ 98 - 88
frontend/src/component/photo/mosaic.vue

@@ -21,118 +21,116 @@
       </v-alert>
     </template>
     <v-layout row wrap class="search-results photo-results mosaic-view" :class="{'select-results': selectMode}">
-      <v-flex
+      <div
           v-for="(photo, index) in photos"
           ref="items"
           :key="photo.ID"
+          class="flex xs4 sm3 md2 lg1"
           :data-index="index"
-          xs4 sm3 md2 lg1 d-flex
       >
-
-        <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"
+       <!--
+         The following div is the layout + size container. It makes the browser not
+         re-layout all elements in the list when the children of one of them changes
+        -->
+        <div class="image-container">
+          <div v-if="index < firstVisibleElementIndex || index > lastVisibileElementIndex" class="accent lighten-2 result image" />
+          <div  v-else
+                :key="photo.Hash"
                 tile
                 :data-id="photo.ID"
                 :data-uid="photo.UID"
-                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"
-                 :src="photo.thumbnailUrl('tile_224')"
-                 :alt="photo.Title"
-                 :title="photo.Title"
-                 :transition="false"
-                 aspect-ratio="1"
-                 class="clickable"
-                 @touchstart.passive="input.touchStart($event, index)"
-                 @touchend.stop.prevent="onClick($event, index)"
-                 @mousedown.stop.prevent="input.mouseDown($event, index)"
-                 @click.stop.prevent="onClick($event, index)"
-                 @mouseover="playLive(photo)"
-                 @mouseleave="pauseLive(photo)"
-          >
+                :style="`background-image: url(${photo.thumbnailUrl('tile_224')})`"
+                :class="photo.classes().join(' ') + ' accent lighten-2 result clickable image'"
+                :alt="photo.Title"
+                :title="photo.Title"
+                @contextmenu.stop="onContextMenu($event, index)"
+                @touchstart.passive="input.touchStart($event, index)"
+                @touchend.stop.prevent="onClick($event, index)"
+                @mousedown.stop.prevent="input.mouseDown($event, index)"
+                @click.stop.prevent="onClick($event, index)"
+                @mouseover="playLive(photo)"
+                @mouseleave="pauseLive(photo)">
             <v-layout v-if="photo.Type === 'live' || photo.Type === 'animated'" class="live-player">
               <video :id="'live-player-' + photo.ID" :key="photo.ID" width="224" height="224" preload="none"
-                     loop muted playsinline>
+                    loop muted playsinline>
                 <source :src="photo.videoUrl()">
               </video>
             </v-layout>
 
-            <v-btn :ripple="false" :depressed="false" class="input-open"
-                   icon flat small absolute
-                   @touchstart.stop.prevent="input.touchStart($event, index)"
-                   @touchend.stop.prevent="onOpen($event, index, true)"
-                   @touchmove.stop.prevent
-                   @click.stop.prevent="onOpen($event, index, true)">
-              <v-icon color="white" class="default-hidden action-raw" :title="$gettext('RAW')">photo_camera</v-icon>
-              <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>
-              <v-icon color="white" class="default-hidden action-stack" :title="$gettext('Stack')">burst_mode</v-icon>
-            </v-btn>
+            <button v-if="photo.Type !== 'image' || photo.Files.length > 1"
+                  class="input-open"
+                  @touchstart.stop.prevent="input.touchStart($event, index)"
+                  @touchend.stop.prevent="onOpen($event, index, true)"
+                  @touchmove.stop.prevent
+                  @click.stop.prevent="onOpen($event, index, true)">
+              <i v-if="photo.Type === 'raw'" color="white" class="action-raw" :title="$gettext('RAW')">photo_camera</i>
+              <i v-if="photo.Type === 'live'" color="white" class="action-live" :title="$gettext('Live')"><icon-live-photo/></i>
+              <i v-if="photo.Type === 'animated'" color="white" class="action-animated" :title="$gettext('Animated')">gif</i>
+              <i v-if="photo.Type === 'video'" color="white" class="action-play" :title="$gettext('Video')">play_arrow</i>
+              <i v-if="photo.Type === 'image'" color="white" class="action-stack" :title="$gettext('Stack')">burst_mode</i>
+            </button>
 
-            <v-btn :ripple="false" :depressed="false" class="input-view"
-                   icon flat small absolute :title="$gettext('View')"
-                   @touchstart.stop.prevent="input.touchStart($event, index)"
-                   @touchend.stop.prevent="onOpen($event, index, false)"
-                   @touchmove.stop.prevent
-                   @click.stop.prevent="onOpen($event, index, false)">
-              <v-icon color="white" class="action-fullscreen">zoom_in</v-icon>
-            </v-btn>
+            <button v-if="photo.Type === 'image' && selectMode"
+                  class="input-view"
+                  :title="$gettext('View')"
+                  @touchstart.stop.prevent="input.touchStart($event, index)"
+                  @touchend.stop.prevent="onOpen($event, index, false)"
+                  @touchmove.stop.prevent
+                  @click.stop.prevent="onOpen($event, index, false)">
+              <i color="white" class="action-fullscreen">zoom_in</i>
+            </button>
 
-            <v-btn :ripple="false" :depressed="false" color="white" class="input-play"
-                   icon flat small absolute :title="$gettext('Play')"
-                   @touchstart.stop.prevent="input.touchStart($event, index)"
-                   @touchend.stop.prevent="onOpen($event, index, true)"
-                   @touchmove.stop.prevent
-                   @click.stop.prevent="onOpen($event, index, true)">
-              <v-icon color="white" class="action-play">play_arrow</v-icon>
-            </v-btn>
+            <button v-if="hidePrivate && photo.Private" class="input-private">
+              <i color="white" class="select-on">lock</i>
+            </button>
 
-            <v-btn v-if="hidePrivate" :ripple="false"
-                   icon flat small absolute
-                   class="input-private">
-              <v-icon color="white" class="select-on">lock</v-icon>
-            </v-btn>
+            <!--
+              We'd usually use v-if here to only render the button if needed.
+              Because the button is supposed to be visible when the result is
+              being hovered over, implementing the v-if would require the use of
+              a <v-hover> element around the result.
 
-            <v-btn :ripple="false"
-                   icon flat small absolute
-                   class="input-select"
-                   @touchstart.stop.prevent="input.touchStart($event, index)"
-                   @touchend.stop.prevent="onSelect($event, index)"
-                   @touchmove.stop.prevent
-                   @click.stop.prevent="onSelect($event, index)">
-              <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>
+              Because rendering the plain HTML-Button is faster than rendering
+              the v-hover component we instead hide the button by default and
+              use css to show it when it is being hovered.
+            -->
+            <button
+                  class="input-select"
+                  @mousedown.stop.prevent="input.mouseDown($event, index)"
+                  @touchstart.stop.prevent="input.touchStart($event, index)"
+                  @touchend.stop.prevent="onSelect($event, index)"
+                  @touchmove.stop.prevent
+                  @click.stop.prevent="onSelect($event, index)">
+              <i color="white" class="select-on">check_circle</i>
+              <i color="white" class="select-off">radio_button_off</i>
+            </button>
 
-            <v-btn :ripple="false"
-                   icon flat small absolute
-                   class="input-favorite"
-                   @touchstart.stop.prevent="input.touchStart($event, index)"
-                   @touchend.stop.prevent="toggleLike($event, index)"
-                   @touchmove.stop.prevent
-                   @click.stop.prevent="toggleLike($event, index)">
-              <v-icon color="white" class="select-on">favorite</v-icon>
-              <v-icon color="white" class="select-off">favorite_border</v-icon>
-            </v-btn>
-          </v-img>
-        </v-card>
-      </v-flex>
+            <button
+                class="input-favorite"
+                @touchstart.stop.prevent="input.touchStart($event, index)"
+                @touchend.stop.prevent="toggleLike($event, index)"
+                @touchmove.stop.prevent
+                @click.stop.prevent="toggleLike($event, index)"
+            >
+              <i v-if="photo.Favorite">favorite</i>
+              <i v-else>favorite_border</i>
+            </button>
+          </div>
+        </div>
+      </div>
     </v-layout>
   </v-container>
 </template>
 <script>
 import {Input, InputInvalid, ClickShort, ClickLong} from "common/input";
 import {virtualizationTools} from 'common/virtualization-tools';
+import IconLivePhoto from "component/icon/live_photo.vue";
 
 export default {
   name: 'PPhotoMosaic',
+  components: {
+    IconLivePhoto,
+  },
   props: {
     photos: {
       type: Array,
@@ -194,8 +192,16 @@ export default {
       if (this.$refs.items === undefined) {
         return;
       }
-      for (const item of this.$refs.items) {
-        this.intersectionObserver.observe(item);
+
+      /**
+       * observing only every 5th item reduces the amount of time
+       * spent computing intersection by 80%. me might render up to
+       * 8 items more than required, but the time saved computing
+       * intersections is far greater than the time lost rendering
+       * a couple more items
+       */
+      for (let i = 0; i < this.$refs.items.length; i += 5) {
+        this.intersectionObserver.observe(this.$refs.items[i]);
       }
     },
     elementIndexFromIntersectionObserverEntry(entry) {
@@ -208,8 +214,10 @@ export default {
         this.elementIndexFromIntersectionObserverEntry,
       );
 
-      this.firstVisibleElementIndex = smallestIndex;
-      this.lastVisibileElementIndex = largestIndex;
+      // we observe only every 5th item, so we increase the rendered
+      // range here by 4 items in every directio just to be safe
+      this.firstVisibleElementIndex = smallestIndex - 4;
+      this.lastVisibileElementIndex = largestIndex + 4;
     },
     livePlayer(photo) {
       return document.querySelector("#live-player-" + photo.ID);
@@ -258,6 +266,7 @@ export default {
     },
     toggle(photo) {
       this.$clipboard.toggle(photo);
+      this.$forceUpdate();
     },
     onOpen(ev, index, showMerged) {
       const inputType = this.input.eval(ev, index);
@@ -295,6 +304,7 @@ export default {
     },
     selectRange(index) {
       this.$clipboard.addRange(index, this.photos);
+      this.$forceUpdate();
     }
   },
 };

+ 87 - 1
frontend/src/css/results.css

@@ -198,6 +198,92 @@ body.chrome #photoprism .search-results .result {
     box-shadow: 0 0 0 0 rgba(0, 0, 0, .2), 0 0 0 0 rgba(0, 0, 0, .14), 0 0 0 0 rgba(0, 0, 0, .12) !important;
 }
 
+#photoprism .search-results .image-container {
+  aspect-ratio: 1;
+  contain: layout paint style size;
+}
+
+#photoprism .cards-view .result .image,
+#photoprism .list-view .result .image,
+#photoprism .mosaic-view .result.image
+{
+  position: relative;
+  user-select: none;
+  aspect-ratio: 1;
+  background-position: center center;
+  background-size: cover;
+  background-repeat: no-repeat;
+  border-radius: 3px;
+  overflow: hidden;
+}
+
+#photoprism .cards-view .result .image button,
+#photoprism .list-view .result .image button,
+#photoprism .mosaic-view .result.image button {
+  position: absolute;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  width: 28px;
+  height: 28px;
+  padding: 0;
+  transition: background-color .3s cubic-bezier(.25,.8,.5,1);
+}
+
+#photoprism .cards-view .result .image button {
+  width: 36px;
+  height: 36px;
+}
+
+#photoprism .list-view .result .image button {
+  width: 44px;
+  height: 44px;
+}
+#photoprism .cards-view .result .image button:hover,
+#photoprism .list-view .result .image button:hover,
+#photoprism .mosaic-view .result.image button:hover {
+  background-color: rgba(255, 255, 255, 0.12);
+}
+
+#photoprism .mosaic-view .result.image i,
+#photoprism .list-view .result .image i,
+#photoprism .cards-view .result .image i {
+  color: white;
+  font-size: 24px;
+  text-rendering: optimizeLegibility;
+  -webkit-font-smoothing: antialiased;
+  font-family: Material Icons;
+  font-style: normal;
+  font-weight: 400;
+  text-transform: none;
+  line-height: 1;
+}
+
+#photoprism .cards-view .result .caption {
+  hyphens: auto;
+  word-break: break-word;
+}
+
+#photoprism .cards-view .result .card-details i {
+  font-size: 14px;
+  display: inline-block;
+  text-rendering: optimizeLegibility;
+  -webkit-font-smoothing: antialiased;
+  font-family: Material Icons;
+  font-style: normal;
+  font-weight: 400;
+  text-transform: none;
+  line-height: 1;
+  vertical-align: text-bottom;
+}
+
+
+#photoprism .cards-view .result.placeholder .card-details i {
+  width: 14px;
+}
+
+
 #photoprism .cards-view .result.is-selected,
 #photoprism .mosaic-view .result.is-selected {
     margin: 0 !important;
@@ -392,4 +478,4 @@ body.chrome #photoprism .search-results .result {
 #photoprism .face-results .is-face.is-hidden,
 #photoprism .face-results .is-marker.is-invalid {
     opacity: 0.5;
-}
+}

+ 1 - 1
frontend/src/pages/photos.vue

@@ -102,7 +102,7 @@ export default {
       complete: false,
       results: [],
       scrollDisabled: true,
-      scrollDistance: window.innerHeight * 2,
+      scrollDistance: window.innerHeight * 6,
       batchSize: batchSize,
       offset: 0,
       page: 0,