/* 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 `
${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(); })();