370 lines
9.4 KiB
Vue
370 lines
9.4 KiB
Vue
<template>
|
|
<div v-if="showPreviewImage || showNativeImage" class="preview-image-shell">
|
|
<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>
|
|
<span v-else-if=doc.dir class="folder icon"></span>
|
|
<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-else ref=vid :src=doc.url preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
|
|
<div class="play-overlay"><PlayIcon /></div>
|
|
</div>
|
|
<div v-else-if=audio() class="audio icon">
|
|
<audio ref=aud :src=doc.url class=icon preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></audio>
|
|
</div>
|
|
<span v-else-if=archive() class="archive icon"></span>
|
|
<span v-else class="file icon" :class="`ext-${doc.ext}`"></span>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { Play as PlayIcon } from '@/assets/svg'
|
|
import type { Doc } from '@/repositories/Document'
|
|
import { useMainStore } from '@/stores/main'
|
|
import { computed, ref, watch } from 'vue'
|
|
|
|
const aud = ref<HTMLAudioElement | null>(null)
|
|
const vid = ref<HTMLVideoElement | null>(null)
|
|
const media = computed(() => aud.value || vid.value)
|
|
const props = defineProps<{
|
|
doc: Doc
|
|
quality: string
|
|
}>()
|
|
const previewImageFailed = ref(false)
|
|
const nativeImageFailed = ref(false)
|
|
const previewImageLoading = ref(true)
|
|
const nativeImageLoading = ref(true)
|
|
const previewSrc = computed(() =>
|
|
props.doc.previewurl
|
|
? `${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 = () => {
|
|
if (!media.value) return
|
|
media.value.controls = true
|
|
media.value.setAttribute('data-playing', '')
|
|
}
|
|
const onpaused = () => {
|
|
if (!media.value) return
|
|
media.value.controls = false
|
|
media.value.removeAttribute('data-playing')
|
|
}
|
|
const applyPoster = (el: HTMLVideoElement) => {
|
|
if (props.doc.complete) {
|
|
el.poster = previewSrc.value
|
|
} else {
|
|
el.removeAttribute('poster')
|
|
}
|
|
}
|
|
let fscurrent: HTMLVideoElement | null = null
|
|
const next = () => {
|
|
if (!media.value) return
|
|
media.value.load() // Restore poster
|
|
const medias = Array.from(document.querySelectorAll('video, audio')) as (
|
|
| HTMLAudioElement
|
|
| HTMLVideoElement
|
|
)[]
|
|
if (medias.length === 0) return
|
|
let el: HTMLAudioElement | HTMLVideoElement | null = null
|
|
for (const i in medias) {
|
|
if (medias[i] === (fscurrent || media.value)) {
|
|
el = medias[+i + 1] ?? medias[0] ?? null
|
|
break
|
|
}
|
|
}
|
|
if (!el) return
|
|
if (el.tagName === 'VIDEO' && document.fullscreenElement === media.value) {
|
|
// Fullscreen needs to use the current video element for the next video
|
|
// because we are not allowed to fullscreen the next one.
|
|
// FIXME: Write our own player to avoid this problem...
|
|
const elem = media.value as HTMLVideoElement
|
|
const playing = el as HTMLVideoElement
|
|
if (elem === playing) {
|
|
playing.play() // Only one video, just replay
|
|
return
|
|
}
|
|
if (!fscurrent) {
|
|
elem.addEventListener(
|
|
'fullscreenchange',
|
|
ev => {
|
|
if (!fscurrent) return
|
|
// Restore the original video element and continue with the one that was playing
|
|
fscurrent.currentTime = elem.currentTime
|
|
fscurrent.click()
|
|
if (!elem.paused) fscurrent.play()
|
|
fscurrent = null
|
|
elem.src = props.doc.url
|
|
applyPoster(elem)
|
|
onpaused()
|
|
},
|
|
{ once: true }
|
|
)
|
|
}
|
|
fscurrent = playing
|
|
elem.src = playing.src
|
|
elem.poster = ''
|
|
elem.play()
|
|
} else {
|
|
document.exitFullscreen()
|
|
el.click()
|
|
}
|
|
}
|
|
defineExpose({
|
|
play() {
|
|
if (!media.value) return false
|
|
if (media.value.paused) {
|
|
media.value.play()
|
|
for (const el of Array.from(document.querySelectorAll('video, audio')) as (
|
|
| HTMLAudioElement
|
|
| HTMLVideoElement
|
|
)[]) {
|
|
if (el === media.value) continue
|
|
el.pause()
|
|
}
|
|
} else {
|
|
media.value.pause()
|
|
}
|
|
return true
|
|
},
|
|
media
|
|
})
|
|
|
|
const video = () => props.doc.video
|
|
const audio = () => props.doc.audio
|
|
const archive = () => props.doc.archive
|
|
const docs = () => props.doc.document
|
|
// image = requires server-side preview (browsers cannot display it natively)
|
|
// img = browser-viewable image that can be used directly in an <img> tag
|
|
const image = () => props.doc.image
|
|
const print = () => props.doc.print
|
|
const showProgress = () => !props.doc.complete && (preview() || props.doc.img)
|
|
const preview = () => {
|
|
const store = useMainStore()
|
|
return (
|
|
!(store.server.office_previews === false && docs()) &&
|
|
(image() || print() || (props.doc.img && props.doc.size > 500000))
|
|
)
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
img, embed, .icon, audio, video {
|
|
font-size: 8em;
|
|
overflow: hidden;
|
|
min-width: 50%;
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
border-radius: calc(.5em / 8);
|
|
}
|
|
.preview-progress {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 50%;
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
aspect-ratio: 1;
|
|
}
|
|
.preview-progress .icon {
|
|
opacity: 0.9;
|
|
}
|
|
.preview-image-shell {
|
|
position: relative;
|
|
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;
|
|
}
|
|
50% {
|
|
transform: scale(1.04);
|
|
opacity: 0.98;
|
|
}
|
|
100% {
|
|
transform: scale(1);
|
|
opacity: 0.86;
|
|
}
|
|
}
|
|
.folder::before {
|
|
content: '📁';
|
|
}
|
|
.folder:hover::before, .cursor .folder::before {
|
|
content: '📂';
|
|
}
|
|
.archive::before {
|
|
content: '📦';
|
|
}
|
|
.file::before {
|
|
content: '📄';
|
|
}
|
|
.ext-img::before {
|
|
content: '💿';
|
|
}
|
|
.ext-exe::before, .ext-msi::before, .ext-dmg::before, .ext-pkg::before {
|
|
content: '⚙️';
|
|
}
|
|
.ext-torrent::before {
|
|
content: '🏴☠️';
|
|
}
|
|
.audio audio {
|
|
opacity: 0;
|
|
transition: opacity var(--transition-time) ease-in-out;
|
|
}
|
|
.audio:hover audio {
|
|
opacity: 1;
|
|
}
|
|
.audio.icon::before {
|
|
width: 100%;
|
|
content: '🔈';
|
|
}
|
|
.audio.icon:has(audio[data-playing])::before {
|
|
position: absolute;
|
|
content: '🔊';
|
|
bottom: 0;
|
|
}
|
|
.icon {
|
|
filter: brightness(0.9);
|
|
}
|
|
figure.cursor .icon {
|
|
filter: brightness(1);
|
|
}
|
|
.video-container {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 50%;
|
|
min-height: 6em;
|
|
aspect-ratio: 16 / 9;
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
}
|
|
.video-container.pending {
|
|
background: color-mix(in srgb, var(--header-bg) 55%, transparent);
|
|
}
|
|
.video-container video {
|
|
width: 100%;
|
|
height: 100%;
|
|
border-radius: calc(.5em / 8);
|
|
object-fit: contain;
|
|
}
|
|
.play-overlay {
|
|
position: absolute;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
pointer-events: none;
|
|
width: 4em;
|
|
height: 4em;
|
|
background: rgba(0, 0, 0, 0.2);
|
|
border-radius: 50%;
|
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
}
|
|
.play-overlay svg {
|
|
width: 2em;
|
|
height: 2em;
|
|
fill: white;
|
|
margin-left: 0.25em; /* Visual centering for play triangle */
|
|
}
|
|
.video-container:hover .play-overlay {
|
|
transform: scale(1.1);
|
|
}
|
|
video[data-playing] + .play-overlay {
|
|
opacity: 0;
|
|
}
|
|
</style>
|