vuetify
Version:
Vue Material Component Framework
427 lines (426 loc) • 15.4 kB
JavaScript
import { createVNode as _createVNode, createElementVNode as _createElementVNode, mergeProps as _mergeProps, normalizeClass as _normalizeClass, normalizeStyle as _normalizeStyle } from "vue";
// Styles
import "./VVideo.css";
// Components
import { makeVVideoControlsProps, VVideoControls } from "./VVideoControls.js";
import { VFadeTransition } from "../../components/transitions/index.js";
import { VSpacer } from "../../components/VGrid/VSpacer.js";
import { VImg } from "../../components/VImg/VImg.js";
import { VOverlay } from "../../components/VOverlay/VOverlay.js";
import { VProgressCircular } from "../../components/VProgressCircular/VProgressCircular.js";
import { VIconBtn } from "../VIconBtn/VIconBtn.js"; // Composables
import { useDisplay } from "../../composables/index.js";
import { makeComponentProps } from "../../composables/component.js";
import { makeDensityProps, useDensity } from "../../composables/density.js";
import { makeDimensionProps, useDimension } from "../../composables/dimensions.js";
import { useElevation } from "../../composables/elevation.js";
import { forwardRefs } from "../../composables/forwardRefs.js";
import { useProxiedModel } from "../../composables/proxiedModel.js";
import { useRounded } from "../../composables/rounded.js";
import { makeThemeProps, provideTheme } from "../../composables/theme.js";
import { MaybeTransition } from "../../composables/transition.js"; // Utilities
import { nextTick, onBeforeUnmount, onMounted, ref, shallowRef, toRef, watch } from 'vue';
import { createRange, genericComponent, omit, pick, propsFactory, useRender } from "../../util/index.js"; // Types
const allowedVariants = ['background', 'player'];
export const makeVVideoProps = propsFactory({
aspectRatio: [String, Number],
autoplay: Boolean,
muted: Boolean,
eager: Boolean,
src: String,
type: String,
// e.g. video/mp4
image: String,
hideOverlay: Boolean,
noFullscreen: Boolean,
startAt: [Number, String],
variant: {
type: String,
default: 'player',
validator: v => allowedVariants.includes(v)
},
controlsTransition: {
type: [Boolean, String, Object],
component: VFadeTransition
},
controlsVariant: {
type: String,
default: 'default'
},
controlsProps: {
type: Object
},
rounded: [Boolean, Number, String, Array],
...makeComponentProps(),
...makeDensityProps(),
...makeDimensionProps(),
...makeThemeProps(),
...omit(makeVVideoControlsProps(), ['fullscreen', 'variant'])
}, 'VVideo');
export const VVideo = genericComponent()({
name: 'VVideo',
inheritAttrs: false,
props: makeVVideoProps(),
emits: {
loaded: element => true,
'update:playing': val => true,
'update:progress': val => true,
'update:volume': val => true
},
setup(props, _ref) {
let {
attrs,
emit,
slots
} = _ref;
const {
themeClasses
} = provideTheme(props);
const {
densityClasses
} = useDensity(props);
const {
dimensionStyles
} = useDimension(props);
const {
elevationClasses
} = useElevation(props);
const {
ssr
} = useDisplay();
const roundedForContainer = toRef(() => Array.isArray(props.rounded) ? props.rounded[0] : props.rounded);
const roundedForControls = toRef(() => Array.isArray(props.rounded) ? props.rounded.at(-1) : props.rounded ?? false);
const {
roundedClasses: roundedContainerClasses
} = useRounded(roundedForContainer);
const {
roundedClasses: roundedControlsClasses
} = useRounded(roundedForControls);
const containerRef = ref();
const videoRef = ref();
const controlsRef = ref();
const playing = useProxiedModel(props, 'playing');
const progress = useProxiedModel(props, 'progress');
const volume = useProxiedModel(props, 'volume', 0, v => Number(v ?? 0));
const fullscreen = shallowRef(false);
const waiting = shallowRef(false);
const triggered = shallowRef(false);
const startAfterLoad = shallowRef(false);
const state = shallowRef(props.autoplay ? 'loading' : 'idle');
const duration = shallowRef(0);
const fullscreenEnabled = toRef(() => !props.noFullscreen && !String(attrs.controlsList ?? '').includes('nofullscreen'));
function onTimeupdate() {
const {
currentTime,
duration
} = videoRef.value;
progress.value = duration === 0 ? 0 : 100 * currentTime / duration;
}
async function onTriggered() {
await nextTick();
if (!videoRef.value) return;
videoRef.value.addEventListener('timeupdate', onTimeupdate);
videoRef.value.volume = volume.value / 100;
if (state.value !== 'loaded') {
state.value = 'loading';
}
}
function onVideoLoaded() {
state.value = 'loaded';
duration.value = videoRef.value.duration;
const startTime = Number(props.startAt ?? 0);
if (startTime && startTime <= duration.value) {
videoRef.value.currentTime = startTime;
progress.value = duration.value === 0 ? 0 : 100 * startTime / duration.value;
}
if (startAfterLoad.value) {
setTimeout(() => playing.value = true, 100);
}
emit('loaded', videoRef.value);
}
function onClick() {
if (state.value !== 'loaded') {
triggered.value = true;
startAfterLoad.value = !startAfterLoad.value;
}
}
function onKeydown(e) {
if (!videoRef.value || e.ctrlKey) return;
if (e.key.startsWith('Arrow')) {
e.preventDefault();
}
switch (true) {
case e.key === ' ':
{
if (!['A', 'BUTTON'].includes(e.target?.tagName)) {
e.preventDefault();
playing.value = !playing.value;
}
break;
}
case e.key === 'ArrowRight':
{
const step = 10 * (e.shiftKey ? 6 : 1);
videoRef.value.currentTime = Math.min(videoRef.value.currentTime + step, duration.value);
// TODO: show skip indicator
break;
}
case e.key === 'ArrowLeft':
{
const step = 10 * (e.shiftKey ? 6 : 1);
videoRef.value.currentTime = Math.max(videoRef.value.currentTime - step, 0);
// TODO: show skip indicator
break;
}
case createRange(10).map(String).includes(e.key):
{
skipTo(Number(e.key) * 10);
break;
}
case e.key === 'ArrowUp':
{
volume.value = Math.min(volume.value + 10, 100);
// TODO: show volume change indicator
break;
}
case e.key === 'ArrowDown':
{
volume.value = Math.max(volume.value - 10, 0);
// TODO: show volume change indicator
break;
}
case e.key === 'm':
{
controlsRef.value?.toggleMuted();
break;
}
case e.key === 'f':
{
toggleFullscreen();
break;
}
}
}
function skipTo(v) {
if (!videoRef.value) return;
progress.value = v;
videoRef.value.currentTime = duration.value * v / 100;
}
watch(() => props.src, v => {
progress.value = 0;
});
watch(playing, v => {
if (!videoRef.value) return;
if (v) {
videoRef.value.play();
} else {
videoRef.value.pause();
}
});
watch(volume, v => {
if (!videoRef.value) return;
videoRef.value.volume = v / 100;
});
watch(triggered, () => onTriggered(), {
once: true
});
watch(() => props.eager, v => v && (triggered.value = true), {
immediate: true
});
onMounted(() => {
if (props.autoplay && !ssr) {
triggered.value = true;
startAfterLoad.value = true;
}
});
onBeforeUnmount(() => {
videoRef.value?.removeEventListener('timeupdate', onTimeupdate);
});
function focusSlider() {
const container = videoRef.value?.closest('.v-video');
const innerSlider = container?.querySelector('[role="slider"]');
innerSlider?.focus();
}
function fullscreenExitShortcut(e) {
if (['ESC', 'f'].includes(e.key)) {
toggleFullscreen();
document.body.removeEventListener('keydown', fullscreenExitShortcut);
}
}
async function toggleFullscreen() {
if (!fullscreenEnabled.value || !document.fullscreenEnabled) {
return;
}
if (document.fullscreenElement) {
document.exitFullscreen();
onFullscreenExit();
} else {
await containerRef.value?.requestFullscreen();
document.body.addEventListener('keydown', fullscreenExitShortcut);
document.addEventListener('fullscreenchange', onFullscreenExit);
fullscreen.value = true;
}
}
function onFullscreenExit() {
// event fires with a delay after requestFullscreen(), ignore first run
if (document.fullscreenElement) return;
focusSlider();
fullscreen.value = false;
document.body.removeEventListener('keydown', fullscreenExitShortcut);
document.removeEventListener('fullscreenchange', onFullscreenExit);
}
function onVideoClick(e) {
e.preventDefault();
if (state.value === 'loaded') {
playing.value = !playing.value;
focusSlider();
}
}
function onDoubleClick(e) {
e.preventDefault();
toggleFullscreen();
}
let lastTap = 0;
function onTouchend(e) {
const now = performance.now();
if (now - lastTap < 500) {
e.preventDefault();
toggleFullscreen();
} else {
lastTap = now;
}
}
useRender(() => {
const showControls = state.value === 'loaded' && props.variant === 'player' && props.controlsVariant !== 'hidden';
const posterTransition = props.variant === 'background' ? 'poster-fade-out' : 'fade-transition';
const overlayProps = {
contained: true,
persistent: true,
contentClass: 'v-video__overlay-fill'
};
const controlsProps = {
...VVideoControls.filterProps(omit(props, ['variant', 'rounded', 'hideVolume'])),
rounded: Array.isArray(props.rounded) ? props.rounded.at(-1) : props.rounded,
fullscreen: fullscreen.value,
hideVolume: props.hideVolume || props.muted,
hideFullscreen: props.hideFullscreen || !fullscreenEnabled.value,
density: props.density,
variant: props.controlsVariant,
playing: playing.value,
progress: progress.value,
duration: duration.value,
volume: volume.value,
...props.controlsProps
};
const controlsEventHandlers = {
onSkip: v => skipTo(v),
'onClick:fullscreen': () => toggleFullscreen(),
'onUpdate:playing': v => playing.value = v,
'onUpdate:progress': v => skipTo(v),
'onUpdate:volume': v => volume.value = v,
onClick: e => e.stopPropagation()
};
const controlslist = [attrs.controlslist, props.noFullscreen ? 'nofullscreen' : ''].filter(Boolean).join(' ');
const loadingIndicator = _createVNode(VProgressCircular, {
"indeterminate": true,
"color": props.color,
"width": "3",
"size": Math.min(100, Number(props.height) / 2 || 50)
}, null);
const overlayPlayIcon = _createVNode(VIconBtn, {
"icon": "$play",
"size": "80",
"color": "#fff",
"variant": "outlined",
"iconSize": "50",
"class": "v-video__center-icon"
}, null);
return _createElementVNode("div", {
"ref": containerRef,
"class": _normalizeClass(['v-video', `v-video--variant-${props.variant}`, `v-video--${state.value}`, {
'v-video--playing': playing.value
}, themeClasses.value, densityClasses.value, roundedContainerClasses.value, props.class]),
"style": _normalizeStyle([{
'--v-video-aspect-ratio': props.aspectRatio
}, props.variant === 'background' ? [] : pick(dimensionStyles.value, ['width', 'minWidth', 'maxWidth']), props.style]),
"onKeydown": onKeydown,
"onClick": onClick
}, [_createElementVNode("div", {
"class": _normalizeClass(['v-video__content', elevationClasses.value]),
"style": _normalizeStyle([props.variant === 'background' ? [] : dimensionStyles.value])
}, [(props.eager || triggered.value) && _createElementVNode("video", _mergeProps({
"key": "video-element",
"class": ['v-video__video', roundedContainerClasses.value]
}, omit(attrs, ['controlslist', 'class', 'style']), {
"controlslist": controlslist,
"autoplay": props.autoplay,
"muted": props.muted,
"playsinline": true,
"ref": videoRef,
"onLoadeddata": onVideoLoaded,
"onPlay": () => playing.value = true,
"onPause": () => playing.value = false,
"onWaiting": () => waiting.value = true,
"onPlaying": () => waiting.value = false,
"onClick": onVideoClick,
"onDblclick": onDoubleClick,
"onTouchend": onTouchend
}), [slots.sources?.() ?? _createElementVNode("source", {
"src": props.src,
"type": props.type
}, null)]), props.variant === 'player' && !props.hideOverlay && _createVNode(VOverlay, _mergeProps({
"key": "pause-overlay",
"modelValue": state.value === 'loaded',
"opacity": "0"
}, overlayProps), {
default: () => [_createVNode(VSpacer, null, null), _createVNode(MaybeTransition, {
"name": "fade-transition"
}, {
default: () => [!playing.value && overlayPlayIcon]
}), _createVNode(VSpacer, null, null)]
}), props.variant === 'player' && !!slots.header ? _createElementVNode("div", {
"key": "header",
"class": "v-video__header"
}, [slots.header()]) : '', _createVNode(VOverlay, _mergeProps({
"key": "poster-overlay",
"modelValue": state.value !== 'loaded',
"transition": posterTransition
}, overlayProps), {
default: () => [_createVNode(VImg, {
"cover": true,
"src": props.image
}, {
default: () => [_createElementVNode("div", {
"class": _normalizeClass(['v-video__overlay-fill', ...roundedContainerClasses.value])
}, [props.variant === 'player' && overlayPlayIcon])]
})]
}), props.variant === 'player' && _createVNode(VOverlay, _mergeProps({
"key": "loading-overlay",
"modelValue": state.value === 'loading' || waiting.value,
"opacity": ".1"
}, overlayProps), {
default: () => [loadingIndicator]
})]), _createVNode(MaybeTransition, {
"key": "actions",
"transition": props.controlsTransition
}, {
default: () => [showControls && _createVNode(VVideoControls, _mergeProps({
"ref": controlsRef,
"class": roundedControlsClasses.value
}, controlsProps, controlsEventHandlers), {
default: slots.controls,
prepend: slots.prepend,
append: slots.append
})]
})]);
});
return {
video: videoRef,
...forwardRefs({
skipTo,
toggleFullscreen
}, controlsRef)
};
}
});
//# sourceMappingURL=VVideo.js.map