aboutsummaryrefslogtreecommitdiffstats
path: root/src/js/ui.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/js/ui.js')
-rw-r--r--src/js/ui.js381
1 files changed, 381 insertions, 0 deletions
diff --git a/src/js/ui.js b/src/js/ui.js
new file mode 100644
index 00000000..2d612cdb
--- /dev/null
+++ b/src/js/ui.js
@@ -0,0 +1,381 @@
+// ==========================================================================
+// Plyr UI
+// ==========================================================================
+
+import utils from './utils';
+import captions from './captions';
+import controls from './controls';
+import fullscreen from './fullscreen';
+import listeners from './listeners';
+import storage from './storage';
+
+const ui = {
+ addStyleHook() {
+ utils.toggleClass(this.elements.container, this.config.selectors.container.replace('.', ''), true);
+ utils.toggleClass(this.elements.container, this.config.classNames.uiSupported, this.supported.ui);
+ },
+
+ // Toggle native HTML5 media controls
+ toggleNativeControls(toggle) {
+ 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
+ listeners.media.call(this);
+
+ // Don't setup interface if no support
+ if (!this.supported.ui) {
+ this.warn(`Basic support only for ${this.type}`);
+
+ // Remove controls
+ utils.removeElement.call(this, 'controls');
+
+ // Remove large play
+ utils.removeElement.call(this, 'buttons.play');
+
+ // Restore native controls
+ ui.toggleNativeControls.call(this, true);
+
+ // Bail
+ return;
+ }
+
+ // Inject custom controls if not present
+ if (!utils.is.htmlElement(this.elements.controls)) {
+ // Inject custom controls
+ controls.inject.call(this);
+
+ // Re-attach control listeners
+ listeners.controls.call(this);
+ }
+
+ // If there's no controls, bail
+ if (!utils.is.htmlElement(this.elements.controls)) {
+ return;
+ }
+
+ // Remove native controls
+ ui.toggleNativeControls.call(this);
+
+ // Setup fullscreen
+ fullscreen.setup.call(this);
+
+ // Captions
+ captions.setup.call(this);
+
+ // Set volume
+ this.volume = null;
+ ui.updateVolume.call(this);
+
+ // Set playback speed
+ this.speed = null;
+
+ // Set loop
+ // this.setLoop();
+
+ // Reset time display
+ ui.timeUpdate.call(this);
+
+ // Update the UI
+ ui.checkPlaying.call(this);
+
+ this.ready = true;
+
+ // Ready event at end of execution stack
+ utils.dispatchEvent.call(this, this.media, 'ready');
+
+ // Autoplay
+ if (this.config.autoplay) {
+ this.play();
+ }
+ },
+
+ // Show the duration on metadataloaded
+ displayDuration() {
+ if (!this.supported.ui) {
+ return;
+ }
+
+ // If there's only one time display, display duration there
+ if (!this.elements.display.duration && this.config.displayDuration && this.media.paused) {
+ ui.updateTimeDisplay.call(this, this.duration, this.elements.display.currentTime);
+ }
+
+ // If there's a duration element, update content
+ if (this.elements.display.duration) {
+ ui.updateTimeDisplay.call(this, this.duration, this.elements.display.duration);
+ }
+
+ // Update the tooltip (if visible)
+ ui.updateSeekTooltip.call(this);
+ },
+
+ // Setup aria attribute for play and iframe title
+ setTitle() {
+ // Find the current text
+ let label = this.config.i18n.play;
+
+ // If there's a media title set, use that for the label
+ if (utils.is.string(this.config.title) && !utils.is.empty(this.config.title)) {
+ label += `, ${this.config.title}`;
+
+ // Set container label
+ this.elements.container.setAttribute('aria-label', this.config.title);
+ }
+
+ // If there's a play button, set label
+ if (this.supported.ui) {
+ if (utils.is.htmlElement(this.elements.buttons.play)) {
+ this.elements.buttons.play.setAttribute('aria-label', label);
+ }
+ if (utils.is.htmlElement(this.elements.buttons.playLarge)) {
+ this.elements.buttons.playLarge.setAttribute('aria-label', label);
+ }
+ }
+
+ // Set iframe title
+ // https://github.com/sampotts/plyr/issues/124
+ if (this.isEmbed) {
+ const iframe = utils.getElement.call(this, 'iframe');
+
+ if (!utils.is.htmlElement(iframe)) {
+ return;
+ }
+
+ // Default to media type
+ const title = !utils.is.empty(this.config.title) ? this.config.title : 'video';
+
+ iframe.setAttribute('title', this.config.i18n.frameTitle.replace('{title}', title));
+ }
+ },
+
+ // Check playing state
+ checkPlaying() {
+ utils.toggleClass(this.elements.container, this.config.classNames.playing, !this.media.paused);
+
+ utils.toggleClass(this.elements.container, this.config.classNames.stopped, this.media.paused);
+
+ this.toggleControls(this.media.paused);
+ },
+
+ // Update volume UI and storage
+ updateVolume() {
+ // Update the <input type="range"> if present
+ if (this.supported.ui) {
+ const value = this.media.muted ? 0 : this.media.volume;
+
+ if (this.elements.inputs.volume) {
+ ui.setRange.call(this, this.elements.inputs.volume, value);
+ }
+ }
+
+ // Update the volume in storage
+ storage.set.call(this, {
+ volume: this.media.volume,
+ });
+
+ // Toggle class if muted
+ utils.toggleClass(this.elements.container, this.config.classNames.muted, this.media.muted);
+
+ // Update checkbox for mute state
+ if (this.supported.ui && this.elements.buttons.mute) {
+ utils.toggleState(this.elements.buttons.mute, this.media.muted);
+ }
+ },
+
+ // Check if media is loading
+ checkLoading(event) {
+ this.loading = event.type === 'waiting';
+
+ // Clear timer
+ clearTimeout(this.timers.loading);
+
+ // Timer to prevent flicker when seeking
+ this.timers.loading = setTimeout(() => {
+ // Toggle container class hook
+ utils.toggleClass(this.elements.container, this.config.classNames.loading, this.loading);
+
+ // Show controls if loading, hide if done
+ this.toggleControls(this.loading);
+ }, this.loading ? 250 : 0);
+ },
+
+ // Update seek value and lower fill
+ setRange(target, value) {
+ if (!utils.is.htmlElement(target)) {
+ return;
+ }
+
+ target.value = value;
+
+ // Webkit range fill
+ controls.updateRangeFill.call(this, target);
+ },
+
+ // Set <progress> value
+ setProgress(target, input) {
+ // Default to 0
+ const value = !utils.is.undefined(input) ? input : 0;
+ const progress = !utils.is.undefined(target) ? target : this.elements.display.buffer;
+
+ // Update value and label
+ if (utils.is.htmlElement(progress)) {
+ progress.value = value;
+
+ // Update text label inside
+ const label = progress.getElementsByTagName('span')[0];
+ if (utils.is.htmlElement(label)) {
+ label.childNodes[0].nodeValue = value;
+ }
+ }
+ },
+
+ // Update <progress> elements
+ updateProgress(event) {
+ if (!this.supported.ui) {
+ return;
+ }
+
+ let value = 0;
+
+ if (event) {
+ switch (event.type) {
+ // Video playing
+ case 'timeupdate':
+ case 'seeking':
+ value = utils.getPercentage(this.currentTime, this.duration);
+
+ // Set seek range value only if it's a 'natural' time event
+ if (event.type === 'timeupdate') {
+ ui.setRange.call(this, this.elements.inputs.seek, value);
+ }
+
+ break;
+
+ // Check buffer status
+ case 'playing':
+ case 'progress':
+ value = (() => {
+ const { buffered } = this.media;
+
+ if (buffered && buffered.length) {
+ // HTML5
+ return utils.getPercentage(buffered.end(0), this.duration);
+ } else if (utils.is.number(buffered)) {
+ // YouTube returns between 0 and 1
+ return buffered * 100;
+ }
+
+ return 0;
+ })();
+
+ ui.setProgress.call(this, this.elements.display.buffer, value);
+
+ break;
+
+ default:
+ break;
+ }
+ }
+ },
+
+ // Update the displayed time
+ updateTimeDisplay(value, element) {
+ // Bail if there's no duration display
+ if (!utils.is.htmlElement(element)) {
+ return null;
+ }
+
+ // Fallback to 0
+ const time = !Number.isNaN(value) ? value : 0;
+
+ let secs = parseInt(time % 60, 10);
+ let mins = parseInt((time / 60) % 60, 10);
+ const hours = parseInt((time / 60 / 60) % 60, 10);
+
+ // Do we need to display hours?
+ const displayHours = parseInt((this.duration / 60 / 60) % 60, 10) > 0;
+
+ // Ensure it's two digits. For example, 03 rather than 3.
+ secs = `0${secs}`.slice(-2);
+ mins = `0${mins}`.slice(-2);
+
+ // Generate display
+ const display = `${(displayHours ? `${hours}:` : '') + mins}:${secs}`;
+
+ // Render
+ element.textContent = display;
+
+ // Return for looping
+ return display;
+ },
+
+ // Handle time change event
+ timeUpdate(event) {
+ // Duration
+ ui.updateTimeDisplay.call(this, this.currentTime, this.elements.display.currentTime);
+
+ // Ignore updates while seeking
+ if (event && event.type === 'timeupdate' && this.media.seeking) {
+ return;
+ }
+
+ // Playing progress
+ ui.updateProgress.call(this, event);
+ },
+
+ // Update hover tooltip for seeking
+ updateSeekTooltip(event) {
+ // Bail if setting not true
+ if (
+ !this.config.tooltips.seek ||
+ !utils.is.htmlElement(this.elements.inputs.seek) ||
+ !utils.is.htmlElement(this.elements.display.seekTooltip) ||
+ this.duration === 0
+ ) {
+ return;
+ }
+
+ // Calculate percentage
+ const clientRect = this.elements.inputs.seek.getBoundingClientRect();
+ let percent = 0;
+ const visible = `${this.config.classNames.tooltip}--visible`;
+
+ // Determine percentage, if already visible
+ if (utils.is.event(event)) {
+ percent = 100 / clientRect.width * (event.pageX - clientRect.left);
+ } else if (utils.hasClass(this.elements.display.seekTooltip, visible)) {
+ percent = this.elements.display.seekTooltip.style.left.replace('%', '');
+ } else {
+ return;
+ }
+
+ // Set bounds
+ if (percent < 0) {
+ percent = 0;
+ } else if (percent > 100) {
+ percent = 100;
+ }
+
+ // Display the time a click would seek to
+ ui.updateTimeDisplay.call(this, this.duration / 100 * percent, this.elements.display.seekTooltip);
+
+ // Set position
+ this.elements.display.seekTooltip.style.left = `${percent}%`;
+
+ // Show/hide the tooltip
+ // If the event is a moues in/out and percentage is inside bounds
+ if (utils.is.event(event) && ['mouseenter', 'mouseleave'].includes(event.type)) {
+ utils.toggleClass(this.elements.display.seekTooltip, visible, event.type === 'mouseenter');
+ }
+ },
+};
+
+export default ui;