Skip to content

Commit

Permalink
feat(video-on-demand): quality chooser
Browse files Browse the repository at this point in the history
  • Loading branch information
limulus committed Aug 13, 2024
1 parent 0f01160 commit 98d7417
Show file tree
Hide file tree
Showing 4 changed files with 153 additions and 19 deletions.
48 changes: 40 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
71 changes: 70 additions & 1 deletion src/video-on-demand/hls.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<Level[]> =>
uniqueHeights(await onlySupportedLevels(rankLevelsByVideoCodec(levels)))

/**
* Sorts levels by video codec ranking (prefer higher efficiency codecs)
*/
const rankLevelsByVideoCodec = (levels: Level[]): Level[] => {
const videoCodecRanking = new Map<string, number>([
['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<Level[]> => {
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<number>()

return levels.filter((level) => {
if (seenHeights.has(level.height)) return false
seenHeights.add(level.height)
return true
})
}
47 changes: 39 additions & 8 deletions src/video-on-demand/index.ts
Original file line number Diff line number Diff line change
@@ -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 */ `
Expand All @@ -15,18 +17,24 @@ template.innerHTML = /* HTML */ `
x-webkit-airplay="allow"
></video>
<media-settings-menu hidden anchor="auto">
<media-settings-menu-item>
Speed
<media-playback-rate-menu slot="submenu" hidden>
<div slot="title">Speed</div>
</media-playback-rate-menu>
</media-settings-menu-item>
<media-settings-menu-item>
Captions
<media-captions-menu slot="submenu" hidden>
<div slot="title">Captions</div>
</media-captions-menu>
</media-settings-menu-item>
<media-settings-menu-item>
Quality
<media-rendition-menu slot="submenu" hidden>
<div slot="title">Quality</div>
</media-rendition-menu>
</media-settings-menu-item>
<media-settings-menu-item>
Speed
<media-playback-rate-menu slot="submenu" hidden>
<div slot="title">Speed</div>
</media-playback-rate-menu>
</media-settings-menu-item>
</media-settings-menu>
<media-control-bar>
<media-play-button></media-play-button>
Expand Down Expand Up @@ -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<Level, number>(
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<string>
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)

Expand Down

0 comments on commit 98d7417

Please sign in to comment.