Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions __tests__/component/media.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions lib/components/Videos.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@
class="viewer-media__poster"
alt=""
:src="poster">
<!-- The sound plays on: only the picture is lost -->
<p v-if="pictureMissing" class="viewer-media__no-picture" role="status">
<VideoOffOutlineIcon :size="18" />
{{ t('Your browser can\'t show this video\'s picture, only its sound') }}
</p>
</video-skin>
</video-player>
</media-i18n>
Expand All @@ -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'
Expand Down Expand Up @@ -106,6 +112,16 @@ const livePhotoPath = computed(() => {
const videoPreview = ref<string>()
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<number>()

/**
* 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.
Expand All @@ -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
Expand Down Expand Up @@ -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()
}
Expand All @@ -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;
Expand Down
Loading