Files
servo/components/script/resources/media-controls.js
Messi II Innocent R. 4029f196e6 media: Clean up shadow root content when removing controls (#43983)
When media controls are removed (either by removing the controls
attribute or by removing the element from the DOM), the shadow root's
children are now cleared. This breaks the reference cycle between the js
mediacontrols instance and the media element, the event listeners and
this.media reference in the controls script would otherwise keep the
element alive and prevent garbage collection.

I tested the controls and it renders correctly and deleting a video with
controls doesn't crash.

Fixes: #43828

Signed-off-by: Messi002 <rostandmessi2@gmail.com>
2026-04-09 13:21:03 +00:00

431 lines
12 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((mutations) => {
for (const mutation of mutations) {
// Here we handle the `controls` attribute removal.
if (mutation.type === "attributes") {
this.cleanup();
return;
}
// Here we handle element removal from DOM.
if (mutation.type === "childList") {
for (const node of mutation.removedNodes) {
if (node === this.media) {
this.cleanup();
return;
}
}
}
}
});
this.mutationObserver.observe(this.media, {
attributeFilter: ["controls"]
});
if (this.media.parentNode) {
this.mutationObserver.observe(this.media.parentNode, {
childList: true
});
}
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.mutationObserver = null;
this.mediaEvents.forEach(event => {
this.media.removeEventListener(event, this);
});
this.controlEvents.forEach(({ el, type }) => {
el.removeEventListener(type, this);
});
this.media = null;
this.controls = null;
}
// 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();
})();