Compare commits

..
2 Commits
2 changed files with 132 additions and 23 deletions
+13 -1
View File
@@ -17,6 +17,7 @@ import gc
import io import io
import logging import logging
import mimetypes import mimetypes
import shlex
import struct import struct
import subprocess import subprocess
import sys import sys
@@ -217,7 +218,18 @@ def _image_via_ffmpeg(path: Path, maxsize: int, quality: int) -> bytes:
cmd.insert(4, "-s") cmd.insert(4, "-s")
cmd.insert(5, f"{new_w}x{new_h}") cmd.insert(5, f"{new_w}x{new_h}")
try: try:
subprocess.run(cmd, capture_output=True, check=True, shell=False) # noqa: S603 try:
subprocess.run(cmd, capture_output=True, check=True, shell=False) # noqa: S603
except subprocess.CalledProcessError as e:
shell_cmd = shlex.join(cmd)
stderr = (e.stderr or b"").decode(errors="replace").strip()
if stderr:
raise RuntimeError(
f"ffmpeg failed (exit {e.returncode}): {shell_cmd}\n{stderr}"
) from e
raise RuntimeError(
f"ffmpeg failed (exit {e.returncode}): {shell_cmd}"
) from e
with Path(tmp_path).open("rb") as f: with Path(tmp_path).open("rb") as f:
return f.read() return f.read()
finally: finally:
+119 -22
View File
@@ -1,9 +1,35 @@
<template> <template>
<div v-if=showProgress() class="preview-progress" aria-label="Preview pending"> <div v-if="showPreviewImage || showNativeImage" class="preview-image-shell">
<SpinnerIcon /> <span
v-show="activeImageLoading"
class="file icon"
:class="[`ext-${doc.ext}`, 'loading-pulse']"
:style="loadingPulseStyle"
></span>
<img
v-if="showPreviewImage"
:src="previewSrc"
alt=""
:class="{ ready: !previewImageLoading }"
@load="onPreviewImageLoad"
@error="onPreviewImageError"
>
<img
v-else
:src="doc.url"
alt=""
:class="{ ready: !nativeImageLoading }"
@load="onNativeImageLoad"
@error="onNativeImageError"
>
</div>
<div v-else-if=showProgress() class="preview-progress" aria-label="Preview pending">
<span
class="file icon"
:class="[`ext-${doc.ext}`, { 'loading-pulse': !previewLoadFailed }]"
:style="loadingPulseStyle"
></span>
</div> </div>
<img v-else-if="previewSrc && !video() && !audio()" :src="previewSrc" alt="">
<img v-else-if=doc.img :src=doc.url alt="">
<span v-else-if=doc.dir class="folder icon"></span> <span v-else-if=doc.dir class="folder icon"></span>
<div v-else-if=video() class="video-container" :class="{ pending: !doc.complete }"> <div v-else-if=video() class="video-container" :class="{ pending: !doc.complete }">
<video v-if=doc.complete ref=vid :src=doc.url :poster=previewSrc preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video> <video v-if=doc.complete ref=vid :src=doc.url :poster=previewSrc preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
@@ -18,10 +44,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Play as PlayIcon, Spinner as SpinnerIcon } from '@/assets/svg' import { Play as PlayIcon } from '@/assets/svg'
import type { Doc } from '@/repositories/Document' import type { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
const aud = ref<HTMLAudioElement | null>(null) const aud = ref<HTMLAudioElement | null>(null)
const vid = ref<HTMLVideoElement | null>(null) const vid = ref<HTMLVideoElement | null>(null)
@@ -30,11 +56,58 @@ const props = defineProps<{
doc: Doc doc: Doc
quality: string quality: string
}>() }>()
const previewImageFailed = ref(false)
const nativeImageFailed = ref(false)
const previewImageLoading = ref(true)
const nativeImageLoading = ref(true)
const previewSrc = computed(() => const previewSrc = computed(() =>
props.doc.previewurl props.doc.previewurl
? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}` ? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`
: '' : ''
) )
const showPreviewImage = computed(
() => !!previewSrc.value && !video() && !audio() && !previewImageFailed.value
)
const showNativeImage = computed(() => props.doc.img && !nativeImageFailed.value)
const activeImageLoading = computed(() =>
showPreviewImage.value ? previewImageLoading.value : nativeImageLoading.value
)
const previewLoadFailed = computed(
() => previewImageFailed.value || nativeImageFailed.value
)
const loadingPulseDelayMs = computed(() => {
let hash = 0
for (const ch of props.doc.key) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
return hash % 1800
})
const loadingPulseStyle = computed(() => ({
animationDelay: `${-loadingPulseDelayMs.value}ms`
}))
const onPreviewImageLoad = () => {
previewImageLoading.value = false
}
const onPreviewImageError = () => {
previewImageLoading.value = false
previewImageFailed.value = true
}
const onNativeImageLoad = () => {
nativeImageLoading.value = false
}
const onNativeImageError = () => {
nativeImageLoading.value = false
nativeImageFailed.value = true
}
watch(
() => props.doc.key,
() => {
previewImageFailed.value = false
nativeImageFailed.value = false
previewImageLoading.value = true
nativeImageLoading.value = true
}
)
const onplay = () => { const onplay = () => {
if (!media.value) return if (!media.value) return
@@ -154,6 +227,7 @@ img, embed, .icon, audio, video {
border-radius: calc(.5em / 8); border-radius: calc(.5em / 8);
} }
.preview-progress { .preview-progress {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -162,18 +236,47 @@ img, embed, .icon, audio, video {
max-height: 100%; max-height: 100%;
aspect-ratio: 1; aspect-ratio: 1;
} }
.preview-progress :deep(svg) { .preview-progress .icon {
width: 4.5em; opacity: 0.9;
height: 4.5em;
opacity: 0.8;
animation: media-preview-spin 0.9s linear infinite;
} }
@keyframes media-preview-spin { .preview-image-shell {
from { position: relative;
transform: rotate(0deg); display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
}
.preview-image-shell img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
min-width: 0;
object-fit: contain;
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.preview-image-shell img.ready {
opacity: 1;
}
.loading-pulse {
animation: media-preview-pulse 1.8s ease-in-out infinite;
}
@keyframes media-preview-pulse {
0% {
transform: scale(1);
opacity: 0.86;
} }
to { 50% {
transform: rotate(360deg); transform: scale(1.04);
opacity: 0.98;
}
100% {
transform: scale(1);
opacity: 0.86;
} }
} }
.folder::before { .folder::before {
@@ -219,12 +322,6 @@ img, embed, .icon, audio, video {
figure.cursor .icon { figure.cursor .icon {
filter: brightness(1); filter: brightness(1);
} }
img::before {
/* broken image */
text-shadow: 0 0 .5rem #000;
filter: grayscale(1);
content: '❌';
}
.video-container { .video-container {
position: relative; position: relative;
display: flex; display: flex;