├── .node-version ├── .prettierrc ├── src ├── version.ts ├── exports-default.ts ├── crypt │ ├── decrypter-aes-mode.ts │ ├── fast-aes-key.ts │ └── aes-crypto.ts ├── types │ ├── tuples.ts │ ├── vtt.ts │ ├── track.ts │ ├── component-api.ts │ ├── fragment-tracker.ts │ ├── buffer.ts │ ├── transmuxer.ts │ ├── remuxer.ts │ ├── media-playlist.ts │ └── demuxer.ts ├── utils │ ├── global.ts │ ├── hash.ts │ ├── time-ranges.ts │ ├── hex.ts │ ├── typed-array.ts │ ├── encryption-methods-util.ts │ ├── mediasource-helper.ts │ ├── utf8-utils.ts │ ├── numeric-encoding-utils.ts │ ├── timescale-conversion.ts │ ├── chunker.ts │ ├── ewma.ts │ ├── output-filter.ts │ ├── keysystem-util.ts │ ├── binary-search.ts │ ├── media-option-attributes.ts │ ├── hdr.ts │ ├── error-helper.ts │ ├── ewma-bandwidth-estimator.ts │ ├── cues.ts │ ├── variable-substitution.ts │ ├── logger.ts │ ├── texttrack-utils.ts │ └── buffer-helper.ts ├── empty.js ├── demux │ ├── dummy-demuxed-track.ts │ ├── audio │ │ ├── dolby.ts │ │ ├── mp3demuxer.ts │ │ └── aacdemuxer.ts │ ├── chunk-cache.ts │ ├── inject-worker.ts │ └── video │ │ └── exp-golomb.ts ├── empty-es.js ├── polyfills │ └── number.ts ├── loader │ ├── load-stats.ts │ └── level-details.ts ├── define-plugin.d.ts ├── is-supported.ts ├── controller │ ├── buffer-operation-queue.ts │ └── fps-controller.ts ├── exports-named.ts ├── remux │ └── aac-helper.ts └── task-loop.ts ├── .husky └── pre-commit ├── .mversionrc ├── api-extractor └── .gitignore ├── demo ├── .prettierrc ├── demo-utils.js ├── basic-usage.html ├── benchmark.html └── style.css ├── tests ├── functional │ ├── .prettierrc │ ├── auto │ │ ├── index.html │ │ ├── index-light.html │ │ ├── style.css │ │ └── .eslintrc.js │ ├── multiple │ │ └── index.html │ └── issues │ │ ├── 617.html │ │ └── video-tag-hijack.html ├── unit │ ├── utils │ │ ├── utf8.ts │ │ ├── codecs.ts │ │ ├── binary-search.js │ │ ├── vttparser.ts │ │ ├── exp-golomb.ts │ │ ├── error-helper.js │ │ ├── output-filter.js │ │ └── texttrack-utils.js │ ├── loader │ │ └── level.js │ ├── events.js │ ├── crypt │ │ ├── aes-decryptor.js │ │ └── decrypter.js │ ├── demuxer │ │ └── base-audio-demuxer.ts │ └── controller │ │ ├── timeline-controller-nonnative.js │ │ ├── ewma-bandwidth-estimator.ts │ │ └── base-stream-controller.ts ├── .eslintrc.js ├── mocks │ ├── time-ranges.mock.js │ ├── loader.mock.ts │ ├── data.js │ └── hls.mock.ts └── index.js ├── docs ├── media-zigzagging.png ├── release-process.md └── logo.svg ├── .escheckrc ├── .prettierignore ├── scripts ├── set-package-version.sh ├── get-version-tag.js ├── check-docs-built.sh ├── build-on-cloudflare.sh ├── build-cloudflare.sh ├── foldersize.js ├── check-already-published.js ├── publish-npm.sh ├── build-deployments-readme.js ├── deploy-cloudflare.sh ├── version-parser.js └── get-package-version.js ├── .vscode └── tasks.json ├── hls.js.sublime-project ├── .editorconfig ├── tsconfig.json ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── question.yaml │ ├── feature.yaml │ └── bug.yaml ├── workflows │ ├── automerge.yml │ └── codeql-analysis.yml ├── release-drafter.yml └── stale.yml ├── tsconfig-lib.json ├── .gitignore ├── renovate.json ├── lint-staged.config.js ├── rollup.config.js ├── api-extractor.json ├── LICENSE ├── karma.conf.js ├── CONTRIBUTING.md ├── .eslintrc.js └── CODE_OF_CONDUCT.md /.node-version: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | export const version = __VERSION__; 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run lint:staged 2 | npm run type-check 3 | -------------------------------------------------------------------------------- /.mversionrc: -------------------------------------------------------------------------------- 1 | { 2 | "commitMessage": "Release %s", 3 | "tagName": "v%s" 4 | } 5 | -------------------------------------------------------------------------------- /api-extractor/.gitignore: -------------------------------------------------------------------------------- 1 | /report-temp 2 | hls.js.api.json 3 | api-documenter 4 | -------------------------------------------------------------------------------- /src/exports-default.ts: -------------------------------------------------------------------------------- 1 | import Hls from './hls'; 2 | 3 | export default Hls; 4 | -------------------------------------------------------------------------------- /demo/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5" 4 | } 5 | -------------------------------------------------------------------------------- /tests/functional/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5" 4 | } 5 | -------------------------------------------------------------------------------- /docs/media-zigzagging.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brunobritodev/hls.js/master/docs/media-zigzagging.png -------------------------------------------------------------------------------- /src/crypt/decrypter-aes-mode.ts: -------------------------------------------------------------------------------- 1 | export const enum DecrypterAesMode { 2 | cbc = 0, 3 | ctr = 1, 4 | } 5 | -------------------------------------------------------------------------------- /.escheckrc: -------------------------------------------------------------------------------- 1 | { 2 | "ecmaVersion": "es5", 3 | "modules": "false", 4 | "files": [ 5 | "./dist/**/*.js" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /api-docs 2 | /api-extractor/report 3 | /api-extractor/report-* 4 | /coverage 5 | /demo/libs 6 | /dist 7 | /lib 8 | package-lock.json 9 | -------------------------------------------------------------------------------- /src/types/tuples.ts: -------------------------------------------------------------------------------- 1 | export type Tail = ((...t: T) => any) extends ( 2 | _: any, 3 | ...tail: infer U 4 | ) => any 5 | ? U 6 | : []; 7 | -------------------------------------------------------------------------------- /src/utils/global.ts: -------------------------------------------------------------------------------- 1 | /** returns `undefined` is `self` is missing, e.g. in node */ 2 | export const optionalSelf = typeof self !== 'undefined' ? self : undefined; 3 | -------------------------------------------------------------------------------- /scripts/set-package-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | VERSION="$(node ./scripts/get-package-version.js)" 5 | echo "Setting version to '$VERSION'" 6 | npm version --git-tag-version false "$VERSION" 7 | -------------------------------------------------------------------------------- /src/empty.js: -------------------------------------------------------------------------------- 1 | // This file is inserted as a shim for modules which we do not want to include into the distro. 2 | // This replacement is done in the "alias" plugin of the rollup config. 3 | module.exports = undefined; 4 | -------------------------------------------------------------------------------- /src/types/vtt.ts: -------------------------------------------------------------------------------- 1 | export type VTTCCs = { 2 | ccOffset: number; 3 | presentationOffset: number; 4 | [key: number]: { 5 | start: number; 6 | prevCC: number; 7 | new: boolean; 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /scripts/get-version-tag.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const versionParser = require('./version-parser.js'); 4 | const packageJson = require('../package.json'); 5 | 6 | console.log(versionParser.getVersionTag('v' + packageJson.version)); 7 | -------------------------------------------------------------------------------- /src/utils/hash.ts: -------------------------------------------------------------------------------- 1 | // From https://github.com/darkskyapp/string-hash 2 | export function hash(text: string) { 3 | let hash = 5381; 4 | let i = text.length; 5 | while (i) { 6 | hash = (hash * 33) ^ text.charCodeAt(--i); 7 | } 8 | 9 | return (hash >>> 0).toString(); 10 | } 11 | -------------------------------------------------------------------------------- /scripts/check-docs-built.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | $(git diff --exit-code docs/API.md > /dev/null) && exit_status=$? || exit_status=$? 5 | if [[ $exit_status -ne 0 ]]; then 6 | echo "API.md is not in sync. Please run 'npm run docs' and commit that change" 7 | exit 1 8 | fi 9 | 10 | echo "Docs up to date" 11 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "lint", 9 | "problemMatcher": ["$eslint-stylish"] 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /hls.js.sublime-project: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": ".", 5 | "folder_exclude_patterns": [".git", "node_modules", "dist", "lib"], 6 | "file_exclude_patterns": [ 7 | ".gitignore", 8 | "hls.js.sublime-project", 9 | "hls.js.sublime-workspace", 10 | ], 11 | }, 12 | ], 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /src/demux/dummy-demuxed-track.ts: -------------------------------------------------------------------------------- 1 | import type { DemuxedTrack } from '../types/demuxer'; 2 | 3 | export function dummyTrack(type = '', inputTimeScale = 90000): DemuxedTrack { 4 | return { 5 | type, 6 | id: -1, 7 | pid: -1, 8 | inputTimeScale, 9 | sequenceNumber: -1, 10 | samples: [], 11 | dropped: 0, 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /src/empty-es.js: -------------------------------------------------------------------------------- 1 | // This file is inserted as a shim for modules which we do not want to include into the distro. 2 | // This replacement is done in the "alias" plugin of the rollup config. 3 | // Use a ES dedicated file as Rollup assigns an object in the output 4 | // For example: "var KeySystemFormats = emptyEs.KeySystemFormats;" 5 | module.exports = {}; 6 | -------------------------------------------------------------------------------- /scripts/build-on-cloudflare.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | if [[ $(git rev-parse --is-shallow-repository) = "true" ]]; then 5 | # make sure everything is fetched 6 | git fetch --unshallow 7 | fi 8 | 9 | npm ci --force 10 | ./scripts/set-package-version.sh 11 | npm run lint 12 | npm run type-check 13 | npm run build:ci 14 | npm run docs 15 | ./scripts/build-cloudflare.sh 16 | -------------------------------------------------------------------------------- /src/types/track.ts: -------------------------------------------------------------------------------- 1 | export interface TrackSet { 2 | audio?: Track; 3 | video?: Track; 4 | audiovideo?: Track; 5 | } 6 | 7 | export interface Track { 8 | id: 'audio' | 'main'; 9 | buffer?: SourceBuffer; // eslint-disable-line no-restricted-globals 10 | container: string; 11 | codec?: string; 12 | initSegment?: Uint8Array; 13 | levelCodec?: string; 14 | metadata?: any; 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/time-ranges.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * TimeRanges to string helper 3 | */ 4 | 5 | const TimeRanges = { 6 | toString: function (r: TimeRanges) { 7 | let log = ''; 8 | const len = r.length; 9 | for (let i = 0; i < len; i++) { 10 | log += `[${r.start(i).toFixed(3)}-${r.end(i).toFixed(3)}]`; 11 | } 12 | 13 | return log; 14 | }, 15 | }; 16 | 17 | export default TimeRanges; 18 | -------------------------------------------------------------------------------- /src/utils/hex.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * hex dump helper class 3 | */ 4 | 5 | const Hex = { 6 | hexDump: function (array: Uint8Array) { 7 | let str = ''; 8 | for (let i = 0; i < array.length; i++) { 9 | let h = array[i].toString(16); 10 | if (h.length < 2) { 11 | h = '0' + h; 12 | } 13 | 14 | str += h; 15 | } 16 | return str; 17 | }, 18 | }; 19 | 20 | export default Hex; 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig-lib.json", 3 | "compilerOptions": { 4 | "emitDeclarationOnly": false 5 | }, 6 | "include": [ 7 | "src/**/*", 8 | "tests/**/*", 9 | "demo/**/*", 10 | /* needed for eslint to work for some reason ¯\_(ツ)_/¯ */ 11 | "tests/**/.eslintrc.js", 12 | "rollup.config.js", 13 | "build-config.js", 14 | "lint-staged.config.js", 15 | "scripts/**/*" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### This PR will... 2 | 3 | ### Why is this Pull Request needed? 4 | 5 | ### Are there any points in the code the reviewer needs to double check? 6 | 7 | ### Resolves issues: 8 | 9 | ### Checklist 10 | 11 | - [ ] changes have been done against master branch, and PR does not conflict 12 | - [ ] new unit / functional tests have been added (whenever applicable) 13 | - [ ] API or design changes are documented in API.md 14 | -------------------------------------------------------------------------------- /src/utils/typed-array.ts: -------------------------------------------------------------------------------- 1 | export function sliceUint8( 2 | array: Uint8Array, 3 | start?: number, 4 | end?: number, 5 | ): Uint8Array { 6 | // @ts-expect-error This polyfills IE11 usage of Uint8Array slice. 7 | // It always exists in the TypeScript definition so fails, but it fails at runtime on IE11. 8 | return Uint8Array.prototype.slice 9 | ? array.slice(start, end) 10 | : new Uint8Array(Array.prototype.slice.call(array, start, end)); 11 | } 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.yaml: -------------------------------------------------------------------------------- 1 | name: Question 2 | description: Need help with something not related to a Bug or Feature Request? 3 | labels: [Question, Needs Triage] 4 | body: 5 | - type: textarea 6 | id: question 7 | attributes: 8 | label: What do you want to do with Hls.js? 9 | validations: 10 | required: true 11 | - type: textarea 12 | id: what_tried_so_far 13 | attributes: 14 | label: What have you tried so far? 15 | validations: 16 | required: false 17 | -------------------------------------------------------------------------------- /scripts/build-cloudflare.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | root="./cloudflare-pages" 5 | 6 | rm -rf "$root" 7 | mkdir "$root" 8 | 9 | echo "Building for CloudFlare..." 10 | 11 | # redirect / to /demo 12 | echo "/ /demo" > "$root/_redirects" 13 | echo "/api-docs/ /api-docs/hls.js.hls.html" >> "$root/_redirects" 14 | echo "/api-docs/index.html /api-docs/hls.js.hls.html" >> "$root/_redirects" 15 | cp -r "./dist" "$root/dist" 16 | cp -r "./demo" "$root/demo" 17 | cp -r "./api-docs" "$root/api-docs" 18 | 19 | echo "Built for CloudFlare." 20 | -------------------------------------------------------------------------------- /tests/unit/utils/utf8.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { utf8ArrayToStr } from '@svta/common-media-library/utils/utf8ArrayToStr'; 3 | 4 | describe('UTF8 tests', function () { 5 | it('utf8ArrayToStr', function (done) { 6 | const aB = new Uint8Array([97, 98]); 7 | const aNullBNullC = new Uint8Array([97, 0, 98, 0, 99]); 8 | 9 | expect(utf8ArrayToStr(aB)).to.equal('ab'); 10 | expect(utf8ArrayToStr(aNullBNullC)).to.equal('abc'); 11 | expect(utf8ArrayToStr(aNullBNullC, true)).to.equal('a'); 12 | 13 | done(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /scripts/foldersize.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'), 4 | path = require('path'); 5 | 6 | var folderName = process.argv[2]; 7 | 8 | fs.readdir(folderName, function (err, files) { 9 | if (err) { 10 | throw err; 11 | } 12 | files 13 | .map(function (file) { 14 | return path.join(folderName, file); 15 | }) 16 | .filter(function (file) { 17 | return fs.statSync(file).isFile(); 18 | }) 19 | .forEach(function (file) { 20 | console.log('%s (%s)', file, fs.statSync(file).size); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /tests/functional/auto/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 |
13 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/polyfills/number.ts: -------------------------------------------------------------------------------- 1 | // https://caniuse.com/mdn-javascript_builtins_number_isfinite 2 | export const isFiniteNumber = 3 | Number.isFinite || 4 | function (value) { 5 | return typeof value === 'number' && isFinite(value); 6 | }; 7 | 8 | // https://caniuse.com/mdn-javascript_builtins_number_issafeinteger 9 | export const isSafeInteger = 10 | Number.isSafeInteger || 11 | function (value) { 12 | return typeof value === 'number' && Math.abs(value) <= MAX_SAFE_INTEGER; 13 | }; 14 | 15 | export const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER || 9007199254740991; 16 | -------------------------------------------------------------------------------- /tests/functional/auto/index-light.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 |
13 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/loader/load-stats.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | HlsPerformanceTiming, 3 | HlsProgressivePerformanceTiming, 4 | LoaderStats, 5 | } from '../types/loader'; 6 | 7 | export class LoadStats implements LoaderStats { 8 | aborted: boolean = false; 9 | loaded: number = 0; 10 | retry: number = 0; 11 | total: number = 0; 12 | chunkCount: number = 0; 13 | bwEstimate: number = 0; 14 | loading: HlsProgressivePerformanceTiming = { start: 0, first: 0, end: 0 }; 15 | parsing: HlsPerformanceTiming = { start: 0, end: 0 }; 16 | buffering: HlsProgressivePerformanceTiming = { start: 0, first: 0, end: 0 }; 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig-lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "declarationMap": true, 7 | "sourceMap": true, 8 | "strict": false, 9 | "strictNullChecks": true, 10 | "strictPropertyInitialization": true, 11 | "lib": ["dom", "es2015"], 12 | "outDir": "./lib/", 13 | "allowSyntheticDefaultImports": true, 14 | "emitDeclarationOnly": true, 15 | "incremental": true, 16 | "tsBuildInfoFile": "cache.tsbuildinfo" 17 | }, 18 | "include": ["src/**/*"], 19 | "exclude": ["node_modules", "dist/**/*", "demo/libs/**/*"] 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/automerge.yml: -------------------------------------------------------------------------------- 1 | name: Auto Merge Dependency Updates 2 | 3 | on: 4 | - pull_request_target 5 | 6 | permissions: 7 | contents: read 8 | pull-requests: write 9 | 10 | jobs: 11 | run: 12 | runs-on: ubuntu-latest 13 | concurrency: 14 | group: 'automerge:run:${{ github.head_ref }}' 15 | cancel-in-progress: true 16 | steps: 17 | - uses: tjenkinson/gh-action-auto-merge-dependency-updates@964cf1547be62862846b42466ae1f68f7bdafee8 # v1.4.2 18 | with: 19 | repo-token: ${{ secrets.CI_GITHUB_TOKEN }} 20 | use-auto-merge: true 21 | allowed-actors: renovate[bot] 22 | -------------------------------------------------------------------------------- /src/types/component-api.ts: -------------------------------------------------------------------------------- 1 | import type EwmaBandWidthEstimator from '../utils/ewma-bandwidth-estimator'; 2 | 3 | export interface ComponentAPI { 4 | destroy(): void; 5 | } 6 | 7 | export interface AbrComponentAPI extends ComponentAPI { 8 | firstAutoLevel: number; 9 | forcedAutoLevel: number; 10 | nextAutoLevel: number; 11 | readonly bwEstimator?: EwmaBandWidthEstimator; 12 | resetEstimator(abrEwmaDefaultEstimate: number); 13 | } 14 | 15 | export interface NetworkComponentAPI extends ComponentAPI { 16 | startLoad(startPosition: number): void; 17 | stopLoad(): void; 18 | pauseBuffering?(): void; 19 | resumeBuffering?(): void; 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/encryption-methods-util.ts: -------------------------------------------------------------------------------- 1 | import { DecrypterAesMode } from '../crypt/decrypter-aes-mode'; 2 | 3 | export function isFullSegmentEncryption(method: string): boolean { 4 | return ( 5 | method === 'AES-128' || method === 'AES-256' || method === 'AES-256-CTR' 6 | ); 7 | } 8 | 9 | export function getAesModeFromFullSegmentMethod( 10 | method: string, 11 | ): DecrypterAesMode { 12 | switch (method) { 13 | case 'AES-128': 14 | case 'AES-256': 15 | return DecrypterAesMode.cbc; 16 | case 'AES-256-CTR': 17 | return DecrypterAesMode.ctr; 18 | default: 19 | throw new Error(`invalid full segment method ${method}`); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/functional/auto/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #cccccc; 3 | } 4 | 5 | #log { 6 | position: fixed; 7 | top: 0; 8 | bottom: 0; 9 | left: 0; 10 | right: 0; 11 | margin: 0; 12 | color: #ff0000; 13 | overflow: hidden; 14 | font-size: 20px; 15 | z-index: 999; 16 | } 17 | 18 | #log .inner { 19 | position: absolute; 20 | bottom: 0; 21 | left: 0; 22 | right: 0; 23 | margin: 0; 24 | padding: 0; 25 | background-color: rgba(255, 255, 255, 0.75); 26 | } 27 | 28 | #log .inner .line { 29 | margin: 0; 30 | padding: 3px 15px; 31 | border-width: 1px; 32 | border-color: #aaaaaa; 33 | border-style: none; 34 | border-bottom-style: solid; 35 | white-space: pre-wrap; 36 | } 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # MacOS 2 | .DS_Store 3 | 4 | # npm 5 | node_modules/* 6 | npm-debug.log 7 | bower_components/* 8 | 9 | # NYC 10 | .nyc_output/ 11 | 12 | # Coverage 13 | coverage/ 14 | 15 | # JetBrains 16 | .idea/* 17 | 18 | # Build 19 | /lib 20 | /dist 21 | /dist.zip 22 | /cloudflare-pages 23 | /api-docs 24 | /api-docs-markdown 25 | /karma-temp 26 | 27 | # eslint 28 | .eslintcache 29 | /cache.tsbuildinfo 30 | 31 | # Visual Studio exclusions 32 | *.suo 33 | *.user 34 | .vs/ 35 | bin/ 36 | obj/ 37 | Generated\ Files/ 38 | 39 | /web.config 40 | /vwd.webinfo 41 | 42 | # ReSharper is a .NET coding add-in 43 | _ReSharper*/ 44 | *.[Rr]e[Ss]harper 45 | *.DotSettings.user 46 | 47 | # VSCode custom workspace settings 48 | .vscode/settings.json 49 | -------------------------------------------------------------------------------- /src/utils/mediasource-helper.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * MediaSource helper 3 | */ 4 | 5 | export function getMediaSource( 6 | preferManagedMediaSource = true, 7 | ): typeof MediaSource | undefined { 8 | if (typeof self === 'undefined') return undefined; 9 | const mms = 10 | (preferManagedMediaSource || !self.MediaSource) && 11 | ((self as any).ManagedMediaSource as undefined | typeof MediaSource); 12 | return ( 13 | mms || 14 | self.MediaSource || 15 | ((self as any).WebKitMediaSource as typeof MediaSource) 16 | ); 17 | } 18 | 19 | export function isManagedMediaSource(source: typeof MediaSource | undefined) { 20 | return ( 21 | typeof self !== 'undefined' && source === (self as any).ManagedMediaSource 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/define-plugin.d.ts: -------------------------------------------------------------------------------- 1 | declare const __VERSION__: string; 2 | 3 | // Dynamic Modules 4 | declare const __USE_ALT_AUDIO__: boolean; 5 | declare const __USE_EME_DRM__: boolean; 6 | declare const __USE_SUBTITLES__: boolean; 7 | declare const __USE_CMCD__: boolean; 8 | declare const __USE_CONTENT_STEERING__: boolean; 9 | declare const __USE_VARIABLE_SUBSTITUTION__: boolean; 10 | declare const __USE_M2TS_ADVANCED_CODECS__: boolean; 11 | declare const __USE_MEDIA_CAPABILITIES__: boolean; 12 | 13 | // __IN_WORKER__ is provided from a closure call around the final UMD bundle. 14 | declare const __IN_WORKER__: boolean; 15 | // __HLS_WORKER_BUNDLE__ is the name of the closure around the final UMD bundle. 16 | declare const __HLS_WORKER_BUNDLE__: Function; 17 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "labels": ["dependencies", "skip-change-log"], 4 | "prHourlyLimit": 0, 5 | "prConcurrentLimit": 0, 6 | "prCreation": "immediate", 7 | "minimumReleaseAge": "7 days", 8 | "internalChecksFilter": "strict", 9 | "vulnerabilityAlerts": { 10 | "addLabels": ["security"] 11 | }, 12 | "major": { 13 | "addLabels": ["semver-major"] 14 | }, 15 | "packageRules": [ 16 | { 17 | "matchPackagePatterns": ["*"], 18 | "rangeStrategy": "bump" 19 | }, 20 | { 21 | "matchDepTypes": ["devDependencies"], 22 | "rangeStrategy": "pin" 23 | }, 24 | { 25 | "matchDepTypes": ["peerDependencies"], 26 | "rangeStrategy": "widen" 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/utf8-utils.ts: -------------------------------------------------------------------------------- 1 | // breaking up those two types in order to clarify what is happening in the decoding path. 2 | type DecodedFrame = { key: string; data: T; info?: any }; 3 | export type Frame = DecodedFrame; 4 | // http://stackoverflow.com/questions/8936984/uint8array-to-string-in-javascript/22373197 5 | // http://www.onicos.com/staff/iz/amuse/javascript/expert/utf.txt 6 | /* utf.js - UTF-8 <=> UTF-16 convertion 7 | * 8 | * Copyright (C) 1999 Masanao Izumo 9 | * Version: 1.0 10 | * LastModified: Dec 25 1999 11 | * This library is free. You can redistribute it and/or modify it. 12 | */ 13 | 14 | export function strToUtf8array(str: string): Uint8Array { 15 | return Uint8Array.from(unescape(encodeURIComponent(str)), (c) => 16 | c.charCodeAt(0), 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/numeric-encoding-utils.ts: -------------------------------------------------------------------------------- 1 | export function base64ToBase64Url(base64encodedStr: string): string { 2 | return base64encodedStr 3 | .replace(/\+/g, '-') 4 | .replace(/\//g, '_') 5 | .replace(/=+$/, ''); 6 | } 7 | 8 | export function strToBase64Encode(str: string): string { 9 | return btoa(str); 10 | } 11 | 12 | export function base64DecodeToStr(str: string): string { 13 | return atob(str); 14 | } 15 | 16 | export function base64Encode(input: Uint8Array): string { 17 | return btoa(String.fromCharCode(...input)); 18 | } 19 | 20 | export function base64UrlEncode(input: Uint8Array): string { 21 | return base64ToBase64Url(base64Encode(input)); 22 | } 23 | 24 | export function base64Decode(base64encodedStr: string): Uint8Array { 25 | return Uint8Array.from(atob(base64encodedStr), (c) => c.charCodeAt(0)); 26 | } 27 | -------------------------------------------------------------------------------- /src/types/fragment-tracker.ts: -------------------------------------------------------------------------------- 1 | import type { MediaFragment } from '../loader/fragment'; 2 | import type { SourceBufferName } from './buffer'; 3 | import type { FragLoadedData } from './events'; 4 | 5 | export interface FragmentEntity { 6 | body: MediaFragment; 7 | // appendedPTS is the latest buffered presentation time within the fragment's time range. 8 | // It is used to determine: which fragment is appended at any given position, and hls.currentLevel. 9 | appendedPTS: number | null; 10 | loaded: FragLoadedData | null; 11 | buffered: boolean; 12 | range: { [key in SourceBufferName]: FragmentBufferedRange }; 13 | } 14 | 15 | export interface FragmentTimeRange { 16 | startPTS: number; 17 | endPTS: number; 18 | } 19 | 20 | export interface FragmentBufferedRange { 21 | time: Array; 22 | partial: boolean; 23 | } 24 | -------------------------------------------------------------------------------- /tests/unit/loader/level.js: -------------------------------------------------------------------------------- 1 | import { LevelDetails } from '../../../src/loader/level-details'; 2 | 3 | describe('Level Class tests', function () { 4 | it('sets programDateTime to true when the first fragment has valid pdt', function () { 5 | const level = new LevelDetails(); 6 | level.fragments = [{ programDateTime: 1 }]; 7 | expect(level.hasProgramDateTime).to.be.true; 8 | }); 9 | 10 | it('sets programDateTime to false when no fragments is empty', function () { 11 | const level = new LevelDetails(); 12 | expect(level.hasProgramDateTime).to.be.false; 13 | }); 14 | 15 | it('sets programDateTime to false when the first fragment has an invalid pdt', function () { 16 | const level = new LevelDetails(); 17 | level.fragments = [{ programDateTime: 'foo' }]; 18 | expect(level.hasProgramDateTime).to.be.false; 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | const micromatch = require('micromatch'); 2 | const prettier = require('prettier'); 3 | 4 | const addQuotes = (a) => `"${a}"`; 5 | 6 | module.exports = async (allStagedFiles) => { 7 | const prettierSupportedExtensions = ( 8 | await prettier.getSupportInfo() 9 | ).languages 10 | .map(({ extensions }) => extensions) 11 | .flat(); 12 | 13 | const eslintFiles = micromatch(allStagedFiles, '**/*.{js,ts}'); 14 | const prettierFiles = micromatch( 15 | allStagedFiles, 16 | prettierSupportedExtensions.map((extension) => `**/*${extension}`), 17 | ); 18 | 19 | return [ 20 | eslintFiles.length && 21 | `eslint --cache --fix ${eslintFiles.map(addQuotes).join(' ')}`, 22 | prettierFiles.length && 23 | `prettier --cache --write ${prettierFiles.map(addQuotes).join(' ')}`, 24 | ].filter(Boolean); 25 | }; 26 | -------------------------------------------------------------------------------- /src/demux/audio/dolby.ts: -------------------------------------------------------------------------------- 1 | export const getAudioBSID = (data: Uint8Array, offset: number): number => { 2 | // check the bsid to confirm ac-3 | ec-3 3 | let bsid = 0; 4 | let numBits = 5; 5 | offset += numBits; 6 | const temp = new Uint32Array(1); // unsigned 32 bit for temporary storage 7 | const mask = new Uint32Array(1); // unsigned 32 bit mask value 8 | const byte = new Uint8Array(1); // unsigned 8 bit for temporary storage 9 | while (numBits > 0) { 10 | byte[0] = data[offset]; 11 | // read remaining bits, upto 8 bits at a time 12 | const bits = Math.min(numBits, 8); 13 | const shift = 8 - bits; 14 | mask[0] = (0xff000000 >>> (24 + shift)) << shift; 15 | temp[0] = (byte[0] & mask[0]) >> shift; 16 | bsid = !bsid ? temp[0] : (bsid << bits) | temp[0]; 17 | offset += 1; 18 | numBits -= bits; 19 | } 20 | return bsid; 21 | }; 22 | -------------------------------------------------------------------------------- /demo/demo-utils.js: -------------------------------------------------------------------------------- 1 | export function sortObject(obj) { 2 | if (typeof obj !== 'object') { 3 | return obj; 4 | } 5 | let temp = {}; 6 | let keys = []; 7 | for (let key in obj) { 8 | keys.push(key); 9 | } 10 | keys.sort(); 11 | for (let index in keys) { 12 | temp[keys[index]] = sortObject(obj[keys[index]]); 13 | } 14 | return temp; 15 | } 16 | 17 | export function copyTextToClipboard(text) { 18 | let textArea = document.createElement('textarea'); 19 | textArea.value = text; 20 | document.body.appendChild(textArea); 21 | textArea.select(); 22 | try { 23 | let successful = document.execCommand('copy'); 24 | let msg = successful ? 'successful' : 'unsuccessful'; 25 | console.log('Copying text command was ' + msg); 26 | } catch (err) { 27 | console.log('Oops, unable to copy'); 28 | } 29 | document.body.removeChild(textArea); 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: 'CodeQL' 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | schedule: 9 | - cron: '0 3 * * 1' 10 | 11 | permissions: 12 | actions: read 13 | security-events: write 14 | 15 | jobs: 16 | analyze: 17 | name: Analyze 18 | runs-on: ubuntu-latest 19 | concurrency: 20 | group: 'codeql-analysis:analyze:${{ github.ref }}' 21 | cancel-in-progress: true 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | language: ['javascript'] 26 | 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | 31 | - name: Initialize CodeQL 32 | uses: github/codeql-action/init@v3 33 | with: 34 | languages: ${{ matrix.language }} 35 | 36 | - name: Perform CodeQL Analysis 37 | uses: github/codeql-action/analyze@v3 38 | -------------------------------------------------------------------------------- /tests/unit/utils/codecs.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { convertAVC1ToAVCOTI } from '../../../src/utils/codecs'; 3 | 4 | describe('codecs', function () { 5 | it('convert codec string from AVC1 to AVCOTI', function () { 6 | expect(convertAVC1ToAVCOTI('avc1.66.30')).to.equal('avc1.42001e'); 7 | }); 8 | 9 | it('convert list of codecs string from AVC1 to AVCOTI', function () { 10 | expect(convertAVC1ToAVCOTI('avc1.77.30,avc1.66.30')).to.equal( 11 | 'avc1.4d001e,avc1.42001e', 12 | ); 13 | }); 14 | 15 | it('does not convert string if it is already converted', function () { 16 | expect(convertAVC1ToAVCOTI('avc1.64001E')).to.equal('avc1.64001E'); 17 | }); 18 | 19 | it('does not convert list of codecs string if it is already converted', function () { 20 | expect(convertAVC1ToAVCOTI('avc1.64001E,avc1.64001f')).to.equal( 21 | 'avc1.64001E,avc1.64001f', 22 | ); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.yaml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project 3 | labels: [Feature proposal, Needs Triage] 4 | body: 5 | - type: textarea 6 | id: problem 7 | attributes: 8 | label: Is your feature request related to a problem? Please describe. 9 | description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 10 | validations: 11 | required: true 12 | - type: textarea 13 | id: solution 14 | attributes: 15 | label: Describe the solution you'd like 16 | description: A clear and concise description of what you want to happen. 17 | validations: 18 | required: true 19 | - type: textarea 20 | id: context 21 | attributes: 22 | label: Additional context 23 | description: Add any other context or screenshots about the feature request here. 24 | validations: 25 | required: false 26 | -------------------------------------------------------------------------------- /tests/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | commonjs: true, 5 | es6: true, 6 | mocha: true, 7 | }, 8 | plugins: ['mocha', 'n'], 9 | globals: { 10 | // Test globals 11 | after: false, 12 | afterEach: false, 13 | assert: false, 14 | before: false, 15 | beforeEach: false, 16 | describe: false, 17 | expect: true, 18 | sinon: false, 19 | xit: false, 20 | }, 21 | rules: { 22 | 'one-var': 0, 23 | 'no-undefined': 0, 24 | 'no-unused-expressions': 0, 25 | 'no-restricted-properties': [ 26 | 2, 27 | { property: 'findIndex' }, // Intended to block usage of Array.prototype.findIndex 28 | { property: 'find' }, // Intended to block usage of Array.prototype.find 29 | { property: 'only' }, // Intended to block usage of it.only in commits 30 | ], 31 | 'n/no-restricted-require': ['error', ['assert']], 32 | 'mocha/no-mocha-arrows': 2, 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | template: | 2 | # Summary 3 | HLS.js v$INPUT_VERSION includes bug fixes and improvements over the last release. 4 | 5 | ## Changes Since The Last Release 6 | https://github.com/video-dev/hls.js/compare/$PREVIOUS_TAG...v$INPUT_VERSION 7 | 8 | $CHANGES 9 | 10 | ## Demo Page 11 | > Get demo url from https://github.com/video-dev/hls.js/tree/deployments 12 | 13 | ## API and Breaking Changes 14 | If you are upgrading from version v0.14.17 or lower, see the [MIGRATING](https://github.com/video-dev/hls.js/blob/v1.0.0/MIGRATING.md) guide for API changes between v0.14.x and v1.0.0. 15 | 16 | ## Feedback 17 | Please provide feedback via [Issues in GitHub](https://github.com/video-dev/hls.js/issues/new/choose). For more details on how to contribute to HLS.js, see our [CONTRIBUTING guide](https://github.com/video-dev/hls.js/blob/master/CONTRIBUTING.md). 18 | 19 | version-template: $COMPLETE 20 | exclude-labels: 21 | - 'skip-change-log' 22 | -------------------------------------------------------------------------------- /scripts/check-already-published.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | /* eslint-disable no-console */ 3 | 'use strict'; 4 | 5 | const packageJson = require('../package.json'); 6 | 7 | (async () => { 8 | try { 9 | if (await versionPublished()) { 10 | console.log('published'); 11 | } else { 12 | console.log('not published'); 13 | } 14 | } catch (e) { 15 | console.error(e); 16 | process.exit(1); 17 | } 18 | process.exit(0); 19 | })(); 20 | 21 | async function versionPublished() { 22 | const fetch = (await import('node-fetch')).default; 23 | 24 | //https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md 25 | const response = await fetch( 26 | `https://registry.npmjs.org/${encodeURIComponent( 27 | packageJson.name, 28 | )}/${encodeURIComponent(packageJson.version)}`, 29 | ); 30 | if (response.status === 200) { 31 | return true; 32 | } else if (response.status === 404) { 33 | return false; 34 | } else { 35 | throw new Error(`Invalid status: ${response.status}`); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/crypt/fast-aes-key.ts: -------------------------------------------------------------------------------- 1 | import { DecrypterAesMode } from './decrypter-aes-mode'; 2 | 3 | export default class FastAESKey { 4 | private subtle: SubtleCrypto; 5 | private key: ArrayBuffer; 6 | private aesMode: DecrypterAesMode; 7 | 8 | constructor( 9 | subtle: SubtleCrypto, 10 | key: ArrayBuffer, 11 | aesMode: DecrypterAesMode, 12 | ) { 13 | this.subtle = subtle; 14 | this.key = key; 15 | this.aesMode = aesMode; 16 | } 17 | 18 | expandKey() { 19 | const subtleAlgoName = getSubtleAlgoName(this.aesMode); 20 | return this.subtle.importKey( 21 | 'raw', 22 | this.key, 23 | { name: subtleAlgoName }, 24 | false, 25 | ['encrypt', 'decrypt'], 26 | ); 27 | } 28 | } 29 | 30 | function getSubtleAlgoName(aesMode: DecrypterAesMode) { 31 | switch (aesMode) { 32 | case DecrypterAesMode.cbc: 33 | return 'AES-CBC'; 34 | case DecrypterAesMode.ctr: 35 | return 'AES-CTR'; 36 | default: 37 | throw new Error(`[FastAESKey] invalid aes mode ${aesMode}`); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/types/buffer.ts: -------------------------------------------------------------------------------- 1 | export type SourceBufferName = 'video' | 'audio' | 'audiovideo'; 2 | 3 | // eslint-disable-next-line no-restricted-globals 4 | export type ExtendedSourceBuffer = SourceBuffer & { 5 | ended?: boolean; 6 | ending?: boolean; 7 | changeType?: (type: string) => void; 8 | }; 9 | 10 | export type SourceBuffers = Partial< 11 | Record 12 | >; 13 | 14 | export interface BufferOperationQueues { 15 | video: Array; 16 | audio: Array; 17 | audiovideo: Array; 18 | } 19 | 20 | export interface BufferOperation { 21 | execute: Function; 22 | onStart: Function; 23 | onComplete: Function; 24 | onError: Function; 25 | start?: number; 26 | end?: number; 27 | } 28 | 29 | export interface SourceBufferListeners { 30 | video: Array; 31 | audio: Array; 32 | audiovideo: Array; 33 | } 34 | 35 | export interface SourceBufferListener { 36 | event: string; 37 | listener: EventListener; 38 | } 39 | -------------------------------------------------------------------------------- /scripts/publish-npm.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | if [[ $(node ./scripts/check-already-published.js) = "not published" ]]; then 5 | # write the token to config 6 | # see https://docs.npmjs.com/private-modules/ci-server-config 7 | echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc 8 | if [[ -z "$TAG" ]]; then 9 | npm publish --provenance --tag canary 10 | echo "Published canary." 11 | curl https://purge.jsdelivr.net/npm/hls.js@canary 12 | curl https://purge.jsdelivr.net/npm/hls.js@canary/dist/hls-demo.js 13 | echo "Cleared jsdelivr cache." 14 | else 15 | tag=$(node ./scripts/get-version-tag.js) 16 | if [ "${tag}" = "canary" ]; then 17 | # canary is blocked because this is handled separately on every commit 18 | echo "canary not supported as explicit tag" 19 | exit 1 20 | fi 21 | echo "Publishing tag: ${tag}" 22 | npm publish --provenance --tag "${tag}" 23 | curl "https://purge.jsdelivr.net/npm/hls.js@${tag}" 24 | echo "Published." 25 | fi 26 | else 27 | echo "Already published." 28 | fi 29 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const { configs } = require('./build-config'); 3 | 4 | module.exports = ({ configType = [] }) => { 5 | const requestedConfigs = Array.isArray(configType) 6 | ? configType 7 | : [configType]; 8 | 9 | let configEntries; 10 | if (!requestedConfigs.length) { 11 | // If no arguments are specified, return every configuration 12 | configEntries = configs; 13 | } else { 14 | // Filter out enabled configs 15 | const enabledEntries = configs.filter(([name]) => 16 | requestedConfigs.includes(name), 17 | ); 18 | if (!enabledEntries.length) { 19 | throw new Error( 20 | `Couldn't find a valid config with the names ${JSON.stringify( 21 | requestedConfigs, 22 | )}. Known configs are: ${configs.map(([name]) => name).join(', ')}`, 23 | ); 24 | } 25 | configEntries = enabledEntries; 26 | } 27 | 28 | console.log( 29 | `Building configs: ${configEntries.map(([name]) => name).join(', ')}.\n`, 30 | ); 31 | return configEntries.map(([, config]) => config); 32 | }; 33 | -------------------------------------------------------------------------------- /tests/mocks/time-ranges.mock.js: -------------------------------------------------------------------------------- 1 | const assertValidRange = (name, length, index) => { 2 | if (index >= length || index < 0) { 3 | throw new DOMException( 4 | `Failed to execute '${name}' on 'TimeRanges': The index provided (${index}) is greater than the maximum bound (${length}).`, 5 | ); 6 | } 7 | return true; 8 | }; 9 | 10 | export class TimeRangesMock { 11 | _ranges = []; 12 | 13 | // Accepts an argument list of [start, end] tuples or { start: number, end: number } objects 14 | constructor(...ranges) { 15 | this._ranges = ranges.map((range) => 16 | Array.isArray(range) ? range : [range.start, range.end], 17 | ); 18 | } 19 | 20 | get length() { 21 | const { _ranges: ranges } = this; 22 | return ranges.length; 23 | } 24 | 25 | start(i) { 26 | const { _ranges: ranges, length } = this; 27 | assertValidRange('start', length, i); 28 | return ranges[i]?.[0]; 29 | } 30 | 31 | end(i) { 32 | const { _ranges: ranges, length } = this; 33 | assertValidRange('end', length, i); 34 | return ranges[i]?.[1]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/crypt/aes-crypto.ts: -------------------------------------------------------------------------------- 1 | import { DecrypterAesMode } from './decrypter-aes-mode'; 2 | 3 | export default class AESCrypto { 4 | private subtle: SubtleCrypto; 5 | private aesIV: Uint8Array; 6 | private aesMode: DecrypterAesMode; 7 | 8 | constructor(subtle: SubtleCrypto, iv: Uint8Array, aesMode: DecrypterAesMode) { 9 | this.subtle = subtle; 10 | this.aesIV = iv; 11 | this.aesMode = aesMode; 12 | } 13 | 14 | decrypt(data: ArrayBuffer, key: CryptoKey) { 15 | switch (this.aesMode) { 16 | case DecrypterAesMode.cbc: 17 | return this.subtle.decrypt( 18 | { name: 'AES-CBC', iv: this.aesIV }, 19 | key, 20 | data, 21 | ); 22 | case DecrypterAesMode.ctr: 23 | return this.subtle.decrypt( 24 | { name: 'AES-CTR', counter: this.aesIV, length: 64 }, //64 : NIST SP800-38A standard suggests that the counter should occupy half of the counter block 25 | key, 26 | data, 27 | ); 28 | default: 29 | throw new Error(`[AESCrypto] invalid aes mode ${this.aesMode}`); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/demux/chunk-cache.ts: -------------------------------------------------------------------------------- 1 | export default class ChunkCache { 2 | private chunks: Array = []; 3 | public dataLength: number = 0; 4 | 5 | push(chunk: Uint8Array) { 6 | this.chunks.push(chunk); 7 | this.dataLength += chunk.length; 8 | } 9 | 10 | flush(): Uint8Array { 11 | const { chunks, dataLength } = this; 12 | let result; 13 | if (!chunks.length) { 14 | return new Uint8Array(0); 15 | } else if (chunks.length === 1) { 16 | result = chunks[0]; 17 | } else { 18 | result = concatUint8Arrays(chunks, dataLength); 19 | } 20 | this.reset(); 21 | return result; 22 | } 23 | 24 | reset() { 25 | this.chunks.length = 0; 26 | this.dataLength = 0; 27 | } 28 | } 29 | 30 | function concatUint8Arrays( 31 | chunks: Array, 32 | dataLength: number, 33 | ): Uint8Array { 34 | const result = new Uint8Array(dataLength); 35 | let offset = 0; 36 | for (let i = 0; i < chunks.length; i++) { 37 | const chunk = chunks[i]; 38 | result.set(chunk, offset); 39 | offset += chunk.length; 40 | } 41 | return result; 42 | } 43 | -------------------------------------------------------------------------------- /tests/unit/events.js: -------------------------------------------------------------------------------- 1 | import { Events } from '../../src/events'; 2 | 3 | function getAllCapsSnakeCaseToCamelCase(eventType) { 4 | let eventValue = ''; 5 | let previousWasUscore, nextChar; 6 | 7 | for (let i = 0; i < eventType.length; i++) { 8 | nextChar = eventType.charAt(i); 9 | if (i !== 0 && !previousWasUscore) { 10 | nextChar = nextChar.toLowerCase(); 11 | } 12 | 13 | previousWasUscore = false; 14 | if (nextChar === '_') { 15 | previousWasUscore = true; 16 | continue; 17 | } 18 | eventValue += nextChar; 19 | } 20 | return eventValue; 21 | } 22 | 23 | describe('Events tests', function () { 24 | describe('Events enumeration', function () { 25 | Object.keys(Events).forEach(function (event) { 26 | it( 27 | 'should have a value matching generics convention for event type: ' + 28 | event, 29 | function () { 30 | const value = Events[event]; 31 | const expected = 'hls' + getAllCapsSnakeCaseToCamelCase(event); 32 | expect(value).to.equal(expected); 33 | }, 34 | ); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /tests/unit/crypt/aes-decryptor.js: -------------------------------------------------------------------------------- 1 | import { removePadding } from '../../../src/crypt/aes-decryptor'; 2 | 3 | describe('AESDecryptor', function () { 4 | describe('removePadding()', function () { 5 | // this should never happen with a valid stream 6 | it('is a no-op when the last byte is 0', function () { 7 | const arr = new Uint8Array([1, 2, 3, 0]); 8 | expect(removePadding(arr)).to.equal(arr); 9 | }); 10 | 11 | it('removes 1 byte when the last byte is 1', function () { 12 | const arr = new Uint8Array([1, 2, 3, 1]); 13 | expect(Array.from(new Uint8Array(removePadding(arr)))).to.deep.equal([ 14 | 1, 2, 3, 15 | ]); 16 | }); 17 | 18 | it('removes 3 bytes when the last byte is 3', function () { 19 | const arr = new Uint8Array([1, 2, 3, 3]); 20 | expect(Array.from(new Uint8Array(removePadding(arr)))).to.deep.equal([1]); 21 | }); 22 | 23 | it('removes 4 bytes when the last byte is 4', function () { 24 | const arr = new Uint8Array([1, 2, 3, 4]); 25 | expect(Array.from(new Uint8Array(removePadding(arr)))).to.deep.equal([]); 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/utils/timescale-conversion.ts: -------------------------------------------------------------------------------- 1 | const MPEG_TS_CLOCK_FREQ_HZ = 90000; 2 | 3 | export type RationalTimestamp = { 4 | baseTime: number; // ticks 5 | timescale: number; // ticks per second 6 | }; 7 | 8 | export function toTimescaleFromBase( 9 | baseTime: number, 10 | destScale: number, 11 | srcBase: number = 1, 12 | round: boolean = false, 13 | ): number { 14 | const result = baseTime * destScale * srcBase; // equivalent to `(value * scale) / (1 / base)` 15 | return round ? Math.round(result) : result; 16 | } 17 | 18 | export function toTimescaleFromScale( 19 | baseTime: number, 20 | destScale: number, 21 | srcScale: number = 1, 22 | round: boolean = false, 23 | ): number { 24 | return toTimescaleFromBase(baseTime, destScale, 1 / srcScale, round); 25 | } 26 | 27 | export function toMsFromMpegTsClock( 28 | baseTime: number, 29 | round: boolean = false, 30 | ): number { 31 | return toTimescaleFromBase(baseTime, 1000, 1 / MPEG_TS_CLOCK_FREQ_HZ, round); 32 | } 33 | 34 | export function toMpegTsClockFromTimescale( 35 | baseTime: number, 36 | srcScale: number = 1, 37 | ): number { 38 | return toTimescaleFromBase(baseTime, MPEG_TS_CLOCK_FREQ_HZ, 1 / srcScale); 39 | } 40 | -------------------------------------------------------------------------------- /tests/mocks/loader.mock.ts: -------------------------------------------------------------------------------- 1 | import { LoadStats } from '../../src/loader/load-stats'; 2 | import type { 3 | FragmentLoaderContext, 4 | Loader, 5 | LoaderCallbacks, 6 | LoaderConfiguration, 7 | LoaderContext, 8 | } from '../../src/types/loader'; 9 | import type { HlsConfig } from '../../src/config'; 10 | 11 | export class MockXhr implements Loader { 12 | context!: LoaderContext; 13 | stats: LoadStats; 14 | callbacks: LoaderCallbacks | null = null; 15 | config: LoaderConfiguration | null = null; 16 | 17 | constructor(confg: HlsConfig) { 18 | this.stats = new LoadStats(); 19 | } 20 | 21 | load( 22 | context: LoaderContext, 23 | config: LoaderConfiguration, 24 | callbacks: LoaderCallbacks, 25 | ) { 26 | this.stats.loading.start = self.performance.now(); 27 | this.context = context; 28 | this.config = config; 29 | this.callbacks = callbacks; 30 | } 31 | 32 | abort() { 33 | if (this.callbacks?.onAbort) { 34 | this.callbacks.onAbort(this.stats, this.context as any, null); 35 | } 36 | } 37 | 38 | destroy(): void { 39 | this.callbacks = null; 40 | this.config = null; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/utils/chunker.ts: -------------------------------------------------------------------------------- 1 | import { appendUint8Array } from './mp4-tools'; 2 | import { sliceUint8 } from './typed-array'; 3 | 4 | export default class Chunker { 5 | private chunkSize: number; 6 | public cache: Uint8Array | null = null; 7 | constructor(chunkSize = Math.pow(2, 19)) { 8 | this.chunkSize = chunkSize; 9 | } 10 | 11 | public push(data: Uint8Array): Array { 12 | const { cache, chunkSize } = this; 13 | const result: Array = []; 14 | 15 | let temp: Uint8Array | null = null; 16 | if (cache?.length) { 17 | temp = appendUint8Array(cache, data); 18 | this.cache = null; 19 | } else { 20 | temp = data; 21 | } 22 | 23 | if (temp.length < chunkSize) { 24 | this.cache = temp; 25 | return result; 26 | } 27 | 28 | if (temp.length > chunkSize) { 29 | let offset = 0; 30 | const len = temp.length; 31 | while (offset < len - chunkSize) { 32 | result.push(sliceUint8(temp, offset, offset + chunkSize)); 33 | offset += chunkSize; 34 | } 35 | this.cache = sliceUint8(temp, offset); 36 | } else { 37 | result.push(temp); 38 | } 39 | 40 | return result; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/mocks/data.js: -------------------------------------------------------------------------------- 1 | import { Fragment } from '../../src/loader/fragment'; 2 | import { PlaylistLevelType } from '../../src/types/loader'; 3 | 4 | function fragment(options) { 5 | const frag = new Fragment(PlaylistLevelType.MAIN, ''); 6 | Object.assign(frag, options); 7 | return frag; 8 | } 9 | 10 | export const mockFragments = [ 11 | fragment({ 12 | programDateTime: 1505502661523, 13 | level: 2, 14 | duration: 5.0, 15 | start: 0, 16 | sn: 0, 17 | cc: 0, 18 | }), 19 | // Discontinuity with PDT 1505502671523 which does not exist in level 1 as per fragPrevious 20 | fragment({ 21 | programDateTime: 1505502671523, 22 | level: 2, 23 | duration: 5.0, 24 | start: 5.0, 25 | sn: 1, 26 | cc: 1, 27 | }), 28 | fragment({ 29 | programDateTime: 1505502676523, 30 | level: 2, 31 | duration: 5.0, 32 | start: 10.0, 33 | sn: 2, 34 | cc: 1, 35 | }), 36 | fragment({ 37 | programDateTime: 1505502681523, 38 | level: 2, 39 | duration: 5.0, 40 | start: 15.0, 41 | sn: 3, 42 | cc: 1, 43 | }), 44 | fragment({ 45 | programDateTime: 1505502686523, 46 | level: 2, 47 | duration: 5.0, 48 | start: 20.0, 49 | sn: 4, 50 | cc: 1, 51 | }), 52 | ]; 53 | -------------------------------------------------------------------------------- /tests/unit/utils/binary-search.js: -------------------------------------------------------------------------------- 1 | import BinarySearch from '../../../src/utils/binary-search'; 2 | 3 | describe('binary search util', function () { 4 | describe('search helper', function () { 5 | let list = null; 6 | const buildComparisonFunction = function (itemToSearchFor) { 7 | return function (candidate) { 8 | if (candidate < itemToSearchFor) { 9 | return 1; 10 | } else if (candidate > itemToSearchFor) { 11 | return -1; 12 | } 13 | 14 | return 0; 15 | }; 16 | }; 17 | 18 | beforeEach(function () { 19 | list = [4, 8, 15, 16, 23, 42]; 20 | }); 21 | it('finds the element if it is present', function () { 22 | for (let i = 0; i < list.length; i++) { 23 | const item = list[i]; 24 | const foundItem = BinarySearch.search( 25 | list, 26 | buildComparisonFunction(item), 27 | ); 28 | expect(foundItem).to.equal(item); 29 | } 30 | }); 31 | it('does not find the element if it is not present', function () { 32 | const item = 1000; 33 | const foundItem = BinarySearch.search( 34 | list, 35 | buildComparisonFunction(item), 36 | ); 37 | expect(foundItem).to.not.exist; 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /tests/functional/auto/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | commonjs: true, 5 | es6: false, 6 | mocha: true, 7 | }, 8 | plugins: ['mocha', 'n'], 9 | globals: { 10 | // Test globals 11 | after: false, 12 | afterEach: false, 13 | assert: false, 14 | before: false, 15 | beforeEach: false, 16 | describe: false, 17 | expect: true, 18 | sinon: false, 19 | xit: false, 20 | }, 21 | rules: { 22 | 'object-shorthand': ['error', 'never'], // Object-shorthand not supported in IE11 23 | // destructuring is not supported in IE11. This does not prevent it. 24 | // ES6 env settings in parent files cannot be overwritten. 25 | 'prefer-destructuring': ['error', { object: false, array: false }], 26 | 'one-var': 0, 27 | 'no-undefined': 0, 28 | 'no-unused-expressions': 0, 29 | 'no-restricted-properties': [ 30 | 2, 31 | { property: 'findIndex' }, // Intended to block usage of Array.prototype.findIndex 32 | { property: 'find' }, // Intended to block usage of Array.prototype.find 33 | { property: 'only' }, // Intended to block usage of it.only in commits 34 | ], 35 | 'n/no-restricted-require': ['error', ['assert']], 36 | 'mocha/no-mocha-arrows': 2, 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /tests/unit/utils/vttparser.ts: -------------------------------------------------------------------------------- 1 | import { parseTimeStamp } from '../../../src/utils/vttparser'; 2 | import chai from 'chai'; 3 | import sinonChai from 'sinon-chai'; 4 | 5 | chai.use(sinonChai); 6 | const expect = chai.expect; 7 | 8 | describe('VTTParser', function () { 9 | describe('parseTimeStamp', function () { 10 | function assertTimeStampValue(timestamp, value) { 11 | expect(parseTimeStamp(timestamp)).to.eq( 12 | value, 13 | `"${timestamp}" should equal ${value}`, 14 | ); 15 | } 16 | it('should parse fractional seconds correctly regardless of length', function () { 17 | assertTimeStampValue('00:00:01.5', 1.5); 18 | assertTimeStampValue('00:00:01.05', 1.05); 19 | assertTimeStampValue('00:00:01.005', 1.005); 20 | assertTimeStampValue('00:00:01.', 1); 21 | }); 22 | 23 | it('should parse h:m:s', function () { 24 | assertTimeStampValue('01:01:01', 3661); 25 | }); 26 | 27 | it('should parse h>59:m and h>59:m.ms', function () { 28 | assertTimeStampValue('60:01', 216060); 29 | assertTimeStampValue('60:01.55', 216060.55); 30 | }); 31 | 32 | it('should parse m:s and m:s.ms', function () { 33 | assertTimeStampValue('01:01', 61); 34 | assertTimeStampValue('01:01.09', 61.09); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /docs/release-process.md: -------------------------------------------------------------------------------- 1 | # Performing A Release 2 | 3 | Releases are performed automatically with [GitHub actions](https://github.com/video-dev/hls.js/actions?query=workflow%3ABuild+branch%3Amaster). 4 | 5 | Note that [protected tags](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/managing-repository-settings/configuring-tag-protection-rules) are configured so you need to be either an admin or maintainer to push the tag. 6 | 7 | 1. `git tag v..` or `git tag v..-` _('v' required)_ where anything before the first `.` in `` will be become the [npm dist-tag](https://docs.npmjs.com/cli/dist-tag). 8 | 1. `git push` 9 | 1. `git push --tag` 10 | 1. Wait for the GitHub action to create a new draft GitHub release with the build attached. The publish to npm should happen around the same time from a different step. 11 | 1. Update the release notes to the new draft GitHub release if needed. 12 | 1. Publish the GitHub release. 13 | 14 | ## Examples 15 | 16 | - `git tag -a v1.2.3` will result in `1.2.3` being published with the `latest` npm tag. 17 | - `git tag -a v1.2.3-beta` will result in `1.2.3-beta` being published with the `beta` npm tag. 18 | - `git tag -a v1.2.3-beta.1` will result in `1.2.3-beta.1` being published with the `beta` npm tag. 19 | -------------------------------------------------------------------------------- /tests/unit/demuxer/base-audio-demuxer.ts: -------------------------------------------------------------------------------- 1 | import { initPTSFn } from '../../../src/demux/audio/base-audio-demuxer'; 2 | import { expect } from 'chai'; 3 | 4 | describe('BaseAudioDemuxer', function () { 5 | describe('initPTSFn', function () { 6 | it('should use the timestamp if it is valid', function () { 7 | expect(initPTSFn(1, 1, { baseTime: 0, timescale: 1 })).to.be.eq(90); 8 | expect(initPTSFn(5, 1, { baseTime: 0, timescale: 1 })).to.be.eq(450); 9 | expect(initPTSFn(0, 1, { baseTime: 0, timescale: 1 })).to.be.eq(0); 10 | }); 11 | it('should use the timeOffset if timestamp is undefined or not finite', function () { 12 | expect(initPTSFn(undefined, 1, { baseTime: 0, timescale: 1 })).to.be.eq( 13 | 90000, 14 | ); 15 | expect(initPTSFn(NaN, 1, { baseTime: 0, timescale: 1 })).to.be.eq(90000); 16 | expect(initPTSFn(Infinity, 1, { baseTime: 0, timescale: 1 })).to.be.eq( 17 | 90000, 18 | ); 19 | }); 20 | it('should add initPTS to timeOffset when timestamp is undefined or not finite', function () { 21 | expect( 22 | initPTSFn(undefined, 1, { baseTime: 42, timescale: 90000 }), 23 | ).to.be.eq(90042); 24 | expect(initPTSFn(NaN, 1, { baseTime: 42, timescale: 90000 })).to.be.eq( 25 | 90042, 26 | ); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", 3 | "mainEntryPointFilePath": "/lib/hls.d.ts", 4 | "bundledPackages": [], 5 | "compiler": { 6 | "tsconfigFilePath": "/tsconfig-lib.json" 7 | }, 8 | "apiReport": { 9 | "enabled": true, 10 | "reportFolder": "/api-extractor/report", 11 | "reportTempFolder": "/api-extractor/report-temp" 12 | }, 13 | "docModel": { 14 | "enabled": true, 15 | "apiJsonFilePath": "/api-extractor/.api.json" 16 | }, 17 | "dtsRollup": { 18 | "enabled": true, 19 | "untrimmedFilePath": "/dist/hls.d.ts" 20 | }, 21 | "tsdocMetadata": { 22 | "enabled": false 23 | }, 24 | "newlineKind": "lf", 25 | "messages": { 26 | "compilerMessageReporting": { 27 | "default": { 28 | "logLevel": "warning", 29 | "addToApiReportFile": true 30 | } 31 | }, 32 | "extractorMessageReporting": { 33 | "default": { 34 | "logLevel": "warning", 35 | "addToApiReportFile": true 36 | } 37 | }, 38 | "tsdocMessageReporting": { 39 | "default": { 40 | "logLevel": "none", 41 | "addToApiReportFile": false 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/types/transmuxer.ts: -------------------------------------------------------------------------------- 1 | import type { RemuxerResult } from './remuxer'; 2 | import type { HlsChunkPerformanceTiming } from './loader'; 3 | import type { SourceBufferName } from './buffer'; 4 | 5 | export interface TransmuxerResult { 6 | remuxResult: RemuxerResult; 7 | chunkMeta: ChunkMetadata; 8 | } 9 | 10 | export class ChunkMetadata { 11 | public readonly level: number; 12 | public readonly sn: number; 13 | public readonly part: number; 14 | public readonly id: number; 15 | public readonly size: number; 16 | public readonly partial: boolean; 17 | public readonly transmuxing: HlsChunkPerformanceTiming = 18 | getNewPerformanceTiming(); 19 | public readonly buffering: { 20 | [key in SourceBufferName]: HlsChunkPerformanceTiming; 21 | } = { 22 | audio: getNewPerformanceTiming(), 23 | video: getNewPerformanceTiming(), 24 | audiovideo: getNewPerformanceTiming(), 25 | }; 26 | 27 | constructor( 28 | level: number, 29 | sn: number, 30 | id: number, 31 | size = 0, 32 | part = -1, 33 | partial = false, 34 | ) { 35 | this.level = level; 36 | this.sn = sn; 37 | this.id = id; 38 | this.size = size; 39 | this.part = part; 40 | this.partial = partial; 41 | } 42 | } 43 | 44 | function getNewPerformanceTiming(): HlsChunkPerformanceTiming { 45 | return { start: 0, executeStart: 0, executeEnd: 0, end: 0 }; 46 | } 47 | -------------------------------------------------------------------------------- /src/utils/ewma.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * compute an Exponential Weighted moving average 3 | * - https://en.wikipedia.org/wiki/Moving_average#Exponential_moving_average 4 | * - heavily inspired from shaka-player 5 | */ 6 | 7 | class EWMA { 8 | public readonly halfLife: number; 9 | private alpha_: number; 10 | private estimate_: number; 11 | private totalWeight_: number; 12 | 13 | // About half of the estimated value will be from the last |halfLife| samples by weight. 14 | constructor(halfLife: number, estimate: number = 0, weight: number = 0) { 15 | this.halfLife = halfLife; 16 | // Larger values of alpha expire historical data more slowly. 17 | this.alpha_ = halfLife ? Math.exp(Math.log(0.5) / halfLife) : 0; 18 | this.estimate_ = estimate; 19 | this.totalWeight_ = weight; 20 | } 21 | 22 | sample(weight: number, value: number) { 23 | const adjAlpha = Math.pow(this.alpha_, weight); 24 | this.estimate_ = value * (1 - adjAlpha) + adjAlpha * this.estimate_; 25 | this.totalWeight_ += weight; 26 | } 27 | 28 | getTotalWeight(): number { 29 | return this.totalWeight_; 30 | } 31 | 32 | getEstimate(): number { 33 | if (this.alpha_) { 34 | const zeroFactor = 1 - Math.pow(this.alpha_, this.totalWeight_); 35 | if (zeroFactor) { 36 | return this.estimate_ / zeroFactor; 37 | } 38 | } 39 | return this.estimate_; 40 | } 41 | } 42 | 43 | export default EWMA; 44 | -------------------------------------------------------------------------------- /docs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Dailymotion (http://www.dailymotion.com) 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | 15 | src/remux/mp4-generator.js and src/demux/exp-golomb.ts implementation in this project 16 | are derived from the HLS library for video.js (https://github.com/videojs/videojs-contrib-hls) 17 | 18 | That work is also covered by the Apache 2 License, following copyright: 19 | Copyright (c) 2013-2015 Brightcove 20 | 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 28 | THE SOFTWARE. 29 | -------------------------------------------------------------------------------- /src/utils/output-filter.ts: -------------------------------------------------------------------------------- 1 | import type { TimelineController } from '../controller/timeline-controller'; 2 | import type { CaptionScreen } from './cea-608-parser'; 3 | 4 | export default class OutputFilter { 5 | private timelineController: TimelineController; 6 | private cueRanges: Array<[number, number]> = []; 7 | private trackName: string; 8 | private startTime: number | null = null; 9 | private endTime: number | null = null; 10 | private screen: CaptionScreen | null = null; 11 | 12 | constructor(timelineController: TimelineController, trackName: string) { 13 | this.timelineController = timelineController; 14 | this.trackName = trackName; 15 | } 16 | 17 | dispatchCue() { 18 | if (this.startTime === null) { 19 | return; 20 | } 21 | 22 | this.timelineController.addCues( 23 | this.trackName, 24 | this.startTime, 25 | this.endTime as number, 26 | this.screen as CaptionScreen, 27 | this.cueRanges, 28 | ); 29 | this.startTime = null; 30 | } 31 | 32 | newCue(startTime: number, endTime: number, screen: CaptionScreen) { 33 | if (this.startTime === null || this.startTime > startTime) { 34 | this.startTime = startTime; 35 | } 36 | 37 | this.endTime = endTime; 38 | this.screen = screen; 39 | this.timelineController.createCaptionsTrack(this.trackName); 40 | } 41 | 42 | reset() { 43 | this.cueRanges = []; 44 | this.startTime = null; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /demo/basic-usage.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Hls.js demo - basic usage 4 | 5 | 6 | 7 | 8 | 9 |
10 |

Hls.js demo - basic usage

11 | 12 |
13 | 14 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 18 3 | 4 | # Number of days of inactivity before a stale issue is closed 5 | daysUntilClose: 7 6 | 7 | # Issues with these labels will never be considered stale 8 | exemptLabels: 9 | - pinned 10 | - security 11 | - good-first-issue 12 | - Browser issue 13 | - CI 14 | - Confirmed 15 | - Chore 16 | - Enhancement 17 | - Feature proposal 18 | - Missing Feature 19 | - Needs Triage 20 | 21 | # Set to true to ignore issues in a project (defaults to false) 22 | exemptProjects: true 23 | 24 | # Set to true to ignore issues in a milestone (defaults to false) 25 | exemptMilestones: true 26 | 27 | # Set to true to ignore issues with an assignee (defaults to false) 28 | # exemptAssignees: true 29 | 30 | # Label to use when marking an issue as stale 31 | staleLabel: Stale 32 | 33 | # Comment to post when marking an issue as stale. Set to `false` to disable 34 | markComment: > 35 | This issue has been automatically marked as stale because it has not had 36 | recent activity. It will be closed if no further activity occurs. 37 | 38 | # Comment to post when closing a stale issue. Set to `false` to disable 39 | closeComment: > 40 | This issue has been automatically closed because it has not had 41 | recent activity. If this issue is still valid, please ping a maintainer and 42 | ask them to label it accordingly. 43 | 44 | # Limit the number of actions per hour, from 1-30. Default is 30 45 | limitPerRun: 3 46 | -------------------------------------------------------------------------------- /src/utils/keysystem-util.ts: -------------------------------------------------------------------------------- 1 | import { base64Decode } from './numeric-encoding-utils'; 2 | import { strToUtf8array } from './utf8-utils'; 3 | 4 | function getKeyIdBytes(str: string): Uint8Array { 5 | const keyIdbytes = strToUtf8array(str).subarray(0, 16); 6 | const paddedkeyIdbytes = new Uint8Array(16); 7 | paddedkeyIdbytes.set(keyIdbytes, 16 - keyIdbytes.length); 8 | return paddedkeyIdbytes; 9 | } 10 | 11 | export function changeEndianness(keyId: Uint8Array) { 12 | const swap = function (array: Uint8Array, from: number, to: number) { 13 | const cur = array[from]; 14 | array[from] = array[to]; 15 | array[to] = cur; 16 | }; 17 | 18 | swap(keyId, 0, 3); 19 | swap(keyId, 1, 2); 20 | swap(keyId, 4, 5); 21 | swap(keyId, 6, 7); 22 | } 23 | 24 | export function convertDataUriToArrayBytes(uri: string): Uint8Array | null { 25 | // data:[ 26 | const colonsplit = uri.split(':'); 27 | let keydata: Uint8Array | null = null; 28 | if (colonsplit[0] === 'data' && colonsplit.length === 2) { 29 | const semicolonsplit = colonsplit[1].split(';'); 30 | const commasplit = semicolonsplit[semicolonsplit.length - 1].split(','); 31 | if (commasplit.length === 2) { 32 | const isbase64 = commasplit[0] === 'base64'; 33 | const data = commasplit[1]; 34 | if (isbase64) { 35 | semicolonsplit.splice(-1, 1); // remove from processing 36 | keydata = base64Decode(data); 37 | } else { 38 | keydata = getKeyIdBytes(data); 39 | } 40 | } 41 | } 42 | return keydata; 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/binary-search.ts: -------------------------------------------------------------------------------- 1 | type BinarySearchComparison = (candidate: T) => -1 | 0 | 1; 2 | 3 | const BinarySearch = { 4 | /** 5 | * Searches for an item in an array which matches a certain condition. 6 | * This requires the condition to only match one item in the array, 7 | * and for the array to be ordered. 8 | * 9 | * @param list The array to search. 10 | * @param comparisonFn 11 | * Called and provided a candidate item as the first argument. 12 | * Should return: 13 | * > -1 if the item should be located at a lower index than the provided item. 14 | * > 1 if the item should be located at a higher index than the provided item. 15 | * > 0 if the item is the item you're looking for. 16 | * 17 | * @returns the object if found, otherwise returns null 18 | */ 19 | search: function ( 20 | list: T[], 21 | comparisonFn: BinarySearchComparison, 22 | ): T | null { 23 | let minIndex: number = 0; 24 | let maxIndex: number = list.length - 1; 25 | let currentIndex: number | null = null; 26 | let currentElement: T | null = null; 27 | 28 | while (minIndex <= maxIndex) { 29 | currentIndex = ((minIndex + maxIndex) / 2) | 0; 30 | currentElement = list[currentIndex]; 31 | 32 | const comparisonResult = comparisonFn(currentElement); 33 | if (comparisonResult > 0) { 34 | minIndex = currentIndex + 1; 35 | } else if (comparisonResult < 0) { 36 | maxIndex = currentIndex - 1; 37 | } else { 38 | return currentElement; 39 | } 40 | } 41 | 42 | return null; 43 | }, 44 | }; 45 | 46 | export default BinarySearch; 47 | -------------------------------------------------------------------------------- /tests/unit/utils/exp-golomb.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import ExpGolomb from '../../../src/demux/video/exp-golomb'; 3 | 4 | describe('Exp-Golomb reader', function () { 5 | const testBuffer = new Uint8Array([ 6 | 0b00000111, 0b01000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 7 | 0b00000000, 0b00000000, 0b00000000, 0b00001101, 0b11011101, 0b10000000, 8 | 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b11111111, 0b11111111, 9 | 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 10 | 0b10101010, 11 | ]); 12 | const reader = new ExpGolomb(testBuffer); 13 | 14 | it('should return 29 (0b 1110 1)', function () { 15 | reader.skipBits(5); 16 | expect(reader.readBits(5)).to.equal(0b11101); 17 | }); 18 | 19 | it('should return 7099 (0b 1101 1101 1101 1)', function () { 20 | reader.skipBits(6 + 56 + 4); 21 | expect(reader.readBits(13)).to.equal(0b1101110111011); 22 | }); 23 | 24 | it('should return UINT_MAX(4294967295) twice', function () { 25 | reader.skipBits(39); 26 | expect(reader.readBits(32)).to.equal(4294967295); //0b 11111111 11111111 11111111 11111111 27 | expect(reader.readBits(32)).to.equal(4294967295); //0b 11111111 11111111 11111111 11111111 28 | }); 29 | 30 | it('should throw error if can no longer buffer be skip', function () { 31 | expect(reader.readBits(4)).to.equal(0b1010); 32 | expect(function () { 33 | reader.skipBits(16); 34 | }).to.throw('no bytes available'); 35 | }); 36 | 37 | it('should throw error if can no longer buffer be read', function () { 38 | expect(function () { 39 | reader.readBits(16); 40 | }).to.throw('no bits available'); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/is-supported.ts: -------------------------------------------------------------------------------- 1 | import { getMediaSource } from './utils/mediasource-helper'; 2 | import { mimeTypeForCodec } from './utils/codecs'; 3 | import type { ExtendedSourceBuffer } from './types/buffer'; 4 | 5 | function getSourceBuffer(): typeof self.SourceBuffer { 6 | return self.SourceBuffer || (self as any).WebKitSourceBuffer; 7 | } 8 | 9 | export function isMSESupported(): boolean { 10 | const mediaSource = getMediaSource(); 11 | if (!mediaSource) { 12 | return false; 13 | } 14 | 15 | // if SourceBuffer is exposed ensure its API is valid 16 | // Older browsers do not expose SourceBuffer globally so checking SourceBuffer.prototype is impossible 17 | const sourceBuffer = getSourceBuffer(); 18 | return ( 19 | !sourceBuffer || 20 | (sourceBuffer.prototype && 21 | typeof sourceBuffer.prototype.appendBuffer === 'function' && 22 | typeof sourceBuffer.prototype.remove === 'function') 23 | ); 24 | } 25 | 26 | export function isSupported(): boolean { 27 | if (!isMSESupported()) { 28 | return false; 29 | } 30 | 31 | const mediaSource = getMediaSource(); 32 | return ( 33 | typeof mediaSource?.isTypeSupported === 'function' && 34 | (['avc1.42E01E,mp4a.40.2', 'av01.0.01M.08', 'vp09.00.50.08'].some( 35 | (codecsForVideoContainer) => 36 | mediaSource.isTypeSupported( 37 | mimeTypeForCodec(codecsForVideoContainer, 'video'), 38 | ), 39 | ) || 40 | ['mp4a.40.2', 'fLaC'].some((codecForAudioContainer) => 41 | mediaSource.isTypeSupported( 42 | mimeTypeForCodec(codecForAudioContainer, 'audio'), 43 | ), 44 | )) 45 | ); 46 | } 47 | 48 | export function changeTypeSupported(): boolean { 49 | const sourceBuffer = getSourceBuffer(); 50 | return ( 51 | typeof (sourceBuffer?.prototype as ExtendedSourceBuffer)?.changeType === 52 | 'function' 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/utils/media-option-attributes.ts: -------------------------------------------------------------------------------- 1 | import type { Level } from '../types/level'; 2 | import type { MediaAttributes, MediaPlaylist } from '../types/media-playlist'; 3 | 4 | export function subtitleOptionsIdentical( 5 | trackList1: MediaPlaylist[] | Level[], 6 | trackList2: MediaPlaylist[], 7 | ): boolean { 8 | if (trackList1.length !== trackList2.length) { 9 | return false; 10 | } 11 | for (let i = 0; i < trackList1.length; i++) { 12 | if ( 13 | !mediaAttributesIdentical( 14 | trackList1[i].attrs as MediaAttributes, 15 | trackList2[i].attrs, 16 | ) 17 | ) { 18 | return false; 19 | } 20 | } 21 | return true; 22 | } 23 | 24 | export function mediaAttributesIdentical( 25 | attrs1: MediaAttributes, 26 | attrs2: MediaAttributes, 27 | customAttributes?: string[], 28 | ): boolean { 29 | // Media options with the same rendition ID must be bit identical 30 | const stableRenditionId = attrs1['STABLE-RENDITION-ID']; 31 | if (stableRenditionId && !customAttributes) { 32 | return stableRenditionId === attrs2['STABLE-RENDITION-ID']; 33 | } 34 | // When rendition ID is not present, compare attributes 35 | return !( 36 | customAttributes || [ 37 | 'LANGUAGE', 38 | 'NAME', 39 | 'CHARACTERISTICS', 40 | 'AUTOSELECT', 41 | 'DEFAULT', 42 | 'FORCED', 43 | 'ASSOC-LANGUAGE', 44 | ] 45 | ).some( 46 | (subtitleAttribute) => 47 | attrs1[subtitleAttribute] !== attrs2[subtitleAttribute], 48 | ); 49 | } 50 | 51 | export function subtitleTrackMatchesTextTrack( 52 | subtitleTrack: Pick, 53 | textTrack: TextTrack, 54 | ) { 55 | return ( 56 | textTrack.label.toLowerCase() === subtitleTrack.name.toLowerCase() && 57 | (!textTrack.language || 58 | textTrack.language.toLowerCase() === 59 | (subtitleTrack.lang || '').toLowerCase()) 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /tests/functional/multiple/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | hls.js 8 | 9 | 23 | 24 | 25 | 26 | 51 | 52 | 53 | 54 |

hls.js

55 | 56 |

First instance

57 | 58 |
59 | 60 | 61 |
62 |
63 | 64 |

Second instance

65 | 66 |
67 | 68 | 69 |
70 |
71 | 72 | 73 | -------------------------------------------------------------------------------- /tests/unit/utils/error-helper.js: -------------------------------------------------------------------------------- 1 | import { shouldRetry } from '../../../src/utils/error-helper'; 2 | 3 | describe('ErrorHelper', function () { 4 | it('shouldRetry', function () { 5 | const retryConfig = { 6 | maxNumRetry: 3, 7 | }; 8 | expect( 9 | shouldRetry(retryConfig, 3, false, { 10 | url: '', 11 | data: undefined, 12 | code: 502, 13 | }), 14 | ).to.be.false; 15 | expect( 16 | shouldRetry(null, 3, false, { 17 | url: '', 18 | data: undefined, 19 | code: 502, 20 | }), 21 | ).to.be.false; 22 | expect( 23 | shouldRetry(retryConfig, 2, false, { 24 | url: '', 25 | data: undefined, 26 | code: 502, 27 | }), 28 | ).to.be.true; 29 | expect( 30 | shouldRetry(retryConfig, 2, false, { 31 | url: '', 32 | data: undefined, 33 | code: 404, 34 | }), 35 | ).to.be.false; 36 | 37 | retryConfig.shouldRetry = ( 38 | _retryConfig, 39 | _retryCount, 40 | _isTimeout, 41 | loaderResponse, 42 | retry, 43 | ) => { 44 | if (!retry && loaderResponse?.code === 404) { 45 | return true; 46 | } 47 | 48 | return false; 49 | }; 50 | expect( 51 | shouldRetry( 52 | retryConfig, 53 | 5, 54 | false, 55 | { 56 | url: '', 57 | data: undefined, 58 | code: 404, 59 | }, 60 | false, 61 | ), 62 | ).to.be.true; 63 | 64 | retryConfig.shouldRetry = (retryConfig, retryCount) => { 65 | return retryConfig.maxNumRetry <= retryCount; 66 | }; 67 | expect( 68 | shouldRetry( 69 | retryConfig, 70 | 2, 71 | false, 72 | { 73 | url: '', 74 | data: undefined, 75 | code: 502, 76 | }, 77 | true, 78 | ), 79 | ).to.be.false; 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /scripts/build-deployments-readme.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const fs = require('node:fs'); 3 | const path = require('node:path'); 4 | const assert = require('node:assert'); 5 | 6 | const itemCountMd = 100; 7 | const txtFileName = 'deployments.txt'; 8 | 9 | async function go() { 10 | // eslint-disable-next-line no-undef 11 | const [, , deploymentsFile, outputDir] = process.argv; 12 | 13 | assert(deploymentsFile, 'Missing deploymentsFile'); 14 | assert(outputDir, 'Missing outputDir'); 15 | 16 | const { stable, latest, individual } = JSON.parse( 17 | await fs.promises.readFile(deploymentsFile, { encoding: 'utf-8' }), 18 | ); 19 | 20 | const mdContent = `# Deployments 21 | 22 | - **Stable:** [${stable}](${stable}) 23 | - **Latest:** [${latest}](${latest}) 24 | 25 | Below you can find the URL's to deployments for individual commits: 26 | 27 | ${Array.from(individual) 28 | .reverse() 29 | .slice(0, itemCountMd) 30 | .map( 31 | ({ commit, version, url }) => 32 | `- [\`${commit.slice( 33 | 0, 34 | 8, 35 | )} (${version})\`](https://github.com/video-dev/hls.js/commit/${commit}): [${url}](${url})`, 36 | ) 37 | .join('\n')} 38 | 39 | _Note for older deployments please check [${txtFileName}](./${txtFileName})._ 40 | `; 41 | 42 | await fs.promises.writeFile(path.resolve(outputDir, 'README.md'), mdContent, { 43 | encoding: 'utf-8', 44 | }); 45 | 46 | const txtContent = `Deployments 47 | =========== 48 | 49 | - Stable: ${stable} 50 | - Latest: ${latest} 51 | 52 | Below you can find the URL's to deployments for individual commits: 53 | 54 | ${Array.from(individual) 55 | .reverse() 56 | .map( 57 | ({ commit, version, url }) => 58 | `- ${commit.slice(0, 8)} (${version}): ${url}`, 59 | ) 60 | .join('\n')} 61 | `; 62 | 63 | await fs.promises.writeFile( 64 | path.resolve(outputDir, txtFileName), 65 | txtContent, 66 | { 67 | encoding: 'utf-8', 68 | }, 69 | ); 70 | } 71 | 72 | go().catch((e) => { 73 | console.error(e); 74 | // eslint-disable-next-line no-undef 75 | process.exit(1); 76 | }); 77 | -------------------------------------------------------------------------------- /tests/unit/utils/output-filter.js: -------------------------------------------------------------------------------- 1 | import OutputFilter from '../../../src/utils/output-filter'; 2 | 3 | describe('OutputFilter', function () { 4 | const sandbox = sinon.createSandbox(); 5 | 6 | const createMockTimelineController = function () { 7 | return { 8 | addCues: sandbox.spy(), 9 | createCaptionsTrack: sandbox.spy(), 10 | }; 11 | }; 12 | 13 | let timelineController, outputFilter; 14 | 15 | beforeEach(function () { 16 | timelineController = createMockTimelineController(); 17 | outputFilter = new OutputFilter(timelineController, 1); 18 | }); 19 | 20 | it('handles new cue without dispatching', function () { 21 | outputFilter.newCue(0, 1, {}); 22 | expect(timelineController.addCues).to.not.have.been.called; 23 | expect(timelineController.createCaptionsTrack).to.have.been.called; 24 | }); 25 | 26 | it('handles single cue and dispatch', function () { 27 | const lastScreen = {}; 28 | outputFilter.newCue(0, 1, lastScreen); 29 | outputFilter.dispatchCue(); 30 | expect(timelineController.addCues).to.have.been.calledOnce; 31 | expect(timelineController.addCues).to.have.been.calledWith( 32 | 1, 33 | 0, 34 | 1, 35 | lastScreen, 36 | ); 37 | }); 38 | 39 | it('handles multiple cues and dispatch', function () { 40 | outputFilter.newCue(0, 1, {}); 41 | outputFilter.newCue(1, 2, {}); 42 | const lastScreen = {}; 43 | outputFilter.newCue(3, 4, lastScreen); 44 | outputFilter.dispatchCue(); 45 | expect(timelineController.addCues).to.have.been.calledOnce; 46 | expect(timelineController.addCues).to.have.been.calledWith( 47 | 1, 48 | 0, 49 | 4, 50 | lastScreen, 51 | ); 52 | }); 53 | 54 | it('does not dispatch empty cues', function () { 55 | outputFilter.newCue(0, 1, {}); 56 | expect(timelineController.addCues).to.not.have.been.called; 57 | outputFilter.dispatchCue(); 58 | expect(timelineController.addCues).to.have.been.calledOnce; 59 | outputFilter.dispatchCue(); 60 | expect(timelineController.addCues).to.have.been.calledOnce; 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /tests/unit/utils/texttrack-utils.js: -------------------------------------------------------------------------------- 1 | import { 2 | sendAddTrackEvent, 3 | clearCurrentCues, 4 | } from '../../../src/utils/texttrack-utils'; 5 | import sinon from 'sinon'; 6 | 7 | describe('text track utils', function () { 8 | const cues = [ 9 | { 10 | begin: 0, 11 | end: 5, 12 | text: 'First 5', 13 | }, 14 | { 15 | begin: 5, 16 | end: 10, 17 | text: 'Last 5', 18 | }, 19 | ]; 20 | 21 | let track; 22 | let video; 23 | 24 | beforeEach(function () { 25 | video = document.createElement('video'); 26 | track = video.addTextTrack('subtitles', 'test'); 27 | cues.forEach((cue) => { 28 | track.addCue(new VTTCue(cue.begin, cue.end, cue.text)); 29 | }); 30 | }); 31 | 32 | describe('synthetic addtrack event', function () { 33 | it('should have the provided track as data', function (done) { 34 | const dispatchSpy = sinon.spy(video, 'dispatchEvent'); 35 | video.addEventListener('addtrack', function (e) { 36 | expect(e.track).to.equal(track); 37 | done(); 38 | }); 39 | 40 | sendAddTrackEvent(track, video); 41 | expect(dispatchSpy.calledOnce).to.be.true; 42 | }); 43 | 44 | it('should fallback to document.createEvent if window.Event constructor throws', function (done) { 45 | const stub = sinon.stub(self, 'Event'); 46 | stub.throws(); 47 | 48 | const spy = sinon.spy(document, 'createEvent'); 49 | 50 | video.addEventListener('addtrack', function (e) { 51 | expect(e.track).to.equal(track); 52 | done(); 53 | }); 54 | 55 | sendAddTrackEvent(track, video); 56 | expect(spy.calledOnce).to.be.true; 57 | }); 58 | }); 59 | 60 | describe('clear current cues', function () { 61 | it('should not fail with empty cue list', function () { 62 | const emptyTrack = video.addTextTrack('subtitles', 'empty'); 63 | expect(clearCurrentCues(emptyTrack)).to.not.throw; 64 | }); 65 | 66 | it('should clear the cues from track', function () { 67 | clearCurrentCues(track); 68 | expect(track.cues.length).to.equal(0); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /demo/benchmark.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 35 | 47 | 66 |
67 | 68 | 69 | -------------------------------------------------------------------------------- /tests/unit/controller/timeline-controller-nonnative.js: -------------------------------------------------------------------------------- 1 | import { TimelineController } from '../../../src/controller/timeline-controller'; 2 | import Hls from '../../../src/hls'; 3 | import sinon from 'sinon'; 4 | 5 | describe('Non-Native TimelineController functions', function () { 6 | let timelineController; 7 | let hls; 8 | 9 | beforeEach(function () { 10 | hls = new Hls(); 11 | hls.config.renderTextTracksNatively = false; 12 | hls.config.enableWebVTT = true; 13 | timelineController = new TimelineController(hls); 14 | timelineController.media = document.createElement('video'); 15 | }); 16 | 17 | it('has the createNonNativeTrack method', function () { 18 | expect(timelineController.createNonNativeTrack).to.be.a('function'); 19 | }); 20 | 21 | it('has the createNativeTrack method', function () { 22 | expect(timelineController.createNativeTrack).to.be.a('function'); 23 | }); 24 | 25 | it('calls createNonNativeTrack when renderTextTracksNatively is false', function () { 26 | const nonNativeSpy = sinon.spy(); 27 | timelineController.createNonNativeTrack = nonNativeSpy; 28 | 29 | timelineController.createCaptionsTrack('foo'); 30 | expect(nonNativeSpy).to.have.been.calledOnce; 31 | }); 32 | 33 | it('fires the NON_NATIVE_TEXT_TRACKS_FOUND event', function (done) { 34 | hls.on(Hls.Events.NON_NATIVE_TEXT_TRACKS_FOUND, (event, data) => { 35 | const track = data.tracks[0]; 36 | expect(track.kind).to.equal('captions'); 37 | expect(track.default).to.equal(false); 38 | expect(track.label).to.equal( 39 | timelineController.captionsProperties.textTrack1.label, 40 | ); 41 | expect(timelineController.nonNativeCaptionsTracks.textTrack1).to.equal( 42 | track, 43 | ); 44 | done(); 45 | }); 46 | 47 | timelineController.createNonNativeTrack('textTrack1'); 48 | }); 49 | 50 | it('does not create a non native track if the track does not have any defined properties', function () { 51 | const triggerSpy = sinon.spy(hls, 'trigger'); 52 | timelineController.createNonNativeTrack('foo'); 53 | expect(triggerSpy).to.have.not.been.called; 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /tests/index.js: -------------------------------------------------------------------------------- 1 | import 'promise-polyfill/src/polyfill'; 2 | import './unit/hls'; 3 | import './unit/events'; 4 | import './unit/controller/abr-controller'; 5 | import './unit/controller/audio-stream-controller'; 6 | import './unit/controller/audio-track-controller'; 7 | import './unit/controller/base-stream-controller'; 8 | import './unit/controller/buffer-controller-operations'; 9 | import './unit/controller/buffer-controller'; 10 | import './unit/controller/buffer-operation-queue'; 11 | import './unit/controller/cap-level-controller'; 12 | import './unit/controller/cmcd-controller'; 13 | import './unit/controller/content-steering-controller'; 14 | import './unit/controller/eme-controller'; 15 | import './unit/controller/error-controller'; 16 | import './unit/controller/ewma-bandwidth-estimator'; 17 | import './unit/controller/fragment-finders'; 18 | import './unit/controller/fragment-tracker'; 19 | import './unit/controller/gap-controller'; 20 | import './unit/controller/latency-controller'; 21 | import './unit/controller/level-controller'; 22 | import './unit/controller/level-helper'; 23 | import './unit/controller/stream-controller'; 24 | import './unit/controller/subtitle-stream-controller'; 25 | import './unit/controller/subtitle-track-controller'; 26 | import './unit/controller/timeline-controller-nonnative'; 27 | import './unit/controller/timeline-controller'; 28 | import './unit/crypt/aes-decryptor'; 29 | import './unit/crypt/decrypter'; 30 | import './unit/demuxer/adts'; 31 | import './unit/demuxer/base-audio-demuxer'; 32 | import './unit/loader/date-range'; 33 | import './unit/loader/fragment-loader'; 34 | import './unit/loader/fragment'; 35 | import './unit/loader/level'; 36 | import './unit/loader/playlist-loader'; 37 | import './unit/utils/attr-list'; 38 | import './unit/utils/binary-search'; 39 | import './unit/utils/buffer-helper'; 40 | import './unit/utils/codecs'; 41 | import './unit/utils/error-helper'; 42 | import './unit/utils/discontinuities'; 43 | import './unit/utils/exp-golomb'; 44 | import './unit/utils/output-filter'; 45 | import './unit/utils/texttrack-utils'; 46 | import './unit/utils/vttparser'; 47 | import './unit/utils/utf8'; 48 | import './unit/demuxer/transmuxer'; 49 | -------------------------------------------------------------------------------- /src/types/remuxer.ts: -------------------------------------------------------------------------------- 1 | import type { TrackSet } from './track'; 2 | import type { 3 | DemuxedAudioTrack, 4 | DemuxedMetadataTrack, 5 | DemuxedUserdataTrack, 6 | DemuxedVideoTrackBase, 7 | MetadataSample, 8 | UserdataSample, 9 | } from './demuxer'; 10 | import type { SourceBufferName } from './buffer'; 11 | import type { PlaylistLevelType } from './loader'; 12 | import type { DecryptData } from '../loader/level-key'; 13 | import type { RationalTimestamp } from '../utils/timescale-conversion'; 14 | 15 | export interface Remuxer { 16 | remux( 17 | audioTrack: DemuxedAudioTrack, 18 | videoTrack: DemuxedVideoTrackBase, 19 | id3Track: DemuxedMetadataTrack, 20 | textTrack: DemuxedUserdataTrack, 21 | timeOffset: number, 22 | accurateTimeOffset: boolean, 23 | flush: boolean, 24 | playlistType: PlaylistLevelType, 25 | ): RemuxerResult; 26 | resetInitSegment( 27 | initSegment: Uint8Array | undefined, 28 | audioCodec: string | undefined, 29 | videoCodec: string | undefined, 30 | decryptdata: DecryptData | null, 31 | ): void; 32 | resetTimeStamp(defaultInitPTS: RationalTimestamp | null): void; 33 | resetNextTimestamp(): void; 34 | destroy(): void; 35 | } 36 | 37 | export interface RemuxedTrack { 38 | data1: Uint8Array; 39 | data2?: Uint8Array; 40 | startPTS: number; 41 | endPTS: number; 42 | startDTS: number; 43 | endDTS: number; 44 | type: SourceBufferName; 45 | hasAudio: boolean; 46 | hasVideo: boolean; 47 | independent?: boolean; 48 | firstKeyFrame?: number; 49 | firstKeyFramePTS?: number; 50 | nb: number; 51 | transferredData1?: ArrayBuffer; 52 | transferredData2?: ArrayBuffer; 53 | dropped?: number; 54 | } 55 | 56 | export interface RemuxedMetadata { 57 | samples: MetadataSample[]; 58 | } 59 | 60 | export interface RemuxedUserdata { 61 | samples: UserdataSample[]; 62 | } 63 | 64 | export interface RemuxerResult { 65 | audio?: RemuxedTrack; 66 | video?: RemuxedTrack; 67 | text?: RemuxedUserdata; 68 | id3?: RemuxedMetadata; 69 | initSegment?: InitSegmentData; 70 | independent?: boolean; 71 | } 72 | 73 | export interface InitSegmentData { 74 | tracks?: TrackSet; 75 | initPTS: number | undefined; 76 | timescale: number | undefined; 77 | } 78 | -------------------------------------------------------------------------------- /tests/functional/issues/617.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | HLS.js Error Example 4 | 5 | 6 | 7 | 8 | 13 | 56 |
57 | Start stream one
62 | Start stream two
67 | Start stream three
72 | 73 | 74 | -------------------------------------------------------------------------------- /src/utils/hdr.ts: -------------------------------------------------------------------------------- 1 | import { type VideoRange, VideoRangeValues } from '../types/level'; 2 | import type { VideoSelectionOption } from '../types/media-playlist'; 3 | 4 | /** 5 | * @returns Whether we can detect and validate HDR capability within the window context 6 | */ 7 | export function isHdrSupported() { 8 | if (typeof matchMedia === 'function') { 9 | const mediaQueryList = matchMedia('(dynamic-range: high)'); 10 | const badQuery = matchMedia('bad query'); 11 | if (mediaQueryList.media !== badQuery.media) { 12 | return mediaQueryList.matches === true; 13 | } 14 | } 15 | return false; 16 | } 17 | 18 | /** 19 | * Sanitizes inputs to return the active video selection options for HDR/SDR. 20 | * When both inputs are null: 21 | * 22 | * `{ preferHDR: false, allowedVideoRanges: [] }` 23 | * 24 | * When `currentVideoRange` non-null, maintain the active range: 25 | * 26 | * `{ preferHDR: currentVideoRange !== 'SDR', allowedVideoRanges: [currentVideoRange] }` 27 | * 28 | * When VideoSelectionOption non-null: 29 | * 30 | * - Allow all video ranges if `allowedVideoRanges` unspecified. 31 | * - If `preferHDR` is non-null use the value to filter `allowedVideoRanges`. 32 | * - Else check window for HDR support and set `preferHDR` to the result. 33 | * 34 | * @param currentVideoRange 35 | * @param videoPreference 36 | */ 37 | export function getVideoSelectionOptions( 38 | currentVideoRange: VideoRange | undefined, 39 | videoPreference: VideoSelectionOption | undefined, 40 | ) { 41 | let preferHDR = false; 42 | let allowedVideoRanges: Array = []; 43 | 44 | if (currentVideoRange) { 45 | preferHDR = currentVideoRange !== 'SDR'; 46 | allowedVideoRanges = [currentVideoRange]; 47 | } 48 | 49 | if (videoPreference) { 50 | allowedVideoRanges = 51 | videoPreference.allowedVideoRanges || VideoRangeValues.slice(0); 52 | const allowAutoPreferHDR = 53 | allowedVideoRanges.join('') !== 'SDR' && !videoPreference.videoCodec; 54 | preferHDR = 55 | videoPreference.preferHDR !== undefined 56 | ? videoPreference.preferHDR 57 | : allowAutoPreferHDR && isHdrSupported(); 58 | if (!preferHDR) { 59 | allowedVideoRanges = ['SDR']; 60 | } 61 | } 62 | 63 | return { 64 | preferHDR, 65 | allowedVideoRanges, 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /src/demux/inject-worker.ts: -------------------------------------------------------------------------------- 1 | // ensure the worker ends up in the bundle 2 | // If the worker should not be included this gets aliased to empty.js 3 | import './transmuxer-worker'; 4 | import { version } from '../version'; 5 | 6 | const workerStore: Record = {}; 7 | 8 | export function hasUMDWorker(): boolean { 9 | return typeof __HLS_WORKER_BUNDLE__ === 'function'; 10 | } 11 | 12 | export type WorkerContext = { 13 | worker: Worker; 14 | objectURL?: string; 15 | scriptURL?: string; 16 | clientCount: number; 17 | }; 18 | 19 | export function injectWorker(): WorkerContext { 20 | const workerContext = workerStore[version]; 21 | if (workerContext) { 22 | workerContext.clientCount++; 23 | return workerContext; 24 | } 25 | const blob = new self.Blob( 26 | [ 27 | `var exports={};var module={exports:exports};function define(f){f()};define.amd=true;(${__HLS_WORKER_BUNDLE__.toString()})(true);`, 28 | ], 29 | { 30 | type: 'text/javascript', 31 | }, 32 | ); 33 | const objectURL = self.URL.createObjectURL(blob); 34 | const worker = new self.Worker(objectURL); 35 | const result = { 36 | worker, 37 | objectURL, 38 | clientCount: 1, 39 | }; 40 | workerStore[version] = result; 41 | return result; 42 | } 43 | 44 | export function loadWorker(path: string): WorkerContext { 45 | const workerContext = workerStore[path]; 46 | if (workerContext) { 47 | workerContext.clientCount++; 48 | return workerContext; 49 | } 50 | const scriptURL = new self.URL(path, self.location.href).href; 51 | const worker = new self.Worker(scriptURL); 52 | const result = { 53 | worker, 54 | scriptURL, 55 | clientCount: 1, 56 | }; 57 | workerStore[path] = result; 58 | return result; 59 | } 60 | 61 | export function removeWorkerFromStore(path?: string | null) { 62 | const workerContext = workerStore[path || version]; 63 | if (workerContext) { 64 | const clientCount = workerContext.clientCount--; 65 | if (clientCount === 1) { 66 | const { worker, objectURL } = workerContext; 67 | delete workerStore[path || version]; 68 | if (objectURL) { 69 | // revoke the Object URL that was used to create transmuxer worker, so as not to leak it 70 | self.URL.revokeObjectURL(objectURL); 71 | } 72 | worker.terminate(); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/utils/error-helper.ts: -------------------------------------------------------------------------------- 1 | import { ErrorDetails } from '../errors'; 2 | import type { LoadPolicy, LoaderConfig, RetryConfig } from '../config'; 3 | import type { ErrorData } from '../types/events'; 4 | import type { LoaderResponse } from '../types/loader'; 5 | 6 | export function isTimeoutError(error: ErrorData): boolean { 7 | switch (error.details) { 8 | case ErrorDetails.FRAG_LOAD_TIMEOUT: 9 | case ErrorDetails.KEY_LOAD_TIMEOUT: 10 | case ErrorDetails.LEVEL_LOAD_TIMEOUT: 11 | case ErrorDetails.MANIFEST_LOAD_TIMEOUT: 12 | return true; 13 | } 14 | return false; 15 | } 16 | 17 | export function getRetryConfig( 18 | loadPolicy: LoadPolicy, 19 | error: ErrorData, 20 | ): RetryConfig | null { 21 | const isTimeout = isTimeoutError(error); 22 | return loadPolicy.default[`${isTimeout ? 'timeout' : 'error'}Retry`]; 23 | } 24 | 25 | export function getRetryDelay( 26 | retryConfig: RetryConfig, 27 | retryCount: number, 28 | ): number { 29 | // exponential backoff capped to max retry delay 30 | const backoffFactor = 31 | retryConfig.backoff === 'linear' ? 1 : Math.pow(2, retryCount); 32 | return Math.min( 33 | backoffFactor * retryConfig.retryDelayMs, 34 | retryConfig.maxRetryDelayMs, 35 | ); 36 | } 37 | 38 | export function getLoaderConfigWithoutReties( 39 | loderConfig: LoaderConfig, 40 | ): LoaderConfig { 41 | return { 42 | ...loderConfig, 43 | ...{ 44 | errorRetry: null, 45 | timeoutRetry: null, 46 | }, 47 | }; 48 | } 49 | 50 | export function shouldRetry( 51 | retryConfig: RetryConfig | null | undefined, 52 | retryCount: number, 53 | isTimeout: boolean, 54 | loaderResponse?: LoaderResponse | undefined, 55 | ): retryConfig is RetryConfig & boolean { 56 | if (!retryConfig) { 57 | return false; 58 | } 59 | const httpStatus = loaderResponse?.code; 60 | const retry = 61 | retryCount < retryConfig.maxNumRetry && 62 | (retryForHttpStatus(httpStatus) || !!isTimeout); 63 | return retryConfig.shouldRetry 64 | ? retryConfig.shouldRetry( 65 | retryConfig, 66 | retryCount, 67 | isTimeout, 68 | loaderResponse, 69 | retry, 70 | ) 71 | : retry; 72 | } 73 | 74 | export function retryForHttpStatus(httpStatus: number | undefined) { 75 | // Do not retry on status 4xx, status 0 (CORS error), or undefined (decrypt/gap/parse error) 76 | return ( 77 | (httpStatus === 0 && navigator.onLine === false) || 78 | (!!httpStatus && (httpStatus < 400 || httpStatus > 499)) 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /tests/mocks/hls.mock.ts: -------------------------------------------------------------------------------- 1 | import Hls from '../../src/hls'; 2 | import { EventEmitter } from 'eventemitter3'; 3 | import { logger } from '../../src/utils/logger'; 4 | import type { HlsEventEmitter, HlsListeners } from '../../src/events'; 5 | import type { HlsConfig } from '../../src/config'; 6 | import type { Level } from '../../src/types/level'; 7 | 8 | import * as sinon from 'sinon'; 9 | 10 | // All public methods of Hls instance 11 | const publicMethods = [ 12 | 'destroy', 13 | 'attachMedia', 14 | 'loadSource', 15 | 'startLoad', 16 | 'stopLoad', 17 | 'swapAudioCodec', 18 | 'recoverMediaError', 19 | ]; 20 | 21 | export default class HlsMock extends EventEmitter implements HlsEventEmitter { 22 | config: Partial; 23 | [key: string]: any; 24 | 25 | constructor(config: Partial = {}) { 26 | super(); 27 | // Mock arguments can at will override the default config 28 | // and have to specify things that are not in the default config 29 | this.config = Object.assign({}, Hls.DefaultConfig, config); 30 | this.logger = logger; 31 | // stub public API with spies 32 | publicMethods.forEach((methodName) => { 33 | this[methodName] = sinon.stub(); 34 | }); 35 | // add spies to event emitters 36 | this.trigger = ( 37 | event: E, 38 | eventObject: Parameters[1], 39 | ): boolean => this.emit(event as string, event, eventObject); 40 | sinon.spy(this, 'on'); 41 | sinon.spy(this, 'once'); 42 | sinon.spy(this, 'off'); 43 | sinon.spy(this, 'trigger'); 44 | } 45 | 46 | getEventData(n: number): { name: string; payload: any } { 47 | const event = (this.trigger as any).getCall(n).args; 48 | return { name: event[0], payload: event[1] }; 49 | } 50 | 51 | get levels(): Level[] { 52 | const levels = this.levelController?.levels; 53 | return levels ? levels : []; 54 | } 55 | 56 | get loadLevel(): number { 57 | return this.levelController?.level; 58 | } 59 | 60 | set loadLevel(newLevel: number) { 61 | if (this.levelController) { 62 | this.levelController.manualLevel = newLevel; 63 | } 64 | } 65 | 66 | get nextLoadLevel(): number { 67 | return this.levelController?.nextLoadLevel; 68 | } 69 | 70 | set nextLoadLevel(level: number) { 71 | if (this.levelController) { 72 | this.levelController.nextLoadLevel = level; 73 | } 74 | } 75 | 76 | // Reset all spies 77 | __reset__() { 78 | publicMethods.forEach((methodName) => { 79 | this[methodName].reset(); 80 | }); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/controller/buffer-operation-queue.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '../utils/logger'; 2 | import type { 3 | BufferOperation, 4 | BufferOperationQueues, 5 | SourceBuffers, 6 | SourceBufferName, 7 | } from '../types/buffer'; 8 | 9 | export default class BufferOperationQueue { 10 | private buffers: SourceBuffers; 11 | private queues: BufferOperationQueues = { 12 | video: [], 13 | audio: [], 14 | audiovideo: [], 15 | }; 16 | 17 | constructor(sourceBufferReference: SourceBuffers) { 18 | this.buffers = sourceBufferReference; 19 | } 20 | 21 | public append( 22 | operation: BufferOperation, 23 | type: SourceBufferName, 24 | pending?: boolean, 25 | ) { 26 | const queue = this.queues[type]; 27 | queue.push(operation); 28 | if (queue.length === 1 && !pending) { 29 | this.executeNext(type); 30 | } 31 | } 32 | 33 | public appendBlocker(type: SourceBufferName): Promise { 34 | return new Promise((resolve) => { 35 | const operation: BufferOperation = { 36 | execute: resolve, 37 | onStart: () => {}, 38 | onComplete: () => {}, 39 | onError: () => {}, 40 | }; 41 | this.append(operation, type); 42 | }); 43 | } 44 | 45 | unblockAudio(op: BufferOperation) { 46 | const queue = this.queues.audio; 47 | if (queue[0] === op) { 48 | this.shiftAndExecuteNext('audio'); 49 | } 50 | } 51 | 52 | public executeNext(type: SourceBufferName) { 53 | const queue = this.queues[type]; 54 | if (queue.length) { 55 | const operation: BufferOperation = queue[0]; 56 | try { 57 | // Operations are expected to result in an 'updateend' event being fired. If not, the queue will lock. Operations 58 | // which do not end with this event must call _onSBUpdateEnd manually 59 | operation.execute(); 60 | } catch (error) { 61 | logger.warn( 62 | `[buffer-operation-queue]: Exception executing "${type}" SourceBuffer operation: ${error}`, 63 | ); 64 | operation.onError(error); 65 | 66 | // Only shift the current operation off, otherwise the updateend handler will do this for us 67 | const sb = this.buffers[type]; 68 | if (!sb?.updating) { 69 | this.shiftAndExecuteNext(type); 70 | } 71 | } 72 | } 73 | } 74 | 75 | public shiftAndExecuteNext(type: SourceBufferName) { 76 | this.queues[type].shift(); 77 | this.executeNext(type); 78 | } 79 | 80 | public current(type: SourceBufferName): BufferOperation { 81 | return this.queues[type][0]; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /scripts/deploy-cloudflare.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # GITHUB_TOKEN and CLOUDFLARE_API_TOKEN required 5 | accountId="0b5dddd7f1257d8d0b3594dbe325053c" 6 | stableProjectName="hls-js" 7 | latestProjectName="hls-js-dev" 8 | 9 | currentCommit=$(git rev-parse HEAD) 10 | 11 | root="./cloudflare-pages" 12 | version="$(jq -r -e '.version' "./package.json")" 13 | commitShort="$(echo "$currentCommit" | cut -c 1-8)" 14 | 15 | deploy () { 16 | projectName=$1 17 | echo "Deploying on CloudFlare to '$projectName'." 18 | CLOUDFLARE_ACCOUNT_ID="$accountId" ./node_modules/.bin/wrangler pages publish --project-name "$projectName" --commit-dirty=true --branch=master --commit-hash="$currentCommit" $root 19 | echo "Deployed on CloudFlare to '$projectName'." 20 | } 21 | 22 | deploy "$latestProjectName" 23 | 24 | if [[ $version != *"-"* ]]; then 25 | echo "Detected new version: $version" 26 | deploy "$stableProjectName" 27 | fi 28 | 29 | echo "Finished deploying to CloudFlare." 30 | 31 | echo "Fetching deployment urls." 32 | 33 | deploymentUrl=`curl -X GET --fail "https://api.cloudflare.com/client/v4/accounts/$accountId/pages/projects/$latestProjectName/deployments" \ 34 | -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ 35 | -H "Content-Type:application/json" | jq --raw-output --exit-status '[.result[] | select(.deployment_trigger.metadata.commit_hash == "'"$currentCommit"'")][0].url'` 36 | 37 | echo "Updating deployments branch." 38 | git clone --depth 1 "https://${GITHUB_TOKEN}@github.com/video-dev/hls.js.git" -b deployments "$root/deployments" 39 | cd "$root/deployments" 40 | 41 | jq '.stable = "https://hlsjs.video-dev.org/"' deployments.json > deployments.json.tmp 42 | mv deployments.json.tmp deployments.json 43 | 44 | jq '.latest = "https://hlsjs-dev.video-dev.org/"' deployments.json > deployments.json.tmp 45 | mv deployments.json.tmp deployments.json 46 | 47 | jq \ 48 | --arg version "$version" \ 49 | --arg commit "$currentCommit" \ 50 | --arg url "$deploymentUrl/" \ 51 | '.individual += [{ 52 | "version": $version, 53 | "commit": $commit, 54 | "url": $url, 55 | }]' deployments.json > deployments.json.tmp 56 | mv deployments.json.tmp deployments.json 57 | 58 | node ../../scripts/build-deployments-readme.js './deployments.json' '.' 59 | 60 | git add "deployments.json" 61 | git add "deployments.txt" 62 | git add "README.md" 63 | 64 | git -c user.name="hlsjs-ci" -c user.email="40664919+hlsjs-ci@users.noreply.github.com" commit -m "update for $commitShort" 65 | git push "https://${GITHUB_TOKEN}@github.com/video-dev/hls.js.git" 66 | cd .. 67 | echo "Updated deployments branch." 68 | -------------------------------------------------------------------------------- /src/exports-named.ts: -------------------------------------------------------------------------------- 1 | import Hls from './hls'; 2 | import { Events } from './events'; 3 | import { ErrorTypes, ErrorDetails } from './errors'; 4 | import type { Level } from './types/level'; 5 | import AbrController from './controller/abr-controller'; 6 | import AudioTrackController from './controller/audio-track-controller'; 7 | import AudioStreamController from './controller/audio-stream-controller'; 8 | import BasePlaylistController from './controller/base-playlist-controller'; 9 | import BaseStreamController from './controller/base-stream-controller'; 10 | import BufferController from './controller/buffer-controller'; 11 | import CapLevelController from './controller/cap-level-controller'; 12 | import CMCDController from './controller/cmcd-controller'; 13 | import ContentSteeringController from './controller/content-steering-controller'; 14 | import EMEController from './controller/eme-controller'; 15 | import ErrorController from './controller/error-controller'; 16 | import FPSController from './controller/fps-controller'; 17 | import SubtitleTrackController from './controller/subtitle-track-controller'; 18 | 19 | export default Hls; 20 | 21 | export { 22 | Hls, 23 | ErrorDetails, 24 | ErrorTypes, 25 | Events, 26 | Level, 27 | AbrController, 28 | AudioStreamController, 29 | AudioTrackController, 30 | BasePlaylistController, 31 | BaseStreamController, 32 | BufferController, 33 | CapLevelController, 34 | CMCDController, 35 | ContentSteeringController, 36 | EMEController, 37 | ErrorController, 38 | FPSController, 39 | SubtitleTrackController, 40 | }; 41 | export { SubtitleStreamController } from './controller/subtitle-stream-controller'; 42 | export { TimelineController } from './controller/timeline-controller'; 43 | export { KeySystems, KeySystemFormats } from './utils/mediakeys-helper'; 44 | export { DateRange } from './loader/date-range'; 45 | export { LoadStats } from './loader/load-stats'; 46 | export { LevelKey } from './loader/level-key'; 47 | export { LevelDetails } from './loader/level-details'; 48 | export { MetadataSchema } from './types/demuxer'; 49 | export { HlsSkip, HlsUrlParameters } from './types/level'; 50 | export { PlaylistLevelType } from './types/loader'; 51 | export { ChunkMetadata } from './types/transmuxer'; 52 | export { BaseSegment, Fragment, Part } from './loader/fragment'; 53 | export { 54 | NetworkErrorAction, 55 | ErrorActionFlags, 56 | } from './controller/error-controller'; 57 | export { AttrList } from './utils/attr-list'; 58 | export { isSupported, isMSESupported } from './is-supported'; 59 | export { getMediaSource } from './utils/mediasource-helper'; 60 | -------------------------------------------------------------------------------- /scripts/version-parser.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const semver = require('semver'); 4 | 5 | const VALID_VERSION_REGEX = 6 | /^v(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z][0-9a-zA-Z-]*))?/; 7 | const STABLE_VERSION_REGEX = /^v\d+\.\d+\.\d+$/; 8 | 9 | module.exports = { 10 | isValidVersion: (version) => { 11 | return VALID_VERSION_REGEX.test(version); 12 | }, 13 | isValidStableVersion: (version) => { 14 | return STABLE_VERSION_REGEX.test(version); 15 | }, 16 | incrementPatch: (version) => { 17 | const newVersion = 'v' + semver.inc(version, 'patch'); 18 | if (!newVersion) { 19 | throw new Error(`Error incrementing patch for version "${version}"`); 20 | } 21 | return newVersion; 22 | }, 23 | isGreaterOrEqual: (newVersion, previousVersion) => { 24 | return semver.gte(newVersion, previousVersion); 25 | }, 26 | // returns true if the provided version is definitely greater than any existing 27 | // auto generated canary versions 28 | isDefinitelyGreaterThanCanaries: (version) => { 29 | const parsed = semver.parse(version, { 30 | loose: false, 31 | includePrerelease: true, 32 | }); 33 | if (!parsed) { 34 | throw new Error('Error parsing version.'); 35 | } 36 | 37 | // anything after a part of `0` must be greater than `canary` 38 | let hadZero = false; 39 | return parsed.prerelease.every((part) => { 40 | if (hadZero && part <= 'canary') { 41 | return false; 42 | } else { 43 | hadZero = false; 44 | } 45 | if (part === 0) { 46 | hadZero = true; 47 | } 48 | return true; 49 | }); 50 | }, 51 | // extract what we should use as the npm dist-tag (https://docs.npmjs.com/cli/dist-tag) 52 | // e.g 53 | // v1.2.3-beta => beta 54 | // v1.2.3-beta.1 => beta 55 | // v1.2.3 => latest 56 | getVersionTag: (version) => { 57 | const match = VALID_VERSION_REGEX.exec(version); 58 | if (!match) { 59 | throw new Error('Invalid version.'); 60 | } 61 | return match[4] || 'latest'; 62 | }, 63 | getPotentialPreviousStableVersions: (version) => { 64 | const match = VALID_VERSION_REGEX.exec(version); 65 | if (!match) { 66 | throw new Error('Invalid version.'); 67 | } 68 | 69 | const major = parseInt(match[1]); 70 | const minor = parseInt(match[2]); 71 | const patch = parseInt(match[3]); 72 | 73 | const versions = []; 74 | if (major > 0) versions.push(`${major - 1}.0.${major === 1 ? '1' : '0'}`); 75 | if (minor > 0) versions.push(`${major}.${minor - 1}.0`); 76 | if (patch > 0) versions.push(`${major}.${minor}.${patch - 1}`); 77 | return versions; 78 | }, 79 | }; 80 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | const { buildRollupConfig, BUILD_TYPE, FORMAT } = require('./build-config'); 2 | 3 | // Do not add coverage for JavaScript debugging when running `test:unit:debug` 4 | // eslint-disable-next-line no-undef 5 | const includeCoverage = !process.env.DEBUG_UNIT_TESTS && !process.env.CI; 6 | 7 | const rollupPreprocessor = buildRollupConfig({ 8 | type: BUILD_TYPE.full, 9 | format: FORMAT.iife, 10 | minified: false, 11 | allowCircularDeps: true, 12 | includeCoverage, 13 | sourcemap: false, 14 | outputFile: 'karma-temp/tests.js', 15 | }); 16 | 17 | // preprocess matching files before serving them to the browser 18 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 19 | const preprocessors = { 20 | './tests/index.js': ['rollup'], 21 | }; 22 | // test results reporter to use 23 | // possible values: 'dots', 'progress' 24 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 25 | const reporters = ['mocha']; 26 | const coverageReporter = { 27 | reporters: [], 28 | }; 29 | 30 | if (includeCoverage) { 31 | reporters.push('coverage'); 32 | coverageReporter.reporters.push({ type: 'html', subdir: '.' }); 33 | } 34 | 35 | module.exports = function (config) { 36 | config.set({ 37 | // frameworks to use 38 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 39 | frameworks: ['mocha', 'sinon-chai'], 40 | 41 | // list of files / patterns to load in the browser 42 | files: [ 43 | { 44 | pattern: 'tests/index.js', 45 | watched: false, 46 | }, 47 | ], 48 | 49 | // list of files to exclude 50 | exclude: [], 51 | 52 | preprocessors, 53 | coverageReporter, 54 | reporters, 55 | 56 | rollupPreprocessor, 57 | 58 | // web server port 59 | port: 9876, 60 | 61 | // enable / disable colors in the output (reporters and logs) 62 | colors: true, 63 | 64 | // level of logging 65 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 66 | logLevel: config.LOG_INFO, 67 | 68 | // enable / disable watching file and executing tests whenever any file changes 69 | autoWatch: false, 70 | 71 | // start these browsers 72 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 73 | browsers: ['ChromeHeadless'], 74 | 75 | // Continuous Integration mode 76 | // if true, Karma captures browsers, runs the tests and exits 77 | singleRun: true, 78 | 79 | // Concurrency level 80 | // how many browser should be started simultaneous 81 | concurrency: 1, 82 | }); 83 | }; 84 | -------------------------------------------------------------------------------- /src/demux/audio/mp3demuxer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * MP3 demuxer 3 | */ 4 | import BaseAudioDemuxer from './base-audio-demuxer'; 5 | import { getAudioBSID } from './dolby'; 6 | import { logger } from '../../utils/logger'; 7 | import * as MpegAudio from './mpegaudio'; 8 | import { getId3Data } from '@svta/common-media-library/id3/getId3Data'; 9 | import { getId3Timestamp } from '@svta/common-media-library/id3/getId3Timestamp'; 10 | 11 | class MP3Demuxer extends BaseAudioDemuxer { 12 | resetInitSegment( 13 | initSegment: Uint8Array | undefined, 14 | audioCodec: string | undefined, 15 | videoCodec: string | undefined, 16 | trackDuration: number, 17 | ) { 18 | super.resetInitSegment(initSegment, audioCodec, videoCodec, trackDuration); 19 | this._audioTrack = { 20 | container: 'audio/mpeg', 21 | type: 'audio', 22 | id: 2, 23 | pid: -1, 24 | sequenceNumber: 0, 25 | segmentCodec: 'mp3', 26 | samples: [], 27 | manifestCodec: audioCodec, 28 | duration: trackDuration, 29 | inputTimeScale: 90000, 30 | dropped: 0, 31 | }; 32 | } 33 | 34 | static probe(data: Uint8Array | undefined): boolean { 35 | if (!data) { 36 | return false; 37 | } 38 | 39 | // check if data contains ID3 timestamp and MPEG sync word 40 | // Look for MPEG header | 1111 1111 | 111X XYZX | where X can be either 0 or 1 and Y or Z should be 1 41 | // Layer bits (position 14 and 15) in header should be always different from 0 (Layer I or Layer II or Layer III) 42 | // More info http://www.mp3-tech.org/programmer/frame_header.html 43 | const id3Data = getId3Data(data, 0); 44 | let offset = id3Data?.length || 0; 45 | 46 | // Check for ac-3|ec-3 sync bytes and return false if present 47 | if ( 48 | id3Data && 49 | data[offset] === 0x0b && 50 | data[offset + 1] === 0x77 && 51 | getId3Timestamp(id3Data) !== undefined && 52 | // check the bsid to confirm ac-3 or ec-3 (not mp3) 53 | getAudioBSID(data, offset) <= 16 54 | ) { 55 | return false; 56 | } 57 | 58 | for (let length = data.length; offset < length; offset++) { 59 | if (MpegAudio.probe(data, offset)) { 60 | logger.log('MPEG Audio sync word found !'); 61 | return true; 62 | } 63 | } 64 | return false; 65 | } 66 | 67 | canParse(data, offset) { 68 | return MpegAudio.canParse(data, offset); 69 | } 70 | 71 | appendFrame(track, data, offset) { 72 | if (this.basePTS === null) { 73 | return; 74 | } 75 | return MpegAudio.appendFrame( 76 | track, 77 | data, 78 | offset, 79 | this.basePTS, 80 | this.frameIndex, 81 | ); 82 | } 83 | } 84 | 85 | export default MP3Demuxer; 86 | -------------------------------------------------------------------------------- /src/types/media-playlist.ts: -------------------------------------------------------------------------------- 1 | import type { AttrList } from '../utils/attr-list'; 2 | import type { LevelDetails } from '../loader/level-details'; 3 | import type { Level, VideoRange } from './level'; 4 | import type { PlaylistLevelType } from './loader'; 5 | 6 | export type AudioPlaylistType = 'AUDIO'; 7 | 8 | export type MainPlaylistType = AudioPlaylistType | 'VIDEO'; 9 | 10 | export type SubtitlePlaylistType = 'SUBTITLES' | 'CLOSED-CAPTIONS'; 11 | 12 | export type MediaPlaylistType = MainPlaylistType | SubtitlePlaylistType; 13 | 14 | export type MediaSelection = { 15 | [PlaylistLevelType.MAIN]: Level; 16 | [PlaylistLevelType.AUDIO]?: MediaPlaylist; 17 | [PlaylistLevelType.SUBTITLE]?: MediaPlaylist; 18 | }; 19 | 20 | export type VideoSelectionOption = { 21 | preferHDR?: boolean; 22 | allowedVideoRanges?: Array; 23 | videoCodec?: string; 24 | }; 25 | 26 | export type AudioSelectionOption = { 27 | lang?: string; 28 | assocLang?: string; 29 | characteristics?: string; 30 | channels?: string; 31 | name?: string; 32 | audioCodec?: string; 33 | groupId?: string; 34 | default?: boolean; 35 | }; 36 | 37 | export type SubtitleSelectionOption = { 38 | lang?: string; 39 | assocLang?: string; 40 | characteristics?: string; 41 | name?: string; 42 | groupId?: string; 43 | default?: boolean; 44 | forced?: boolean; 45 | }; 46 | 47 | // audioTracks, captions and subtitles returned by `M3U8Parser.parseMasterPlaylistMedia` 48 | export interface MediaPlaylist { 49 | attrs: MediaAttributes; 50 | audioCodec?: string; 51 | autoselect: boolean; // implicit false if not present 52 | bitrate: number; 53 | channels?: string; 54 | characteristics?: string; 55 | details?: LevelDetails; 56 | height?: number; 57 | default: boolean; // implicit false if not present 58 | forced: boolean; // implicit false if not present 59 | groupId: string; // required in HLS playlists 60 | id: number; // incrementing number to track media playlists 61 | instreamId?: string; 62 | lang?: string; 63 | assocLang?: string; 64 | name: string; 65 | textCodec?: string; 66 | unknownCodecs?: string[]; 67 | // 'main' is a custom type added to signal a audioCodec in main track?; see playlist-loader~L310 68 | type: MediaPlaylistType | 'main'; 69 | url: string; 70 | videoCodec?: string; 71 | width?: number; 72 | } 73 | 74 | export interface MediaAttributes extends AttrList { 75 | 'ASSOC-LANGUAGE'?: string; 76 | AUTOSELECT?: 'YES' | 'NO'; 77 | CHANNELS?: string; 78 | CHARACTERISTICS?: string; 79 | DEFAULT?: 'YES' | 'NO'; 80 | FORCED?: 'YES' | 'NO'; 81 | 'GROUP-ID': string; 82 | 'INSTREAM-ID'?: string; 83 | LANGUAGE?: string; 84 | NAME: string; 85 | 'PATHWAY-ID'?: string; 86 | 'STABLE-RENDITION-ID'?: string; 87 | TYPE?: 'AUDIO' | 'VIDEO' | 'SUBTITLES' | 'CLOSED-CAPTIONS'; 88 | URI?: string; 89 | } 90 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Hls.js 2 | 3 | Thanks for contributing to hls.js. Your time and input are appreciated. To get the most out of the project, please consider the following. 4 | 5 | ## General Guidelines 6 | 7 | ### API Reference 8 | 9 | Are you having trouble getting started with hls.js, configuration, or integration? If so, please check the [API reference](https://github.com/video-dev/hls.js/blob/master/docs/API.md) 10 | before submitting an issue here. 11 | 12 | ### Code of Conduct 13 | 14 | Please review the project [Code of Conduct](https://github.com/video-dev/hls.js/blob/master/CODE_OF_CONDUCT.md) and adhere to the pledge and standards for behavior when filing issues, submitting changes or interacting with maintainers. 15 | 16 | ## Reporting bugs 17 | 18 | First, if you found an issue, **ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/video-dev/hls.js/issues). 19 | 20 | If you're unable to find an open issue addressing the problem, open a new one using the [bug report template](https://github.com/video-dev/hls.js/issues/new?template=bug.yaml). As part of your issue, make sure to include: 21 | 22 | - Test stream/page (if possible) 23 | - hls.js configuration 24 | - Steps to reproduce 25 | - Expected behavior 26 | - Actual behavior 27 | 28 | Please be as detailed as possible, provide everything another contributor would need to reproduce and issue, and understand its impact on playback and the end-user experience. 29 | 30 | If the issue is related to your stream, and you cannot share the stream, please include all the information we would need to reproduce the problem. This includes how to generate a stream if necessary. 31 | 32 | ## Feature Requests 33 | 34 | File feature requests using the [Feature request template](https://github.com/video-dev/hls.js/issues/new?assignees=&labels=&template=feature.yaml) filling out all parts. 35 | 36 | Like with bug reports, please be as detailed as possible and try to make sure other contributors have everything they need to understand your request and how it will improve the project. 37 | 38 | ## Creating PRs 39 | 40 | Pull requests are welcome and pair well with bug reports and feature requests. Here are some tips to follow before submitting your first PR: 41 | 42 | - Use [EditorConfig](https://editorconfig.org) or at least stay consistent to the file formats defined in the `.editorconfig` file. 43 | - Develop in a topic branch (bugfix/describe-your-fix, feature/describe-your-feature), not master 44 | - The pre-commit hook will cover some tasks, but be sure to run `npm run prettier` before staging your commits. 45 | - Make sure your changes pass all the required build and test tasks using `npm run sanity-check` 46 | - Run functional integration tests locally using `npm run test:func` 47 | 48 | ## Contact 49 | 50 | If you aren't already a member, consider joining video-dev on Slack https://www.video-dev.org/ and chatting with us in the `#hlsjs` channel. 51 | -------------------------------------------------------------------------------- /src/demux/audio/aacdemuxer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * AAC demuxer 3 | */ 4 | import BaseAudioDemuxer from './base-audio-demuxer'; 5 | import * as ADTS from './adts'; 6 | import * as MpegAudio from './mpegaudio'; 7 | import { getId3Data } from '@svta/common-media-library/id3/getId3Data'; 8 | import type { HlsEventEmitter } from '../../events'; 9 | import type { HlsConfig } from '../../config'; 10 | import type { DemuxedAudioTrack } from '../../types/demuxer'; 11 | import type { ILogger } from '../../utils/logger'; 12 | 13 | class AACDemuxer extends BaseAudioDemuxer { 14 | private readonly observer: HlsEventEmitter; 15 | private readonly config: HlsConfig; 16 | 17 | constructor(observer: HlsEventEmitter, config) { 18 | super(); 19 | this.observer = observer; 20 | this.config = config; 21 | } 22 | 23 | resetInitSegment( 24 | initSegment: Uint8Array | undefined, 25 | audioCodec: string | undefined, 26 | videoCodec: string | undefined, 27 | trackDuration: number, 28 | ) { 29 | super.resetInitSegment(initSegment, audioCodec, videoCodec, trackDuration); 30 | this._audioTrack = { 31 | container: 'audio/adts', 32 | type: 'audio', 33 | id: 2, 34 | pid: -1, 35 | sequenceNumber: 0, 36 | segmentCodec: 'aac', 37 | samples: [], 38 | manifestCodec: audioCodec, 39 | duration: trackDuration, 40 | inputTimeScale: 90000, 41 | dropped: 0, 42 | }; 43 | } 44 | 45 | // Source for probe info - https://wiki.multimedia.cx/index.php?title=ADTS 46 | static probe(data: Uint8Array | undefined, logger: ILogger): boolean { 47 | if (!data) { 48 | return false; 49 | } 50 | 51 | // Check for the ADTS sync word 52 | // Look for ADTS header | 1111 1111 | 1111 X00X | where X can be either 0 or 1 53 | // Layer bits (position 14 and 15) in header should be always 0 for ADTS 54 | // More info https://wiki.multimedia.cx/index.php?title=ADTS 55 | const id3Data = getId3Data(data, 0); 56 | let offset = id3Data?.length || 0; 57 | 58 | if (MpegAudio.probe(data, offset)) { 59 | return false; 60 | } 61 | 62 | for (let length = data.length; offset < length; offset++) { 63 | if (ADTS.probe(data, offset)) { 64 | logger.log('ADTS sync word found !'); 65 | return true; 66 | } 67 | } 68 | return false; 69 | } 70 | 71 | canParse(data, offset) { 72 | return ADTS.canParse(data, offset); 73 | } 74 | 75 | appendFrame(track: DemuxedAudioTrack, data: Uint8Array, offset: number) { 76 | ADTS.initTrackConfig( 77 | track, 78 | this.observer, 79 | data, 80 | offset, 81 | track.manifestCodec, 82 | ); 83 | const frame = ADTS.appendFrame( 84 | track, 85 | data, 86 | offset, 87 | this.basePTS as number, 88 | this.frameIndex, 89 | ); 90 | if (frame && frame.missing === 0) { 91 | return frame; 92 | } 93 | } 94 | } 95 | 96 | export default AACDemuxer; 97 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | commonjs: true, 5 | es6: true, 6 | }, 7 | globals: { 8 | // Allowed globals 9 | console: true, 10 | 11 | // Compile-time defines 12 | __VERSION__: true, 13 | __USE_SUBTITLES__: true, 14 | __USE_ALT_AUDIO__: true, 15 | __USE_EME_DRM__: true, 16 | __USE_CMCD__: true, 17 | __USE_CONTENT_STEERING__: true, 18 | __USE_VARIABLE_SUBSTITUTION__: true, 19 | __USE_M2TS_ADVANCED_CODECS__: true, 20 | __USE_MEDIA_CAPABILITIES__: true, 21 | }, 22 | // see https://github.com/standard/eslint-config-standard 23 | // 'prettier' (https://github.com/prettier/eslint-config-prettier) must be last 24 | extends: ['eslint:recommended', 'prettier'], 25 | parser: '@typescript-eslint/parser', 26 | parserOptions: { 27 | sourceType: 'module', 28 | project: './tsconfig.json', 29 | }, 30 | plugins: ['@typescript-eslint', 'import'], 31 | rules: { 32 | 'no-restricted-globals': [ 33 | 2, 34 | { 35 | name: 'window', 36 | message: 37 | 'Use `self` instead of `window` to access the global context everywhere (including workers).', 38 | }, 39 | { 40 | name: 'SourceBuffer', 41 | message: 'Use `self.SourceBuffer`', 42 | }, 43 | { 44 | name: 'setTimeout', 45 | message: 'Use `self.setTimeout`', 46 | }, 47 | { 48 | name: 'setInterval', 49 | message: 'Use `self.setInterval`', 50 | }, 51 | ], 52 | 53 | 'no-restricted-properties': [ 54 | 2, 55 | { property: 'findIndex' }, // Intended to block usage of Array.prototype.findIndex 56 | { property: 'find' }, // Intended to block usage of Array.prototype.find 57 | ], 58 | 59 | 'import/first': 1, 60 | 'no-var': 1, 61 | 'no-empty': 1, 62 | 'no-unused-vars': 'warn', 63 | 'no-console': [ 64 | 1, 65 | { 66 | allow: ['assert'], 67 | }, 68 | ], 69 | 'no-fallthrough': 1, 70 | 'no-case-declarations': 2, 71 | 'no-self-assign': 1, 72 | 'new-cap': 1, 73 | 'no-undefined': 0, 74 | 'no-global-assign': 2, 75 | 'prefer-const': 2, 76 | 'dot-notation': 2, 77 | 'no-void': 2, 78 | 'no-useless-catch': 2, 79 | 'no-prototype-builtins': 0, 80 | }, 81 | overrides: [ 82 | { 83 | files: ['*.ts'], 84 | rules: { 85 | 'no-unused-vars': 0, 86 | 'no-undef': 0, 87 | 'no-use-before-define': 'off', 88 | '@typescript-eslint/no-unused-vars': [ 89 | 'warn', 90 | { 91 | args: 'none', 92 | }, 93 | ], 94 | '@typescript-eslint/prefer-optional-chain': 2, 95 | '@typescript-eslint/consistent-type-assertions': [ 96 | 2, 97 | { 98 | assertionStyle: 'as', 99 | objectLiteralTypeAssertions: 'never', 100 | }, 101 | ], 102 | }, 103 | }, 104 | ], 105 | }; 106 | -------------------------------------------------------------------------------- /src/utils/ewma-bandwidth-estimator.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * EWMA Bandwidth Estimator 3 | * - heavily inspired from shaka-player 4 | * Tracks bandwidth samples and estimates available bandwidth. 5 | * Based on the minimum of two exponentially-weighted moving averages with 6 | * different half-lives. 7 | */ 8 | 9 | import EWMA from '../utils/ewma'; 10 | 11 | class EwmaBandWidthEstimator { 12 | private defaultEstimate_: number; 13 | private minWeight_: number; 14 | private minDelayMs_: number; 15 | private slow_: EWMA; 16 | private fast_: EWMA; 17 | private defaultTTFB_: number; 18 | private ttfb_: EWMA; 19 | 20 | constructor( 21 | slow: number, 22 | fast: number, 23 | defaultEstimate: number, 24 | defaultTTFB: number = 100, 25 | ) { 26 | this.defaultEstimate_ = defaultEstimate; 27 | this.minWeight_ = 0.001; 28 | this.minDelayMs_ = 50; 29 | this.slow_ = new EWMA(slow); 30 | this.fast_ = new EWMA(fast); 31 | this.defaultTTFB_ = defaultTTFB; 32 | this.ttfb_ = new EWMA(slow); 33 | } 34 | 35 | update(slow: number, fast: number) { 36 | const { slow_, fast_, ttfb_ } = this; 37 | if (slow_.halfLife !== slow) { 38 | this.slow_ = new EWMA(slow, slow_.getEstimate(), slow_.getTotalWeight()); 39 | } 40 | if (fast_.halfLife !== fast) { 41 | this.fast_ = new EWMA(fast, fast_.getEstimate(), fast_.getTotalWeight()); 42 | } 43 | if (ttfb_.halfLife !== slow) { 44 | this.ttfb_ = new EWMA(slow, ttfb_.getEstimate(), ttfb_.getTotalWeight()); 45 | } 46 | } 47 | 48 | sample(durationMs: number, numBytes: number) { 49 | durationMs = Math.max(durationMs, this.minDelayMs_); 50 | const numBits = 8 * numBytes; 51 | // weight is duration in seconds 52 | const durationS = durationMs / 1000; 53 | // value is bandwidth in bits/s 54 | const bandwidthInBps = numBits / durationS; 55 | this.fast_.sample(durationS, bandwidthInBps); 56 | this.slow_.sample(durationS, bandwidthInBps); 57 | } 58 | 59 | sampleTTFB(ttfb: number) { 60 | // weight is frequency curve applied to TTFB in seconds 61 | // (longer times have less weight with expected input under 1 second) 62 | const seconds = ttfb / 1000; 63 | const weight = Math.sqrt(2) * Math.exp(-Math.pow(seconds, 2) / 2); 64 | this.ttfb_.sample(weight, Math.max(ttfb, 5)); 65 | } 66 | 67 | canEstimate(): boolean { 68 | return this.fast_.getTotalWeight() >= this.minWeight_; 69 | } 70 | 71 | getEstimate(): number { 72 | if (this.canEstimate()) { 73 | // console.log('slow estimate:'+ Math.round(this.slow_.getEstimate())); 74 | // console.log('fast estimate:'+ Math.round(this.fast_.getEstimate())); 75 | // Take the minimum of these two estimates. This should have the effect of 76 | // adapting down quickly, but up more slowly. 77 | return Math.min(this.fast_.getEstimate(), this.slow_.getEstimate()); 78 | } else { 79 | return this.defaultEstimate_; 80 | } 81 | } 82 | 83 | getEstimateTTFB(): number { 84 | if (this.ttfb_.getTotalWeight() >= this.minWeight_) { 85 | return this.ttfb_.getEstimate(); 86 | } else { 87 | return this.defaultTTFB_; 88 | } 89 | } 90 | 91 | destroy() {} 92 | } 93 | export default EwmaBandWidthEstimator; 94 | -------------------------------------------------------------------------------- /tests/functional/issues/video-tag-hijack.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | HLS.js Video tag hijack tester 4 | 5 | 6 | 7 | 8 | 9 | 10 | 15 |
16 | Hijacking video tag in: 17 | 18 |
19 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /src/utils/cues.ts: -------------------------------------------------------------------------------- 1 | import { fixLineBreaks } from './vttparser'; 2 | import type { CaptionScreen, Row } from './cea-608-parser'; 3 | import { generateCueId } from './webvtt-parser'; 4 | import { addCueToTrack } from './texttrack-utils'; 5 | 6 | const WHITESPACE_CHAR = /\s/; 7 | 8 | export interface CuesInterface { 9 | newCue( 10 | track: TextTrack | null, 11 | startTime: number, 12 | endTime: number, 13 | captionScreen: CaptionScreen, 14 | ): VTTCue[]; 15 | } 16 | 17 | const Cues: CuesInterface = { 18 | newCue( 19 | track: TextTrack | null, 20 | startTime: number, 21 | endTime: number, 22 | captionScreen: CaptionScreen, 23 | ): VTTCue[] { 24 | const result: VTTCue[] = []; 25 | let row: Row; 26 | // the type data states this is VTTCue, but it can potentially be a TextTrackCue on old browsers 27 | let cue: VTTCue; 28 | let indenting: boolean; 29 | let indent: number; 30 | let text: string; 31 | const Cue = (self.VTTCue || self.TextTrackCue) as any; 32 | 33 | for (let r = 0; r < captionScreen.rows.length; r++) { 34 | row = captionScreen.rows[r]; 35 | indenting = true; 36 | indent = 0; 37 | text = ''; 38 | 39 | if (!row.isEmpty()) { 40 | for (let c = 0; c < row.chars.length; c++) { 41 | if (WHITESPACE_CHAR.test(row.chars[c].uchar) && indenting) { 42 | indent++; 43 | } else { 44 | text += row.chars[c].uchar; 45 | indenting = false; 46 | } 47 | } 48 | // To be used for cleaning-up orphaned roll-up captions 49 | row.cueStartTime = startTime; 50 | 51 | // Give a slight bump to the endTime if it's equal to startTime to avoid a SyntaxError in IE 52 | if (startTime === endTime) { 53 | endTime += 0.0001; 54 | } 55 | 56 | if (indent >= 16) { 57 | indent--; 58 | } else { 59 | indent++; 60 | } 61 | 62 | const cueText = fixLineBreaks(text.trim()); 63 | const id = generateCueId(startTime, endTime, cueText); 64 | 65 | // If this cue already exists in the track do not push it 66 | if (!track?.cues?.getCueById(id)) { 67 | cue = new Cue(startTime, endTime, cueText); 68 | cue.id = id; 69 | cue.line = r + 1; 70 | cue.align = 'left'; 71 | // Clamp the position between 10 and 80 percent (CEA-608 PAC indent code) 72 | // https://dvcs.w3.org/hg/text-tracks/raw-file/default/608toVTT/608toVTT.html#positioning-in-cea-608 73 | // Firefox throws an exception and captions break with out of bounds 0-100 values 74 | cue.position = 10 + Math.min(80, Math.floor((indent * 8) / 32) * 10); 75 | result.push(cue); 76 | } 77 | } 78 | } 79 | if (track && result.length) { 80 | // Sort bottom cues in reverse order so that they render in line order when overlapping in Chrome 81 | result.sort((cueA, cueB) => { 82 | if (cueA.line === 'auto' || cueB.line === 'auto') { 83 | return 0; 84 | } 85 | if (cueA.line > 8 && cueB.line > 8) { 86 | return cueB.line - cueA.line; 87 | } 88 | return cueA.line - cueB.line; 89 | }); 90 | result.forEach((cue) => addCueToTrack(track, cue)); 91 | } 92 | return result; 93 | }, 94 | }; 95 | 96 | export default Cues; 97 | -------------------------------------------------------------------------------- /src/utils/variable-substitution.ts: -------------------------------------------------------------------------------- 1 | import type { AttrList } from './attr-list'; 2 | import type { ParsedMultivariantPlaylist } from '../loader/m3u8-parser'; 3 | import type { LevelDetails } from '../loader/level-details'; 4 | import type { VariableMap } from '../types/level'; 5 | 6 | const VARIABLE_REPLACEMENT_REGEX = /\{\$([a-zA-Z0-9-_]+)\}/g; 7 | 8 | export function hasVariableReferences(str: string): boolean { 9 | return VARIABLE_REPLACEMENT_REGEX.test(str); 10 | } 11 | 12 | export function substituteVariables( 13 | parsed: Pick< 14 | ParsedMultivariantPlaylist | LevelDetails, 15 | 'variableList' | 'hasVariableRefs' | 'playlistParsingError' 16 | >, 17 | value: string, 18 | ): string { 19 | if (parsed.variableList !== null || parsed.hasVariableRefs) { 20 | const variableList = parsed.variableList; 21 | return value.replace( 22 | VARIABLE_REPLACEMENT_REGEX, 23 | (variableReference: string) => { 24 | const variableName = variableReference.substring( 25 | 2, 26 | variableReference.length - 1, 27 | ); 28 | const variableValue = variableList?.[variableName]; 29 | if (variableValue === undefined) { 30 | parsed.playlistParsingError ||= new Error( 31 | `Missing preceding EXT-X-DEFINE tag for Variable Reference: "${variableName}"`, 32 | ); 33 | return variableReference; 34 | } 35 | return variableValue; 36 | }, 37 | ); 38 | } 39 | return value; 40 | } 41 | 42 | export function addVariableDefinition( 43 | parsed: Pick< 44 | ParsedMultivariantPlaylist | LevelDetails, 45 | 'variableList' | 'playlistParsingError' 46 | >, 47 | attr: AttrList, 48 | parentUrl: string, 49 | ) { 50 | let variableList = parsed.variableList; 51 | if (!variableList) { 52 | parsed.variableList = variableList = {}; 53 | } 54 | let NAME: string; 55 | let VALUE; 56 | if ('QUERYPARAM' in attr) { 57 | NAME = attr.QUERYPARAM; 58 | try { 59 | const searchParams = new self.URL(parentUrl).searchParams; 60 | if (searchParams.has(NAME)) { 61 | VALUE = searchParams.get(NAME); 62 | } else { 63 | throw new Error( 64 | `"${NAME}" does not match any query parameter in URI: "${parentUrl}"`, 65 | ); 66 | } 67 | } catch (error) { 68 | parsed.playlistParsingError ||= new Error( 69 | `EXT-X-DEFINE QUERYPARAM: ${error.message}`, 70 | ); 71 | } 72 | } else { 73 | NAME = attr.NAME; 74 | VALUE = attr.VALUE; 75 | } 76 | if (NAME in variableList) { 77 | parsed.playlistParsingError ||= new Error( 78 | `EXT-X-DEFINE duplicate Variable Name declarations: "${NAME}"`, 79 | ); 80 | } else { 81 | variableList[NAME] = VALUE || ''; 82 | } 83 | } 84 | 85 | export function importVariableDefinition( 86 | parsed: Pick< 87 | ParsedMultivariantPlaylist | LevelDetails, 88 | 'variableList' | 'playlistParsingError' 89 | >, 90 | attr: AttrList, 91 | sourceVariableList: VariableMap | null, 92 | ) { 93 | const IMPORT = attr.IMPORT; 94 | if (sourceVariableList && IMPORT in sourceVariableList) { 95 | let variableList = parsed.variableList; 96 | if (!variableList) { 97 | parsed.variableList = variableList = {}; 98 | } 99 | variableList[IMPORT] = sourceVariableList[IMPORT]; 100 | } else { 101 | parsed.playlistParsingError ||= new Error( 102 | `EXT-X-DEFINE IMPORT attribute not found in Multivariant Playlist: "${IMPORT}"`, 103 | ); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /tests/unit/controller/ewma-bandwidth-estimator.ts: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import sinonChai from 'sinon-chai'; 3 | import EwmaBandWidthEstimator from '../../../src/utils/ewma-bandwidth-estimator'; 4 | 5 | chai.use(sinonChai); 6 | const expect = chai.expect; 7 | 8 | describe('EwmaBandWidthEstimator', function () { 9 | it('returns default estimate if bw estimator not available yet', function () { 10 | const defaultEstimate = 5e5; 11 | const bwEstimator = new EwmaBandWidthEstimator(0, 0, defaultEstimate); 12 | expect(bwEstimator.getEstimate()).to.equal(5e5); 13 | }); 14 | 15 | it('returns last bitrate is fast=slow=0', function () { 16 | const defaultEstimate = 5e5; 17 | const bwEstimator = new EwmaBandWidthEstimator(0, 0, defaultEstimate); 18 | bwEstimator.sample(8000, 1000000); 19 | expect(bwEstimator.getEstimate()).to.equal(1000000); 20 | bwEstimator.sample(4000, 1000000); 21 | expect(bwEstimator.getEstimate()).to.equal(2000000); 22 | bwEstimator.sample(1000, 1000000); 23 | expect(bwEstimator.getEstimate()).to.equal(8000000); 24 | }); 25 | 26 | it('returns correct value bitrate is slow=15,fast=4', function () { 27 | const defaultEstimate = 5e5; 28 | const bwEstimator = new EwmaBandWidthEstimator(15, 4, defaultEstimate); 29 | bwEstimator.sample(8000, 1000000); 30 | expect(bwEstimator.getEstimate()).to.equal(1000000); 31 | bwEstimator.sample(4000, 1000000); 32 | expect(bwEstimator.getEstimate()).to.closeTo( 33 | 1396480.1544736226, 34 | 0.000000001, 35 | ); 36 | bwEstimator.sample(1000, 1000000); 37 | expect(bwEstimator.getEstimate()).to.closeTo( 38 | 2056826.9489827948, 39 | 0.000000001, 40 | ); 41 | }); 42 | 43 | it('returns correct value bitrate is slow=9,fast=5', function () { 44 | const defaultEstimate = 5e5; 45 | const bwEstimator = new EwmaBandWidthEstimator(9, 5, defaultEstimate); 46 | bwEstimator.sample(8000, 1000000); 47 | expect(bwEstimator.getEstimate()).to.equal(1000000); 48 | bwEstimator.sample(4000, 1000000); 49 | expect(bwEstimator.getEstimate()).to.closeTo( 50 | 1439580.319105247, 51 | 0.000000001, 52 | ); 53 | bwEstimator.sample(1000, 1000000); 54 | expect(bwEstimator.getEstimate()).to.closeTo( 55 | 2208342.324322311, 56 | 0.000000001, 57 | ); 58 | }); 59 | 60 | it('returns correct value after updating slow and fast', function () { 61 | const defaultEstimate = 5e5; 62 | const bwEstimator = new EwmaBandWidthEstimator(9, 3, defaultEstimate); 63 | expect(bwEstimator.getEstimate()).to.equal(defaultEstimate); 64 | bwEstimator.sample(8000, 1000000); 65 | expect(bwEstimator.getEstimate()).to.equal(1000000); 66 | bwEstimator.sample(4000, 1000000); 67 | expect(bwEstimator.getEstimate()).to.closeTo( 68 | 1439580.319105247, 69 | 0.000000001, 70 | ); 71 | bwEstimator.update(15, 4); 72 | expect(bwEstimator.getEstimate()).to.closeTo( 73 | 1878125.393685882, 74 | 0.000000001, 75 | ); 76 | bwEstimator.sample(1000, 1000000); 77 | expect(bwEstimator.getEstimate()).to.closeTo( 78 | 2966543.443461984, 79 | 0.000000001, 80 | ); 81 | }); 82 | 83 | it('returns correct value when updating before a sample', function () { 84 | const defaultEstimate = 5e5; 85 | const bwEstimator = new EwmaBandWidthEstimator(9, 3, defaultEstimate); 86 | bwEstimator.update(15, 4); 87 | expect(bwEstimator.getEstimate()).to.equal(defaultEstimate); 88 | bwEstimator.sample(8000, 1000000); 89 | expect(bwEstimator.getEstimate()).to.equal(1000000); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | export interface ILogFunction { 2 | (message?: any, ...optionalParams: any[]): void; 3 | } 4 | 5 | export interface ILogger { 6 | trace: ILogFunction; 7 | debug: ILogFunction; 8 | log: ILogFunction; 9 | warn: ILogFunction; 10 | info: ILogFunction; 11 | error: ILogFunction; 12 | } 13 | 14 | export class Logger implements ILogger { 15 | trace: ILogFunction; 16 | debug: ILogFunction; 17 | log: ILogFunction; 18 | warn: ILogFunction; 19 | info: ILogFunction; 20 | error: ILogFunction; 21 | 22 | constructor(label: string, logger: ILogger) { 23 | const lb = `[${label}]:`; 24 | this.trace = noop; 25 | this.debug = logger.debug.bind(null, lb); 26 | this.log = logger.log.bind(null, lb); 27 | this.warn = logger.warn.bind(null, lb); 28 | this.info = logger.info.bind(null, lb); 29 | this.error = logger.error.bind(null, lb); 30 | } 31 | } 32 | 33 | const noop: ILogFunction = function () {}; 34 | 35 | const fakeLogger: ILogger = { 36 | trace: noop, 37 | debug: noop, 38 | log: noop, 39 | warn: noop, 40 | info: noop, 41 | error: noop, 42 | }; 43 | 44 | function createLogger() { 45 | return Object.assign({}, fakeLogger); 46 | } 47 | 48 | // let lastCallTime; 49 | // function formatMsgWithTimeInfo(type, msg) { 50 | // const now = Date.now(); 51 | // const diff = lastCallTime ? '+' + (now - lastCallTime) : '0'; 52 | // lastCallTime = now; 53 | // msg = (new Date(now)).toISOString() + ' | [' + type + '] > ' + msg + ' ( ' + diff + ' ms )'; 54 | // return msg; 55 | // } 56 | 57 | function consolePrintFn(type: string, id: string | undefined): ILogFunction { 58 | const func: ILogFunction = self.console[type]; 59 | return func 60 | ? func.bind(self.console, `${id ? '[' + id + '] ' : ''}[${type}] >`) 61 | : noop; 62 | } 63 | 64 | function getLoggerFn( 65 | key: string, 66 | debugConfig: boolean | Partial, 67 | id?: string, 68 | ): ILogFunction { 69 | return debugConfig[key] 70 | ? debugConfig[key].bind(debugConfig) 71 | : consolePrintFn(key, id); 72 | } 73 | 74 | const exportedLogger: ILogger = createLogger(); 75 | 76 | export function enableLogs( 77 | debugConfig: boolean | ILogger, 78 | context: string, 79 | id?: string | undefined, 80 | ): ILogger { 81 | // check that console is available 82 | const newLogger = createLogger(); 83 | if ( 84 | (typeof console === 'object' && debugConfig === true) || 85 | typeof debugConfig === 'object' 86 | ) { 87 | const keys: (keyof ILogger)[] = [ 88 | // Remove out from list here to hard-disable a log-level 89 | // 'trace', 90 | 'debug', 91 | 'log', 92 | 'info', 93 | 'warn', 94 | 'error', 95 | ]; 96 | keys.forEach((key) => { 97 | newLogger[key] = getLoggerFn(key, debugConfig, id); 98 | }); 99 | // Some browsers don't allow to use bind on console object anyway 100 | // fallback to default if needed 101 | try { 102 | newLogger.log( 103 | `Debug logs enabled for "${context}" in hls.js version ${__VERSION__}`, 104 | ); 105 | } catch (e) { 106 | /* log fn threw an exception. All logger methods are no-ops. */ 107 | return createLogger(); 108 | } 109 | // global exported logger uses the same functions as new logger without `id` 110 | keys.forEach((key) => { 111 | exportedLogger[key] = getLoggerFn(key, debugConfig); 112 | }); 113 | } else { 114 | // Reset global exported logger 115 | Object.assign(exportedLogger, newLogger); 116 | } 117 | return newLogger; 118 | } 119 | 120 | export const logger: ILogger = exportedLogger; 121 | -------------------------------------------------------------------------------- /tests/unit/controller/base-stream-controller.ts: -------------------------------------------------------------------------------- 1 | import Hls from '../../../src/hls'; 2 | import { hlsDefaultConfig } from '../../../src/config'; 3 | import BaseStreamController from '../../../src/controller/stream-controller'; 4 | import KeyLoader from '../../../src/loader/key-loader'; 5 | import { TimeRangesMock } from '../../mocks/time-ranges.mock'; 6 | import type { BufferInfo } from '../../../src/utils/buffer-helper'; 7 | import type { LevelDetails } from '../../../src/loader/level-details'; 8 | import type { Fragment, Part } from '../../../src/loader/fragment'; 9 | 10 | import chai from 'chai'; 11 | import sinonChai from 'sinon-chai'; 12 | 13 | chai.use(sinonChai); 14 | const expect = chai.expect; 15 | 16 | type BaseStreamControllerTestable = Omit< 17 | BaseStreamController, 18 | 'media' | '_streamEnded' 19 | > & { 20 | media: HTMLMediaElement | null; 21 | _streamEnded: (bufferInfo: BufferInfo, levelDetails: LevelDetails) => boolean; 22 | }; 23 | 24 | describe('BaseStreamController', function () { 25 | let hls: Hls; 26 | let baseStreamController: BaseStreamControllerTestable; 27 | let bufferInfo: BufferInfo; 28 | let levelDetails: LevelDetails; 29 | let fragmentTracker; 30 | let media; 31 | beforeEach(function () { 32 | hls = new Hls({}); 33 | fragmentTracker = { 34 | state: null, 35 | getState() { 36 | return this.state; 37 | }, 38 | isEndListAppended() { 39 | return true; 40 | }, 41 | }; 42 | baseStreamController = new BaseStreamController( 43 | hls, 44 | fragmentTracker, 45 | new KeyLoader(hlsDefaultConfig), 46 | ) as unknown as BaseStreamControllerTestable; 47 | bufferInfo = { 48 | len: 1, 49 | nextStart: 0, 50 | start: 0, 51 | end: 1, 52 | }; 53 | levelDetails = { 54 | endSN: 0, 55 | live: false, 56 | get fragments() { 57 | const frags: Fragment[] = []; 58 | for (let i = 0; i < this.endSN; i++) { 59 | frags.push({ sn: i, type: 'main' } as unknown as Fragment); 60 | } 61 | return frags; 62 | }, 63 | } as unknown as LevelDetails; 64 | media = { 65 | duration: 0, 66 | buffered: new TimeRangesMock(), 67 | } as unknown as HTMLMediaElement; 68 | baseStreamController.media = media; 69 | }); 70 | 71 | describe('_streamEnded', function () { 72 | it('returns false if the stream is live', function () { 73 | levelDetails.live = true; 74 | expect(baseStreamController._streamEnded(bufferInfo, levelDetails)).to.be 75 | .false; 76 | }); 77 | 78 | it('returns false if there is subsequently buffered range', function () { 79 | levelDetails.endSN = 10; 80 | bufferInfo.nextStart = 100; 81 | expect(baseStreamController._streamEnded(bufferInfo, levelDetails)).to.be 82 | .false; 83 | }); 84 | 85 | it('returns true if parts are buffered for low latency content', function () { 86 | media.buffered = new TimeRangesMock([0, 1]); 87 | levelDetails.endSN = 10; 88 | levelDetails.partList = [{ start: 0, duration: 1 } as unknown as Part]; 89 | 90 | expect(baseStreamController._streamEnded(bufferInfo, levelDetails)).to.be 91 | .true; 92 | }); 93 | 94 | it('depends on fragment-tracker to determine if last fragment is buffered', function () { 95 | media.buffered = new TimeRangesMock([0, 1]); 96 | levelDetails.endSN = 10; 97 | 98 | expect(baseStreamController._streamEnded(bufferInfo, levelDetails)).to.be 99 | .true; 100 | 101 | fragmentTracker.isEndListAppended = () => false; 102 | expect(baseStreamController._streamEnded(bufferInfo, levelDetails)).to.be 103 | .false; 104 | }); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /src/remux/aac-helper.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * AAC helper 3 | */ 4 | 5 | class AAC { 6 | static getSilentFrame( 7 | codec?: string, 8 | channelCount?: number, 9 | ): Uint8Array | undefined { 10 | switch (codec) { 11 | case 'mp4a.40.2': 12 | if (channelCount === 1) { 13 | return new Uint8Array([0x00, 0xc8, 0x00, 0x80, 0x23, 0x80]); 14 | } else if (channelCount === 2) { 15 | return new Uint8Array([ 16 | 0x21, 0x00, 0x49, 0x90, 0x02, 0x19, 0x00, 0x23, 0x80, 17 | ]); 18 | } else if (channelCount === 3) { 19 | return new Uint8Array([ 20 | 0x00, 0xc8, 0x00, 0x80, 0x20, 0x84, 0x01, 0x26, 0x40, 0x08, 0x64, 21 | 0x00, 0x8e, 22 | ]); 23 | } else if (channelCount === 4) { 24 | return new Uint8Array([ 25 | 0x00, 0xc8, 0x00, 0x80, 0x20, 0x84, 0x01, 0x26, 0x40, 0x08, 0x64, 26 | 0x00, 0x80, 0x2c, 0x80, 0x08, 0x02, 0x38, 27 | ]); 28 | } else if (channelCount === 5) { 29 | return new Uint8Array([ 30 | 0x00, 0xc8, 0x00, 0x80, 0x20, 0x84, 0x01, 0x26, 0x40, 0x08, 0x64, 31 | 0x00, 0x82, 0x30, 0x04, 0x99, 0x00, 0x21, 0x90, 0x02, 0x38, 32 | ]); 33 | } else if (channelCount === 6) { 34 | return new Uint8Array([ 35 | 0x00, 0xc8, 0x00, 0x80, 0x20, 0x84, 0x01, 0x26, 0x40, 0x08, 0x64, 36 | 0x00, 0x82, 0x30, 0x04, 0x99, 0x00, 0x21, 0x90, 0x02, 0x00, 0xb2, 37 | 0x00, 0x20, 0x08, 0xe0, 38 | ]); 39 | } 40 | 41 | break; 42 | // handle HE-AAC below (mp4a.40.5 / mp4a.40.29) 43 | default: 44 | if (channelCount === 1) { 45 | // ffmpeg -y -f lavfi -i "aevalsrc=0:d=0.05" -c:a libfdk_aac -profile:a aac_he -b:a 4k output.aac && hexdump -v -e '16/1 "0x%x," "\n"' -v output.aac 46 | return new Uint8Array([ 47 | 0x1, 0x40, 0x22, 0x80, 0xa3, 0x4e, 0xe6, 0x80, 0xba, 0x8, 0x0, 0x0, 48 | 0x0, 0x1c, 0x6, 0xf1, 0xc1, 0xa, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 49 | 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 50 | 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 51 | 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 52 | 0x5a, 0x5e, 53 | ]); 54 | } else if (channelCount === 2) { 55 | // ffmpeg -y -f lavfi -i "aevalsrc=0|0:d=0.05" -c:a libfdk_aac -profile:a aac_he_v2 -b:a 4k output.aac && hexdump -v -e '16/1 "0x%x," "\n"' -v output.aac 56 | return new Uint8Array([ 57 | 0x1, 0x40, 0x22, 0x80, 0xa3, 0x5e, 0xe6, 0x80, 0xba, 0x8, 0x0, 0x0, 58 | 0x0, 0x0, 0x95, 0x0, 0x6, 0xf1, 0xa1, 0xa, 0x5a, 0x5a, 0x5a, 0x5a, 59 | 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 60 | 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 61 | 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 62 | 0x5a, 0x5e, 63 | ]); 64 | } else if (channelCount === 3) { 65 | // ffmpeg -y -f lavfi -i "aevalsrc=0|0|0:d=0.05" -c:a libfdk_aac -profile:a aac_he_v2 -b:a 4k output.aac && hexdump -v -e '16/1 "0x%x," "\n"' -v output.aac 66 | return new Uint8Array([ 67 | 0x1, 0x40, 0x22, 0x80, 0xa3, 0x5e, 0xe6, 0x80, 0xba, 0x8, 0x0, 0x0, 68 | 0x0, 0x0, 0x95, 0x0, 0x6, 0xf1, 0xa1, 0xa, 0x5a, 0x5a, 0x5a, 0x5a, 69 | 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 70 | 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 71 | 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 72 | 0x5a, 0x5e, 73 | ]); 74 | } 75 | break; 76 | } 77 | return undefined; 78 | } 79 | } 80 | 81 | export default AAC; 82 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Create a report to help us improve 3 | labels: [Bug, Needs Triage] 4 | body: 5 | - type: input 6 | id: version 7 | attributes: 8 | label: What version of Hls.js are you using? 9 | placeholder: e.g. vX.Y.Z 10 | validations: 11 | required: true 12 | - type: input 13 | id: browser 14 | attributes: 15 | label: What browser (including version) are you using? 16 | placeholder: e.g. Chrome 91.0.4472.106 (Official Build) (x86_64) 17 | validations: 18 | required: true 19 | - type: input 20 | id: os 21 | attributes: 22 | label: What OS (including version) are you using? 23 | placeholder: e.g. Windows 10 24 | validations: 25 | required: true 26 | - type: input 27 | id: stream 28 | attributes: 29 | label: Test stream 30 | description: If possible, please provide a test stream or page. You can paste your stream into the demo and provide the permalink here. 31 | validations: 32 | required: false 33 | - type: textarea 34 | id: configuration 35 | attributes: 36 | label: Configuration 37 | description: Please provide the player configuration. 38 | placeholder: | 39 | { 40 | "debug": false, 41 | "backBufferLength": 60 42 | } 43 | value: '{}' 44 | render: JavaScript 45 | validations: 46 | required: true 47 | - type: textarea 48 | id: additional_player_steps 49 | attributes: 50 | label: Additional player setup steps 51 | description: Please provide any additional player setup steps if there are any. 52 | validations: 53 | required: false 54 | - type: checkboxes 55 | id: checklist 56 | attributes: 57 | label: Checklist 58 | options: 59 | - label: The issue observed is not already reported by searching on Github under https://github.com/video-dev/hls.js/issues 60 | required: true 61 | - label: The issue occurs in the stable client (latest release) on https://hlsjs.video-dev.org/demo and not just on my page 62 | required: true 63 | - label: The issue occurs in the latest client (main branch) on https://hlsjs-dev.video-dev.org/demo and not just on my page 64 | required: true 65 | - label: The stream has correct Access-Control-Allow-Origin headers (CORS) 66 | required: true 67 | - label: There are no network errors such as 404s in the browser console when trying to play the stream 68 | required: true 69 | - type: textarea 70 | id: steps_to_reproduce 71 | attributes: 72 | label: Steps to reproduce 73 | description: Please provide clear steps to reproduce your problem. If the bug is intermittent, give a rough frequency. 74 | value: | 75 | 1. 76 | 2. 77 | validations: 78 | required: true 79 | - type: textarea 80 | id: expected_behaviour 81 | attributes: 82 | label: Expected behaviour 83 | validations: 84 | required: true 85 | - type: textarea 86 | id: actual_behaviour 87 | attributes: 88 | label: What actually happened? 89 | validations: 90 | required: true 91 | - type: textarea 92 | id: console_output 93 | attributes: 94 | label: Console output 95 | description: Paste the contents of the browser console here (with `debug` enabled in your config). 96 | render: shell 97 | validations: 98 | required: true 99 | - type: textarea 100 | id: media_internals_output 101 | attributes: 102 | label: Chrome media internals output 103 | description: For media errors reported on Chrome browser, please also paste the output of "chrome://media-internals" 104 | render: shell 105 | validations: 106 | required: false 107 | -------------------------------------------------------------------------------- /scripts/get-package-version.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | /* eslint-env node */ 3 | 'use strict'; 4 | 5 | const versionParser = require('./version-parser.js'); 6 | const { isValidStableVersion, incrementPatch } = require('./version-parser.js'); 7 | 8 | const latestVersion = getLatestVersionTag(); 9 | let newVersion = ''; 10 | 11 | try { 12 | if (process.env.TAG) { 13 | // write the version field in the package json to the version in the git tag 14 | const tag = process.env.TAG; 15 | if (!versionParser.isValidVersion(tag)) { 16 | throw new Error(`Unsupported tag for release: "${tag}"`); 17 | } 18 | // remove v 19 | newVersion = tag.substring(1); 20 | if (!versionParser.isDefinitelyGreaterThanCanaries(newVersion)) { 21 | // 1.2.3-0.canary.500 22 | // 1.2.3-0.canary.501 23 | // 1.2.3-0.caaanary.custom => bad 24 | // 1.2.3-0.caaanary.custom.0.canary.503 => now lower than 1.2.3-0.canary.501 25 | throw new Error( 26 | `It's possible that "${newVersion}" has a lower precedense than an existing canary version which is not allowed.`, 27 | ); 28 | } 29 | } else { 30 | // bump patch in version from latest git tag 31 | let intermediateVersion = latestVersion; 32 | const isStable = isValidStableVersion(intermediateVersion); 33 | 34 | // if last git tagged version is a prerelease we should append `.0..` 35 | // if the last git tagged version is a stable version then we should append `-0..` and increment the patch 36 | // `type` can be `pr`, `branch`, or `canary` 37 | if (isStable) { 38 | intermediateVersion = incrementPatch(intermediateVersion); 39 | } 40 | 41 | // remove v 42 | intermediateVersion = intermediateVersion.substring(1); 43 | 44 | const suffix = process.env.CF_PAGES 45 | ? `pr.${process.env.CF_PAGES_BRANCH.replace( 46 | /[^a-zA-Z-]/g, 47 | '-', 48 | )}.${getCommitHash().slice(0, 8)}` 49 | : `0.canary.${getCommitNum()}`; 50 | 51 | newVersion = `${intermediateVersion}${isStable ? '-' : '.'}${suffix}`; 52 | } 53 | 54 | if (!versionParser.isGreaterOrEqual(newVersion, latestVersion)) { 55 | throw new Error( 56 | `New version "${newVersion}" is not >= latest version "${latestVersion}" on this branch.`, 57 | ); 58 | } 59 | 60 | const foundPreviousVersion = versionParser 61 | .getPotentialPreviousStableVersions(`v${newVersion}`) 62 | .every((potentialPreviousVersion) => 63 | hasTag(`v${potentialPreviousVersion}`), 64 | ); 65 | if (!foundPreviousVersion) { 66 | throw new Error( 67 | 'Could not find a previous version. The tag must follow a previous stable version number.', 68 | ); 69 | } 70 | 71 | console.log(newVersion); 72 | } catch (e) { 73 | console.error(e); 74 | process.exit(1); 75 | } 76 | process.exit(0); 77 | 78 | function getCommitNum() { 79 | return parseInt(exec('git rev-list --count HEAD'), 10); 80 | } 81 | 82 | function getCommitHash() { 83 | return exec('git rev-parse HEAD'); 84 | } 85 | 86 | function getLatestVersionTag() { 87 | let commitish = ''; 88 | // eslint-disable-next-line no-constant-condition 89 | while (true) { 90 | const tag = exec('git describe --tag --abbrev=0 --match="v*" ' + commitish); 91 | if (!tag) { 92 | throw new Error('Could not find tag.'); 93 | } 94 | if (versionParser.isValidVersion(tag)) { 95 | return tag; 96 | } 97 | // next time search older tags than this one 98 | commitish = tag + '~1'; 99 | } 100 | } 101 | 102 | function hasTag(tag) { 103 | try { 104 | exec(`git rev-parse "refs/tags/${tag}"`); 105 | return true; 106 | } catch (e) { 107 | return false; 108 | } 109 | } 110 | 111 | function exec(cmd) { 112 | return require('child_process') 113 | .execSync(cmd, { stdio: 'pipe' }) 114 | .toString() 115 | .trim(); 116 | } 117 | -------------------------------------------------------------------------------- /src/types/demuxer.ts: -------------------------------------------------------------------------------- 1 | import type { RationalTimestamp } from '../utils/timescale-conversion'; 2 | 3 | export interface Demuxer { 4 | demux( 5 | data: Uint8Array, 6 | timeOffset: number, 7 | isSampleAes?: boolean, 8 | flush?: boolean, 9 | ): DemuxerResult; 10 | demuxSampleAes( 11 | data: Uint8Array, 12 | keyData: KeyData, 13 | timeOffset: number, 14 | ): Promise; 15 | flush(timeOffset?: number): DemuxerResult | Promise; 16 | destroy(): void; 17 | resetInitSegment( 18 | initSegment: Uint8Array | undefined, 19 | audioCodec: string | undefined, 20 | videoCodec: string | undefined, 21 | trackDuration: number, 22 | ); 23 | resetTimeStamp(defaultInitPTS?: RationalTimestamp | null): void; 24 | resetContiguity(): void; 25 | } 26 | 27 | export interface DemuxerResult { 28 | audioTrack: DemuxedAudioTrack; 29 | videoTrack: DemuxedVideoTrackBase; 30 | id3Track: DemuxedMetadataTrack; 31 | textTrack: DemuxedUserdataTrack; 32 | } 33 | 34 | export interface DemuxedTrack { 35 | type: string; 36 | id: number; 37 | pid: number; 38 | inputTimeScale: number; 39 | sequenceNumber: number; 40 | samples: 41 | | AudioSample[] 42 | | VideoSample[] 43 | | MetadataSample[] 44 | | UserdataSample[] 45 | | Uint8Array; 46 | timescale?: number; 47 | container?: string; 48 | dropped: number; 49 | duration?: number; 50 | pesData?: ElementaryStreamData | null; 51 | codec?: string; 52 | } 53 | 54 | export interface PassthroughTrack extends DemuxedTrack { 55 | sampleDuration: number; 56 | samples: Uint8Array; 57 | timescale: number; 58 | duration: number; 59 | codec: string; 60 | } 61 | export interface DemuxedAudioTrack extends DemuxedTrack { 62 | config?: number[] | Uint8Array; 63 | samplerate?: number; 64 | segmentCodec?: string; 65 | channelCount?: number; 66 | manifestCodec?: string; 67 | parsedCodec?: string; 68 | samples: AudioSample[]; 69 | } 70 | 71 | export interface DemuxedVideoTrackBase extends DemuxedTrack { 72 | width?: number; 73 | height?: number; 74 | pixelRatio?: [number, number]; 75 | audFound?: boolean; 76 | vps?: Uint8Array[]; 77 | pps?: Uint8Array[]; 78 | sps?: Uint8Array[]; 79 | naluState?: number; 80 | segmentCodec?: string; 81 | manifestCodec?: string; 82 | samples: VideoSample[] | Uint8Array; 83 | params?: object; 84 | } 85 | 86 | export interface DemuxedVideoTrack extends DemuxedVideoTrackBase { 87 | samples: VideoSample[]; 88 | } 89 | 90 | export interface DemuxedMetadataTrack extends DemuxedTrack { 91 | samples: MetadataSample[]; 92 | } 93 | 94 | export interface DemuxedUserdataTrack extends DemuxedTrack { 95 | samples: UserdataSample[]; 96 | } 97 | 98 | export enum MetadataSchema { 99 | audioId3 = 'org.id3', 100 | dateRange = 'com.apple.quicktime.HLS', 101 | emsg = 'https://aomedia.org/emsg/ID3', 102 | misbklv = 'urn:misb:KLV:bin:1910.1', 103 | } 104 | export interface MetadataSample { 105 | pts: number; 106 | dts: number; 107 | duration: number; 108 | len?: number; 109 | data: Uint8Array; 110 | type: MetadataSchema; 111 | } 112 | 113 | export interface UserdataSample { 114 | pts: number; 115 | bytes?: Uint8Array; 116 | type?: number; 117 | payloadType?: number; 118 | uuid?: string; 119 | userData?: string; 120 | userDataBytes?: Uint8Array; 121 | } 122 | 123 | export interface VideoSample { 124 | dts: number; 125 | pts: number; 126 | key: boolean; 127 | frame: boolean; 128 | units: VideoSampleUnit[]; 129 | length: number; 130 | } 131 | 132 | export interface VideoSampleUnit { 133 | data: Uint8Array; 134 | type: number; 135 | state?: number; 136 | } 137 | 138 | export type AudioSample = { 139 | unit: Uint8Array; 140 | pts: number; 141 | }; 142 | 143 | export type AudioFrame = { 144 | sample: AudioSample; 145 | length: number; 146 | missing: number; 147 | }; 148 | 149 | export interface ElementaryStreamData { 150 | data: Uint8Array[]; 151 | size: number; 152 | } 153 | 154 | export interface KeyData { 155 | method: string; 156 | key: Uint8Array; 157 | iv: Uint8Array; 158 | } 159 | -------------------------------------------------------------------------------- /tests/unit/crypt/decrypter.js: -------------------------------------------------------------------------------- 1 | import Decrypter from '../../../src/crypt/decrypter'; 2 | 3 | describe('Decrypter', function () { 4 | it('decripts correctly aes-128-cbc software mode', function () { 5 | const data = get128cbcData(); 6 | 7 | const config = { enableSoftwareAES: true }; 8 | const decrypter = new Decrypter(config, { removePKCS7Padding: true }); 9 | const cbcMode = 0; 10 | 11 | decrypter.softwareDecrypt(data.encrypted, data.key, data.iv, cbcMode); 12 | const decrypted = decrypter.flush(); 13 | expect(new Uint8Array(decrypted)).to.deep.equal(data.expected); 14 | }); 15 | 16 | it('decripts correctly aes-128-cbc webCrypto mode', async function () { 17 | const data = get128cbcData(); 18 | 19 | const config = { enableSoftwareAES: false }; 20 | const decrypter = new Decrypter(config); 21 | const cbcMode = 0; 22 | const decrypted = await decrypter.webCryptoDecrypt( 23 | data.encrypted, 24 | data.key, 25 | data.iv, 26 | cbcMode, 27 | ); 28 | expect(new Uint8Array(decrypted)).to.deep.equal(data.expected); 29 | }); 30 | 31 | it('decripts correctly aes-128-cbc', async function () { 32 | const data = get128cbcData(); 33 | 34 | const config = { enableSoftwareAES: true }; 35 | const decrypter = new Decrypter(config); 36 | const cbcMode = 0; 37 | const decrypted = await decrypter.decrypt( 38 | data.encrypted, 39 | data.key, 40 | data.iv, 41 | cbcMode, 42 | ); 43 | expect(new Uint8Array(decrypted)).to.deep.equal(data.expected); 44 | }); 45 | 46 | it('decripts correctly aes-256-cbc', async function () { 47 | const data = get256cbcData(); 48 | 49 | const config = { enableSoftwareAES: false }; 50 | const decrypter = new Decrypter(config); 51 | const cbcMode = 0; 52 | const decrypted = await decrypter.decrypt( 53 | data.encrypted, 54 | data.key, 55 | data.iv, 56 | cbcMode, 57 | ); 58 | expect(new Uint8Array(decrypted)).to.deep.equal(data.expected); 59 | }); 60 | 61 | it('decripts correctly aes-256-ctr', async function () { 62 | const data = get256ctrData(); 63 | 64 | const config = { enableSoftwareAES: false }; 65 | const decrypter = new Decrypter(config); 66 | const ctrMode = 1; 67 | const decrypted = await decrypter.decrypt( 68 | data.encrypted, 69 | data.key, 70 | data.iv, 71 | ctrMode, 72 | ); 73 | expect(new Uint8Array(decrypted)).to.deep.equal(data.expected); 74 | }); 75 | }); 76 | 77 | function get128cbcData() { 78 | const key = new Uint8Array([ 79 | 0xe5, 0xe9, 0xfa, 0x1b, 0xa3, 0x1e, 0xcd, 0x1a, 0xe8, 0x4f, 0x75, 0xca, 80 | 0xaa, 0x47, 0x4f, 0x3a, 81 | ]).buffer; 82 | const iv = new Uint8Array([ 83 | 0x66, 0x3f, 0x05, 0xf4, 0x12, 0x02, 0x8f, 0x81, 0xda, 0x65, 0xd2, 0x6e, 84 | 0xe5, 0x64, 0x24, 0xb2, 85 | ]).buffer; 86 | const encrypted = new Uint8Array([ 87 | 0x2c, 0x94, 0xcf, 0xc0, 0x91, 0xff, 0x0e, 0xcc, 0x98, 0x66, 0xcc, 0x83, 88 | 0x0d, 0xd7, 0xc3, 0x55, 89 | ]); 90 | const expected = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x21, 0x0a]); 91 | return { key: key, iv: iv, encrypted: encrypted, expected: expected }; 92 | } 93 | 94 | function get256Data() { 95 | const key = new Uint8Array([ 96 | 0xe5, 0xe9, 0xfa, 0x1b, 0xa3, 0x1e, 0xcd, 0x1a, 0xe8, 0x4f, 0x75, 0xca, 97 | 0xaa, 0x47, 0x4f, 0x3a, 0x66, 0x3f, 0x05, 0xf4, 0x12, 0x02, 0x8f, 0x81, 98 | 0xda, 0x65, 0xd2, 0x6e, 0xe5, 0x64, 0x24, 0xb2, 99 | ]).buffer; 100 | const iv = new Uint8Array([ 101 | 0xf4, 0x8c, 0xef, 0xa0, 0xad, 0x59, 0xc9, 0xa5, 0x60, 0x16, 0xcf, 0xbb, 102 | 0x26, 0x5b, 0xee, 0x8c, 103 | ]).buffer; 104 | const expected = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x21, 0x0a]); 105 | return { key: key, iv: iv, expected: expected }; 106 | } 107 | 108 | function get256cbcData() { 109 | const some256data = get256Data(); 110 | const encrypted = new Uint8Array([ 111 | 0xe7, 0x25, 0x6a, 0x77, 0x3a, 0xa5, 0x43, 0x59, 0xaf, 0x60, 0xc1, 0xd3, 112 | 0xed, 0x31, 0xc4, 0x01, 113 | ]); 114 | some256data.encrypted = encrypted; 115 | return some256data; 116 | } 117 | 118 | function get256ctrData() { 119 | const some256data = get256Data(); 120 | const encrypted = new Uint8Array([0xb8, 0xd1, 0xcf, 0x15, 0x0d, 0x34, 0x12]); 121 | some256data.encrypted = encrypted; 122 | return some256data; 123 | } 124 | -------------------------------------------------------------------------------- /src/demux/video/exp-golomb.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parser for exponential Golomb codes, a variable-bitwidth number encoding scheme used by h264. 3 | */ 4 | 5 | import { logger } from '../../utils/logger'; 6 | 7 | class ExpGolomb { 8 | private data: Uint8Array; 9 | public bytesAvailable: number; 10 | private word: number; 11 | private bitsAvailable: number; 12 | 13 | constructor(data: Uint8Array) { 14 | this.data = data; 15 | // the number of bytes left to examine in this.data 16 | this.bytesAvailable = data.byteLength; 17 | // the current word being examined 18 | this.word = 0; // :uint 19 | // the number of bits left to examine in the current word 20 | this.bitsAvailable = 0; // :uint 21 | } 22 | 23 | // ():void 24 | loadWord(): void { 25 | const data = this.data; 26 | const bytesAvailable = this.bytesAvailable; 27 | const position = data.byteLength - bytesAvailable; 28 | const workingBytes = new Uint8Array(4); 29 | const availableBytes = Math.min(4, bytesAvailable); 30 | if (availableBytes === 0) { 31 | throw new Error('no bytes available'); 32 | } 33 | 34 | workingBytes.set(data.subarray(position, position + availableBytes)); 35 | this.word = new DataView(workingBytes.buffer).getUint32(0); 36 | // track the amount of this.data that has been processed 37 | this.bitsAvailable = availableBytes * 8; 38 | this.bytesAvailable -= availableBytes; 39 | } 40 | 41 | // (count:int):void 42 | skipBits(count: number): void { 43 | let skipBytes; // :int 44 | count = Math.min(count, this.bytesAvailable * 8 + this.bitsAvailable); 45 | if (this.bitsAvailable > count) { 46 | this.word <<= count; 47 | this.bitsAvailable -= count; 48 | } else { 49 | count -= this.bitsAvailable; 50 | skipBytes = count >> 3; 51 | count -= skipBytes << 3; 52 | this.bytesAvailable -= skipBytes; 53 | this.loadWord(); 54 | this.word <<= count; 55 | this.bitsAvailable -= count; 56 | } 57 | } 58 | 59 | // (size:int):uint 60 | readBits(size: number): number { 61 | let bits = Math.min(this.bitsAvailable, size); // :uint 62 | const valu = this.word >>> (32 - bits); // :uint 63 | if (size > 32) { 64 | logger.error('Cannot read more than 32 bits at a time'); 65 | } 66 | 67 | this.bitsAvailable -= bits; 68 | if (this.bitsAvailable > 0) { 69 | this.word <<= bits; 70 | } else if (this.bytesAvailable > 0) { 71 | this.loadWord(); 72 | } else { 73 | throw new Error('no bits available'); 74 | } 75 | 76 | bits = size - bits; 77 | if (bits > 0 && this.bitsAvailable) { 78 | return (valu << bits) | this.readBits(bits); 79 | } else { 80 | return valu; 81 | } 82 | } 83 | 84 | // ():uint 85 | skipLZ(): number { 86 | let leadingZeroCount; // :uint 87 | for ( 88 | leadingZeroCount = 0; 89 | leadingZeroCount < this.bitsAvailable; 90 | ++leadingZeroCount 91 | ) { 92 | if ((this.word & (0x80000000 >>> leadingZeroCount)) !== 0) { 93 | // the first bit of working word is 1 94 | this.word <<= leadingZeroCount; 95 | this.bitsAvailable -= leadingZeroCount; 96 | return leadingZeroCount; 97 | } 98 | } 99 | // we exhausted word and still have not found a 1 100 | this.loadWord(); 101 | return leadingZeroCount + this.skipLZ(); 102 | } 103 | 104 | // ():void 105 | skipUEG(): void { 106 | this.skipBits(1 + this.skipLZ()); 107 | } 108 | 109 | // ():void 110 | skipEG(): void { 111 | this.skipBits(1 + this.skipLZ()); 112 | } 113 | 114 | // ():uint 115 | readUEG(): number { 116 | const clz = this.skipLZ(); // :uint 117 | return this.readBits(clz + 1) - 1; 118 | } 119 | 120 | // ():int 121 | readEG(): number { 122 | const valu = this.readUEG(); // :int 123 | if (0x01 & valu) { 124 | // the number is odd if the low order bit is set 125 | return (1 + valu) >>> 1; // add 1 to make it even, and divide by 2 126 | } else { 127 | return -1 * (valu >>> 1); // divide by two then make it negative 128 | } 129 | } 130 | 131 | // Some convenience functions 132 | // :Boolean 133 | readBoolean(): boolean { 134 | return this.readBits(1) === 1; 135 | } 136 | 137 | // ():int 138 | readUByte(): number { 139 | return this.readBits(8); 140 | } 141 | 142 | // ():int 143 | readUShort(): number { 144 | return this.readBits(16); 145 | } 146 | 147 | // ():int 148 | readUInt(): number { 149 | return this.readBits(32); 150 | } 151 | } 152 | 153 | export default ExpGolomb; 154 | -------------------------------------------------------------------------------- /demo/style.css: -------------------------------------------------------------------------------- 1 | header { 2 | text-align: center; 3 | } 4 | 5 | th, 6 | td { 7 | padding: 15px; 8 | } 9 | 10 | select { 11 | padding: 2px 15px; 12 | background-color: rgb(181, 222, 255); 13 | font-weight: 600; 14 | padding: 5px 0; 15 | } 16 | 17 | select option { 18 | font-size: 11px; 19 | } 20 | 21 | .innerControls input { 22 | font-weight: normal; 23 | float: right; 24 | } 25 | 26 | .innerControls.permalink { 27 | display: inline-block; 28 | } 29 | 30 | @media (prefers-color-scheme: dark) { 31 | body { 32 | color: #ddd; 33 | background-color: #111; 34 | } 35 | 36 | button, 37 | optgroup, 38 | select { 39 | background-color: rgb(0, 40, 70); 40 | } 41 | 42 | input, 43 | textarea { 44 | background-color: #222; 45 | } 46 | 47 | label { 48 | font-weight: initial; 49 | } 50 | 51 | a { 52 | color: #337ab7; 53 | } 54 | 55 | pre { 56 | background-color: #050505; 57 | border-color: #333; 58 | color: #ccc; 59 | } 60 | 61 | .ace-github { 62 | background: #000; 63 | color: #fff; 64 | } 65 | 66 | .ace-github .ace_gutter { 67 | background: #181818; 68 | color: #888; 69 | } 70 | 71 | .ace-github .ace_marker-layer .ace_active-line { 72 | background: #111; 73 | } 74 | 75 | .ace-github.ace_focus .ace_marker-layer .ace_active-line { 76 | background: #321; 77 | } 78 | 79 | div.config-editor-commands { 80 | background-color: #444; 81 | border-color: #333; 82 | } 83 | 84 | button.btn { 85 | color: #050505; 86 | } 87 | 88 | canvas { 89 | background: #bbb; 90 | } 91 | } 92 | 93 | #controls { 94 | display: flex; 95 | flex-direction: column; 96 | width: 80%; 97 | max-width: 1200px; 98 | margin: 0 auto 20px auto; 99 | border: 1px solid #606060; 100 | overflow: hidden; 101 | } 102 | 103 | .demo-controls-wrapper { 104 | flex: 1 1 auto; 105 | max-width: 100%; 106 | padding: 5px 5px 0 3px; 107 | } 108 | 109 | .config-editor-wrapper { 110 | flex: 1 1 auto; 111 | display: flex; 112 | flex-direction: column; 113 | border-top: solid 1px #ccc; 114 | height: 256px; 115 | } 116 | 117 | .config-editor-container { 118 | flex: 1 1 auto; 119 | position: relative; 120 | width: 100%; 121 | height: 100%; 122 | } 123 | 124 | #config-editor { 125 | position: absolute; 126 | top: 0; 127 | right: 0; 128 | bottom: 0; 129 | left: 0; 130 | } 131 | 132 | .config-editor-commands { 133 | flex: 1 1 auto; 134 | background-color: #ddd; 135 | border-top: solid 1px #ccc; 136 | padding: 5px; 137 | display: flex; 138 | justify-content: space-between; 139 | align-items: center; 140 | } 141 | 142 | .config-editor-commands label { 143 | margin-bottom: 0; 144 | } 145 | 146 | .config-editor-commands button { 147 | padding: 5px 8px; 148 | font-size: 14px; 149 | } 150 | 151 | .innerControls { 152 | display: flex; 153 | font-size: 12px; 154 | align-items: center; 155 | margin-bottom: 5px; 156 | padding-left: 5px; 157 | justify-content: space-between; 158 | } 159 | 160 | .videoCentered { 161 | width: 720px; 162 | margin-left: auto; 163 | margin-right: auto; 164 | display: block; 165 | } 166 | 167 | .center { 168 | width: 70%; 169 | min-width: 615px; 170 | overflow: hidden; 171 | margin-left: auto; 172 | margin-right: auto; 173 | display: block; 174 | } 175 | 176 | #toggleButtons button { 177 | width: 16%; 178 | display: inline-block; 179 | text-align: center; 180 | font-size: 10pt; 181 | font-weight: bolder; 182 | background-color: rgb(181, 222, 255); 183 | padding: 5px; 184 | overflow: hidden; 185 | text-overflow: ellipsis; 186 | } 187 | 188 | #statusOut { 189 | height: auto; 190 | max-height: calc((17px * 3) + 19px); 191 | overflow: auto; 192 | } 193 | 194 | #errorOut { 195 | height: auto; 196 | max-height: calc((17px * 3) + 19px); 197 | overflow: auto; 198 | } 199 | 200 | #streamURL, 201 | #streamSelect { 202 | width: calc(100% - 4px); 203 | margin-left: 3px; 204 | } 205 | 206 | #streamURL { 207 | margin-bottom: 10px; 208 | padding-left: 3px; 209 | } 210 | 211 | #streamSelect { 212 | padding: 5px 0; 213 | } 214 | 215 | #StreamPermalink { 216 | overflow-wrap: break-word; 217 | overflow: hidden; /* for IE11 */ 218 | } 219 | 220 | #StreamPermalink a { 221 | font-size: 10px; 222 | font-family: monospace; 223 | } 224 | 225 | /* Small devices (portrait tablets and large phones, 600px and up) */ 226 | @media only screen and (min-width: 600px) { 227 | #controls { 228 | flex-direction: row; 229 | } 230 | 231 | .demo-controls-wrapper { 232 | max-width: 50%; 233 | } 234 | 235 | .config-editor-wrapper { 236 | height: auto; 237 | border-top: 0; 238 | border-left: solid 1px #ccc; 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/task-loop.ts: -------------------------------------------------------------------------------- 1 | import { type ILogger, Logger } from './utils/logger'; 2 | 3 | /** 4 | * @ignore 5 | * Sub-class specialization of EventHandler base class. 6 | * 7 | * TaskLoop allows to schedule a task function being called (optionnaly repeatedly) on the main loop, 8 | * scheduled asynchroneously, avoiding recursive calls in the same tick. 9 | * 10 | * The task itself is implemented in `doTick`. It can be requested and called for single execution 11 | * using the `tick` method. 12 | * 13 | * It will be assured that the task execution method (`tick`) only gets called once per main loop "tick", 14 | * no matter how often it gets requested for execution. Execution in further ticks will be scheduled accordingly. 15 | * 16 | * If further execution requests have already been scheduled on the next tick, it can be checked with `hasNextTick`, 17 | * and cancelled with `clearNextTick`. 18 | * 19 | * The task can be scheduled as an interval repeatedly with a period as parameter (see `setInterval`, `clearInterval`). 20 | * 21 | * Sub-classes need to implement the `doTick` method which will effectively have the task execution routine. 22 | * 23 | * Further explanations: 24 | * 25 | * The baseclass has a `tick` method that will schedule the doTick call. It may be called synchroneously 26 | * only for a stack-depth of one. On re-entrant calls, sub-sequent calls are scheduled for next main loop ticks. 27 | * 28 | * When the task execution (`tick` method) is called in re-entrant way this is detected and 29 | * we are limiting the task execution per call stack to exactly one, but scheduling/post-poning further 30 | * task processing on the next main loop iteration (also known as "next tick" in the Node/JS runtime lingo). 31 | */ 32 | export default class TaskLoop extends Logger { 33 | private readonly _boundTick: () => void; 34 | private _tickTimer: number | null = null; 35 | private _tickInterval: number | null = null; 36 | private _tickCallCount = 0; 37 | 38 | constructor(label: string, logger: ILogger) { 39 | super(label, logger); 40 | this._boundTick = this.tick.bind(this); 41 | } 42 | 43 | public destroy() { 44 | this.onHandlerDestroying(); 45 | this.onHandlerDestroyed(); 46 | } 47 | 48 | protected onHandlerDestroying() { 49 | // clear all timers before unregistering from event bus 50 | this.clearNextTick(); 51 | this.clearInterval(); 52 | } 53 | 54 | protected onHandlerDestroyed() {} 55 | 56 | public hasInterval(): boolean { 57 | return !!this._tickInterval; 58 | } 59 | 60 | public hasNextTick(): boolean { 61 | return !!this._tickTimer; 62 | } 63 | 64 | /** 65 | * @param millis - Interval time (ms) 66 | * @eturns True when interval has been scheduled, false when already scheduled (no effect) 67 | */ 68 | public setInterval(millis: number): boolean { 69 | if (!this._tickInterval) { 70 | this._tickCallCount = 0; 71 | this._tickInterval = self.setInterval(this._boundTick, millis); 72 | return true; 73 | } 74 | return false; 75 | } 76 | 77 | /** 78 | * @returns True when interval was cleared, false when none was set (no effect) 79 | */ 80 | public clearInterval(): boolean { 81 | if (this._tickInterval) { 82 | self.clearInterval(this._tickInterval); 83 | this._tickInterval = null; 84 | return true; 85 | } 86 | return false; 87 | } 88 | 89 | /** 90 | * @returns True when timeout was cleared, false when none was set (no effect) 91 | */ 92 | public clearNextTick(): boolean { 93 | if (this._tickTimer) { 94 | self.clearTimeout(this._tickTimer); 95 | this._tickTimer = null; 96 | return true; 97 | } 98 | return false; 99 | } 100 | 101 | /** 102 | * Will call the subclass doTick implementation in this main loop tick 103 | * or in the next one (via setTimeout(,0)) in case it has already been called 104 | * in this tick (in case this is a re-entrant call). 105 | */ 106 | public tick(): void { 107 | this._tickCallCount++; 108 | if (this._tickCallCount === 1) { 109 | this.doTick(); 110 | // re-entrant call to tick from previous doTick call stack 111 | // -> schedule a call on the next main loop iteration to process this task processing request 112 | if (this._tickCallCount > 1) { 113 | // make sure only one timer exists at any time at max 114 | this.tickImmediate(); 115 | } 116 | this._tickCallCount = 0; 117 | } 118 | } 119 | 120 | public tickImmediate(): void { 121 | this.clearNextTick(); 122 | this._tickTimer = self.setTimeout(this._boundTick, 0); 123 | } 124 | 125 | /** 126 | * For subclass to implement task logic 127 | * @abstract 128 | */ 129 | protected doTick(): void {} 130 | } 131 | -------------------------------------------------------------------------------- /src/loader/level-details.ts: -------------------------------------------------------------------------------- 1 | import type { Fragment, MediaFragment, Part } from './fragment'; 2 | import type { DateRange } from './date-range'; 3 | import type { AttrList } from '../utils/attr-list'; 4 | import type { VariableMap } from '../types/level'; 5 | 6 | const DEFAULT_TARGET_DURATION = 10; 7 | 8 | /** 9 | * Object representing parsed data from an HLS Media Playlist. Found in {@link hls.js#Level.details}. 10 | */ 11 | export class LevelDetails { 12 | public PTSKnown: boolean = false; 13 | public alignedSliding: boolean = false; 14 | public averagetargetduration?: number; 15 | public endCC: number = 0; 16 | public endSN: number = 0; 17 | public fragments: MediaFragment[]; 18 | public fragmentHint?: MediaFragment; 19 | public partList: Part[] | null = null; 20 | public dateRanges: Record; 21 | public dateRangeTagCount: number = 0; 22 | public live: boolean = true; 23 | public ageHeader: number = 0; 24 | public advancedDateTime?: number; 25 | public updated: boolean = true; 26 | public advanced: boolean = true; 27 | public availabilityDelay?: number; // Manifest reload synchronization 28 | public misses: number = 0; 29 | public startCC: number = 0; 30 | public startSN: number = 0; 31 | public startTimeOffset: number | null = null; 32 | public targetduration: number = 0; 33 | public totalduration: number = 0; 34 | public type: string | null = null; 35 | public url: string; 36 | public m3u8: string = ''; 37 | public version: number | null = null; 38 | public canBlockReload: boolean = false; 39 | public canSkipUntil: number = 0; 40 | public canSkipDateRanges: boolean = false; 41 | public skippedSegments: number = 0; 42 | public recentlyRemovedDateranges?: string[]; 43 | public partHoldBack: number = 0; 44 | public holdBack: number = 0; 45 | public partTarget: number = 0; 46 | public preloadHint?: AttrList; 47 | public renditionReports?: AttrList[]; 48 | public tuneInGoal: number = 0; 49 | public deltaUpdateFailed?: boolean; 50 | public driftStartTime: number = 0; 51 | public driftEndTime: number = 0; 52 | public driftStart: number = 0; 53 | public driftEnd: number = 0; 54 | public encryptedFragments: Fragment[]; 55 | public playlistParsingError: Error | null = null; 56 | public variableList: VariableMap | null = null; 57 | public hasVariableRefs = false; 58 | 59 | constructor(baseUrl: string) { 60 | this.fragments = []; 61 | this.encryptedFragments = []; 62 | this.dateRanges = {}; 63 | this.url = baseUrl; 64 | } 65 | 66 | reloaded(previous: LevelDetails | undefined) { 67 | if (!previous) { 68 | this.advanced = true; 69 | this.updated = true; 70 | return; 71 | } 72 | const partSnDiff = this.lastPartSn - previous.lastPartSn; 73 | const partIndexDiff = this.lastPartIndex - previous.lastPartIndex; 74 | this.updated = 75 | this.endSN !== previous.endSN || 76 | !!partIndexDiff || 77 | !!partSnDiff || 78 | !this.live; 79 | this.advanced = 80 | this.endSN > previous.endSN || 81 | partSnDiff > 0 || 82 | (partSnDiff === 0 && partIndexDiff > 0); 83 | if (this.updated || this.advanced) { 84 | this.misses = Math.floor(previous.misses * 0.6); 85 | } else { 86 | this.misses = previous.misses + 1; 87 | } 88 | this.availabilityDelay = previous.availabilityDelay; 89 | } 90 | 91 | get hasProgramDateTime(): boolean { 92 | if (this.fragments.length) { 93 | return Number.isFinite( 94 | this.fragments[this.fragments.length - 1].programDateTime as number, 95 | ); 96 | } 97 | return false; 98 | } 99 | 100 | get levelTargetDuration(): number { 101 | return ( 102 | this.averagetargetduration || 103 | this.targetduration || 104 | DEFAULT_TARGET_DURATION 105 | ); 106 | } 107 | 108 | get drift(): number { 109 | const runTime = this.driftEndTime - this.driftStartTime; 110 | if (runTime > 0) { 111 | const runDuration = this.driftEnd - this.driftStart; 112 | return (runDuration * 1000) / runTime; 113 | } 114 | return 1; 115 | } 116 | 117 | get edge(): number { 118 | return this.partEnd || this.fragmentEnd; 119 | } 120 | 121 | get partEnd(): number { 122 | if (this.partList?.length) { 123 | return this.partList[this.partList.length - 1].end; 124 | } 125 | return this.fragmentEnd; 126 | } 127 | 128 | get fragmentEnd(): number { 129 | if (this.fragments?.length) { 130 | return this.fragments[this.fragments.length - 1].end; 131 | } 132 | return 0; 133 | } 134 | 135 | get age(): number { 136 | if (this.advancedDateTime) { 137 | return Math.max(Date.now() - this.advancedDateTime, 0) / 1000; 138 | } 139 | return 0; 140 | } 141 | 142 | get lastPartIndex(): number { 143 | if (this.partList?.length) { 144 | return this.partList[this.partList.length - 1].index; 145 | } 146 | return -1; 147 | } 148 | 149 | get lastPartSn(): number { 150 | if (this.partList?.length) { 151 | return this.partList[this.partList.length - 1].fragment.sn; 152 | } 153 | return this.endSN; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/controller/fps-controller.ts: -------------------------------------------------------------------------------- 1 | import { Events } from '../events'; 2 | import type { ComponentAPI } from '../types/component-api'; 3 | import type Hls from '../hls'; 4 | import type { MediaAttachingData } from '../types/events'; 5 | import StreamController from './stream-controller'; 6 | 7 | class FPSController implements ComponentAPI { 8 | private hls: Hls; 9 | private isVideoPlaybackQualityAvailable: boolean = false; 10 | private timer?: number; 11 | private media: HTMLVideoElement | null = null; 12 | private lastTime: any; 13 | private lastDroppedFrames: number = 0; 14 | private lastDecodedFrames: number = 0; 15 | // stream controller must be provided as a dependency! 16 | private streamController!: StreamController; 17 | 18 | constructor(hls: Hls) { 19 | this.hls = hls; 20 | 21 | this.registerListeners(); 22 | } 23 | 24 | public setStreamController(streamController: StreamController) { 25 | this.streamController = streamController; 26 | } 27 | 28 | protected registerListeners() { 29 | this.hls.on(Events.MEDIA_ATTACHING, this.onMediaAttaching, this); 30 | this.hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this); 31 | } 32 | 33 | protected unregisterListeners() { 34 | this.hls.off(Events.MEDIA_ATTACHING, this.onMediaAttaching, this); 35 | this.hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this); 36 | } 37 | 38 | destroy() { 39 | if (this.timer) { 40 | clearInterval(this.timer); 41 | } 42 | 43 | this.unregisterListeners(); 44 | this.isVideoPlaybackQualityAvailable = false; 45 | this.media = null; 46 | } 47 | 48 | protected onMediaAttaching( 49 | event: Events.MEDIA_ATTACHING, 50 | data: MediaAttachingData, 51 | ) { 52 | const config = this.hls.config; 53 | if (config.capLevelOnFPSDrop) { 54 | const media = 55 | data.media instanceof self.HTMLVideoElement ? data.media : null; 56 | this.media = media; 57 | if (media && typeof media.getVideoPlaybackQuality === 'function') { 58 | this.isVideoPlaybackQualityAvailable = true; 59 | } 60 | 61 | self.clearInterval(this.timer); 62 | this.timer = self.setInterval( 63 | this.checkFPSInterval.bind(this), 64 | config.fpsDroppedMonitoringPeriod, 65 | ); 66 | } 67 | } 68 | 69 | private onMediaDetaching() { 70 | this.media = null; 71 | } 72 | 73 | checkFPS( 74 | video: HTMLVideoElement, 75 | decodedFrames: number, 76 | droppedFrames: number, 77 | ) { 78 | const currentTime = performance.now(); 79 | if (decodedFrames) { 80 | if (this.lastTime) { 81 | const currentPeriod = currentTime - this.lastTime; 82 | const currentDropped = droppedFrames - this.lastDroppedFrames; 83 | const currentDecoded = decodedFrames - this.lastDecodedFrames; 84 | const droppedFPS = (1000 * currentDropped) / currentPeriod; 85 | const hls = this.hls; 86 | hls.trigger(Events.FPS_DROP, { 87 | currentDropped: currentDropped, 88 | currentDecoded: currentDecoded, 89 | totalDroppedFrames: droppedFrames, 90 | }); 91 | if (droppedFPS > 0) { 92 | // hls.logger.log('checkFPS : droppedFPS/decodedFPS:' + droppedFPS/(1000 * currentDecoded / currentPeriod)); 93 | if ( 94 | currentDropped > 95 | hls.config.fpsDroppedMonitoringThreshold * currentDecoded 96 | ) { 97 | let currentLevel = hls.currentLevel; 98 | hls.logger.warn( 99 | 'drop FPS ratio greater than max allowed value for currentLevel: ' + 100 | currentLevel, 101 | ); 102 | if ( 103 | currentLevel > 0 && 104 | (hls.autoLevelCapping === -1 || 105 | hls.autoLevelCapping >= currentLevel) 106 | ) { 107 | currentLevel = currentLevel - 1; 108 | hls.trigger(Events.FPS_DROP_LEVEL_CAPPING, { 109 | level: currentLevel, 110 | droppedLevel: hls.currentLevel, 111 | }); 112 | hls.autoLevelCapping = currentLevel; 113 | this.streamController.nextLevelSwitch(); 114 | } 115 | } 116 | } 117 | } 118 | this.lastTime = currentTime; 119 | this.lastDroppedFrames = droppedFrames; 120 | this.lastDecodedFrames = decodedFrames; 121 | } 122 | } 123 | 124 | checkFPSInterval() { 125 | const video = this.media; 126 | if (video) { 127 | if (this.isVideoPlaybackQualityAvailable) { 128 | const videoPlaybackQuality = video.getVideoPlaybackQuality(); 129 | this.checkFPS( 130 | video, 131 | videoPlaybackQuality.totalVideoFrames, 132 | videoPlaybackQuality.droppedVideoFrames, 133 | ); 134 | } else { 135 | // HTMLVideoElement doesn't include the webkit types 136 | this.checkFPS( 137 | video, 138 | (video as any).webkitDecodedFrameCount as number, 139 | (video as any).webkitDroppedFrameCount as number, 140 | ); 141 | } 142 | } 143 | } 144 | } 145 | 146 | export default FPSController; 147 | -------------------------------------------------------------------------------- /src/utils/texttrack-utils.ts: -------------------------------------------------------------------------------- 1 | import { logger } from './logger'; 2 | 3 | export function sendAddTrackEvent(track: TextTrack, videoEl: HTMLMediaElement) { 4 | let event: Event; 5 | try { 6 | event = new Event('addtrack'); 7 | } catch (err) { 8 | // for IE11 9 | event = document.createEvent('Event'); 10 | event.initEvent('addtrack', false, false); 11 | } 12 | (event as any).track = track; 13 | videoEl.dispatchEvent(event); 14 | } 15 | 16 | export function addCueToTrack(track: TextTrack, cue: VTTCue) { 17 | // Sometimes there are cue overlaps on segmented vtts so the same 18 | // cue can appear more than once in different vtt files. 19 | // This avoid showing duplicated cues with same timecode and text. 20 | const mode = track.mode; 21 | if (mode === 'disabled') { 22 | track.mode = 'hidden'; 23 | } 24 | if (track.cues && !track.cues.getCueById(cue.id)) { 25 | try { 26 | track.addCue(cue); 27 | if (!track.cues.getCueById(cue.id)) { 28 | throw new Error(`addCue is failed for: ${cue}`); 29 | } 30 | } catch (err) { 31 | logger.debug(`[texttrack-utils]: ${err}`); 32 | try { 33 | const textTrackCue = new (self.TextTrackCue as any)( 34 | cue.startTime, 35 | cue.endTime, 36 | cue.text, 37 | ); 38 | textTrackCue.id = cue.id; 39 | track.addCue(textTrackCue); 40 | } catch (err2) { 41 | logger.debug( 42 | `[texttrack-utils]: Legacy TextTrackCue fallback failed: ${err2}`, 43 | ); 44 | } 45 | } 46 | } 47 | if (mode === 'disabled') { 48 | track.mode = mode; 49 | } 50 | } 51 | 52 | export function clearCurrentCues(track: TextTrack) { 53 | // When track.mode is disabled, track.cues will be null. 54 | // To guarantee the removal of cues, we need to temporarily 55 | // change the mode to hidden 56 | const mode = track.mode; 57 | if (mode === 'disabled') { 58 | track.mode = 'hidden'; 59 | } 60 | if (track.cues) { 61 | for (let i = track.cues.length; i--; ) { 62 | track.removeCue(track.cues[i]); 63 | } 64 | } 65 | if (mode === 'disabled') { 66 | track.mode = mode; 67 | } 68 | } 69 | 70 | export function removeCuesInRange( 71 | track: TextTrack, 72 | start: number, 73 | end: number, 74 | predicate?: (cue: TextTrackCue) => boolean, 75 | ) { 76 | const mode = track.mode; 77 | if (mode === 'disabled') { 78 | track.mode = 'hidden'; 79 | } 80 | 81 | if (track.cues && track.cues.length > 0) { 82 | const cues = getCuesInRange(track.cues, start, end); 83 | for (let i = 0; i < cues.length; i++) { 84 | if (!predicate || predicate(cues[i])) { 85 | track.removeCue(cues[i]); 86 | } 87 | } 88 | } 89 | if (mode === 'disabled') { 90 | track.mode = mode; 91 | } 92 | } 93 | 94 | // Find first cue starting after given time. 95 | // Modified version of binary search O(log(n)). 96 | function getFirstCueIndexAfterTime( 97 | cues: TextTrackCueList | TextTrackCue[], 98 | time: number, 99 | ): number { 100 | // If first cue starts after time, start there 101 | if (time < cues[0].startTime) { 102 | return 0; 103 | } 104 | // If the last cue ends before time there is no overlap 105 | const len = cues.length - 1; 106 | if (time > cues[len].endTime) { 107 | return -1; 108 | } 109 | 110 | let left = 0; 111 | let right = len; 112 | 113 | while (left <= right) { 114 | const mid = Math.floor((right + left) / 2); 115 | 116 | if (time < cues[mid].startTime) { 117 | right = mid - 1; 118 | } else if (time > cues[mid].startTime && left < len) { 119 | left = mid + 1; 120 | } else { 121 | // If it's not lower or higher, it must be equal. 122 | return mid; 123 | } 124 | } 125 | // At this point, left and right have swapped. 126 | // No direct match was found, left or right element must be the closest. Check which one has the smallest diff. 127 | return cues[left].startTime - time < time - cues[right].startTime 128 | ? left 129 | : right; 130 | } 131 | 132 | export function getCuesInRange( 133 | cues: TextTrackCueList | TextTrackCue[], 134 | start: number, 135 | end: number, 136 | ): TextTrackCue[] { 137 | const cuesFound: TextTrackCue[] = []; 138 | const firstCueInRange = getFirstCueIndexAfterTime(cues, start); 139 | if (firstCueInRange > -1) { 140 | for (let i = firstCueInRange, len = cues.length; i < len; i++) { 141 | const cue = cues[i]; 142 | if (cue.startTime >= start && cue.endTime <= end) { 143 | cuesFound.push(cue); 144 | } else if (cue.startTime > end) { 145 | return cuesFound; 146 | } 147 | } 148 | } 149 | return cuesFound; 150 | } 151 | 152 | export function filterSubtitleTracks( 153 | textTrackList: TextTrackList, 154 | ): TextTrack[] { 155 | const tracks: TextTrack[] = []; 156 | for (let i = 0; i < textTrackList.length; i++) { 157 | const track = textTrackList[i]; 158 | // Edge adds a track without a label; we don't want to use it 159 | if ( 160 | (track.kind === 'subtitles' || track.kind === 'captions') && 161 | track.label 162 | ) { 163 | tracks.push(textTrackList[i]); 164 | } 165 | } 166 | return tracks; 167 | } 168 | -------------------------------------------------------------------------------- /src/utils/buffer-helper.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Provides methods dealing with buffer length retrieval for example. 3 | * 4 | * In general, a helper around HTML5 MediaElement TimeRanges gathered from `buffered` property. 5 | * 6 | * Also @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/buffered 7 | */ 8 | 9 | import { logger } from './logger'; 10 | 11 | type BufferTimeRange = { 12 | start: number; 13 | end: number; 14 | }; 15 | 16 | export type Bufferable = { 17 | buffered: TimeRanges; 18 | }; 19 | 20 | export type BufferInfo = { 21 | len: number; 22 | start: number; 23 | end: number; 24 | nextStart?: number; 25 | }; 26 | 27 | const noopBuffered: TimeRanges = { 28 | length: 0, 29 | start: () => 0, 30 | end: () => 0, 31 | }; 32 | 33 | export class BufferHelper { 34 | /** 35 | * Return true if `media`'s buffered include `position` 36 | */ 37 | static isBuffered(media: Bufferable, position: number): boolean { 38 | if (media) { 39 | const buffered = BufferHelper.getBuffered(media); 40 | for (let i = buffered.length; i--; ) { 41 | if (position >= buffered.start(i) && position <= buffered.end(i)) { 42 | return true; 43 | } 44 | } 45 | } 46 | return false; 47 | } 48 | 49 | static bufferInfo( 50 | media: Bufferable | null, 51 | pos: number, 52 | maxHoleDuration: number, 53 | ): BufferInfo { 54 | if (media) { 55 | const vbuffered = BufferHelper.getBuffered(media); 56 | if (vbuffered.length) { 57 | const buffered: BufferTimeRange[] = []; 58 | for (let i = 0; i < vbuffered.length; i++) { 59 | buffered.push({ start: vbuffered.start(i), end: vbuffered.end(i) }); 60 | } 61 | return BufferHelper.bufferedInfo(buffered, pos, maxHoleDuration); 62 | } 63 | } 64 | return { len: 0, start: pos, end: pos, nextStart: undefined }; 65 | } 66 | 67 | static bufferedInfo( 68 | buffered: BufferTimeRange[], 69 | pos: number, 70 | maxHoleDuration: number, 71 | ): { 72 | len: number; 73 | start: number; 74 | end: number; 75 | nextStart?: number; 76 | } { 77 | pos = Math.max(0, pos); 78 | // sort on buffer.start/smaller end (IE does not always return sorted buffered range) 79 | buffered.sort((a, b) => a.start - b.start || b.end - a.end); 80 | 81 | let buffered2: BufferTimeRange[] = []; 82 | if (maxHoleDuration) { 83 | // there might be some small holes between buffer time range 84 | // consider that holes smaller than maxHoleDuration are irrelevant and build another 85 | // buffer time range representations that discards those holes 86 | for (let i = 0; i < buffered.length; i++) { 87 | const buf2len = buffered2.length; 88 | if (buf2len) { 89 | const buf2end = buffered2[buf2len - 1].end; 90 | // if small hole (value between 0 or maxHoleDuration ) or overlapping (negative) 91 | if (buffered[i].start - buf2end < maxHoleDuration) { 92 | // merge overlapping time ranges 93 | // update lastRange.end only if smaller than item.end 94 | // e.g. [ 1, 15] with [ 2,8] => [ 1,15] (no need to modify lastRange.end) 95 | // whereas [ 1, 8] with [ 2,15] => [ 1,15] ( lastRange should switch from [1,8] to [1,15]) 96 | if (buffered[i].end > buf2end) { 97 | buffered2[buf2len - 1].end = buffered[i].end; 98 | } 99 | } else { 100 | // big hole 101 | buffered2.push(buffered[i]); 102 | } 103 | } else { 104 | // first value 105 | buffered2.push(buffered[i]); 106 | } 107 | } 108 | } else { 109 | buffered2 = buffered; 110 | } 111 | 112 | let bufferLen = 0; 113 | 114 | // bufferStartNext can possibly be undefined based on the conditional logic below 115 | let bufferStartNext: number | undefined; 116 | 117 | // bufferStart and bufferEnd are buffer boundaries around current video position 118 | let bufferStart: number = pos; 119 | let bufferEnd: number = pos; 120 | for (let i = 0; i < buffered2.length; i++) { 121 | const start = buffered2[i].start; 122 | const end = buffered2[i].end; 123 | // logger.log('buf start/end:' + buffered.start(i) + '/' + buffered.end(i)); 124 | if (pos + maxHoleDuration >= start && pos < end) { 125 | // play position is inside this buffer TimeRange, retrieve end of buffer position and buffer length 126 | bufferStart = start; 127 | bufferEnd = end; 128 | bufferLen = bufferEnd - pos; 129 | } else if (pos + maxHoleDuration < start) { 130 | bufferStartNext = start; 131 | break; 132 | } 133 | } 134 | return { 135 | len: bufferLen, 136 | start: bufferStart || 0, 137 | end: bufferEnd || 0, 138 | nextStart: bufferStartNext, 139 | }; 140 | } 141 | 142 | /** 143 | * Safe method to get buffered property. 144 | * SourceBuffer.buffered may throw if SourceBuffer is removed from it's MediaSource 145 | */ 146 | static getBuffered(media: Bufferable): TimeRanges { 147 | try { 148 | return media.buffered || noopBuffered; 149 | } catch (e) { 150 | logger.log('failed to get media.buffered', e); 151 | return noopBuffered; 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | - Demonstrating empathy and kindness toward other people 14 | - Being respectful of differing opinions, viewpoints, and experiences 15 | - Giving and gracefully accepting constructive feedback 16 | - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | - Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | - The use of sexualized language or imagery, and sexual attention or 22 | advances of any kind 23 | - Trolling, insulting or derogatory comments, and personal or political attacks 24 | - Public or private harassment 25 | - Publishing others' private information, such as a physical or email 26 | address, without their explicit permission 27 | - Other conduct which could reasonably be considered inappropriate in a 28 | professional setting 29 | 30 | ## Enforcement Responsibilities 31 | 32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 33 | 34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 35 | 36 | ## Scope 37 | 38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 39 | 40 | ## Enforcement 41 | 42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at the [video-dev slack](https://video-dev.org/). All complaints will be reviewed and investigated promptly and fairly. 43 | 44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 45 | 46 | ## Enforcement Guidelines 47 | 48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 49 | 50 | ### 1. Correction 51 | 52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 53 | 54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 55 | 56 | ### 2. Warning 57 | 58 | **Community Impact**: A violation through a single incident or series of actions. 59 | 60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 61 | 62 | ### 3. Temporary Ban 63 | 64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 65 | 66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 67 | 68 | ### 4. Permanent Ban 69 | 70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 71 | 72 | **Consequence**: A permanent ban from any sort of public interaction within the project community. 73 | 74 | ## Attribution 75 | 76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 78 | 79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | For answers to common questions about this code of conduct, see the FAQ at 84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 85 | --------------------------------------------------------------------------------