* force fullscreen events to trigger on plyr element (media element in iOS) and not fullscreen container * Fixing "missing code in detail" for PlyrEvent type When using typescript and listening for youtube statechange event, it is missing the code property definition inside the event (even though it is provided in the code). By making events a map of key-value, we can add easily custom event type for specific event name. Since YouTube "statechange" event differs from the basic PlyrEvent, I added a new Event Type "PlyrStateChangeEvent" having a code property corresponding to a YoutubeState enum defined by the YouTube API documentation. This pattern follows how addEventListener in the lib.dom.d.ts is defined. * Update link to working dash.js demo (was broken) * Fix PreviewThumbnailsOptions type According to the docs, the `src` should also accept an array of strings. * fix issue #1872 * Check if key is a string before attempt --plyr checking * Fix for Slow loading videos not autoplaying * Fix for Slow loading videos not autoplaying * Network requests are not cancelled after the player is destroyed * Fix for apect ratio problem when using Vimeo player on mobile devices (issue #1940) * chore: update packages and linting * Invoke custom listener on triggering fullscreen via double-click * Fix volume when unmuting from volume 0 * adding a nice Svelte plugin that I found * Add missing unit to calc in media query * Assigning player's lastSeekTime on rewind/fast forward to prevent immediate controls hide on mobile * Fix youtube not working when player is inside shadow dom * v3.6.2 * ESLint to use common config * add BitChute to users list * Fix aspect ratio issue * Revert noCookie change * feat: demo radius tweaks * fix: poster image shouldn’t receive click events * chore: package updates * chore: linting * feat: custom controls option for embedded players * Package upgrades * ESLint to use common config * Linting changes * Update README.md * chore: formatting * fix: revert pointer events change for poster * fix: hack for Safari 14 not repainting Vimeo embed on entering fullscreen * fix: demo using custom controls for YouTube * doc: Add STROLLÿN among the list of Plyr users * Fixes #2005 * fix: overflowing volume slider * chore: clean up CSS * fix: hide poster when not using custom controls * Package upgrades * ESLint to use common config * Linting changes * chore: revert customControls default option (to prevent breaking change) * docs: changelog for v3.6.3 Co-authored-by: Som Meaden <som@theprojectsomething.com> Co-authored-by: akuma06 <demon.akuma06@gmail.com> Co-authored-by: Jonathan Arbely <dev@jonathanarbely.de> Co-authored-by: Takeshi <iwatakeshi@users.noreply.github.com> Co-authored-by: Hex <hex@codeigniter.org.cn> Co-authored-by: Syed Husain <syed.husain@appspace.com> Co-authored-by: Danielh112 <Daniel@sbgsportssoftware.com> Co-authored-by: Danil Stoyanov <d.stoyanov@corp.mail.ru> Co-authored-by: Guru Prasad Srinivasa <gurupras@buffalo.edu> Co-authored-by: Stephane Fortin Bouchard <stephane.f.bouchard@gmail.com> Co-authored-by: Zev Averbach <zev@averba.ch> Co-authored-by: Vincent Orback <hello@vincentorback.se> Co-authored-by: trafium <trafium@gmail.com> Co-authored-by: xansen <27698939+xansen@users.noreply.github.com> Co-authored-by: zoomerdev <59863739+zoomerdev@users.noreply.github.com> Co-authored-by: Mikaël Castellani <mikael.castellani@gmail.com> Co-authored-by: dirkjf <d.j.faber@outlook.com>
408 lines
13 KiB
JavaScript
408 lines
13 KiB
JavaScript
// ==========================================================================
|
|
// Plyr Captions
|
|
// TODO: Create as class
|
|
// ==========================================================================
|
|
|
|
import controls from './controls';
|
|
import support from './support';
|
|
import { dedupe } from './utils/arrays';
|
|
import browser from './utils/browser';
|
|
import {
|
|
createElement,
|
|
emptyElement,
|
|
getAttributesFromSelector,
|
|
insertAfter,
|
|
removeElement,
|
|
toggleClass,
|
|
} from './utils/elements';
|
|
import { on, triggerEvent } from './utils/events';
|
|
import fetch from './utils/fetch';
|
|
import i18n from './utils/i18n';
|
|
import is from './utils/is';
|
|
import { getHTML } from './utils/strings';
|
|
import { parseUrl } from './utils/urls';
|
|
|
|
const captions = {
|
|
// Setup captions
|
|
setup() {
|
|
// Requires UI support
|
|
if (!this.supported.ui) {
|
|
return;
|
|
}
|
|
|
|
// Only Vimeo and HTML5 video supported at this point
|
|
if (!this.isVideo || this.isYouTube || (this.isHTML5 && !support.textTracks)) {
|
|
// Clear menu and hide
|
|
if (
|
|
is.array(this.config.controls) &&
|
|
this.config.controls.includes('settings') &&
|
|
this.config.settings.includes('captions')
|
|
) {
|
|
controls.setCaptionsMenu.call(this);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Inject the container
|
|
if (!is.element(this.elements.captions)) {
|
|
this.elements.captions = createElement('div', getAttributesFromSelector(this.config.selectors.captions));
|
|
|
|
insertAfter(this.elements.captions, this.elements.wrapper);
|
|
}
|
|
|
|
// Fix IE captions if CORS is used
|
|
// Fetch captions and inject as blobs instead (data URIs not supported!)
|
|
if (browser.isIE && window.URL) {
|
|
const elements = this.media.querySelectorAll('track');
|
|
|
|
Array.from(elements).forEach((track) => {
|
|
const src = track.getAttribute('src');
|
|
const url = parseUrl(src);
|
|
|
|
if (
|
|
url !== null &&
|
|
url.hostname !== window.location.href.hostname &&
|
|
['http:', 'https:'].includes(url.protocol)
|
|
) {
|
|
fetch(src, 'blob')
|
|
.then((blob) => {
|
|
track.setAttribute('src', window.URL.createObjectURL(blob));
|
|
})
|
|
.catch(() => {
|
|
removeElement(track);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Get and set initial data
|
|
// The "preferred" options are not realized unless / until the wanted language has a match
|
|
// * languages: Array of user's browser languages.
|
|
// * language: The language preferred by user settings or config
|
|
// * active: The state preferred by user settings or config
|
|
// * toggled: The real captions state
|
|
|
|
const browserLanguages = navigator.languages || [navigator.language || navigator.userLanguage || 'en'];
|
|
const languages = dedupe(browserLanguages.map((language) => language.split('-')[0]));
|
|
let language = (this.storage.get('language') || this.config.captions.language || 'auto').toLowerCase();
|
|
|
|
// Use first browser language when language is 'auto'
|
|
if (language === 'auto') {
|
|
[language] = languages;
|
|
}
|
|
|
|
let active = this.storage.get('captions');
|
|
if (!is.boolean(active)) {
|
|
({ active } = this.config.captions);
|
|
}
|
|
|
|
Object.assign(this.captions, {
|
|
toggled: false,
|
|
active,
|
|
language,
|
|
languages,
|
|
});
|
|
|
|
// Watch changes to textTracks and update captions menu
|
|
if (this.isHTML5) {
|
|
const trackEvents = this.config.captions.update ? 'addtrack removetrack' : 'removetrack';
|
|
on.call(this, this.media.textTracks, trackEvents, captions.update.bind(this));
|
|
}
|
|
|
|
// Update available languages in list next tick (the event must not be triggered before the listeners)
|
|
setTimeout(captions.update.bind(this), 0);
|
|
},
|
|
|
|
// Update available language options in settings based on tracks
|
|
update() {
|
|
const tracks = captions.getTracks.call(this, true);
|
|
// Get the wanted language
|
|
const { active, language, meta, currentTrackNode } = this.captions;
|
|
const languageExists = Boolean(tracks.find((track) => track.language === language));
|
|
|
|
// Handle tracks (add event listener and "pseudo"-default)
|
|
if (this.isHTML5 && this.isVideo) {
|
|
tracks
|
|
.filter((track) => !meta.get(track))
|
|
.forEach((track) => {
|
|
this.debug.log('Track added', track);
|
|
|
|
// Attempt to store if the original dom element was "default"
|
|
meta.set(track, {
|
|
default: track.mode === 'showing',
|
|
});
|
|
|
|
// Turn off native caption rendering to avoid double captions
|
|
// Note: mode='hidden' forces a track to download. To ensure every track
|
|
// isn't downloaded at once, only 'showing' tracks should be reassigned
|
|
// eslint-disable-next-line no-param-reassign
|
|
if (track.mode === 'showing') {
|
|
// eslint-disable-next-line no-param-reassign
|
|
track.mode = 'hidden';
|
|
}
|
|
|
|
// Add event listener for cue changes
|
|
on.call(this, track, 'cuechange', () => captions.updateCues.call(this));
|
|
});
|
|
}
|
|
|
|
// Update language first time it matches, or if the previous matching track was removed
|
|
if ((languageExists && this.language !== language) || !tracks.includes(currentTrackNode)) {
|
|
captions.setLanguage.call(this, language);
|
|
captions.toggle.call(this, active && languageExists);
|
|
}
|
|
|
|
// Enable or disable captions based on track length
|
|
toggleClass(this.elements.container, this.config.classNames.captions.enabled, !is.empty(tracks));
|
|
|
|
// Update available languages in list
|
|
if (
|
|
is.array(this.config.controls) &&
|
|
this.config.controls.includes('settings') &&
|
|
this.config.settings.includes('captions')
|
|
) {
|
|
controls.setCaptionsMenu.call(this);
|
|
}
|
|
},
|
|
|
|
// Toggle captions display
|
|
// Used internally for the toggleCaptions method, with the passive option forced to false
|
|
toggle(input, passive = true) {
|
|
// If there's no full support
|
|
if (!this.supported.ui) {
|
|
return;
|
|
}
|
|
|
|
const { toggled } = this.captions; // Current state
|
|
const activeClass = this.config.classNames.captions.active;
|
|
// Get the next state
|
|
// If the method is called without parameter, toggle based on current value
|
|
const active = is.nullOrUndefined(input) ? !toggled : input;
|
|
|
|
// Update state and trigger event
|
|
if (active !== toggled) {
|
|
// When passive, don't override user preferences
|
|
if (!passive) {
|
|
this.captions.active = active;
|
|
this.storage.set({ captions: active });
|
|
}
|
|
|
|
// Force language if the call isn't passive and there is no matching language to toggle to
|
|
if (!this.language && active && !passive) {
|
|
const tracks = captions.getTracks.call(this);
|
|
const track = captions.findTrack.call(this, [this.captions.language, ...this.captions.languages], true);
|
|
|
|
// Override user preferences to avoid switching languages if a matching track is added
|
|
this.captions.language = track.language;
|
|
|
|
// Set caption, but don't store in localStorage as user preference
|
|
captions.set.call(this, tracks.indexOf(track));
|
|
return;
|
|
}
|
|
|
|
// Toggle button if it's enabled
|
|
if (this.elements.buttons.captions) {
|
|
this.elements.buttons.captions.pressed = active;
|
|
}
|
|
|
|
// Add class hook
|
|
toggleClass(this.elements.container, activeClass, active);
|
|
|
|
this.captions.toggled = active;
|
|
|
|
// Update settings menu
|
|
controls.updateSetting.call(this, 'captions');
|
|
|
|
// Trigger event (not used internally)
|
|
triggerEvent.call(this, this.media, active ? 'captionsenabled' : 'captionsdisabled');
|
|
}
|
|
|
|
// Wait for the call stack to clear before setting mode='hidden'
|
|
// on the active track - forcing the browser to download it
|
|
setTimeout(() => {
|
|
if (active && this.captions.toggled) {
|
|
this.captions.currentTrackNode.mode = 'hidden';
|
|
}
|
|
});
|
|
},
|
|
|
|
// Set captions by track index
|
|
// Used internally for the currentTrack setter with the passive option forced to false
|
|
set(index, passive = true) {
|
|
const tracks = captions.getTracks.call(this);
|
|
|
|
// Disable captions if setting to -1
|
|
if (index === -1) {
|
|
captions.toggle.call(this, false, passive);
|
|
return;
|
|
}
|
|
|
|
if (!is.number(index)) {
|
|
this.debug.warn('Invalid caption argument', index);
|
|
return;
|
|
}
|
|
|
|
if (!(index in tracks)) {
|
|
this.debug.warn('Track not found', index);
|
|
return;
|
|
}
|
|
|
|
if (this.captions.currentTrack !== index) {
|
|
this.captions.currentTrack = index;
|
|
const track = tracks[index];
|
|
const { language } = track || {};
|
|
|
|
// Store reference to node for invalidation on remove
|
|
this.captions.currentTrackNode = track;
|
|
|
|
// Update settings menu
|
|
controls.updateSetting.call(this, 'captions');
|
|
|
|
// When passive, don't override user preferences
|
|
if (!passive) {
|
|
this.captions.language = language;
|
|
this.storage.set({ language });
|
|
}
|
|
|
|
// Handle Vimeo captions
|
|
if (this.isVimeo) {
|
|
this.embed.enableTextTrack(language);
|
|
}
|
|
|
|
// Trigger event
|
|
triggerEvent.call(this, this.media, 'languagechange');
|
|
}
|
|
|
|
// Show captions
|
|
captions.toggle.call(this, true, passive);
|
|
|
|
if (this.isHTML5 && this.isVideo) {
|
|
// If we change the active track while a cue is already displayed we need to update it
|
|
captions.updateCues.call(this);
|
|
}
|
|
},
|
|
|
|
// Set captions by language
|
|
// Used internally for the language setter with the passive option forced to false
|
|
setLanguage(input, passive = true) {
|
|
if (!is.string(input)) {
|
|
this.debug.warn('Invalid language argument', input);
|
|
return;
|
|
}
|
|
// Normalize
|
|
const language = input.toLowerCase();
|
|
this.captions.language = language;
|
|
|
|
// Set currentTrack
|
|
const tracks = captions.getTracks.call(this);
|
|
const track = captions.findTrack.call(this, [language]);
|
|
captions.set.call(this, tracks.indexOf(track), passive);
|
|
},
|
|
|
|
// Get current valid caption tracks
|
|
// If update is false it will also ignore tracks without metadata
|
|
// This is used to "freeze" the language options when captions.update is false
|
|
getTracks(update = false) {
|
|
// Handle media or textTracks missing or null
|
|
const tracks = Array.from((this.media || {}).textTracks || []);
|
|
// For HTML5, use cache instead of current tracks when it exists (if captions.update is false)
|
|
// Filter out removed tracks and tracks that aren't captions/subtitles (for example metadata)
|
|
return tracks
|
|
.filter((track) => !this.isHTML5 || update || this.captions.meta.has(track))
|
|
.filter((track) => ['captions', 'subtitles'].includes(track.kind));
|
|
},
|
|
|
|
// Match tracks based on languages and get the first
|
|
findTrack(languages, force = false) {
|
|
const tracks = captions.getTracks.call(this);
|
|
const sortIsDefault = (track) => Number((this.captions.meta.get(track) || {}).default);
|
|
const sorted = Array.from(tracks).sort((a, b) => sortIsDefault(b) - sortIsDefault(a));
|
|
let track;
|
|
|
|
languages.every((language) => {
|
|
track = sorted.find((t) => t.language === language);
|
|
return !track; // Break iteration if there is a match
|
|
});
|
|
|
|
// If no match is found but is required, get first
|
|
return track || (force ? sorted[0] : undefined);
|
|
},
|
|
|
|
// Get the current track
|
|
getCurrentTrack() {
|
|
return captions.getTracks.call(this)[this.currentTrack];
|
|
},
|
|
|
|
// Get UI label for track
|
|
getLabel(track) {
|
|
let currentTrack = track;
|
|
|
|
if (!is.track(currentTrack) && support.textTracks && this.captions.toggled) {
|
|
currentTrack = captions.getCurrentTrack.call(this);
|
|
}
|
|
|
|
if (is.track(currentTrack)) {
|
|
if (!is.empty(currentTrack.label)) {
|
|
return currentTrack.label;
|
|
}
|
|
|
|
if (!is.empty(currentTrack.language)) {
|
|
return track.language.toUpperCase();
|
|
}
|
|
|
|
return i18n.get('enabled', this.config);
|
|
}
|
|
|
|
return i18n.get('disabled', this.config);
|
|
},
|
|
|
|
// Update captions using current track's active cues
|
|
// Also optional array argument in case there isn't any track (ex: vimeo)
|
|
updateCues(input) {
|
|
// Requires UI
|
|
if (!this.supported.ui) {
|
|
return;
|
|
}
|
|
|
|
if (!is.element(this.elements.captions)) {
|
|
this.debug.warn('No captions element to render to');
|
|
return;
|
|
}
|
|
|
|
// Only accept array or empty input
|
|
if (!is.nullOrUndefined(input) && !Array.isArray(input)) {
|
|
this.debug.warn('updateCues: Invalid input', input);
|
|
return;
|
|
}
|
|
|
|
let cues = input;
|
|
|
|
// Get cues from track
|
|
if (!cues) {
|
|
const track = captions.getCurrentTrack.call(this);
|
|
|
|
cues = Array.from((track || {}).activeCues || [])
|
|
.map((cue) => cue.getCueAsHTML())
|
|
.map(getHTML);
|
|
}
|
|
|
|
// Set new caption text
|
|
const content = cues.map((cueText) => cueText.trim()).join('\n');
|
|
const changed = content !== this.elements.captions.innerHTML;
|
|
|
|
if (changed) {
|
|
// Empty the container and create a new child element
|
|
emptyElement(this.elements.captions);
|
|
const caption = createElement('span', getAttributesFromSelector(this.config.selectors.caption));
|
|
caption.innerHTML = content;
|
|
this.elements.captions.appendChild(caption);
|
|
|
|
// Trigger event
|
|
triggerEvent.call(this, this.media, 'cuechange');
|
|
}
|
|
},
|
|
};
|
|
|
|
export default captions;
|