import $ from 'jquery';
import DOMPurify from 'dompurify';
function addDescriptionFunctions(AblePlayer) {
AblePlayer.prototype.initDescription = function() {
// set default mode for delivering description (open vs closed)
// based on availability and user preference
// called when player is being built, or when a user
// toggles the Description button or changes a description-related preference
// The following variables are applicable to delivery of description:
// defaultStateDescriptions == 'on' or 'off', defined by website owner (overridden by prefDesc)
// prefDesc == 1 if user wants description (i.e., Description button is on); else 0
// prefDescPause == 1 to pause video when description starts; else 0
// prefDescVisible == 1 to visibly show text-based description area; else 0
// prefDescMethod == either 'video' or 'text' (as of v4.0.10, prefDescMethod is always 'video')
// descMethod is the format actually used ('video' or 'text'), regardless of user preference
// hasOpenDesc == true if a described version of video is available via data-desc-src attribute
// hasClosedDesc == true if a description text track is available
// descOn == true if description of either type is on
// readDescriptionsAloud == true if text description is to be announced audibly; otherwise false
// descReader == either 'browser' or 'screenreader'
var deferred, promise;
deferred = new this.defer();
promise = deferred.promise();
if (this.mediaType === 'audio') {
deferred.resolve();
}
// check to see if there's an open-described version of this video
// checks only the first source since if a described version is provided,
// it must be provided for all sources
this.descFile = this.sources[0].getAttribute('data-desc-src');
if (typeof this.descFile !== 'undefined') {
this.hasOpenDesc = true;
} else {
// there's no open-described version via data-desc-src,
// but what about data-youtube-desc-src or data-vimeo-desc-src?
// if these exist, they would have been defined earlier
this.hasOpenDesc = (this.youTubeDescId || this.vimeoDescId) ? true : false;
}
// Set this.descMethod based on media availability & user preferences
// no description is available for this video
this.descMethod = null;
if (this.hasOpenDesc && this.hasClosedDesc) {
// both formats are available. User gets their preference.
this.descMethod = (this.prefDescMethod) ? this.prefDescMethod : 'video';
} else if (this.hasOpenDesc) {
this.descMethod = 'video';
} else if (this.hasClosedDesc) {
this.descMethod = 'text';
}
// Set the default state of descriptions
this.descOn = false;
if (this.descMethod) {
if (this.prefDesc === 1) {
this.descOn = true;
} else if (this.prefDesc === 0) {
this.descOn = false;
} else {
// user has no prefs. Use default state.
this.descOn = (this.defaultStateDescriptions === 1) ? true : false;
}
}
// If a video has text audio descriptions, inject the description area.
if (typeof this.$descDiv === 'undefined' && this.hasClosedDesc ) {
this.injectTextDescriptionArea();
}
if (this.descOn) {
if (this.descMethod === 'video' && !this.usingDescribedVersion() ) {
// switched from non-described to described version
this.swapDescription();
}
if (this.hasClosedDesc) {
if (this.prefDescVisible) {
// make description text visible
if (typeof this.$descDiv !== 'undefined') {
this.$descDiv.show();
this.$descDiv.removeClass('able-offscreen');
}
} else {
// keep it visible to screen readers, but hide it visibly
if (typeof this.$descDiv !== 'undefined') {
this.$descDiv.addClass('able-offscreen');
}
}
}
} else { // description is off.
if (this.descMethod === 'video') { // user has turned off described version of video
if (this.usingDescribedVersion()) {
// user was using the described verion. Swap for non-described version
this.swapDescription();
}
} else if (this.descMethod === 'text') { // user has turned off text description
// hide description div from everyone, including screen reader users
if (typeof this.$descDiv !== 'undefined') {
this.$descDiv.hide();
this.$descDiv.removeClass('able-offscreen');
}
}
}
deferred.resolve();
return promise;
};
AblePlayer.prototype.usingDescribedVersion = function () {
// Returns true if currently using audio description, false otherwise.
if (this.player === 'youtube') {
return (this.activeYouTubeId === this.youTubeDescId);
} else if (this.player === 'vimeo') {
return (this.activeVimeoId === this.vimeoDescId);
} else {
return (this.sources[0].getAttribute('data-desc-src') === this.sources[0].getAttribute('src'));
}
};
/**
* Initializes speech synthesis capabilities for the player.
* This method addresses browser and OS limitations that require user interaction
* before speech synthesis functions become available. It handles different contexts
* like initialization, playing media, accessing preferences, or announcing descriptions.
* @param {string} context - The context in which the function is called ('init', 'play', 'prefs', 'desc').
*/
AblePlayer.prototype.initSpeech = function (context) {
var thisObj = this;
// Function to attempt enabling speech synthesis
function attemptEnableSpeech() {
var greeting = new SpeechSynthesisUtterance("\x20");
greeting.onend = function () {
thisObj.getBrowserVoices();
if (
(Array.isArray(thisObj.descVoices) && thisObj.descVoices.length) ||
context !== "init"
) {
thisObj.speechEnabled = true;
}
};
thisObj.synth.speak(greeting);
}
// Function to handle the initial click and enable speech synthesis
function handleInitialClick() {
attemptEnableSpeech();
// Once the utterance starts, remove this specific click event listener
// Ensures the event handler only runs once and cleans up after itself
$(document).off("click", handleInitialClick);
}
if (this.speechEnabled === null) {
if (window.speechSynthesis) {
// Browser supports speech synthesis
this.synth = window.speechSynthesis;
this.synth.cancel(); // Cancel any ongoing speech synthesis
if (context === "init") {
// Attempt to enable speech synthesis directly for browsers that don't require a click
attemptEnableSpeech();
// For initial setup, require a user click to enable speech synthesis
// Scoping to a particular handler to avoid conflicts with other click events
$(document).on("click", handleInitialClick);
} else {
// For other contexts, attempt to enable speech synthesis directly
attemptEnableSpeech();
}
} else {
// Browser does not support speech synthesis
this.speechEnabled = false;
}
}
};
AblePlayer.prototype.getBrowserVoices = function () {
// define this.descVoices array
// includes only languages that match the language of the captions or player
var voices, trackLangs, voiceLang, preferredLang;
preferredLang = (this.captionLang) ? this.captionLang.substring(0,2).toLowerCase() : this.lang.substring(0,2).toLowerCase();
this.descVoices = [];
voices = this.synth.getVoices();
trackLangs = this.getTrackLangs();
if (voices.length > 0) {
this.descVoices = [];
// available languages are identified with local suffixes (e.g., en-US)
for (var i=0; i 0) {
if (prefDescVoice) {
// select the language that matches prefDescVoice, if it's available
prefVoiceFound = false;
for (var i=0; i 0) {
this.swapTime = this.elapsed;
} else {
this.swapTime = 0;
}
if (this.duration > 0) {
this.prevDuration = this.duration;
}
// Capture current playback state, so media can resume after source is swapped
if (!this.okToPlay) {
this.okToPlay = this.playing;
}
if (this.descOn) {
this.showAlert( this.translate( 'alertDescribedVersion', 'Using the audio described version of this video' ) );
} else {
this.showAlert( this.translate( 'alertNonDescribedVersion', 'Using the non-described version of this video' ) );
}
if (this.player === 'html5') {
this.swappingSrc = true;
this.paused = true;
if (this.usingDescribedVersion()) {
// the described version is currently playing. Swap to non-described
for (i=0; i < this.sources.length; i++) {
// for all elements, replace src with data-orig-src
origSrc = DOMPurify.sanitize( this.sources[i].getAttribute('data-orig-src') );
if (origSrc) {
this.sources[i].setAttribute('src',origSrc);
}
}
} else {
// the non-described version is currently playing. Swap to described.
for (i=0; i < this.sources.length; i++) {
// for all elements, replace src with data-desc-src (if one exists)
// then store original source in a new data-orig-src attribute
origSrc = DOMPurify.sanitize( this.sources[i].getAttribute('src') );
descSrc = DOMPurify.sanitize( this.sources[i].getAttribute('data-desc-src') );
if (descSrc) {
this.sources[i].setAttribute('src',descSrc);
this.sources[i].setAttribute('data-orig-src',origSrc);
}
}
}
if (this.recreatingPlayer) {
// stopgap to prevent multiple firings of recreatePlayer()
return;
}
if (this.playerCreated) {
// delete old player, then recreate it with new source & tracks
this.deletePlayer('swap-desc-html');
this.recreatePlayer().then(function() {
if (!thisObj.loadingMedia) {
thisObj.media.load();
thisObj.loadingMedia = true;
}
});
} else {
// player is in the process of being created
// no need to recreate it
}
} else if (this.player === 'youtube') {
// if the described version is currently playing, swap to non-described
this.activeYouTubeId = (this.usingDescribedVersion()) ? this.youTubeId : this.youTubeDescId;
if (typeof this.youTubePlayer !== 'undefined') {
thisObj.swappingSrc = true;
if (thisObj.playing) {
// loadVideoById() loads and immediately plays the new video at swapTime
thisObj.youTubePlayer.loadVideoById(thisObj.activeYouTubeId,thisObj.swapTime);
} else {
// cueVideoById() loads the new video and seeks to swapTime, but does not play
thisObj.youTubePlayer.cueVideoById(thisObj.activeYouTubeId,thisObj.swapTime);
}
}
if (this.playerCreated) {
this.deletePlayer('swap-desc-youtube');
}
// player needs to be recreated with new source
if (this.recreatingPlayer) {
// stopgap to prevent multiple firings of recreatePlayer()
return;
}
this.recreatePlayer().then(function() {
// nothing to do here
// next steps occur when youtube onReady event fires
// see youtube.js > finalizeYoutubeInit()
});
} else if (this.player === 'vimeo') {
if (this.usingDescribedVersion()) {
// the described version is currently playing. Swap to non-described
this.activeVimeoId = this.vimeoId;
this.showAlert( this.translate( 'alertNonDescribedVersion', 'Using the non-described version of this video' ) );
} else {
// the non-described version is currently playing. Swap to described.
this.activeVimeoId = this.vimeoDescId;
this.showAlert( this.translate( 'alertDescribedVersion', 'Using the audio described version of this video' ) );
}
if (this.playerCreated) {
this.deletePlayer('swap-desc-vimeo');
}
// player needs to be recreated with new source
if (this.recreatingPlayer) {
// stopgap to prevent multiple firings of recreatePlayer()
return;
}
this.recreatePlayer().then(function() {
// load the new video source
thisObj.vimeoPlayer.loadVideo(thisObj.activeVimeoId).then(function() {
if (thisObj.playing) {
// video was playing when user requested an alternative version
// seek to swapTime and continue playback (playback happens automatically)
thisObj.vimeoPlayer.setCurrentTime(thisObj.swapTime);
} else {
// Vimeo autostarts immediately after video loads
// The "Described" button should not trigger playback, so stop this before the user notices.
thisObj.vimeoPlayer.pause();
}
});
});
}
};
AblePlayer.prototype.showDescription = function(now) {
if (!this.playing || !this.hasClosedDesc || this.swappingSrc || !this.descOn || ( this.descMethod === 'video' && !this.prefDescVisible ) ) {
return;
}
var cues, d, thisDescription, descText;
var flattenComponentForDescription = function (component) {
var result = [];
if (component.type === 'string') {
result.push(component.value);
} else {
for (var i = 0; i < component.children.length; i++) {
result.push(flattenComponentForDescription(component.children[i]));
}
}
return result.join('');
};
cues = [];
if (this.selectedDescriptions) {
cues = this.selectedDescriptions.cues;
} else if (this.descriptions.length >= 1) {
cues = this.descriptions[0].cues;
}
for (d = 0; d < cues.length; d++) {
if ((cues[d].start <= now) && (cues[d].end > now)) {
thisDescription = d;
break;
}
}
if (typeof thisDescription !== 'undefined') {
if (this.currentDescription !== thisDescription) {
// temporarily remove aria-live from $status to prevent description from being interrupted
this.$status.removeAttr('aria-live');
descText = flattenComponentForDescription(cues[thisDescription].components);
if (this.descReader === 'screenreader') {
// load the new description into the container div for screen readers to read
this.$descDiv.html(descText);
} else if (this.speechEnabled) {
if ( 'video' !== this.descMethod ) {
// use browser's built-in speech synthesis
this.announceText('description',descText);
}
if (this.prefDescVisible) {
// write description to the screen for sighted users
// but remove ARIA attributes since it isn't intended to be read by screen readers
this.$descDiv.html(descText).removeAttr('aria-live aria-atomic');
}
} else {
// browser does not support speech synthesis
// load the new description into the container div for screen readers to read
this.$descDiv.html(descText);
}
// Only pause video if not using a described video.
if (this.prefDescPause && this.descMethod === 'text') {
this.pauseMedia();
this.pausedForDescription = true;
}
this.currentDescription = thisDescription;
}
} else {
this.$descDiv.html('');
this.currentDescription = -1;
// restore aria-live to $status
this.$status.attr('aria-live','polite');
}
};
AblePlayer.prototype.syncSpeechToPlaybackRate = function(rate) {
// called when user changed playback rate
// adjust rate of audio description to match
var speechRate;
if (rate === 0.5) {
speechRate = 0.7; // option 1 in prefs menu
} else if (rate === 0.75) {
speechRate = 0.8; // option 2 in prefs menu
} else if (rate === 1.0) {
speechRate = 1; // option 4 in prefs menu (normal speech, default)
} else if (rate === 1.25) {
speechRate = 1.1; // option 5 in prefs menu
} else if (rate === 1.5) {
speechRate = 1.2; // option 6 in prefs menu
} else if (rate === 1.75) {
speechRate = 1.5; // option 7 in prefs menu
} else if (rate === 2.0) {
speechRate = 2; // option 8 in prefs menu (fast)
} else if (rate === 2.25) {
speechRate = 2.5; // option 9 in prefs menu (very fast)
} else if (rate >= 2.5) {
speechRate = 3; // option 10 in prefs menu (super fast)
}
this.prefDescRate = speechRate;
};
AblePlayer.prototype.announceText = function(context, text, rate) {
// this function announces text using speech synthesis
// it's only called if already determined that browser supports speech synthesis
// context is either:
// 'description' - actual description text extracted from WebVTT file
// 'sample' - called when user changes a setting in Description Prefs dialog
// 'caption' - called when announcing a caption.
var thisObj, voiceName, i, voice, pitch, volume, utterance,
timeElapsed, secondsElapsed;
thisObj = this;
// As of Feb 2021,
// 1. In some browsers (e.g., Chrome) window.speechSynthesis.getVoices()
// returns 0 voices unless the request is triggered with a user click
// Therefore, description may have failed to initialize when the page loaded
// This function cannot have been called without a mouse click.
// Therefore, this is a good time to check that, and try again if needed
// 2. In some browsers, the window.speechSynthesis.speaking property fails to reset,
// and onend event is never fired. This prevents new speech from being spoken.
// window.speechSynthesis.cancel() also fails, so it's impossible to recover.
// This only seems to happen with some voices.
// Typically the first voice in the getVoices() array (index 0) is realiable
// When speech synthesis gets wonky, this is a deep problem that impacts all browsers
// and typically requires a computer reboot to make right again.
// This has been observed frequently in macOS Big Sur, but also in Windows 10
// To ignore user's voice preferences and always use the first voice, set the following var to true
// This is for testing only; not recommended for production
// unless the voice select field is also removed from the Prefs dialog
var useFirstVoice = false;
if (!this.speechEnabled) {
// voices array failed to load the first time. Try again
this.initSpeech('desc');
}
if (context === 'sample') {
// get settings from form
voiceName = $('#' + this.mediaId + '_prefDescVoice').val();
pitch = $('#' + this.mediaId + '_prefDescPitch').val();
rate = $('#' + this.mediaId + '_prefDescRate').val();
volume = $('#' + this.mediaId + '_prefDescVolume').val();
} else if ( context === 'captionSample' ) {
// get settings from form
voiceName = $('#' + this.mediaId + '_prefCaptionsVoice').val();
pitch = $('#' + this.mediaId + '_prefCaptionsPitch').val();
rate = $('#' + this.mediaId + '_prefCaptionsRate').val();
volume = $('#' + this.mediaId + '_prefCaptionsVolume').val();
} else if ( context === 'description' ) {
// get settings from global prefs
voiceName = this.prefDescVoice;
pitch = this.prefDescPitch;
rate = this.prefDescRate;
volume = this.prefDescVolume;
} else {
// get settings from global prefs
voiceName = this.prefCaptionsVoice;
pitch = this.prefCaptionsPitch;
rate = ( rate < this.prefCaptionsRate ) ? this.prefCaptionsRate : rate;
volume = this.prefCaptionsVolume;
}
// get the voice associated with the user's chosen voice name
if (this.descVoices) {
if (this.descVoices.length > 0) {
if (useFirstVoice) {
voice = this.descVoices[0];
} else if (voiceName) {
// get the voice that matches user's preferred voiceName
for (i = 0; i < this.descVoices.length; i++) {
if (this.descVoices[i].name == voiceName) {
voice = this.descVoices[i];
break;
}
}
}
if (typeof voice === 'undefined') {
// no matching voice was found
// use the first voice in the array
voice = this.descVoices[0];
}
}
} else {
voice = null;
}
utterance = new SpeechSynthesisUtterance();
if (voice) {
utterance.voice = voice;
}
utterance.voiceURI = 'native';
utterance.volume = volume;
utterance.rate = rate;
utterance.pitch = pitch;
utterance.text = text;
// TODO: Consider the best language for the utterance:
// language of the web page? (this.lang)
// language of the WebVTT description track?
// language of the user's chosen voice?
// If there's a mismatch between any of these, the description will likely be unintelligible
utterance.lang = this.lang;
utterance.onstart = function(e) {
// utterance has started
};
utterance.onpause = function(e) {
// utterance has paused
};
utterance.onend = function(e) {
// utterance has ended
if ( 'description' === context ) {
this.speakingDescription = false;
} else if ( 'caption' === context ) {
this.speakingCaption = false;
}
timeElapsed = e.elapsedTime;
// As of Firefox 95, e.elapsedTime is expressed in seconds
// Other browsers (tested in Chrome & Edge) express this in milliseconds
// Assume no utterance will require over 100 seconds to express...
// If a large value, time is likely expressed in milliseconds.
secondsElapsed = (timeElapsed > 100) ? (e.elapsedTime/1000).toFixed(2) : (e.elapsedTime).toFixed(2);
if (this.debug) {
console.log('Finished speaking. That took ' + secondsElapsed + ' seconds.');
}
if (context === 'description') {
if (thisObj.prefDescPause) {
if (thisObj.pausedForDescription) {
thisObj.playMedia();
this.pausedForDescription = false;
}
}
}
};
utterance.onerror = function(e) {
// handle error
console.log('Web Speech API error',e);
};
if (this.synth.paused) {
this.synth.resume();
}
this.synth.speak(utterance);
if ( 'description' === context ) {
this.speakingDescription = true;
} else if ( 'caption' === context ) {
this.speakingCaption = true;
}
};
}
export default addDescriptionFunctions;