'use strict';
(function() {
const style = document.createElement('template');
style.innerHTML = `
`;
const icons = document.createElement('template');
icons.innerHTML = `
`;
/*
* Parse the GET parameters in the URL bar into an Object, mapping keys
* to values. Will not behave properly on repeated keys.
*
* Recognizes two nested levels of key-value pairs:
* key1=value1&key2=value2 (standard GET syntax)
* nestedKey1:nestedValue1;nestedKey2:nestedValue2 (custom extension)
*
* This would be a full query string with a nested key-value mapping:
* key1=nestedkey1:nestedvalue1;nestedkey2:nestedvalue2&key2=value2
*
* The Object has a toString() method which returns the parameters in the
* same format as `window.location.search`.
*/
function parseGETParameters(presID) {
const get = window.location.search.substring(1);
const out = {};
get.split('&').forEach((arg) => {
if(arg === '') {
return;
}
const split = arg.split('=');
const name = split[0];
var value = null;
if(name === presID) {
value = {};
split[1].split(';').forEach((localarg) => {
const [localkey, localvalue] = localarg.split(':');
value[localkey] = localvalue;
});
} else if(split.length > 1) {
value = split[1];
}
out[name] = value;
});
out.toString = function() {
// Return a properly formatted search string
const result = [];
Object.keys(this).forEach((key) => {
if(key === 'toString') {
return;
}
if(key === presID) {
const localresult = [];
Object.keys(this[key]).forEach((localkey) => {
localresult.push(localkey + ':'
+ this[key][localkey]);
});
result.push(key + '=' + localresult.join(';'));
} else if(this[key]) {
result.push(key + '=' + this[key]);
} else {
result.push(key);
}
});
return '?' + result.join('&');
}
return out;
}
function createElementNS(ns, tagName, attributes={}, children=[]) {
const elem = document.createElementNS(ns, tagName);
Object.entries(attributes).forEach(([key, value]) => {
elem.setAttribute(key, value);
});
if(children) {
for(let i=0; i0) {
icon.setAttribute('class', 'hidden');
}
alternatives.push(icon);
}
if(attributes.viewBox === undefined) {
attributes.viewBox = "0 0 24 24";
}
return createSVGElement('svg', attributes, alternatives);
}
function createChoiceList(name, title,
choiceList=[], defaultIndex=0) {
const wrapper = createElement('div', {'id': `${name}-select`,
'class': 'select'});
if(choiceList.length === 0) {
return wrapper;
}
wrapper.createChild('button',
{'id': `${name}-current`,
'title': title},
[choiceList[defaultIndex]]);
const list = wrapper.createChild('ul', {'id': `${name}-list`,
'class': 'list'});
for(let i=0; i response.json())
.then((data) => this.initPresentation(data));
break;
case 'list':
if(newValue === "" || newValue === null) {
this.initPlaylist(null);
}
fetch(newValue)
.then((response) => response.json())
.then((data) => this.initPlaylist(data));
break;
case 'play-local':
if(newValue === "" || newValue === null) {
return;
}
this.initPresentation(JSON.parse(newValue));
break;
case 'list-local':
if(newValue === "" || newValue === null) {
this.initPlaylist(null);
}
this.initPlaylist(JSON.parse(newValue));
break;
case 'nomunge':
if(newValue) {
this._mungeSources = false;
} else {
this._mungeSources = true;
}
break;
case 'timelink':
this.toggleTimelink(newValue);
break;
}
}
constructor() {
super();
// Controls whether to prefix a random subdomain to each remote
// source in order to work around the browser limit of ~6
// connections per host. On by default.
// Controlled by the 'nomunge' attribute on this element.
this._mungeSources = true;
// raw presentation json is stored here
this._presentation = null;
// all sources to be played back
this._sources = [];
// the controlling video element
// other video elements will sync with this source
this._controlSource = null;
// the source that plays the audio
this._audioSource = null;
// the main playback source at any given time
this._mainSource = null;
// the element wrapping the playlist sidebar items
this._playlist = null;
// the playlist title element
this._playlistTitle = null;
// list of elements to show/hide based on playlist presence
this._playlistUI = [];
// list of stream overlay icons for play/pause
this._overlayPlayIcons = [];
// the container element for the progress bar
this._progressContainer = null;
// the element displaying buffer state
this._progressBuffer = null;
// the actual progress bar
this._progressBar = null;
// the popup displaying time information when hovering on
// the progress bar
this._progressPopup = null;
// all subtitle selection buttons
this._subtitleChoices = [];
// all resolution selection buttons
this._resolutionChoices = [];
// the mute toggle
this._volumeButton = null;
// the volume slider
this._volumeSlider = null;
// the button to copy link to current time in the presentation
this._timelinkButton = null;
// the current UI language
this._lang = 'en';
const html = document.querySelector('html');
if(html.lang) {
this._lang = html.lang;
}
// localization strings
this._i18n = {
//all i18n keys are english already, no translations needed.
'en': {},
'sv': {
'Show playlist': 'Visa spellista',
'Hide playlist': 'Dölj spellista',
'Previous': 'Föregående',
'Next': 'Nästa',
'Play': 'Spela',
'Pause': 'Pausa',
'Speed selection': 'Välj hastighet',
'Resolution selection': 'Välj upplösning',
'Mute': 'Stäng av ljud',
'Unmute': 'Slå på ljud',
'Copy URL at current time':
'Kopiera länk till den här tidpunkten',
'Fullscreen': 'Helskärm',
'Leave fullscreen': 'Lämna helskärm',
'Subtitles are off': 'Undertexter är av',
'Subtitles are on': 'Undertexter är på',
'Off': 'Av'
}
};
// prepare shadow root
this.attachShadow({'mode': 'open'});
this.shadowRoot.appendChild(style.content.cloneNode(true));
this.shadowRoot.appendChild(icons.content.cloneNode(true));
// initialize player skeleton
this._root = createElement('div', {'id': 'player-root'});
this._viewport = this._root.createChild('div', {'id': 'viewport'});
const sidebar = this._root.createChild(
'div', {'id': 'sidebar', 'class': 'hidden'});
this.initSidebar(sidebar);
this._playlistUI.push(sidebar);
const controlRoot = this._root.createChild('div',
{'id': 'controls'});
this._progressContainer = controlRoot.createChild(
'div', {'id': 'progress-container'})
this._progressBuffer = this._progressContainer.createChild(
'canvas', {'id': 'buffer'});
this._progressBar = this._progressContainer.createChild(
'div', {'id': 'progress'});
this._progressPopup = this._progressContainer.createChild(
'div', {'id': 'progress-popup'});
this.initLeftControls(controlRoot.createChild(
'div', {'id': 'left-controls', 'class': 'control-box'}));
this.initRightControls(controlRoot.createChild(
'div', {'id': 'right-controls', 'class': 'control-box'}));
this._spinner = this._root.createChild(
'div', {'id': 'loading-overlay'},
[createIcon(['loading'])]);
// set up hiding of mouse pointer and controls
const hide = () => {
if(this._presTitle.classList.contains('hidden')) {
return;
}
if(!this._root.classList.contains('nocursor')) {
this._root.classList.add('nocursor');
}
}
let timer = null;
const reveal = () => {
if(timer) {
window.clearTimeout(timer);
}
if(this._root.classList.contains('nocursor')) {
this._root.classList.remove('nocursor');
}
if(this._presTitle.classList.contains('hidden')) {
return;
}
const hover = this._root.querySelectorAll(
'button:hover, input:hover');
if(hover.length > 0) {
return;
}
timer = window.setTimeout(hide, 2500);
}
this._root.addEventListener('mousemove', reveal);
this._root.addEventListener('mouseleave', hide);
this._root.addEventListener('keyup', (event) => {
if(event.key === 'Tab') {
reveal();
}
});
this.initKeyboard();
// attach the shadow root
this.shadowRoot.append(this._root);
}
/*
* Get the appropriate translation of key for the language
* stored in this._lang.
*/
i18n(key) {
// Since the keys are in english, the key is simply returned if
// this._lang is 'en'.
if(this._lang == 'en') {
return key;
}
// If we don't have a translation for the language, use english
if(!(this._lang in this._i18n)) {
return key;
}
return this._i18n[this._lang][key];
}
/*
* Prepare the sidebar for playlist info
*/
initSidebar(parent) {
const listButton = parent.createChild(
'button',
{'id': 'playlist-button',
'title': this.i18n('Show playlist'),
'data-title_alt': this.i18n('Hide playlist')},
[createIcon(['playlist', 'close'])]);
this._presTitle = parent.createChild('h1', {'id': 'title'});
this._playlistTitle = parent.createChild(
'h1', {'id': 'playlist-title', 'class': 'playlist hidden'});
this._playlist = parent.createChild(
'ul', {'id': 'playlist', 'class': 'playlist hidden'});
listButton.addEventListener('click', (event) => {
const elems = [this._playlist,
this._presTitle,
this._playlistTitle];
elems.forEach((elem) => elem.classList.toggle('hidden'));
parent.classList.toggle('open');
this.toggleButtonState(listButton);
});
return parent;
}
/*
* Prepare controls anchored to the left part of the viewport
*/
initLeftControls(parent) {
const switchPresentation = (event) => {
const current = this._playlist.querySelector('.current');
let other = null;
if(event.currentTarget.id === 'previous') {
other = current.previousElementSibling;
} else {
other = current.nextElementSibling;
}
// this will require refactoring for local playback
this.setAttribute('play', other.querySelector('a').href);
}
const prevButton = parent.createChild(
'button', {'id': 'previous',
'class': 'playlist hidden',
'title': this.i18n('Previous')},
[createIcon(['previous'])]);
prevButton.addEventListener('click', switchPresentation);
this._playlistUI.push(prevButton);
this._playButton = parent.createChild(
'button', {'id': 'play-button',
'title': this.i18n('Play'),
'data-title_alt': this.i18n('Pause')},
[createIcon(['play', 'pause'])]);
this._playButton.addEventListener('click', (event) => {
// since the main stream may overlap the play button
event.stopPropagation();
this.togglePlayback();
});
const nextButton = parent.createChild(
'button', {'id': 'next',
'class': 'playlist hidden',
'title': this.i18n('Next')},
[createIcon(['next'])]);
nextButton.addEventListener('click', switchPresentation);
this._playlistUI.push(nextButton);
const speedSelect = parent.appendChild(
createChoiceList('speed',
this.i18n('Speed selection'),
['2',
'1.75',
'1.5',
'1.25',
'1',
'0.75'],
4));
const currentSpeed = speedSelect.querySelector('#speed-current');
const speedButtons = speedSelect.querySelectorAll(
'#speed-list button');
speedButtons.forEach((button) => {
button.addEventListener('click', (event) => {
const speed = event.currentTarget.textContent;
if(speed === currentSpeed.textContent) {
return;
}
currentSpeed.textContent = speed;
this._sources.forEach((source) => {
source.playbackRate = speed;
});
});
});
this._volumeButton = parent.createChild(
'button',
{'id': 'volume-button',
'title': this.i18n('Mute'),
'data-title_alt': this.i18n('Unmute')},
[createIcon(['volume', 'mute'])]);
this._volumeSlider = parent.createChild('input',
{'id': 'volume',
'type': 'range',
'min': '0',
'max': '1',
'step': '0.01'});
this._volumeSlider.value = 1;
this._volumeButton.addEventListener('click', (event) => {
const audio = this._audioSource;
if(!audio.muted) {
this._volumeSlider.value = 0;
audio.muted = true;
localStorage.setItem('multi-player-muted', true);
} else {
this._volumeSlider.value = audio.volume;
audio.muted = false;
localStorage.removeItem('multi-player-muted');
}
this.toggleButtonState(this._volumeButton);
});
this._volumeSlider.addEventListener('input', (event) => {
const volume = this._volumeSlider.valueAsNumber;
this._audioSource.volume = volume;
if(this._audioSource.muted === true) {
this._volumeButton.click();
}
localStorage.setItem('multi-player-volume', volume);
});
this._elapsed = parent.createChild('span', {'id': 'elapsed'});
parent.createChild('span', {'id': 'of'}, ['/']);
this._duration = parent.createChild('span', {'id': 'duration'});
}
/*
* Prepare controls anchored to the right part of the viewport
*/
initRightControls(parent) {
this._timelinkButton = parent.createChild(
'button',
{'id': 'timelink-button',
'title': this.i18n('Copy URL at current time')},
[createIcon(['timelink'])]);
this._timelinkButton.addEventListener('click', (event) => {
const params = parseGETParameters(this._presentation.id);
if(!params[this._presentation.id]) {
params[this._presentation.id] = {};
}
const localargs = params[this._presentation.id];
// Add time argument
const time = this._mainSource.currentTime;
localargs['t'] = time.toFixed(1);
// Save to clipboard
const url = window.location.href.split('?', 1)
+ params.toString()
navigator.clipboard.writeText(url);
});
// setSubtitles() requires this._subtitlesSelect to
// be in the correct position in the DOM already,
// so creating a dummy
this._subtitlesSelect = parent.createChild('button',
{'class': 'hidden'},
['placeholder']);
// setResolutions() requires this._resolutionSelect to
// be in the correct position in the DOM already,
// so creating a dummy
this._resolutionSelect = parent.createChild('button',
{},
['placeholder']);
const fullscreenButton = parent.createChild(
'button',
{'id': 'fullscreen-button',
'title': this.i18n('Fullscreen'),
'data-title_alt': this.i18n('Leave fullscreen')},
[createIcon(['fullscreen-enter',
'fullscreen-exit'])]);
fullscreenButton.addEventListener('click', (event) => {
if(document.fullscreenElement) {
document.exitFullscreen()
} else {
this._root.requestFullscreen();
}
this.toggleButtonState(fullscreenButton);
});
}
/*
* Finalize setup for playback based on presentation data.
*/
initPresentation(presentation) {
const parent = this._viewport;
//clear out any existing streams
while(parent.hasChildNodes()) {
parent.removeChild(parent.firstChild());
}
this._sources = [];
this._overlayPlayIcons = [];
// initialize UI elements
this._presentation = presentation;
this._presTitle.textContent = presentation.title;
this._elapsed.textContent = this.formatTime(0);
this._duration.textContent = this.formatTime(
presentation.duration);
const streams = presentation.sources;
const sourcenames = Object.keys(streams);
// sorting the source names so they get a predictable ordering
sourcenames.sort();
const resolutions = Object.keys(streams[sourcenames[0]].video);
const defaultRes = 0;
const token = presentation.token;
this.setResolutions(resolutions, defaultRes);
// create streams
for(var i = 0; i < sourcenames.length; ++i) {
const key = sourcenames[i];
const stream = streams[key];
// skip setting up the stream if it is explicitly
// configured to be hidden.
if(stream['enabled'] === false) {
continue;
}
const streamRoot = parent.createChild('div',
{'class': 'stream'});
const video = streamRoot.createChild(
'video',
{'playsinline': 1,
'preload': 'auto',
'crossorigin': 'anonymous'});
// append token to each resolution and save it in the
// video element's dataset
Object.entries(stream.video).forEach(([resolution, src]) => {
let tokenized = `${src}?token=${token}`;
if(this._mungeSources) {
// In order to work around the browser's limit of ~6
// simultaneous connections per server, prepend a
// random subdomain to the source if it is remote.
const prefix = randString(6);
tokenized = src.replace(/^(https?:\/\/)(.*)/,
`$1${prefix}.$2`);
}
video.dataset[resolution] = tokenized;
});
// pick the default resolution from the dataset and use it
// as initial src
video.src = video.dataset[resolutions[defaultRes]];
// subs, if present
if(presentation.subtitles) {
for(const key in presentation.subtitles) {
const file = presentation.subtitles[key];
video.createChild(
'track',
{'kind': 'subtitles',
'label': key,
'src': `${file}?token=${token}`});
}
}
//misc details
video.muted = !stream.playAudio;
video.poster = stream.poster;
video.load();
this._sources.push(video);
// set up overlay icons
const switchIcon = streamRoot.appendChild(
createIcon(['switch'], {'class': 'switch fade'}));
const playIcon = streamRoot.appendChild(
createIcon(['play', 'pause'], {'class': 'play fade'}));
if(stream.playAudio) {
streamRoot.classList.add('main');
switchIcon.classList.add('hidden');
// kepp a reference to the control source around
this._controlSource = video;
// keep a reference to the main source around
this._mainSource = video;
// keep a reference to the source that plays audio
this._audioSource = video;
} else {
playIcon.classList.add('hidden');
}
// set up stream listeners
streamRoot.addEventListener('click', (event) => {
if(event.currentTarget.classList.contains('main')) {
// this is the main source, toggle playback
this.togglePlayback();
} else {
// this is a secondary source, switch it to main
this.switchMainSource(
event.currentTarget.querySelector('video'));
}
});
}
if(presentation.subtitles) {
this.setSubtitles(Object.keys(presentation.subtitles));
} else {
this._subtitlesSelect.classList.add('hidden');
}
this.setActivePlaylistItem();
this.autoBlurControls();
this.initProgressContainer();
this.initBuffer();
this.initSpinner();
this.recallSettings();
}
/*
* Populate playlist UI based on playlist data.
*/
initPlaylist(list) {
const playlist = this._playlist;
// clean up first
this._playlistTitle.textContent = '';
while(playlist.hasChildNodes()) {
playlist.removeChild(playlist.lastChild);
}
this.showPlaylistUI(false);
// is there even a playlist?
if(list === null) {
return;
}
// set up
this._playlistTitle.textContent = list.title;
for(let i=0; i {
const bounds =
this._progressContainer.getBoundingClientRect();
const xRel = event.clientX - bounds.x;
const fraction = xRel / this._progressContainer.clientWidth;
const time = fraction * this._controlSource.duration;
popup.textContent = this.formatTime(time);
const margin = popup.clientWidth / 2;
const xmax = this._progressContainer.clientWidth - margin;
let pos = xRel;
if(pos < margin) {
pos = margin;
} else if(pos > xmax) {
pos = xmax;
}
popup.style.left = pos + 'px';
});
// Enable seeking
this._progressContainer.addEventListener('click', (event) => {
const bounds =
this._progressContainer.getBoundingClientRect();
const xRel = event.clientX - bounds.x;
const fraction = xRel / this._progressContainer.clientWidth;
const time = fraction * this._controlSource.duration;
this.setTime(time);
});
// Initialize progressbar
this._controlSource.addEventListener('timeupdate', (event) => {
const elapsed = this._controlSource.currentTime;
this._elapsed.textContent = this.formatTime(elapsed);
const progressWidth = (
elapsed
/ this._controlSource.duration
* this._progressContainer.clientWidth);
this._progressBar.style.width = progressWidth + 'px';
});
}
/*
* Initialize display of buffered segments in the progress bar.
*/
initBuffer() {
const color = window.getComputedStyle(this._root)
.getPropertyValue('--foreground');
const buffer = this._progressBuffer;
const control = this._controlSource;
let needsUpdate = false;
function paintBuffer() {
if(needsUpdate) {
buffer.width = buffer.clientWidth;
buffer.height = buffer.clientHeight;
const context = buffer.getContext('2d');
context.clearRect(0, 0, buffer.width, buffer.height);
context.fillStyle = color;
context.strokeStyle = color;
const inc = buffer.width / control.duration;
const buffered = control.buffered;
for(let i = 0; i < buffered.length; ++i) {
const start = buffered.start(i) * inc;
const end = buffered.end(i) * inc;
const width = end - start;
context.fillRect(start, 0, width, buffer.height);
}
needsUpdate = false;
}
requestAnimationFrame(paintBuffer);
}
function flagUpdate(event) {
needsUpdate = true;
}
control.addEventListener('progress', flagUpdate);
window.addEventListener('resize', flagUpdate);
window.requestAnimationFrame(paintBuffer);
}
/*
* Initialize dynamic showing of spinner based on buffering state
*/
initSpinner() {
let buffering = new Set();
let tryHiding = () => {
// hide the spinner when all streams are done buffering
if(buffering.size === 0) {
this._spinner.classList.add('hidden');
// modifying z-index so controls are still usable when
// buffering mid-playback
this._spinner.style.zIndex = 1;
}
}
this._sources.forEach((source) => {
// show the spinner when playback is stalled
source.addEventListener('waiting', (event) => {
buffering.add(source);
this._spinner.classList.remove('hidden');
});
source.addEventListener('canplay', (event) => {
buffering.delete(source);
tryHiding();
});
});
}
/*
* Set up keyboard bindings.
*/
initKeyboard() {
this._root.addEventListener('keydown', (event) => {
switch(event.key) {
case " ":
this.togglePlayback();
break;
case "ArrowRight":
if(event.target === this._volumeSlider) {
break;
}
let ahead = this._controlSource.currentTime + 5;
if(ahead > this._controlSource.duration) {
ahead = this._controlSource.duration;
}
this.setTime(ahead);
break;
case "ArrowLeft":
if(event.target === this._volumeSlider) {
break;
}
let back = this._controlSource.currentTime - 5;
if(back < 0) {
back = 0;
}
this.setTime(back);
break;
}
});
}
/*
* Apply any saved settings from previous usage
*/
recallSettings() {
// Set subtitles state
const savedState = localStorage.getItem(
'multi-player-subs-state');
if(savedState === 'showing') {
const savedTrack = localStorage.getItem(
'multi-player-subs-track');
let found = false;
this._subtitleChoices.forEach((button) => {
if(savedTrack === button.textContent) {
button.click();
found = true;
}
});
if(!found && this._subtitleChoices.length != 0) {
this._subtitleChoices[0].click();
}
}
// Apply saved volume
const savedLevel = localStorage.getItem('multi-player-volume');
if(savedLevel !== null) {
this._volumeSlider.value = savedLevel;
this._audioSource.volume = savedLevel;
}
// Apply saved mute state
const muted = localStorage.getItem('multi-player-muted');
if(muted) {
this._volumeButton.click();
}
// Apply saved resolution choice
const savedResolution = localStorage.getItem(
'multi-player-resolution');
if(savedResolution) {
this._resolutionChoices.forEach((button) => {
if(button.textContent === savedResolution) {
button.click();
}
});
}
// Apply start time if applicable
const args = parseGETParameters(this._presentation.id);
if(args[this._presentation.id]) {
const localargs = args[this._presentation.id];
if(localargs['t']) {
this._controlSource.currentTime = localargs['t'];
}
}
}
/*
* Update the playlist to reflect which item is playing
*
* Playlist data and the active item are pulled from this._playlist
* and this._presentation respectively.
*/
setActivePlaylistItem() {
if(this._presentation === null) {
return;
}
this._playlist.childNodes.forEach((item) => {
if(item.dataset.id === this._presentation.id
&& !item.classList.contains('current')) {
item.classList.add('current');
}
if(item.classList.contains('current')
&& item.dataset.id !== this._presentation.id) {
item.classList.remove('current');
}
});
}
/*
* Show/hide playlist UI
*
* The desired state is given by the boolean arg 'visible'.
*/
showPlaylistUI(visible) {
this._playlistUI.forEach((element) => {
if(visible && element.classList.contains('hidden')) {
element.classList.remove('hidden');
} else if(!visible && !element.classList.contains('hidden')) {
element.classList.add('hidden');
}
});
}
/*
* Populate the list of resolution choices.
*
* The available resolutions are passed in the 'resolutions' arg,
* and a default may optionally be specified.
*/
setResolutions(resolutions, defaultIndex=0) {
const select = createChoiceList(
'resolution',
this.i18n('Resolution selection'),
resolutions.reverse(),
resolutions.length - defaultIndex - 1);
const current = select.querySelector('#resolution-current');
this._resolutionChoices = select.querySelectorAll(
'#resolution-list button');
this._resolutionChoices.forEach((button) => {
button.addEventListener('click', (event) => {
const res = event.currentTarget.textContent;
const time = this._mainSource.currentTime;
const paused = this._mainSource.paused;
if(res === current.textContent) {
return;
}
// temporarily pause playback for the switch
if(!paused) {
this.togglePlayback();
}
current.textContent = res;
this._sources.forEach((source) => {
source.src = source.dataset[res];
source.currentTime = time;
});
// unpause again if we paused before
if(!paused) {
this.togglePlayback();
}
localStorage.setItem('multi-player-resolution', res);
});
});
this._resolutionSelect.replaceWith(select);
this._resolutionSelect = select;
return select;
}
/*
* Populate the list of subtitle choices.
*
* The available subtitles are passed in the 'subtitles' arg.
*
* If there is only one subtitle track, the menu is eliminated
* and it becomes a button toggle instead.
*/
setSubtitles(subtitles) {
let select = null;
if(subtitles.length === 1) {
select = createElement(
'button',
{'id': 'subtitles-select',
'title': this.i18n('Subtitles are off'),
'data-title_alt': this.i18n('Subtitles are on')},
[createIcon(['subtitles-off',
'subtitles-on'])]);
select.addEventListener('click', (event) => {
const track = this._mainSource.textTracks[0];
if(track.mode === 'disabled') {
track.mode = 'showing';
} else {
track.mode = 'disabled';
}
localStorage.setItem('multi-player-subs-state',
track.mode);
this.toggleButtonState(select);
});
const savedState = localStorage.getItem(
'multi-player-subs-state');
if(savedState === 'showing') {
select.click();
}
} else {
select = createElement('div', {'id': 'subtitles-select',
'class': 'select'});
const current = select.createChild(
'button',
{'id': 'subtitles-current',
'title': this.i18n('Subtitles are off'),
'data-title-off': this.i18n('Subtitles are off'),
'data-subtitles-state': 'off'},
[createIcon(['subtitles-off',
'subtitles-on'])]);
const onIcon = current.querySelector(
'svg use[href="#subtitles-on-icon"]');
const offIcon = current.querySelector(
'svg use[href="#subtitles-off-icon"]');
const list = select.createChild('ul', {'id': 'subtitles-list',
'class': 'list'});
for(let i=0; i {
button.addEventListener('click', (event) => {
const choice = event.currentTarget.dataset.choice;
if(choice === current.dataset.subtitlesState) {
return;
}
current.dataset.subtitlesState = choice;
const length = this._mainSource.textTracks.length;
for(let i=0; i 3600) {
return hours +':'+ minutes +':'+ seconds;
} else {
return minutes +':'+ seconds;
}
}
/*
* Jump in the presentation.
*
* The arg 'time' gives the time to jump to as a number of seconds
* since start.
*/
setTime(time) {
this._sources.forEach((source) => {
source.currentTime = time;
});
}
/*
* Switch icon and text on a button to its alternative text/icon.
*
* 'button' is the button to be acted upon.
*/
toggleButtonState(button) {
const alt = button.dataset['title_alt'];
button.dataset['title_alt'] = button.title;
button.title = alt;
const icon = button.querySelector('svg');
if(icon) {
this.toggleIconState(icon);
}
}
/*
* Switch the glyph dispayed on an icon to its alternative.
*
* 'icon' is the icon to be acted upon. Does nothing if there is
* no alternate glyph.
*/
toggleIconState(icon) {
const glyphs = icon.querySelectorAll('use');
if(glyphs.length === 0) {
return;
}
glyphs.forEach((glyph) => {
glyph.classList.toggle('hidden');
});
}
/*
* Set selection popups to lose focus immediately on click.
*/
autoBlurControls() {
const blurrables = this.shadowRoot.querySelectorAll(
'.select button');
blurrables.forEach((candidate) => {
if(candidate.autoblur === undefined) {
candidate.addEventListener('click', (event) => {
event.currentTarget.blur();
});
candidate.autoblur = true;
}
});
}
/*
* Start or stop playback.
*
* Toggles Playback button states and synchronizes sources.
*/
togglePlayback() {
this.toggleButtonState(this._playButton);
const paused = this._controlSource.paused;
this._sources.forEach((source) => {
if(paused) {
source.play();
} else {
source.pause();
}
this.toggleIconState(
source.parentNode.querySelector('.play'));
});
this.syncSources();
}
/*
* Synchronize all secondary sources to the current main source.
*/
syncSources() {
this._sources.forEach((source) => {
if(source === this._controlSource) {
return;
}
source.currentTime = this._controlSource.currentTime;
});
}
/*
* Switch the main playback source to the one passed in newMain.
*/
switchMainSource(newMain) {
function switchIcons(elem) {
elem.querySelectorAll('svg').forEach((icon) => {
icon.classList.toggle('hidden');
});
}
const oldMainParent = this._mainSource.parentNode;
oldMainParent.classList.remove('main');
switchIcons(oldMainParent);
const newMainParent = newMain.parentNode;
newMainParent.classList.add('main');
switchIcons(newMainParent);
let oldTrackLabel = null;
for(let i=0; i