multiplayer-ce/multiplayer.js

2008 lines
68 KiB
JavaScript

'use strict';
(function() {
const style = document.createElement('template');
style.innerHTML = `
<style type="text/css">
/* global settings */
:host {
display: block;
--background: black;
--background-other: rgba(255,255,255,0.15);
--backdrop: rgba(0, 0, 0, 0.7);
--foreground: white;
--highlight: #9BB2CE;
--spotlight: #EB7125;
--fade: #a4a4a4;
--fade-other: #ababab;
--gap: 3px;
--svg-size: calc(var(--gap) * 8);
--controls-height: calc(var(--gap) * 10);
--progress-height: calc(var(--gap) * 2);
--controls-fullheight: calc(var(--progress-height)
+ var(--gap) * 4
+ var(--controls-height));
--font: verdana, sans-serif;
--range-size: calc(var(--gap) * 2);
--range-corner: var(--gap);
}
svg {
fill: var(--foreground);
}
#sidebar svg,
#controls svg {
height: var(--svg-size);
width: var(--svg-size);
}
#viewport svg {
height: calc(var(--svg-size)*3);
width: calc(var(--svg-size)*3);
}
button {
background: none;
border: none;
color: var(--foreground);
}
/* passive element styles */
/** general layout **/
#player-root {
margin: 0;
padding: 0;
min-height: 400px;
min-width: 600px;
background-color: var(--background);
color: var(--foreground);
font-family: var(--font);
display: grid;
overflow: hidden;
position: relative;
}
#title {
grid-area: title;
z-index: 2;
}
#viewport {
display: grid;
grid-gap: var(--gap);
gap: var(--gap);
grid-auto-flow: row;
z-index: 1;
grid-auto-rows: 1fr;
grid-auto-columns: 1fr;
}
#viewport.wide {
grid-auto-flow: column;
}
/** about box **/
#sidebar {
position: absolute;
top: 0;
left: 0;
display: grid;
grid-template-columns: 3rem auto;
grid-template-rows: 3rem;
grid-template-areas: "button title" "playlist playlist";
align-items: start;
justify-items: start;
background-color: var(--backdrop);
z-index: 2;
overflow-y: hidden;
}
#sidebar.open {
height: calc(100% - var(--controls-height));
}
#playlist-button, #title, #playlist-title {
padding: calc(var(--gap) * 4);
margin: 0;
font-size: 120%;
place-self: stretch;
}
#playlist-button {
grid-area: button;
width: 3rem;
}
#title, #playlist-title {
grid-area: title;
}
#playlist {
grid-area: playlist;
place-self: stretch;
display: flex;
flex-direction: column;
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
max-height: 100%;
margin-bottom: calc(var(--progress-height)
+ var(--gap) * 4);
}
.playlist-item a {
padding: calc(var(--gap)*3);
color: var(--foreground);
display: grid;
grid-template-areas: "thumb title" "thumb description";
grid-template-columns: 290px auto;
justify-items: start;
text-decoration: none;
}
.playlist-item img {
width: 100%;
height: auto;
margin: var(--gap);
border: 1px solid var(--fade);
grid-area: thumb;
}
.playlist-item span {
margin: calc(var(--gap) * 3);
max-width: 30vw;
}
.playlist-item span.title {
grid-area: title;
align-self: end;
font-size: 110%;
}
.playlist-item span.desc {
grid-area: description;
align-self: start;
}
.current a {
background-color: var(--background-other);
}
/** control box **/
#controls {
position: absolute;
width: 100%;
bottom: 0;
left: 0;
display: grid;
grid-row-gap: var(--gap);
row-gap: var(--gap);
grid-template-areas: "progress progress" "left right";
grid-template-columns: 1fr 1fr;
z-index: 2;
}
#controls button {
padding: 0 calc(var(--gap) * 2);
}
#controls button,
#controls span {
font-size: 100%;
}
#controls button,
#controls input {
margin: 0;
height: var(--controls-height);
}
#progress-container {
position: relative;
grid-area: progress;
margin: 0 calc(var(--gap) * 3);
height: calc(var(--progress-height) + var(--gap) * 2);
opacity: 60%;
}
#buffer, #progress {
position: absolute;
top: 0;
left: 0;
height: var(--progress-height);
}
#buffer {
background-color: var(--fade);
width: 100%;
z-index: 1;
}
#progress {
background-color: var(--spotlight);
z-index: 2;
}
#progress-popup {
position: absolute;
top: 0;
left: 0;
transform: translate(-50%, -150%);
background: var(--background);
border-radius: var(--gap);
padding: var(--gap);
visibility: hidden;
opacity: 0;
user-select: none;
}
#left-controls,
#right-controls {
display: flex;
align-items: stretch;
height: var(--controls-height);
background-color: var(--backdrop);
}
#left-controls > *,
#right-controls > * {
display: flex;
align-items: center;
justify-content: center;
}
#left-controls {
grid-area: left;
}
#right-controls {
grid-area: right;
justify-content: flex-end;
}
#loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.5;
background-color: black;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
}
#loading-overlay > svg {
height: 50%;
}
.select {
position: relative;
user-select: none;
}
.list {
visibility: hidden;
opacity: 0;
position: absolute;
right: 0;
bottom: 0;
padding: 0;
margin-top: 0;
margin-bottom: var(--controls-height);
padding-bottom: var(--gap);
list-style: none;
width: max-content;
}
.list li {
background-color: var(--fade);
}
.list li:nth-child(2n) {
background-color: var(--fade-other);
}
.list button {
text-align: right;
width: 100%;
}
#speed-select button::after {
content: "x";
}
#resolution-select button::after {
content: "p";
}
#volume {
width: 0;
visibility: hidden;
}
#elapsed,
#duration {
padding: 0 var(--gap);
}
/** video streams **/
video {
max-height: 100%;
max-width: 100%;
}
.stream {
display: grid;
position: relative;
grid-column-start: 4;
place-items: stretch;
}
.stream.main {
grid-row: 1 / 4;
grid-column: 1 / 4;
}
/*
@media screen and (max-aspect-ratio: 33/26) {
this is the aspect ratio when the vertical
stacking starts/stops fitting in the container
.stream {
grid-column-start: 1;
}
.stream.main {
grid-row: 2 / 5;
}
}
*/
.stream > video {
grid-row: 1 / 4;
grid-column: 1 / 4;
}
.stream > svg {
grid-row: 2 / 3;
grid-column: 2 / 3;
place-self: center;
}
.fade {
opacity: 0;
}
/** subtitles **/
video::cue {
background-color: var(--backdrop);
}
/* dynamic changes */
:focus {
outline: 2px solid var(--highlight);
}
/** element hiding **/
.hidden {
display: none !important;
}
.nocursor {
cursor: none;
}
.nocursor .fade {
opacity: 0 !important;
}
.nocursor #sidebar,
.nocursor #controls {
max-height: 0;
}
/** element showing **/
.stream:hover > .fade {
opacity: 80%;
}
#progress-container:hover,
#progress-container:focus-within {
opacity: 100%;
}
#progress-container:hover #progress-popup,
#progress-container:focus-within #progress-popup {
visibility: visible;
opacity: 100%;
}
#progress-container:hover #progress::after,
#progress-container:focus-within #progress::after {
content: "";
display: block;
position: absolute;
top: 0;
right: 0;
transform: translate(50%, -25%);
border: var(--progress-height) solid var(--spotlight);
border-radius: var(--progress-height);
pointer-events: none;
}
.select:focus-within .list,
.select:hover .list {
visibility: initial;
opacity: 100%;
}
#volume-button:hover + #volume,
#volume-button:focus + #volume,
#volume:hover,
#volume:focus-within {
width: 5rem;
visibility: initial;
}
/** highlighting **/
svg:hover,
svg:focus,
*:hover > svg,
*:focus > svg {
fill: var(--highlight);
display: initial;
}
/* override highlight for non-interactive icons */
.no-highlight:hover,
*:hover > .no-highlight {
fill: var(--foreground);
}
.playlist-item a:hover {
background-color: rgba(255,255,255,0.2);
color: inherit;
}
.playlist-item a:hover img {
border-color: var(--highlight);
}
.select:focus-within .current,
.select:hover .current {
color: var(--highlight);
}
.list button:hover,
.list button:focus {
background-color: var(--highlight);
}
/** transition settings **/
.fade {
transition: opacity 0.5s;
}
#sidebar,
#controls {
transition: max-height 0.5s;
max-height: 100%;
}
#progress-container {
transition: opacity 0.5s;
}
#progress-popup {
transition: opacity 0.5s, visibility 0.5s;
}
.list {
transition: opacity 0.5s, visibility 0.5s;
}
#volume {
transition: width 0.5s, visibility 0.5s;
}
::-webkit-scrollbar {
width: 10px;
background-color: #424242
}
::-webkit-scrollbar {
width: 15px;
}
::-webkit-scrollbar-track {
background-color: #424242
}
::-webkit-scrollbar-thumb {
background-color: #8e8e8e;
border: 1px solid #424242;
border-radius: 5px
}
::-webkit-scrollbar-thumb {
border-radius: 8px;
}
/* Range styles, here be dragons */
/* make everything invisible */
input[type="range"] {
background: transparent;
}
input[type="range"]::-moz-range-thumb {
border: none;
}
input[type="range"]::-ms-track {
background: transparent;
border-color: transparent;
color: transparent;
}
/* style the thumb */
#volume::-moz-range-thumb {
height: var(--range-size);
width: var(--range-size);
border-radius: var(--range-corner);
background-color: var(--foreground);
}
#volume:hover::-moz-range-thumb,
#volume:focus::-moz-range-thumb {
border: var(--gap) solid var(--foreground);
}
#volume::-ms-thumb {
height: var(--range-size);
width: var(--range-size);
border-radius: var(--range-corner);
background-color: var(--foreground);
}
#volume:hover::-ms-thumb,
#volume:focus::-ms-thumb {
background-color: var(--highlight);
border: 1px solid var(--foreground);
}
/* style the track */
#volume::-moz-range-track {
background-color: var(--fade-other);
border-radius: var(--range-corner);
height: var(--range-size);
}
#volume::-ms-track {
background-color: var(--fade-other);
border-radius: var(--range-corner);
height: var(--range-size);
}
/* style the filled portion */
#volume::-moz-range-progress {
background-color: var(--foreground);
height: var(--range-size);
border-radius: var(--range-corner);
}
#volume:hover::-moz-range-progress,
#volume:focus::-moz-range-progress {
background-color: var(--highlight);
}
#volume::-ms-fill-lower {
background-color: var(--foreground);
height: var(--range-size);
border-radius: var(--range-corner);
}
#volume:hover::-ms-fill-lower,
#volume:focus::-ms-fill-lower {
background-color: var(--highlight);
}
/* chrome is an idiot */
@media screen and (-webkit-min-device-pixel-ratio:0) {
#volume {
-webkit-appearance: none;
}
#volume::-webkit-slider-thumb {
-webkit-appearance: none;
height: var(--range-size);
width: var(--range-size);
border-radius: var(--range-corner);
background-color: var(--foreground);
box-shadow: -100vw 0 0 calc(100vw - var(--range-corner)) var(--foreground);
}
#volume:hover::-webkit-slider-thumb,
#volume:focus::-webkit-slider-thumb {
border: 1px solid var(--foreground);
box-shadow: -100vw 0 0 calc(100vw - var(--range-corner)) var(--highlight);
}
#volume::-webkit-slider-runnable-track {
background-color: var(--fade-other);
border-radius: var(--range-corner);
overflow: hidden;
}
}
</style>
`;
const icons = document.createElement('template');
icons.innerHTML = `
<svg class="hidden">
<defs>
<symbol id="pause-icon" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</symbol>
<symbol id="play-icon" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M8.016 5.016l10.969 6.984-10.969 6.984v-13.969z"/>
</symbol>
<symbol id="loading-icon" viewBox="0 0 24 24" opacity="0.8">
<path d="M12,2 a10,10 0 0,1 10,10 a2,2 0 0,1 -4,0 a6,6 0 0,0 -6,-6 a2,2 0 0,1 0,-4 z">
<animateTransform attributeName="transform"
type="rotate"
from="0 12 12"
to="360 12 12"
dur="1800ms"
repeatCount="indefinite"/>
</path>
<path d="M12,2 a10,10 0 0,1 10,10 a2,2 0 0,1 -4,0 a6,6 0 0,0 -6,-6 a2,2 0 0,1 0,-4 z"
transform="rotate(120 12 12)">
<animateTransform attributeName="transform"
type="rotate"
from="0 12 12"
to="360 12 12"
dur="2400ms"
repeatCount="indefinite"/>
</path>
<path d="M12,2 a10,10 0 0,1 10,10 a2,2 0 0,1 -4,0 a6,6 0 0,0 -6,-6 a2,2 0 0,1 0,-4 z"
transform="rotate(240 12 12)">
<animateTransform attributeName="transform"
type="rotate"
from="0 12 12"
to="360 12 12"
dur="3000ms"
repeatCount="indefinite"/>
</path>
</symbol>
<symbol id="next-icon" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
</symbol>
<symbol id="previous-icon" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
</symbol>
<symbol id="playlist-icon" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z"/>
</symbol>
<symbol id="close-icon" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</symbol>
<symbol id="switch-icon" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<g transform="scale(0.8) translate(3,3)">
<path d="M12 6v1.79c0 .45.54.67.85.35l2.79-2.79c.2-.2.2-.51 0-.71l-2.79-2.79c-.31-.31-.85-.09-.85.36V4c-4.42 0-8 3.58-8 8 0 1.04.2 2.04.57 2.95.27.67 1.13.85 1.64.34.27-.27.38-.68.23-1.04C6.15 13.56 6 12.79 6 12c0-3.31 2.69-6 6-6zm5.79 2.71c-.27.27-.38.69-.23 1.04.28.7.44 1.46.44 2.25 0 3.31-2.69 6-6 6v-1.79c0-.45-.54-.67-.85-.35l-2.79 2.79c-.2.2-.2.51 0 .71l2.79 2.79c.31.31.85.09.85-.35V20c4.42 0 8-3.58 8-8 0-1.04-.2-2.04-.57-2.95-.27-.67-1.13-.85-1.64-.34z"/>
</g>
</symbol>
<symbol id="volume-icon" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M14.016 3.234q3.047 0.656 5.016 3.117t1.969 5.648-1.969 5.648-5.016 3.117v-2.063q2.203-0.656 3.586-2.484t1.383-4.219-1.383-4.219-3.586-2.484v-2.063zM16.5 12q0 2.813-2.484 4.031v-8.063q1.031 0.516 1.758 1.688t0.727 2.344zM3 9h3.984l5.016-5.016v16.031l-5.016-5.016h-3.984v-6z"/>
</symbol>
<symbol id="mute-icon" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M12 3.984v4.219l-2.109-2.109zM4.266 3l16.734 16.734-1.266 1.266-2.063-2.063q-1.547 1.313-3.656 1.828v-2.063q1.172-0.328 2.25-1.172l-4.266-4.266v6.75l-5.016-5.016h-3.984v-6h4.734l-4.734-4.734zM18.984 12q0-2.391-1.383-4.219t-3.586-2.484v-2.063q3.047 0.656 5.016 3.117t1.969 5.648q0 2.203-1.031 4.172l-1.5-1.547q0.516-1.266 0.516-2.625zM16.5 12q0 0.422-0.047 0.609l-2.438-2.438v-2.203q1.031 0.516 1.758 1.688t0.727 2.344z"/>
</symbol>
<symbol id="down-icon" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<polygon points="6.23,20.23 8,22 18,12 8,2 6.23,3.77 14.46,12" transform="rotate(90, 12, 12)"/>
</symbol>
<symbol id="timelink-icon" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="m 8,11 6.034644,0.0768 -1.779826,1.91574 L 8,13 Z m 12.211341,0.499144 h 1.9 C 22.111341,8.7391436 19.76,7 17,7 h -4 v 1.9 h 4 c 1.71,0 3.211341,0.8891436 3.211341,2.599144 z M 3.9,12 C 3.9,10.29 5.29,8.9 7,8.9 h 4 V 7 H 7 c -2.76,0 -5,2.24 -5,5 0,2.76 2.24,5 5,5 h 4 V 15.1 H 7 C 5.29,15.1 3.9,13.71 3.9,12 Z"/>
<path d="M 11.99,2 C 6.47,2 2,6.48 2,12 2,17.52 6.47,22 11.99,22 17.52,22 22,17.52 22,12 22,6.48 17.52,2 11.99,2 Z M 15.29,16.71 11,12.41 V 7 h 2 v 4.59 l 3.71,3.71 z"
transform="matrix(0.49567486,0,0,0.49567486,11.505292,10.652661)"/>
</symbol>
<symbol id="subtitles-off-icon" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M20,4H6.83l8,8H19c0.55,0,1,0.45,1,1c0,0.55-0.45,1-1,1h-2.17l4.93,4.93C21.91,18.65,22,18.34,22,18V6C22,4.9,21.1,4,20,4 z"/><path d="M20,20l-6-6l-1.71-1.71L12,12L3.16,3.16c-0.39-0.39-1.02-0.39-1.41,0c-0.39,0.39-0.39,1.02,0,1.41l0.49,0.49 C2.09,5.35,2,5.66,2,6v12c0,1.1,0.9,2,2,2h13.17l2.25,2.25c0.39,0.39,1.02,0.39,1.41,0c0.39-0.39,0.39-1.02,0-1.41L20,20z M8,13 c0,0.55-0.45,1-1,1H5c-0.55,0-1-0.45-1-1c0-0.55,0.45-1,1-1h2C7.55,12,8,12.45,8,13z M14,17c0,0.55-0.45,1-1,1H5 c-0.55,0-1-0.45-1-1c0-0.55,0.45-1,1-1h8c0.08,0,0.14,0.03,0.21,0.04l0.74,0.74C13.97,16.86,14,16.92,14,17z"/>
</symbol>
<symbol id="subtitles-on-icon" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM5 12h2c.55 0 1 .45 1 1s-.45 1-1 1H5c-.55 0-1-.45-1-1s.45-1 1-1zm8 6H5c-.55 0-1-.45-1-1s.45-1 1-1h8c.55 0 1 .45 1 1s-.45 1-1 1zm6 0h-2c-.55 0-1-.45-1-1s.45-1 1-1h2c.55 0 1 .45 1 1s-.45 1-1 1zm0-4h-8c-.55 0-1-.45-1-1s.45-1 1-1h8c.55 0 1 .45 1 1s-.45 1-1 1z"/>
</symbol>
<symbol id="fullscreen-enter-icon" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M14.016 5.016h4.969v4.969h-1.969v-3h-3v-1.969zM17.016 17.016v-3h1.969v4.969h-4.969v-1.969h3zM5.016 9.984v-4.969h4.969v1.969h-3v3h-1.969zM6.984 14.016v3h3v1.969h-4.969v-4.969h1.969z"/>
</symbol>
<symbol id="fullscreen-exit-icon" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M15.984 8.016h3v1.969h-4.969v-4.969h1.969v3zM14.016 18.984v-4.969h4.969v1.969h-3v3h-1.969zM8.016 8.016v-3h1.969v4.969h-4.969v-1.969h3zM5.016 15.984v-1.969h4.969v4.969h-1.969v-3h-3z"/>
</symbol>
</defs>
</svg>
`;
/*
* 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; i<children.length; ++i) {
let child = children[i];
if(typeof child === "string") {
child = document.createTextNode(child);
}
elem.appendChild(child);
}
}
return elem;
}
function createElement(tagName, attributes={}, children=[]) {
return createElementNS(
'http://www.w3.org/1999/xhtml', tagName, attributes, children);
}
function createSVGElement(tagName, attributes={}, children=[]) {
return createElementNS(
'http://www.w3.org/2000/svg', tagName, attributes, children);
}
function createChild(tagName, attributes={}, children=[]) {
const elem = createElement(tagName, attributes, children);
this.appendChild(elem);
return elem;
}
if(typeof HTMLElement.prototype.createChild === 'undefined') {
HTMLElement.prototype.createChild = createChild;
} else {
throw new Error(
"HTMLElement.prototype.createChild already exists. "
+ "This will require a patch.");
}
function createIcon(hrefs, attributes={}) {
const alternatives = [];
for(let i=0; i<hrefs.length; ++i) {
const icon = createSVGElement('use',
{'href': `#${hrefs[i]}-icon`});
if(i>0) {
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<choiceList.length; ++i) {
list.createChild('li')
.createChild('button', {}, [choiceList[i]]);
}
return wrapper;
}
function randString(length) {
let pool = 'abcdefghijklmnopqrstuvwxyz1234567890';
let out = '';
for(let i=0; i<length; ++i) {
out += pool.charAt(Math.floor(Math.random() * pool.length));
}
return out;
}
class MultiPlayer extends HTMLElement {
static get observedAttributes() {
return ['play', 'play-local', 'list', 'list-local',
'nomunge', 'timelink'];
}
attributeChangedCallback(name, oldValue, newValue) {
switch(name) {
case 'play':
if(newValue === "" || newValue === null) {
return;
}
fetch(newValue)
.then((response) => 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<list.items.length; ++i) {
const item = list.items[i];
const link = playlist.createChild(
'li', {'class': 'playlist-item', 'data-id': item.id})
.createChild('a', {'href': item.link});
link.createChild('img', {'src': item.thumb});
link.createChild('span', {'class': 'title'}, [item.title]);
link.createChild('span',
{'class': 'desc'},
[item.description]);
}
this.showPlaylistUI(true);
this.setActivePlaylistItem();
this.autoBlurControls();
}
/*
* Set up progress container with progress tracking, seeking etc.
*/
initProgressContainer() {
// Initialize hover timestamp popup
const popup = this._progressContainer.querySelector(
'#progress-popup');
this._progressContainer.addEventListener('mousemove', (event) => {
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<subtitles.length; ++i) {
list.createChild('li')
.createChild('button',
{'data-choice': subtitles[i]},
[subtitles[i]]);
}
list.createChild('li').createChild(
'button',
{'data-choice': 'off'},
[this.i18n('Off')]);
this._subtitleChoices = list.querySelectorAll('button');
this._subtitleChoices.forEach((button) => {
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<length; ++i) {
const track = this._mainSource.textTracks[i];
if(choice !== 'off' && track.label === choice) {
track.mode = 'showing';
} else {
track.mode = 'disabled';
}
}
if(choice === 'off') {
current.title = current.dataset.titleOff;
onIcon.classList.add('hidden');
offIcon.classList.remove('hidden');
localStorage.setItem('multi-player-subs-state',
'disabled');
localStorage.removeItem('multi-player-subs-track');
} else {
current.title = choice;
offIcon.classList.add('hidden');
onIcon.classList.remove('hidden');
localStorage.setItem('multi-player-subs-state',
'showing');
localStorage.setItem('multi-player-subs-track',
choice);
}
});
});
}
this._subtitlesSelect.replaceWith(select);
this._subtitlesSelect = select;
return select;
}
/*
* Set the visibility of the time link button.
*/
toggleTimelink(visible) {
if(visible) {
this._timelinkButton.classList.remove('hidden');
} else {
this._timelinkButton.classList.add('hidden');
}
}
/*
* Return a consistently formatted time string.
*
* The arg 'time' should be a number of seconds.
*/
formatTime(time) {
const hours = (Math.floor(time / 3600) + '').padStart(2, '0');
const minutes = (Math.floor((time % 3600) / 60)
+ '').padStart(2, '0');
const seconds = (Math.round(time % 60) + '').padStart(2, '0');
if(this._presentation.duration > 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<this._mainSource.textTracks.length; ++i) {
const track = this._mainSource.textTracks[i];
if(track.mode === "showing") {
track.mode = "disabled";
oldTrackLabel = track.label;
break;
}
}
if(oldTrackLabel !== null) {
for(let i=0; i<newMain.textTracks.length; ++i) {
const track = newMain.textTracks[i];
if(track.label === oldTrackLabel) {
track.mode = "showing";
break;
}
}
}
this._mainSource = newMain;
this.syncSources();
}
}
customElements.define('multi-player', MultiPlayer);
})();