181 lines
5.5 KiB
Vue
181 lines
5.5 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
|
|
import { useLazyMedia } from '@/composables/useLazyMedia';
|
|
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
|
|
|
|
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
|
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
|
|
|
export interface AccessibleVideoSource {
|
|
src: string;
|
|
type: 'video/mp4' | 'video/webm';
|
|
}
|
|
|
|
const props = withDefaults(defineProps<{
|
|
sources: readonly AccessibleVideoSource[];
|
|
poster: string;
|
|
width: number;
|
|
height: number;
|
|
accessibleLabel?: string;
|
|
caption: string;
|
|
description: string;
|
|
allowAutomatic: boolean;
|
|
}>(), { accessibleLabel: 'Project video' });
|
|
|
|
const figure = ref<HTMLElement | null>(null);
|
|
const video = ref<HTMLVideoElement | null>(null);
|
|
const playing = shallowRef(false);
|
|
const unavailable = shallowRef(false);
|
|
const status = shallowRef('Video ready to play.');
|
|
const savedTime = shallowRef(0);
|
|
const userPaused = ref(false);
|
|
const pageVisible = ref(true);
|
|
const enhanced = ref(false);
|
|
const automatic = computed(() => props.allowAutomatic && !unavailable.value && !userPaused.value && pageVisible.value);
|
|
const lazy = useLazyMedia(figure, automatic, '0px');
|
|
const fallbackSource = computed(() => props.sources.find(({ type }) => type === 'video/mp4') ?? props.sources[0]);
|
|
let failureTimer: number | undefined;
|
|
|
|
const clearFailureTimer = (): void => {
|
|
if (failureTimer !== undefined) window.clearTimeout(failureTimer);
|
|
failureTimer = undefined;
|
|
};
|
|
|
|
const detach = async (): Promise<void> => {
|
|
const element = video.value;
|
|
if (element !== null) {
|
|
savedTime.value = Number.isFinite(element.currentTime) ? element.currentTime : savedTime.value;
|
|
element.pause();
|
|
playing.value = false;
|
|
status.value = unavailable.value ? status.value : 'Video paused while offscreen.';
|
|
}
|
|
await nextTick();
|
|
video.value?.load();
|
|
};
|
|
|
|
watch(() => lazy.shouldAttach.value, (attached, previous) => {
|
|
if (!attached && previous) void detach();
|
|
if (attached) void nextTick(() => {
|
|
// Explicit Play loads its sources before play(); a later load would abort it.
|
|
if (!lazy.explicitRequested.value) video.value?.load();
|
|
});
|
|
}, { flush: 'post' });
|
|
|
|
watch(() => props.allowAutomatic, (allowed) => {
|
|
if (!allowed && !lazy.explicitRequested.value) void detach();
|
|
});
|
|
|
|
const markUnavailable = (): void => {
|
|
clearFailureTimer();
|
|
unavailable.value = true;
|
|
playing.value = false;
|
|
lazy.clearExplicit();
|
|
status.value = 'This video could not be played. The poster and description remain available.';
|
|
};
|
|
|
|
const play = async (): Promise<void> => {
|
|
if (unavailable.value) return;
|
|
if (TEST_BUILD && isTestFault('F8-video-failure')) {
|
|
recordTestFaultEvent('video-failure');
|
|
markUnavailable();
|
|
return;
|
|
}
|
|
lazy.requestExplicit();
|
|
status.value = 'Loading video.';
|
|
await nextTick();
|
|
const element = video.value;
|
|
if (element === null) return markUnavailable();
|
|
element.load();
|
|
clearFailureTimer();
|
|
failureTimer = window.setTimeout(markUnavailable, 3_000);
|
|
try {
|
|
await element.play();
|
|
clearFailureTimer();
|
|
playing.value = true;
|
|
status.value = 'Video playing.';
|
|
} catch {
|
|
markUnavailable();
|
|
}
|
|
};
|
|
|
|
const pause = (): void => {
|
|
userPaused.value = true;
|
|
video.value?.pause();
|
|
playing.value = false;
|
|
status.value = 'Video paused.';
|
|
lazy.clearExplicit();
|
|
};
|
|
|
|
const togglePlayback = (): void => {
|
|
if (playing.value) pause();
|
|
else {
|
|
userPaused.value = false;
|
|
void play();
|
|
}
|
|
};
|
|
|
|
const handleVisibility = (): void => {
|
|
pageVisible.value = !document.hidden;
|
|
if (!pageVisible.value) {
|
|
video.value?.pause();
|
|
lazy.clearExplicit();
|
|
}
|
|
};
|
|
onMounted(() => {
|
|
enhanced.value = true;
|
|
handleVisibility();
|
|
document.addEventListener('visibilitychange', handleVisibility);
|
|
});
|
|
|
|
const restoreTime = (): void => {
|
|
const element = video.value;
|
|
if (element !== null && Number.isFinite(savedTime.value) && savedTime.value > 0) {
|
|
element.currentTime = Math.min(savedTime.value, Number.isFinite(element.duration) ? element.duration : savedTime.value);
|
|
}
|
|
};
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('visibilitychange', handleVisibility);
|
|
clearFailureTimer();
|
|
video.value?.pause();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<figure ref="figure" class="project-media project-media--video" data-project-media :data-enhanced="enhanced">
|
|
<video
|
|
ref="video"
|
|
:poster="poster"
|
|
:width="width"
|
|
:height="height"
|
|
:aria-label="accessibleLabel"
|
|
preload="none"
|
|
muted
|
|
loop
|
|
:autoplay="automatic"
|
|
playsinline
|
|
@play="playing = true; status = 'Video playing.'"
|
|
@pause="playing = false"
|
|
@loadedmetadata="restoreTime"
|
|
@error="markUnavailable"
|
|
@ended="playing = false; status = 'Video ended.'"
|
|
>
|
|
<source
|
|
v-for="source in lazy.shouldAttach.value ? sources : []"
|
|
:key="source.src"
|
|
:src="source.src"
|
|
:type="source.type"
|
|
/>
|
|
</video>
|
|
<figcaption>{{ caption }}</figcaption>
|
|
<p class="media-description">{{ description }}</p>
|
|
<div class="video-controls">
|
|
<button type="button" :disabled="unavailable" @click="togglePlayback">
|
|
{{ unavailable ? 'Video unavailable' : playing ? 'Pause video' : 'Play video' }}
|
|
</button>
|
|
<p data-testid="video-status">{{ status }}</p>
|
|
</div>
|
|
<a v-if="fallbackSource" class="video-fallback" :href="fallbackSource.src" target="_blank" rel="noopener noreferrer">Open gameplay video</a>
|
|
</figure>
|
|
</template>
|