mirror of
https://github.com/servo/servo
synced 2026-05-09 08:32:31 +02:00
Follow the specification and allow to render media controls (expose a user interface to the user) without any HTMLMediaElement `readyState` precondition. The media controls be shown for the following examples without specified media source (e.g. without `src` attribute). ``` <audio controls></audio> <video controls>/<video> ``` See https://html.spec.whatwg.org/multipage/media.html#user-interface For media controls UI don't define root `div` element's style `width` property from the video natural size (`videoWidth/Height` IDL attributes which return `0` if `readyState` is `HAVE_NOTHING`). When the `media` element is adopted between different documents need adopt the media controls id, so media controls UI will keep access to the whilelist of media controls identifiers via `document.servoGetMediaControls(id)` API. Testing: Improvements in the following tests - html/rendering/replaced-elements/embedded-content-rendering-rules/audio-controls-00* - html/semantics/the-button-element/command-and-commandfor/on-audio-behavior.tentative.html - html/semantics/the-button-element/command-and-commandfor/on-audio-invalid-behavior.tentative.html Note that `Invoker Commands` API is not yet supported so these `command and commandfor` tests should fail, but due to previous bug with delayed media controls shown up. The invoker `onClick` function send actions with mouse events (move/up/down) to the test driver at the position of the button element (via getBoundingClientRect), but before test driver will handle these actions the mediadata is received and media controls are shown up with "play" button at this position. So the media control "play" button is clicked instead of the button, and it triggers media element `pause` state change. See https://open-ui.org/components/invokers.explainer/ Signed-off-by: Andrei Volykhin <andrei.volykhin@gmail.com>
409 lines
11 KiB
JavaScript
409 lines
11 KiB
JavaScript
/* 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 `
|
|
<div class="controls">
|
|
<button id="play-pause-button"></button>
|
|
<input id="progress" type="range" value="0" min="0" max="100" step="1"></input>
|
|
<span id="position-duration-box" class="hidden">
|
|
<span id="position-text">#1</span>
|
|
<span id="duration"> / #2</span>
|
|
</span>
|
|
<button id="volume-switch"></button>
|
|
<input id="volume-level" type="range" value="100" min="0" max="100" step="1"></input>
|
|
${isAudioOnly ? "" : '<button id="fullscreen-switch" class="fullscreen"></button>'}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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) {
|
|
// 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();
|
|
})();
|
|
|