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;