diff --git a/examples/nextjs-with-typescript/next-env.d.ts b/examples/nextjs-with-typescript/next-env.d.ts
index 3cd7048ed..c4b7818fb 100644
--- a/examples/nextjs-with-typescript/next-env.d.ts
+++ b/examples/nextjs-with-typescript/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-///
+import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/examples/nextjs-with-typescript/package-lock.json b/examples/nextjs-with-typescript/package-lock.json
index 214b89572..236bc46c6 100644
--- a/examples/nextjs-with-typescript/package-lock.json
+++ b/examples/nextjs-with-typescript/package-lock.json
@@ -14,7 +14,7 @@
"@mui/material": "^7.0.2",
"@mux/mux-video": "^0.17.5",
"media-chrome": "file:../../",
- "next": "15.3.8",
+ "next": "~16.1.1",
"react": "19.2.2",
"react-dom": "19.2.2"
},
@@ -31,7 +31,7 @@
}
},
"../..": {
- "version": "4.17.0",
+ "version": "4.17.2",
"license": "MIT",
"dependencies": {
"ce-la-react": "^0.3.2"
@@ -53,6 +53,7 @@
"react": "19.2.2",
"resolve.exports": "^2.0.3",
"rimraf": "^3.0.2",
+ "shx": "^0.4.0",
"sinon": "^14.0.1",
"typescript": "^5.5.2",
"typescript-eslint": "^7.14.1",
@@ -1328,9 +1329,9 @@
}
},
"node_modules/@next/env": {
- "version": "15.3.8",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.8.tgz",
- "integrity": "sha512-SAfHg0g91MQVMPioeFeDjE+8UPF3j3BvHjs8ZKJAUz1BG7eMPvfCKOAgNWJ6s1MLNeP6O2InKQRTNblxPWuq+Q==",
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz",
+ "integrity": "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -1344,9 +1345,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
- "version": "15.3.5",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.5.tgz",
- "integrity": "sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w==",
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.1.tgz",
+ "integrity": "sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==",
"cpu": [
"arm64"
],
@@ -1360,9 +1361,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
- "version": "15.3.5",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.5.tgz",
- "integrity": "sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA==",
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.1.tgz",
+ "integrity": "sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==",
"cpu": [
"x64"
],
@@ -1376,9 +1377,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
- "version": "15.3.5",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.5.tgz",
- "integrity": "sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ==",
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.1.tgz",
+ "integrity": "sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==",
"cpu": [
"arm64"
],
@@ -1392,9 +1393,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
- "version": "15.3.5",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.5.tgz",
- "integrity": "sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A==",
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.1.tgz",
+ "integrity": "sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==",
"cpu": [
"arm64"
],
@@ -1408,9 +1409,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
- "version": "15.3.5",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.5.tgz",
- "integrity": "sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A==",
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.1.tgz",
+ "integrity": "sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==",
"cpu": [
"x64"
],
@@ -1424,9 +1425,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
- "version": "15.3.5",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.5.tgz",
- "integrity": "sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg==",
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.1.tgz",
+ "integrity": "sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==",
"cpu": [
"x64"
],
@@ -1440,9 +1441,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
- "version": "15.3.5",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.5.tgz",
- "integrity": "sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ==",
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.1.tgz",
+ "integrity": "sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==",
"cpu": [
"arm64"
],
@@ -1456,9 +1457,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
- "version": "15.3.5",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.5.tgz",
- "integrity": "sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw==",
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.1.tgz",
+ "integrity": "sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==",
"cpu": [
"x64"
],
@@ -1554,12 +1555,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@swc/counter": {
- "version": "0.1.3",
- "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
- "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
- "license": "Apache-2.0"
- },
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -2526,6 +2521,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.9.14",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz",
+ "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==",
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.js"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -2596,17 +2600,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
- "node_modules/busboy": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
- "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
- "dependencies": {
- "streamsearch": "^1.1.0"
- },
- "engines": {
- "node": ">=10.16.0"
- }
- },
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -5148,15 +5141,14 @@
"license": "MIT"
},
"node_modules/next": {
- "version": "15.3.8",
- "resolved": "https://registry.npmjs.org/next/-/next-15.3.8.tgz",
- "integrity": "sha512-L+4c5Hlr84fuaNADZbB9+ceRX9/CzwxJ+obXIGHupboB/Q1OLbSUapFs4bO8hnS/E6zV/JDX7sG1QpKVR2bguA==",
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.1.1.tgz",
+ "integrity": "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==",
"license": "MIT",
"dependencies": {
- "@next/env": "15.3.8",
- "@swc/counter": "0.1.3",
+ "@next/env": "16.1.1",
"@swc/helpers": "0.5.15",
- "busboy": "1.6.0",
+ "baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
@@ -5165,22 +5157,22 @@
"next": "dist/bin/next"
},
"engines": {
- "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
+ "node": ">=20.9.0"
},
"optionalDependencies": {
- "@next/swc-darwin-arm64": "15.3.5",
- "@next/swc-darwin-x64": "15.3.5",
- "@next/swc-linux-arm64-gnu": "15.3.5",
- "@next/swc-linux-arm64-musl": "15.3.5",
- "@next/swc-linux-x64-gnu": "15.3.5",
- "@next/swc-linux-x64-musl": "15.3.5",
- "@next/swc-win32-arm64-msvc": "15.3.5",
- "@next/swc-win32-x64-msvc": "15.3.5",
- "sharp": "^0.34.1"
+ "@next/swc-darwin-arm64": "16.1.1",
+ "@next/swc-darwin-x64": "16.1.1",
+ "@next/swc-linux-arm64-gnu": "16.1.1",
+ "@next/swc-linux-arm64-musl": "16.1.1",
+ "@next/swc-linux-x64-gnu": "16.1.1",
+ "@next/swc-linux-x64-musl": "16.1.1",
+ "@next/swc-win32-arm64-msvc": "16.1.1",
+ "@next/swc-win32-x64-msvc": "16.1.1",
+ "sharp": "^0.34.4"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
- "@playwright/test": "^1.41.2",
+ "@playwright/test": "^1.51.1",
"babel-plugin-react-compiler": "*",
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
@@ -6292,14 +6284,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/streamsearch": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
- "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
- "engines": {
- "node": ">=10.0.0"
- }
- },
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
diff --git a/examples/nextjs-with-typescript/package.json b/examples/nextjs-with-typescript/package.json
index 8ae7bc0a9..6546d4e9f 100644
--- a/examples/nextjs-with-typescript/package.json
+++ b/examples/nextjs-with-typescript/package.json
@@ -15,7 +15,7 @@
"@mui/material": "^7.0.2",
"@mux/mux-video": "^0.17.5",
"media-chrome": "file:../../",
- "next": "15.3.8",
+ "next": "~16.1.1",
"react": "19.2.2",
"react-dom": "19.2.2"
},
diff --git a/examples/nextjs-with-typescript/tsconfig.json b/examples/nextjs-with-typescript/tsconfig.json
index f48e7ee6f..877b650fc 100644
--- a/examples/nextjs-with-typescript/tsconfig.json
+++ b/examples/nextjs-with-typescript/tsconfig.json
@@ -14,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
- "jsx": "preserve",
+ "jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -32,7 +32,8 @@
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
- ".next/types/**/*.ts"
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
diff --git a/examples/vanilla/memory-leak-tester.html b/examples/vanilla/memory-leak-tester.html
new file mode 100644
index 000000000..8f2c610de
--- /dev/null
+++ b/examples/vanilla/memory-leak-tester.html
@@ -0,0 +1,74 @@
+
+
+
+
+ <mux-video> example
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/js/media-chrome-button.ts b/src/js/media-chrome-button.ts
index cd16a75a7..8c01c9756 100644
--- a/src/js/media-chrome-button.ts
+++ b/src/js/media-chrome-button.ts
@@ -198,7 +198,7 @@ class MediaChromeButton extends globalThis.HTMLElement {
this.tooltipEl = this.shadowRoot.querySelector('media-tooltip');
}
- #clickListener = (e) => {
+ #clickListener = (e: MouseEvent) => {
if (!this.preventClick) {
this.handleClick(e);
}
@@ -216,7 +216,7 @@ class MediaChromeButton extends globalThis.HTMLElement {
// NOTE: There are definitely some "false positive" cases with multi-key pressing,
// but this should be good enough for most use cases.
- #keyupListener = (e) => {
+ #keyupListener = (e: KeyboardEvent) => {
const { key } = e;
if (!this.keysUsed.includes(key)) {
this.removeEventListener('keyup', this.#keyupListener);
@@ -228,7 +228,7 @@ class MediaChromeButton extends globalThis.HTMLElement {
}
};
- #keydownListener = (e) => {
+ #keydownListener = (e: KeyboardEvent) => {
const { metaKey, altKey, key } = e;
if (metaKey || altKey || !this.keysUsed.includes(key)) {
this.removeEventListener('keyup', this.#keyupListener);
@@ -379,9 +379,8 @@ class MediaChromeButton extends globalThis.HTMLElement {
/**
* @abstract
- * @argument {Event} e
*/
- handleClick(e) {} // eslint-disable-line
+ handleClick(_e: Event) {}
}
if (!globalThis.customElements.get('media-chrome-button')) {
diff --git a/src/js/media-chrome-dialog.ts b/src/js/media-chrome-dialog.ts
index 15d28ed7b..4729ca78d 100644
--- a/src/js/media-chrome-dialog.ts
+++ b/src/js/media-chrome-dialog.ts
@@ -121,10 +121,6 @@ class MediaChromeDialog extends globalThis.HTMLElement {
constructor() {
super();
-
- this.addEventListener('invoke', this);
- this.addEventListener('focusout', this);
- this.addEventListener('keydown', this);
}
get open() {
@@ -177,6 +173,16 @@ class MediaChromeDialog extends globalThis.HTMLElement {
if (!this.role) {
this.role = 'dialog';
}
+
+ this.addEventListener('invoke', this);
+ this.addEventListener('focusout', this);
+ this.addEventListener('keydown', this);
+ }
+
+ disconnectedCallback(): void {
+ this.removeEventListener('invoke', this);
+ this.removeEventListener('focusout', this);
+ this.removeEventListener('keydown', this);
}
attributeChangedCallback(attrName: string, oldValue: string | null, newValue: string | null) {
diff --git a/src/js/media-chrome-range.ts b/src/js/media-chrome-range.ts
index f3333dad1..606d58c10 100644
--- a/src/js/media-chrome-range.ts
+++ b/src/js/media-chrome-range.ts
@@ -566,7 +566,7 @@ class MediaChromeRange extends globalThis.HTMLElement {
}
#enableUserEvents() {
- if (this.hasAttribute('disabled')) return;
+ if (this.hasAttribute('disabled') || !this.isConnected) return;
this.addEventListener('input', this);
this.addEventListener('pointerdown', this);
@@ -577,6 +577,7 @@ class MediaChromeRange extends globalThis.HTMLElement {
this.removeEventListener('input', this);
this.removeEventListener('pointerdown', this);
this.removeEventListener('pointerenter', this);
+ this.removeEventListener('pointerleave', this);
globalThis.window?.removeEventListener('pointerup', this);
globalThis.window?.removeEventListener('pointermove', this);
}
@@ -608,14 +609,14 @@ class MediaChromeRange extends globalThis.HTMLElement {
// Events outside the range element are handled manually below.
this.#isInputTarget = evt.composedPath().includes(this.range);
- globalThis.window?.addEventListener('pointerup', this);
+ globalThis.window?.addEventListener('pointerup', this, {once: true});
}
#handlePointerEnter(evt) {
// On mobile a pointerdown is not required to drag the range.
if (evt.pointerType !== 'mouse') this.#handlePointerDown(evt);
- this.addEventListener('pointerleave', this);
+ this.addEventListener('pointerleave', this, {once: true});
globalThis.window?.addEventListener('pointermove', this);
}
diff --git a/src/js/media-container.ts b/src/js/media-container.ts
index 50b4b7662..1e69eb3b2 100644
--- a/src/js/media-container.ts
+++ b/src/js/media-container.ts
@@ -348,7 +348,7 @@ class MediaContainer extends globalThis.HTMLElement {
)
);
}
-
+ #mutationObserver: MutationObserver
#pointerDownTimeStamp = 0;
#currentMedia: HTMLMediaElement | null = null;
#inactiveTimeout: ReturnType | null = null;
@@ -370,24 +370,8 @@ class MediaContainer extends globalThis.HTMLElement {
this.shadowRoot.setHTMLUnsafe(html) :
this.shadowRoot.innerHTML = html;
}
+ this.#mutationObserver = new MutationObserver(this.#handleMutation);
- // Handles the case when the slotted media element is a slot element itself.
- // e.g. chaining media slots for media themes.
- const chainedSlot = this.querySelector(
- ':scope > slot[slot=media]'
- ) as HTMLSlotElement;
- if (chainedSlot) {
- chainedSlot.addEventListener('slotchange', () => {
- const slotEls = chainedSlot.assignedElements({ flatten: true });
- if (!slotEls.length) {
- if (this.#currentMedia) {
- this.mediaUnsetCallback(this.#currentMedia);
- }
- return;
- }
- this.handleMediaUpdated(this.media);
- });
- }
}
// Could share this code with media-chrome-html-element instead
@@ -424,22 +408,6 @@ class MediaContainer extends globalThis.HTMLElement {
await globalThis.customElements.whenDefined(media.localName);
}
- const setVideoAccessibility = (videoEl: HTMLVideoElement) => {
- if (!videoEl.hasAttribute('aria-hidden')) {
- videoEl.setAttribute('aria-hidden', 'true');
- }
- };
-
- if (media instanceof HTMLVideoElement || media.tagName === 'VIDEO') {
- setVideoAccessibility(media as HTMLVideoElement);
- } else if (media.localName?.includes('-')) {
- // If `media` is a custom media element search in its shadow DOM
- const videoElement = media.shadowRoot?.querySelector('video') as HTMLVideoElement | null;
- if (videoElement) {
- setVideoAccessibility(videoElement);
- }
- }
-
// Even if we are not connected to the DOM after this await still call mediaSetCallback
// so the media state is already computed once, then when the container is connected
// to the DOM mediaSetCallback is called again to attach the root node event listeners.
@@ -465,6 +433,16 @@ class MediaContainer extends globalThis.HTMLElement {
// Set breakpoints on connect since we delay resize observer callbacks.
setBreakpoints(this, this.getBoundingClientRect().width);
+
+ // Handles the case when the slotted media element is a slot element itself.
+ // e.g. chaining media slots for media themes.
+ const chainedSlot = this.querySelector(
+ ':scope > slot[slot=media]'
+ ) as HTMLSlotElement;
+ if (chainedSlot) {
+ this.#chainedSlot = chainedSlot;
+ this.#chainedSlot.addEventListener('slotchange', this.#handleSlotChange)
+ }
this.addEventListener('pointerdown', this);
this.addEventListener('pointermove', this);
@@ -476,8 +454,9 @@ class MediaContainer extends globalThis.HTMLElement {
}
disconnectedCallback(): void {
- this.#mutationObserver.disconnect();
unobserveResize(this, this.#handleResize);
+ clearTimeout(this.#inactiveTimeout);
+ this.#mutationObserver.disconnect();
// When disconnected from the DOM, remove root node and media event listeners
// to prevent memory leaks and unneeded invisble UI updates.
@@ -486,6 +465,18 @@ class MediaContainer extends globalThis.HTMLElement {
}
globalThis.window?.removeEventListener('mouseup', this);
+
+ this.removeEventListener('pointerdown', this);
+ this.removeEventListener('pointermove', this);
+ this.removeEventListener('pointerup', this);
+ this.removeEventListener('mouseleave', this);
+ this.removeEventListener('keyup', this);
+
+ if (this.#chainedSlot){
+ this.#chainedSlot.removeEventListener('slotchange', this.#handleSlotChange)
+ // Free this because it is set on connect
+ this.#chainedSlot = null;
+ }
}
/**
@@ -524,8 +515,7 @@ class MediaContainer extends globalThis.HTMLElement {
}
}
- #mutationObserver = new MutationObserver(this.#handleMutation.bind(this));
- #handleMutation(mutationsList: MutationRecord[]) {
+ #handleMutation = (mutationsList: MutationRecord[]) => {
const media = this.media;
for (const mutation of mutationsList) {
@@ -685,6 +675,18 @@ class MediaContainer extends globalThis.HTMLElement {
}, autohide * 1000);
}
+ #chainedSlot: HTMLSlotElement | null
+ #handleSlotChange = () => {
+ const slotEls = this.#chainedSlot.assignedElements({ flatten: true });
+ if (!slotEls.length) {
+ if (this.#currentMedia) {
+ this.mediaUnsetCallback(this.#currentMedia);
+ }
+ return;
+ }
+ this.handleMediaUpdated(this.media);
+ };
+
set autohide(seconds: string) {
const parsedSeconds = Number(seconds);
this.#autohide = isNaN(parsedSeconds) ? 0 : parsedSeconds;
diff --git a/src/js/media-controller.ts b/src/js/media-controller.ts
index 0c637b4ea..743580f20 100644
--- a/src/js/media-controller.ts
+++ b/src/js/media-controller.ts
@@ -470,6 +470,8 @@ class MediaController extends MediaContainer {
// mediaUnsetCallback() is called in super.disconnectedCallback();
super.disconnectedCallback?.();
+ this.disableHotkeys();
+
if (this.#mediaStore) {
// Save the current state of subtitles before disconnecting
const currentState = this.#mediaStore.getState();
@@ -490,6 +492,8 @@ class MediaController extends MediaContainer {
this.#mediaStoreUnsubscribe?.();
this.#mediaStoreUnsubscribe = undefined;
}
+
+ this.unassociateElement(this);
}
/**
@@ -503,7 +507,15 @@ class MediaController extends MediaContainer {
detail: media,
});
- // TODO: What does this do? At least add comment, maybe move to media-container
+ /*
+ * Prevents the media element from being tab focusable to avoid the blue focus ring,
+ * particularly when going full screen. The media controller handles all accessibility
+ * responsibilities (clickable, keyboard controls, etc.) instead.
+ *
+ * See related links:
+ * - https://github.com/muxinc/media-chrome/issues/309
+ * - https://github.com/muxinc/media-chrome/pull/312
+ */
if (!media.hasAttribute('tabindex')) {
media.tabIndex = -1;
}
@@ -595,7 +607,7 @@ class MediaController extends MediaContainer {
els.splice(index, 1);
}
- #keyUpHandler(e: KeyboardEvent) {
+ #keyUpHandler = (e: KeyboardEvent) => {
const { key, shiftKey } = e;
// Check for Shift + / (which produces '?' on US keyboards or '/' on others)
const isShiftSlash = shiftKey && (key === '/' || key === '?');
diff --git a/src/js/media-gesture-receiver.ts b/src/js/media-gesture-receiver.ts
index 603342d88..97fbc371d 100644
--- a/src/js/media-gesture-receiver.ts
+++ b/src/js/media-gesture-receiver.ts
@@ -88,8 +88,18 @@ class MediaGestureReceiver extends globalThis.HTMLElement {
this.#mediaController?.associateElement?.(this);
}
- this.#mediaController?.addEventListener('pointerdown', this);
- this.#mediaController?.addEventListener('click', this);
+ if (!this.#mediaController) return
+
+ this.#mediaController.addEventListener('pointerdown', this);
+ this.#mediaController.addEventListener('click', this);
+
+ /*
+ * Note: According to ARIA: "Clickable elements must be focusable and should have interactive semantics"
+ * Since this class adds the click listener, it also makes it focusable
+ */
+ if (!this.#mediaController.hasAttribute("tabindex")) {
+ this.#mediaController.tabIndex = 0;
+ }
}
disconnectedCallback(): void {
diff --git a/src/js/media-preview-thumbnail.ts b/src/js/media-preview-thumbnail.ts
index 1d7ed9592..ed601e6d8 100644
--- a/src/js/media-preview-thumbnail.ts
+++ b/src/js/media-preview-thumbnail.ts
@@ -182,6 +182,8 @@ class MediaPreviewThumbnail extends globalThis.HTMLElement {
this.imgWidth = img.naturalWidth;
this.imgHeight = img.naturalHeight;
resize();
+
+ img.onload = null;
};
img.src = src;
resize();
diff --git a/src/js/media-store/state-mediator.ts b/src/js/media-store/state-mediator.ts
index d378b7bdd..48c2e2c8c 100644
--- a/src/js/media-store/state-mediator.ts
+++ b/src/js/media-store/state-mediator.ts
@@ -1020,7 +1020,7 @@ export const stateMediator: StateMediator = {
} else {
enterFullscreen(stateOwners);
const isPointer = event.detail;
- if (isPointer) stateOwners.media?.focus();
+ if (isPointer && !stateOwners.media?.inert) stateOwners.media?.focus();
}
},
// older Safari version may require webkit-specific events
diff --git a/src/js/media-store/util.ts b/src/js/media-store/util.ts
index a7ab59f5b..39c001b8d 100644
--- a/src/js/media-store/util.ts
+++ b/src/js/media-store/util.ts
@@ -1,6 +1,7 @@
import { TextTrackKinds, TextTrackModes } from '../constants.js';
import { getTextTracksList, updateTracksModeTo } from '../utils/captions.js';
import { TextTrackLike } from '../utils/TextTrackLike.js';
+import { globalThis } from '../utils/server-safe-globals.js';
export const getSubtitleTracks = (stateOwners): TextTrackLike[] => {
return getTextTracksList(stateOwners.media, (textTrack) => {
diff --git a/src/js/media-theme-element.ts b/src/js/media-theme-element.ts
index ef60a2a44..90eb1f90e 100644
--- a/src/js/media-theme-element.ts
+++ b/src/js/media-theme-element.ts
@@ -59,6 +59,7 @@ export class MediaThemeElement extends globalThis.HTMLElement {
#template: HTMLTemplateElement;
#prevTemplate: HTMLTemplateElement;
#prevTemplateId: string | null;
+ #observer: MutationObserver;
constructor() {
super();
@@ -71,7 +72,7 @@ export class MediaThemeElement extends globalThis.HTMLElement {
this.createRenderer();
}
- const observer = new MutationObserver((mutationList) => {
+ this.#observer = new MutationObserver((mutationList) => {
// Only update if `` has computed breakpoints at least once.
if (this.mediaController && !this.mediaController?.breakpointsComputed)
return;
@@ -99,19 +100,7 @@ export class MediaThemeElement extends globalThis.HTMLElement {
}
});
- // Observe the `` element for attribute changes.
- observer.observe(this, { attributes: true });
-
- // Observe the subtree of the render root, by default the elements in the shadow dom.
- observer.observe(this.renderRoot, {
- attributes: true,
- subtree: true,
- });
-
- this.addEventListener(
- MediaStateChangeEvents.BREAKPOINTS_COMPUTED,
- this.render
- );
+ this.#renderBind = this.render.bind(this);
// In case the template prop was set before custom element upgrade.
// https://web.dev/custom-elements-best-practices/#make-properties-lazy
@@ -196,9 +185,31 @@ export class MediaThemeElement extends globalThis.HTMLElement {
}
connectedCallback(): void {
+ this.addEventListener(
+ MediaStateChangeEvents.BREAKPOINTS_COMPUTED,
+ this.#renderBind
+ );
+
+ // Observe the `` element for attribute changes.
+ this.#observer.observe(this, { attributes: true });
+
+ // Observe the subtree of the render root, by default the elements in the shadow dom.
+ this.#observer.observe(this.renderRoot, {
+ attributes: true,
+ subtree: true,
+ });
+
this.#updateTemplate();
}
+ disconnectedCallback(): void {
+ this.removeEventListener(
+ MediaStateChangeEvents.BREAKPOINTS_COMPUTED,
+ this.#renderBind
+ );
+ this.#observer.disconnect();
+ }
+
#updateTemplate(): void {
const templateId = this.getAttribute('template');
if (!templateId || templateId === this.#prevTemplateId) return;
@@ -252,6 +263,7 @@ export class MediaThemeElement extends globalThis.HTMLElement {
}
}
+ #renderBind: () => void;
render(): void {
this.renderer?.update(this.props);
}
diff --git a/src/js/media-time-display.ts b/src/js/media-time-display.ts
index a438586cf..4f8ab140f 100644
--- a/src/js/media-time-display.ts
+++ b/src/js/media-time-display.ts
@@ -109,6 +109,14 @@ class MediaTimeDisplay extends MediaTextDisplay {
#slot: HTMLSlotElement;
#keyUpHandler: ((evt: KeyboardEvent) => void) | null = null;
+ #keyDownHandler = (evt: KeyboardEvent) => {
+ const { metaKey, altKey, key } = evt;
+ if (metaKey || altKey || !ButtonPressedKeys.includes(key)) {
+ this.removeEventListener('keyup', this.#keyUpHandler);
+ return;
+ }
+ this.addEventListener('keyup', this.#keyUpHandler);
+ }
static get observedAttributes(): string[] {
return [...super.observedAttributes, ...CombinedAttributes, 'disabled'];
@@ -146,28 +154,20 @@ class MediaTimeDisplay extends MediaTextDisplay {
this.#keyUpHandler = (evt: KeyboardEvent) => {
const { key } = evt;
if (!ButtonPressedKeys.includes(key)) {
- this.removeEventListener('keyup', this.#keyUpHandler!);
+ this.removeEventListener('keyup', this.#keyUpHandler);
return;
}
this.toggleTimeDisplay();
};
-
- this.addEventListener('keydown', (evt: KeyboardEvent) => {
- const { metaKey, altKey, key } = evt;
- if (metaKey || altKey || !ButtonPressedKeys.includes(key)) {
- this.removeEventListener('keyup', this.#keyUpHandler!);
- return;
- }
- this.addEventListener('keyup', this.#keyUpHandler!);
- });
-
+ this.addEventListener('keydown', this.#keyDownHandler);
this.addEventListener('click', this.toggleTimeDisplay);
}
#removeEventListeners(): void {
if (this.#keyUpHandler) {
this.removeEventListener('keyup', this.#keyUpHandler);
+ this.removeEventListener('keydown', this.#keyDownHandler);
this.removeEventListener('click', this.toggleTimeDisplay);
this.#keyUpHandler = null;
}
@@ -202,6 +202,7 @@ class MediaTimeDisplay extends MediaTextDisplay {
disconnectedCallback(): void {
this.disable();
+ this.#removeEventListeners();
super.disconnectedCallback();
}
diff --git a/src/js/media-time-range.ts b/src/js/media-time-range.ts
index 9d161405b..5c086943c 100644
--- a/src/js/media-time-range.ts
+++ b/src/js/media-time-range.ts
@@ -427,8 +427,8 @@ class MediaTimeRange extends MediaChromeRange {
];
}
- #rootNode;
- #animation;
+ #rootNode: Node | null = null;
+ #animation: RangeAnimation;
#boxes;
#previewTime: number;
#previewBox: HTMLElement;
@@ -517,7 +517,7 @@ class MediaTimeRange extends MediaChromeRange {
}
}
- #toggleRangeAnimation(): void {
+ #toggleRangeAnimation = (): void => {
if (this.#shouldRangeAnimate()) {
this.#animation.start();
} else {
diff --git a/src/js/media-volume-range.ts b/src/js/media-volume-range.ts
index 0de215546..f01998840 100644
--- a/src/js/media-volume-range.ts
+++ b/src/js/media-volume-range.ts
@@ -38,26 +38,28 @@ class MediaVolumeRange extends MediaChromeRange {
];
}
- constructor() {
- super();
-
- this.range.addEventListener('input', () => {
- const detail = this.range.value;
- const evt = new globalThis.CustomEvent(
- MediaUIEvents.MEDIA_VOLUME_REQUEST,
- {
- composed: true,
- bubbles: true,
- detail,
- }
- );
- this.dispatchEvent(evt);
- });
+ #handleRangeInput: () => void = () => {
+ const detail = this.range.value;
+ const evt = new globalThis.CustomEvent(
+ MediaUIEvents.MEDIA_VOLUME_REQUEST,
+ {
+ composed: true,
+ bubbles: true,
+ detail,
+ }
+ );
+ this.dispatchEvent(evt);
}
connectedCallback(): void {
super.connectedCallback();
this.range.setAttribute('aria-label', t('volume'));
+ this.range.addEventListener('input', this.#handleRangeInput);
+ }
+
+ disconnectedCallback(): void {
+ this.range.removeEventListener('input', this.#handleRangeInput);
+ super.disconnectedCallback();
}
attributeChangedCallback(
diff --git a/src/js/menu/media-chrome-menu-item.ts b/src/js/menu/media-chrome-menu-item.ts
index ca2e1ad7c..286b241a1 100644
--- a/src/js/menu/media-chrome-menu-item.ts
+++ b/src/js/menu/media-chrome-menu-item.ts
@@ -183,7 +183,7 @@ class MediaChromeMenuItem extends globalThis.HTMLElement {
}
#dirty = false;
- #ownerElement;
+ #ownerElement: MediaChromeMenu | null;
constructor() {
super();
@@ -195,8 +195,6 @@ class MediaChromeMenuItem extends globalThis.HTMLElement {
const attrs = namedNodeMapToObject(this.attributes);
this.shadowRoot.innerHTML = (this.constructor as typeof MediaChromeMenuItem).getTemplateHTML(attrs);
}
-
- this.shadowRoot.addEventListener('slotchange', this);
}
enable() {
@@ -269,6 +267,7 @@ class MediaChromeMenuItem extends globalThis.HTMLElement {
if (this.submenuElement) {
this.#submenuConnected();
}
+ this.shadowRoot.addEventListener('slotchange', this);
}
disconnectedCallback(): void {
@@ -276,6 +275,7 @@ class MediaChromeMenuItem extends globalThis.HTMLElement {
this.#reset();
this.#ownerElement = null;
+ this.shadowRoot.removeEventListener('slotchange', this);
}
get invokeTarget() {
@@ -440,7 +440,7 @@ class MediaChromeMenuItem extends globalThis.HTMLElement {
return ['Enter', ' '];
}
- #handleKeyUp(event) {
+ #handleKeyUp = (event: KeyboardEvent) => {
const { key } = event;
if (!this.keysUsed.includes(key)) {
@@ -451,7 +451,7 @@ class MediaChromeMenuItem extends globalThis.HTMLElement {
this.handleClick(event);
}
- #handleKeyDown(event) {
+ #handleKeyDown = (event: KeyboardEvent) => {
const { metaKey, altKey, key } = event;
if (metaKey || altKey || !this.keysUsed.includes(key)) {
diff --git a/src/js/menu/media-chrome-menu.ts b/src/js/menu/media-chrome-menu.ts
index 74cc3316c..9c5078766 100644
--- a/src/js/menu/media-chrome-menu.ts
+++ b/src/js/menu/media-chrome-menu.ts
@@ -348,10 +348,7 @@ class MediaChromeMenu extends globalThis.HTMLElement {
'slot:not([name])'
) as HTMLSlotElement;
- this.shadowRoot.addEventListener('slotchange', this);
-
this.#mutationObserver = new MutationObserver(this.#handleMenuItems);
- this.#mutationObserver.observe(this.defaultSlot, { childList: true });
}
enable(): void {
@@ -394,6 +391,7 @@ class MediaChromeMenu extends globalThis.HTMLElement {
}
connectedCallback(): void {
+ this.#mutationObserver.observe(this.defaultSlot, { childList: true });
this.#cssRule = insertCSSRule(this.shadowRoot, ':host');
this.#updateLayoutStyle();
@@ -419,9 +417,13 @@ class MediaChromeMenu extends globalThis.HTMLElement {
// Required when using declarative shadow DOM.
this.#toggleHeader();
+
+ this.shadowRoot.addEventListener('slotchange', this);
}
disconnectedCallback(): void {
+ this.#mutationObserver.disconnect();
+
unobserveResize(getBoundsElement(this), this.#handleBoundsResize);
unobserveResize(this, this.#handleMenuResize);
@@ -430,6 +432,8 @@ class MediaChromeMenu extends globalThis.HTMLElement {
// Use cached mediaController, getRootNode() doesn't work if disconnected.
this.#mediaController?.unassociateElement?.(this);
this.#mediaController = null;
+
+ this.shadowRoot.removeEventListener('slotchange', this);
}
attributeChangedCallback(
diff --git a/src/js/utils/element-utils.ts b/src/js/utils/element-utils.ts
index 00fd56716..0f9a84301 100644
--- a/src/js/utils/element-utils.ts
+++ b/src/js/utils/element-utils.ts
@@ -271,10 +271,8 @@ export function insertCSSRule(
};
}
- style?.sheet.insertRule(`${selectorText}{}`, style.sheet.cssRules.length);
- return /** @type {CSSStyleRule} */ style.sheet.cssRules?.[
- style.sheet.cssRules.length - 1
- ];
+ const cssRuleId = style?.sheet.insertRule(`${selectorText}{}`, style.sheet.cssRules.length);
+ return style.sheet.cssRules?.[cssRuleId];
}
/**
diff --git a/src/js/utils/resize-observer.ts b/src/js/utils/resize-observer.ts
index 86f14264c..46c141df6 100644
--- a/src/js/utils/resize-observer.ts
+++ b/src/js/utils/resize-observer.ts
@@ -3,6 +3,7 @@ import { globalThis } from './server-safe-globals.js';
// Use 1 resize observer instance for many elements for best performance.
// https://groups.google.com/a/chromium.org/g/blink-dev/c/z6ienONUb5A/m/F5-VcUZtBAAJ
+// This weakmap may hold detached references for a while, which may appear as a leak, but get replaced/removed eventually
const callbacksMap = new WeakMap>();
type ResizeCallback = (entry: ResizeObserverEntry) => void;