276 lines
9.4 KiB
JavaScript
276 lines
9.4 KiB
JavaScript
|
|
/**
|
||
|
|
* A helper class to provide common functionality for working with HTML5 video objects
|
||
|
|
* A singleton instance of this class is available as ``game.video``
|
||
|
|
*/
|
||
|
|
class VideoHelper {
|
||
|
|
constructor() {
|
||
|
|
if ( game.video instanceof this.constructor ) {
|
||
|
|
throw new Error("You may not re-initialize the singleton VideoHelper. Use game.video instead.");
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A user gesture must be registered before video playback can begin.
|
||
|
|
* This Set records the video elements which await such a gesture.
|
||
|
|
* @type {Set}
|
||
|
|
*/
|
||
|
|
this.pending = new Set();
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A mapping of base64 video thumbnail images
|
||
|
|
* @type {Map<string,string>}
|
||
|
|
*/
|
||
|
|
this.thumbs = new Map();
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A flag for whether video playback is currently locked by awaiting a user gesture
|
||
|
|
* @type {boolean}
|
||
|
|
*/
|
||
|
|
this.locked = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Store a Promise while the YouTube API is initializing.
|
||
|
|
* @type {Promise}
|
||
|
|
*/
|
||
|
|
#youTubeReady;
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The YouTube URL regex.
|
||
|
|
* @type {RegExp}
|
||
|
|
*/
|
||
|
|
#youTubeRegex = /^https:\/\/(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=([^&]+)|(?:embed\/)?([^?]+))/;
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
/* Methods */
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Return the HTML element which provides the source for a loaded texture.
|
||
|
|
* @param {PIXI.Sprite|SpriteMesh} mesh The rendered mesh
|
||
|
|
* @returns {HTMLImageElement|HTMLVideoElement|null} The source HTML element
|
||
|
|
*/
|
||
|
|
getSourceElement(mesh) {
|
||
|
|
if ( !mesh.texture.valid ) return null;
|
||
|
|
return mesh.texture.baseTexture.resource.source;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the video element source corresponding to a Sprite or SpriteMesh.
|
||
|
|
* @param {PIXI.Sprite|SpriteMesh|PIXI.Texture} object The PIXI source
|
||
|
|
* @returns {HTMLVideoElement|null} The source video element or null
|
||
|
|
*/
|
||
|
|
getVideoSource(object) {
|
||
|
|
if ( !object ) return null;
|
||
|
|
const texture = object.texture || object;
|
||
|
|
if ( !texture.valid ) return null;
|
||
|
|
const source = texture.baseTexture.resource.source;
|
||
|
|
return source?.tagName === "VIDEO" ? source : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Clone a video texture so that it can be played independently of the original base texture.
|
||
|
|
* @param {HTMLVideoElement} source The video element source
|
||
|
|
* @returns {Promise<PIXI.Texture>} An unlinked PIXI.Texture which can be played independently
|
||
|
|
*/
|
||
|
|
async cloneTexture(source) {
|
||
|
|
const clone = source.cloneNode(true);
|
||
|
|
const resource = new PIXI.VideoResource(clone, {autoPlay: false});
|
||
|
|
resource.internal = true;
|
||
|
|
await resource.load();
|
||
|
|
return new PIXI.Texture(new PIXI.BaseTexture(resource, {
|
||
|
|
alphaMode: await PIXI.utils.detectVideoAlphaMode()
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if a source has a video extension.
|
||
|
|
* @param {string} src The source.
|
||
|
|
* @returns {boolean} If the source has a video extension or not.
|
||
|
|
*/
|
||
|
|
static hasVideoExtension(src) {
|
||
|
|
let rgx = new RegExp(`(\\.${Object.keys(CONST.VIDEO_FILE_EXTENSIONS).join("|\\.")})(\\?.*)?`, "i");
|
||
|
|
return rgx.test(src);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Play a single video source
|
||
|
|
* If playback is not yet enabled, add the video to the pending queue
|
||
|
|
* @param {HTMLElement} video The VIDEO element to play
|
||
|
|
* @param {object} [options={}] Additional options for modifying video playback
|
||
|
|
* @param {boolean} [options.playing] Should the video be playing? Otherwise, it will be paused
|
||
|
|
* @param {boolean} [options.loop] Should the video loop?
|
||
|
|
* @param {number} [options.offset] A specific timestamp between 0 and the video duration to begin playback
|
||
|
|
* @param {number} [options.volume] Desired volume level of the video's audio channel (if any)
|
||
|
|
*/
|
||
|
|
async play(video, {playing=true, loop=true, offset, volume}={}) {
|
||
|
|
|
||
|
|
// Video offset time and looping
|
||
|
|
video.loop = loop;
|
||
|
|
offset ??= video.currentTime;
|
||
|
|
|
||
|
|
// Playback volume and muted state
|
||
|
|
if ( volume !== undefined ) video.volume = volume;
|
||
|
|
|
||
|
|
// Pause playback
|
||
|
|
if ( !playing ) return video.pause();
|
||
|
|
|
||
|
|
// Wait for user gesture
|
||
|
|
if ( this.locked ) return this.pending.add([video, offset]);
|
||
|
|
|
||
|
|
// Begin playback
|
||
|
|
video.currentTime = Math.clamp(offset, 0, video.duration);
|
||
|
|
return video.play();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Stop a single video source
|
||
|
|
* @param {HTMLElement} video The VIDEO element to stop
|
||
|
|
*/
|
||
|
|
stop(video) {
|
||
|
|
video.pause();
|
||
|
|
video.currentTime = 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Register an event listener to await the first mousemove gesture and begin playback once observed
|
||
|
|
* A user interaction must involve a mouse click or keypress.
|
||
|
|
* Listen for any of these events, and handle the first observed gesture.
|
||
|
|
*/
|
||
|
|
awaitFirstGesture() {
|
||
|
|
if ( !this.locked ) return;
|
||
|
|
const interactions = ["contextmenu", "auxclick", "pointerdown", "pointerup", "keydown"];
|
||
|
|
interactions.forEach(event => document.addEventListener(event, this._onFirstGesture.bind(this), {once: true}));
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle the first observed user gesture
|
||
|
|
* We need a slight delay because unfortunately Chrome is stupid and doesn't always acknowledge the gesture fast enough.
|
||
|
|
* @param {Event} event The mouse-move event which enables playback
|
||
|
|
*/
|
||
|
|
_onFirstGesture(event) {
|
||
|
|
this.locked = false;
|
||
|
|
if ( !this.pending.size ) return;
|
||
|
|
console.log(`${vtt} | Activating pending video playback with user gesture.`);
|
||
|
|
for ( const [video, offset] of Array.from(this.pending) ) {
|
||
|
|
this.play(video, {offset, loop: video.loop});
|
||
|
|
}
|
||
|
|
this.pending.clear();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create and cache a static thumbnail to use for the video.
|
||
|
|
* The thumbnail is cached using the video file path or URL.
|
||
|
|
* @param {string} src The source video URL
|
||
|
|
* @param {object} options Thumbnail creation options, including width and height
|
||
|
|
* @returns {Promise<string>} The created and cached base64 thumbnail image, or a placeholder image if the canvas is
|
||
|
|
* disabled and no thumbnail can be generated.
|
||
|
|
*/
|
||
|
|
async createThumbnail(src, options) {
|
||
|
|
if ( game.settings.get("core", "noCanvas") ) return "icons/svg/video.svg";
|
||
|
|
const t = await ImageHelper.createThumbnail(src, options);
|
||
|
|
this.thumbs.set(src, t.thumb);
|
||
|
|
return t.thumb;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
/* YouTube API */
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Lazily-load the YouTube API and retrieve a Player instance for a given iframe.
|
||
|
|
* @param {string} id The iframe ID.
|
||
|
|
* @param {object} config A player config object. See {@link https://developers.google.com/youtube/iframe_api_reference} for reference.
|
||
|
|
* @returns {Promise<YT.Player>}
|
||
|
|
*/
|
||
|
|
async getYouTubePlayer(id, config={}) {
|
||
|
|
this.#youTubeReady ??= this.#injectYouTubeAPI();
|
||
|
|
await this.#youTubeReady;
|
||
|
|
return new Promise(resolve => new YT.Player(id, foundry.utils.mergeObject(config, {
|
||
|
|
events: {
|
||
|
|
onReady: event => resolve(event.target)
|
||
|
|
}
|
||
|
|
})));
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Retrieve a YouTube video ID from a URL.
|
||
|
|
* @param {string} url The URL.
|
||
|
|
* @returns {string}
|
||
|
|
*/
|
||
|
|
getYouTubeId(url) {
|
||
|
|
const [, id1, id2] = url?.match(this.#youTubeRegex) || [];
|
||
|
|
return id1 || id2 || "";
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Take a URL to a YouTube video and convert it into a URL suitable for embedding in a YouTube iframe.
|
||
|
|
* @param {string} url The URL to convert.
|
||
|
|
* @param {object} vars YouTube player parameters.
|
||
|
|
* @returns {string} The YouTube embed URL.
|
||
|
|
*/
|
||
|
|
getYouTubeEmbedURL(url, vars={}) {
|
||
|
|
const videoId = this.getYouTubeId(url);
|
||
|
|
if ( !videoId ) return "";
|
||
|
|
const embed = new URL(`https://www.youtube.com/embed/${videoId}`);
|
||
|
|
embed.searchParams.append("enablejsapi", "1");
|
||
|
|
Object.entries(vars).forEach(([k, v]) => embed.searchParams.append(k, v));
|
||
|
|
// To loop a video with iframe parameters, we must additionally supply the playlist parameter that points to the
|
||
|
|
// same video: https://developers.google.com/youtube/player_parameters#Parameters
|
||
|
|
if ( vars.loop ) embed.searchParams.append("playlist", videoId);
|
||
|
|
return embed.href;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Test a URL to see if it points to a YouTube video.
|
||
|
|
* @param {string} url The URL to test.
|
||
|
|
* @returns {boolean}
|
||
|
|
*/
|
||
|
|
isYouTubeURL(url="") {
|
||
|
|
return this.#youTubeRegex.test(url);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Inject the YouTube API into the page.
|
||
|
|
* @returns {Promise} A Promise that resolves when the API has initialized.
|
||
|
|
*/
|
||
|
|
#injectYouTubeAPI() {
|
||
|
|
const script = document.createElement("script");
|
||
|
|
script.src = "https://www.youtube.com/iframe_api";
|
||
|
|
document.head.appendChild(script);
|
||
|
|
return new Promise(resolve => {
|
||
|
|
window.onYouTubeIframeAPIReady = () => {
|
||
|
|
delete window.onYouTubeIframeAPIReady;
|
||
|
|
resolve();
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|