'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