diff --git a/apps/files/src/components/FileEntry/FileEntryPreview.vue b/apps/files/src/components/FileEntry/FileEntryPreview.vue index 2e43338cf0558..817dac40b40e2 100644 --- a/apps/files/src/components/FileEntry/FileEntryPreview.vue +++ b/apps/files/src/components/FileEntry/FileEntryPreview.vue @@ -14,16 +14,19 @@ </template> </template> - <!-- Decorative image, should not be aria documented --> - <img v-else-if="previewUrl && backgroundFailed !== true" - ref="previewImg" - alt="" - class="files-list__row-icon-preview" - :class="{'files-list__row-icon-preview--loaded': backgroundFailed === false}" - loading="lazy" - :src="previewUrl" - @error="onBackgroundError" - @load="backgroundFailed = false"> + <!-- Decorative images, should not be aria documented --> + <span v-else-if="previewUrl" class="files-list__row-icon-preview-container"> + <canvas v-if="hasBlurhash && (backgroundFailed === true || !backgroundLoaded)" ref="canvas" class="files-list__row-icon-blurhash" /> + <img v-if="backgroundFailed !== true" + ref="previewImg" + alt="" + class="files-list__row-icon-preview" + :class="{'files-list__row-icon-preview--loaded': backgroundFailed === false}" + loading="lazy" + :src="previewUrl" + @error="onBackgroundError" + @load="onBackgroundLoad"> + </span> <FileIcon v-else v-once /> @@ -58,6 +61,7 @@ import LinkIcon from 'vue-material-design-icons/Link.vue' import NetworkIcon from 'vue-material-design-icons/Network.vue' import TagIcon from 'vue-material-design-icons/Tag.vue' import PlayCircleIcon from 'vue-material-design-icons/PlayCircle.vue' +import { decode } from 'blurhash' import CollectivesIcon from './CollectivesIcon.vue' import FavoriteIcon from './FavoriteIcon.vue' @@ -107,6 +111,7 @@ export default Vue.extend({ data() { return { backgroundFailed: undefined as boolean | undefined, + backgroundLoaded: false as boolean, } }, @@ -206,6 +211,16 @@ export default Vue.extend({ return null }, + + hasBlurhash() { + return this.source.attributes['metadata-blurhash'] !== undefined + }, + }, + + mounted() { + if (this.hasBlurhash && this.$refs.canvas) { + this.drawBlurhash() + } }, methods: { @@ -213,17 +228,36 @@ export default Vue.extend({ reset() { // Reset background state to cancel any ongoing requests this.backgroundFailed = undefined + this.backgroundLoaded = false if (this.$refs.previewImg) { this.$refs.previewImg.src = '' } }, + onBackgroundLoad(event) { + this.backgroundFailed = false + this.backgroundLoaded = true + }, + onBackgroundError(event) { // Do not fail if we just reset the background if (event.target?.src === '') { return } this.backgroundFailed = true + this.backgroundLoaded = false + }, + + drawBlurhash() { + const width = this.$refs.canvas.width + const height = this.$refs.canvas.height + + const pixels = decode(this.source.attributes['metadata-blurhash'], width, height) + + const ctx = this.$refs.canvas.getContext('2d') + const imageData = ctx.createImageData(width, height) + imageData.data.set(pixels) + ctx.putImageData(imageData, 0, 0) }, t, diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index 75f5792498435..068a3cb9e64e8 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -557,11 +557,24 @@ export default defineComponent({ } } - &-preview { + &-preview-container { + position: relative; // Needed for the blurshash to be positioned correctly overflow: hidden; width: var(--icon-preview-size); height: var(--icon-preview-size); border-radius: var(--border-radius); + } + + &-blurhash { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + object-fit: cover; + } + + &-preview { // Center and contain the preview object-fit: contain; object-position: center; diff --git a/apps/files/src/init.ts b/apps/files/src/init.ts index db4aec7fa0633..0f4ef70b1b2b6 100644 --- a/apps/files/src/init.ts +++ b/apps/files/src/init.ts @@ -66,5 +66,6 @@ registerPreviewServiceWorker() registerDavProperty('nc:hidden', { nc: 'http://nextcloud.org/ns' }) registerDavProperty('nc:is-mount-root', { nc: 'http://nextcloud.org/ns' }) +registerDavProperty('nc:metadata-blurhash', { nc: 'http://nextcloud.org/ns' }) initLivePhotos() diff --git a/package-lock.json b/package-lock.json index a54e042dd5363..046bba8f8b2cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@vueuse/integrations": "^11.0.1", "backbone": "^1.4.1", "blueimp-md5": "^2.19.0", + "blurhash": "^2.0.5", "browserslist-useragent-regexp": "^4.1.1", "camelcase": "^8.0.0", "cancelable-promise": "^4.3.1", @@ -8111,6 +8112,11 @@ "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==", "license": "MIT" }, + "node_modules/blurhash": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz", + "integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==" + }, "node_modules/bmp-js": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", diff --git a/package.json b/package.json index 2a0efe599ce98..2d9ffcc856d9b 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@vueuse/integrations": "^11.0.1", "backbone": "^1.4.1", "blueimp-md5": "^2.19.0", + "blurhash": "^2.0.5", "browserslist-useragent-regexp": "^4.1.1", "camelcase": "^8.0.0", "cancelable-promise": "^4.3.1",