diff --git a/package-lock.json b/package-lock.json index 0c66ee5..47e2606 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,11 +31,13 @@ "markdown-it-footnote": "^4.0.0", "markdown-it-task-lists": "^2.1.1", "media-chrome": "^3.2.5", + "media-tracks": "^0.3.3", "mkdirp": "^3.0.1", + "p-filter": "^4.1.0", "raf": "^3.4.1", "touch-pad": "^1.1.0", - "tslib": "^2.6.2", - "typescript": "^5.4.5", + "tslib": "^2.6.3", + "typescript": "^5.5.4", "ulid": "^2.3.0" }, "devDependencies": { @@ -5807,6 +5809,11 @@ "resolved": "https://registry.npmjs.org/media-chrome/-/media-chrome-3.2.5.tgz", "integrity": "sha512-tTsgS7x77Bn4p/wca/Si/7A+Q3z9DzKq0SOkroQvrNMXBVyQasMayDcsKg5Ur5NGsymZfttnJi7tXvVr/tPj8g==" }, + "node_modules/media-tracks": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/media-tracks/-/media-tracks-0.3.3.tgz", + "integrity": "sha512-9P2FuUHnZZ3iji+2RQk7Zkh5AmZTnOG5fODACnjhCVveX1McY3jmCRHofIEI+yTBqplz7LXy48c7fQ3Uigp88w==" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -6232,6 +6239,31 @@ "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.8.0.tgz", "integrity": "sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A==" }, + "node_modules/p-filter": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-4.1.0.tgz", + "integrity": "sha512-37/tPdZ3oJwHaS3gNJdenCDB3Tz26i9sjhnguBtvN0vYlRIiDNnvTWkuh+0hETV9rLPdJ3rlL3yVOYPIAnM8rw==", + "dependencies": { + "p-map": "^7.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-filter/node_modules/p-map": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.2.tgz", + "integrity": "sha512-z4cYYMMdKHzw4O5UkWJImbZynVIo0lSGTXc7bzB1e/rrDqkgGUNysK/o4bTr+0+xKvvLoTyGqYC4Fgljy9qe1Q==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", @@ -7537,9 +7569,9 @@ } }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" }, "node_modules/type-check": { "version": "0.4.0", @@ -7633,9 +7665,9 @@ } }, "node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 62d7021..e36fe9a 100644 --- a/package.json +++ b/package.json @@ -57,11 +57,13 @@ "markdown-it-footnote": "^4.0.0", "markdown-it-task-lists": "^2.1.1", "media-chrome": "^3.2.5", + "media-tracks": "^0.3.3", "mkdirp": "^3.0.1", + "p-filter": "^4.1.0", "raf": "^3.4.1", "touch-pad": "^1.1.0", - "tslib": "^2.6.2", - "typescript": "^5.4.5", + "tslib": "^2.6.3", + "typescript": "^5.5.4", "ulid": "^2.3.0" }, "devDependencies": { diff --git a/src/video-on-demand/hls.ts b/src/video-on-demand/hls.ts index 2619520..1c83db9 100644 --- a/src/video-on-demand/hls.ts +++ b/src/video-on-demand/hls.ts @@ -1,4 +1,5 @@ -import Hls from 'hls.js' +import Hls, { Level } from 'hls.js' +import pFilter from 'p-filter' import { VideoOnDemand } from '.' import { VODEvent } from './VODEvent' @@ -37,3 +38,71 @@ export const createHlsAndBindEvents = ( return hls } + +/** + * Returns the available quality levels based on the video codec and what is supported by + * the browser. + */ +export const availableQualityLevels = async (levels: Level[]): Promise => + uniqueHeights(await onlySupportedLevels(rankLevelsByVideoCodec(levels))) + +/** + * Sorts levels by video codec ranking (prefer higher efficiency codecs) + */ +const rankLevelsByVideoCodec = (levels: Level[]): Level[] => { + const videoCodecRanking = new Map([ + ['hvc1', 0], + ['avc1', 100], + ]) + + const getVideoCodecRanking = (codec: string): number => { + const ranking = videoCodecRanking.get(codec.split('.')[0]) + return ranking ?? 1000 + } + + return levels + .filter((level) => level.videoCodec !== undefined) + .sort((a, b) => { + const aCodecRanking = getVideoCodecRanking(a.videoCodec!) + const bCodecRanking = getVideoCodecRanking(b.videoCodec!) + if (aCodecRanking !== bCodecRanking) return aCodecRanking - bCodecRanking + return 0 + }) +} + +/** + * Filter out unsupported levels + */ +const onlySupportedLevels = async (levels: Level[]): Promise => { + return await pFilter(levels, async (level) => { + try { + const info = await navigator.mediaCapabilities.decodingInfo({ + type: 'media-source', + video: { + contentType: `video/mp4; codecs="${level.videoCodec}"`, + bitrate: level.bitrate, + framerate: level.frameRate, + width: level.width, + height: level.height, + }, + }) + return info.supported + } catch { + console.debug('decodingInfo failed', level) + return false + } + }) +} + +/** + * Filter out levels with duplicate heights. + */ +const uniqueHeights = (levels: Level[]): Level[] => { + const seenHeights = new Set() + + return levels.filter((level) => { + if (seenHeights.has(level.height)) return false + seenHeights.add(level.height) + return true + }) +} diff --git a/src/video-on-demand/index.ts b/src/video-on-demand/index.ts index 7c6c35e..3fbf8aa 100644 --- a/src/video-on-demand/index.ts +++ b/src/video-on-demand/index.ts @@ -1,8 +1,10 @@ +import 'media-tracks/polyfill' import 'media-chrome' -import Hls from 'hls.js' + +import Hls, { Level } from 'hls.js' import { VODEvent } from './VODEvent' -import { createHlsAndBindEvents } from './hls' +import { availableQualityLevels, createHlsAndBindEvents } from './hls' const template = document.createElement('template') template.innerHTML = /* HTML */ ` @@ -15,18 +17,24 @@ template.innerHTML = /* HTML */ ` x-webkit-airplay="allow" > @@ -89,6 +97,29 @@ export class VideoOnDemand extends HTMLElement { this.#videoEl.setAttribute('poster', `${vodUrl}/poster.jpeg`) if (this.#hls) { + this.#hls.on(Hls.Events.MANIFEST_PARSED, async (_event, data) => { + const levelIdMap = new Map( + data.levels.map((level, index) => [level, index]) + ) + const qualityLevels = await availableQualityLevels(data.levels) + this.shadowRoot!.querySelector('media-rendition-menu')!.setAttribute( + 'mediarenditionlist', + qualityLevels + .map((level) => `${levelIdMap.get(level)}:${level.width}:${level.height}`) + .join(' ') + ) + }) + + this.addEventListener('mediarenditionrequest', (event: Event) => { + const customEvent = event as CustomEvent + if (customEvent.detail === 'auto') { + this.#hls!.nextLevel = -1 + } else { + const levelId = parseInt(customEvent.detail, 10) + this.#hls!.nextLevel = levelId + } + }) + this.#hls.loadSource(`${vodUrl}/index.m3u8`) this.#hls.attachMedia(this.#videoEl)