Merge branch 'develop' of https://github.com/Selz/plyr into develop
# Conflicts: # dist/plyr.js # dist/plyr.js.map # src/js/controls.js
This commit is contained in:
@ -79,8 +79,9 @@ const captions = {
|
||||
|
||||
// Filter doesn't seem to work for a TextTrackList :-(
|
||||
Array.from(this.captions.tracks).forEach(track => {
|
||||
if (track.language === this.captions.language.toLowerCase()) {
|
||||
if (track.language.toLowerCase() === this.language.toLowerCase()) {
|
||||
this.captions.currentTrack = track;
|
||||
console.warn(`Set current track to ${this.language}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
169
src/js/controls.js
vendored
169
src/js/controls.js
vendored
@ -75,7 +75,7 @@ const controls = {
|
||||
const use = document.createElementNS(namespace, 'use');
|
||||
const path = `${iconPath}-${type}`;
|
||||
|
||||
// If the new `href` attribute is supported, use that
|
||||
// Set `href` attributes
|
||||
// https://github.com/sampotts/plyr/issues/460
|
||||
// https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xlink:href
|
||||
if ('href' in use) {
|
||||
@ -118,6 +118,10 @@ const controls = {
|
||||
|
||||
// Create a badge
|
||||
createBadge(text) {
|
||||
if (utils.is.empty(text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const badge = utils.createElement('span', {
|
||||
class: this.config.classNames.menu.value,
|
||||
});
|
||||
@ -322,6 +326,39 @@ const controls = {
|
||||
return container;
|
||||
},
|
||||
|
||||
// Create a settings menu item
|
||||
createMenuItem(value, list, type, title, badge = null, checked = false) {
|
||||
const item = utils.createElement('li');
|
||||
|
||||
const label = utils.createElement('label', {
|
||||
class: this.config.classNames.control,
|
||||
});
|
||||
|
||||
const radio = utils.createElement(
|
||||
'input',
|
||||
utils.extend(utils.getAttributesFromSelector(this.config.selectors.inputs[type]), {
|
||||
type: 'radio',
|
||||
name: `plyr-${type}`,
|
||||
value,
|
||||
checked,
|
||||
class: 'plyr__sr-only',
|
||||
})
|
||||
);
|
||||
|
||||
const faux = utils.createElement('span', { 'aria-hidden': true });
|
||||
|
||||
label.appendChild(radio);
|
||||
label.appendChild(faux);
|
||||
label.insertAdjacentHTML('beforeend', title);
|
||||
|
||||
if (utils.is.htmlElement(badge)) {
|
||||
label.appendChild(badge);
|
||||
}
|
||||
|
||||
item.appendChild(label);
|
||||
list.appendChild(item);
|
||||
},
|
||||
|
||||
// Update hover tooltip for seeking
|
||||
updateSeekTooltip(event) {
|
||||
// Bail if setting not true
|
||||
@ -356,7 +393,7 @@ const controls = {
|
||||
}
|
||||
|
||||
// Display the time a click would seek to
|
||||
ui.updateTimeDisplay.call(this, this.duration / 100 * percent, this.elements.display.seekTooltip);
|
||||
ui.updateTimeDisplay.call(this, this.elements.display.seekTooltip, this.duration / 100 * percent);
|
||||
|
||||
// Set position
|
||||
this.elements.display.seekTooltip.style.left = `${percent}%`;
|
||||
@ -393,6 +430,7 @@ const controls = {
|
||||
// Set the YouTube quality menu
|
||||
// TODO: Support for HTML5
|
||||
setQualityMenu(options) {
|
||||
const type = 'quality';
|
||||
const list = this.elements.settings.panes.quality.querySelector('ul');
|
||||
|
||||
// Set options if passed and filter based on config
|
||||
@ -404,7 +442,7 @@ const controls = {
|
||||
|
||||
// Toggle the pane and tab
|
||||
const toggle = !utils.is.empty(this.options.quality) && this.type === 'youtube';
|
||||
controls.toggleTab.call(this, 'quality', toggle);
|
||||
controls.toggleTab.call(this, type, toggle);
|
||||
|
||||
// If we're hiding, nothing more to do
|
||||
if (!toggle) {
|
||||
@ -446,35 +484,18 @@ const controls = {
|
||||
return controls.createBadge.call(this, label);
|
||||
};
|
||||
|
||||
this.options.quality.forEach(quality => {
|
||||
const item = utils.createElement('li');
|
||||
|
||||
const label = utils.createElement('label', {
|
||||
class: this.config.classNames.control,
|
||||
});
|
||||
|
||||
const radio = utils.createElement(
|
||||
'input',
|
||||
utils.extend(utils.getAttributesFromSelector(this.config.selectors.inputs.quality), {
|
||||
type: 'radio',
|
||||
name: 'plyr-quality',
|
||||
value: quality,
|
||||
})
|
||||
this.options.quality.forEach(quality =>
|
||||
controls.createMenuItem.call(
|
||||
this,
|
||||
quality,
|
||||
list,
|
||||
type,
|
||||
controls.getLabel.call(this, 'quality', quality),
|
||||
getBadge(quality)
|
||||
)
|
||||
);
|
||||
|
||||
label.appendChild(radio);
|
||||
label.appendChild(document.createTextNode(controls.getLabel.call(this, 'quality', quality)));
|
||||
|
||||
const badge = getBadge(quality);
|
||||
if (utils.is.htmlElement(badge)) {
|
||||
label.appendChild(badge);
|
||||
}
|
||||
|
||||
item.appendChild(label);
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
controls.updateSetting.call(this, 'quality', list);
|
||||
controls.updateSetting.call(this, type, list);
|
||||
},
|
||||
|
||||
// Translate a value into a nice label
|
||||
@ -576,7 +597,7 @@ const controls = {
|
||||
},
|
||||
|
||||
// Set the looping options
|
||||
setLoopMenu() {
|
||||
/* setLoopMenu() {
|
||||
const options = ['start', 'end', 'all', 'reset'];
|
||||
const list = this.elements.settings.panes.loop.querySelector('ul');
|
||||
|
||||
@ -612,7 +633,7 @@ const controls = {
|
||||
item.appendChild(button);
|
||||
list.appendChild(item);
|
||||
});
|
||||
},
|
||||
}, */
|
||||
|
||||
// Get current selected caption language
|
||||
// TODO: rework this to user the getter in the API?
|
||||
@ -634,11 +655,13 @@ const controls = {
|
||||
|
||||
// Set a list of available captions languages
|
||||
setCaptionsMenu() {
|
||||
// TODO: Captions or language? Currently it's mixed
|
||||
const type = 'captions';
|
||||
const list = this.elements.settings.panes.captions.querySelector('ul');
|
||||
|
||||
// Toggle the pane and tab
|
||||
const toggle = !utils.is.empty(this.captions.tracks);
|
||||
controls.toggleTab.call(this, 'captions', toggle);
|
||||
controls.toggleTab.call(this, type, toggle);
|
||||
|
||||
// Empty the menu
|
||||
utils.emptyElement(list);
|
||||
@ -651,7 +674,6 @@ const controls = {
|
||||
// Re-map the tracks into just the data we need
|
||||
const tracks = Array.from(this.captions.tracks).map(track => ({
|
||||
language: track.language,
|
||||
badge: true,
|
||||
label: !utils.is.empty(track.label) ? track.label : track.language.toUpperCase(),
|
||||
}));
|
||||
|
||||
@ -663,41 +685,24 @@ const controls = {
|
||||
|
||||
// Generate options
|
||||
tracks.forEach(track => {
|
||||
const item = utils.createElement('li');
|
||||
|
||||
const label = utils.createElement('label', {
|
||||
class: this.config.classNames.control,
|
||||
});
|
||||
|
||||
const radio = utils.createElement(
|
||||
'input',
|
||||
utils.extend(utils.getAttributesFromSelector(this.config.selectors.inputs.language), {
|
||||
type: 'radio',
|
||||
name: 'plyr-language',
|
||||
value: track.language,
|
||||
})
|
||||
controls.createMenuItem.call(
|
||||
this,
|
||||
track.language,
|
||||
list,
|
||||
'language',
|
||||
track.label || track.language,
|
||||
controls.createBadge.call(this, track.language.toUpperCase()),
|
||||
track.language.toLowerCase() === this.captions.language.toLowerCase()
|
||||
);
|
||||
|
||||
if (track.language.toLowerCase() === this.captions.language.toLowerCase()) {
|
||||
radio.checked = true;
|
||||
}
|
||||
|
||||
label.appendChild(radio);
|
||||
label.appendChild(document.createTextNode(track.label || track.language));
|
||||
|
||||
if (track.badge) {
|
||||
label.appendChild(controls.createBadge.call(this, track.language.toUpperCase()));
|
||||
}
|
||||
|
||||
item.appendChild(label);
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
controls.updateSetting.call(this, 'captions', list);
|
||||
controls.updateSetting.call(this, type, list);
|
||||
},
|
||||
|
||||
// Set a list of available captions languages
|
||||
setSpeedMenu(options) {
|
||||
const type = 'speed';
|
||||
|
||||
// Set options if passed and filter based on config
|
||||
if (utils.is.array(options)) {
|
||||
this.options.speed = options.filter(speed => this.config.speed.options.includes(speed));
|
||||
@ -707,7 +712,7 @@ const controls = {
|
||||
|
||||
// Toggle the pane and tab
|
||||
const toggle = !utils.is.empty(this.options.speed);
|
||||
controls.toggleTab.call(this, 'speed', toggle);
|
||||
controls.toggleTab.call(this, type, toggle);
|
||||
|
||||
// If we're hiding, nothing more to do
|
||||
if (!toggle) {
|
||||
@ -725,39 +730,23 @@ const controls = {
|
||||
utils.emptyElement(list);
|
||||
|
||||
// Create items
|
||||
this.options.speed.forEach(speed => {
|
||||
const item = utils.createElement('li');
|
||||
|
||||
const label = utils.createElement('label', {
|
||||
class: this.config.classNames.control,
|
||||
});
|
||||
|
||||
const radio = utils.createElement(
|
||||
'input',
|
||||
utils.extend(utils.getAttributesFromSelector(this.config.selectors.inputs.speed), {
|
||||
type: 'radio',
|
||||
name: 'plyr-speed',
|
||||
value: speed,
|
||||
})
|
||||
this.options.speed.forEach(speed =>
|
||||
controls.createMenuItem.call(this, speed, list, type, controls.getLabel.call(this, 'speed', speed))
|
||||
);
|
||||
|
||||
label.appendChild(radio);
|
||||
label.insertAdjacentHTML('beforeend', controls.getLabel.call(this, 'speed', speed));
|
||||
item.appendChild(label);
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
controls.updateSetting.call(this, 'speed', list);
|
||||
controls.updateSetting.call(this, type, list);
|
||||
},
|
||||
|
||||
// Show/hide menu
|
||||
toggleMenu(event) {
|
||||
const { form } = this.elements.settings;
|
||||
const button = this.elements.buttons.settings;
|
||||
const show = utils.is.boolean(event) ? event : form && form.getAttribute('aria-hidden') === 'true';
|
||||
const show = utils.is.boolean(event)
|
||||
? event
|
||||
: utils.is.htmlElement(form) && form.getAttribute('aria-hidden') === 'true';
|
||||
|
||||
if (utils.is.event(event)) {
|
||||
const isMenuItem = form && form.contains(event.target);
|
||||
const isMenuItem = utils.is.htmlElement(form) && form.contains(event.target);
|
||||
const isButton = event.target === this.elements.buttons.settings;
|
||||
|
||||
// If the click was inside the form or if the click
|
||||
@ -774,10 +763,11 @@ const controls = {
|
||||
}
|
||||
|
||||
// Set form and button attributes
|
||||
if (button) {
|
||||
if (utils.is.htmlElement(button)) {
|
||||
button.setAttribute('aria-expanded', show);
|
||||
}
|
||||
if (form) {
|
||||
|
||||
if (utils.is.htmlElement(form)) {
|
||||
form.setAttribute('aria-hidden', !show);
|
||||
|
||||
if (show) {
|
||||
@ -885,6 +875,9 @@ const controls = {
|
||||
pane.setAttribute('aria-hidden', !show);
|
||||
tab.setAttribute('aria-expanded', show);
|
||||
pane.removeAttribute('tabindex');
|
||||
|
||||
// Focus the first item
|
||||
pane.querySelectorAll('button:not(:disabled), input:not(:disabled), [tabindex]')[0].focus();
|
||||
},
|
||||
|
||||
// Build the default HTML
|
||||
|
@ -22,13 +22,20 @@ const defaults = {
|
||||
// Pass a custom duration
|
||||
duration: null,
|
||||
|
||||
// Display the media duration
|
||||
// Display the media duration on load in the current time position
|
||||
// If you have opted to display both duration and currentTime, this is ignored
|
||||
displayDuration: true,
|
||||
|
||||
// Invert the current time to be a countdown
|
||||
invertTime: true,
|
||||
|
||||
// Clicking the currentTime inverts it's value to show time left rather than elapsed
|
||||
toggleInvert: true,
|
||||
|
||||
// Aspect ratio (for embeds)
|
||||
ratio: '16:9',
|
||||
|
||||
// Click video to play
|
||||
// Click video container to play/pause
|
||||
clickToPlay: true,
|
||||
|
||||
// Auto hide the controls
|
||||
@ -203,7 +210,7 @@ const defaults = {
|
||||
'exitfullscreen',
|
||||
'captionsenabled',
|
||||
'captionsdisabled',
|
||||
'captionchange',
|
||||
'languagechange',
|
||||
'controlshidden',
|
||||
'controlsshown',
|
||||
'ready',
|
||||
@ -276,6 +283,7 @@ const defaults = {
|
||||
isIos: 'plyr--is-ios',
|
||||
isTouch: 'plyr--is-touch',
|
||||
uiSupported: 'plyr--full-ui',
|
||||
noTransition: 'plyr--no-transition',
|
||||
menu: {
|
||||
value: 'plyr__menu__value',
|
||||
badge: 'plyr__badge',
|
||||
@ -298,6 +306,11 @@ const defaults = {
|
||||
},
|
||||
tabFocus: 'plyr__tab-focus',
|
||||
},
|
||||
|
||||
// API keys
|
||||
keys: {
|
||||
google: null,
|
||||
},
|
||||
};
|
||||
|
||||
export default defaults;
|
||||
|
@ -101,7 +101,6 @@ const listeners = {
|
||||
case 75:
|
||||
// Space and K key
|
||||
if (!held) {
|
||||
this.console.warn('togglePlay', event.type);
|
||||
this.togglePlay();
|
||||
}
|
||||
break;
|
||||
@ -119,7 +118,7 @@ const listeners = {
|
||||
case 77:
|
||||
// M key
|
||||
if (!held) {
|
||||
this.muted = 'toggle';
|
||||
this.muted = !this.muted;
|
||||
}
|
||||
break;
|
||||
|
||||
@ -145,6 +144,11 @@ const listeners = {
|
||||
}
|
||||
break;
|
||||
|
||||
case 76:
|
||||
// L key
|
||||
this.loop = !this.loop;
|
||||
break;
|
||||
|
||||
/* case 73:
|
||||
this.setLoop('start');
|
||||
break;
|
||||
@ -205,7 +209,7 @@ const listeners = {
|
||||
// Toggle controls on mouse events and entering fullscreen
|
||||
utils.on(
|
||||
this.elements.container,
|
||||
'mouseenter mouseleave mousemove touchstart touchend touchcancel touchmove enterfullscreen',
|
||||
'click mouseenter mouseleave mousemove touchmove enterfullscreen exitfullscreen',
|
||||
event => {
|
||||
this.toggleControls(event);
|
||||
}
|
||||
@ -213,11 +217,11 @@ const listeners = {
|
||||
}
|
||||
|
||||
// Handle user exiting fullscreen by escaping etc
|
||||
if (fullscreen.enabled) {
|
||||
/* if (fullscreen.enabled) {
|
||||
utils.on(document, fullscreen.eventType, event => {
|
||||
this.toggleFullscreen(event);
|
||||
});
|
||||
}
|
||||
} */
|
||||
},
|
||||
|
||||
// Listen for media events
|
||||
@ -226,7 +230,7 @@ const listeners = {
|
||||
utils.on(this.media, 'timeupdate seeking', event => ui.timeUpdate.call(this, event));
|
||||
|
||||
// Display duration
|
||||
utils.on(this.media, 'durationchange loadedmetadata', event => ui.displayDuration.call(this, event));
|
||||
utils.on(this.media, 'durationchange loadedmetadata', event => ui.durationUpdate.call(this, event));
|
||||
|
||||
// Handle the media finishing
|
||||
utils.on(this.media, 'ended', () => {
|
||||
@ -314,7 +318,7 @@ const listeners = {
|
||||
});
|
||||
|
||||
// Caption language change
|
||||
utils.on(this.media, 'captionchange', () => {
|
||||
utils.on(this.media, 'languagechange', () => {
|
||||
// Save to storage
|
||||
storage.set.call(this, { language: this.language });
|
||||
});
|
||||
@ -337,7 +341,14 @@ const listeners = {
|
||||
// Proxy events to container
|
||||
// Bubble up key events for Edge
|
||||
utils.on(this.media, this.config.events.concat(['keyup', 'keydown']).join(' '), event => {
|
||||
utils.dispatchEvent.call(this, this.elements.container, event.type, true);
|
||||
let detail = {};
|
||||
|
||||
// Get error details from media
|
||||
if (event.type === 'error') {
|
||||
detail = this.media.error;
|
||||
}
|
||||
|
||||
utils.dispatchEvent.call(this, this.elements.container, event.type, true, detail);
|
||||
});
|
||||
},
|
||||
|
||||
@ -452,11 +463,16 @@ const listeners = {
|
||||
controls.showTab.call(this, event);
|
||||
|
||||
// Settings menu items - use event delegation as items are added/removed
|
||||
// Settings - Language
|
||||
if (utils.matches(event.target, this.config.selectors.inputs.language)) {
|
||||
// Settings - Language
|
||||
proxy(event, 'language', () => {
|
||||
this.toggleCaptions(true);
|
||||
this.language = event.target.value.toLowerCase();
|
||||
const language = event.target.value;
|
||||
|
||||
this.toggleCaptions(!utils.is.empty(language));
|
||||
|
||||
if (!utils.is.empty(language)) {
|
||||
this.language = event.target.value.toLowerCase();
|
||||
}
|
||||
});
|
||||
} else if (utils.matches(event.target, this.config.selectors.inputs.quality)) {
|
||||
// Settings - Quality
|
||||
@ -468,7 +484,7 @@ const listeners = {
|
||||
proxy(event, 'speed', () => {
|
||||
this.speed = parseFloat(event.target.value);
|
||||
});
|
||||
} else if (utils.matches(event.target, this.config.selectors.buttons.loop)) {
|
||||
} /* else if (utils.matches(event.target, this.config.selectors.buttons.loop)) {
|
||||
// Settings - Looping
|
||||
// TODO: use toggle buttons
|
||||
proxy(event, 'loop', () => {
|
||||
@ -477,7 +493,7 @@ const listeners = {
|
||||
|
||||
this.console.warn('Set loop');
|
||||
});
|
||||
}
|
||||
} */
|
||||
});
|
||||
|
||||
// Seek
|
||||
@ -487,6 +503,20 @@ const listeners = {
|
||||
})
|
||||
);
|
||||
|
||||
// Current time invert
|
||||
// Only if one time element is used for both currentTime and duration
|
||||
if (this.config.toggleInvert && !utils.is.htmlElement(this.elements.display.duration)) {
|
||||
utils.on(this.elements.display.currentTime, 'click', () => {
|
||||
// Do nothing if we're at the start
|
||||
if (this.currentTime === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.config.invertTime = !this.config.invertTime;
|
||||
ui.timeUpdate.call(this);
|
||||
});
|
||||
}
|
||||
|
||||
// Volume
|
||||
utils.on(this.elements.inputs.volume, inputEvent, event =>
|
||||
proxy(event, 'volume', () => {
|
||||
@ -522,7 +552,7 @@ const listeners = {
|
||||
// TODO: Check we need capture here
|
||||
utils.on(
|
||||
this.elements.controls,
|
||||
'focus blur',
|
||||
'focusin focusout',
|
||||
event => {
|
||||
this.toggleControls(event);
|
||||
},
|
||||
|
@ -65,7 +65,6 @@ const media = {
|
||||
utils.wrap(this.media, this.elements.wrapper);
|
||||
}
|
||||
|
||||
// Embeds
|
||||
if (this.isEmbed) {
|
||||
switch (this.type) {
|
||||
case 'youtube':
|
||||
@ -79,9 +78,9 @@ const media = {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
ui.setTitle.call(this);
|
||||
}
|
||||
|
||||
ui.setTitle.call(this);
|
||||
},
|
||||
|
||||
// Cancel current network requests
|
||||
|
@ -37,7 +37,8 @@ const vimeo = {
|
||||
setAspectRatio(input) {
|
||||
const ratio = utils.is.string(input) ? input.split(':') : this.config.ratio.split(':');
|
||||
const padding = 100 / ratio[0] * ratio[1];
|
||||
const offset = (300 - padding) / 6;
|
||||
const height = 200;
|
||||
const offset = (height - padding) / (height / 50);
|
||||
this.elements.wrapper.style.paddingBottom = `${padding}%`;
|
||||
this.media.style.transform = `translateY(-${offset}%)`;
|
||||
},
|
||||
@ -55,6 +56,7 @@ const vimeo = {
|
||||
title: false,
|
||||
speed: true,
|
||||
transparent: 0,
|
||||
gesture: 'media',
|
||||
};
|
||||
const params = utils.buildUrlParameters(options);
|
||||
const id = utils.parseVimeoId(player.embedId);
|
||||
@ -70,23 +72,27 @@ const vimeo = {
|
||||
// https://github.com/vimeo/player.js
|
||||
player.embed = new window.Vimeo.Player(iframe);
|
||||
|
||||
// Create a faux HTML5 API using the Vimeo API
|
||||
player.media.play = () => {
|
||||
player.embed.play();
|
||||
player.media.paused = false;
|
||||
};
|
||||
player.media.pause = () => {
|
||||
player.embed.pause();
|
||||
player.media.paused = true;
|
||||
};
|
||||
player.media.stop = () => {
|
||||
player.embed.stop();
|
||||
player.media.paused = true;
|
||||
};
|
||||
|
||||
player.media.paused = true;
|
||||
player.media.currentTime = 0;
|
||||
|
||||
// Create a faux HTML5 API using the Vimeo API
|
||||
player.media.play = () => {
|
||||
player.embed.play().then(() => {
|
||||
player.media.paused = false;
|
||||
});
|
||||
};
|
||||
player.media.pause = () => {
|
||||
player.embed.pause().then(() => {
|
||||
player.media.paused = true;
|
||||
});
|
||||
};
|
||||
player.media.stop = () => {
|
||||
player.embed.stop().then(() => {
|
||||
player.media.paused = true;
|
||||
player.currentTime = 0;
|
||||
});
|
||||
};
|
||||
|
||||
// Seeking
|
||||
let { currentTime } = player.media;
|
||||
Object.defineProperty(player.media, 'currentTime', {
|
||||
@ -121,9 +127,10 @@ const vimeo = {
|
||||
return speed;
|
||||
},
|
||||
set(input) {
|
||||
speed = input;
|
||||
player.embed.setPlaybackRate(input);
|
||||
utils.dispatchEvent.call(player, player.media, 'ratechange');
|
||||
player.embed.setPlaybackRate(input).then(() => {
|
||||
speed = input;
|
||||
utils.dispatchEvent.call(player, player.media, 'ratechange');
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@ -134,9 +141,10 @@ const vimeo = {
|
||||
return volume;
|
||||
},
|
||||
set(input) {
|
||||
volume = input;
|
||||
player.embed.setVolume(input);
|
||||
utils.dispatchEvent.call(player, player.media, 'volumechange');
|
||||
player.embed.setVolume(input).then(() => {
|
||||
volume = input;
|
||||
utils.dispatchEvent.call(player, player.media, 'volumechange');
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@ -148,9 +156,11 @@ const vimeo = {
|
||||
},
|
||||
set(input) {
|
||||
const toggle = utils.is.boolean(input) ? input : false;
|
||||
muted = toggle;
|
||||
player.embed.setVolume(toggle ? 0 : player.config.volume);
|
||||
utils.dispatchEvent.call(player, player.media, 'volumechange');
|
||||
|
||||
player.embed.setVolume(toggle ? 0 : player.config.volume).then(() => {
|
||||
muted = toggle;
|
||||
utils.dispatchEvent.call(player, player.media, 'volumechange');
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@ -161,8 +171,11 @@ const vimeo = {
|
||||
return loop;
|
||||
},
|
||||
set(input) {
|
||||
loop = utils.is.boolean(input) ? input : player.config.loop.active;
|
||||
player.embed.setLoop(loop);
|
||||
const toggle = utils.is.boolean(input) ? input : player.config.loop.active;
|
||||
|
||||
player.embed.setLoop(toggle).then(() => {
|
||||
loop = toggle;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@ -191,6 +204,7 @@ const vimeo = {
|
||||
// Get title
|
||||
player.embed.getVideoTitle().then(title => {
|
||||
player.config.title = title;
|
||||
ui.setTitle.call(this);
|
||||
});
|
||||
|
||||
// Get current time
|
||||
@ -269,6 +283,11 @@ const vimeo = {
|
||||
utils.dispatchEvent.call(player, player.media, 'ended');
|
||||
});
|
||||
|
||||
player.embed.on('error', detail => {
|
||||
player.media.error = detail;
|
||||
utils.dispatchEvent.call(player, player.media, 'error');
|
||||
});
|
||||
|
||||
// Rebuild UI
|
||||
window.setTimeout(() => ui.build.call(player), 0);
|
||||
},
|
||||
|
@ -23,6 +23,22 @@ const youtube = {
|
||||
// Set ID
|
||||
this.media.setAttribute('id', utils.generateId(this.type));
|
||||
|
||||
// Get the media title via Google API
|
||||
const key = this.config.keys.google;
|
||||
if (utils.is.string(key) && !utils.is.empty(key)) {
|
||||
const url = `https://www.googleapis.com/youtube/v3/videos?id=${videoId}&key=${key}&fields=items(snippet(title))&part=snippet`;
|
||||
|
||||
fetch(url)
|
||||
.then(response => (response.ok ? response.json() : null))
|
||||
.then(result => {
|
||||
if (result !== null && utils.is.object(result)) {
|
||||
this.config.title = result.items[0].snippet.title;
|
||||
ui.setTitle.call(this);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Setup API
|
||||
if (utils.is.object(window.YT)) {
|
||||
youtube.ready.call(this, videoId);
|
||||
@ -81,10 +97,47 @@ const youtube = {
|
||||
},
|
||||
events: {
|
||||
onError(event) {
|
||||
utils.dispatchEvent.call(player, player.media, 'error', true, {
|
||||
// If we've already fired an error, don't do it again
|
||||
// YouTube fires onError twice
|
||||
if (utils.is.object(player.media.error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = {
|
||||
code: event.data,
|
||||
embed: event.target,
|
||||
});
|
||||
};
|
||||
|
||||
// Messages copied from https://developers.google.com/youtube/iframe_api_reference#onError
|
||||
switch (event.data) {
|
||||
case 2:
|
||||
detail.message =
|
||||
'The request contains an invalid parameter value. For example, this error occurs if you specify a video ID that does not have 11 characters, or if the video ID contains invalid characters, such as exclamation points or asterisks.';
|
||||
break;
|
||||
|
||||
case 5:
|
||||
detail.message =
|
||||
'The requested content cannot be played in an HTML5 player or another error related to the HTML5 player has occurred.';
|
||||
break;
|
||||
|
||||
case 100:
|
||||
detail.message =
|
||||
'The video requested was not found. This error occurs when a video has been removed (for any reason) or has been marked as private.';
|
||||
break;
|
||||
|
||||
case 101:
|
||||
case 150:
|
||||
detail.message =
|
||||
'The owner of the requested video does not allow it to be played in embedded players.';
|
||||
break;
|
||||
|
||||
default:
|
||||
detail.message = 'An unknown error occured';
|
||||
break;
|
||||
}
|
||||
|
||||
player.media.error = detail;
|
||||
|
||||
utils.dispatchEvent.call(player, player.media, 'error');
|
||||
},
|
||||
onPlaybackQualityChange(event) {
|
||||
// Get the instance
|
||||
@ -207,7 +260,9 @@ const youtube = {
|
||||
}
|
||||
|
||||
// Set title
|
||||
player.config.title = instance.getVideoData().title;
|
||||
if (utils.is.function(instance.getVideoData)) {
|
||||
player.config.title = instance.getVideoData().title;
|
||||
}
|
||||
|
||||
// Set the tabindex to avoid focus entering iframe
|
||||
if (player.supported.ui) {
|
||||
|
@ -669,7 +669,7 @@ class Plyr {
|
||||
const language = input.toLowerCase();
|
||||
|
||||
// If nothing to change, bail
|
||||
if (this.captions.language === language) {
|
||||
if (this.language === language) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -680,7 +680,7 @@ class Plyr {
|
||||
this.captions.language = language;
|
||||
|
||||
// Trigger an event
|
||||
utils.dispatchEvent.call(this, this.media, 'captionchange');
|
||||
utils.dispatchEvent.call(this, this.media, 'languagechange');
|
||||
|
||||
// Clear caption
|
||||
captions.set.call(this);
|
||||
@ -797,31 +797,28 @@ class Plyr {
|
||||
|
||||
// Show the player controls in fullscreen mode
|
||||
toggleControls(toggle) {
|
||||
const player = this;
|
||||
|
||||
// We need controls of course...
|
||||
if (!utils.is.htmlElement(this.elements.controls)) {
|
||||
return player;
|
||||
return this;
|
||||
}
|
||||
|
||||
// Don't hide if config says not to, it's audio, or not ready or loading
|
||||
if (!this.supported.ui || !this.config.hideControls || this.type === 'audio') {
|
||||
return player;
|
||||
return this;
|
||||
}
|
||||
|
||||
let delay = 0;
|
||||
let show = toggle;
|
||||
let isEnterFullscreen = false;
|
||||
const loading = utils.hasClass(this.elements.container, this.config.classNames.loading);
|
||||
|
||||
// Default to false if no boolean
|
||||
// Get toggle state if not set
|
||||
if (!utils.is.boolean(toggle)) {
|
||||
if (utils.is.event(toggle)) {
|
||||
// Is the enter fullscreen event
|
||||
isEnterFullscreen = toggle.type === 'enterfullscreen';
|
||||
|
||||
// Whether to show controls
|
||||
show = ['mousemove', 'touchstart', 'mouseenter', 'focus'].includes(toggle.type);
|
||||
show = ['click', 'mousemove', 'touchmove', 'mouseenter', 'focusin'].includes(toggle.type);
|
||||
|
||||
// Delay hiding on move events
|
||||
if (['mousemove', 'touchmove'].includes(toggle.type)) {
|
||||
@ -829,8 +826,9 @@ class Plyr {
|
||||
}
|
||||
|
||||
// Delay a little more for keyboard users
|
||||
if (toggle.type === 'focus') {
|
||||
if (toggle.type === 'focusin') {
|
||||
delay = 3000;
|
||||
utils.toggleClass(this.elements.controls, this.config.classNames.noTransition, true);
|
||||
}
|
||||
} else {
|
||||
show = utils.hasClass(this.elements.container, this.config.classNames.hideControls);
|
||||
@ -841,7 +839,7 @@ class Plyr {
|
||||
window.clearTimeout(this.timers.hover);
|
||||
|
||||
// If the mouse is not over the controls, set a timeout to hide them
|
||||
if (show || this.media.paused || loading) {
|
||||
if (show || this.media.paused || this.loading) {
|
||||
// Check if controls toggled
|
||||
const toggled = utils.toggleClass(this.elements.container, this.config.classNames.hideControls, false);
|
||||
|
||||
@ -851,8 +849,8 @@ class Plyr {
|
||||
}
|
||||
|
||||
// Always show controls when paused or if touch
|
||||
if (this.media.paused || loading) {
|
||||
return player;
|
||||
if (this.media.paused || this.loading) {
|
||||
return this;
|
||||
}
|
||||
|
||||
// Delay for hiding on touch
|
||||
@ -870,6 +868,11 @@ class Plyr {
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore transition behaviour
|
||||
if (!utils.hasClass(this.elements.container, this.config.classNames.hideControls)) {
|
||||
utils.toggleClass(this.elements.controls, this.config.classNames.noTransition, false);
|
||||
}
|
||||
|
||||
// Check if controls toggled
|
||||
const toggled = utils.toggleClass(this.elements.container, this.config.classNames.hideControls, true);
|
||||
|
||||
|
157
src/js/ui.js
157
src/js/ui.js
@ -15,7 +15,7 @@ const ui = {
|
||||
},
|
||||
|
||||
// Toggle native HTML5 media controls
|
||||
toggleNativeControls(toggle) {
|
||||
toggleNativeControls(toggle = false) {
|
||||
if (toggle && this.isHTML5) {
|
||||
this.media.setAttribute('controls', '');
|
||||
} else {
|
||||
@ -96,31 +96,8 @@ const ui = {
|
||||
// Ready event at end of execution stack
|
||||
utils.dispatchEvent.call(this, this.media, 'ready');
|
||||
|
||||
// Autoplay
|
||||
// TODO: check we still need this?
|
||||
/* if (this.isEmbed && 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.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)
|
||||
controls.updateSeekTooltip.call(this);
|
||||
// Set the title
|
||||
ui.setTitle.call(this);
|
||||
},
|
||||
|
||||
// Setup aria attribute for play and iframe title
|
||||
@ -137,13 +114,10 @@ const ui = {
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
if (utils.is.nodeList(this.elements.buttons.play)) {
|
||||
Array.from(this.elements.buttons.play).forEach(button => {
|
||||
button.setAttribute('aria-label', label);
|
||||
});
|
||||
}
|
||||
|
||||
// Set iframe title
|
||||
@ -171,23 +145,6 @@ const ui = {
|
||||
this.toggleControls(this.paused);
|
||||
},
|
||||
|
||||
// Update volume UI and storage
|
||||
updateVolume() {
|
||||
if (!this.supported.ui) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update range
|
||||
if (utils.is.htmlElement(this.elements.inputs.volume)) {
|
||||
ui.setRange.call(this, this.elements.inputs.volume, this.muted ? 0 : this.volume);
|
||||
}
|
||||
|
||||
// Update checkbox for mute state
|
||||
if (utils.is.htmlElement(this.elements.buttons.mute)) {
|
||||
utils.toggleState(this.elements.buttons.mute, this.muted || this.volume === 0);
|
||||
}
|
||||
},
|
||||
|
||||
// Check if media is loading
|
||||
checkLoading(event) {
|
||||
this.loading = event.type === 'waiting';
|
||||
@ -205,8 +162,25 @@ const ui = {
|
||||
}, this.loading ? 250 : 0);
|
||||
},
|
||||
|
||||
// Update volume UI and storage
|
||||
updateVolume() {
|
||||
if (!this.supported.ui) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update range
|
||||
if (utils.is.htmlElement(this.elements.inputs.volume)) {
|
||||
ui.setRange.call(this, this.elements.inputs.volume, this.muted ? 0 : this.volume);
|
||||
}
|
||||
|
||||
// Update checkbox for mute state
|
||||
if (utils.is.htmlElement(this.elements.buttons.mute)) {
|
||||
utils.toggleState(this.elements.buttons.mute, this.muted || this.volume === 0);
|
||||
}
|
||||
},
|
||||
|
||||
// Update seek value and lower fill
|
||||
setRange(target, value) {
|
||||
setRange(target, value = 0) {
|
||||
if (!utils.is.htmlElement(target)) {
|
||||
return;
|
||||
}
|
||||
@ -220,9 +194,8 @@ const ui = {
|
||||
|
||||
// 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;
|
||||
const value = utils.is.number(input) ? input : 0;
|
||||
const progress = utils.is.htmlElement(target) ? target : this.elements.display.buffer;
|
||||
|
||||
// Update value and label
|
||||
if (utils.is.htmlElement(progress)) {
|
||||
@ -238,7 +211,7 @@ const ui = {
|
||||
|
||||
// Update <progress> elements
|
||||
updateProgress(event) {
|
||||
if (!this.supported.ui) {
|
||||
if (!this.supported.ui || !utils.is.event(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -286,41 +259,49 @@ const ui = {
|
||||
},
|
||||
|
||||
// Update the displayed time
|
||||
updateTimeDisplay(value, element) {
|
||||
// Bail if there's no duration display
|
||||
if (!utils.is.htmlElement(element)) {
|
||||
return null;
|
||||
updateTimeDisplay(target = null, time = 0, inverted = false) {
|
||||
// Bail if there's no element to display or the value isn't a number
|
||||
if (!utils.is.htmlElement(target) || !utils.is.number(time)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to 0
|
||||
const time = !Number.isNaN(value) ? value : 0;
|
||||
// Format time component to add leading zero
|
||||
const format = value => `0${value}`.slice(-2);
|
||||
|
||||
let secs = parseInt(time % 60, 10);
|
||||
let mins = parseInt((time / 60) % 60, 10);
|
||||
const hours = parseInt((time / 60 / 60) % 60, 10);
|
||||
// Helpers
|
||||
const getHours = value => parseInt((value / 60 / 60) % 60, 10);
|
||||
const getMinutes = value => parseInt((value / 60) % 60, 10);
|
||||
const getSeconds = value => parseInt(value % 60, 10);
|
||||
|
||||
// Breakdown to hours, mins, secs
|
||||
let hours = getHours(time);
|
||||
const mins = getMinutes(time);
|
||||
const secs = getSeconds(time);
|
||||
|
||||
// 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}`;
|
||||
if (getHours(this.duration) > 0) {
|
||||
hours = `${hours}:`;
|
||||
} else {
|
||||
hours = '';
|
||||
}
|
||||
|
||||
// Render
|
||||
// eslint-disable-next-line
|
||||
element.textContent = display;
|
||||
|
||||
// Return for looping
|
||||
return display;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
target.textContent = `${inverted ? '-' : ''}${hours}${format(mins)}:${format(secs)}`;
|
||||
},
|
||||
|
||||
// Handle time change event
|
||||
timeUpdate(event) {
|
||||
// Only invert if only one time element is displayed and used for both duration and currentTime
|
||||
const invert = !utils.is.htmlElement(this.elements.display.duration) && this.config.invertTime;
|
||||
|
||||
// Duration
|
||||
ui.updateTimeDisplay.call(this, this.currentTime, this.elements.display.currentTime);
|
||||
ui.updateTimeDisplay.call(
|
||||
this,
|
||||
this.elements.display.currentTime,
|
||||
invert ? this.duration - this.currentTime : this.currentTime,
|
||||
invert
|
||||
);
|
||||
|
||||
// Ignore updates while seeking
|
||||
if (event && event.type === 'timeupdate' && this.media.seeking) {
|
||||
@ -330,6 +311,26 @@ const ui = {
|
||||
// Playing progress
|
||||
ui.updateProgress.call(this, event);
|
||||
},
|
||||
|
||||
// Show the duration on metadataloaded
|
||||
durationUpdate() {
|
||||
if (!this.supported.ui) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there's only one time display, display duration there
|
||||
if (!utils.is.htmlElement(this.elements.display.duration) && this.config.displayDuration && this.paused) {
|
||||
ui.updateTimeDisplay.call(this, this.elements.display.currentTime, this.duration);
|
||||
}
|
||||
|
||||
// If there's a duration element, update content
|
||||
if (utils.is.htmlElement(this.elements.display.duration)) {
|
||||
ui.updateTimeDisplay.call(this, this.elements.display.duration, this.duration);
|
||||
}
|
||||
|
||||
// Update the tooltip (if visible)
|
||||
controls.updateSeekTooltip.call(this);
|
||||
},
|
||||
};
|
||||
|
||||
export default ui;
|
||||
|
@ -31,6 +31,9 @@ const utils = {
|
||||
htmlElement(input) {
|
||||
return !this.undefined(input) && input instanceof HTMLElement;
|
||||
},
|
||||
textNode(input) {
|
||||
return this.getConstructor(input) === Text;
|
||||
},
|
||||
event(input) {
|
||||
return !this.undefined(input) && input instanceof Event;
|
||||
},
|
||||
@ -49,8 +52,8 @@ const utils = {
|
||||
return (
|
||||
input === null ||
|
||||
typeof input === 'undefined' ||
|
||||
((this.string(input) || this.array(input) || this.nodeList(input)) && input.length === 0) ||
|
||||
(this.object(input) && Object.keys(input).length === 0)
|
||||
((this.string(input) || this.array(input) || this.nodeList(input)) && !input.length) ||
|
||||
(this.object(input) && !Object.keys(input).length)
|
||||
);
|
||||
},
|
||||
getConstructor(input) {
|
||||
@ -100,12 +103,12 @@ const utils = {
|
||||
|
||||
// Load an external SVG sprite
|
||||
loadSprite(url, id) {
|
||||
if (typeof url !== 'string') {
|
||||
if (!utils.is.string(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prefix = 'cache-';
|
||||
const hasId = typeof id === 'string';
|
||||
const hasId = utils.is.string(id);
|
||||
let isCached = false;
|
||||
|
||||
function updateSprite(data) {
|
||||
@ -134,34 +137,30 @@ const utils = {
|
||||
if (isCached) {
|
||||
const data = JSON.parse(cached);
|
||||
updateSprite.call(container, data.content);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
const xhr = new XMLHttpRequest();
|
||||
// Get the sprite
|
||||
fetch(url)
|
||||
.then(response => (response.ok ? response.text() : null))
|
||||
.then(text => {
|
||||
if (text === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// XHR for Chrome/Firefox/Opera/Safari
|
||||
if ('withCredentials' in xhr) {
|
||||
xhr.open('GET', url, true);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (support.storage) {
|
||||
window.localStorage.setItem(
|
||||
prefix + id,
|
||||
JSON.stringify({
|
||||
content: text,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Once loaded, inject to container and body
|
||||
xhr.onload = () => {
|
||||
if (support.storage) {
|
||||
window.localStorage.setItem(
|
||||
prefix + id,
|
||||
JSON.stringify({
|
||||
content: xhr.responseText,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updateSprite.call(container, xhr.responseText);
|
||||
};
|
||||
|
||||
xhr.send();
|
||||
updateSprite.call(container, text);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
@ -210,22 +209,6 @@ const utils = {
|
||||
});
|
||||
},
|
||||
|
||||
// Remove an element
|
||||
removeElement(element) {
|
||||
if (!utils.is.htmlElement(element) || !utils.is.htmlElement(element.parentNode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
element.parentNode.removeChild(element);
|
||||
|
||||
return element;
|
||||
},
|
||||
|
||||
// Inaert an element after another
|
||||
insertAfter(element, target) {
|
||||
target.parentNode.insertBefore(element, target.nextSibling);
|
||||
},
|
||||
|
||||
// Create a DocumentFragment
|
||||
createElement(type, attributes, text) {
|
||||
// Create a new <element>
|
||||
@ -245,12 +228,28 @@ const utils = {
|
||||
return element;
|
||||
},
|
||||
|
||||
// Inaert an element after another
|
||||
insertAfter(element, target) {
|
||||
target.parentNode.insertBefore(element, target.nextSibling);
|
||||
},
|
||||
|
||||
// Insert a DocumentFragment
|
||||
insertElement(type, parent, attributes, text) {
|
||||
// Inject the new <element>
|
||||
parent.appendChild(utils.createElement(type, attributes, text));
|
||||
},
|
||||
|
||||
// Remove an element
|
||||
removeElement(element) {
|
||||
if (!utils.is.htmlElement(element) || !utils.is.htmlElement(element.parentNode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
element.parentNode.removeChild(element);
|
||||
|
||||
return element;
|
||||
},
|
||||
|
||||
// Remove all child elements
|
||||
emptyElement(element) {
|
||||
let { length } = element.childNodes;
|
||||
@ -442,9 +441,9 @@ const utils = {
|
||||
|
||||
// Trap focus inside container
|
||||
trapFocus() {
|
||||
const tabbables = utils.getElements.call(this, 'button:not(:disabled), input:not(:disabled), [tabindex]');
|
||||
const first = tabbables[0];
|
||||
const last = tabbables[tabbables.length - 1];
|
||||
const focusable = utils.getElements.call(this, 'button:not(:disabled), input:not(:disabled), [tabindex]');
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
utils.on(
|
||||
this.elements.container,
|
||||
@ -525,7 +524,7 @@ const utils = {
|
||||
},
|
||||
|
||||
// Trigger event
|
||||
dispatchEvent(element, type, bubbles, properties) {
|
||||
dispatchEvent(element, type, bubbles, detail) {
|
||||
// Bail if no element
|
||||
if (!element || !type) {
|
||||
return;
|
||||
@ -534,7 +533,7 @@ const utils = {
|
||||
// Create and dispatch the event
|
||||
const event = new CustomEvent(type, {
|
||||
bubbles: utils.is.boolean(bubbles) ? bubbles : false,
|
||||
detail: Object.assign({}, properties, {
|
||||
detail: Object.assign({}, detail, {
|
||||
plyr: this instanceof Plyr ? this : null,
|
||||
}),
|
||||
});
|
||||
|
Reference in New Issue
Block a user