UNPKG

@haxtheweb/video-player

Version:
1,168 lines (1,141 loc) 33.2 kB
/** * Copyright 2018 The Pennsylvania State University * @license Apache-2.0, see License.md for full text. */ import { css, html } from "lit"; import { DDD } from "@haxtheweb/d-d-d/d-d-d.js"; import "@haxtheweb/a11y-media-player/a11y-media-player.js"; import { I18NMixin } from "@haxtheweb/i18n-manager/lib/I18NMixin.js"; import { IntersectionObserverMixin } from "@haxtheweb/intersection-element/lib/IntersectionObserverMixin.js"; import { MediaBehaviorsVideo } from "@haxtheweb/media-behaviors/media-behaviors.js"; import { SchemaBehaviors } from "@haxtheweb/schema-behaviors/schema-behaviors.js"; import { copyToClipboard } from "@haxtheweb/utils/utils.js"; /** * `video-player` * `A simple responsive video player with ridiculously powerful backing` * * @microcopy - language worth noting: * - `video source` - url / link to video file * * @demo demo/index.html * @element video-player */ class VideoPlayer extends IntersectionObserverMixin( MediaBehaviorsVideo(SchemaBehaviors(I18NMixin(DDD))), ) { //styles function static get styles() { return [ super.styles, css` :host { display: block; margin: var(--ddd-spacing-4) 0; } .video-caption { font-style: italic; margin: 0; padding: 8px; } a11y-media-player { box-shadow: var(--ddd-boxShadow-lg); --a11y-media-color: var( --video-player-color, var(--simple-colors-default-theme-accent-11, #111111) ); --a11y-media-caption-color: var( --video-player-caption-color, var(--simple-colors-default-theme-grey-2, #eeeeee) ); --a11y-media-bg-color: var( --video-player-bg-color, var(--simple-colors-default-theme-grey-2, #eeeeee) ); --a11y-media-border-color: var( --video-player-border-color, var(--simple-colors-default-theme-accent-3, #dddddd) ); --a11y-media-hover-color: var( --video-player-hover-color, var(--simple-colors-default-theme-accent-12, #000000) ); --a11y-media-hover-bg-color: var( --video-player-hover-bg-color, var(--simple-colors-default-theme-accent-2, #eeeeee) ); --a11y-media-accent-color: var( --video-player-accent-color, var(--simple-colors-default-theme-accent-9, #333333) ); --a11y-media-faded-accent-color: var( --video-player-faded-accent-color, var(--simple-colors-default-theme-accent-8, #444444) ); --a11y-media-disabled-color: var( --video-player-disabled-color, var(--simple-colors-default-theme-accent-5, #bbbbbb) ); } `, ]; } // render function render() { return html` ${this.elementVisible ? html`${!this.isA11yMedia ? html` <div class="responsive-video-container" .lang="${this.lang || "en"}" > ${this.sandboxed ? html`` : html` <webview resource="${this.schemaResourceID}-video" .src="${(this.sourceData && this.sourceData[0] && this.sourceData[0].src) || undefined}" .width="${this.width || undefined}" .height="${this.height || undefined}" frameborder="0" > </webview>`} ${!(!this.sandboxed && this.iframed) ? html`` : html` <iframe loading="lazy" resource="${this.schemaResourceID}-video" .src="${(this.sourceData && this.sourceData[0] && this.sourceData[0].src) || undefined}" width="${this.width}" height="${this.height}" frameborder="0" allow="autoplay; encrypted-media; fullscreen; picture-in-picture" ></iframe> `} </div> <div id="videocaption" class="video-caption"> <p> ${this.mediaTitle} <span class="media-type print-only" >(${this.t.embeddedMedia})</span > </p> <slot name="caption"></slot> </div> <slot hidden></slot>` : html` <a11y-media-player accent-color="${this.accentColor}" ?audio-only="${this.audioOnly}" ?dark="${this.dark}" ?dark-transcript="${this.darkTranscript}" ?disable-interactive="${this.disableInteractive}" ?hide-timestamps="${this.hideTimestamps}" ?hide-transcript="${this.hideTranscript}" ?hide-youtube-link="${this.hideYoutubeLink}" id="${this.playerId}" @play="${this.playEvent}" @restart="${this.restartEvent}" @pause="${this.pauseEvent}" lang="${this.lang || "en"}" ?learning-mode="${this.learningMode}" ?linkable="${this.linkable}" preload="metadata" media-title="${this.mediaTitle || ""}" .sources="${this.sourceProperties}" ?stand-alone="${this.standAlone}" sticky-corner="${this.stickyCorner || "none"}" thumbnail-src="${this.thumbnailSrc}" .tracks="${this.trackProperties}" .crossorigin="${this.crossorigin || "anonymous"}" .width="${this.width}" .height="${this.height}" youtube-id="${this.youtubeId}" > </a11y-media-player ><slot hidden></slot>`}` : ``}`; } // haxProperty definition static get haxProperties() { return { canScale: { min: 50, step: 25, }, designSystem: { card: true, }, canEditSource: true, gizmo: { title: "Video", description: "This can present video in a highly accessible manner regardless of source.", icon: "av:play-circle-filled", color: "red", tags: [ "Audio / Video", "Media", "youtube", "watch", "vimeo", "twitch", "mp4", "webm", "ogg", "video-player", "a11y", "media-player", ], handles: [ { type: "video", type_exclusive: true, source: "source", title: "caption", caption: "caption", description: "caption", color: "primaryColor", }, ], meta: { author: "HAXTheWeb core team", outlineDesigner: true, anchorLabel: "mediaTitle", }, }, settings: { configure: [ { property: "source", title: "Source", description: "The URL for this media.", inputMethod: "haxupload", noCamera: true, noVoiceRecord: true, validationType: "url", }, { property: "mediaTitle", title: "Title", description: "Simple title for under video", inputMethod: "textfield", validationType: "text", }, { property: "thumbnailSrc", title: "Thumbnail image", description: "The URL for a thumbnail/poster image.", inputMethod: "haxupload", noVoiceRecord: true, validationType: "url", }, ], advanced: [ { property: "track", title: "Closed captions", description: "The URL for the captions file.", inputMethod: "haxupload", noCamera: true, noVoiceRecord: true, validationType: "url", }, { property: "startTime", title: "Start time", description: "Start video at a specific time (seconds)", inputMethod: "number", }, { property: "endTime", title: "End time", description: "End video at a specific time (seconds), requires a start time.", inputMethod: "number", }, { property: "learningMode", title: "Enable learning mode", description: "Disables fast forward and rewind.", inputMethod: "boolean", }, { property: "hideYoutubeLink", title: "Remove open on YouTube button", description: "Removes the button for opening the video on YouTube.", inputMethod: "boolean", }, { property: "linkable", title: "Include a share link?", description: "Provides a link to share the video.", inputMethod: "boolean", }, ], developer: [ { property: "crossorigin", title: "Crossorigin", description: "Indicates whether to use CORS.", inputMethod: "select", options: { "": "", anonymous: "anonymous", "use-credentials": "use-credentials", }, }, { property: "allowBackgroundPlay", title: "Allow background playback", description: "Videos pause / play automatically when tab loses focus; this enables video to play without tab having focus", inputMethod: "boolean", }, { property: "darkTranscript", title: "Dark theme for transcript", description: "Enable dark theme for the transcript.", inputMethod: "boolean", }, { property: "disableInteractive", title: "Disable Interactive", description: "Disable interactive mode that makes transcript clickable.", inputMethod: "boolean", }, { property: "hideTimestamps", title: "Hide timestamps", description: "Hide the time stamps on the transcript.", inputMethod: "boolean", }, { property: "hideTranscript", title: "Hide Transcript", description: "Hide transcript by default.", inputMethod: "boolean", }, { property: "lang", title: "Language", description: "Language of the media.", inputMethod: "textfield", validationType: "text", }, { property: "accentColor", title: "Accent color", description: "(deprecated) Select the accent color for the player.", inputMethod: "colorpicker", }, ], }, saveOptions: { unsetAttributes: [ "__utils", "__stand-alone", "colors", "playing", "__forcePaused", "t", ], }, demoSchema: [ { tag: "video-player", properties: { source: "https://www.youtube.com/watch?v=LrS7dqokTLE", "data-width": "75", "data-margin": "center", }, content: "", }, ], }; } // properties available to the custom element for data binding static get properties() { return { ...super.properties, sourceType: { type: String }, /** * Optional accent color for controls, * using these colors: * `red`, `pink`, `purple`, `deep-purple`, `indigo`, `blue`, * `light-blue`, `cyan`, `teal`, `green`, `light-green`, `lime`, * `yellow`, `amber`, orange, deep-orange, and brown. * Default is null. */ accentColor: { type: String, attribute: "accent-color", reflect: true, }, /** * Cross origin flag for transcripts to load */ crossorigin: { type: String, attribute: "crossorigin", reflect: true, }, /** * Enables darker player. */ dark: { type: Boolean, attribute: "dark", reflect: true, }, /** * Use dark theme on transcript? Default is false, even when player is dark. */ darkTranscript: { type: Boolean, }, /** * disable interactive mode that makes transcript clickable */ disableInteractive: { type: Boolean, }, /** * Height of media player. */ height: { type: String, }, /** * show cue's start and end time */ hideTimestamps: { type: Boolean, attribute: "hide-timestamps", }, /** * Hide transcript by default */ hideTranscript: { type: Boolean, reflect: true, attribute: "hide-transcript", }, /** * Unique id */ id: { type: String, attribute: "id", reflect: true, }, /** * Learning mode */ learningMode: { type: Boolean, attribute: "learning-mode", }, /** * Language of media */ lang: { type: String, }, /** * Include a share link? */ linkable: { type: Boolean, }, /** * Simple caption for video */ mediaTitle: { type: String, attribute: "media-title", reflect: true, }, /** * Open on YouTube button */ hideYoutubeLink: { type: Boolean, attribute: "hide-youtube-link", }, /** * Single sources of video */ source: { type: String, reflect: true, }, /** * Array of multiple video sources */ sources: { type: Array, }, sourceData: { type: Object, }, /** * When playing but scrolled off screen, to which corner does it "stick": * `top-left`, `top-right`, `bottom-left`, `bottom-right`, or `none`? * Default is `top-right`. `None` disables stickiness. */ stickyCorner: { type: String, attribute: "sticky-corner", reflect: true, }, /** * Url for a single subtitle track */ track: { type: String, }, /** * Array of text tracks, eg. `[{ "src": "path/to/track.vtt", "label": "English", "srclang": "en", "kind": "subtitles", }]` */ tracks: { type: Array, }, /** * Source of optional thumbnail image */ thumbnailSrc: { type: String, attribute: "thumbnail-src", reflect: true, }, /** * Width of media player for non-a11y-media. */ width: { type: String, }, /** * Data reactivity for play status from a11y-media-player */ playing: { type: Boolean, reflect: true, }, /** * Option to allow playing while not active tab */ allowBackgroundPlay: { type: Boolean, reflect: true, attribute: "allow-background-play", }, /** * Start time for video */ startTime: { type: Number, attribute: "start-time", }, /** * End time for video */ endTime: { type: Number, attribute: "end-time", }, }; } /** * Store tag name to make it easier to obtain directly. * @notice function name must be here for tooling to operate correctly */ static get tag() { return "video-player"; } // weird looking but allows for SSR support querySelectorAll(query) { if (super.query) { super.querySelectorAll(query); } return []; } constructor() { super(); this.windowControllers = new AbortController(); this.sourceType = ""; this.crossorigin = "anonymous"; this.dark = false; this.darkTranscript = false; this.disableInteractive = false; this.hideTimestamps = false; this.hideTranscript = false; this.hideYoutubeLink = false; this.lang = "en"; this.playing = false; this.__setVisChange = false; this.allowBackgroundPlay = false; this.learningMode = false; this.linkable = false; this.sources = []; this.startTime = null; this.endTime = null; this.stickyCorner = "none"; this.tracks = []; this.source = ""; this.observer.observe(this, { childList: true, subtree: false, }); this.t = this.t || {}; this.t = { ...this.t, embeddedMedia: "embedded media", }; this.registerLocalization({ context: this, localesPath: new URL("./locales/video-player.es.json", import.meta.url).href + "/../", locales: ["es"], }); } /** * life cycle, element is removed from the DOM */ disconnectedCallback() { if (this.__setVisChange) { this.__setVisChange = false; this.windowControllers.abort(); } if (this.observer && this.observer.disconnect) this.observer.disconnect(); super.disconnectedCallback(); } /** * gets the HTML5 `audio` or `video` children * @readonly * @returns {object} HTML template */ get html5() { return html` ${this.sourceData .filter((item) => item.type !== "youtube") .map((sd) => { html` <source .src="${sd.src || undefined}" .type="${sd.type || undefined}" /> `; })} ${this.trackData.map((track) => { `<track .src="${track.src || undefined}" .kind="${track.kind || undefined}" .label="${track.label || undefined}" .srclang="${track.lang || undefined}" />`; })} `; } /** * Computes whether uses iframe * @readonly * @returns {Boolean} */ get iframed() { // make sure we take into account sandboxing as well // so that we can manage state effectively if ( this.sourceData && this.sourceData.length > 0 && this.sourceData[0] !== undefined && globalThis.MediaBehaviors.Video._sourceIsIframe(this.sourceData[0].src) && !this.sandboxed ) { return true; } return false; } /** * Determines if compatible with `a11y-media-player` * @readonly * @returns {Boolean} */ get isA11yMedia() { if ( !this.sandboxed && (this.sourceType == "youtube" || this.sourceType == "local" || this.sourceData.length < 1) ) { return true; } return false; } /** * mutation observer for tabs * @readonly * @returns {object} */ get observer() { let callback = () => this.setSourceData(); return new MutationObserver(callback); } /** * Compute sandboxed status * @readonly * @returns {Boolean} */ get sandboxed() { // we have something that would require an iframe // see if we have a local system that would want to sandbox instead if ( this.sourceData && this.sourceData.length > 0 && typeof this.sourceData[0] !== undefined && globalThis.MediaBehaviors.Video._sourceIsIframe(this.sourceData[0].src) ) { // fake creation of a webview element to see if it's valid // or not. let test = globalThis.document.createElement("webview"); // if this function exists it means that our deploy target // is in a sandboxed environment and is not able to run iframe // content with any real stability. This is beyond edge case but // as this is an incredibly useful tag we want to make sure it // can mutate to work in chromium and android environments // which support such sandboxing if (typeof test.reload === "function") { return true; } } return false; } /** * Gets cleaned source list from source and sources properties * @readonly * @returns {Array} Eg. `[{ "src": "path/to/media.mp3", "type": "audio/mp3"}]` */ get sourceProperties() { let temp = typeof this.sources === "string" ? JSON.parse(this.sources) : (this.sources || []).slice(); if (this.source) temp.unshift({ src: this.source }); if (temp && temp.length > 0) temp.forEach((item) => { item.type = item.type || this._computeMediaType(item.src); item.src = this._computeSRC(item.src, item.type); }); return temp; } /** * Gets cleaned track list from track and tracks properties * @readonly * @returns {Array} Eg. `[{ "src": "path/to/track.vtt", "label": "English", "srclang": "en", "kind": "subtitles"}]` */ get trackProperties() { let temp = typeof this.tracks === "string" ? JSON.parse(this.tracks) : (this.tracks || []).slice(); if (this.track) temp.unshift({ src: this.track }); if (temp && temp.length > 0) temp.forEach((item) => { item.srclang = item.srclang || this.lang; item.kind = item.kind || "subtitles"; item.label = item.label || item.kind || item.lang; }); return temp; } /** * Source properties and slotted sources * @readonly * @returns {Array} List of source objects */ get sourceData() { let temp = (this.sourceProperties || []).slice(), slotted = this.querySelectorAll("video source, audio source, iframe"); slotted.forEach((slot) => { if (this.sources.filter((source) => source.src === slot.src).length < 1) this.sources.unshift({ src: slot.src, type: slot.type || this._computeMediaType(slot.src), }); }); return temp; } get audioOnly() { let videos = this.sourceData.filter( (item) => item.type.indexOf("audio") > -1, ); return videos.length > 1; } get standAlone() { return ( this.trackData === undefined || this.trackData === null || this.trackData.length < 1 ); } /** * Gets cleaned track list * @readonly * @returns {Array} Eg. `[{ "src": "path/to/track.vtt", "label": "English", "srclang": "en", "kind": "subtitles",}]` */ get trackData() { let temp = typeof this.tracks === "string" ? (JSON.parse(this.tracks) || []).slice() : (this.tracks || []).slice(), slotted = this.querySelectorAll("video track, audio track"); slotted.forEach((slot) => { if (this.tracks.filter((track) => track.src === slot.src).length < 1) { let track = { src: slot.src }; if (slot.lang) track.lang = slot.lang; if (slot.srclang) track.srclang = slot.srclang; if (slot.label) track.label = slot.label; if (slot.kind) track.kind = slot.kind; this.tracks.unshift(track); } slot.remove(); }); if (this.track !== undefined && this.track !== null && this.track !== "") temp.push({ src: this.track, srclang: this.lang, label: this.lang === "en" ? "English" : this.lang, kind: "subtitles", }); return temp; } /** * Gets Youtube ID from source string * @readonly * @returns {String} */ get youtubeId() { if ( this.sourceData && this.sourceData[0] && this.sourceType === "youtube" ) { return this._computeSRC(this.sourceData[0].src).replace( /.*\/embed\//, "", ); } return; } /** * gets an id for a11y-media-player * @readonly * @returns {string} an id for player */ get playerId() { return `${this.id || this.schemaResourceID}-media`; } /** * Compute media type based on source, i.e. 'audio/wav' for '.wav' */ _computeMediaType(source) { let audio = ["aac", "flac", "mp3", "oga", "wav"], video = ["mov", "mp4", "ogv", "webm"], type = "", findType = (text, data) => { data.forEach((item) => { if ( type === "" && typeof source !== undefined && source !== null && source.toLowerCase().indexOf("." + item) > -1 ) { type = text + "/" + item; } }); }; findType("audio", audio); findType("video", video); return type; } /** * Compute src from type / source combo. * Type is set by source so this ensures a waterfall * of valid values. */ _computeSRC(source, type) { if (source !== null && typeof source !== undefined) { // ensure that this is a valid url / cleaned up a bit type = type || globalThis.MediaBehaviors.Video.getVideoType(source); source = globalThis.MediaBehaviors.Video.cleanVideoSource(source, type); if (type == "vimeo") { if (this.vimeoTitle) { source += "?title=1"; } else { source += "?title=0"; } if (this.vimeoByline) { source += "&byline=1"; } else { source += "&byline=0"; } if (this.vimeoPortrait) { source += "&portrait=1"; } else { source += "&portrait=0"; } } else if (type == "twitch") { // required for origin matching when doing iframe embed if (source.indexOf("?") > -1) { source += "&parent=" + globalThis.location.hostname; } else { source += "?parent=" + globalThis.location.hostname; } } else if (type == "youtube" && !this.startTime) { let timestamp = this.source.split("t="); if (timestamp.length > 1) { this.startTime = timestamp[1]; } } } return source; } /** * Implements haxHooks to tie into life-cycle if hax exists. */ haxHooks() { return { postProcessNodeToContent: "haxpostProcessNodeToContent", inlineContextMenu: "haxinlineContextMenu", }; } /** * add buttons when it is in context */ haxinlineContextMenu(ceMenu) { ceMenu.ceButtons = [ { icon: "hax:anchor", callback: "haxClickTimeCode", label: "Copy current timecode", }, ]; } haxClickTimeCode(e) { this.pause(); copyToClipboard(parseInt(this.currentTime)); return true; } /** * postProcesshaxNodeToContent - clean up so we don't have empty array data */ haxpostProcessNodeToContent(content) { content = content.replace(' sources="[]",', ""); content = content.replace(' tracks="[]",', ""); return content; } /** * triggers an update of sourceData property when slot changes * * @memberof VideoPlayer */ setSourceData() { let temp = this.source; this.source = ""; this.source = temp; // set source type based on available data if ( this.sourceData && this.sourceData.length > 0 && this.sourceData[0] !== undefined && typeof this.sourceData[0].src !== typeof undefined ) { this.sourceType = globalThis.MediaBehaviors.Video.getVideoType( this.sourceData[0].src, ); } } playEvent(e) { this.playing = e.detail.__playing; // time stamp found if (this.playing && this.startTime) { if (!this.__hasPlayed) { this.seek(this.startTime); this.endTimeTest(); this.__hasPlayed = true; } } } pauseEvent(e) { this.playing = e.detail.__playing; } updated(changedProperties) { super.updated(changedProperties); changedProperties.forEach((oldValue, propName) => { // hack to account for poor state management prior to and then source type switches on the fly if ( propName === "source" && this.sourceType && typeof oldValue !== typeof undefined && this.sourceData && this.sourceData[0] ) { let type = globalThis.MediaBehaviors.Video.getVideoType( this.sourceData[0].src, ); if (type != this.sourceType) { this.sourceType = type; if (this.elementVisible) { this.elementVisible = false; setTimeout(() => { this.elementVisible = true; }, 0); } } } }); } /** * LitElement lifecycle */ firstUpdated(changedProperties) { if (super.firstUpdated) { super.firstUpdated(changedProperties); } changedProperties.forEach((oldValue, propName) => { if ( propName === "allowBackgroundPlay" && this[propName] && this.__setVisChange ) { this.__setVisChange = false; this.windowControllers.abort(); } else if ( propName === "allowBackgroundPlay" && !this[propName] && !this.__setVisChange ) { this.__setVisChange = true; this.windowControllers = new AbortController(); globalThis.document.addEventListener( "visibilitychange", this._visChange.bind(this), { signal: this.windowControllers.signal }, ); } }); // set source type based on available data if ( this.sourceData && this.sourceData.length > 0 && this.sourceData[0] !== undefined && typeof this.sourceData[0].src !== typeof undefined ) { this.sourceType = globalThis.MediaBehaviors.Video.getVideoType( this.sourceData[0].src, ); } } /** * mapping down into the shadowRoot element bc these are common things to want to know */ get currentTime() { if (this.shadowRoot) { return this.shadowRoot.querySelector("a11y-media-player").currentTime; } return 0; } restart() { this.pause(); if (this.startTime) { this.seek(this.startTime); this.endTimeTest(); } else { this.seek(0); } this.play(); } restartEvent() { if (this.startTime) { this.seek(this.startTime); this.endTimeTest(); } } pause() { if ( this.shadowRoot && this.shadowRoot.querySelector("a11y-media-player").__playing ) { this.shadowRoot.querySelector("a11y-media-player").pause(); } } play() { if ( this.shadowRoot && !this.shadowRoot.querySelector("a11y-media-player").__playing ) { this.shadowRoot.querySelector("a11y-media-player").play(); } } seek(time) { if (this.shadowRoot) { if (!this.shadowRoot.querySelector("a11y-media-player").__playing) { this.play(); } setTimeout(() => { this.shadowRoot.querySelector("a11y-media-player").seek(parseInt(time)); }, 0); } } // end timer needs to be handled in a special way so we don't flood listeners // or run forever endTimeTest() { if (this.endTime && !this.__endTimerTest) { // we are playing and have an endtime so we need to listen // to see when we should pause the video // this helps ensure we are not even listener flooding // or getting stuck in really bad loops that kill performance // for this one feature this.__endTimerTest = true; setTimeout(() => { this.__endTimeInterval = setInterval(() => { if (parseInt(this.currentTime) >= parseInt(this.endTime)) { this.pause(); this.__endTimerTest = false; clearInterval(this.__endTimeInterval); } // if we stop playing we need to clear regardless if (!this.playing) { this.__endTimerTest = false; clearInterval(this.__endTimeInterval); } }, 1000); }, 1000); } } _visChange(e) { setTimeout(() => { if ( globalThis.document.visibilityState === "visible" && !this.playing && this.__forcePaused ) { this.__forcePaused = false; // resume the video bc it has focus and we stopped it playing previously this.shadowRoot.querySelector("a11y-media-player").togglePlay(); } else if (document.visibilityState === "hidden" && this.playing) { // force pause the video; we're in learning mode and they swtiched tabs this.__forcePaused = true; this.shadowRoot.querySelector("a11y-media-player").togglePlay(); } else { this.__forcePaused = false; } }, 500); } } globalThis.customElements.define(VideoPlayer.tag, VideoPlayer); export { VideoPlayer };