* change browserslist to cover 100% not dead browsers * feat: add MediaMetadata * Revert browserslist change Co-authored-by: Sam Potts <sam@potts.es>
301 lines
8.8 KiB
JavaScript
301 lines
8.8 KiB
JavaScript
// ==========================================================================
|
|
// Plyr UI
|
|
// ==========================================================================
|
|
|
|
import captions from './captions';
|
|
import controls from './controls';
|
|
import support from './support';
|
|
import browser from './utils/browser';
|
|
import { getElement, toggleClass } from './utils/elements';
|
|
import { ready, triggerEvent } from './utils/events';
|
|
import i18n from './utils/i18n';
|
|
import is from './utils/is';
|
|
import loadImage from './utils/load-image';
|
|
|
|
const ui = {
|
|
addStyleHook() {
|
|
toggleClass(this.elements.container, this.config.selectors.container.replace('.', ''), true);
|
|
toggleClass(this.elements.container, this.config.classNames.uiSupported, this.supported.ui);
|
|
},
|
|
|
|
// Toggle native HTML5 media controls
|
|
toggleNativeControls(toggle = false) {
|
|
if (toggle && this.isHTML5) {
|
|
this.media.setAttribute('controls', '');
|
|
} else {
|
|
this.media.removeAttribute('controls');
|
|
}
|
|
},
|
|
|
|
// Setup the UI
|
|
build() {
|
|
// Re-attach media element listeners
|
|
// TODO: Use event bubbling?
|
|
this.listeners.media();
|
|
|
|
// Don't setup interface if no support
|
|
if (!this.supported.ui) {
|
|
this.debug.warn(`Basic support only for ${this.provider} ${this.type}`);
|
|
|
|
// Restore native controls
|
|
ui.toggleNativeControls.call(this, true);
|
|
|
|
// Bail
|
|
return;
|
|
}
|
|
|
|
// Inject custom controls if not present
|
|
if (!is.element(this.elements.controls)) {
|
|
// Inject custom controls
|
|
controls.inject.call(this);
|
|
|
|
// Re-attach control listeners
|
|
this.listeners.controls();
|
|
}
|
|
|
|
// Remove native controls
|
|
ui.toggleNativeControls.call(this);
|
|
|
|
// Setup captions for HTML5
|
|
if (this.isHTML5) {
|
|
captions.setup.call(this);
|
|
}
|
|
|
|
// Reset volume
|
|
this.volume = null;
|
|
|
|
// Reset mute state
|
|
this.muted = null;
|
|
|
|
// Reset loop state
|
|
this.loop = null;
|
|
|
|
// Reset quality setting
|
|
this.quality = null;
|
|
|
|
// Reset speed
|
|
this.speed = null;
|
|
|
|
// Reset volume display
|
|
controls.updateVolume.call(this);
|
|
|
|
// Reset time display
|
|
controls.timeUpdate.call(this);
|
|
|
|
// Reset duration display
|
|
controls.durationUpdate.call(this);
|
|
|
|
// Update the UI
|
|
ui.checkPlaying.call(this);
|
|
|
|
// Check for picture-in-picture support
|
|
toggleClass(
|
|
this.elements.container,
|
|
this.config.classNames.pip.supported,
|
|
support.pip && this.isHTML5 && this.isVideo,
|
|
);
|
|
|
|
// Check for airplay support
|
|
toggleClass(this.elements.container, this.config.classNames.airplay.supported, support.airplay && this.isHTML5);
|
|
|
|
// Add iOS class
|
|
toggleClass(this.elements.container, this.config.classNames.isIos, browser.isIos);
|
|
|
|
// Add touch class
|
|
toggleClass(this.elements.container, this.config.classNames.isTouch, this.touch);
|
|
|
|
// Ready for API calls
|
|
this.ready = true;
|
|
|
|
// Ready event at end of execution stack
|
|
setTimeout(() => {
|
|
triggerEvent.call(this, this.media, 'ready');
|
|
}, 0);
|
|
|
|
// Set the title
|
|
ui.setTitle.call(this);
|
|
|
|
// Assure the poster image is set, if the property was added before the element was created
|
|
if (this.poster) {
|
|
ui.setPoster.call(this, this.poster, false).catch(() => {});
|
|
}
|
|
|
|
// Manually set the duration if user has overridden it.
|
|
// The event listeners for it doesn't get called if preload is disabled (#701)
|
|
if (this.config.duration) {
|
|
controls.durationUpdate.call(this);
|
|
}
|
|
|
|
// Media metadata
|
|
if (this.config.mediaMetadata) {
|
|
controls.setMediaMetadata.call(this);
|
|
}
|
|
},
|
|
|
|
// Setup aria attribute for play and iframe title
|
|
setTitle() {
|
|
// Find the current text
|
|
let label = i18n.get('play', this.config);
|
|
|
|
// If there's a media title set, use that for the label
|
|
if (is.string(this.config.title) && !is.empty(this.config.title)) {
|
|
label += `, ${this.config.title}`;
|
|
}
|
|
|
|
// If there's a play button, set label
|
|
Array.from(this.elements.buttons.play || []).forEach((button) => {
|
|
button.setAttribute('aria-label', label);
|
|
});
|
|
|
|
// Set iframe title
|
|
// https://github.com/sampotts/plyr/issues/124
|
|
if (this.isEmbed) {
|
|
const iframe = getElement.call(this, 'iframe');
|
|
|
|
if (!is.element(iframe)) {
|
|
return;
|
|
}
|
|
|
|
// Default to media type
|
|
const title = !is.empty(this.config.title) ? this.config.title : 'video';
|
|
const format = i18n.get('frameTitle', this.config);
|
|
|
|
iframe.setAttribute('title', format.replace('{title}', title));
|
|
}
|
|
},
|
|
|
|
// Toggle poster
|
|
togglePoster(enable) {
|
|
toggleClass(this.elements.container, this.config.classNames.posterEnabled, enable);
|
|
},
|
|
|
|
// Set the poster image (async)
|
|
// Used internally for the poster setter, with the passive option forced to false
|
|
setPoster(poster, passive = true) {
|
|
// Don't override if call is passive
|
|
if (passive && this.poster) {
|
|
return Promise.reject(new Error('Poster already set'));
|
|
}
|
|
|
|
// Set property synchronously to respect the call order
|
|
this.media.setAttribute('data-poster', poster);
|
|
|
|
// Show the poster
|
|
this.elements.poster.removeAttribute('hidden');
|
|
|
|
// Wait until ui is ready
|
|
return (
|
|
ready
|
|
.call(this)
|
|
// Load image
|
|
.then(() => loadImage(poster))
|
|
.catch((error) => {
|
|
// Hide poster on error unless it's been set by another call
|
|
if (poster === this.poster) {
|
|
ui.togglePoster.call(this, false);
|
|
}
|
|
// Rethrow
|
|
throw error;
|
|
})
|
|
.then(() => {
|
|
// Prevent race conditions
|
|
if (poster !== this.poster) {
|
|
throw new Error('setPoster cancelled by later call to setPoster');
|
|
}
|
|
})
|
|
.then(() => {
|
|
Object.assign(this.elements.poster.style, {
|
|
backgroundImage: `url('${poster}')`,
|
|
// Reset backgroundSize as well (since it can be set to "cover" for padded thumbnails for youtube)
|
|
backgroundSize: '',
|
|
});
|
|
|
|
ui.togglePoster.call(this, true);
|
|
|
|
return poster;
|
|
})
|
|
);
|
|
},
|
|
|
|
// Check playing state
|
|
checkPlaying(event) {
|
|
// Class hooks
|
|
toggleClass(this.elements.container, this.config.classNames.playing, this.playing);
|
|
toggleClass(this.elements.container, this.config.classNames.paused, this.paused);
|
|
toggleClass(this.elements.container, this.config.classNames.stopped, this.stopped);
|
|
|
|
// Set state
|
|
Array.from(this.elements.buttons.play || []).forEach((target) => {
|
|
Object.assign(target, { pressed: this.playing });
|
|
target.setAttribute('aria-label', i18n.get(this.playing ? 'pause' : 'play', this.config));
|
|
});
|
|
|
|
// Only update controls on non timeupdate events
|
|
if (is.event(event) && event.type === 'timeupdate') {
|
|
return;
|
|
}
|
|
|
|
// Toggle controls
|
|
ui.toggleControls.call(this);
|
|
},
|
|
|
|
// Check if media is loading
|
|
checkLoading(event) {
|
|
this.loading = ['stalled', 'waiting'].includes(event.type);
|
|
|
|
// Clear timer
|
|
clearTimeout(this.timers.loading);
|
|
|
|
// Timer to prevent flicker when seeking
|
|
this.timers.loading = setTimeout(
|
|
() => {
|
|
// Update progress bar loading class state
|
|
toggleClass(this.elements.container, this.config.classNames.loading, this.loading);
|
|
|
|
// Update controls visibility
|
|
ui.toggleControls.call(this);
|
|
},
|
|
this.loading ? 250 : 0,
|
|
);
|
|
},
|
|
|
|
// Toggle controls based on state and `force` argument
|
|
toggleControls(force) {
|
|
const { controls: controlsElement } = this.elements;
|
|
|
|
if (controlsElement && this.config.hideControls) {
|
|
// Don't hide controls if a touch-device user recently seeked. (Must be limited to touch devices, or it occasionally prevents desktop controls from hiding.)
|
|
const recentTouchSeek = this.touch && this.lastSeekTime + 2000 > Date.now();
|
|
|
|
// Show controls if force, loading, paused, button interaction, or recent seek, otherwise hide
|
|
this.toggleControls(
|
|
Boolean(
|
|
force || this.loading || this.paused || controlsElement.pressed || controlsElement.hover || recentTouchSeek,
|
|
),
|
|
);
|
|
}
|
|
},
|
|
|
|
// Migrate any custom properties from the media to the parent
|
|
migrateStyles() {
|
|
// Loop through values (as they are the keys when the object is spread 🤔)
|
|
Object.values({ ...this.media.style })
|
|
// We're only fussed about Plyr specific properties
|
|
.filter((key) => !is.empty(key) && is.string(key) && key.startsWith('--plyr'))
|
|
.forEach((key) => {
|
|
// Set on the container
|
|
this.elements.container.style.setProperty(key, this.media.style.getPropertyValue(key));
|
|
|
|
// Clean up from media element
|
|
this.media.style.removeProperty(key);
|
|
});
|
|
|
|
// Remove attribute if empty
|
|
if (is.empty(this.media.style)) {
|
|
this.media.removeAttribute('style');
|
|
}
|
|
},
|
|
};
|
|
|
|
export default ui;
|