feat: present cleared accessible project media

This commit is contained in:
2026-09-02 06:50:00 +03:00
parent dfd56f0a48
commit f953905a4b
12 changed files with 832 additions and 4 deletions

View File

@@ -0,0 +1,140 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, shallowRef, watch } from 'vue';
import { useLazyMedia } from '@/composables/useLazyMedia';
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 automatic = computed(() => props.allowAutomatic && !unavailable.value);
const lazy = useLazyMedia(figure, automatic);
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(() => 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;
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 => {
video.value?.pause();
playing.value = false;
status.value = 'Video paused.';
lazy.clearExplicit();
};
const togglePlayback = (): void => {
if (playing.value) pause();
else void play();
};
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(() => {
clearFailureTimer();
video.value?.pause();
});
</script>
<template>
<figure ref="figure" class="project-media project-media--video" data-project-media>
<video
ref="video"
:poster="poster"
:width="width"
:height="height"
:aria-label="accessibleLabel"
preload="none"
playsinline
@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>
</figure>
</template>