/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
(() => {
"use strict";
// States.
const BUFFERING = "buffering";
const ENDED = "ended";
const ERRORED = "errored";
const PAUSED = "paused";
const PLAYING = "playing";
// State transitions.
const TRANSITIONS = {
buffer: {
paused: BUFFERING
},
end: {
playing: ENDED,
paused: ENDED
},
error: {
buffering: ERRORED,
playing: ERRORED,
paused: ERRORED
},
pause: {
buffering: PAUSED,
playing: PAUSED
},
play: {
buffering: PLAYING,
ended: PLAYING,
paused: PLAYING
}
};
function generateMarkup(isAudioOnly) {
return `
#1 / #2
${isAudioOnly ? "" : ''}
`;
}
function camelCase(str) {
const rdashes = /-(.)/g;
return str.replace(rdashes, (str, p1) => {
return p1.toUpperCase();
});
}
function formatTime(time, showHours = false) {
// Format the duration as "h:mm:ss" or "m:ss"
time = Math.round(time / 1000);
const hours = Math.floor(time / 3600);
const mins = Math.floor((time % 3600) / 60);
const secs = Math.floor(time % 60);
const formattedHours =
hours || showHours ? `${hours.toString().padStart(2, "0")}:` : "";
return `${formattedHours}${mins
.toString()
.padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
class MediaControls {
constructor() {
this.nonce = Date.now();
// Get the instance of the shadow root where these controls live.
this.controls = document.servoGetMediaControls("@@@id@@@");
// Get the instance of the host of these controls.
this.media = this.controls.host;
this.mutationObserver = new MutationObserver(() => {
// We can only get here if the `controls` attribute is removed.
this.cleanup();
});
this.mutationObserver.observe(this.media, {
attributeFilter: ["controls"]
});
this.isAudioOnly = this.media.localName == "audio";
// Create root element and load markup.
this.root = document.createElement("div");
this.root.classList.add("root");
this.root.innerHTML = generateMarkup(this.isAudioOnly);
this.controls.appendChild(this.root);
const elementNames = [
"duration",
"play-pause-button",
"position-duration-box",
"position-text",
"progress",
"volume-switch",
"volume-level"
];
if (!this.isAudioOnly) {
elementNames.push("fullscreen-switch");
}
// Import elements.
this.elements = {};
elementNames.forEach(id => {
this.elements[camelCase(id)] = this.controls.getElementById(id);
});
// Init position duration box.
const positionTextNode = this.elements.positionText;
const durationSpan = this.elements.duration;
const durationFormat = durationSpan.textContent;
const positionFormat = positionTextNode.textContent;
durationSpan.classList.add("duration");
durationSpan.setAttribute("role", "none");
Object.defineProperties(this.elements.positionDurationBox, {
durationSpan: {
value: durationSpan
},
position: {
get: () => {
return positionTextNode.textContent;
},
set: v => {
positionTextNode.textContent = positionFormat.replace("#1", v);
}
},
duration: {
get: () => {
return durationSpan.textContent;
},
set: v => {
durationSpan.textContent = v ? durationFormat.replace("#2", v) : "";
}
},
show: {
value: (currentTime, duration) => {
const self = this.elements.positionDurationBox;
if (self.position != currentTime) {
self.position = currentTime;
}
if (self.duration != duration) {
self.duration = duration;
}
self.classList.remove("hidden");
}
}
});
// Add event listeners.
this.mediaEvents = [
"play",
"pause",
"ended",
"volumechange",
"loadeddata",
"loadstart",
"timeupdate",
"progress",
"playing",
"waiting",
"canplay",
"canplaythrough",
"seeking",
"seeked",
"emptied",
"loadedmetadata",
"error",
"suspend"
];
this.mediaEvents.forEach(event => {
this.media.addEventListener(event, this);
});
this.controlEvents = [
{ el: this.elements.playPauseButton, type: "click" },
{ el: this.elements.volumeSwitch, type: "click" },
{ el: this.elements.volumeLevel, type: "input" }
];
if (!this.isAudioOnly) {
this.controlEvents.push({ el: this.elements.fullscreenSwitch, type: "click" });
}
this.controlEvents.forEach(({ el, type }) => {
el.addEventListener(type, this);
});
// Create state transitions.
//
// It exposes one method per transition. i.e. this.pause(), this.play(), etc.
// For each transition, we check that the transition is possible and call
// the `onStateChange` handler.
for (let name in TRANSITIONS) {
if (!TRANSITIONS.hasOwnProperty(name)) {
continue;
}
this[name] = () => {
const from = this.state;
// Checks if the transition is valid in the current state.
if (!TRANSITIONS[name][from]) {
const error = `Transition "${name}" invalid for the current state "${from}"`;
console.error(error);
throw new Error(error);
}
const to = TRANSITIONS[name][from];
if (from == to) {
return;
}
// Transition to the next state.
this.state = to;
this.onStateChange(from);
};
}
// Set initial state.
this.state = this.media.paused ? PAUSED : PLAYING;
this.onStateChange(null);
}
cleanup() {
this.mutationObserver.disconnect();
this.mediaEvents.forEach(event => {
this.media.removeEventListener(event, this);
});
this.controlEvents.forEach(({ el, type }) => {
el.removeEventListener(type, this);
});
}
// State change handler
onStateChange(from) {
this.render(from);
}
render(from = this.state) {
if (!this.isAudioOnly) {
// XXX This should ideally use clientHeight/clientWidth,
// but for some reason I couldn't figure out yet,
// using it breaks layout.
this.root.style.height = this.media.videoHeight;
this.root.style.width = this.media.videoWidth;
}
// Error
if (this.state == ERRORED) {
//XXX render errored state
return;
}
if (this.state != from) {
// Play/Pause button.
const playPauseButton = this.elements.playPauseButton;
playPauseButton.classList.remove(from);
playPauseButton.classList.add(this.state);
}
// Progress.
const positionPercent =
(this.media.currentTime / this.media.duration) * 100;
if (Number.isFinite(positionPercent)) {
this.elements.progress.value = positionPercent;
} else {
this.elements.progress.value = 0;
}
// Current time and duration.
let currentTime = formatTime(0);
let duration = formatTime(0);
if (!isNaN(this.media.currentTime) && !isNaN(this.media.duration)) {
currentTime = formatTime(Math.round(this.media.currentTime * 1000));
duration = formatTime(Math.round(this.media.duration * 1000));
}
this.elements.positionDurationBox.show(currentTime, duration);
// Volume.
this.elements.volumeSwitch.className =
this.media.muted || !this.media.volume ? "muted" : "volumeup";
const volumeLevelValue = this.media.muted
? 0
: Math.round(this.media.volume * 100);
if (this.elements.volumeLevel.value != volumeLevelValue) {
this.elements.volumeLevel.value = volumeLevelValue;
}
}
handleEvent(event) {
if (!event.isTrusted) {
console.warn(`Drop untrusted event ${event.type}`);
return;
}
if (this.mediaEvents.includes(event.type)) {
this.onMediaEvent(event);
} else {
this.onControlEvent(event);
}
}
onControlEvent(event) {
switch (event.type) {
case "click":
switch (event.currentTarget) {
case this.elements.playPauseButton:
this.playOrPause();
break;
case this.elements.volumeSwitch:
this.toggleMuted();
break;
case this.elements.fullscreenSwitch:
this.toggleFullscreen();
break;
}
break;
case "input":
switch (event.currentTarget) {
case this.elements.volumeLevel:
this.changeVolume();
break;
}
break;
default:
throw new Error(`Unknown event ${event.type}`);
}
}
// HTMLMediaElement event handler
onMediaEvent(event) {
switch (event.type) {
case "ended":
this.end();
break;
case "play":
case "pause":
// Transition to PLAYING or PAUSED state.
this[event.type]();
break;
case "volumechange":
case "timeupdate":
case "resize":
this.render();
break;
case "loadedmetadata":
break;
}
}
/* Media actions */
playOrPause() {
switch (this.state) {
case PLAYING:
this.media.pause();
break;
case BUFFERING:
case ENDED:
case PAUSED:
this.media.play();
break;
default:
throw new Error(`Invalid state ${this.state}`);
}
}
toggleMuted() {
this.media.muted = !this.media.muted;
}
toggleFullscreen() {
const { fullscreenEnabled, fullscreenElement } = document;
const isElementFullscreen = fullscreenElement && fullscreenElement === this.media;
if (fullscreenEnabled && isElementFullscreen) {
document.exitFullscreen().then(() => {
this.elements.fullscreenSwitch.classList.remove("fullscreen-active");
});
} else {
this.media.requestFullscreen().then(() => {
this.elements.fullscreenSwitch.classList.add("fullscreen-active");
});
}
}
changeVolume() {
const volume = parseInt(this.elements.volumeLevel.value);
if (!isNaN(volume)) {
this.media.volume = volume / 100;
}
}
}
new MediaControls();
})();