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;