@haxtheweb/a11y-media-player
Version:
A feature rich, highly accessible video player
588 lines (551 loc) • 14.7 kB
JavaScript
/**
* Copyright 2018 The Pennsylvania State University
* @license Apache-2.0, see License.md for full text.
*/
import { LitElement, html, css } from "lit";
import "@haxtheweb/hax-iconset/lib/simple-hax-iconset.js";
// singleton for youtube frames
globalThis.A11yMediaYoutubeManager = globalThis.A11yMediaYoutubeManager || {
/* gets iframes for all */
getIframes: () => {
globalThis.A11yMediaYoutubeManager.queue.forEach((instance) => {
instance.__yt = instance._preloadVideo(true);
});
globalThis.A11yMediaYoutubeManager.queue = [];
},
queue: [], //array of instances waiting for iframes
};
/**
* `a11y-media-youtube`
* uses YouTubeAPI to create and control an embedded YouTube video.
* @element a11y-media-youtube
*/
class A11yMediaYoutube extends LitElement {
// properties available to the custom element for data binding
/**
* Store the tag name to make it easier to obtain directly.
*/
static get tag() {
return "a11y-media-youtube";
}
//styles function
static get styles() {
return [
css`
iframe .ytp-pause-overlay {
display: none !important;
}
`,
];
}
//render function
render() {
return html`<slot></slot>`;
}
// properties available to the custom element for data binding
static get properties() {
return {
...super.properties,
/**
* a11y-media-youtube unique id
*/
id: {
type: String,
},
/**
* video loops back to start
*/
autoplay: {
type: Boolean,
},
/**
* height of the embedded video
*/
height: {
type: String,
},
/**
* video loops back to start
*/
loop: {
type: Boolean,
},
/**
* video muted
*/
muted: {
type: Boolean,
},
/**
* preload settings
*/
preload: {
type: String,
attribute: "preload",
reflect: true,
},
/**
* video playback rate
*/
playbackRate: {
type: Number,
attribute: "playback-rate",
},
/**
* youTube's unique identifier for the video
*/
t: {
type: Number,
attribute: "t",
reflect: true,
},
/**
* youTube's unique identifier for the video
*/
videoId: {
type: String,
attribute: "video-id",
reflect: true,
},
/**
* volume between 0 and 100
*/
volume: {
type: Number,
},
/**
* width of the embedded video
*/
width: {
type: String,
},
/**
* video object
*/
__video: {
type: Object,
},
/**
* youtube object
*/
__yt: {
type: Object,
},
};
}
/**
* life cycle, element is afixed to the DOM
* Makes sure there is a utility ready and listening for elements.
*/
constructor() {
super();
this.autoplay = false;
this.height = "100%";
this.loop = false;
this.playbackRate = 1;
this.preload = "metadata";
this.muted = false;
this.volume = 0.7;
this.width = "100%";
this.__video = null;
this.__yt = null;
}
/**
* inspired by https://github.com/paulirish/lite-youtube-embed/blob/master/src/lite-yt-embed.js
*/
static warmConnections() {
if (A11yMediaYoutube.preconnected) return;
// The iframe document and most of its subresources come right off youtube.com
A11yMediaYoutube.addPrefetch(
"preconnect",
"https://www.youtube-nocookie.com",
);
// The botguard script is fetched off from google.com
A11yMediaYoutube.addPrefetch("preconnect", "https://www.google.com");
// Not certain if these ad related domains are in the critical path. Could verify with domain-specific throttling.
A11yMediaYoutube.addPrefetch(
"preconnect",
"https://googleads.g.doubleclick.net",
);
A11yMediaYoutube.addPrefetch(
"preconnect",
"https://static.doubleclick.net",
);
A11yMediaYoutube.preconnected = true;
}
/**
* Add a <link rel={preload | preconnect} ...> to the head
*/
static addPrefetch(kind, url, as) {
const linkEl = globalThis.document.createElement("link");
linkEl.rel = kind;
linkEl.href = url;
if (as) {
linkEl.as = as;
}
globalThis.document.head.append(linkEl);
}
/**
* single instance of YouTube iframe script
* @readonly
* @returns {object} script tag
*/
get api() {
let scriptid = "a11y-media-youtube-api",
ytapi = globalThis.document.querySelector(`#${scriptid}`);
/* only add if script doesn't already exist */
if (!ytapi) {
ytapi = globalThis.document.createElement("script");
ytapi.setAttribute("id", scriptid);
ytapi.setAttribute("src", "https://www.youtube.com/iframe_api");
ytapi.setAttribute("type", "text/javascript");
globalThis.document.body.appendChild(ytapi);
}
return ytapi;
}
/**
* returns buffered media
* @readonly
* @returns {number} seconds of buffered media
*/
get buffered() {
return this.__yt && this.__yt.buffered && this.__yt.buffered.length > 0
? this.__yt.buffered.end(0)
: -1;
}
/**
* elapsed time of video
* @readonly
* @returns {number} time in seconds
*/
get currentTime() {
return this.__yt && this.__yt.getCurrentTime
? this.__yt.getCurrentTime()
: undefined;
}
/**
* duration of video
* @readonly
* @returns {number} duration in seconds
*/
get duration() {
return this.__yt && this.__yt.getDuration ? this.__yt.getDuration() : 0;
}
/**
* whether video playback is paused
* @readonly
* @returns {boolean}
*/
get paused() {
return this.__yt && this.__yt.getPlayerState
? this.__yt.getPlayerState() !== 1
: true;
}
/**
* seekable range of video
* @readonly
* @returns {object} TimeRanges object
*/
get seekable() {
let seekable = { length: 0 };
if (this.duration > 0) {
seekable.length = 1;
seekable.start = (index) => 0;
seekable.end = (index) => this.duration;
}
return seekable;
}
/**
* initializes singleton to manage a11y-manager-youtube instances
*/
init() {
globalThis.A11yMediaYoutubeManager.queue.push(this);
/* checks for api and either uses it to get iframes or gets it */
if (globalThis.A11yMediaYoutubeManager.api) {
if (globalThis.YT) globalThis.A11yMediaYoutubeManager.getIframes();
} else {
globalThis.onYouTubeIframeAPIReady = (e) => {
globalThis.A11yMediaYoutubeManager.getIframes();
};
globalThis.A11yMediaYoutubeManager.api = this.api;
}
}
/**
* @param {map} changedProperties the properties that have changed
*/
updated(changedProperties) {
let iframeChanged = false,
videoChanged = false,
autoChanged = false;
changedProperties.forEach((oldValue, propName) => {
if (propName === "muted") this.setMute(this.muted);
if (propName === "duration" && this.duration > 0)
this._handleMediaLoaded();
if (propName === "loop") this.setLoop(this.loop);
if (propName === "currentTime") this.seek(this.currentTime);
if (propName === "playbackRate") this.setPlaybackRate(this.playbackRate);
if (propName === "volume") this.setVolume(this.volume);
/* reload one batch of changes at a time */
if (propName === "videoId" && !!this.videoId && !this.__yt) this.init();
if (["id", "height", "width", "preload"].includes(propName) && this.__yt)
iframeChanged = true;
if (["autoplay", "videoId", "__video"].includes(propName) && this.__video)
videoChanged = true;
if (
["preload", "t"].includes(propName) &&
(this.preload === "auto" || this.t)
)
autoChanged = true;
});
/* reload iframe changes first, video changes will update based on iframe */
if (iframeChanged) {
this.__yt = this._preloadVideo(true);
} else if (videoChanged) {
this._loadVideo();
if (autoChanged) this._autoMetadata();
}
}
/**
* plays video
*/
play() {
if (!this.__yt) this.__yt = this._preloadVideo(false);
if (
!!this.__yt &&
!!this.__yt.playVideo &&
!!this.__video &&
!!this.videoId
) {
this.__playQueued = true;
var yt = this.__yt,
fn = function () {
yt.playVideo();
this.__playQueued = false;
};
setTimeout(fn, 1000);
}
}
/**
* pauses video
*/
pause() {
if (this.__yt && this.__yt.pauseVideo) this.__yt.pauseVideo();
}
/**
* seeks video
* @param {number} time in seconds
*/
seek(time = 0) {
let root = this;
if (this.__yt && this.__yt.seekTo) {
this.__yt.seekTo(time, true);
}
}
/**
* sets video looping
* @param {boolean} whether video should loop after playback finishes
*/
setLoop(loop) {
if (this.__yt && this.__yt.setLoop) this.media.setLoop(loop);
}
/**
* mutes or unmutes video
* @param {boolean} whether the video should be muted
*/
setMute(muted) {
if (this.__yt) {
if (muted && this.__yt.mute) {
this.__yt.mute();
} else if (this.__yt.unMute) {
this.__yt.unMute();
}
}
}
/**
* sets playbackRate function
* @param {number} playback rate X normal speed
*/
setPlaybackRate(value) {
if (this.__yt && this.__yt.setPlaybackRate) {
this.__yt.setPlaybackRate(Number(value));
}
}
/**
* sets video volume
* @param {number} volume from 1 - 10
*/
setVolume(volume = 0.7) {
if (this.__yt) this.__yt.setVolume(volume * 100);
}
/**
* returns time in seconds of a string, such as 00:00:00.0, 0h0m0.0s, or 0hh0mm0.0ss
* @param {string} time
* @returns {float} seconds
*/
_getSeconds(time = 0) {
let units = time
.replace(/[hm]{1,2}&?/g, ":0")
.replace(/[s]{1,2}$/g, "")
.split(/:/),
hh = units.length > 2 ? parseInt(units[units.length - 3]) : 0,
mm = units.length > 1 ? parseInt(units[units.length - 2]) : 0,
ss = units.length > 0 ? parseFloat(units[units.length - 1]) : 0;
return hh * 3600 + mm * 60 + ss;
}
/**
* Fires as YouTube after video src is loaded
* @event mediastatechange
*/
_handleMediaStateChange(e) {
this.dispatchEvent(
new CustomEvent("mediastatechange", {
bubbles: true,
cancelable: true,
composed: true,
detail: e,
}),
);
}
/**
* Fires as YouTube video time changes
* @event timeupdate
*/
_handleMediaLoaded(e) {
this.dispatchEvent(
new CustomEvent("loadedmetadata", {
bubbles: true,
cancelable: true,
composed: true,
detail: this,
}),
);
}
/**
* Fires as YouTube video time changes
* @event timeupdate
*/
_handleTimeupdate() {
this.dispatchEvent(
new CustomEvent("timeupdate", {
bubbles: true,
cancelable: true,
composed: true,
detail: this,
}),
);
}
/**
* loads metadata by playing a small clip on mute and stopping
*/
_autoMetadata() {
let seek = this.t || 0,
autoplay = this.autoplay;
this.setMute(true);
this.__yt.playVideo();
let timeout = 120000,
checkDuration = setInterval(() => {
timeout--;
//give the video up to 2 minute to attempt preload
if ((this.duration && this.duration > 0) || timeout <= 0) {
this.pause();
this.setMute(this.muted);
clearInterval(checkDuration);
this.seek(seek);
if (autoplay) this.play();
}
}, 1);
this.seek(seek);
}
/**
* loads video (and optionally preloads) from video data object {videoId, optional start timecode, }
* @param {string} preload mode for preloading: `auto`, `metadata`, `none`
*/
_loadVideo(preload = this.preload) {
if (!!this.videoId) this.__video.cueVideoById({ videoId: this.videoId });
}
/**
* initializes the youtube player for a given element
* See https://developers.google.com/youtube/player_parameters for more information
*
* @returns {object} the YouTube player object
*/
_preloadVideo(auto = false) {
let root = this,
load =
(!auto || this.preload !== "none") && this.videoId && !this.__video,
div = globalThis.document.createElement("div"),
divid = `container-${this.id}`,
youtube = null;
globalThis.document.body.appendChild(div);
div.setAttribute("id", divid);
if (load) {
// Warm the connection for the poster image
A11yMediaYoutube.addPrefetch(
"preload",
`https://img.youtube.com/vi/${this.videoId.replace(
/[\?&].*/,
"",
)}/hqdefault.jpg`,
"image",
);
let setYT = (e) => (this.__video = e.target),
port = globalThis.location.port ? `:${globalThis.location.port}` : ``,
origin = `${globalThis.location.protocol}//${globalThis.location.hostname}${port}`;
youtube = new YT.Player(divid, {
width: root.width,
height: root.height,
events: { onReady: setYT },
playerVars: {
color: "white",
controls: 0,
autoplay: root.autoplay,
disablekb: 1,
enablejsapi: 1,
origin: origin,
iv_load_policy: 3,
modestbranding: 1,
//todo research playsinline
rel: 0,
widget_referrer: globalThis.location.href,
},
});
youtube.timeupdate;
youtube.addEventListener("onStateChange", (e) => {
if (root.paused) {
clearInterval(youtube.timeupdate);
} else {
youtube.timeupdate = setInterval(() => root._handleTimeupdate(), 1);
}
this._handleMediaStateChange(e);
});
this.innerHTML = "";
this.appendChild(youtube.getIframe());
div.remove();
}
return youtube;
}
/**
* removes iframe aand resets container
*/
_removeIframe() {
if (this.__yt) {
this.__yt.remove;
try {
this.__yt.destroy();
} catch (e) {
console.warn(e);
}
}
this.innerHTML = "";
}
connectedCallback() {
super.connectedCallback();
A11yMediaYoutube.warmConnections();
}
disconnectedCallback() {
this._removeIframe();
super.disconnectedCallback();
}
}
globalThis.customElements.define(A11yMediaYoutube.tag, A11yMediaYoutube);
export { A11yMediaYoutube };