diff --git a/src/index.css b/src/index.css index 8194d84..9ed941f 100644 --- a/src/index.css +++ b/src/index.css @@ -248,3 +248,30 @@ a:hover { color: #000; } +.yt-iframe { + display: block; + position: absolute; + border: none; +} + +.yt-player-wrapper { + display: flex; + justify-content: center; + align-items: center; +} + +.yt-player-wrapper.hidden { + display: none; +} + +.yt-player-wrapper.inprogress:after { + content: "loading youtube..."; + display: flex; + position: absolute; + color: white; + background: black; + padding: 10px; + justify-content: center; + align-items: center; +} + diff --git a/src/visual/MovieStim.js b/src/visual/MovieStim.js index 71c9357..b29bddc 100644 --- a/src/visual/MovieStim.js +++ b/src/visual/MovieStim.js @@ -15,7 +15,7 @@ import { to_pixiPoint } from "../util/Pixi.js"; import * as util from "../util/Util.js"; import { VisualStim } from "./VisualStim.js"; import {Camera} from "../hardware/Camera.js"; - +import YoutubeIframeAPIHandler from "./YoutubeIframeAPI.js"; /** * Movie Stimulus. @@ -32,6 +32,9 @@ export class MovieStim extends VisualStim * @param {module:core.Window} options.win - the associated Window * @param {string | HTMLVideoElement | module:visual.Camera} movie - the name of a * movie resource or of a HTMLVideoElement or of a Camera component + * @param {string} [options.youtubeUrl] - link to a youtube video. If this parameter is present, movie stim will embed a youtube video to an experiment. + * @param {boolean} [options.showYoutubeControls] - whether or not to show youtube player controls. + * @oaram {boolean} [options.disableYoutubePlayerKeyboardControls=false] - Setting the parameter's value to true causes the youtube player to not respond to keyboard controls. * @param {string} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices) * @param {Array.} [options.pos= [0, 0]] - the position of the center of the stimulus * @param {string} [options.anchor = "center"] - sets the origin point of the stim @@ -52,17 +55,60 @@ export class MovieStim extends VisualStim * @param {boolean} [options.autoLog= false] - whether or not to log * @param {boolean} [options.draggable= false] - whether or not to make stim draggable with mouse/touch/other pointer device */ - constructor({ name, win, movie, pos, anchor, units, ori, size, color, opacity, contrast, interpolate, flipHoriz, flipVert, loop, volume, noAudio, autoPlay, autoDraw, autoLog, draggable } = {}) + constructor({ + name, + win, + movie, + youtubeUrl, + showYoutubeControls, + disableYoutubePlayerKeyboardControls, + pos, + anchor, + units, + ori, + size, + color, + opacity, + contrast, + interpolate, + flipHoriz, + flipVert, + loop, + volume, + noAudio, + autoPlay, + autoDraw, + autoLog, + draggable + } = {}) { super({ name, win, units, ori, opacity, pos, anchor, size, autoDraw, autoLog, draggable }); this.psychoJS.logger.debug("create a new MovieStim with name: ", name); - // movie and movie control: + // Used in case when youtubeUrl parameter is set to a proper youtube url. + this._youtubePlayer = undefined; + this._ytPlayerIsReady = false; + this._addAttribute( "movie", movie, ); + this._addAttribute( + "youtubeUrl", + youtubeUrl, + "" + ); + this._addAttribute( + "showYoutubeControls", + showYoutubeControls, + true + ); + this._addAttribute( + "disableYoutubePlayerKeyboardControls", + disableYoutubePlayerKeyboardControls, + false + ); this._addAttribute( "volume", volume, @@ -139,7 +185,7 @@ export class MovieStim extends VisualStim * * @param {string | HTMLVideoElement | module:visual.Camera} movie - the name of a * movie resource or of a HTMLVideoElement or of a Camera component - * @param {boolean} [log= false] - whether of not to log + * @param {boolean} [log= false] - whether or not to log */ setMovie(movie, log = false) { @@ -158,7 +204,6 @@ export class MovieStim extends VisualStim `setting the movie of MovieStim: ${this._name} with argument: undefined.`); this.psychoJS.logger.debug(`set the movie of MovieStim: ${this._name} as: undefined`); } - else { // if movie is a string, then it should be the name of a resource, which we get: @@ -200,6 +245,20 @@ export class MovieStim extends VisualStim this.status = PsychoJS.Status.FINISHED; }; } + + // Resize the stim when video is loaded. Otherwise this._texture.width is 1. + const loadedDataCb = () => + { + this.size = this._size; + movie.removeEventListener("loadeddata", loadedDataCb); + }; + + if (movie.readyState < movie.HAVE_FUTURE_DATA) + { + movie.addEventListener("loadeddata", loadedDataCb) + } + + this.hideYoutubePlayer(); } this._setAttribute("movie", movie, log); @@ -212,10 +271,268 @@ export class MovieStim extends VisualStim } } + /** + * Setter for the size attribute. + * + * @param {undefined | null | number | number[]} size - the stimulus size + * @param {boolean} [log= false] - whether or not to log + */ + setSize(size, log = false) + { + if (!Array.isArray(size)) + { + size = [size, size]; + } + + if (Array.isArray(size) && size.length <= 1) + { + size = [size[0], size[0]]; + } + + for (let i = 0; i < size.length; i++) + { + try + { + size[i] = util.toNumerical(size[i]); + } + catch (err) + { + // Failed to convert to numeric. Set to NaN. + size[ i ] = NaN; + } + } + + // If the html5Video is available and loaded enough, use information from it to convert NaN to proper values. + if (this._movie !== undefined && this._movie.readyState >= this._movie.HAVE_FUTURE_DATA) + { + size = this._ensureNaNSizeConversion(size, this._movie); + } + + if (this._texture !== undefined) + { + this._applySizeToPixi(size); + } + + if (this._youtubePlayer !== undefined && this._ytPlayerIsReady) + { + // Handling youtube iframe resize here, since _updateIfNeeded aint going to be triggered due to absence of _pixi component. + this._applySizeToYoutubeIframe(size); + + // Youtube player handles NaN size automatically. Leveraging that to cover unset size. + // IMPORTANT! this._youtubePlayer.getSize() is not used intentionally, because it returns initial values event after different size was set. + const ytPlayerBCR = this._youtubePlayer.getIframe().getBoundingClientRect(); + size = util.to_unit([ ytPlayerBCR.width, ytPlayerBCR.height ], "pix", this._win, this._units); + } + + this._setAttribute("size", size, log); + } + + /** + * Setter for the position attribute. + * + * @param {Array.} pos - position of the center of the stimulus, in stimulus units + * @param {boolean} [log= false] - whether or not to log + */ + setPos(pos, log = false) + { + super.setPos(pos, log); + // if (this._youtubePlayer !== undefined && this._ytPlayerIsReady) + if (this._youtubePlayer !== undefined) + { + const pos_px = util.to_px(pos, this._units, this._win, false); + pos_px[1] *= this._win._rootContainer.scale.y; + this._youtubePlayer.getIframe().style.transform = `translate3d(${pos_px[0]}px, ${pos_px[1]}px, 0)`; + } + } + + /** + * Setter for the volume attribute. + * + * @param {number} volume - desired volume of the movie in [0, 1]. + * @param {boolean} [log= false] - whether of not to log + */ + setVolume(vol, log = false) + { + this._setAttribute("volume", vol, log); + if (this._movie !== undefined) + { + this._movie.volume = vol; + } + else if (this._youtubePlayer !== undefined && this._ytPlayerIsReady) + { + // Original movie takes volume in [0, 1], whereas youtube's player [0, 100]. + this._youtubePlayer.setVolume(vol * 100); + } + } + + /** + * Draw this stimulus on the next frame draw. + */ + draw() + { + super.draw(); + if (this._youtubePlayer !== undefined && this._ytPlayerIsReady) + { + this.showYoutubePlayer(); + } + } + + /** + * Hide this stimulus on the next frame draw. + */ + hide() + { + super.hide(); + if (this._youtubePlayer !== undefined && this._ytPlayerIsReady) + { + this.hideYoutubePlayer(); + } + } + + /** + * Handling youtube player being ready to work. + * + * @param {string} link to a youtube video. If this parameter is present, movie stim will embed a youtube video to an experiment. + * @param {boolean} [log= false] - whether or not to log. + */ + _onYoutubePlayerReady (e) + { + this._ytPlayerIsReady = true; + + if (Number.isNaN(this._size[ 0 ]) || Number.isNaN(this._size[ 1 ])) + { + // Youtube player handles NaN size automatically. Leveraging that to cover unset size. + // IMPORTANT! this._youtubePlayer.getSize() is not used intentionally, because it returns initial values event after different size was set. + const ytPlayerBCR = this._youtubePlayer.getIframe().getBoundingClientRect(); + this._setAttribute("size", util.to_unit([ ytPlayerBCR.width, ytPlayerBCR.height ], "pix", this._win, this._units), true); + } + + this.setVolume(this._volume, true); + } + + /** + * Handling youtube player state change. + * + * @param {string} link to a youtube video. If this parameter is present, movie stim will embed a youtube video to an experiment. + * @param {boolean} [log= false] - whether or not to log. + */ + _onYoutubePlayerStateChange (e) + { + if (e.data === YT.PlayerState.PLAYING) + { + // Just in case for potential future requirements. + } + else if (e.data === YT.PlayerState.PAUSED) + { + // Just in case for potential future requirements. + } + else if (e.data === YT.PlayerState.ENDED) + { + // Just in case for potential future requirements. + } + else if (e.data === YT.PlayerState.ENDED) + { + // Just in case for potential future requirements. + } + } + + /** + * Handling youtube player errors. + * + * @param {string} link to a youtube video. If this parameter is present, movie stim will embed a youtube video to an experiment. + * @param {boolean} [log= false] - whether or not to log. + */ + _onYoutubePlayerError (err) + { + // Just in case for potential future requirements. + console.error("youtube player error:", arguments); + } + + hideYoutubePlayer () + { + if (this._youtubePlayer !== undefined && this._ytPlayerIsReady) + { + this._youtubePlayer.stopVideo(); + this._youtubePlayer.getIframe().parentElement.classList.add("hidden"); + } + } + + showYoutubePlayer () + { + if (this._youtubePlayer !== undefined && this._ytPlayerIsReady) + { + this._youtubePlayer.getIframe().parentElement.classList.remove("hidden"); + } + } + + /** + * Setter for the youtubeUrl attribute. + * + * @param {string} link to a youtube video. If this parameter is present, movie stim will embed a youtube video to an experiment. + * @param {boolean} [log= false] - whether or not to log. + */ + async setYoutubeUrl (urlString = "", log = false) + { + if (urlString.length === 0) + { + this.hideYoutubePlayer(); + return; + } + + // Handling the case when there's already regular movie is set. + if (this._movie !== undefined) + { + this.stop(); + this.setMovie(undefined); + + // Removing stimuli from the drawing list. + this.hide(); + } + + const urlObj = new URL(urlString); + if (this._youtubePlayer === undefined) + { + const vidSizePx = util.to_unit(this._size, this.units, this.win, "pix"); + + await YoutubeIframeAPIHandler.init(); + + this._youtubePlayer = YoutubeIframeAPIHandler.createPlayer({ + videoId: urlObj.searchParams.get("v"), + width: vidSizePx[0], + height: vidSizePx[ 1 ], + playerVars: { + "rel": 0, + "playsinline": 1, + "modestbranding": 1, + "disablekb": Number(this._disableYoutubePlayerKeyboardControls) || 0, + "autoplay": Number(this._autoPlay) || 0, + "controls": Number(this._showYoutubeControls) || 0, + "loop": Number(this._loop) || 0, + }, + events: { + "onReady": this._onYoutubePlayerReady.bind(this), + "onStateChange": this._onYoutubePlayerStateChange.bind(this), + "onError": this._onYoutubePlayerError.bind(this), + // "onPlaybackQualityChange": + // "onPlaybackRateChange": + // "onApiChange": + } + }); + + // At this point youtube player is added to the page. Invoking position setter to ensure html element is placed as expected. + this.pos = this._pos; + } + else + { + this._youtubePlayer.loadVideoById(urlObj.searchParams.get("v")); + this.showYoutubePlayer(); + } + } + /** * Reset the stimulus. * - * @param {boolean} [log= false] - whether of not to log + * @param {boolean} [log= false] - whether or not to log */ reset(log = false) { @@ -227,49 +544,70 @@ export class MovieStim extends VisualStim /** * Start playing the movie. * - * @param {boolean} [log= false] - whether of not to log + * @param {boolean} [log= false] - whether or not to log */ play(log = false) { this.status = PsychoJS.Status.STARTED; - // As found on https://goo.gl/LdLk22 - const playPromise = this._movie.play(); - - if (playPromise !== undefined) + if (this._movie !== undefined) { - playPromise.catch((error) => + // As found on https://goo.gl/LdLk22 + const playPromise = this._movie.play(); + + if (playPromise !== undefined) { - throw { - origin: "MovieStim.play", - context: `when attempting to play MovieStim: ${this._name}`, - error, - }; - }); + playPromise.catch((error) => + { + throw { + origin: "MovieStim.play", + context: `when attempting to play MovieStim: ${this._name}`, + error, + }; + }); + } + } + else if (this._youtubePlayer !== undefined && this._ytPlayerIsReady) + { + this._youtubePlayer.playVideo(); } } /** * Pause the movie. * - * @param {boolean} [log= false] - whether of not to log + * @param {boolean} [log= false] - whether or not to log */ pause(log = false) { this.status = PsychoJS.Status.STOPPED; - this._movie.pause(); + if (this._movie !== undefined) + { + this._movie.pause(); + } + else if (this._youtubePlayer !== undefined && this._ytPlayerIsReady) + { + this._youtubePlayer.pauseVideo(); + } } /** * Stop the movie and reset to 0s. * - * @param {boolean} [log= false] - whether of not to log + * @param {boolean} [log= false] - whether or not to log */ stop(log = false) { this.status = PsychoJS.Status.STOPPED; - this._movie.pause(); - this.seek(0, log); + if (this._movie !== undefined) + { + this._movie.pause(); + this.seek(0, log); + } + else if (this._youtubePlayer !== undefined && this._ytPlayerIsReady) + { + this._youtubePlayer.stopVideo(); + } } /** @@ -278,45 +616,116 @@ export class MovieStim extends VisualStim *

Note: seek is experimental and does not work on all browsers at the moment.

* * @param {number} timePoint - the timepoint to which to jump (in second) - * @param {boolean} [log= false] - whether of not to log + * @param {boolean} [log= false] - whether or not to log */ seek(timePoint, log = false) { - if (timePoint < 0 || timePoint > this._movie.duration) + if (this._movie !== undefined) { - throw { - origin: "MovieStim.seek", - context: `when seeking to timepoint: ${timePoint} of MovieStim: ${this._name}`, - error: `the timepoint does not belong to [0, ${this._movie.duration}`, - }; - } - - if (this._hasFastSeek) - { - this._movie.fastSeek(timePoint); - } - else - { - try - { - this._movie.currentTime = timePoint; - } - catch (error) + if (timePoint < 0 || timePoint > this._movie.duration) { throw { origin: "MovieStim.seek", context: `when seeking to timepoint: ${timePoint} of MovieStim: ${this._name}`, - error, + error: `the timepoint does not belong to [0, ${this._movie.duration}`, }; } + + if (this._hasFastSeek) + { + this._movie.fastSeek(timePoint); + } + else + { + try + { + this._movie.currentTime = timePoint; + } + catch (error) + { + throw { + origin: "MovieStim.seek", + context: `when seeking to timepoint: ${timePoint} of MovieStim: ${this._name}`, + error, + }; + } + } + } + else if (this._youtubePlayer !== undefined && this._ytPlayerIsReady) + { + this._youtubePlayer.seekTo(timePoint); } } /** - * Estimate the bounding box. + * Get the elapsed time in seconds since the video started playing. * - * @override - * @protected + * @return {number} playback time. + */ + getPlaybackTime () + { + if (this._movie !== undefined) + { + return this._movie.currentTime; + } + else if (this._youtubePlayer !== undefined && this._ytPlayerIsReady) + { + return this._youtubePlayer.getCurrentTime(); + } + + return 0; + } + + /** + * Applies given size values to underlying pixi component of the stim. + * + * @param {Array} size + */ + _applySizeToPixi(size) + { + const size_px = util.to_px(size, this._units, this._win); + const scaleX = size_px[0] / this._movie.videoWidth; + const scaleY = size_px[1] / this._movie.videoHeight; + this._pixi.scale.x = this.flipHoriz ? -scaleX : scaleX; + this._pixi.scale.y = this.flipVert ? scaleY : -scaleY; + } + + /** + * Applies given size values to youtube iframe. + * + * @param {*} size + */ + _applySizeToYoutubeIframe(size) + { + const size_px = util.to_px(size, this._units, this._win); + this._youtubePlayer.setSize(size_px[ 0 ], size_px[ 1 ]); + } + + /** + * Ensures to convert NaN in the size values to proper, numerical values using given texture dimensions. + * + * @param {Array} size + */ + _ensureNaNSizeConversion(size, html5Video) + { + if (Number.isNaN(size[0]) && Number.isNaN(size[1])) + { + size = util.to_unit([html5Video.videoWidth, html5Video.videoHeight], "pix", this._win, this._units); + } + else if (Number.isNaN(size[0])) + { + size[0] = size[1] * (html5Video.videoWidth / html5Video.videoHeight); + } + else if (Number.isNaN(size[1])) + { + size[1] = size[0] / (html5Video.videoWidth / html5Video.videoHeight); + } + + return size; + } + + /** + * Estimate the bounding box. */ _estimateBoundingBox() { @@ -370,8 +779,18 @@ export class MovieStim extends VisualStim return; } + // Not using PIXI.Texture.from() on purpose, as it caches both PIXI.Texture and PIXI.BaseTexture. + // As a result of that we can have multiple MovieStim instances using same PIXI.BaseTexture, + // thus changing texture related properties like interpolation, or calling _pixi.destroy(true) + // will affect all MovieStims which happen to share that BaseTexture. + this._texture = new PIXI.Texture(new PIXI.BaseTexture( + this._movie, + { + resourceOptions: { autoPlay: this.autoPlay } + } + )); + // create a PixiJS video sprite: - this._texture = PIXI.Texture.from(this._movie, { resourceOptions: { autoPlay: this.autoPlay } }); this._pixi = new PIXI.Sprite(this._texture); // since _texture.width may not be immedialy available but the rest of the code needs its value @@ -394,13 +813,10 @@ export class MovieStim extends VisualStim // opacity: this._pixi.alpha = this.opacity; - // set the scale: - const displaySize = this._getDisplaySize(); - const size_px = util.to_px(displaySize, this.units, this.win); - const scaleX = size_px[0] / this._texture.width; - const scaleY = size_px[1] / this._texture.height; - this._pixi.scale.x = this.flipHoriz ? -scaleX : scaleX; - this._pixi.scale.y = this.flipVert ? scaleY : -scaleY; + // initial setSize might be called with incomplete values like [512, null]. + // Before texture is loaded they are converted to [512, NaN]. + // At this point the texture is loaded and we can convert NaN to proper values. + this.size = this._size; // set the position, rotation, and anchor (movie centered on pos): this._pixi.position = to_pixiPoint(this.pos, this.units, this.win); @@ -412,9 +828,11 @@ export class MovieStim extends VisualStim } /** - * Get the size of the display image, which is either that of the ImageStim or that of the image + * Get the size of the display image, which is either that of the MovieStim or that of the image * it contains. * + * @name module:visual.MovieStim#_getDisplaySize + * @private * @protected * @return {number[]} the size of the displayed image */ diff --git a/src/visual/VisualStim.js b/src/visual/VisualStim.js index 939157b..b47bc09 100644 --- a/src/visual/VisualStim.js +++ b/src/visual/VisualStim.js @@ -393,6 +393,7 @@ export class VisualStim extends util.mix(MinimalStim).with(WindowMixin) { anchor[0] = 1.0; } + if (anchorText.indexOf("top") > -1) { anchor[1] = 0.0; diff --git a/src/visual/YoutubeIframeAPI.js b/src/visual/YoutubeIframeAPI.js new file mode 100644 index 0000000..b37e028 --- /dev/null +++ b/src/visual/YoutubeIframeAPI.js @@ -0,0 +1,94 @@ +/** + * Provides a class to work with Youtube Iframe API. See https://developers.google.com/youtube/iframe_api_reference + * + * @author Nikita Agafonov + * @version 2023.2.0 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2023 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * + */ + +import * as util from "../util/Util.js"; + +class YoutubeIframeAPI +{ + constructor () + { + this.isReady = false; + this._initResolver = undefined; + this._initPromise = undefined; + } + + _onYoutubeIframeAPIReady () + { + this.isReady = true; + this._initResolver(); + } + + async init () + { + if (this.isReady) + { + return Promise.resolve(); + } + + // If init is in progress but not done yet, return the promise. + // This is the case when multiple movie stims are created simultaneously. + if (this._initPromise) + { + return this._initPromise; + } + + // Called by Youtube script. + window.onYouTubeIframeAPIReady = this._onYoutubeIframeAPIReady.bind(this); + + let el = document.createElement("script"); + el.src = "https://www.youtube.com/iframe_api"; + let firstScriptTag = document.getElementsByTagName("script")[0]; + firstScriptTag.parentNode.insertBefore(el, firstScriptTag); + + this._initPromise = new Promise((res, rej) => { + this._initResolver = res; + }); + + return this._initPromise; + } + + createPlayer (params = {}) + { + const uuid = util.makeUuid(); + document.body.insertAdjacentHTML("beforeend", + `
+
+
`); + document.querySelector(`#yt-iframe-placeholder-${uuid}`).parentElement.classList.add("inprogress"); + + const originalOnready = params.events.onReady; + params.events.onReady = (event) => + { + document.querySelector(`#yt-iframe-placeholder-${uuid}`).parentElement.classList.remove("inprogress"); + if (typeof originalOnready === "function") + { + originalOnready(event); + } + }; + + const ytPlayer = new YT.Player(`yt-iframe-placeholder-${uuid}`, + params + ); + + return ytPlayer; + } + + destroyPlayer (ytPlayer) + { + const elementId = ytPlayer.getIframe().id; + ytPlayer.destroy(); + + // At this point youtubeAPI destroyed the player and returned the placeholder div back in place instead of it. Cleaning up. + document.getElementById(elementId).parentElement.remove(); + } +} + +const YTAPISingleTon = new YoutubeIframeAPI(); +export default YTAPISingleTon;