diff --git a/__tests__/component/media.spec.ts b/__tests__/component/media.spec.ts
index 9ed0fa7..426ce1e 100644
--- a/__tests__/component/media.spec.ts
+++ b/__tests__/component/media.spec.ts
@@ -537,6 +537,40 @@ describe('Videos.vue (smoke)', () => {
})
})
+// HEVC in many browsers: the sound plays over black, with no error
+describe('a video whose picture the browser cannot decode', () => {
+ /**
+ * Show a video the browser decodes at this width.
+ *
+ * @param decodedWidth - The width the metadata comes with, 0 for no picture
+ * @param headerSize - What the file's header says of its picture
+ */
+ async function showVideo(decodedWidth: number, headerSize?: { width: number, height: number }) {
+ probeFile.mockResolvedValueOnce(headerSize ? { indexFirst: true, size: headerSize } : undefined)
+ const file = makeFile({ basename: 'IMG_0001.mov', mime: 'video/quicktime' })
+ const wrapper = mount(Videos, { props: makeProps({ file, files: [file] }) })
+ await flushPromises()
+ const video = wrapper.find('video').element as HTMLVideoElement
+ Object.defineProperty(video, 'videoWidth', { value: decodedWidth, configurable: true })
+ Object.defineProperty(video, 'videoHeight', { value: decodedWidth === 0 ? 0 : 1080, configurable: true })
+ await wrapper.find('video').trigger('loadedmetadata')
+ return wrapper.find('.viewer-media__no-picture').exists()
+ }
+
+ it('says so, and lets the sound play on', async () => {
+ expect(await showVideo(0, { width: 1920, height: 1080 })).toBe(true)
+ })
+
+ it('says nothing of a picture it decoded', async () => {
+ expect(await showVideo(1920, { width: 1920, height: 1080 })).toBe(false)
+ })
+
+ // A file with only sound has no size either
+ it('says nothing of a file with no picture to show', async () => {
+ expect(await showVideo(0)).toBe(false)
+ })
+})
+
// Space, m, j and l reach the player: the arrows still step through the files
describe('the keyboard, once the media is on screen', () => {
// The skin itself: the modal's focus trap would take back a focus in its
diff --git a/lib/components/Videos.vue b/lib/components/Videos.vue
index 4ab608f..d7eb31f 100644
--- a/lib/components/Videos.vue
+++ b/lib/components/Videos.vue
@@ -43,6 +43,11 @@
class="viewer-media__poster"
alt=""
:src="poster">
+
+
+
+ {{ t('Your browser can\'t show this video\'s picture, only its sound') }}
+
@@ -52,6 +57,7 @@
import type { ViewerEmits, ViewerProps } from '../viewer.ts'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
+import VideoOffOutlineIcon from 'vue-material-design-icons/VideoOffOutline.vue'
import { useMediaPlayer } from '../composables/useMediaPlayer.ts'
import { useVideojsAdapter } from '../composables/useVideojsAdapter.ts'
import { logger } from '../services/logger.ts'
@@ -106,6 +112,16 @@ const livePhotoPath = computed(() => {
const videoPreview = ref()
const knownSize = ref<{ width: number, height: number }>()
let previewLoad: AbortController | undefined
+// The width the browser decoded the picture at, once it has the metadata
+const decodedWidth = ref()
+
+/**
+ * Whether the video has a picture the browser cannot decode, HEVC in many
+ * of them: its metadata loads with no size, and the sound plays over black,
+ * without an error. Only for a video known to have a picture, from its
+ * preview or its header, as a file with only sound is no size too.
+ */
+const pictureMissing = computed(() => decodedWidth.value === 0 && knownSize.value !== undefined)
// The picture of the same name beside the video when there is one: it is
// what people put there to be shown. The video's own preview otherwise.
@@ -114,6 +130,7 @@ const poster = computed(() => livePhotoPath.value ?? videoPreview.value)
watch(() => props.file.source, (source) => {
previewLoad?.abort()
knownSize.value = undefined
+ decodedWidth.value = undefined
if (!canDownload(props.file) || props.file.attributes?.['e2ee-is-encrypted']) {
videoPreview.value = undefined
return
@@ -173,6 +190,7 @@ function updateVideoSize() {
*/
function onLoadedMetadata() {
logger.debug('Video metadata loaded, updating size', { filename: props.file.basename })
+ decodedWidth.value = video.value?.videoWidth ?? 0
updateVideoSize()
showBeforePlayable()
}
@@ -197,6 +215,28 @@ function onLoadedMetadata() {
background-color: black;
}
+.viewer-media__no-picture {
+ position: absolute;
+ top: calc(2 * var(--default-grid-baseline));
+ inset-inline-start: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ align-items: center;
+ gap: var(--default-grid-baseline);
+ max-width: calc(100% - 4 * var(--default-grid-baseline));
+ margin: 0;
+ padding: var(--default-grid-baseline) calc(3 * var(--default-grid-baseline));
+ border: 1px solid rgba(255, 255, 255, .25);
+ border-radius: var(--border-radius-pill);
+ background-color: rgba(0, 0, 0, .75);
+ color: white;
+ pointer-events: none;
+}
+
+[dir="rtl"] .viewer-media__no-picture {
+ transform: translateX(50%);
+}
+
.viewer-media__poster {
position: absolute;
inset: 0;