├── .eslintignore
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ ├── build-docs.yml
│ ├── codeql-analysis.yml
│ ├── codeql
│ └── config.yml
│ ├── npm-publish.yml
│ └── pull-requests.yml
├── .gitignore
├── .husky
├── pre-commit
└── pre-push
├── .nvmrc
├── .prettierignore
├── .prettierrc.yaml
├── .releasinator.json
├── CODEOWNERS
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── babel.config.json
├── docs
├── .gitignore
├── arch
│ ├── 001-native-strategy.md
│ ├── 002-sinon.md
│ ├── 003-subtitles-polling-frequency.md
│ ├── 004-time-representation.md
│ ├── 005-remove-fake-seeking.md
│ ├── 006-detect-autoresume.md
│ ├── 007-estimate-hls-ast.md
│ └── __template.md
├── index.html
├── static
│ ├── bsp-logo.png
│ ├── bsp_arch.svg
│ ├── bsp_state_changes_august_2019.png
│ └── favicon.ico
├── styles.css
└── tutorials
│ ├── 00-getting-started.md
│ ├── 01-playback-strategies.md
│ ├── 02-settings-and-overrides.md
│ ├── Audio Description.md
│ ├── Debugging.md
│ ├── Design.md
│ ├── Events.md
│ ├── Plugins.md
│ ├── State Changes.md
│ ├── Subtitles.md
│ ├── Testing.md
│ ├── XX-mocking.md
│ ├── cdn-failover.md
│ ├── live-streaming.md
│ ├── seeking.md
│ └── tutorials.json
├── eslint.compat.js
├── eslint.config.js
├── index.html
├── jest.config.ts
├── jsdoc.conf.json
├── package-lock.json
├── package.json
├── rollup.config.js
├── rollup.dev.config.js
├── scripts
├── format-staged.sh
├── lint-pr.sh
└── lint-staged.sh
├── src
├── allowedmediatransitions.js
├── bigscreenplayer.js
├── bigscreenplayer.test.js
├── debugger
│ ├── chronicle.test.ts
│ ├── chronicle.ts
│ ├── debugtool.test.ts
│ ├── debugtool.ts
│ ├── debugview.ts
│ ├── debugviewcontroller.test.ts
│ └── debugviewcontroller.ts
├── domhelpers.test.js
├── domhelpers.ts
├── dynamicwindowutils.test.js
├── dynamicwindowutils.ts
├── main.ts
├── manifest
│ ├── manifestmodifier.js
│ ├── manifestmodifier.test.js
│ ├── manifestparser.test.ts
│ ├── manifestparser.ts
│ ├── sourceloader.test.js
│ ├── sourceloader.ts
│ └── stubData
│ │ ├── dashmanifests.ts
│ │ └── hlsmanifests.ts
├── mediasources.test.ts
├── mediasources.ts
├── mockbigscreenplayer.js
├── models
│ ├── errorcode.ts
│ ├── livesupport.ts
│ ├── manifesttypes.ts
│ ├── mediakinds.ts
│ ├── mediastate.ts
│ ├── pausetriggers.ts
│ ├── playbackstrategy.ts
│ ├── timeline.ts
│ ├── transferformats.ts
│ ├── transportcontrolposition.ts
│ └── windowtypes.ts
├── playbackstrategy
│ ├── basicstrategy.js
│ ├── basicstrategy.test.js
│ ├── legacyplayeradapter.js
│ ├── legacyplayeradapter.test.js
│ ├── liveglitchcurtain.js
│ ├── modifiers
│ │ ├── cehtml.js
│ │ ├── cehtml.test.js
│ │ ├── html5.js
│ │ ├── html5.test.js
│ │ ├── live
│ │ │ ├── none.js
│ │ │ ├── playable.js
│ │ │ ├── playable.test.js
│ │ │ ├── restartable.js
│ │ │ ├── restartable.test.js
│ │ │ ├── seekable.js
│ │ │ └── seekable.test.js
│ │ ├── mediaplayerbase.js
│ │ ├── samsungmaple.js
│ │ ├── samsungmaple.test.js
│ │ ├── samsungstreaming.js
│ │ ├── samsungstreaming.test.js
│ │ ├── samsungstreaming2015.js
│ │ └── samsungstreaming2015.test.js
│ ├── msestrategy.js
│ ├── msestrategy.test.js
│ ├── nativestrategy.js
│ ├── nativestrategy.test.js
│ ├── strategypicker.js
│ └── strategypicker.test.js
├── playercomponent.js
├── playercomponent.test.js
├── plugindata.js
├── pluginenums.js
├── plugins.js
├── plugins.test.js
├── readyhelper.test.ts
├── readyhelper.ts
├── resizer.test.ts
├── resizer.ts
├── subtitles
│ ├── embeddedsubtitles.js
│ ├── embeddedsubtitles.test.js
│ ├── imscsubtitles.js
│ ├── imscsubtitles.test.js
│ ├── legacysubtitles.js
│ ├── legacysubtitles.test.js
│ ├── renderer.js
│ ├── renderer.test.js
│ ├── subtitles.js
│ ├── subtitles.test.js
│ ├── timedtext.js
│ ├── timedtext.test.js
│ ├── transformer.js
│ └── transformer.test.js
├── testutils
│ └── geterror.js
├── types.ts
├── utils
│ ├── callcallbacks.test.ts
│ ├── callcallbacks.ts
│ ├── deferexception.test.ts
│ ├── deferexceptions.ts
│ ├── findtemplate.test.ts
│ ├── findtemplate.ts
│ ├── get-values.ts
│ ├── handleplaypromise.ts
│ ├── iserror.ts
│ ├── loadurl.ts
│ ├── mse
│ │ └── convert-timeranges-to-array.ts
│ ├── playbackutils.js
│ ├── playbackutils.test.js
│ ├── timeconverter.test.ts
│ ├── timeconverter.ts
│ ├── timeshiftdetector.test.ts
│ ├── timeshiftdetector.ts
│ ├── timeutils.test.ts
│ ├── timeutils.ts
│ └── types.ts
├── version.test.ts
└── version.ts
├── tsconfig.dist.json
└── tsconfig.json
/.eslintignore:
--------------------------------------------------------------------------------
1 | # Exclude everything
2 | *
3 |
4 | # Re-include supported file formats and locations
5 | !./*.js
6 | !./*.cjs
7 | !./*.mjs
8 | !./*.ts
9 | !./*.cts
10 | !./*.mts
11 | !src/**/*.js
12 | !src/**/*.cjs
13 | !src/**/*.mjs
14 | !src/**/*.ts
15 | !src/**/*.cts
16 | !src/**/*.mts
17 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ""
5 | labels: bug
6 | assignees: ""
7 | ---
8 |
9 | **Describe the bug**
10 | A clear and concise description of what the bug is.
11 |
12 | _Is the bug device specific?_
13 |
14 | _Is this happening on Chrome?_
15 |
16 | _Is the bug consistenly reproducible?_
17 |
18 | _Have you seen this journey working before?_
19 |
20 | **To Reproduce**
21 | Steps to reproduce the behavior:
22 |
23 | 1. Go to '...'
24 | 2. Click on '....'
25 | 3. Scroll down to '....'
26 | 4. See error
27 |
28 | **Expected behavior**
29 | A clear and concise description of what you expected to happen.
30 |
31 | **Screenshots**
32 | If applicable, add screenshots to help explain your problem.
33 |
34 | **Device (please complete the following information):**
35 |
36 | - Make/Model: [e.g. VESTEL TV 2013 MB95 ]
37 | - BSP Version [e.g. 4.18.1]
38 |
39 | **Additional context**
40 | Add any other context about the problem here.
41 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ""
5 | labels: enhancement
6 | assignees: ""
7 | ---
8 |
9 | **Is your feature request related to a problem? Please describe.**
10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
11 |
12 | **Describe the solution you'd like**
13 | A clear and concise description of what you want to happen.
14 |
15 | **Describe alternatives you've considered**
16 | A clear and concise description of any alternative solutions or features you've considered.
17 |
18 | **Additional context**
19 | Add any other context or screenshots about the feature request here.
20 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | 📺 What
2 |
3 | [Enter a brief description of what this pull request does]
4 |
5 | 🛠 How
6 |
7 | [How does this PR perform the features described in What, keep technical detail brief and/or high level - more detail can be requested in comments]
8 |
9 | ✅ Testing [Semi-optional]
10 | [MANDATORY for contributions being tested and released by the contributing team]
11 |
12 | [Test Guidelines](https://github.com/bbc/bigscreen-player/wiki/Areas-Impacted)
13 |
14 | | Test engineer sign off | :x: |
15 | | ---------------------- | --- |
16 |
17 | [How will this change be tested?]
18 |
19 | 👀 See [optional]
20 |
21 | [Describe or add screenshots of any User facing changes]
22 | [If the PR has User Facing changes - this section is mandatory]
23 |
24 | ♿ Accessibility [optional]
25 |
26 | [Any accessibility features or considerations that this PR addresses should be listed here]
27 |
--------------------------------------------------------------------------------
/.github/workflows/build-docs.yml:
--------------------------------------------------------------------------------
1 | name: Build Docs
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 | timeout-minutes: 10
12 |
13 | permissions:
14 | pages: write
15 | id-token: write
16 |
17 | environment:
18 | name: github-pages
19 | url: ${{ steps.deployment.outputs.page_url }}
20 |
21 | steps:
22 | - name: Checkout
23 | uses: actions/checkout@v4
24 |
25 | - name: Setup Node
26 | uses: actions/setup-node@v4
27 | with:
28 | node-version: 20
29 |
30 | - name: Install Dependencies
31 | run: npm ci
32 |
33 | - name: Build Docs
34 | run: npm run docs
35 |
36 | - name: Upload Artifact
37 | uses: actions/upload-pages-artifact@v3
38 | with:
39 | path: "./docs"
40 | if-no-files-found: error
41 |
42 | - name: Deploy Pages
43 | uses: actions/deploy-pages@v4
44 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [master]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [master]
20 | schedule:
21 | - cron: "15 0 * * 4"
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | runs-on: ubuntu-latest
27 | permissions:
28 | actions: read
29 | contents: read
30 | security-events: write
31 |
32 | strategy:
33 | fail-fast: false
34 | matrix:
35 | language: ["javascript"]
36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
37 |
38 | steps:
39 | - name: Checkout repository
40 | uses: actions/checkout@v4
41 |
42 | # Initializes the CodeQL tools for scanning.
43 | - name: Initialize CodeQL
44 | uses: github/codeql-action/init@v3
45 | with:
46 | languages: ${{ matrix.language }}
47 | config-file: ./.github/workflows/codeql/config.yml
48 | # If you wish to specify custom queries, you can do so here or in a config file.
49 | # By default, queries listed here will override any specified in a config file.
50 | # Prefix the list here with "+" to use these queries and those in the config file.
51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main
52 |
53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
54 | # If this step fails, then you should remove it and run the build manually (see below)
55 | - name: Autobuild
56 | uses: github/codeql-action/autobuild@v3
57 |
58 | # ℹ️ Command-line programs to run using the OS shell.
59 | # 📚 https://git.io/JvXDl
60 |
61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
62 | # and modify them (or add more) to build your code if your project
63 | # uses a compiled language
64 |
65 | #- run: |
66 | # make bootstrap
67 | # make release
68 |
69 | - name: Perform CodeQL Analysis
70 | uses: github/codeql-action/analyze@v3
71 |
--------------------------------------------------------------------------------
/.github/workflows/codeql/config.yml:
--------------------------------------------------------------------------------
1 | paths-ignore:
2 | - docs/
3 |
--------------------------------------------------------------------------------
/.github/workflows/npm-publish.yml:
--------------------------------------------------------------------------------
1 | name: NPM Publish
2 |
3 | on:
4 | push:
5 | tags:
6 | - "*.*.*"
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 | timeout-minutes: 10
12 |
13 | steps:
14 | - uses: actions/checkout@v4
15 | - uses: actions/setup-node@v4
16 | with:
17 | node-version: 20
18 | - run: npm ci
19 | - run: npm test
20 |
21 | publish-npm:
22 | needs: build
23 | runs-on: ubuntu-latest
24 | steps:
25 | - uses: actions/checkout@v4
26 | - uses: actions/setup-node@v4
27 | with:
28 | node-version: 20
29 | registry-url: https://registry.npmjs.org/
30 | - run: npm ci
31 | - run: npm publish
32 | env:
33 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
34 |
--------------------------------------------------------------------------------
/.github/workflows/pull-requests.yml:
--------------------------------------------------------------------------------
1 | # This workflow will do a clean install of node dependencies and run tests
2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
3 |
4 | name: Pull Request Checks
5 |
6 | on:
7 | # Trigger the workflow pull requests,
8 | # but only for the master branch
9 | pull_request:
10 | branches:
11 | - master
12 |
13 | jobs:
14 | build:
15 | runs-on: ubuntu-latest
16 | timeout-minutes: 10
17 |
18 | steps:
19 | - name: Checkout branch
20 | uses: actions/checkout@v4
21 | with:
22 | fetch-depth: 0
23 |
24 | - name: Use Node.js 20
25 | uses: actions/setup-node@v4
26 | with:
27 | node-version: 20
28 |
29 | - name: Install dependencies
30 | run: npm ci
31 |
32 | - name: Run tests
33 | run: npm test
34 |
35 | - name: Lint the PR
36 | run: ./scripts/lint-pr.sh
37 | shell: bash
38 | env:
39 | PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
40 | PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
41 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # OS X
2 | .DS_Store
3 |
4 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
5 | .grunt
6 |
7 | # Dependency directory
8 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git
9 | node_modules
10 |
11 | # Packaging dir
12 | dist
13 |
14 | # Local dev dir
15 | dist-local
16 |
17 | # Ignore Visual Studio Code project files
18 | .vscode
19 |
20 | # Rollup build information
21 | stats.html
22 |
23 | # Jest
24 | coverage/
25 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | ./scripts/lint-staged.sh && ./scripts/format-staged.sh
5 |
--------------------------------------------------------------------------------
/.husky/pre-push:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npm test
5 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 20
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # deps
2 | node_modules/
3 |
4 | # build
5 | dist/
6 | dist-local/
7 | docs/api/
8 |
--------------------------------------------------------------------------------
/.prettierrc.yaml:
--------------------------------------------------------------------------------
1 | semi: false
2 | tabWidth: 2
3 | endOfLine: lf
4 | printWidth: 120
5 | singleQuote: false
6 | trailingComma: es5
7 | bracketSameLine: true
8 | quoteProps: consistent
9 | overrides:
10 | - files:
11 | - "*.html"
12 | options:
13 | tabWidth: 4
14 |
--------------------------------------------------------------------------------
/.releasinator.json:
--------------------------------------------------------------------------------
1 | {
2 | "publish": "release",
3 | "versioning": "semver",
4 | "tagPattern": "$.$.$",
5 | "displayPattern": "$.$.$",
6 | "enforceUserFacingLabel": false,
7 | "projectType": "npm-like"
8 | }
9 |
--------------------------------------------------------------------------------
/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @bbc/honey-badger-core
2 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | We welcome and encourage contributions. We are open minded to suggestions, encourage debate, and aim to build a consensus.
4 |
5 | Unless an Issue or Pull Request can be resolved within 2 working days, we will raise an internal ticket for it on our backlog and note this on GitHub.
6 |
7 | ## Opening an Issue
8 |
9 | For any queries, suggestions, or bug reports, please raise an issue.
10 |
11 | ## Opening a Pull Request
12 |
13 | We will look at any and all contributions and aim to get everything adopted, even if we have to make changes to the contribution ourselves for it to be production ready. In the rare cases we cannot accept a contribution, we will explain why to the best of our abilities.
14 |
15 | ### Guidelines
16 |
17 | Don't worry if you are unable to meet all these guidelines, even a rough idea contributed can be made production ready with time and effort.
18 |
19 | - For any changes to the Bigscreen Player API or event model, please raise an issue first to discuss the implications and agree an approach
20 | - Code style:
21 | - We prefer composition over inheritance
22 | - Modules return object creation factory functions or singleton objects
23 | - We prefer verbose, well named, readable code over terse code and comments
24 | - Changes should be covered by unit tests
25 | - Please include any appropriate documentation changes
26 | - Please fill in the PR template as best you can
27 |
28 | ### Review, test, and release process
29 |
30 | - CI runs linting and unit tests against all PRs, which must be passing for a PR to be merged
31 | - Developer maintainers will code review and may request changes
32 | - Once approved by a developer, test engineer maintainers will run manual tests on a range of connected TV devices appropriate for the changes made
33 | - Our test guidelines are documented [here](https://bbc.github.io/bigscreen-player/api/tutorial-Testing.html)
34 | - CI will bump the npm version, tag in git, create a GitHub release and publish the new version to NPM
35 |
36 | **Note:** Other BBC teams may wish to test and release their contributions themselves. In these cases, please reference your Jira ticket and add your test plan (in line with our [test guidelines](https://bbc.github.io/bigscreen-player/api/tutorial-Testing.html)) to the Pull Request for review.
37 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | [](https://github.com/bbc/bigscreen-player/actions/workflows/npm-publish.yml) [](https://www.npmjs.com/package/bigscreen-player) [](https://github.com/bbc/bigscreen-player/blob/master/LICENSE)
4 |
5 | > Simplified media playback for bigscreen devices.
6 |
7 | ## Introduction
8 |
9 | _Bigscreen Player_ is an open source project developed by the BBC to simplify video and audio playback on a wide range of 'bigscreen' devices (TVs, set-top boxes, games consoles, and streaming devices).
10 |
11 | For documentation on using this library, please see our [Getting Started guide](https://bbc.github.io/bigscreen-player/api/tutorial-00-getting-started.html).
12 |
13 | ## Running Locally
14 |
15 | Install dependencies:
16 |
17 | ```bash
18 | npm install
19 | ```
20 |
21 | You can run Bigscreen Player locally in a dev environment by running:
22 |
23 | ```bash
24 | npm run start
25 | ```
26 |
27 | This will open a web page at `localhost:8080`.
28 |
29 | ## Testing
30 |
31 | The project is unit tested using [Jest](https://jestjs.io/). To run the tests:
32 |
33 | ```bash
34 | npm test
35 | ```
36 |
37 | This project currently has unit test coverage but no integration test suite. This is on our Roadmap to address.
38 |
39 | ## Releasing
40 |
41 | 1. Create a PR.
42 | 2. Label the PR with one of these labels; `semver prerelease`, `semver patch`, `semver minor` or `semver major`
43 | 3. Get a review from the core team.
44 | 4. If the PR checks are green. The core team can merge to master.
45 | 5. Automation takes care of the package versioning.
46 | 6. Publishing to NPM is handled with our [GitHub Actions CI integration](https://github.com/bbc/bigscreen-player/blob/master/.github/workflows/npm-publish.yml).
47 |
48 | ## Documentation
49 |
50 | Bigscreen Player uses JSDocs to autogenerate API documentation. To regenerate the documentation run:
51 |
52 | ```bash
53 | npm run docs
54 | ```
55 |
56 | ## Contributing
57 |
58 | See [CONTRIBUTING.md](CONTRIBUTING.md)
59 |
60 | ## License
61 |
62 | Bigscreen Player is available to everyone under the terms of the Apache 2.0 open source license. Take a look at the LICENSE file in the code for more information.
63 |
--------------------------------------------------------------------------------
/babel.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [["@babel/preset-env"], ["@babel/preset-typescript"]]
3 | }
4 |
--------------------------------------------------------------------------------
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | api/*
2 |
--------------------------------------------------------------------------------
/docs/arch/001-native-strategy.md:
--------------------------------------------------------------------------------
1 | # 001 Native Strategy
2 |
3 | Originally Added: February 20th, 2019
4 |
5 | ## Context
6 |
7 | - As it stands, `bigscreen-player` requires using the `tal` device object - when in `talstrategy` - to obtain a media player for playback
8 | - Since the `tal` device media player exists as a singleton, applications using `talstrategy` can only access one active media element.
9 | - Unlike `msestrategy`, `talstrategy` also requires loading in the extra dependency (`tal` device) - and is therefore limited by the limitations of the `tal` device.
10 |
11 | ## Decision
12 |
13 | - A new strategy type - `nativestrategy` - has been created which pulls the media player out of `tal`
14 | - This is a refactor of the `tal` device media player code which allows further manipulation and control i.e. multiple video playback
15 |
16 | ## Status
17 |
18 | Approved
19 |
20 | ## Consequences
21 |
22 | - Multiple active video instances can be controlled
23 | - Preloading of content on different media elements is more achievable
24 | - The successful migration of all media player code from `tal` will create much greater modularity for `bigscreen-player` - therefore removing any limitations introduced by the current coupling with `tal`
25 |
--------------------------------------------------------------------------------
/docs/arch/002-sinon.md:
--------------------------------------------------------------------------------
1 | # 002 Use of Sinon for unit testing
2 |
3 | Update: Depricated, we no longer use sinon, see future ADRs
4 | Originally Added: April 30th, 2019
5 |
6 | ## Context
7 |
8 | With the addition of manifest loading into `bigscreen-player`, there is a need to stub out network requests with specific responses for unit testing.
9 |
10 | It is possible, using a combination of parametrised Squire mocks and multiple asynchronous functions, to achieve this with jasmine.
11 |
12 | However, when loading HLS manifests, multiple chained calls to loadUrl are needed within one test (in order to get the final playlist following the loading of the master playlist). This is difficult to achieve with jasmine, but can be simply handled with sinon.
13 |
14 | ## Decision
15 |
16 | We will pull sinon. This is only a dev dependency.
17 |
18 | ## Status
19 |
20 | Accepted
21 |
22 | ## Consequences
23 |
24 | - Using sinon makes it simpler to provide custom responses to network requests.
25 | - Another third party library is now pulled into bigscreen-player as a dev dependency.
26 |
27 | ## Further Reading
28 |
29 | See for more information
30 |
--------------------------------------------------------------------------------
/docs/arch/003-subtitles-polling-frequency.md:
--------------------------------------------------------------------------------
1 | # 003 Subtitles Polling Frequency
2 |
3 | Originally Added: April 7th, 2020
4 |
5 | ## Context
6 |
7 | Subtitles in Bigscreen Player are updated by checking for the next available subtitle using a set timeout of 750ms.
8 | This number of 750ms was originally used with no record of why it was picked, and is also potentially too infrequent to keep the subtitles perfectly in sync with the audio and visual cues.
9 | There was a piece of work done to increase the polling rate to 250ms, however we found that this caused some slower devices to buffer due to the increased load on the devices memory.
10 |
11 | ## Decision
12 |
13 | We will continue to use 750ms as the polling frequency.
14 |
15 | ## Status
16 |
17 | Accepted
18 |
19 | ## Consequences
20 |
21 | - Synchronisation issues are negligible.
22 | - It does not cause device performance to suffer.
23 |
--------------------------------------------------------------------------------
/docs/arch/004-time-representation.md:
--------------------------------------------------------------------------------
1 | # 004 Removing `windowType`; changing time representation
2 |
3 | Originally added: 2025-02-03
4 |
5 | ## Status
6 |
7 | | Discussing | Approved | Superceded |
8 | | ---------- | -------- | ---------- |
9 | | | x | |
10 |
11 | ## Context
12 |
13 | BigscreenPlayer supports DASH and HLS streaming. Each transfer format (aka streaming protocol) represents time in a stream in a different way. BigscreenPlayer normalised these times in versions prior to v9.0.0. This normalisation made multiple assumptions:
14 |
15 | - The timestamp in the media samples are encoded as UTC times (in seconds) for "sliding window" content i.e. streams with time shift.
16 | - Streams with time shift never use a presentation time offset.
17 |
18 | What is more, metadata (i.e. the `windowType`) determined BigscreenPlayer's manifest parsing strategy from v7.1.0 and codified these assumptions.
19 |
20 | - How might we overhaul our time representation to support streams that don't comply with these assumptions?
21 |
22 | ### Considered Options
23 |
24 | 1. Expose time reported by the `MediaElement` directly. Provide functions to convert the time from the `MediaElement` into availability and media sample time.
25 | 2. Do not apply time correction based on `timeShiftBufferDepth` if `windowType === WindowTypes.GROWING`
26 | 3. Do not apply time correction based on `timeShiftBufferDepth` if SegmentTemplates in the MPD have `presentationTimeOffset`
27 |
28 | ## Decision
29 |
30 | Chosen option: 1
31 |
32 | This approach provides a lot of flexibility to consumers of BigscreenPlayer. It also simplifies time-related calculations such as failover, start time, and subtitle synchronisation.
33 |
34 | ## Consequences
35 |
36 | A major version (v9.0.0) to remove window type and overhaul BigscreenPlayer's internals.
37 |
--------------------------------------------------------------------------------
/docs/arch/005-remove-fake-seeking.md:
--------------------------------------------------------------------------------
1 | # 005 Remove fake seeking from restartable strategy
2 |
3 | Originally added: 2025-02-04
4 |
5 | ## Status
6 |
7 | | Discussing | Approved | Superceded |
8 | | ---------- | -------- | ---------- |
9 | | | x | |
10 |
11 | ## Context
12 |
13 | Native media players with the capability to start playback in a livestream from any (available) point in the stream are called "restartable" in BigscreenPlayer's jargon. Unlike "seekable" devices, "restartable" devices don't support in-stream navigation. In other words, seeking is not supported.
14 |
15 | BigscreenPlayer exploited this restart capability to implement "fake seeking" prior to v9.0.0. The restartable player effectively polyfilled the native media player's implementation of `MediaElement#currentTime` and `MediaElement#seekable`. This polyfill relied on the `windowType` metadata to make assumptions about the shape of the stream's seekable range. v9.0.0 deprecates `windowType`.
16 |
17 | - Should we continue to support fake seeking for native playback?
18 | - How might we continue to support fake seeking?
19 |
20 | ### Considered Options
21 |
22 | 1. Remove fake seeking from restartable strategy
23 | 2. Poll the HLS manifest for time shift
24 | 3. Provide a "magic" time shift buffer depth for HLS streams
25 |
26 | ## Decision
27 |
28 | Chosen option: 1
29 |
30 | The effort to contine support for fake seeking on restartable devices is not justified by the small number of people that benefit from the continued support.
31 |
32 | ## Consequences
33 |
34 | Viewers that use devices on the restartable strategy will no longer be able to pause or seek in-stream.
35 |
--------------------------------------------------------------------------------
/docs/arch/006-detect-autoresume.md:
--------------------------------------------------------------------------------
1 | # 006 Detect timeshift to enable auto-resume
2 |
3 | Originally added: 2025-02-04
4 |
5 | ## Status
6 |
7 | | Discussing | Approved | Superceded |
8 | | ---------- | -------- | ---------- |
9 | | | x | |
10 |
11 | ## Context
12 |
13 | BigscreenPlayer's auto-resume feature prevents undefined behaviour when native players resume playback outside the seekable range. Auto-resume consists of two mechanisms:
14 |
15 | 1. Playback is resumed before current time can drift outside of the seekable range
16 | 2. Pausing isn't possible when current time is close to the start of the seekable range
17 |
18 | Auto-resume is only relevant for streams with time shift. The presence of time shift was signalled through the `windowType === WindowTypes.SLIDING` parameter prior to v9.0.0. v9.0.0 deprecates `windowType`.
19 |
20 | DASH manifests explicitly encode the time shift of the stream through the `timeShiftBufferDepth`. On the other hand, time shift in HLS manifests is only detectable by refreshing the manifest.
21 |
22 | - How might we detect timeshift and enable the auto-resume feature for DASH and HLS streams?
23 |
24 | ### Considered Options
25 |
26 | 1. Poll the HLS manifest to check if the first segment changes
27 | 2. Poll the seekable range for changes to the start of the seekable range
28 | 3. Provide a "magic" time shift buffer depth for HLS streams
29 |
30 | ## Decision
31 |
32 | Chosen option: 2
33 |
34 | ## Consequences
35 |
36 | The time it takes the `timeshiftdetector` to detect and signal timeshift depends on it's polling rate. Hence, there is a risk the user navigates outside of the seekable range in the span of time before the `timeshiftdetector` detects a sliding seekable range.
37 |
--------------------------------------------------------------------------------
/docs/arch/007-estimate-hls-ast.md:
--------------------------------------------------------------------------------
1 | # 007 Estimate HLS Availability Start Time
2 |
3 | Originally added: 2025-02-04
4 |
5 | ## Status
6 |
7 | | Discussing | Approved | Superceded |
8 | | ---------- | -------- | ---------- |
9 | | | x | |
10 |
11 | ## Context
12 |
13 | BigscreenPlayer adds functions to convert between three timelines:
14 |
15 | 1. Presentation time: Output by the MediaElement
16 | 2. Media sample time: Timestamps encoded in the current media
17 | 3. Availablity time: UTC times that denote time available. Only relevant for dynamic streams.
18 |
19 | BigscreenPlayer relies on metadata in the manifest to calculate each conversion.
20 |
21 | For DASH:
22 |
23 | - Presentation time <-> Media sample time relies on `presentationTimeOffset`
24 | - Presentation time <-> Availability time relies on `availabilityStartTime`
25 |
26 | For HLS:
27 |
28 | - Presentation time <-> Media sample time relies on `programDateTime`
29 | - Presentation time <-> Availability time relies on ???
30 |
31 | HLS signals availability through the segment list. An HLS media player must refresh the segment list to track availability. Availability start time can be estimated as the difference between the current wallclock time and the duration of the stream so far. This estimate should also correct for any difference between the client and server's UTC wallclock time.
32 |
33 | ### Considered Options
34 |
35 | 1. Accept the conversion between availability and presentation time is broken for HLS streams.
36 | 2. Estimate availability start time for HLS streams. This requires clients provide the offset between the client and server's UTC wallclock time in order to synchronise the calculation.
37 |
38 | ## Decision
39 |
40 | Chosen option: 1
41 |
42 | ## Consequences
43 |
44 | The conversion between presentation time and availability start time is erroneous for HLS.
45 |
--------------------------------------------------------------------------------
/docs/arch/__template.md:
--------------------------------------------------------------------------------
1 | # 000 Title
2 |
3 | Originally added:
4 |
5 | ## Status
6 |
7 | | Discussing | Approved | Superceded |
8 | | ---------- | -------- | ---------- |
9 | | | x | |
10 |
11 | ## Context
12 |
13 | ### [Considered Options]
14 |
15 | Optional
16 |
17 | ## Decision
18 |
19 | ## Consequences
20 |
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Bigscreen Player
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/docs/static/bsp-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bbc/bigscreen-player/5362700d8142aa661e40c481b88e89ed9d7bc533/docs/static/bsp-logo.png
--------------------------------------------------------------------------------
/docs/static/bsp_state_changes_august_2019.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bbc/bigscreen-player/5362700d8142aa661e40c481b88e89ed9d7bc533/docs/static/bsp_state_changes_august_2019.png
--------------------------------------------------------------------------------
/docs/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bbc/bigscreen-player/5362700d8142aa661e40c481b88e89ed9d7bc533/docs/static/favicon.ico
--------------------------------------------------------------------------------
/docs/styles.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | height: 100%;
3 | background: rgb(255,160,48);
4 | background: linear-gradient(90deg, rgba(255,160,48,1) 34%, rgba(255,254,0,1) 100%);
5 | }
6 |
7 | .main {
8 | height: 100%;
9 | display: flex;
10 | align-items: center;
11 | justify-content: center;
12 | gap: 12px;
13 | }
14 |
15 | .cta {
16 | font: bold 14px Arial;
17 | text-decoration: none;
18 | background-color: #EEEEEE;
19 | color: #333333;
20 | padding: 2px 6px 2px 6px;
21 | border-radius: 10px;
22 | border: 1px solid #CCCCCC;
23 | }
--------------------------------------------------------------------------------
/docs/tutorials/00-getting-started.md:
--------------------------------------------------------------------------------
1 | ## Installation
2 |
3 | ```bash
4 | npm install bigscreen-player --save
5 | ```
6 |
7 | ## Get To Playback
8 |
9 | For a start, let's play back an on-demand video.
10 |
11 | To start a playback session you must minimally provide:
12 |
13 | - Some configuration. See [Configuration](#configuration)
14 | - A streaming manifest. See [Minimal Data](#minimal-data)
15 | - A HTML element to render playback. See [Initialisation](#initialisation)
16 |
17 | ### Configuration
18 |
19 | Configuration for bigscreen-player can be set using an object on the window:
20 |
21 | ```javascript
22 | window.bigscreenPlayer
23 | ```
24 |
25 | You must provide a _playback strategy_ to use BigscreenPlayer:
26 |
27 | ```javascript
28 | window.bigscreenPlayer.playbackStrategy = "msestrategy" // OR 'nativestrategy' OR 'basicstrategy'
29 | ```
30 |
31 | The `msestrategy` uses Dash.js under the hood. It is likely to be what you want. You should read [the documentation on playback strategies](https://bbc.github.io/bigscreen-player/api/tutorial-01-playback-strategies.html) if you want to use a native media player from your browser. You should also have a peek at the [documentation on settings and overrides](https://bbc.github.io/bigscreen-player/api/tutorial-02-settings-and-overrides.html)
32 |
33 | ### Minimal Data
34 |
35 | You must provide a manifest and its MIME type.
36 |
37 | ```javascript
38 | const minimalData = {
39 | media: {
40 | type: "application/dash+xml",
41 | urls: [{ url: "https://example.com/video.mpd" }],
42 | },
43 | }
44 | ```
45 |
46 | ## Initialisation
47 |
48 | A playback session can be initialised by simply calling the `init()` function with some initial data.
49 |
50 | The player will render itself into a supplied parent element, and playback will begin as soon as enough data has buffered.
51 |
52 | ```javascript
53 | import { BigscreenPlayer, MediaKinds, WindowTypes } from 'bigscreen-player'
54 |
55 | // See Configuration
56 | window.bigscreenPlayer.playbackStrategy
57 |
58 | // See Minimal Data
59 | const minimalData
60 |
61 | const bigscreenPlayer = BigscreenPlayer()
62 |
63 | const body = document.querySelector('body')
64 |
65 | const playbackElement = document.createElement('div')
66 | playbackElement.id = 'BigscreenPlayback'
67 |
68 | body.appendChild(playbackElement)
69 |
70 | bigscreenPlayer.init(playbackElement, minimalData)
71 | ```
72 |
73 | ## All Options
74 |
75 | The full set of options for BigscreenPlayer is:
76 |
77 | ```javascript
78 | const optionalData = {
79 | initialPlaybackTime: 0, // Time (in seconds) to begin playback from
80 | enableSubtitles: false,
81 | media: {
82 | type: "application/dash+xml",
83 | kind: MediaKinds.VIDEO, // Can be VIDEO, or AUDIO
84 | urls: [
85 | // Multiple urls offer the ability to fail-over to another CDN if required
86 | {
87 | url: "https://example.com/video.mpd",
88 | cdn: "origin", // For Debug Tool reference
89 | },
90 | {
91 | url: "https://failover.example.com/video.mpd",
92 | cdn: "failover",
93 | },
94 | ],
95 | captions: [
96 | {
97 | url: "https://example.com/captions/$segment$", // $segment$ required for replacement for live subtitle segments
98 | segmentLength: 3.84, // Required to calculate live subtitle segment to fetch & live subtitle URL.
99 | cdn: "origin", // Displayed by Debug Tool
100 | },
101 | {
102 | url: "https://failover.example.com/captions/$segment$",
103 | segmentLength: 3.84,
104 | cdn: "failover",
105 | },
106 | ],
107 | captionsUrl: "https://example.com/imsc-doc.xml", // NB This parameter is being deprecated in favour of the captions array shown above.
108 | subtitlesRequestTimeout: 5000, // Optional override for the XHR timeout on sidecar loaded subtitles
109 | subtitleCustomisation: {
110 | size: 0.75,
111 | lineHeight: 1.1,
112 | fontFamily: "Arial",
113 | backgroundColour: "black", // (css colour, hex)
114 | },
115 | audioDescribed: [
116 | // Multiple urls offer the ability to fail-over to another CDN if required
117 | {
118 | url: "https://example.com/video.mpd",
119 | cdn: "origin", // For Debug Tool reference
120 | },
121 | {
122 | url: "https://failover.example.com/video.mpd",
123 | cdn: "failover",
124 | },
125 | ],
126 | playerSettings: {
127 | // See settings documentation for more details
128 | failoverResetTime: 60000,
129 | streaming: {
130 | buffer: {
131 | bufferToKeep: 8,
132 | },
133 | },
134 | },
135 | },
136 | }
137 | ```
138 |
--------------------------------------------------------------------------------
/docs/tutorials/01-playback-strategies.md:
--------------------------------------------------------------------------------
1 | As part of the configuration of Bigscreen Player, a 'playback strategy' should be provided.
2 |
3 | There are three options available:
4 |
5 | - `msestrategy`
6 | - `nativestrategy`
7 | - `basicstrategy`
8 |
9 | Your app should write this to the `globalThis` object (i.e. the `window` on browsers) before initialising Bigscreen Player. This enables only the required media player code to be loaded. For example, if MSE playback is not needed, the _dashjs_ library does not have to be loaded.
10 |
11 | ```javascript
12 | window.bigscreenPayer.playbackStrategy = "msestrategy" // OR 'nativestrategy' OR 'basicstategy'
13 | ```
14 |
15 | The player will require in the correct strategy file at runtime.
16 |
17 | ## MSE Strategy
18 |
19 | The MSE strategy utilises the open source [_dashjs_](https://github.com/Dash-Industry-Forum/dash.js/wiki) library. Dashjs handles much of the playback, and the strategy interacts with this to provide a consistent interface. No other dependencies are requried.
20 |
21 | ## Native Strategy
22 |
23 | We have migrated TAL media player implementations into the Native Strategy, so that you can play media on devices that do not support Media Source Extensions without TAL. So far there is support for playback via:
24 |
25 | - `HTML5`
26 | - `CEHTML`
27 | - `SAMSUNG_MAPLE`
28 | - `SAMSUNG_STREAMING`
29 | - `SAMSUNG_STREAMING_2015`
30 |
31 | This requires additional config to select which media player implementation to use.
32 |
33 | ```javascript
34 | window.bigscreenPlayer.mediaPlayer: 'html5'
35 | ```
36 |
37 | You must also indicate the device's live playback capability. There's more info in [the documentation on live-streaming](https://bbc.github.io/bigscreen-player/api/tutorial-live-streaming.html)
38 |
39 | ```javascript
40 | window.bigscreenPlayer.liveSupport = "seekable"
41 | ```
42 |
43 | ## Basic Strategy
44 |
45 | This strategy is similar to native, in that it relies on the browser's media element to handle play back of the source provided. It is intended to be a lightweight wrapper around standard HTML5 media playback. It is media element event driven, and contains none of the workarounds (such as overrides and sentinels) that the native strategy does.
46 |
--------------------------------------------------------------------------------
/docs/tutorials/Audio Description.md:
--------------------------------------------------------------------------------
1 | BigscreenPlayer provides Audio Description (AD) support for accessible media playback. This document details the two implementations.
2 |
3 | ## Implementations
4 |
5 | BigscreenPlayer uses two distinct methods for handling AD: a source switching approach and an MSE-specific approach.
6 |
7 | ### 1. Source Switching Audio Description (MediaSources)
8 |
9 | The `MediaSources` module manages AD by accepting separate sources for audio description tracks. This method is used when AD is provided as a distinct media resource.
10 |
11 | - **Separate Source Management:**
12 | - `MediaSources` accepts a separate `media.audioDescribed` source, containing URLs and CDNs, distinct from the main media source.
13 | - Failover for AD sources occurs **independently** within this provided source.
14 | - Example:
15 | If CDN 'A' in the Audio Description source fails, failover occurs only within that AD source, independently of the main video source. If AD is turned off, and CDN 'A' is present for the main source, it will be available.
16 | - **Manifest Switching:**
17 | - Enabling or disabling AD triggers the loading of a new manifest from the appropriate URL within the provided AD source.
18 | - This effectively switches between the main and AD audio streams.
19 |
20 | ### 2. MSE Strategy-Specific Audio Description (MSEStrategy)
21 |
22 | The `MSEStrategy` strategy implementation manages AD within the same media stream.
23 |
24 | - **Track Selection:**
25 | - AD tracks are identified within the DASH manifest by their roles and accessibility schemes.
26 | - Currently, a track is identified as AD if it matches the `Broadcast mix AD` descriptor values. These values are:
27 | - Role `value` of `alternate`
28 | - Accessibility `schemeIdUri` of `urn:tva:metadata:cs:AudioPurposeCS:2007` and `value` of `1`.
29 | - These values are defined in [ETSI TS 103 285 V1.1.1 Technical Specification](https://www.etsi.org/deliver/etsi_ts/103200_103299/103285/01.01.01_60/ts_103285v010101p.pdf#%5B%7B%22num%22%3A59%2C%22gen%22%3A0%7D%2C%7B%22name%22%3A%22FitH%22%7D%2C392%5D)
30 |
31 | ## Developer API
32 |
33 | Audio Described functionality can be interacted with through BigScreenPlayers API, which exposes the following functions:
34 |
35 | - **`isAudioDescribedAvailable()`:**
36 | - Checks if AD tracks are available.
37 | - If the source switching implementation (MediaSources) provides AD, **this takes priority**.
38 | - **`isAudioDescribedEnabled()`:**
39 | - Checks if an AD track/source is currently active.
40 | - **`setAudioDescribed(enable)`:**
41 | - Enables or disables AD.
42 | - Example:
43 | ```javascript
44 | bigScreenPlayer.setAudioDescribed(true)
45 | ```
46 |
47 | ## Priority
48 |
49 | If there are separate audioDescribed media sources provided, it will take priority over the MSE specific implementation.
50 |
--------------------------------------------------------------------------------
/docs/tutorials/Debugging.md:
--------------------------------------------------------------------------------
1 | BigscreenPlayer offers logging through "Chronicle", our own debugging solution. This tool can be used to monitor specific events and changes in metrics over time in order to validate execution of the program logic.
2 |
3 | ## On-Screen Debugging
4 |
5 | We offer an on-screen debugger as part of our solution. This is particularly useful where no native JavaScript consoles are natively accessible on devices.
6 |
7 | The on-screen debugger can be shown using:
8 |
9 | ```js
10 | bigscreenPlayer.toggleDebug()
11 | ```
12 |
13 | ## Structured Logging
14 |
15 | Logs in the Chronicle are _structured_. What is more, logs are captured even when the on-screen debugger isn't active. You can access the complete record of the playback session using:
16 |
17 | ```js
18 | bigscreenPlayer.getDebugLogs()
19 | ```
20 |
21 | You can find the full structure of the data returned by this function in the source file `chronicle.ts`. In short, `getDebugLogs` returns the Chronicle record as a flat array of entries. Each entry falls into one of three (3) categories:
22 |
23 | 1. Message: Unstructured string data. Think `console` output.
24 | 2. Metrics: Values that change over time, such as dropped frames.
25 | 3. Traces: Snapshots and one-of data, such as errors and events.
26 |
--------------------------------------------------------------------------------
/docs/tutorials/Design.md:
--------------------------------------------------------------------------------
1 | This document covers the basics of the `bigscreen-player` high level architecture. It describes an overview of the internal workings that is useful to understand in order to carry out development and possibly contribute to the code base.
2 |
3 | Further setup code and specific examples can be found in the repo itself. A good place to start is the [README](https://github.com/bbc/bigscreen-player/blob/master/README.md).
4 |
5 | ## Dependencies
6 | As it stands, the player relies on two dependencies:
7 |
8 | #### [Dash.js](https://github.com/bbc/dash.js)
9 | Our custom fork of the reference implementation of the ***dynamic adaptive streaming over http*** protocol. Used for MSE (Media Source Extension) capable devices. This is required in specifically by `bigscreen-player`, when the mse-strategy is used.
10 |
11 | #### [imscJS](https://github.com/bbc/imscJS)
12 | Our custom fork for rendering subtitles.
13 |
14 | ## Architecture
15 |
16 | 
17 |
18 | ### Player Component
19 | This stage provides a wrapper for the interaction with all of the individual strategies and is what `bigscreen-player` uses to interact with the video element, at its core.
--------------------------------------------------------------------------------
/docs/tutorials/Events.md:
--------------------------------------------------------------------------------
1 | Bigscreen Player uses a variety of events to signal its current state.
2 |
3 | ## Reacting to state changes
4 |
5 | State changes which are emitted from the player can be acted upon to by registering a callback. The callback will receive all of the following state changes as the `state` property of the event:
6 | - `MediaState.STOPPED`
7 | - `MediaState.PAUSED`
8 | - `MediaState.PLAYING`
9 | - `MediaState.WAITING`
10 | - `MediaState.ENDED`
11 | - `MediaState.FATAL_ERROR`
12 |
13 | State changes may be registered for before initialisation and will automatically be cleared upon `tearDown()` of the player.
14 |
15 | ```javascript
16 | var bigscreenPlayer = BigscreenPlayer();
17 |
18 | // The token is only required in the case where the function is anonymous, a reference to the function can be stored and used to unregister otherwise.
19 | var stateChangeToken = bigscreenPlayer.registerForStateChanges(function (event) {
20 | if(event.state == MediaState.PLAYING) {
21 | console.log('Playing');
22 | // handle playing event
23 | }
24 | });
25 |
26 | bigscreenPlayer.unRegisterForStateChanges(stateChangeToken);
27 | ```
28 |
29 | ## Reacting to time updates
30 |
31 | Time updates are emitted multiple times a second. Your application can register to receive these updates. The emitted object contains the `currentTime` and `endOfStream` properties.
32 |
33 | Time updates may be registered for before initialisation and will automatically be cleared upon `tearDown()` of the player.
34 |
35 | ```javascript
36 | var bigscreenPlayer = BigscreenPlayer();
37 |
38 | // The token is only required in the case where the function is anonymous, a reference to the function can be stored and used to unregister otherwise.
39 | var timeUpdateToken = bigscreenPlayer.registerForTimeUpdates(function (event) {
40 | console.log('Current Time: ' + event.currentTime);
41 | });
42 |
43 | bigscreenPlayer.unRegisterForTimeUpdates(timeUpdateToken);
44 | ```
45 |
46 | ## Reacting to subtitles being turned on/off
47 |
48 | This is emitted on every `setSubtitlesEnabled` call. The emitted object contains an `enabled` property.
49 |
50 | This may be registered for before initialisation and will automatically be cleared upon `tearDown()` of the player.
51 |
52 | ```javascript
53 | var bigscreenPlayer = BigscreenPlayer();
54 |
55 | // The token is only required in the case where the function is anonymous, a reference to the function can be stored and used to unregister otherwise.
56 | var subtitleChangeToken = bigscreenPlayer.registerForSubtitleChanges(function (event) {
57 | console.log('Subttiles enabled: ' + event.enabled);
58 | });
59 |
60 | bigscreenPlayer.unregisterForSubtitleChanges(subtitleChangeToken);
61 | ```
--------------------------------------------------------------------------------
/docs/tutorials/Plugins.md:
--------------------------------------------------------------------------------
1 | Plugins can be created to extend the functionality of the Bigscreen Player by adhering to an interface which propogates non state change events from the player. For example, when an error is raised or cleared.
2 |
3 | The full interface is as follows:
4 |
5 | - `onError`
6 | - `onFatalError`
7 | - `onErrorCleared`
8 | - `onErrorHandled`
9 | - `onBuffering`
10 | - `onBufferingCleared`
11 | - `onScreenCapabilityDetermined`
12 | - `onPlayerInfoUpdated`
13 | - `onManifestLoaded`
14 | - `onManifestParseError`
15 | - `onQualityChangeRequested`
16 | - `onQualityChangedRendered`
17 | - `onSubtitlesLoadError`
18 | - `onSubtitlesTimeout`
19 | - `onSubtitlesXMLError`
20 | - `onSubtitlesTransformError`
21 | - `onSubtitlesRenderError`
22 | - `onSubtitlesDynamicLoadError`
23 | - `onFragmentContentLengthMismatch`
24 |
25 | An example plugin may look like:
26 |
27 | ```javascript
28 | function ExamplePlugin(appName) {
29 | var name = appName
30 |
31 | function onFatalError(evt) {
32 | console.log("A fatal error has occured in the app: " + name)
33 | }
34 |
35 | function onErrorHandled(evt) {
36 | console.log("The " + name + " app is handling a playback error")
37 | }
38 |
39 | return {
40 | onFatalError: onFatalError,
41 | onErrorHandled: onErrorHandled,
42 | }
43 | }
44 | ```
45 |
46 | ```javascript
47 | var bigscreenPlayer = BigscreenPlayer()
48 |
49 | var examplePlugin = ExamplePlugin("myApp")
50 |
51 | bigscreenPlayer.registerPlugin(examplePlugin)
52 |
53 | // initialise bigscreenPlayer - see above
54 |
55 | // you should unregister your plugins as part of your playback cleanup
56 |
57 | // calling with no argument will unregister all plugins
58 | bigscreenPlayer.unregisterPlugin(examplePlugin)
59 | ```
60 |
--------------------------------------------------------------------------------
/docs/tutorials/State Changes.md:
--------------------------------------------------------------------------------
1 | During playback, `bigscreen-player` may change state (e.g enter buffering, pause, end). Whenever the state changes, this is emitted as an event. A client can register to listen to these events with a call to `registerForStateChanges(callback)`.
2 |
3 | The following diagram describes the flow of these events.
4 |
5 | 
--------------------------------------------------------------------------------
/docs/tutorials/Testing.md:
--------------------------------------------------------------------------------
1 | **This page lists the areas that are to be considered for testing Bigscreen Player changes**
2 |
3 | Different Streaming types should be considered - MP4, HLS, DASH - Audio and Video
4 | * Subtitles (currently on demand only)
5 | * CDN Failover
6 | * Tearing down playback and immediately starting something new (e.g 'autoplay' features)
7 | * Soak testing (i.e. play for a long period of time)
8 | * End of playback
9 | * Seeking and related UI (e.g. scrub bar, thumbnails)
10 | * Buffering UI
11 | * Adaptive Bit Rate (ABR)
12 | * Any other application behaviour driven by Bigscreen Player events (e.g. stats)
13 |
14 | ## Live Playback specific areas
15 | Different types of live playback capability should be considered - **Playable, Restartable, Seekable**
16 |
17 | ### Sliding Windows
18 | 1. Live Restart Curtain
19 | 2. Manifest Parsing
20 | * Watch form Live
21 | * Start from a given point in the window (a.k.a Live restart)
22 | * Watch from the start of the window
23 | * Auto resume at the start of the window
24 | * Seeking
25 |
26 | ### Growing Windows
27 | 1. Live Restart Curtain
28 | 2. Manifest Parsing
29 | * Watch from Live
30 | * Live restart
31 | * Seeking
32 | 3. End of stream (Ended Event)
33 |
--------------------------------------------------------------------------------
/docs/tutorials/XX-mocking.md:
--------------------------------------------------------------------------------
1 | THIS IS DEPRECATED
2 |
3 | When writing tests for your application it may be useful to use the mocking functions provided. This creates a fake player with mocking hook functions to simulate real world scenarios.
4 |
5 | Bigscreen Player includes a test mode than can be triggered by calling `mock()` or `mockJasmine()`.
6 |
7 | ## `mock(opts)` and `mockJasmine(opts)`
8 |
9 | `mockJasmine()` should be used when you want to mock Bigscreen Player within Jasmine tests. This turns each Bigsceen Player api function into a Jasmine spy, so you can call all the spy function on them e.g. `expect(BigscreenPlayer.play).toHaveBeenCalled()`.
10 |
11 | `mock()` should be used when you are not running Jasmine tests.
12 |
13 | ### `opts`
14 |
15 | - `autoProgress = true|false` (optional; defaults to false) - playback will automatically progress after 100ms, transitioning into PLAYING, then updates the time every 500ms.
16 |
17 | ## `unmock()`
18 |
19 | Should be called when you want to stop mocking Bigsceen Player, including in the `afterEach()` of Jasmine tests.
20 |
21 | ## Mocking hooks
22 |
23 | When Bigsceen Player has been mocked, there are various hooks added to the API in order to modify its behaviour or emulate certain real life scenarios.
24 |
25 | ### `changeState(state, eventTrigger, opts)`
26 |
27 | Hook for changing the state e.g. `BigscreenPlayer.changeState(MediaState.WAITING)` would emulate playback buffering.
28 |
29 | - `state = MediaState` - [MediaState](global.html#WindowTypes) to change to.
30 | - `eventTrigger = 'device'|'other'` (optional; defaults to device) - determines whether the state change was caused by the device or not; affects `onBufferingCleared` and `onErrorCleared` plugin calls.
31 |
32 | ### `getSource()`
33 |
34 | Get the URL of the current media.
35 |
36 | ### `progressTime(time)`
37 |
38 | Emulate playback jumping to a given `time`.
39 |
40 | ### `setCanPause(value)`
41 |
42 | Make `canPause()` return `value`.
43 |
44 | ### `setCanSeek(value)`
45 |
46 | Make `canSeek()` return `value`.
47 |
48 | ### `setDuration(mediaDuration)`
49 |
50 | Set the duration to `mediaDuration`.
51 |
52 | ### `setEndOfStream(isEndOfStream)`
53 |
54 | Sets the `endOfStream` value to be sent with events to `isEndOfStream`.
55 |
56 | ### `setInitialBuffering(value)`
57 |
58 | If Mock Bigsceen Player is setup to automatically progress, it will by default start progressing 100ms after `init()` has been called. If `setInitialBuffering(true)` is called, playback will not progress until something else causes it e.g. `play()` or `changeState(MediaState.PLAYING)`.
59 |
60 | ### `setLiveWindowStart(value)`
61 |
62 | Sets the live window start time to `value`; used in the calculation for `convertVideoTimeSecondsToEpochMs(seconds)`.
63 |
64 | ### `setMediaKind(kind)`
65 |
66 | Sets return value of `getMediaKind()` and `avType` for AV stats to `kind`.
67 |
68 | ### `setSeekableRange(newSeekableRange)`
69 |
70 | Set the seekable range to `newSeekableRange`.
71 |
72 | - `newSeekableRange`
73 | - `start` - seekable range start
74 | - `end` - seekable range end
75 |
76 | ### `setWindowType(type)`
77 |
78 | Sets the [WindowType](/models/windowtypes.js). Mock Bigsceen Player will behave differently for VOD, WEBCAST, and SIMULCAST as in production.
79 |
80 | ### `setSubtitlesAvailable(value)`
81 |
82 | Sets the return value of `isSubtitlesAvailable()` to `value`.
83 |
84 | ### `triggerError()`
85 |
86 | Triggers a non-fatal error which stalls playback. Will be dismissed when any call to Bigsceen Player is made which results in a state change, or with mocking hooks `changeState()` or `triggerErrorHandled()`.
87 |
88 | ### `triggerErrorHandled()`
89 |
90 | Makes Bigsceen Player handle an error - changes the URL if multiple were passed in, and the list hasn't already been exhausted, and resumes playback if Mock Bigsceen Player is automatically progressing.
91 |
--------------------------------------------------------------------------------
/docs/tutorials/cdn-failover.md:
--------------------------------------------------------------------------------
1 | When a user is playing video/audio and an error occurs we want playback to recover using different CDN.
2 |
3 | ### When is it triggered?
4 | Few reasons why errors can happen when playback is attempted:
5 | - Loss of network/low network
6 | - Loss of connection to a particular CDN
7 | - Missing segments in manifest
8 | - Device specific fatal errors
9 |
10 | _Note: CDN Failover is not attempted if the error occurs in last 60 secs of static (on demand) content_
11 |
12 | ## Standard Failover - On all devices/playback strategies
13 |
14 | ### Buffering timeout Errors
15 | Some potential causes:
16 | - Loss of Network
17 | - Internal error not reported by device browser
18 |
19 | #### Errors at the start of the playback
20 |
21 | 1. Bigscreen Player has been initialised and is in WAITING state
22 | 1. CDN failover is attempted after 30 secs
23 |
24 | #### Errors during playback
25 |
26 | 1. Playback strategy reports waiting event
27 | 1. Bigscreen Player is in WAITING state
28 | 1. CDN failover is attempted after 20 secs
29 |
30 | _**This can be replicated by network throttling. We usually use very low setting of 12 kb/s to trigger buffering.**_
31 |
32 | ### FATAL Errors
33 | Some potential causes:
34 | - Loss of CDN, unavailable CDN
35 | - Corrupted stream
36 | - Issue with the device browser
37 |
38 | 1. Playback strategy reports error event
39 | 1. Bigscreen Player is in WAITING state
40 | 1. CDN failover is attempted after 5 secs
41 |
42 | _**This can be replicated by blocking CDN in the inspect debug tool.**_
43 |
44 | ## Seamless Failover - Only on MSE Strategy Devices
45 |
46 | We provide dash.js with all the `urls` provided. dash.js will switch CDN 'seamlessly' if it detects an issue, which may not always result in a WAITING event being throw.
47 |
48 | _**This can be replicated by blocking CDN in the inspect debug tool**_
--------------------------------------------------------------------------------
/docs/tutorials/live-streaming.md:
--------------------------------------------------------------------------------
1 | > This tutorial assumes you have read [Getting Started](https://bbc.github.io/bigscreen-player/api/tutorial-00-getting-started.html)
2 |
3 | ## Live Playback Capability
4 |
5 | You must define the device's live playback capability. This describes the capabilities of the device's seekable range.
6 |
7 | ```javascript
8 | window.bigscreenPlayer.liveSupport: 'playable' // default
9 | ```
10 |
11 | LiveSupport can be one of:
12 |
13 | - `none` -- Live playback will fail
14 | - `playable` -- Can only play from the live point
15 | - `restartable` -- Can start playback from any (available) point in the stream. Can't pause or seek.
16 | - `seekable` -- Can start playback from any (available) point in the stream. Can pause and seek.
17 |
18 | Note! The `cehtml` player has only been tested with `liveSupport: playable`. Features such as seeking likely won't work as expected.
19 |
20 | ## Requirements for DASH
21 |
22 | The MPD must define an availability start time.
23 |
24 | ### DASH Timing Element
25 |
26 | A `` element must be in the DASH manifest in order to play live content (simul/webcast). The `` element's `value` attribute must be an URL to a timing server that returns a valid ISO date.
27 |
28 | ```xml
29 |
30 |
31 |
32 |
33 | ...
34 |
35 |
36 | ```
37 |
--------------------------------------------------------------------------------
/docs/tutorials/seeking.md:
--------------------------------------------------------------------------------
1 | A seek is initiated by `BigscreenPlayer#setCurrentTime()`. It can take a number or a number and a timeline. Each timeline is defined in the `Timeline` enum.
2 |
3 | BigscreenPlayer will signal a seek is in progress through the `isSeeking` property on the `WAITING` state change.
4 |
5 | ## Setting a start point
6 |
7 | A call to `setCurrentTime()` does nothing until the stream is loaded with `BigscreenPlayer#init()`. You should provide an `initialPlaybackTime` in the initialisation object instead, like:
8 |
9 | ```javascript
10 | bigscreenPlayer.init(playbackElement, {
11 | ...init,
12 | initialPlaybackTime: 30, // a presentation time in seconds
13 | })
14 | ```
15 |
16 | The `initialPlaybackTime` can also reference other timelines, just like `setCurrentTime()`
17 |
18 | ```javascript
19 | bigscreenPlayer.init(playbackElement, {
20 | ...init,
21 | initialPlaybackTime: {
22 | seconds: 30,
23 | timeline: Timeline.MEDIA_SAMPLE_TIME,
24 | },
25 | })
26 | ```
27 |
28 | ## Timelines
29 |
30 | The `Timeline` constant enumerates different reference points you can seek through a stream by. This section explains each timeline.
31 |
32 | ### Presentation time
33 |
34 | The time output by the `MediaElement`. The zero point is determined by the stream and transfer format (aka streaming protocol). For example, for HLS `0` always refers to the start of the first segment in the stream on first load.
35 |
36 | Presentation time is output by `BigscreenPlayer#getCurrentTime()` and `BigscreenPlayer#getSeekableRange()`. The value provided to `setCurrentTime()` and `initialPlaybackTime` is treated as presentation time by default.
37 |
38 | ### Media sample time
39 |
40 | The timestamps encoded in the media sample(s).
41 |
42 | For DASH the conversion between media sample time and presentation time relies on the `presentationTimeOffset` and `timescale` defined in the MPD. BigscreenPlayer assumes the presentation time offset (in seconds) works out as the same value for all representations in the MPD.
43 |
44 | For HLS the conversion between media sample time and presentation time relies on the `programDateTime` defined in the playlist. BigscreenPlayer assumes the `programDateTime` is associated with the first segment in the playlist.
45 |
46 | ### Availability time
47 |
48 | The UTC time denoting the availability of the media. Only applies to dynamic streams.
49 |
50 | For DASH the conversion between availability time and presentation time relies on the `availabilityStartTime`. BigscreenPlayer assumes the stream doesn't define any `availabilityOffset`.
51 |
52 | For HLS the conversion is erroneous, and relies on `programDateTime`. See decision record `007-estimate-hls-ast`.
53 |
--------------------------------------------------------------------------------
/docs/tutorials/tutorials.json:
--------------------------------------------------------------------------------
1 | {
2 | "00-getting-started": {
3 | "title": "Getting Started"
4 | },
5 | "01-playback-strategies": {
6 | "title": "Playback Strategies"
7 | },
8 | "02-settings-and-overrides": {
9 | "title": "Settings And Overrides"
10 | },
11 | "cdn-failover": {
12 | "title": "CDN Failover"
13 | },
14 | "debugging": {
15 | "title": "Debugging"
16 | },
17 | "live-streaming": {
18 | "title": "Live"
19 | },
20 | "seeking": {
21 | "title": "Seeking"
22 | },
23 | "XX-mocking": {
24 | "title": "Mocking Playback (deprecated)"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/eslint.compat.js:
--------------------------------------------------------------------------------
1 | import { FlatCompat } from "@eslint/eslintrc"
2 | import js from "@eslint/js"
3 | import path from "node:path"
4 | import { fileURLToPath } from "node:url"
5 |
6 | // Mimick CommonJS variables
7 | const __filename = fileURLToPath(import.meta.url)
8 | const __dirname = path.dirname(__filename)
9 |
10 | const compat = new FlatCompat({
11 | baseDirectory: __dirname,
12 | resolvePluginsRelativeTo: __dirname,
13 | recommendedConfig: js.configs.recommended,
14 | allConfig: js.configs.all,
15 | })
16 |
17 | export const sonarjs = {
18 | configs: { recommended: compat.extends("plugin:sonarjs/recommended") },
19 | }
20 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | bigscreen-player
6 |
7 |
8 |
9 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/jest.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "jest"
2 |
3 | const config: Config = {
4 | testEnvironment: "jsdom",
5 | showSeed: true,
6 | transform: {
7 | "\\.[j]sx?$": "babel-jest",
8 | "\\.[t]sx?$": "ts-jest",
9 | },
10 | }
11 |
12 | export default config
13 |
--------------------------------------------------------------------------------
/jsdoc.conf.json:
--------------------------------------------------------------------------------
1 | {
2 | "tags": {
3 | "allowUnknownTags": true,
4 | "dictionaries": ["jsdoc"]
5 | },
6 | "source": {
7 | "include": ["src"],
8 | "includePattern": ".(js|ts)$",
9 | "excludePattern": "(node_modules/|docs)"
10 | },
11 | "sourceType": "module",
12 | "plugins": ["jsdoc-plugin-typescript", "plugins/markdown", "node_modules/better-docs/typescript"],
13 | "templates": {
14 | "cleverLinks": false,
15 | "monospaceLinks": true,
16 | "useLongnameInNav": true,
17 | "showInheritedInNav": true,
18 | "default": {
19 | "includeDate": false,
20 | "staticFiles": {
21 | "include": ["./docs/static"]
22 | }
23 | }
24 | },
25 | "opts": {
26 | "template": "node_modules/clean-jsdoc-theme",
27 | "encoding": "utf8",
28 | "destination": "./docs/api",
29 | "recurse": true,
30 | "hierarchy": true,
31 | "readme": "README.md",
32 | "tutorials": "./docs/tutorials",
33 | "theme_opts": {
34 | "default_theme": "dark",
35 | "base_url": "https://bbc.github.io/bigscreen-player/api/",
36 | "favicon": "favicon.ico",
37 | "homepageTitle": "Docs: Bigscreen Player",
38 | "title": " ",
39 | "menu": [
40 | { "title": "Home", "link": "index.html" },
41 | { "title": "Repo", "link": "https://github.com/bbc/bigscreen-player" }
42 | ],
43 | "sections": ["Tutorials", "Modules"],
44 | "displayModuleHeader": true,
45 | "meta": [
46 | {
47 | "name": "description",
48 | "content": "Simplified media playback for bigscreen devices"
49 | }
50 | ],
51 | "search": {
52 | "shouldSort": true,
53 | "threshold": 0.4,
54 | "location": 0,
55 | "distance": 100,
56 | "maxPatternLength": 32,
57 | "minMatchCharLength": 1
58 | },
59 | "footer": ""
60 | }
61 | },
62 | "markdown": {
63 | "$comment": "Important for `clean-jsdoc-theme`",
64 | "hardwrap": false,
65 | "idInHeadings": true
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bigscreen-player",
3 | "version": "10.7.1",
4 | "type": "module",
5 | "description": "Simplified media playback for bigscreen devices.",
6 | "main": "dist/esm/main.js",
7 | "browser": "dist/esm/main.js",
8 | "module": "dist/esm/main.js",
9 | "types": "dist/esm/main.d.ts",
10 | "files": [
11 | "dist",
12 | "CONTRIBUTING.md"
13 | ],
14 | "scripts": {
15 | "prepare": "if [ ! -d dist/ ]; then npm run build; fi",
16 | "postinstall": "if [ -d .git/ ]; then husky install; fi",
17 | "docs": "jsdoc -c jsdoc.conf.json",
18 | "build": "npm run build:clean && npm run build:bundle && npm run build:tmp",
19 | "build:clean": "rm -rf dist/*",
20 | "build:bundle": "rollup --config rollup.config.js",
21 | "build:tmp": "rm -r dist/esm/__tmp",
22 | "watch": "rollup --watch --config rollup.config.js",
23 | "start": "rollup --watch --config rollup.dev.config.js",
24 | "test": "jest",
25 | "coverage": "jest --coverage",
26 | "lint": "eslint ."
27 | },
28 | "devDependencies": {
29 | "@babel/core": "^7.23.7",
30 | "@babel/plugin-transform-runtime": "^7.23.9",
31 | "@babel/preset-env": "^7.23.8",
32 | "@babel/preset-typescript": "^7.23.3",
33 | "@rollup/plugin-alias": "^5.1.1",
34 | "@rollup/plugin-babel": "^6.0.4",
35 | "@rollup/plugin-commonjs": "^25.0.7",
36 | "@rollup/plugin-inject": "^5.0.5",
37 | "@rollup/plugin-json": "^6.1.0",
38 | "@rollup/plugin-node-resolve": "^15.2.3",
39 | "@rollup/plugin-replace": "^5.0.5",
40 | "@rollup/plugin-typescript": "^11.1.6",
41 | "@types/jest": "^29.5.11",
42 | "babel-jest": "^29.7.0",
43 | "better-docs": "^2.7.3",
44 | "clean-jsdoc-theme": "^4.2.7",
45 | "eslint": "^8.57.0",
46 | "eslint-plugin-jest": "^27.9.0",
47 | "eslint-plugin-sonarjs": "^0.23.0",
48 | "eslint-plugin-unicorn": "^50.0.1",
49 | "husky": "^8.0.3",
50 | "jest": "^29.5.0",
51 | "jest-environment-jsdom": "^29.5.0",
52 | "jsdoc": "^4.0.4",
53 | "jsdoc-plugin-typescript": "^3.2.0",
54 | "prettier": "^3.1.1",
55 | "rollup": "^3.29.4",
56 | "rollup-plugin-dts": "^6.1.0",
57 | "rollup-plugin-livereload": "^2.0.5",
58 | "rollup-plugin-polyfill-node": "^0.13.0",
59 | "rollup-plugin-serve": "^1.1.0",
60 | "rollup-plugin-visualizer": "^5.5.2",
61 | "ts-jest": "^29.1.1",
62 | "ts-node": "^10.9.2",
63 | "typescript": "^5.3.3",
64 | "typescript-eslint": "^7.2.0"
65 | },
66 | "dependencies": {
67 | "dashjs": "github:bbc/dash.js#smp-v4.7.3-7",
68 | "smp-imsc": "github:bbc/imscJS#v1.0.3"
69 | },
70 | "repository": {
71 | "type": "git",
72 | "url": "git+https://github.com/bbc/bigscreen-player.git"
73 | },
74 | "keywords": [
75 | "BBC",
76 | "Media Player",
77 | "Video Playback",
78 | "TV",
79 | "Set Top Box",
80 | "Streaming"
81 | ],
82 | "author": "BBC",
83 | "license": "Apache-2.0",
84 | "bugs": {
85 | "url": "https://github.com/bbc/bigscreen-player/issues"
86 | },
87 | "homepage": "https://github.com/bbc/bigscreen-player#readme"
88 | }
89 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import PackageJSON from "./package.json" assert { type: "json" }
2 |
3 | import alias from "@rollup/plugin-alias"
4 | import replace from "@rollup/plugin-replace"
5 | import typescript from "@rollup/plugin-typescript"
6 | import { dts } from "rollup-plugin-dts"
7 |
8 | export default [
9 | {
10 | input: "src/main.ts",
11 | external: [/^dashjs/, "smp-imsc", "tslib"],
12 | output: [{ dir: "dist/esm", format: "es" }],
13 | plugins: [
14 | alias({
15 | entries: [{ find: "imsc", replacement: "smp-imsc" }],
16 | }),
17 | replace({
18 | preventAssignment: true,
19 | __VERSION__: () => PackageJSON.version,
20 | }),
21 | typescript({
22 | tsconfig: "./tsconfig.dist.json",
23 | }),
24 | ],
25 | },
26 | {
27 | input: "./dist/esm/__tmp/dts/main.d.ts",
28 | output: [{ file: "./dist/esm/main.d.ts", format: "es" }],
29 | plugins: [dts()],
30 | },
31 | ]
32 |
--------------------------------------------------------------------------------
/rollup.dev.config.js:
--------------------------------------------------------------------------------
1 | import PackageJSON from "./package.json" assert { type: "json" }
2 |
3 | import alias from "@rollup/plugin-alias"
4 | import babel from "@rollup/plugin-babel"
5 | import commonjs from "@rollup/plugin-commonjs"
6 | import resolve from "@rollup/plugin-node-resolve"
7 | import replace from "@rollup/plugin-replace"
8 | import liveReload from "rollup-plugin-livereload"
9 | import nodePolyfills from "rollup-plugin-polyfill-node"
10 | import serve from "rollup-plugin-serve"
11 |
12 | const extensions = [".js", ".ts"]
13 |
14 | export default {
15 | input: "src/main.ts",
16 | output: {
17 | file: "dist-local/esm/main.js",
18 | name: "bsp",
19 | inlineDynamicImports: true,
20 | sourcemap: true,
21 | format: "es",
22 | },
23 | plugins: [
24 | alias({
25 | entries: [{ find: "imsc", replacement: "smp-imsc" }],
26 | }),
27 | replace({
28 | preventAssignment: true,
29 | __VERSION__: () => PackageJSON.version,
30 | }),
31 | resolve({ extensions, preferBuiltins: false }),
32 | commonjs(),
33 | nodePolyfills(),
34 | babel({ extensions, babelHelpers: "bundled" }),
35 | serve({
36 | open: true,
37 | }),
38 | liveReload({
39 | watch: ["index.html", "dist-local"],
40 | }),
41 | ],
42 | }
43 |
--------------------------------------------------------------------------------
/scripts/format-staged.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # Adapted from: https://prettier.io/docs/en/precommit.html#option-6-shell-script
4 |
5 | staged_files="$(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g')"
6 |
7 | [ -z "${staged_files}" ] && exit 0
8 |
9 | # Prettify all selected files
10 | printf "%s\n" "${staged_files}" | xargs ./node_modules/.bin/prettier --ignore-unknown --write
11 |
12 | # Add back the modified/prettified files to staging
13 | printf "%s\n" "${staged_files}" | xargs git add
14 |
15 | exit 0
16 |
--------------------------------------------------------------------------------
/scripts/lint-pr.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | PR_BASE_SHA="${PR_BASE_SHA}"
4 | PR_HEAD_SHA="${PR_HEAD_SHA}"
5 |
6 | changed_files="$(git diff --name-only --diff-filter=ACMR ${PR_BASE_SHA} ${PR_HEAD_SHA})"
7 |
8 | # Manually check the `git diff` succeeded
9 | if [ "${?}" -ne 0 ]; then
10 | exit 1
11 | fi
12 |
13 | # Exit early if no files were changed
14 | if [ -z "${changed_files}" ]; then
15 | exit 0
16 | fi
17 |
18 | # Lint changed files
19 | if !(
20 | printf "%s\n" "${changed_files}" |
21 | sed 's| |\\ |g' |
22 | xargs ./node_modules/.bin/eslint --quiet
23 | )
24 | then
25 | exit 1
26 | fi
27 |
28 | exit 0
29 |
--------------------------------------------------------------------------------
/scripts/lint-staged.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # Adapted from: https://prettier.io/docs/en/precommit.html#option-6-shell-script
4 |
5 | staged_files="$(
6 | git diff --cached --name-only --diff-filter=ACMR | # get staged files
7 | sed 's| |\\ |g' # escape whitespace
8 | )"
9 |
10 | if [ -z "${staged_files}" ]; then
11 | exit 0
12 | fi
13 |
14 | # Lint all selected files
15 | # --quiet to silence unmatched file warnings caused by .eslintignore
16 | if !(
17 | printf "%s\n" "${staged_files}" |
18 | xargs ./node_modules/.bin/eslint --fix --quiet
19 | )
20 | then
21 | exit 1
22 | fi
23 |
24 | # Add back the modified/linted files to staging
25 | printf "%s\n" "${staged_files}" | xargs git add
26 |
27 | exit 0
28 |
--------------------------------------------------------------------------------
/src/allowedmediatransitions.js:
--------------------------------------------------------------------------------
1 | function AllowedMediaTransitions(mediaplayer) {
2 | const player = mediaplayer
3 |
4 | const MediaPlayerState = {
5 | EMPTY: "EMPTY", // No source set
6 | STOPPED: "STOPPED", // Source set but no playback
7 | BUFFERING: "BUFFERING", // Not enough data to play, waiting to download more
8 | PLAYING: "PLAYING", // Media is playing
9 | PAUSED: "PAUSED", // Media is paused
10 | COMPLETE: "COMPLETE", // Media has reached its end point
11 | ERROR: "ERROR", // An error occurred
12 | }
13 |
14 | function canBePaused() {
15 | const pausableStates = [MediaPlayerState.BUFFERING, MediaPlayerState.PLAYING]
16 |
17 | return pausableStates.indexOf(player.getState()) !== -1
18 | }
19 |
20 | function canBeStopped() {
21 | const unstoppableStates = [MediaPlayerState.EMPTY, MediaPlayerState.ERROR]
22 |
23 | const stoppable = unstoppableStates.indexOf(player.getState()) === -1
24 | return stoppable
25 | }
26 |
27 | function canBeginSeek() {
28 | const unseekableStates = [MediaPlayerState.EMPTY, MediaPlayerState.ERROR]
29 |
30 | const state = player.getState()
31 | const seekable = state ? unseekableStates.indexOf(state) === -1 : false
32 |
33 | return seekable
34 | }
35 |
36 | function canResume() {
37 | return player.getState() === MediaPlayerState.PAUSED || player.getState() === MediaPlayerState.BUFFERING
38 | }
39 |
40 | return {
41 | canBePaused: canBePaused,
42 | canBeStopped: canBeStopped,
43 | canBeginSeek: canBeginSeek,
44 | canResume: canResume,
45 | }
46 | }
47 |
48 | export default AllowedMediaTransitions
49 |
--------------------------------------------------------------------------------
/src/debugger/debugview.ts:
--------------------------------------------------------------------------------
1 | import DOMHelpers from "../domhelpers"
2 |
3 | type Entry = { id: string; key: string; value: string | number | boolean }
4 |
5 | let appElement: HTMLElement | undefined
6 | let logBox: HTMLElement | undefined
7 | let logContainer: HTMLElement | undefined
8 | let staticContainer: HTMLElement | undefined
9 | let staticBox: HTMLElement | undefined
10 |
11 | function init() {
12 | logBox = document.createElement("div")
13 | logContainer = document.createElement("span")
14 | staticBox = document.createElement("div")
15 | staticContainer = document.createElement("span")
16 |
17 | if (appElement === undefined) {
18 | appElement = document.body
19 | }
20 |
21 | logBox.id = "logBox"
22 | logBox.style.position = "absolute"
23 | logBox.style.width = "63%"
24 | logBox.style.left = "5%"
25 | logBox.style.top = "15%"
26 | logBox.style.bottom = "25%"
27 | logBox.style.backgroundColor = "#1D1D1D"
28 | logBox.style.opacity = "0.9"
29 | logBox.style.overflow = "hidden"
30 |
31 | staticBox.id = "staticBox"
32 | staticBox.style.position = "absolute"
33 | staticBox.style.width = "30%"
34 | staticBox.style.right = "1%"
35 | staticBox.style.top = "15%"
36 | staticBox.style.bottom = "25%"
37 | staticBox.style.backgroundColor = "#1D1D1D"
38 | staticBox.style.opacity = "0.9"
39 | staticBox.style.overflow = "hidden"
40 |
41 | logContainer.id = "logContainer"
42 | logContainer.style.color = "#ffffff"
43 | logContainer.style.fontSize = "11pt"
44 | logContainer.style.position = "absolute"
45 | logContainer.style.bottom = "1%"
46 | logContainer.style.left = "1%"
47 | logContainer.style.wordWrap = "break-word"
48 | logContainer.style.whiteSpace = "pre-line"
49 |
50 | staticContainer.id = "staticContainer"
51 | staticContainer.style.color = "#ffffff"
52 | staticContainer.style.fontSize = "11pt"
53 | staticContainer.style.wordWrap = "break-word"
54 | staticContainer.style.left = "1%"
55 | staticContainer.style.whiteSpace = "pre-line"
56 |
57 | logBox.appendChild(logContainer)
58 | staticBox.appendChild(staticContainer)
59 | appElement.appendChild(logBox)
60 | appElement.appendChild(staticBox)
61 | }
62 |
63 | function setRootElement(root?: HTMLElement) {
64 | if (root) {
65 | appElement = root
66 | }
67 | }
68 |
69 | function renderDynamicLogs(dynamic: string[]) {
70 | if (logContainer) logContainer.textContent = dynamic.join("\n")
71 | }
72 |
73 | function renderStaticLogs(staticLogs: (Entry | null)[]) {
74 | staticLogs.forEach((entry) => renderStaticLog(entry))
75 | }
76 |
77 | function render({ dynamic: dynamicLogs, static: staticLogs }: { dynamic: string[]; static: (Entry | null)[] }) {
78 | renderDynamicLogs(dynamicLogs)
79 | renderStaticLogs(staticLogs)
80 | }
81 |
82 | function renderStaticLog(entry: Entry | null) {
83 | if (entry == null) {
84 | return
85 | }
86 |
87 | const { id, key, value } = entry
88 |
89 | const existingElement = document.querySelector(`#${id}`)
90 |
91 | const text = `${key}: ${value}`
92 |
93 | if (existingElement == null) {
94 | createNewStaticElement(entry)
95 |
96 | return
97 | }
98 |
99 | if (existingElement.textContent === text) {
100 | return
101 | }
102 |
103 | existingElement.textContent = text
104 | }
105 |
106 | function createNewStaticElement({ id, key, value }: Entry) {
107 | const staticLog = document.createElement("div")
108 |
109 | staticLog.id = id
110 | staticLog.style.paddingBottom = "1%"
111 | staticLog.style.borderBottom = "1px solid white"
112 | staticLog.textContent = `${key}: ${value}`
113 |
114 | staticContainer?.appendChild(staticLog)
115 | }
116 |
117 | function tearDown() {
118 | DOMHelpers.safeRemoveElement(logBox)
119 | DOMHelpers.safeRemoveElement(staticBox)
120 |
121 | appElement = undefined
122 | staticContainer = undefined
123 | logContainer = undefined
124 | logBox = undefined
125 | }
126 |
127 | export default {
128 | init,
129 | setRootElement,
130 | render,
131 | tearDown,
132 | }
133 |
--------------------------------------------------------------------------------
/src/domhelpers.test.js:
--------------------------------------------------------------------------------
1 | import DOMHelpers from "./domhelpers"
2 |
3 | describe("DOMHelpers", () => {
4 | it("Converts an RGBA tuple string to RGB tripple string", () => {
5 | expect(DOMHelpers.rgbaToRGB("#FFAAFFAA")).toBe("#FFAAFF")
6 | })
7 |
8 | it("Will return a RGB as it is", () => {
9 | expect(DOMHelpers.rgbaToRGB("#FFAAFF")).toBe("#FFAAFF")
10 | })
11 |
12 | it("Will return a non-RGBA as it is", () => {
13 | expect(DOMHelpers.rgbaToRGB("black")).toBe("black")
14 | })
15 |
16 | it("Will delete a node which has a parent", () => {
17 | const body = document.createElement("body")
18 | const child = document.createElement("p")
19 | body.appendChild(child)
20 |
21 | DOMHelpers.safeRemoveElement(child)
22 |
23 | expect(body.hasChildNodes()).toBe(false)
24 | })
25 |
26 | it("Will do nothing when the node is detatched", () => {
27 | const node = document.createElement("p")
28 |
29 | expect(() => {
30 | DOMHelpers.safeRemoveElement(node)
31 | }).not.toThrow()
32 | })
33 | })
34 |
--------------------------------------------------------------------------------
/src/domhelpers.ts:
--------------------------------------------------------------------------------
1 | function addClass(el: HTMLElement, className: string) {
2 | if (el.classList) {
3 | el.classList.add(className)
4 | } else {
5 | el.className += ` ${className}`
6 | }
7 | }
8 |
9 | function removeClass(el: HTMLElement, className: string) {
10 | if (el.classList) {
11 | el.classList.remove(className)
12 | } else {
13 | el.className = el.className.replace(new RegExp(`(^|\\b)${className.split(" ").join("|")}(\\b|$)`, "gi"), " ")
14 | }
15 | }
16 |
17 | function hasClass(el: HTMLElement, className: string) {
18 | return el.classList ? el.classList.contains(className) : new RegExp(`(^| )${className}( |$)`, "gi").test(el.className)
19 | }
20 |
21 | function isRGBA(rgbaString: string) {
22 | return new RegExp("^#([A-Fa-f0-9]{8})$").test(rgbaString)
23 | }
24 |
25 | /**
26 | * Checks that the string is an RGBA tuple and returns a RGB Tripple.
27 | * A string that isn't an RGBA tuple will be returned to the caller.
28 | */
29 | function rgbaToRGB(rgbaString: string) {
30 | return isRGBA(rgbaString) ? rgbaString.slice(0, 7) : rgbaString
31 | }
32 |
33 | /**
34 | * Safely removes an element from the DOM, simply doing
35 | * nothing if the node is detached (Has no parent).
36 | * @param el The Element to remove
37 | */
38 | function safeRemoveElement(el?: Element) {
39 | if (el && el.parentNode) {
40 | el.parentNode.removeChild(el)
41 | }
42 | }
43 |
44 | export default {
45 | addClass,
46 | removeClass,
47 | hasClass,
48 | rgbaToRGB,
49 | isRGBA,
50 | safeRemoveElement,
51 | }
52 |
--------------------------------------------------------------------------------
/src/dynamicwindowutils.test.js:
--------------------------------------------------------------------------------
1 | import { autoResumeAtStartOfRange, canPauseAndSeek } from "./dynamicwindowutils"
2 | import LiveSupport from "./models/livesupport"
3 |
4 | describe("autoResumeAtStartOfRange", () => {
5 | const currentTime = 20
6 |
7 | const seekableRange = {
8 | start: 0,
9 | end: 7200,
10 | }
11 |
12 | let resume
13 | let addEventCallback
14 | let removeEventCallback
15 | let checkNotPauseEvent
16 |
17 | afterAll(() => {
18 | jest.useRealTimers()
19 | })
20 |
21 | beforeAll(() => {
22 | jest.useFakeTimers()
23 | })
24 |
25 | beforeEach(() => {
26 | jest.clearAllTimers()
27 |
28 | resume = jest.fn()
29 | addEventCallback = jest.fn()
30 | removeEventCallback = jest.fn()
31 | checkNotPauseEvent = jest.fn()
32 | })
33 |
34 | it.each([
35 | [0, 7200, 20],
36 | [3600, 10800, 3620],
37 | ])(
38 | "resumes play when the start of the seekable range (%d - %d) catches up to current time %d",
39 | (seekableRangeStart, seekableRangeEnd, currentTime) => {
40 | const seekableRange = {
41 | start: seekableRangeStart,
42 | end: seekableRangeEnd,
43 | }
44 |
45 | autoResumeAtStartOfRange(currentTime, seekableRange, addEventCallback, removeEventCallback, undefined, resume)
46 |
47 | jest.advanceTimersByTime(20000)
48 |
49 | expect(addEventCallback).toHaveBeenCalledTimes(1)
50 | expect(removeEventCallback).toHaveBeenCalledTimes(1)
51 | expect(resume).toHaveBeenCalledTimes(1)
52 | }
53 | )
54 |
55 | it("resumes play when the start of the seekable range is within a threshold of current time", () => {
56 | autoResumeAtStartOfRange(currentTime, seekableRange, addEventCallback, removeEventCallback, undefined, resume)
57 |
58 | jest.advanceTimersByTime(15000)
59 |
60 | expect(addEventCallback).toHaveBeenCalledTimes(1)
61 | expect(removeEventCallback).toHaveBeenCalledTimes(1)
62 | expect(resume).toHaveBeenCalledTimes(1)
63 | })
64 |
65 | it("resumes play when the start of the seekable range is at the threshold of current time", () => {
66 | autoResumeAtStartOfRange(currentTime, seekableRange, addEventCallback, removeEventCallback, undefined, resume)
67 |
68 | jest.advanceTimersByTime(12000)
69 |
70 | expect(addEventCallback).toHaveBeenCalledTimes(1)
71 | expect(removeEventCallback).toHaveBeenCalledTimes(1)
72 | expect(resume).toHaveBeenCalledTimes(1)
73 | })
74 |
75 | it("resumes play when the start of the time shift buffer is at the threshold of current time", () => {
76 | const seekableRange = { start: 0, end: 7170 }
77 |
78 | autoResumeAtStartOfRange(30, seekableRange, addEventCallback, removeEventCallback, undefined, resume, 7200)
79 |
80 | expect(addEventCallback).toHaveBeenCalledTimes(1)
81 |
82 | jest.advanceTimersByTime(40000)
83 |
84 | expect(resume).not.toHaveBeenCalled()
85 | expect(removeEventCallback).not.toHaveBeenCalled()
86 |
87 | jest.advanceTimersByTime(20000)
88 |
89 | expect(resume).toHaveBeenCalledTimes(1)
90 | expect(removeEventCallback).toHaveBeenCalledTimes(1)
91 | })
92 |
93 | it("does not resume play when the start of the seekable range has not caught up to current time", () => {
94 | autoResumeAtStartOfRange(currentTime, seekableRange, addEventCallback, removeEventCallback, undefined, resume)
95 |
96 | jest.advanceTimersByTime(10000)
97 |
98 | expect(addEventCallback).toHaveBeenCalledTimes(1)
99 | expect(removeEventCallback).toHaveBeenCalledTimes(0)
100 | expect(resume).toHaveBeenCalledTimes(0)
101 | })
102 |
103 | it("non pause event stops autoresume", () => {
104 | checkNotPauseEvent.mockImplementation(() => true)
105 |
106 | addEventCallback.mockImplementation((_, callback) => callback())
107 |
108 | autoResumeAtStartOfRange(
109 | currentTime,
110 | seekableRange,
111 | addEventCallback,
112 | removeEventCallback,
113 | checkNotPauseEvent,
114 | resume
115 | )
116 |
117 | jest.advanceTimersByTime(20000)
118 |
119 | expect(removeEventCallback).toHaveBeenCalledTimes(1)
120 | expect(resume).toHaveBeenCalledTimes(0)
121 | })
122 |
123 | it("pause event does not stop autoresume", () => {
124 | checkNotPauseEvent.mockImplementation(() => false)
125 |
126 | addEventCallback.mockImplementation((_, callback) => callback())
127 |
128 | autoResumeAtStartOfRange(
129 | currentTime,
130 | seekableRange,
131 | addEventCallback,
132 | removeEventCallback,
133 | checkNotPauseEvent,
134 | resume
135 | )
136 |
137 | jest.advanceTimersByTime(20000)
138 |
139 | expect(removeEventCallback).toHaveBeenCalledTimes(1)
140 | expect(resume).toHaveBeenCalledTimes(1)
141 | })
142 | })
143 |
144 | describe("canPause", () => {
145 | it("can't pause no live support", () => {
146 | expect(canPauseAndSeek(LiveSupport.NONE, { start: 0, end: 30 * 60 })).toBe(false)
147 | })
148 |
149 | it("can't pause playable", () => {
150 | expect(canPauseAndSeek(LiveSupport.PLAYABLE, { start: 0, end: 30 * 60 })).toBe(false)
151 | })
152 |
153 | it("can't pause restartable", () => {
154 | expect(canPauseAndSeek(LiveSupport.RESTARTABLE, { start: 0, end: 30 * 60 })).toBe(false)
155 | })
156 |
157 | it("can pause seekable", () => {
158 | expect(canPauseAndSeek(LiveSupport.SEEKABLE, { start: 0, end: 30 * 60 })).toBe(true)
159 | })
160 |
161 | it("can't pause a seekable range less than 4 minutes", () => {
162 | expect(canPauseAndSeek(LiveSupport.SEEKABLE, { start: 0, end: 3 * 60 })).toBe(false)
163 | })
164 | })
165 |
--------------------------------------------------------------------------------
/src/dynamicwindowutils.ts:
--------------------------------------------------------------------------------
1 | import LiveSupport from "./models/livesupport"
2 | import DebugTool from "./debugger/debugtool"
3 | import PlaybackStrategy from "./models/playbackstrategy"
4 |
5 | const AUTO_RESUME_WINDOW_START_CUSHION_SECONDS = 8
6 | const FOUR_MINUTES = 4 * 60
7 |
8 | declare global {
9 | interface Window {
10 | bigscreenPlayer?: {
11 | playbackStrategy: PlaybackStrategy
12 | liveSupport?: LiveSupport
13 | }
14 | }
15 | }
16 |
17 | type SeekableRange = {
18 | start: number
19 | end: number
20 | }
21 |
22 | function isSeekableRange(obj: unknown): obj is SeekableRange {
23 | return (
24 | obj != null &&
25 | typeof obj === "object" &&
26 | "start" in obj &&
27 | "end" in obj &&
28 | typeof obj.start === "number" &&
29 | typeof obj.end === "number" &&
30 | isFinite(obj.start) &&
31 | isFinite(obj.end)
32 | )
33 | }
34 |
35 | function isSeekableRangeBigEnough({ start, end }: SeekableRange): boolean {
36 | return end - start > FOUR_MINUTES
37 | }
38 |
39 | export function canPauseAndSeek(liveSupport: LiveSupport, seekableRange: unknown): boolean {
40 | return (
41 | liveSupport === LiveSupport.SEEKABLE && isSeekableRange(seekableRange) && isSeekableRangeBigEnough(seekableRange)
42 | )
43 | }
44 |
45 | export function autoResumeAtStartOfRange(
46 | currentTime: number,
47 | seekableRange: SeekableRange,
48 | addEventCallback: (thisArg: undefined, callback: (event: unknown) => void) => void,
49 | removeEventCallback: (thisArg: undefined, callback: (event: unknown) => void) => void,
50 | checkNotPauseEvent: (event: unknown) => boolean,
51 | resume: () => void,
52 | timeShiftBufferDepthInSeconds?: number
53 | ): void {
54 | const { start, end } = seekableRange
55 |
56 | const duration = end - start
57 |
58 | const windowLengthInSeconds =
59 | timeShiftBufferDepthInSeconds && duration < timeShiftBufferDepthInSeconds ? timeShiftBufferDepthInSeconds : duration
60 |
61 | const resumeTimeOut = Math.max(
62 | 0,
63 | windowLengthInSeconds - (end - currentTime) - AUTO_RESUME_WINDOW_START_CUSHION_SECONDS
64 | )
65 |
66 | DebugTool.dynamicMetric("auto-resume", resumeTimeOut)
67 |
68 | const autoResumeTimer = setTimeout(() => {
69 | removeEventCallback(undefined, detectIfUnpaused)
70 | resume()
71 | }, resumeTimeOut * 1000)
72 |
73 | addEventCallback(undefined, detectIfUnpaused)
74 |
75 | function detectIfUnpaused(event: unknown) {
76 | if (checkNotPauseEvent(event)) {
77 | removeEventCallback(undefined, detectIfUnpaused)
78 | clearTimeout(autoResumeTimer)
79 | }
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | export { default as BigscreenPlayer } from "./bigscreenplayer"
2 | export { default as MockBigscreenPlayer } from "./mockbigscreenplayer"
3 | export { default as LiveSupport } from "./models/livesupport"
4 | export { ManifestType } from "./models/manifesttypes"
5 | export { default as MediaKinds } from "./models/mediakinds"
6 | export { default as MediaState } from "./models/mediastate"
7 | export { default as PauseTriggers } from "./models/pausetriggers"
8 | export { default as PlaybackStrategy } from "./models/playbackstrategy"
9 | export { TransferFormat } from "./models/transferformats"
10 | export { Timeline } from "./models/timeline"
11 | export { default as TransportControlPosition } from "./models/transportcontrolposition"
12 | export { default as WindowTypes } from "./models/windowtypes"
13 | export { default as DebugTool } from "./debugger/debugtool"
14 | export * from "./debugger/chronicle"
15 |
--------------------------------------------------------------------------------
/src/manifest/manifestmodifier.js:
--------------------------------------------------------------------------------
1 | function filter(manifest, representationOptions) {
2 | const constantFps = representationOptions.constantFps
3 | const maxFps = representationOptions.maxFps
4 |
5 | if (constantFps || maxFps) {
6 | manifest.Period.AdaptationSet = manifest.Period.AdaptationSet.map((adaptationSet) => {
7 | if (adaptationSet.contentType === "video") {
8 | const frameRates = []
9 |
10 | adaptationSet.Representation_asArray = adaptationSet.Representation_asArray.filter((representation) => {
11 | if (!maxFps || representation.frameRate <= maxFps) {
12 | frameRates.push(representation.frameRate)
13 | return true
14 | }
15 | }).filter((representation) => {
16 | return !constantFps || representation.frameRate === Math.max.apply(null, frameRates)
17 | })
18 | }
19 | return adaptationSet
20 | })
21 | }
22 |
23 | return manifest
24 | }
25 |
26 | function extractBaseUrl(manifest) {
27 | if (manifest.Period && typeof manifest.Period.BaseURL === "string") {
28 | return manifest.Period.BaseURL
29 | }
30 |
31 | if (manifest.Period && manifest.Period.BaseURL && typeof manifest.Period.BaseURL.__text === "string") {
32 | return manifest.Period.BaseURL.__text
33 | }
34 |
35 | if (typeof manifest.BaseURL === "string") {
36 | return manifest.BaseURL
37 | }
38 |
39 | if (manifest.BaseURL && typeof manifest.BaseURL.__text === "string") {
40 | return manifest.BaseURL.__text
41 | }
42 | }
43 |
44 | function generateBaseUrls(manifest, sources) {
45 | if (!manifest) return
46 |
47 | const baseUrl = extractBaseUrl(manifest)
48 |
49 | if (isBaseUrlAbsolute(baseUrl)) {
50 | setAbsoluteBaseUrl(baseUrl)
51 | } else {
52 | if (baseUrl) {
53 | setBaseUrlsFromBaseUrl(baseUrl)
54 | } else {
55 | setBaseUrlsFromSource()
56 | }
57 | }
58 |
59 | removeUnusedPeriodAttributes()
60 |
61 | function generateBaseUrl(source, priority, serviceLocation) {
62 | return {
63 | __text: source,
64 | "dvb:priority": priority,
65 | "dvb:weight": isNaN(source.dpw) ? 0 : source.dpw,
66 | serviceLocation: serviceLocation,
67 | }
68 | }
69 |
70 | function removeUnusedPeriodAttributes() {
71 | if (manifest.Period && manifest.Period.BaseURL) delete manifest.Period.BaseURL
72 | if (manifest.Period && manifest.Period.BaseURL_asArray) delete manifest.Period.BaseURL_asArray
73 | }
74 |
75 | function isBaseUrlAbsolute(baseUrl) {
76 | return baseUrl && baseUrl.match(/^https?:\/\//)
77 | }
78 |
79 | function setAbsoluteBaseUrl(baseUrl) {
80 | const newBaseUrl = generateBaseUrl(baseUrl, 0, sources[0])
81 |
82 | manifest.BaseURL_asArray = [newBaseUrl]
83 |
84 | if (manifest.BaseURL || (manifest.Period && manifest.Period.BaseURL)) {
85 | manifest.BaseURL = newBaseUrl
86 | }
87 | }
88 |
89 | function setBaseUrlsFromBaseUrl(baseUrl) {
90 | manifest.BaseURL_asArray = sources.map((source, priority) => {
91 | const sourceUrl = new URL(baseUrl, source)
92 |
93 | return generateBaseUrl(sourceUrl.href, priority, source)
94 | })
95 | }
96 |
97 | function setBaseUrlsFromSource() {
98 | manifest.BaseURL_asArray = sources.map((source, priority) => {
99 | return generateBaseUrl(source, priority, source)
100 | })
101 | }
102 | }
103 |
104 | export default {
105 | filter: filter,
106 | extractBaseUrl: extractBaseUrl,
107 | generateBaseUrls: generateBaseUrls,
108 | }
109 |
--------------------------------------------------------------------------------
/src/manifest/manifestparser.ts:
--------------------------------------------------------------------------------
1 | import { durationToSeconds } from "../utils/timeutils"
2 | import DebugTool from "../debugger/debugtool"
3 | import Plugins from "../plugins"
4 | import PluginEnums from "../pluginenums"
5 | import { ManifestType } from "../models/manifesttypes"
6 | import { TransferFormat, DASH, HLS } from "../models/transferformats"
7 | import isError from "../utils/iserror"
8 | import { ErrorWithCode } from "../models/errorcode"
9 |
10 | export type TimeInfo = {
11 | manifestType: ManifestType
12 | presentationTimeOffsetInMilliseconds: number
13 | timeShiftBufferDepthInMilliseconds: number
14 | availabilityStartTimeInMilliseconds: number
15 | }
16 |
17 | function getMPDType(mpd: Element): ManifestType {
18 | const type = mpd.getAttribute("type")
19 |
20 | if (type !== ManifestType.STATIC && type !== ManifestType.DYNAMIC) {
21 | throw new TypeError(`MPD type attribute must be 'static' or 'dynamic'. Got ${type}`)
22 | }
23 |
24 | return type as ManifestType
25 | }
26 |
27 | function getMPDAvailabilityStartTimeInMilliseconds(mpd: Element): number {
28 | return Date.parse(mpd.getAttribute("availabilityStartTime") ?? "") || 0
29 | }
30 |
31 | function getMPDTimeShiftBufferDepthInMilliseconds(mpd: Element): number {
32 | return (durationToSeconds(mpd.getAttribute("timeShiftBufferDepth") ?? "") || 0) * 1000
33 | }
34 |
35 | function getMPDPresentationTimeOffsetInMilliseconds(mpd: Element): number {
36 | // Can be either audio or video data. It doesn't matter as we use the factor of x/timescale. This is the same for both.
37 | const segmentTemplate = mpd.querySelector("SegmentTemplate")
38 | const presentationTimeOffsetInFrames = parseFloat(segmentTemplate?.getAttribute("presentationTimeOffset") ?? "")
39 | const timescale = parseFloat(segmentTemplate?.getAttribute("timescale") ?? "")
40 |
41 | return (presentationTimeOffsetInFrames / timescale) * 1000 || 0
42 | }
43 |
44 | function parseMPD(manifestEl: Document): Promise {
45 | return new Promise((resolve, reject) => {
46 | const mpd = manifestEl.querySelector("MPD")
47 |
48 | if (mpd == null) {
49 | return reject(new TypeError("Could not find an 'MPD' tag in the document"))
50 | }
51 |
52 | const manifestType = getMPDType(mpd)
53 | const presentationTimeOffsetInMilliseconds = getMPDPresentationTimeOffsetInMilliseconds(mpd)
54 | const availabilityStartTimeInMilliseconds = getMPDAvailabilityStartTimeInMilliseconds(mpd)
55 | const timeShiftBufferDepthInMilliseconds = getMPDTimeShiftBufferDepthInMilliseconds(mpd)
56 |
57 | return resolve({
58 | manifestType,
59 | timeShiftBufferDepthInMilliseconds,
60 | availabilityStartTimeInMilliseconds,
61 | presentationTimeOffsetInMilliseconds,
62 | })
63 | }).catch((reason: unknown) => {
64 | const errorWithCode = (isError(reason) ? reason : new Error("manifest-dash-parse-error")) as ErrorWithCode
65 | errorWithCode.code = PluginEnums.ERROR_CODES.MANIFEST_PARSE
66 | throw errorWithCode
67 | })
68 | }
69 |
70 | function parseM3U8(manifest: string): Promise {
71 | return new Promise((resolve) => {
72 | const programDateTimeInMilliseconds = getM3U8ProgramDateTimeInMilliseconds(manifest)
73 | const durationInMilliseconds = getM3U8WindowSizeInMilliseconds(manifest)
74 |
75 | if (
76 | programDateTimeInMilliseconds == null ||
77 | durationInMilliseconds == null ||
78 | (programDateTimeInMilliseconds === 0 && durationInMilliseconds === 0)
79 | ) {
80 | throw new Error("manifest-hls-attributes-parse-error")
81 | }
82 |
83 | const manifestType = hasM3U8EndList(manifest) ? ManifestType.STATIC : ManifestType.DYNAMIC
84 |
85 | return resolve({
86 | manifestType,
87 | timeShiftBufferDepthInMilliseconds: 0,
88 | availabilityStartTimeInMilliseconds: manifestType === ManifestType.STATIC ? 0 : programDateTimeInMilliseconds,
89 | presentationTimeOffsetInMilliseconds: programDateTimeInMilliseconds,
90 | })
91 | }).catch((reason: unknown) => {
92 | const errorWithCode = (isError(reason) ? reason : new Error("manifest-hls-parse-error")) as ErrorWithCode
93 | errorWithCode.code = PluginEnums.ERROR_CODES.MANIFEST_PARSE
94 | throw errorWithCode
95 | })
96 | }
97 |
98 | function getM3U8ProgramDateTimeInMilliseconds(data: string) {
99 | const match = /^#EXT-X-PROGRAM-DATE-TIME:(.*)$/m.exec(data)
100 |
101 | if (match == null) {
102 | return 0
103 | }
104 |
105 | const parsedDate = Date.parse(match[1])
106 |
107 | return isNaN(parsedDate) ? null : parsedDate
108 | }
109 |
110 | function getM3U8WindowSizeInMilliseconds(data: string): number {
111 | const regex = /#EXTINF:(\d+(?:\.\d+)?)/g
112 | let matches = regex.exec(data)
113 | let result = 0
114 |
115 | while (matches) {
116 | result += +matches[1]
117 | matches = regex.exec(data)
118 | }
119 |
120 | return Math.floor(result * 1000)
121 | }
122 |
123 | function hasM3U8EndList(data: string): boolean {
124 | const match = /^#EXT-X-ENDLIST$/m.exec(data)
125 |
126 | return match != null
127 | }
128 |
129 | function parse({ body, type }: { body: Document; type: DASH } | { body: string; type: HLS }): Promise {
130 | return Promise.resolve()
131 | .then(() => {
132 | switch (type) {
133 | case TransferFormat.DASH:
134 | return parseMPD(body)
135 | case TransferFormat.HLS:
136 | return parseM3U8(body)
137 | }
138 | })
139 | .catch((error: ErrorWithCode) => {
140 | DebugTool.error(error)
141 | Plugins.interface.onManifestParseError({ code: error.code, message: error.message })
142 |
143 | return {
144 | manifestType: ManifestType.STATIC,
145 | timeShiftBufferDepthInMilliseconds: 0,
146 | availabilityStartTimeInMilliseconds: 0,
147 | presentationTimeOffsetInMilliseconds: 0,
148 | }
149 | })
150 | }
151 |
152 | export default {
153 | parse,
154 | }
155 |
--------------------------------------------------------------------------------
/src/manifest/sourceloader.ts:
--------------------------------------------------------------------------------
1 | import ManifestParser, { TimeInfo } from "./manifestparser"
2 | import { ManifestType } from "../models/manifesttypes"
3 | import { TransferFormat } from "../models/transferformats"
4 | import LoadUrl from "../utils/loadurl"
5 | import isError from "../utils/iserror"
6 |
7 | function parseXmlString(text: string): Document {
8 | const parser = new DOMParser()
9 |
10 | const document = parser.parseFromString(text, "application/xml")
11 |
12 | // DOMParser lists the XML errors in the document
13 | if (document.querySelector("parsererror") != null) {
14 | throw new TypeError(`Failed to parse input string to XML`)
15 | }
16 |
17 | return document
18 | }
19 |
20 | function retrieveDashManifest(url: string) {
21 | return new Promise((resolveLoad, rejectLoad) =>
22 | LoadUrl(url, {
23 | method: "GET",
24 | headers: {},
25 | timeout: 10000,
26 | // Try to parse ourselves if the XHR parser failed due to f.ex. content-type
27 | onLoad: (responseXML, responseText) => resolveLoad(responseXML || parseXmlString(responseText)),
28 | onError: () => rejectLoad(new Error("Network error: Unable to retrieve DASH manifest")),
29 | })
30 | )
31 | .then((xml) => {
32 | if (xml == null) {
33 | throw new TypeError("Unable to retrieve DASH XML response")
34 | }
35 |
36 | return ManifestParser.parse({ body: xml, type: TransferFormat.DASH })
37 | })
38 | .then((time) => ({ time, transferFormat: TransferFormat.DASH }))
39 | .catch((error) => {
40 | if (isError(error) && error.message.indexOf("DASH") !== -1) {
41 | throw error
42 | }
43 |
44 | throw new Error("Unable to retrieve DASH XML response")
45 | })
46 | }
47 |
48 | function retrieveHLSManifest(url: string) {
49 | return new Promise((resolveLoad, rejectLoad) =>
50 | LoadUrl(url, {
51 | method: "GET",
52 | headers: {},
53 | timeout: 10000,
54 | onLoad: (_, responseText) => resolveLoad(responseText),
55 | onError: () => rejectLoad(new Error("Network error: Unable to retrieve HLS master playlist")),
56 | })
57 | ).then((text) => {
58 | if (!text || typeof text !== "string") {
59 | throw new TypeError("Unable to retrieve HLS master playlist")
60 | }
61 |
62 | let streamUrl = getStreamUrl(text)
63 |
64 | if (!streamUrl || typeof streamUrl !== "string") {
65 | throw new TypeError("Unable to retrieve playlist url from HLS master playlist")
66 | }
67 |
68 | if (streamUrl.indexOf("http") !== 0) {
69 | const parts = url.split("/")
70 |
71 | parts.pop()
72 | parts.push(streamUrl)
73 | streamUrl = parts.join("/")
74 | }
75 |
76 | return retrieveHLSLivePlaylist(streamUrl)
77 | })
78 | }
79 |
80 | function retrieveHLSLivePlaylist(url: string) {
81 | return new Promise((resolveLoad, rejectLoad) =>
82 | LoadUrl(url, {
83 | method: "GET",
84 | headers: {},
85 | timeout: 10000,
86 | onLoad: (_, responseText) => resolveLoad(responseText),
87 | onError: () => rejectLoad(new Error("Network error: Unable to retrieve HLS live playlist")),
88 | })
89 | )
90 | .then((text) => {
91 | if (!text || typeof text !== "string") {
92 | throw new TypeError("Unable to retrieve HLS live playlist")
93 | }
94 |
95 | return ManifestParser.parse({ body: text, type: TransferFormat.HLS })
96 | })
97 | .then((time) => ({ time, transferFormat: TransferFormat.HLS }))
98 | }
99 |
100 | function getStreamUrl(data: string) {
101 | const match = /#EXT-X-STREAM-INF:.*[\n\r]+(.*)[\n\r]?/.exec(data)
102 |
103 | return match ? match[1] : null
104 | }
105 |
106 | export default {
107 | load: (mediaUrl: string): Promise<{ transferFormat: TransferFormat; time: TimeInfo }> => {
108 | if (/\.mpd(\?.*)?$/.test(mediaUrl)) {
109 | return retrieveDashManifest(mediaUrl)
110 | }
111 |
112 | if (/\.m3u8(\?.*)?$/.test(mediaUrl)) {
113 | return retrieveHLSManifest(mediaUrl)
114 | }
115 |
116 | if (/\.mp4(\?.*)?$/.test(mediaUrl)) {
117 | return Promise.resolve({
118 | time: {
119 | manifestType: ManifestType.STATIC,
120 | presentationTimeOffsetInMilliseconds: 0,
121 | timeShiftBufferDepthInMilliseconds: 0,
122 | availabilityStartTimeInMilliseconds: 0,
123 | },
124 | transferFormat: TransferFormat.PLAIN,
125 | })
126 | }
127 |
128 | return Promise.reject(new Error("Invalid media url"))
129 | },
130 | }
131 |
--------------------------------------------------------------------------------
/src/manifest/stubData/hlsmanifests.ts:
--------------------------------------------------------------------------------
1 | const HLS_MANIFESTS = {
2 | INVALID_PROGRAM_DATETIME:
3 | "#EXTM3U\n" +
4 | "#EXT-X-VERSION:2\n" +
5 | "#EXT-X-MEDIA-SEQUENCE:179532414\n" +
6 | "#EXT-X-TARGETDURATION:8\n" +
7 | "#USP-X-TIMESTAMP-MAP:MPEGTS=2003059584,LOCAL=2015-07-07T08:55:10Z\n" +
8 | "#EXT-X-PROGRAM-DATE-TIME:invaliddatetime\n" +
9 | "#EXTINF:8, no desc\n" +
10 | "content-audio_2=96000-video=1374000-179532414.ts\n",
11 | VALID_PROGRAM_DATETIME_NO_ENDLIST:
12 | "#EXTM3U\n" +
13 | "#EXT-X-VERSION:3\n" +
14 | "#EXT-X-INDEPENDENT-SEGMENTS\n" +
15 | "#EXT-X-TARGETDURATION:4\n" +
16 | "#EXT-X-MEDIA-SEQUENCE:450795161\n" +
17 | "#EXT-X-PROGRAM-DATE-TIME:2024-11-08T08:00:00.0Z\n" +
18 | "#EXTINF:3.84\n" +
19 | "450795161.ts\n" +
20 | "#EXTINF:3.84\n" +
21 | "450795162.ts\n" +
22 | "#EXTINF:3.84\n" +
23 | "450795163.ts\n" +
24 | "#EXTINF:3.84\n" +
25 | "450795164.ts\n",
26 | VALID_PROGRAM_DATETIME_AND_ENDLIST:
27 | "#EXTM3U\n" +
28 | "#EXT-X-VERSION:3\n" +
29 | "#EXT-X-INDEPENDENT-SEGMENTS\n" +
30 | "#EXT-X-PLAYLIST-TYPE:VOD\n" +
31 | "#EXT-X-TARGETDURATION:4\n" +
32 | "#EXT-X-MEDIA-SEQUENCE:450795161\n" +
33 | "#EXT-X-PROGRAM-DATE-TIME:2024-11-08T06:00:00Z\n" +
34 | "#EXTINF:3.84\n" +
35 | "450793126.ts\n" +
36 | "#EXTINF:3.84\n" +
37 | "450793127.ts\n" +
38 | "#EXTINF:3.84\n" +
39 | "450793128.ts\n" +
40 | "#EXTINF:3.84\n" +
41 | "450793129.ts\n" +
42 | "#EXT-X-ENDLIST\n",
43 | NO_PROGRAM_DATETIME_ENDLIST:
44 | "#EXTM3U\n" +
45 | "#EXT-X-VERSION:3\n" +
46 | "#EXT-X-TARGETDURATION:8\n" +
47 | "#EXT-X-MEDIA-SEQUENCE:1\n" +
48 | "#USP-X-TIMESTAMP-MAP:MPEGTS=900000,LOCAL=1970-01-01T00:00:00Z\n" +
49 | "#EXTINF:8\n" +
50 | "segment-1.ts\n" +
51 | "#EXTINF:7\n" +
52 | "segment-2.ts\n" +
53 | "#EXTINF:8\n" +
54 | "segment-3.ts\n" +
55 | "#EXTINF:8\n" +
56 | "segment-4.ts\n" +
57 | "#EXT-X-ENDLIST\n",
58 | }
59 |
60 | export default HLS_MANIFESTS
61 |
--------------------------------------------------------------------------------
/src/models/errorcode.ts:
--------------------------------------------------------------------------------
1 | export interface ErrorWithCode extends Error {
2 | code: number
3 | }
4 |
--------------------------------------------------------------------------------
/src/models/livesupport.ts:
--------------------------------------------------------------------------------
1 | export const LiveSupport = {
2 | NONE: "none",
3 | PLAYABLE: "playable",
4 | RESTARTABLE: "restartable",
5 | SEEKABLE: "seekable",
6 | } as const
7 |
8 | export type LiveSupport = (typeof LiveSupport)[keyof typeof LiveSupport]
9 |
10 | export default LiveSupport
11 |
--------------------------------------------------------------------------------
/src/models/manifesttypes.ts:
--------------------------------------------------------------------------------
1 | export const ManifestType = {
2 | STATIC: "static",
3 | DYNAMIC: "dynamic",
4 | } as const
5 |
6 | export type ManifestType = (typeof ManifestType)[keyof typeof ManifestType]
7 |
--------------------------------------------------------------------------------
/src/models/mediakinds.ts:
--------------------------------------------------------------------------------
1 | const AUDIO = "audio" as const
2 | const VIDEO = "video" as const
3 |
4 | export const MediaKinds = { AUDIO, VIDEO } as const
5 |
6 | export type Audio = typeof AUDIO
7 | export type Video = typeof VIDEO
8 | export type MediaKinds = (typeof MediaKinds)[keyof typeof MediaKinds]
9 |
10 | export default MediaKinds
11 |
--------------------------------------------------------------------------------
/src/models/mediastate.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Provides an enumeration of possible media states.
3 | */
4 | export const MediaState = {
5 | /** Media is stopped and is not attempting to start. */
6 | STOPPED: 0,
7 | /** Media is paused. */
8 | PAUSED: 1,
9 | /** Media is playing successfully. */
10 | PLAYING: 2,
11 | /** Media is waiting for data (buffering). */
12 | WAITING: 4,
13 | /** Media has ended. */
14 | ENDED: 5,
15 | /** Media has thrown a fatal error. */
16 | FATAL_ERROR: 6,
17 | } as const
18 |
19 | export type MediaState = (typeof MediaState)[keyof typeof MediaState]
20 |
21 | export default MediaState
22 |
--------------------------------------------------------------------------------
/src/models/pausetriggers.ts:
--------------------------------------------------------------------------------
1 | export const PauseTriggers = {
2 | USER: 1,
3 | APP: 2,
4 | DEVICE: 3,
5 | } as const
6 |
7 | export type PauseTriggers = (typeof PauseTriggers)[keyof typeof PauseTriggers]
8 |
9 | export default PauseTriggers
10 |
--------------------------------------------------------------------------------
/src/models/playbackstrategy.ts:
--------------------------------------------------------------------------------
1 | export const PlaybackStrategy = {
2 | MSE: "msestrategy",
3 | NATIVE: "nativestrategy",
4 | BASIC: "basicstrategy",
5 | } as const
6 |
7 | export type PlaybackStrategy = (typeof PlaybackStrategy)[keyof typeof PlaybackStrategy]
8 |
9 | export default PlaybackStrategy
10 |
--------------------------------------------------------------------------------
/src/models/timeline.ts:
--------------------------------------------------------------------------------
1 | export const Timeline = {
2 | AVAILABILITY_TIME: "availabilityTime",
3 | MEDIA_SAMPLE_TIME: "mediaSampleTime",
4 | PRESENTATION_TIME: "presentationTime",
5 | } as const
6 |
7 | export type Timeline = (typeof Timeline)[keyof typeof Timeline]
8 |
--------------------------------------------------------------------------------
/src/models/transferformats.ts:
--------------------------------------------------------------------------------
1 | const DASH = "dash" as const
2 |
3 | const HLS = "hls" as const
4 |
5 | const PLAIN = "plain" as const
6 |
7 | export const TransferFormat = { DASH, HLS, PLAIN } as const
8 |
9 | export type DASH = typeof DASH
10 |
11 | export type HLS = typeof HLS
12 |
13 | export type PLAIN = typeof PLAIN
14 |
15 | export type TransferFormat = (typeof TransferFormat)[keyof typeof TransferFormat]
16 |
--------------------------------------------------------------------------------
/src/models/transportcontrolposition.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Provides an enumeration of on-screen transport control positions, which can be combined as flags.
3 | */
4 | export const TransportControlPosition = {
5 | /** No transport controls are visible. */
6 | NONE: 0,
7 | /** The basic transport controls are visible. */
8 | CONTROLS_ONLY: 1,
9 | /** The transport controls are visible with an expanded info area. */
10 | CONTROLS_WITH_INFO: 2,
11 | /** The left-hand onwards navigation carousel is visible. */
12 | LEFT_CAROUSEL: 4,
13 | /** The bottom-right onwards navigation carousel is visible. */
14 | BOTTOM_CAROUSEL: 8,
15 | /** The whole screen is obscured by a navigation menu. */
16 | FULLSCREEN: 16,
17 | } as const
18 |
19 | export type TransportControlPosition = (typeof TransportControlPosition)[keyof typeof TransportControlPosition]
20 |
21 | export default TransportControlPosition
22 |
--------------------------------------------------------------------------------
/src/models/windowtypes.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Enums for WindowTypes
3 | * @readonly
4 | * @enum {string}
5 | */
6 | export const WindowTypes = {
7 | /** Media with a duration */
8 | STATIC: "staticWindow",
9 | /** Media with a start time but without a duration until an indeterminate time in the future */
10 | GROWING: "growingWindow",
11 | /** Media with a rewind window that progresses through a media timeline */
12 | SLIDING: "slidingWindow",
13 | } as const
14 |
15 | export type WindowTypes = (typeof WindowTypes)[keyof typeof WindowTypes]
16 |
17 | export default WindowTypes
18 |
--------------------------------------------------------------------------------
/src/playbackstrategy/liveglitchcurtain.js:
--------------------------------------------------------------------------------
1 | import DOMHelpers from "../domhelpers"
2 |
3 | function LiveGlitchCurtain(parentElement) {
4 | let curtain = document.createElement("div")
5 |
6 | curtain.id = "liveGlitchCurtain"
7 | curtain.style.display = "none"
8 | curtain.style.position = "absolute"
9 | curtain.style.top = 0
10 | curtain.style.left = 0
11 | curtain.style.right = 0
12 | curtain.style.bottom = 0
13 | curtain.style.backgroundColor = "#3c3c3c"
14 |
15 | return {
16 | showCurtain: () => {
17 | curtain.style.display = "block"
18 | parentElement.appendChild(curtain)
19 | },
20 |
21 | hideCurtain: () => {
22 | curtain.style.display = "none"
23 | },
24 |
25 | tearDown: () => {
26 | DOMHelpers.safeRemoveElement(curtain)
27 | },
28 | }
29 | }
30 |
31 | export default LiveGlitchCurtain
32 |
--------------------------------------------------------------------------------
/src/playbackstrategy/modifiers/live/none.js:
--------------------------------------------------------------------------------
1 | function None() {
2 | throw new Error("Cannot create a none live support player")
3 | }
4 |
5 | export default None
6 |
--------------------------------------------------------------------------------
/src/playbackstrategy/modifiers/live/playable.js:
--------------------------------------------------------------------------------
1 | import MediaPlayerBase from "../mediaplayerbase"
2 |
3 | function PlayableLivePlayer(mediaPlayer) {
4 | return {
5 | initialiseMedia: (mediaType, sourceUrl, mimeType, sourceContainer, opts) => {
6 | const liveMediaType =
7 | mediaType === MediaPlayerBase.TYPE.AUDIO ? MediaPlayerBase.TYPE.LIVE_AUDIO : MediaPlayerBase.TYPE.LIVE_VIDEO
8 |
9 | mediaPlayer.initialiseMedia(liveMediaType, sourceUrl, mimeType, sourceContainer, opts)
10 | },
11 |
12 | beginPlayback: () => mediaPlayer.beginPlayback(),
13 | stop: () => mediaPlayer.stop(),
14 | reset: () => mediaPlayer.reset(),
15 | getState: () => mediaPlayer.getState(),
16 | getSource: () => mediaPlayer.getSource(),
17 | getMimeType: () => mediaPlayer.getMimeType(),
18 | getPlayerElement: () => mediaPlayer.getPlayerElement(),
19 |
20 | addEventCallback: (thisArg, callback) => mediaPlayer.addEventCallback(thisArg, callback),
21 | removeEventCallback: (callback) => mediaPlayer.removeEventCallback(callback),
22 | removeAllEventCallbacks: () => mediaPlayer.removeAllEventCallbacks(),
23 | }
24 | }
25 |
26 | export default PlayableLivePlayer
27 |
--------------------------------------------------------------------------------
/src/playbackstrategy/modifiers/live/playable.test.js:
--------------------------------------------------------------------------------
1 | import MediaPlayerBase from "../mediaplayerbase"
2 | import PlayableMediaPlayer from "./playable"
3 |
4 | describe("Playable HMTL5 Live Player", () => {
5 | const callback = () => {}
6 | const sourceContainer = document.createElement("div")
7 |
8 | let player
9 | let playableMediaPlayer
10 |
11 | function wrapperTests(action, expectedReturn) {
12 | if (expectedReturn) {
13 | player[action].mockReturnValue(expectedReturn)
14 |
15 | expect(playableMediaPlayer[action]()).toBe(expectedReturn)
16 | } else {
17 | playableMediaPlayer[action]()
18 |
19 | expect(player[action]).toHaveBeenCalledTimes(1)
20 | }
21 | }
22 |
23 | function isUndefined(action) {
24 | expect(playableMediaPlayer[action]).toBeUndefined()
25 | }
26 |
27 | beforeEach(() => {
28 | player = {
29 | beginPlayback: jest.fn(),
30 | initialiseMedia: jest.fn(),
31 | stop: jest.fn(),
32 | reset: jest.fn(),
33 | getState: jest.fn(),
34 | getSource: jest.fn(),
35 | getMimeType: jest.fn(),
36 | addEventCallback: jest.fn(),
37 | removeEventCallback: jest.fn(),
38 | removeAllEventCallbacks: jest.fn(),
39 | getPlayerElement: jest.fn(),
40 | }
41 |
42 | playableMediaPlayer = PlayableMediaPlayer(player)
43 | })
44 |
45 | it("calls beginPlayback on the media player", () => {
46 | wrapperTests("beginPlayback")
47 | })
48 |
49 | it("calls initialiseMedia on the media player", () => {
50 | wrapperTests("initialiseMedia")
51 | })
52 |
53 | it("calls stop on the media player", () => {
54 | wrapperTests("stop")
55 | })
56 |
57 | it("calls reset on the media player", () => {
58 | wrapperTests("reset")
59 | })
60 |
61 | it("calls getState on the media player", () => {
62 | wrapperTests("getState", "thisState")
63 | })
64 |
65 | it("calls getSource on the media player", () => {
66 | wrapperTests("getSource", "thisSource")
67 | })
68 |
69 | it("calls getMimeType on the media player", () => {
70 | wrapperTests("getMimeType", "thisMimeType")
71 | })
72 |
73 | it("calls addEventCallback on the media player", () => {
74 | const thisArg = "arg"
75 |
76 | playableMediaPlayer.addEventCallback(thisArg, callback)
77 |
78 | expect(player.addEventCallback).toHaveBeenCalledWith(thisArg, callback)
79 | })
80 |
81 | it("calls removeEventCallback on the media player", () => {
82 | playableMediaPlayer.removeEventCallback(callback)
83 |
84 | expect(player.removeEventCallback).toHaveBeenCalledWith(callback)
85 | })
86 |
87 | it("calls removeAllEventCallbacks on the media player", () => {
88 | wrapperTests("removeAllEventCallbacks")
89 | })
90 |
91 | it("calls getPlayerElement on the media player", () => {
92 | wrapperTests("getPlayerElement", "thisPlayerElement")
93 | })
94 |
95 | describe("should not have methods for", () => {
96 | it("beginPlaybackFrom", () => {
97 | isUndefined("beginPlaybackFrom")
98 | })
99 |
100 | it("playFrom", () => {
101 | isUndefined("playFrom")
102 | })
103 |
104 | it("pause", () => {
105 | isUndefined("pause")
106 | })
107 |
108 | it("resume", () => {
109 | isUndefined("resume")
110 | })
111 |
112 | it("getCurrentTime", () => {
113 | isUndefined("getCurrentTime")
114 | })
115 |
116 | it("getSeekableRange", () => {
117 | isUndefined("getSeekableRange")
118 | })
119 | })
120 |
121 | describe("calls the mediaplayer with the correct media Type", () => {
122 | it("when is an audio stream", () => {
123 | const mediaType = MediaPlayerBase.TYPE.AUDIO
124 | playableMediaPlayer.initialiseMedia(mediaType, null, null, sourceContainer, null)
125 |
126 | expect(player.initialiseMedia).toHaveBeenCalledWith(
127 | MediaPlayerBase.TYPE.LIVE_AUDIO,
128 | null,
129 | null,
130 | sourceContainer,
131 | null
132 | )
133 | })
134 |
135 | it("when is an video stream", () => {
136 | const mediaType = MediaPlayerBase.TYPE.VIDEO
137 | playableMediaPlayer.initialiseMedia(mediaType, null, null, sourceContainer, null)
138 |
139 | expect(player.initialiseMedia).toHaveBeenCalledWith(
140 | MediaPlayerBase.TYPE.LIVE_VIDEO,
141 | null,
142 | null,
143 | sourceContainer,
144 | null
145 | )
146 | })
147 | })
148 | })
149 |
--------------------------------------------------------------------------------
/src/playbackstrategy/modifiers/live/restartable.js:
--------------------------------------------------------------------------------
1 | import MediaPlayerBase from "../mediaplayerbase"
2 |
3 | function RestartableLivePlayer(mediaPlayer) {
4 | return {
5 | beginPlayback: () => {
6 | if (
7 | window.bigscreenPlayer &&
8 | window.bigscreenPlayer.overrides &&
9 | window.bigscreenPlayer.overrides.forceBeginPlaybackToEndOfWindow
10 | ) {
11 | mediaPlayer.beginPlaybackFrom(Infinity)
12 | } else {
13 | mediaPlayer.beginPlayback()
14 | }
15 | },
16 |
17 | beginPlaybackFrom: (presentationTimeInSeconds) => {
18 | mediaPlayer.beginPlaybackFrom(presentationTimeInSeconds)
19 | },
20 |
21 | initialiseMedia: (mediaType, sourceUrl, mimeType, sourceContainer, opts) => {
22 | const mediaSubType =
23 | mediaType === MediaPlayerBase.TYPE.AUDIO ? MediaPlayerBase.TYPE.LIVE_AUDIO : MediaPlayerBase.TYPE.LIVE_VIDEO
24 |
25 | mediaPlayer.initialiseMedia(mediaSubType, sourceUrl, mimeType, sourceContainer, opts)
26 | },
27 | stop: () => mediaPlayer.stop(),
28 | reset: () => mediaPlayer.reset(),
29 | getState: () => mediaPlayer.getState(),
30 | getSource: () => mediaPlayer.getSource(),
31 | getMimeType: () => mediaPlayer.getMimeType(),
32 | getPlayerElement: () => mediaPlayer.getPlayerElement(),
33 | addEventCallback: (thisArg, callback) => mediaPlayer.addEventCallback(thisArg, callback),
34 | removeEventCallback: (callback) => mediaPlayer.removeEventCallback(callback),
35 | removeAllEventCallbacks: () => mediaPlayer.removeAllEventCallbacks(),
36 | }
37 | }
38 |
39 | export default RestartableLivePlayer
40 |
--------------------------------------------------------------------------------
/src/playbackstrategy/modifiers/live/restartable.test.js:
--------------------------------------------------------------------------------
1 | import MediaPlayerBase from "../mediaplayerbase"
2 | import RestartableMediaPlayer from "./restartable"
3 |
4 | describe("restartable HMTL5 Live Player", () => {
5 | const sourceContainer = document.createElement("div")
6 |
7 | let player
8 |
9 | beforeEach(() => {
10 | player = {
11 | addEventCallback: jest.fn(),
12 | beginPlayback: jest.fn(),
13 | beginPlaybackFrom: jest.fn(),
14 | getMimeType: jest.fn(),
15 | getPlayerElement: jest.fn(),
16 | getState: jest.fn(),
17 | getSource: jest.fn(),
18 | initialiseMedia: jest.fn(),
19 | removeEventCallback: jest.fn(),
20 | removeAllEventCallbacks: jest.fn(),
21 | reset: jest.fn(),
22 | stop: jest.fn(),
23 | }
24 | })
25 |
26 | describe("methods call the appropriate media player methods", () => {
27 | let restartableMediaPlayer
28 |
29 | function wrapperTests(action, expectedReturn) {
30 | if (expectedReturn) {
31 | player[action].mockReturnValue(expectedReturn)
32 |
33 | expect(restartableMediaPlayer[action]()).toBe(expectedReturn)
34 | } else {
35 | restartableMediaPlayer[action]()
36 |
37 | expect(player[action]).toHaveBeenCalledTimes(1)
38 | }
39 | }
40 |
41 | beforeEach(() => {
42 | restartableMediaPlayer = RestartableMediaPlayer(player)
43 | })
44 |
45 | it("calls beginPlayback on the media player", () => {
46 | wrapperTests("beginPlayback")
47 | })
48 |
49 | it("calls initialiseMedia on the media player", () => {
50 | wrapperTests("initialiseMedia")
51 | })
52 |
53 | it("calls stop on the media player", () => {
54 | wrapperTests("stop")
55 | })
56 |
57 | it("calls reset on the media player", () => {
58 | wrapperTests("reset")
59 | })
60 |
61 | it("calls getState on the media player", () => {
62 | wrapperTests("getState", "thisState")
63 | })
64 |
65 | it("calls getSource on the media player", () => {
66 | wrapperTests("getSource", "thisSource")
67 | })
68 |
69 | it("calls getMimeType on the media player", () => {
70 | wrapperTests("getMimeType", "thisMimeType")
71 | })
72 |
73 | it("calls addEventCallback on the media player", () => {
74 | const thisArg = "arg"
75 |
76 | restartableMediaPlayer.addEventCallback(thisArg, jest.fn())
77 |
78 | expect(player.addEventCallback).toHaveBeenCalledWith(thisArg, expect.any(Function))
79 | })
80 |
81 | it("calls removeEventCallback on the media player", () => {
82 | const thisArg = "arg"
83 | const callback = jest.fn()
84 |
85 | restartableMediaPlayer.addEventCallback(thisArg, callback)
86 | restartableMediaPlayer.removeEventCallback(callback)
87 |
88 | expect(player.removeEventCallback).toHaveBeenCalledWith(callback)
89 | })
90 |
91 | it("calls removeAllEventCallbacks on the media player", () => {
92 | wrapperTests("removeAllEventCallbacks")
93 | })
94 |
95 | it("calls getPlayerElement on the media player", () => {
96 | wrapperTests("getPlayerElement", "thisPlayerElement")
97 | })
98 | })
99 |
100 | describe("should not have methods for", () => {
101 | it("playFrom", () => {
102 | expect(RestartableMediaPlayer(player).playFrom).toBeUndefined()
103 | })
104 |
105 | it("pause", () => {
106 | expect(RestartableMediaPlayer(player).pause).toBeUndefined()
107 | })
108 |
109 | it("resume", () => {
110 | expect(RestartableMediaPlayer(player).resume).toBeUndefined()
111 | })
112 |
113 | it("getCurrentTime", () => {
114 | expect(RestartableMediaPlayer(player).getCurrentTime).toBeUndefined()
115 | })
116 |
117 | it("getSeekableRange", () => {
118 | expect(RestartableMediaPlayer(player).getSeekableRange).toBeUndefined()
119 | })
120 | })
121 |
122 | describe("calls the mediaplayer with the correct media Type", () => {
123 | it.each([
124 | [MediaPlayerBase.TYPE.LIVE_VIDEO, MediaPlayerBase.TYPE.VIDEO],
125 | [MediaPlayerBase.TYPE.LIVE_VIDEO, MediaPlayerBase.TYPE.LIVE_VIDEO],
126 | [MediaPlayerBase.TYPE.LIVE_AUDIO, MediaPlayerBase.TYPE.AUDIO],
127 | ])("should initialise the Media Player with the correct type %s for a %s stream", (expectedType, streamType) => {
128 | const restartableMediaPlayer = RestartableMediaPlayer(player)
129 |
130 | restartableMediaPlayer.initialiseMedia(streamType, "http://mock.url", "mockMimeType", sourceContainer)
131 |
132 | expect(player.initialiseMedia).toHaveBeenCalledWith(
133 | expectedType,
134 | "http://mock.url",
135 | "mockMimeType",
136 | sourceContainer,
137 | undefined
138 | )
139 | })
140 | })
141 |
142 | describe("beginPlayback", () => {
143 | it("should respect config forcing playback from the end of the window", () => {
144 | window.bigscreenPlayer = {
145 | overrides: {
146 | forceBeginPlaybackToEndOfWindow: true,
147 | },
148 | }
149 |
150 | const restartableMediaPlayer = RestartableMediaPlayer(player)
151 |
152 | restartableMediaPlayer.beginPlayback()
153 |
154 | expect(player.beginPlaybackFrom).toHaveBeenCalledWith(Infinity)
155 | })
156 | })
157 |
158 | describe("beginPlaybackFrom", () => {
159 | afterEach(() => {
160 | delete window.bigscreenPlayer
161 | })
162 |
163 | it("begins playback with the desired offset", () => {
164 | const restartableMediaPlayer = RestartableMediaPlayer(player)
165 |
166 | restartableMediaPlayer.beginPlaybackFrom(10)
167 |
168 | expect(player.beginPlaybackFrom).toHaveBeenCalledWith(10)
169 | })
170 | })
171 | })
172 |
--------------------------------------------------------------------------------
/src/playbackstrategy/modifiers/live/seekable.js:
--------------------------------------------------------------------------------
1 | import MediaPlayerBase from "../mediaplayerbase"
2 | import { autoResumeAtStartOfRange } from "../../../dynamicwindowutils"
3 | import TimeShiftDetector from "../../../utils/timeshiftdetector"
4 |
5 | function SeekableLivePlayer(mediaPlayer) {
6 | const AUTO_RESUME_WINDOW_START_CUSHION_SECONDS = 8
7 |
8 | const timeShiftDetector = TimeShiftDetector(() => {
9 | if (getState() !== MediaPlayerBase.STATE.PAUSED) {
10 | return
11 | }
12 |
13 | startAutoResumeTimeout()
14 | })
15 |
16 | mediaPlayer.addEventCallback(null, (event) => {
17 | if (event.type === MediaPlayerBase.EVENT.METADATA) {
18 | // Avoid observing the seekable range before metadata loads
19 | timeShiftDetector.observe(getSeekableRange)
20 | }
21 | })
22 |
23 | function addEventCallback(thisArg, callback) {
24 | mediaPlayer.addEventCallback(thisArg, callback)
25 | }
26 |
27 | function removeEventCallback(callback) {
28 | mediaPlayer.removeEventCallback(callback)
29 | }
30 |
31 | function removeAllEventCallbacks() {
32 | mediaPlayer.removeAllEventCallbacks()
33 | }
34 |
35 | function resume() {
36 | mediaPlayer.resume()
37 | }
38 |
39 | function getState() {
40 | return mediaPlayer.getState()
41 | }
42 |
43 | function getSeekableRange() {
44 | return mediaPlayer.getSeekableRange()
45 | }
46 |
47 | function reset() {
48 | timeShiftDetector.disconnect()
49 | mediaPlayer.reset()
50 | }
51 |
52 | function stop() {
53 | timeShiftDetector.disconnect()
54 | mediaPlayer.stop()
55 | }
56 |
57 | function startAutoResumeTimeout() {
58 | autoResumeAtStartOfRange(
59 | mediaPlayer.getCurrentTime(),
60 | mediaPlayer.getSeekableRange(),
61 | addEventCallback,
62 | removeEventCallback,
63 | MediaPlayerBase.unpausedEventCheck,
64 | resume
65 | )
66 | }
67 |
68 | return {
69 | initialiseMedia: function initialiseMedia(mediaKind, sourceUrl, mimeType, sourceContainer, opts) {
70 | const mediaType =
71 | mediaKind === MediaPlayerBase.TYPE.AUDIO ? MediaPlayerBase.TYPE.LIVE_AUDIO : MediaPlayerBase.TYPE.LIVE_VIDEO
72 |
73 | mediaPlayer.initialiseMedia(mediaType, sourceUrl, mimeType, sourceContainer, opts)
74 | },
75 |
76 | beginPlayback: function beginPlayback() {
77 | if (window.bigscreenPlayer?.overrides?.forceBeginPlaybackToEndOfWindow) {
78 | mediaPlayer.beginPlaybackFrom(Infinity)
79 | } else {
80 | mediaPlayer.beginPlayback()
81 | }
82 | },
83 |
84 | beginPlaybackFrom: function beginPlaybackFrom(presentationTimeInSeconds) {
85 | mediaPlayer.beginPlaybackFrom(presentationTimeInSeconds)
86 | },
87 |
88 | playFrom: function playFrom(presentationTimeInSeconds) {
89 | mediaPlayer.playFrom(presentationTimeInSeconds)
90 | },
91 |
92 | pause: function pause() {
93 | if (
94 | mediaPlayer.getCurrentTime() - mediaPlayer.getSeekableRange().start <=
95 | AUTO_RESUME_WINDOW_START_CUSHION_SECONDS
96 | ) {
97 | mediaPlayer.toPaused()
98 | mediaPlayer.toPlaying()
99 |
100 | return
101 | }
102 |
103 | mediaPlayer.pause()
104 |
105 | if (timeShiftDetector.isSeekableRangeSliding()) {
106 | startAutoResumeTimeout()
107 | }
108 | },
109 |
110 | resume,
111 | stop,
112 | reset,
113 | getState,
114 | getSource: () => mediaPlayer.getSource(),
115 | getCurrentTime: () => mediaPlayer.getCurrentTime(),
116 | getSeekableRange,
117 | getMimeType: () => mediaPlayer.getMimeType(),
118 | addEventCallback,
119 | removeEventCallback,
120 | removeAllEventCallbacks,
121 | getPlayerElement: () => mediaPlayer.getPlayerElement(),
122 | getLiveSupport: () => MediaPlayerBase.LIVE_SUPPORT.SEEKABLE,
123 | }
124 | }
125 |
126 | export default SeekableLivePlayer
127 |
--------------------------------------------------------------------------------
/src/playbackstrategy/modifiers/mediaplayerbase.js:
--------------------------------------------------------------------------------
1 | const STATE = {
2 | EMPTY: "EMPTY", // No source set
3 | STOPPED: "STOPPED", // Source set but no playback
4 | BUFFERING: "BUFFERING", // Not enough data to play, waiting to download more
5 | PLAYING: "PLAYING", // Media is playing
6 | PAUSED: "PAUSED", // Media is paused
7 | COMPLETE: "COMPLETE", // Media has reached its end point
8 | ERROR: "ERROR", // An error occurred
9 | }
10 |
11 | const EVENT = {
12 | STOPPED: "stopped", // Event fired when playback is stopped
13 | BUFFERING: "buffering", // Event fired when playback has to suspend due to buffering
14 | PLAYING: "playing", // Event fired when starting (or resuming) playing of the media
15 | PAUSED: "paused", // Event fired when media playback pauses
16 | COMPLETE: "complete", // Event fired when media playback has reached the end of the media
17 | ERROR: "error", // Event fired when an error condition occurs
18 | STATUS: "status", // Event fired regularly during play
19 | METADATA: "metadata", // Event fired when media element loaded the init segment(s)
20 | SENTINEL_ENTER_BUFFERING: "sentinel-enter-buffering", // Event fired when a sentinel has to act because the device has started buffering but not reported it
21 | SENTINEL_EXIT_BUFFERING: "sentinel-exit-buffering", // Event fired when a sentinel has to act because the device has finished buffering but not reported it
22 | SENTINEL_PAUSE: "sentinel-pause", // Event fired when a sentinel has to act because the device has failed to pause when expected
23 | SENTINEL_PLAY: "sentinel-play", // Event fired when a sentinel has to act because the device has failed to play when expected
24 | SENTINEL_SEEK: "sentinel-seek", // Event fired when a sentinel has to act because the device has failed to seek to the correct location
25 | SENTINEL_COMPLETE: "sentinel-complete", // Event fired when a sentinel has to act because the device has completed the media but not reported it
26 | SENTINEL_PAUSE_FAILURE: "sentinel-pause-failure", // Event fired when the pause sentinel has failed twice, so it is giving up
27 | SENTINEL_SEEK_FAILURE: "sentinel-seek-failure", // Event fired when the seek sentinel has failed twice, so it is giving up
28 | SEEK_ATTEMPTED: "seek-attempted", // Event fired when a device using a seekfinishedemitevent modifier sets the source
29 | SEEK_FINISHED: "seek-finished", // Event fired when a device using a seekfinishedemitevent modifier has seeked successfully
30 | }
31 |
32 | const TYPE = {
33 | VIDEO: "video",
34 | AUDIO: "audio",
35 | LIVE_VIDEO: "live-video",
36 | LIVE_AUDIO: "live-audio",
37 | }
38 |
39 | function unpausedEventCheck(event) {
40 | return event != null && event.state && event.type !== "status" ? event.state !== STATE.PAUSED : undefined
41 | }
42 |
43 | export default {
44 | STATE,
45 | EVENT,
46 | TYPE,
47 | unpausedEventCheck,
48 | }
49 |
--------------------------------------------------------------------------------
/src/playbackstrategy/nativestrategy.js:
--------------------------------------------------------------------------------
1 | import LegacyAdapter from "./legacyplayeradapter"
2 |
3 | import Cehtml from "./modifiers/cehtml"
4 | import Html5 from "./modifiers/html5"
5 | import SamsungMaple from "./modifiers/samsungmaple"
6 | import SamsungStreaming from "./modifiers/samsungstreaming"
7 | import SamsungStreaming2015 from "./modifiers/samsungstreaming2015"
8 |
9 | import None from "./modifiers/live/none"
10 | import Playable from "./modifiers/live/playable"
11 | import Restartable from "./modifiers/live/restartable"
12 | import Seekable from "./modifiers/live/seekable"
13 | import { ManifestType } from "../models/manifesttypes"
14 |
15 | const getBasePlayer = () => {
16 | const configuredPlayer = window.bigscreenPlayer?.mediaPlayer
17 |
18 | if (configuredPlayer === "cehtml") return Cehtml()
19 | if (configuredPlayer === "samsungmaple") return SamsungMaple()
20 | if (configuredPlayer === "samsungstreaming") return SamsungStreaming()
21 | if (configuredPlayer === "samsungstreaming2015") return SamsungStreaming2015()
22 |
23 | return Html5()
24 | }
25 |
26 | const getMediaPlayer = (mediaSources) => {
27 | const basePlayer = getBasePlayer()
28 | const liveSupport = window.bigscreenPlayer?.liveSupport
29 |
30 | if (mediaSources.time().manifestType !== ManifestType.DYNAMIC) return basePlayer
31 |
32 | if (liveSupport === "none") return None(basePlayer, mediaSources)
33 | if (liveSupport === "restartable") return Restartable(basePlayer, mediaSources)
34 | if (liveSupport === "seekable") return Seekable(basePlayer, mediaSources)
35 |
36 | return Playable(basePlayer, mediaSources)
37 | }
38 |
39 | const NativeStrategy = (mediaSources, _mediaKind, playbackElement, isUHD) =>
40 | LegacyAdapter(mediaSources, playbackElement, isUHD, getMediaPlayer(mediaSources))
41 |
42 | NativeStrategy.getLiveSupport = () => window.bigscreenPlayer.liveSupport
43 |
44 | export default NativeStrategy
45 |
--------------------------------------------------------------------------------
/src/playbackstrategy/strategypicker.js:
--------------------------------------------------------------------------------
1 | import PlaybackStrategy from "../models/playbackstrategy"
2 | import NativeStrategy from "./nativestrategy"
3 | import BasicStrategy from "./basicstrategy"
4 | import isError from "../utils/iserror"
5 |
6 | function StrategyPicker() {
7 | return new Promise((resolve, reject) => {
8 | if (window.bigscreenPlayer.playbackStrategy === PlaybackStrategy.MSE) {
9 | return import("./msestrategy")
10 | .then(({ default: MSEStrategy }) => resolve(MSEStrategy))
11 | .catch((reason) => {
12 | const error = new Error(isError(reason) ? reason.message : undefined)
13 | error.name = "StrategyDynamicLoadError"
14 |
15 | reject(error)
16 | })
17 | } else if (window.bigscreenPlayer.playbackStrategy === PlaybackStrategy.BASIC) {
18 | return resolve(BasicStrategy)
19 | }
20 | return resolve(NativeStrategy)
21 | })
22 | }
23 |
24 | export default StrategyPicker
25 |
--------------------------------------------------------------------------------
/src/playbackstrategy/strategypicker.test.js:
--------------------------------------------------------------------------------
1 | import StrategyPicker from "./strategypicker"
2 | import NativeStrategy from "./nativestrategy"
3 | import MSEStrategy from "./msestrategy"
4 | import BasicStrategy from "./basicstrategy"
5 |
6 | jest.mock("./nativestrategy")
7 | jest.mock("./basicstrategy")
8 | jest.mock("./msestrategy", () => jest.fn)
9 |
10 | class NoErrorThrownError extends Error {}
11 |
12 | const getError = async (call) => {
13 | try {
14 | await call()
15 |
16 | throw new NoErrorThrownError()
17 | } catch (error) {
18 | return error
19 | }
20 | }
21 |
22 | describe("Strategy Picker", () => {
23 | beforeEach(() => {
24 | window.bigscreenPlayer = {}
25 | jest.resetModules()
26 | })
27 |
28 | afterEach(() => {
29 | delete window.bigscreenPlayer
30 | })
31 |
32 | it("should default to native strategy", () =>
33 | StrategyPicker().then((strategy) => {
34 | expect(strategy).toEqual(NativeStrategy)
35 | }))
36 |
37 | it("should use basic strategy when defined", () => {
38 | window.bigscreenPlayer = {
39 | playbackStrategy: "basicstrategy",
40 | }
41 |
42 | return StrategyPicker().then((strategy) => {
43 | expect(strategy).toEqual(BasicStrategy)
44 | })
45 | })
46 |
47 | it("should use mse strategy when configured", () => {
48 | window.bigscreenPlayer.playbackStrategy = "msestrategy"
49 |
50 | return StrategyPicker().then((strategy) => {
51 | expect(strategy).toEqual(MSEStrategy)
52 | })
53 | })
54 |
55 | it("should reject when mse strategy cannot be loaded", async () => {
56 | window.bigscreenPlayer.playbackStrategy = "msestrategy"
57 |
58 | jest.doMock("./msestrategy", () => {
59 | throw new Error("Could not construct MSE Strategy!")
60 | })
61 |
62 | const error = await getError(async () => StrategyPicker())
63 |
64 | expect(error).not.toBeInstanceOf(NoErrorThrownError)
65 | expect(error.name).toBe("StrategyDynamicLoadError")
66 | expect(error.message).toBe("Could not construct MSE Strategy!")
67 | })
68 | })
69 |
--------------------------------------------------------------------------------
/src/plugindata.js:
--------------------------------------------------------------------------------
1 | function PluginData(args) {
2 | this.status = args.status
3 | this.stateType = args.stateType
4 | this.isBufferingTimeoutError = args.isBufferingTimeoutError || false
5 | this.isInitialPlay = args.isInitialPlay
6 | this.cdn = args.cdn
7 | this.newCdn = args.newCdn
8 | this.timeStamp = new Date()
9 | this.code = args.code
10 | this.message = args.message
11 | }
12 |
13 | export default PluginData
14 |
--------------------------------------------------------------------------------
/src/pluginenums.js:
--------------------------------------------------------------------------------
1 | export default {
2 | STATUS: {
3 | STARTED: "started",
4 | DISMISSED: "dismissed",
5 | FATAL: "fatal",
6 | FAILOVER: "failover",
7 | },
8 | TYPE: {
9 | BUFFERING: "buffering",
10 | ERROR: "error",
11 | },
12 | ERROR_CODES: {
13 | MANIFEST_PARSE: 7,
14 | BUFFERING_TIMEOUT: 8,
15 | MANIFEST_LOAD: 9,
16 | },
17 | ERROR_MESSAGES: {
18 | BUFFERING_TIMEOUT: "bigscreen-player-buffering-timeout-error",
19 | MANIFEST: "bigscreen-player-manifest-error",
20 | },
21 | }
22 |
--------------------------------------------------------------------------------
/src/plugins.js:
--------------------------------------------------------------------------------
1 | import PlaybackUtils from "./utils/playbackutils"
2 | import CallCallbacks from "./utils/callcallbacks"
3 |
4 | let plugins = []
5 |
6 | function callOnAllPlugins(funcKey, evt) {
7 | const clonedEvent = PlaybackUtils.deepClone(evt)
8 | const selectedPlugins = plugins
9 | .filter((plugin) => plugin[funcKey] && typeof plugin[funcKey] === "function")
10 | .map((plugin) => plugin[funcKey].bind(plugin))
11 |
12 | CallCallbacks(selectedPlugins, clonedEvent)
13 | }
14 |
15 | export default {
16 | registerPlugin: (plugin) => {
17 | plugins.push(plugin)
18 | },
19 |
20 | unregisterPlugin: (plugin) => {
21 | if (!plugin && plugins.length > 0) {
22 | plugins = []
23 | } else {
24 | for (let pluginsIndex = plugins.length - 1; pluginsIndex >= 0; pluginsIndex--) {
25 | if (plugins[pluginsIndex] === plugin) {
26 | plugins.splice(pluginsIndex, 1)
27 | }
28 | }
29 | }
30 | },
31 |
32 | interface: {
33 | onError: (evt) => callOnAllPlugins("onError", evt),
34 | onFatalError: (evt) => callOnAllPlugins("onFatalError", evt),
35 | onErrorCleared: (evt) => callOnAllPlugins("onErrorCleared", evt),
36 | onErrorHandled: (evt) => callOnAllPlugins("onErrorHandled", evt),
37 | onBuffering: (evt) => callOnAllPlugins("onBuffering", evt),
38 | onBufferingCleared: (evt) => callOnAllPlugins("onBufferingCleared", evt),
39 | onScreenCapabilityDetermined: (tvInfo) => callOnAllPlugins("onScreenCapabilityDetermined", tvInfo),
40 | onPlayerInfoUpdated: (evt) => callOnAllPlugins("onPlayerInfoUpdated", evt),
41 | onManifestLoaded: (manifest) => callOnAllPlugins("onManifestLoaded", manifest),
42 | onManifestParseError: (evt) => callOnAllPlugins("onManifestParseError", evt),
43 | onQualityChangeRequested: (evt) => callOnAllPlugins("onQualityChangeRequested", evt),
44 | onQualityChangedRendered: (evt) => callOnAllPlugins("onQualityChangedRendered", evt),
45 | onSubtitlesLoadError: (evt) => callOnAllPlugins("onSubtitlesLoadError", evt),
46 | onSubtitlesTimeout: (evt) => callOnAllPlugins("onSubtitlesTimeout", evt),
47 | onSubtitlesXMLError: (evt) => callOnAllPlugins("onSubtitlesXMLError", evt),
48 | onSubtitlesTransformError: (evt) => callOnAllPlugins("onSubtitlesTransformError", evt),
49 | onSubtitlesRenderError: (evt) => callOnAllPlugins("onSubtitlesRenderError", evt),
50 | onSubtitlesDynamicLoadError: (evt) => callOnAllPlugins("onSubtitlesDynamicLoadError", evt),
51 | onFragmentContentLengthMismatch: (evt) => callOnAllPlugins("onFragmentContentLengthMismatch", evt),
52 | onQuotaExceeded: (evt) => callOnAllPlugins("onQuotaExceeded", evt),
53 | onPlaybackRateChanged: (evt) => callOnAllPlugins("onPlaybackRateChanged", evt),
54 | },
55 | }
56 |
--------------------------------------------------------------------------------
/src/plugins.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable jest/no-conditional-expect */
2 | import plugins from "./plugins"
3 |
4 | describe("Plugins", () => {
5 | it("Calls a registered plugin on interface invocation", () => {
6 | const fatalErrorPlugin = {
7 | onFatalError: jest.fn(),
8 | }
9 |
10 | plugins.registerPlugin(fatalErrorPlugin)
11 |
12 | plugins.interface.onFatalError()
13 | expect(fatalErrorPlugin.onFatalError).toHaveBeenCalled()
14 | })
15 |
16 | it("Calls multiple plugins and defers any error thrown inside a plugin", () => {
17 | expect.assertions(4)
18 |
19 | const fatalErrorPlugin = {
20 | onFatalError: jest.fn(),
21 | }
22 |
23 | const newError = new Error("oops")
24 |
25 | fatalErrorPlugin.onFatalError.mockImplementationOnce(() => {
26 | throw newError
27 | })
28 |
29 | const anotherFatalErrorPlugin = {
30 | onFatalError: jest.fn(),
31 | }
32 |
33 | plugins.registerPlugin(fatalErrorPlugin)
34 | plugins.registerPlugin(anotherFatalErrorPlugin)
35 |
36 | jest.useFakeTimers()
37 |
38 | try {
39 | expect(() => {
40 | plugins.interface.onFatalError()
41 | }).not.toThrow()
42 | jest.advanceTimersByTime(1)
43 | } catch (error) {
44 | // Test for the async error case, linting hates it!
45 | expect(error).toBe(newError)
46 | expect(anotherFatalErrorPlugin.onFatalError).toHaveBeenCalled()
47 | expect(fatalErrorPlugin.onFatalError).toHaveBeenCalled()
48 | }
49 | })
50 |
51 | it("Calls plugins including context and defers any errors thrown", () => {
52 | expect.assertions(4)
53 |
54 | const fatalErrorPlugin = {
55 | onFatalError: jest.fn(),
56 | }
57 |
58 | class ErrorHandlingClass {
59 | constructor() {
60 | this.state = "bad"
61 | }
62 |
63 | report() {
64 | return this.state
65 | }
66 |
67 | onFatalError() {
68 | this.report()
69 | }
70 | }
71 |
72 | const classBasedErrorHandler = new ErrorHandlingClass()
73 |
74 | const classSpy = jest.spyOn(classBasedErrorHandler, "report")
75 |
76 | const newError = new Error("oops")
77 |
78 | fatalErrorPlugin.onFatalError.mockImplementationOnce(() => {
79 | throw newError
80 | })
81 |
82 | plugins.registerPlugin(classBasedErrorHandler)
83 | plugins.registerPlugin(fatalErrorPlugin)
84 |
85 | jest.useFakeTimers()
86 |
87 | try {
88 | expect(() => {
89 | plugins.interface.onFatalError()
90 | }).not.toThrow()
91 | jest.advanceTimersByTime(1)
92 | } catch (error) {
93 | // Test for the async error case, linting hates it!
94 | expect(error).toBe(newError)
95 | expect(fatalErrorPlugin.onFatalError).toHaveBeenCalled()
96 | expect(classSpy).toHaveBeenCalled()
97 | }
98 | })
99 | })
100 |
--------------------------------------------------------------------------------
/src/readyhelper.ts:
--------------------------------------------------------------------------------
1 | import { MediaState } from "./models/mediastate"
2 | import { LiveSupport } from "./models/livesupport"
3 | import { ManifestType } from "./models/manifesttypes"
4 |
5 | type SeekableRange = { start: number; end: number } | null
6 | type State = { state?: MediaState }
7 | type Time = { currentTime?: number; seekableRange?: SeekableRange }
8 |
9 | function ReadyHelper(
10 | initialPlaybackTime: number | undefined,
11 | manifestType: ManifestType,
12 | liveSupport: LiveSupport,
13 | callback?: () => void
14 | ) {
15 | let ready = false
16 |
17 | const callbackWhenReady = ({ data, timeUpdate }: { data?: State | Time; timeUpdate?: boolean }) => {
18 | if (ready) return
19 |
20 | if (!data) {
21 | ready = false
22 | } else if (timeUpdate) {
23 | ready = isValidTime(data as Time)
24 | } else {
25 | ready = isValidState(data as State) && isValidTime(data as Time)
26 | }
27 |
28 | if (ready && callback) {
29 | callback()
30 | }
31 | }
32 |
33 | function isValidState({ state }: State): boolean {
34 | return state ? state !== MediaState.FATAL_ERROR : false
35 | }
36 |
37 | function isValidTime({ currentTime, seekableRange }: Time) {
38 | if (manifestType === ManifestType.STATIC) return validateStaticTime(currentTime)
39 | return validateLiveTime(currentTime, seekableRange)
40 | }
41 |
42 | function validateStaticTime(currentTime?: number) {
43 | if (currentTime !== undefined) {
44 | return initialPlaybackTime ? currentTime > 0 : currentTime >= 0
45 | }
46 | return false
47 | }
48 |
49 | function validateLiveTime(currentTime?: number, seekableRange?: SeekableRange) {
50 | if (liveSupport === LiveSupport.PLAYABLE || liveSupport === LiveSupport.RESTARTABLE) {
51 | return currentTime ? currentTime >= 0 : false
52 | }
53 |
54 | return isValidSeekableRange(seekableRange)
55 | }
56 |
57 | function isValidSeekableRange(seekableRange?: SeekableRange) {
58 | return seekableRange ? !(seekableRange.start === 0 && seekableRange.end === 0) : false
59 | }
60 |
61 | return {
62 | callbackWhenReady,
63 | }
64 | }
65 |
66 | export default ReadyHelper
67 |
--------------------------------------------------------------------------------
/src/resizer.test.ts:
--------------------------------------------------------------------------------
1 | import Resizer from "./resizer"
2 |
3 | describe("Resizer", () => {
4 | let resizer: ReturnType
5 | let element: HTMLElement
6 |
7 | beforeEach(() => {
8 | element = document.createElement("div")
9 | element.style.top = "0px"
10 | element.style.left = "0px"
11 | element.style.width = "1280px"
12 | element.style.height = "720px"
13 | resizer = Resizer()
14 | })
15 |
16 | describe("resize", () => {
17 | it("Resizes and positions the element with the correct values", () => {
18 | resizer.resize(element, 10, 20, 3, 4, 5)
19 |
20 | expect(element.style.top).toBe("10px")
21 | expect(element.style.left).toBe("20px")
22 | expect(element.style.width).toBe("3px")
23 | expect(element.style.height).toBe("4px")
24 | expect(element.style.zIndex).toBe("5")
25 | expect(element.style.position).toBe("absolute")
26 | })
27 | })
28 |
29 | describe("clear", () => {
30 | it("resets the css properties", () => {
31 | resizer.resize(element, 1, 2, 3, 4, 5)
32 | resizer.clear(element)
33 |
34 | expect(element.style.top).toBe("")
35 | expect(element.style.left).toBe("")
36 | expect(element.style.width).toBe("")
37 | expect(element.style.height).toBe("")
38 | expect(element.style.zIndex).toBe("")
39 | expect(element.style.position).toBe("")
40 | })
41 | })
42 |
43 | describe("isResized", () => {
44 | it("should return false if no call to resize or clear has been made", () => {
45 | expect(resizer.isResized()).toBe(false)
46 | })
47 |
48 | it("should return true if the last call was to resized", () => {
49 | resizer.clear(element)
50 | resizer.resize(element, 1, 2, 3, 4, 5)
51 |
52 | expect(resizer.isResized()).toBe(true)
53 | })
54 |
55 | it("should return true if the last call was to clear", () => {
56 | resizer.resize(element, 1, 2, 3, 4, 5)
57 | resizer.clear(element)
58 |
59 | expect(resizer.isResized()).toBe(false)
60 | })
61 | })
62 | })
63 |
--------------------------------------------------------------------------------
/src/resizer.ts:
--------------------------------------------------------------------------------
1 | export default function Resizer() {
2 | let resized: boolean
3 |
4 | function resize(element: HTMLElement, top: number, left: number, width: number, height: number, zIndex: number) {
5 | element.style.top = `${top}px`
6 | element.style.left = `${left}px`
7 | element.style.width = `${width}px`
8 | element.style.height = `${height}px`
9 | element.style.zIndex = `${zIndex}`
10 | element.style.position = "absolute"
11 | resized = true
12 | }
13 |
14 | function clear(element: HTMLElement) {
15 | element.style.top = ""
16 | element.style.left = ""
17 | element.style.width = ""
18 | element.style.height = ""
19 | element.style.zIndex = ""
20 | element.style.position = ""
21 | resized = false
22 | }
23 |
24 | function isResized() {
25 | return resized || false
26 | }
27 |
28 | return {
29 | resize,
30 | clear,
31 | isResized,
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/subtitles/embeddedsubtitles.js:
--------------------------------------------------------------------------------
1 | import { fromXML, generateISD, renderHTML } from "smp-imsc"
2 | import DOMHelpers from "../domhelpers"
3 | import Utils from "../utils/playbackutils"
4 | import DebugTool from "../debugger/debugtool"
5 | import Plugins from "../plugins"
6 |
7 | function EmbeddedSubtitles(mediaPlayer, autoStart, parentElement, _mediaSources, defaultStyleOpts) {
8 | let exampleSubtitlesElement
9 | let imscRenderOpts = transformStyleOptions(defaultStyleOpts)
10 | let subtitlesEnabled = false
11 |
12 | if (autoStart) start()
13 |
14 | function removeExampleSubtitlesElement() {
15 | if (exampleSubtitlesElement) {
16 | DOMHelpers.safeRemoveElement(exampleSubtitlesElement)
17 | exampleSubtitlesElement = undefined
18 | }
19 | }
20 |
21 | function renderExample(exampleXmlString, styleOpts, safePosition = {}) {
22 | const exampleXml = fromXML(exampleXmlString)
23 | removeExampleSubtitlesElement()
24 |
25 | const customStyleOptions = transformStyleOptions(styleOpts)
26 | const exampleStyle = Utils.merge(imscRenderOpts, customStyleOptions)
27 |
28 | exampleSubtitlesElement = document.createElement("div")
29 | exampleSubtitlesElement.id = "subtitlesPreview"
30 | exampleSubtitlesElement.style.position = "absolute"
31 |
32 | const elementWidth = parentElement.clientWidth
33 | const elementHeight = parentElement.clientHeight
34 | const topPixels = ((safePosition.top || 0) / 100) * elementHeight
35 | const rightPixels = ((safePosition.right || 0) / 100) * elementWidth
36 | const bottomPixels = ((safePosition.bottom || 0) / 100) * elementHeight
37 | const leftPixels = ((safePosition.left || 0) / 100) * elementWidth
38 |
39 | const renderWidth = elementWidth - leftPixels - rightPixels
40 | const renderHeight = elementHeight - topPixels - bottomPixels
41 |
42 | exampleSubtitlesElement.style.top = `${topPixels}px`
43 | exampleSubtitlesElement.style.right = `${rightPixels}px`
44 | exampleSubtitlesElement.style.bottom = `${bottomPixels}px`
45 | exampleSubtitlesElement.style.left = `${leftPixels}px`
46 | parentElement.appendChild(exampleSubtitlesElement)
47 |
48 | renderSubtitle(exampleXml, 1, exampleSubtitlesElement, exampleStyle, renderHeight, renderWidth)
49 | }
50 |
51 | function renderSubtitle(xml, currentTime, subsElement, styleOpts, renderHeight, renderWidth) {
52 | try {
53 | const isd = generateISD(xml, currentTime)
54 | renderHTML(isd, subsElement, null, renderHeight, renderWidth, false, null, null, false, styleOpts)
55 | } catch (error) {
56 | error.name = "SubtitlesRenderError"
57 | DebugTool.error(error)
58 |
59 | Plugins.interface.onSubtitlesRenderError()
60 | }
61 | }
62 |
63 | function start() {
64 | subtitlesEnabled = true
65 | mediaPlayer.setSubtitles(true)
66 | mediaPlayer.customiseSubtitles(imscRenderOpts)
67 | }
68 |
69 | function stop() {
70 | subtitlesEnabled = false
71 | mediaPlayer.setSubtitles(false)
72 | }
73 |
74 | function tearDown() {
75 | stop()
76 | }
77 |
78 | function customise(styleOpts) {
79 | const customStyleOptions = transformStyleOptions(styleOpts)
80 | imscRenderOpts = Utils.merge(imscRenderOpts, customStyleOptions)
81 | mediaPlayer.customiseSubtitles(imscRenderOpts)
82 | if (subtitlesEnabled) {
83 | stop()
84 | start()
85 | }
86 | }
87 |
88 | // Opts: { backgroundColour: string (css colour, hex), fontFamily: string , size: number, lineHeight: number }
89 | function transformStyleOptions(opts) {
90 | if (opts === undefined) return
91 |
92 | const customStyles = {}
93 |
94 | if (opts.backgroundColour) {
95 | customStyles.spanBackgroundColorAdjust = { transparent: opts.backgroundColour }
96 | }
97 |
98 | if (opts.fontFamily) {
99 | customStyles.fontFamily = opts.fontFamily
100 | }
101 |
102 | if (opts.size > 0) {
103 | customStyles.sizeAdjust = opts.size
104 | }
105 |
106 | if (opts.lineHeight) {
107 | customStyles.lineHeightAdjust = opts.lineHeight
108 | }
109 |
110 | return customStyles
111 | }
112 |
113 | return {
114 | start,
115 | stop,
116 | updatePosition: () => {},
117 | customise,
118 | renderExample,
119 | clearExample: removeExampleSubtitlesElement,
120 | tearDown,
121 | }
122 | }
123 |
124 | export default EmbeddedSubtitles
125 |
--------------------------------------------------------------------------------
/src/subtitles/legacysubtitles.js:
--------------------------------------------------------------------------------
1 | import Renderer from "./renderer"
2 | import TransportControlPosition from "../models/transportcontrolposition"
3 | import DOMHelpers from "../domhelpers"
4 | import LoadURL from "../utils/loadurl"
5 | import DebugTool from "../debugger/debugtool"
6 | import Plugins from "../plugins"
7 |
8 | function LegacySubtitles(mediaPlayer, autoStart, parentElement, mediaSources) {
9 | const container = document.createElement("div")
10 | let subtitlesRenderer
11 |
12 | if (autoStart) {
13 | start()
14 | }
15 |
16 | function loadSubtitles() {
17 | const url = mediaSources.currentSubtitlesSource()
18 |
19 | if (url && url !== "") {
20 | LoadURL(url, {
21 | timeout: mediaSources.subtitlesRequestTimeout(),
22 | onLoad: (responseXML) => {
23 | if (responseXML) {
24 | createContainer(responseXML)
25 | } else {
26 | DebugTool.info("Error: responseXML is invalid.")
27 | Plugins.interface.onSubtitlesXMLError({ cdn: mediaSources.currentSubtitlesCdn() })
28 | }
29 | },
30 | onError: ({ statusCode, ...rest } = {}) => {
31 | DebugTool.info(`Error loading subtitles data: ${statusCode}`)
32 |
33 | mediaSources
34 | .failoverSubtitles({ statusCode, ...rest })
35 | .then(() => loadSubtitles())
36 | .catch(() => DebugTool.info("Failed to load from subtitles file from all available CDNs"))
37 | },
38 | onTimeout: () => {
39 | DebugTool.info("Request timeout loading subtitles")
40 | Plugins.interface.onSubtitlesTimeout({ cdn: mediaSources.currentSubtitlesCdn() })
41 | },
42 | })
43 | }
44 | }
45 |
46 | function createContainer(xml) {
47 | container.id = "playerCaptionsContainer"
48 | DOMHelpers.addClass(container, "playerCaptions")
49 |
50 | const videoHeight = parentElement.clientHeight
51 |
52 | container.style.position = "absolute"
53 | container.style.bottom = "0px"
54 | container.style.right = "0px"
55 | container.style.fontWeight = "bold"
56 | container.style.textAlign = "center"
57 | container.style.textShadow = "#161616 2px 2px 1px"
58 | container.style.color = "#ebebeb"
59 |
60 | if (videoHeight === 1080) {
61 | container.style.width = "1824px"
62 | container.style.fontSize = "63px"
63 | container.style.paddingRight = "48px"
64 | container.style.paddingLeft = "48px"
65 | container.style.paddingBottom = "60px"
66 | } else {
67 | // Assume 720 if not 1080. Styling implementation could be cleaner, but this is a quick fix for legacy subtitles
68 | container.style.width = "1216px"
69 | container.style.fontSize = "42px"
70 | container.style.paddingRight = "32px"
71 | container.style.paddingLeft = "32px"
72 | container.style.paddingBottom = "40px"
73 | }
74 |
75 | // TODO: We don't need this extra Div really... can we get rid of render() and use the passed in container?
76 | subtitlesRenderer = Renderer("playerCaptions", xml, mediaPlayer)
77 | container.appendChild(subtitlesRenderer.render())
78 |
79 | parentElement.appendChild(container)
80 | }
81 |
82 | function start() {
83 | if (subtitlesRenderer) {
84 | subtitlesRenderer.start()
85 | } else {
86 | loadSubtitles()
87 | }
88 | }
89 |
90 | function stop() {
91 | if (subtitlesRenderer) {
92 | subtitlesRenderer.stop()
93 | }
94 | }
95 |
96 | function updatePosition(transportControlPosition) {
97 | const classes = {
98 | controlsVisible: TransportControlPosition.CONTROLS_ONLY,
99 | controlsWithInfoVisible: TransportControlPosition.CONTROLS_WITH_INFO,
100 | leftCarouselVisible: TransportControlPosition.LEFT_CAROUSEL,
101 | bottomCarouselVisible: TransportControlPosition.BOTTOM_CAROUSEL,
102 | }
103 |
104 | for (const cssClassName in classes) {
105 | // eslint-disable-next-line no-prototype-builtins
106 | if (classes.hasOwnProperty(cssClassName)) {
107 | // Allow multiple flags to be set at once
108 | if ((classes[cssClassName] & transportControlPosition) === classes[cssClassName]) {
109 | DOMHelpers.addClass(container, cssClassName)
110 | } else {
111 | DOMHelpers.removeClass(container, cssClassName)
112 | }
113 | }
114 | }
115 | }
116 |
117 | function tearDown() {
118 | stop()
119 | DOMHelpers.safeRemoveElement(container)
120 | }
121 |
122 | return {
123 | start,
124 | stop,
125 | updatePosition,
126 | customise: () => {},
127 | renderExample: () => {},
128 | clearExample: () => {},
129 | tearDown,
130 | }
131 | }
132 |
133 | export default LegacySubtitles
134 |
--------------------------------------------------------------------------------
/src/subtitles/renderer.js:
--------------------------------------------------------------------------------
1 | import DebugTool from "../debugger/debugtool"
2 | import Transformer from "./transformer"
3 | import Plugins from "../plugins"
4 |
5 | function Renderer(id, captionsXML, mediaPlayer) {
6 | let transformedSubtitles
7 | let liveItems = []
8 | let interval = 0
9 | let outputElement
10 |
11 | outputElement = document.createElement("div")
12 | outputElement.id = id
13 |
14 | transformedSubtitles = Transformer().transformXML(captionsXML)
15 |
16 | start()
17 |
18 | function render() {
19 | return outputElement
20 | }
21 |
22 | function start() {
23 | if (transformedSubtitles) {
24 | interval = setInterval(() => update(), 750)
25 | if (outputElement) {
26 | outputElement.setAttribute("style", transformedSubtitles.baseStyle)
27 | outputElement.style.cssText = transformedSubtitles.baseStyle
28 | outputElement.style.display = "block"
29 | }
30 | }
31 | }
32 |
33 | function stop() {
34 | if (outputElement) {
35 | outputElement.style.display = "none"
36 | }
37 |
38 | cleanOldCaptions(mediaPlayer.getDuration())
39 | clearInterval(interval)
40 | }
41 |
42 | function update() {
43 | try {
44 | if (!mediaPlayer) {
45 | stop()
46 | }
47 |
48 | const time = mediaPlayer.getCurrentTime()
49 | updateCaptions(time)
50 |
51 | confirmCaptionsRendered()
52 | } catch (e) {
53 | DebugTool.info("Exception while rendering subtitles: " + e)
54 | Plugins.interface.onSubtitlesRenderError()
55 | }
56 | }
57 |
58 | function confirmCaptionsRendered() {
59 | if (outputElement && !outputElement.hasChildNodes() && liveItems.length > 0) {
60 | Plugins.interface.onSubtitlesRenderError()
61 | }
62 | }
63 |
64 | function updateCaptions(time) {
65 | cleanOldCaptions(time)
66 | addNewCaptions(time)
67 | }
68 |
69 | function cleanOldCaptions(time) {
70 | const live = liveItems
71 | for (let i = live.length - 1; i >= 0; i--) {
72 | if (live[i].removeFromDomIfExpired(time)) {
73 | live.splice(i, 1)
74 | }
75 | }
76 | }
77 |
78 | function addNewCaptions(time) {
79 | const live = liveItems
80 | const fresh = transformedSubtitles.subtitlesForTime(time)
81 | liveItems = live.concat(fresh)
82 | for (let i = 0, j = fresh.length; i < j; i++) {
83 | // TODO: Probably start adding to the DOM here rather than calling through.
84 | fresh[i].addToDom(outputElement)
85 | }
86 | }
87 |
88 | return {
89 | render: render,
90 | start: start,
91 | stop: stop,
92 | }
93 | }
94 |
95 | export default Renderer
96 |
--------------------------------------------------------------------------------
/src/subtitles/renderer.test.js:
--------------------------------------------------------------------------------
1 | import Renderer from "./renderer"
2 |
3 | jest.mock("./transformer", () => () => ({
4 | transformXML: () => ({
5 | baseStyle: "",
6 | subtitlesForTime: () => {},
7 | }),
8 | }))
9 |
10 | describe("Renderer", () => {
11 | it("should initialise with a id, xml object, media player", () => {
12 | const mockMediaPlayer = jest.fn()
13 | const renderer = Renderer("subtitlesOutputId", "", mockMediaPlayer)
14 |
15 | expect(renderer).toEqual(
16 | expect.objectContaining({
17 | render: expect.any(Function),
18 | start: expect.any(Function),
19 | stop: expect.any(Function),
20 | })
21 | )
22 | })
23 |
24 | it("should set the output elements display style on initialisation", () => {
25 | const mockMediaPlayer = jest.fn()
26 | const renderer = Renderer("subtitlesOutputId", "", mockMediaPlayer)
27 | const outputElement = renderer.render()
28 |
29 | expect(outputElement.style.display).toBe("block")
30 | })
31 | })
32 |
--------------------------------------------------------------------------------
/src/subtitles/subtitles.js:
--------------------------------------------------------------------------------
1 | import Plugins from "../plugins"
2 | import findSegmentTemplate from "../utils/findtemplate"
3 |
4 | function Subtitles(mediaPlayer, autoStart, playbackElement, defaultStyleOpts, mediaSources, callback) {
5 | const useLegacySubs = window.bigscreenPlayer?.overrides?.legacySubtitles ?? false
6 | const embeddedSubs = window.bigscreenPlayer?.overrides?.embeddedSubtitles ?? false
7 |
8 | const isSeekableLiveSupport =
9 | window.bigscreenPlayer.liveSupport == null || window.bigscreenPlayer.liveSupport === "seekable"
10 |
11 | let subtitlesEnabled = autoStart
12 | let subtitlesContainer
13 |
14 | if (available()) {
15 | if (useLegacySubs) {
16 | import("./legacysubtitles.js")
17 | .then(({ default: LegacySubtitles }) => {
18 | subtitlesContainer = LegacySubtitles(mediaPlayer, autoStart, playbackElement, mediaSources, defaultStyleOpts)
19 | callback(subtitlesEnabled)
20 | })
21 | .catch(() => {
22 | Plugins.interface.onSubtitlesDynamicLoadError()
23 | })
24 | } else if (embeddedSubs) {
25 | import("./embeddedsubtitles.js")
26 | .then(({ default: EmbeddedSubtitles }) => {
27 | subtitlesContainer = EmbeddedSubtitles(
28 | mediaPlayer,
29 | autoStart,
30 | playbackElement,
31 | mediaSources,
32 | defaultStyleOpts
33 | )
34 | callback(subtitlesEnabled)
35 | })
36 | .catch(() => {
37 | Plugins.interface.onSubtitlesDynamicLoadError()
38 | })
39 | } else {
40 | import("./imscsubtitles.js")
41 | .then(({ default: IMSCSubtitles }) => {
42 | subtitlesContainer = IMSCSubtitles(mediaPlayer, autoStart, playbackElement, mediaSources, defaultStyleOpts)
43 | callback(subtitlesEnabled)
44 | })
45 | .catch(() => {
46 | Plugins.interface.onSubtitlesDynamicLoadError()
47 | })
48 | }
49 | } else {
50 | /* This is needed to deal with a race condition wherein the Subtitles Callback runs before the Subtitles object
51 | * has finished construction. This is leveraging a feature of the Javascript Event Loop, specifically how it interacts
52 | * with Promises, called Microtasks.
53 | *
54 | * For more information, please see:
55 | * https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide
56 | */
57 | Promise.resolve().then(() => {
58 | callback(subtitlesEnabled)
59 | })
60 | }
61 |
62 | function enable() {
63 | subtitlesEnabled = true
64 | }
65 |
66 | function disable() {
67 | subtitlesEnabled = false
68 | }
69 |
70 | function show() {
71 | if (available() && enabled()) {
72 | subtitlesContainer?.start()
73 | }
74 | }
75 |
76 | function hide() {
77 | if (available()) {
78 | subtitlesContainer?.stop()
79 | }
80 | }
81 |
82 | function enabled() {
83 | return subtitlesEnabled
84 | }
85 |
86 | function available() {
87 | if (embeddedSubs) {
88 | return mediaPlayer && mediaPlayer.isSubtitlesAvailable()
89 | }
90 |
91 | const url = mediaSources.currentSubtitlesSource()
92 |
93 | if (!(typeof url === "string" && url !== "")) {
94 | return false
95 | }
96 |
97 | const isWhole = findSegmentTemplate(url) == null
98 |
99 | return isWhole || (!useLegacySubs && isSeekableLiveSupport)
100 | }
101 |
102 | function setPosition(position) {
103 | subtitlesContainer?.updatePosition(position)
104 | }
105 |
106 | function customise(styleOpts) {
107 | subtitlesContainer?.customise(styleOpts, subtitlesEnabled)
108 | }
109 |
110 | function renderExample(exampleXmlString, styleOpts, safePosition) {
111 | subtitlesContainer?.renderExample(exampleXmlString, styleOpts, safePosition)
112 | }
113 |
114 | function clearExample() {
115 | subtitlesContainer?.clearExample()
116 | }
117 |
118 | function tearDown() {
119 | subtitlesContainer?.tearDown()
120 | }
121 |
122 | return {
123 | enable,
124 | disable,
125 | show,
126 | hide,
127 | enabled,
128 | available,
129 | setPosition,
130 | customise,
131 | renderExample,
132 | clearExample,
133 | tearDown,
134 | }
135 | }
136 |
137 | export default Subtitles
138 |
--------------------------------------------------------------------------------
/src/subtitles/timedtext.js:
--------------------------------------------------------------------------------
1 | import DOMHelpers from "../domhelpers"
2 |
3 | /**
4 | * Safely checks if an attribute exists on an element.
5 | * Browsers < DOM Level 2 do not have 'hasAttribute'
6 | *
7 | * The interesting case - can be null when it isn't there or "", but then can also return "" when there is an attribute with no value.
8 | * For subs this is good enough. There should not be attributes without values.
9 | * @param {Element} el HTML Element
10 | * @param {String} attribute attribute to check for
11 | */
12 | function hasAttribute(el, attribute) {
13 | return !!el.getAttribute(attribute)
14 | }
15 |
16 | function hasNestedTime(element) {
17 | return !hasAttribute(element, "begin") || !hasAttribute(element, "end")
18 | }
19 |
20 | function TimedText(timedPieceNode, toStyleFunc) {
21 | const start = timeStampToSeconds(timedPieceNode.getAttribute("begin"))
22 | const end = timeStampToSeconds(timedPieceNode.getAttribute("end"))
23 | const _node = timedPieceNode
24 | let htmlElementNode
25 |
26 | function timeStampToSeconds(timeStamp) {
27 | const timePieces = timeStamp.split(":")
28 | let timeSeconds = parseFloat(timePieces.pop(), 10)
29 | if (timePieces.length) {
30 | timeSeconds += 60 * parseInt(timePieces.pop(), 10)
31 | }
32 | if (timePieces.length) {
33 | timeSeconds += 60 * 60 * parseInt(timePieces.pop(), 10)
34 | }
35 | return timeSeconds
36 | }
37 |
38 | function removeFromDomIfExpired(time) {
39 | if (time > end || time < start) {
40 | DOMHelpers.safeRemoveElement(htmlElementNode)
41 | return true
42 | }
43 | return false
44 | }
45 |
46 | function addToDom(parentNode) {
47 | const node = htmlElementNode || generateHtmlElementNode()
48 | parentNode.appendChild(node)
49 | }
50 |
51 | function generateHtmlElementNode(node) {
52 | const source = node || _node
53 | let localName = source.localName || source.tagName
54 |
55 | // We lose line breaks with nested TimePieces, so this provides similar layout
56 | const parentNodeLocalName =
57 | (source.parentNode && source.parentNode.localName) || (source.parentNode && source.parentNode.tagName)
58 | if (localName === "span" && parentNodeLocalName === "p" && hasNestedTime(source.parentNode)) {
59 | localName = "p"
60 | }
61 |
62 | const html = document.createElement(localName)
63 | const style = toStyleFunc(source)
64 | if (style) {
65 | html.setAttribute("style", style)
66 | html.style.cssText = style
67 | }
68 |
69 | if (localName === "p") {
70 | html.style.margin = "0px"
71 | }
72 |
73 | for (let i = 0, j = source.childNodes.length; i < j; i++) {
74 | const n = source.childNodes[i]
75 | if (n.nodeType === 3) {
76 | html.appendChild(document.createTextNode(n.data))
77 | } else if (n.nodeType === 1) {
78 | html.appendChild(generateHtmlElementNode(n))
79 | }
80 | }
81 | if (!node) {
82 | htmlElementNode = html
83 | }
84 |
85 | return html
86 | }
87 |
88 | return {
89 | start: start,
90 | end: end,
91 | // TODO: can we stop this from adding/removing itself from the DOM? Just expose the 'generateNode' function? OR just generate the node at creation and have a property...
92 | removeFromDomIfExpired: removeFromDomIfExpired,
93 | addToDom: addToDom,
94 | }
95 | }
96 |
97 | export default TimedText
98 |
--------------------------------------------------------------------------------
/src/subtitles/timedtext.test.js:
--------------------------------------------------------------------------------
1 | import TimedText from "./timedtext"
2 |
3 | describe("TimedText", () => {
4 | it("Should initialise with an Element and style callback function", () => {
5 | const mockElement = document.createElement("span")
6 | mockElement.setAttribute("begin", "00:00:10")
7 | mockElement.setAttribute("end", "00:00:13")
8 | const mockFunction = jest.fn()
9 | const timedText = TimedText(mockElement, mockFunction)
10 |
11 | expect(timedText).toEqual(
12 | expect.objectContaining({
13 | start: 10,
14 | end: 13,
15 | addToDom: expect.any(Function),
16 | removeFromDomIfExpired: expect.any(Function),
17 | })
18 | )
19 | })
20 |
21 | it("Should add itself to a supplied DOM element", () => {
22 | const domElement = document.createElement("div")
23 | const mockElement = document.createElement("span")
24 | mockElement.setAttribute("begin", "00:00:10")
25 | mockElement.setAttribute("end", "00:00:13")
26 | const mockParentElement = document.createElement("p")
27 | mockParentElement.appendChild(mockElement)
28 |
29 | const mockFunction = jest.fn()
30 | const timedText = TimedText(mockElement, mockFunction)
31 |
32 | timedText.addToDom(domElement)
33 |
34 | expect(domElement.hasChildNodes()).toBe(true)
35 | })
36 |
37 | it("Should remove itself from a parent DOM element", () => {
38 | const domElement = document.createElement("div")
39 | const mockElement = document.createElement("span")
40 | mockElement.setAttribute("begin", "00:00:10")
41 | mockElement.setAttribute("end", "00:00:13")
42 | const mockParentElement = document.createElement("p")
43 | mockParentElement.appendChild(mockElement)
44 |
45 | const mockFunction = jest.fn()
46 | const timedText = TimedText(mockElement, mockFunction)
47 |
48 | timedText.addToDom(domElement)
49 | timedText.removeFromDomIfExpired(14)
50 |
51 | expect(domElement.hasChildNodes()).toBe(false)
52 | })
53 | })
54 |
--------------------------------------------------------------------------------
/src/subtitles/transformer.js:
--------------------------------------------------------------------------------
1 | import TimedText from "./timedtext"
2 | import DOMHelpers from "../domhelpers"
3 | import Plugins from "../plugins"
4 | import DebugTool from "../debugger/debugtool"
5 |
6 | function Transformer() {
7 | const _styles = {}
8 | const elementToStyleMap = [
9 | {
10 | attribute: "tts:color",
11 | property: "color",
12 | },
13 | {
14 | attribute: "tts:backgroundColor",
15 | property: "text-shadow",
16 | },
17 | {
18 | attribute: "tts:fontStyle",
19 | property: "font-style",
20 | },
21 | {
22 | attribute: "tts:textAlign",
23 | property: "text-align",
24 | },
25 | ]
26 |
27 | /**
28 | * Safely checks if an attribute exists on an element.
29 | * Browsers < DOM Level 2 do not have 'hasAttribute'
30 | *
31 | * The interesting case - can be null when it isn't there or "", but then can also return "" when there is an attribute with no value.
32 | * For subs this is good enough. There should not be attributes without values.
33 | * @param {Element} el HTML Element
34 | * @param {String} attribute attribute to check for
35 | */
36 | const hasAttribute = (el, attribute) => !!el.getAttribute(attribute)
37 |
38 | function hasNestedTime(element) {
39 | return !hasAttribute(element, "begin") || !hasAttribute(element, "end")
40 | }
41 |
42 | function isEBUDistribution(metadata) {
43 | return metadata === "urn:ebu:tt:distribution:2014-01" || metadata === "urn:ebu:tt:distribution:2018-04"
44 | }
45 |
46 | function rgbWithOpacity(value) {
47 | if (DOMHelpers.isRGBA(value)) {
48 | let opacity = parseInt(value.slice(7, 9), 16) / 255
49 |
50 | if (isNaN(opacity)) {
51 | opacity = 1.0
52 | }
53 |
54 | value = DOMHelpers.rgbaToRGB(value)
55 | value += "; opacity: " + opacity + ";"
56 | }
57 | return value
58 | }
59 |
60 | function elementToStyle(el) {
61 | const styles = _styles
62 | const inherit = el.getAttribute("style")
63 | let stringStyle = ""
64 |
65 | if (inherit) {
66 | if (styles[inherit]) {
67 | stringStyle = styles[inherit]
68 | } else {
69 | return false
70 | }
71 | }
72 |
73 | for (let i = 0, j = elementToStyleMap.length; i < j; i++) {
74 | const map = elementToStyleMap[i]
75 | let value = el.getAttribute(map.attribute)
76 |
77 | if (value === null || value === undefined) {
78 | continue
79 | }
80 |
81 | if (map.conversion) {
82 | value = map.conversion(value)
83 | }
84 |
85 | if (map.attribute === "tts:backgroundColor") {
86 | value = rgbWithOpacity(value)
87 | value += " 2px 2px 1px"
88 | }
89 |
90 | if (map.attribute === "tts:color") {
91 | value = rgbWithOpacity(value)
92 | }
93 |
94 | stringStyle += map.property + ": " + value + "; "
95 | }
96 |
97 | return stringStyle
98 | }
99 |
100 | function transformXML(xml) {
101 | try {
102 | // Use .getElementsByTagNameNS() when parsing XML as some implementations of .getElementsByTagName() will lowercase its argument before proceding
103 | const conformsToStandardElements = Array.prototype.slice.call(
104 | xml.getElementsByTagNameNS("urn:ebu:tt:metadata", "conformsToStandard")
105 | )
106 | const isEBUTTD =
107 | conformsToStandardElements && conformsToStandardElements.some((node) => isEBUDistribution(node.textContent))
108 |
109 | const captionValues = {
110 | ttml: {
111 | namespace: "http://www.w3.org/2006/10/ttaf1",
112 | idAttribute: "id",
113 | },
114 | ebuttd: {
115 | namespace: "http://www.w3.org/ns/ttml",
116 | idAttribute: "xml:id",
117 | },
118 | }
119 |
120 | const captionStandard = isEBUTTD ? captionValues.ebuttd : captionValues.ttml
121 | const styles = _styles
122 | const styleElements = xml.getElementsByTagNameNS(captionStandard.namespace, "style")
123 |
124 | for (let i = 0; i < styleElements.length; i++) {
125 | const se = styleElements[i]
126 | const id = se.getAttribute(captionStandard.idAttribute)
127 | const style = elementToStyle(se)
128 |
129 | if (style) {
130 | styles[id] = style
131 | }
132 | }
133 |
134 | const body = xml.getElementsByTagNameNS(captionStandard.namespace, "body")[0]
135 | const s = elementToStyle(body)
136 | const ps = xml.getElementsByTagNameNS(captionStandard.namespace, "p")
137 | const items = []
138 |
139 | for (let k = 0, m = ps.length; k < m; k++) {
140 | if (hasNestedTime(ps[k])) {
141 | const tag = ps[k]
142 | for (let index = 0; index < tag.childNodes.length; index++) {
143 | if (hasAttribute(tag.childNodes[index], "begin") && hasAttribute(tag.childNodes[index], "end")) {
144 | // TODO: rather than pass a function, can't we make timedText look after it's style from this point?
145 | items.push(TimedText(tag.childNodes[index], elementToStyle))
146 | }
147 | }
148 | } else {
149 | items.push(TimedText(ps[k], elementToStyle))
150 | }
151 | }
152 |
153 | return {
154 | baseStyle: s,
155 | subtitlesForTime: (time) => items.filter((subtitle) => subtitle.start < time && subtitle.end > time),
156 | }
157 | } catch (e) {
158 | DebugTool.info("Error transforming captions : " + e)
159 | Plugins.interface.onSubtitlesTransformError()
160 | }
161 | }
162 |
163 | return {
164 | transformXML: transformXML,
165 | }
166 | }
167 |
168 | export default Transformer
169 |
--------------------------------------------------------------------------------
/src/subtitles/transformer.test.js:
--------------------------------------------------------------------------------
1 | import Transformer from "./transformer"
2 | import Plugins from "../plugins"
3 |
4 | const ttml = `
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
Mango balloons are too expensive and they smell odd, even if they taste amazing
16 |
TV: Sartorial before they sold out actually hammock retro trust fund swag authentic succulents palo santo
17 |
18 |
19 | `
20 |
21 | const ebuttd = `
22 |
23 |
24 |
25 | urn:ebu:tt:distribution:2014-01
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | Is that a balloon?
37 | Yes BANG
38 |
39 |
40 | `
41 |
42 | describe("Subtitle transformer", () => {
43 | it("Should load a TTML document", () => {
44 | const docparser = new DOMParser()
45 |
46 | const xmldoc = docparser.parseFromString(ttml, "text/xml")
47 | const doc = Transformer().transformXML(xmldoc)
48 |
49 | const subtitlesForZero = doc.subtitlesForTime(0)
50 | const singleSubtitle = doc.subtitlesForTime(14.1)
51 | const outOfRangeSubtitles = doc.subtitlesForTime(NaN)
52 |
53 | expect(doc.baseStyle).toEqual(expect.any(String))
54 |
55 | expect(subtitlesForZero).toHaveLength(0)
56 |
57 | expect(singleSubtitle).toHaveLength(1)
58 | expect(singleSubtitle[0]).toEqual(expect.objectContaining({ start: 14.04, end: 16.16 }))
59 |
60 | expect(outOfRangeSubtitles).toHaveLength(0)
61 | })
62 |
63 | it("Should load an EBU-TT-D document", () => {
64 | const docparser = new DOMParser()
65 |
66 | const xmldoc = docparser.parseFromString(ebuttd, "text/xml")
67 | const doc = Transformer().transformXML(xmldoc)
68 | const subtitlesForZero = doc.subtitlesForTime(0)
69 | const singleSubtitle = doc.subtitlesForTime(33.6)
70 | const cumulativeSubtitles = doc.subtitlesForTime(35.5)
71 | const outOfRangeSubtitles = doc.subtitlesForTime(NaN)
72 |
73 | expect(doc.baseStyle).toEqual(expect.any(String))
74 |
75 | expect(subtitlesForZero).toHaveLength(0)
76 |
77 | expect(singleSubtitle).toHaveLength(1)
78 | expect(singleSubtitle[0]).toEqual(expect.objectContaining({ start: 33.56, end: 34.96 }))
79 |
80 | expect(cumulativeSubtitles).toHaveLength(2)
81 | expect(cumulativeSubtitles[0]).toEqual(expect.objectContaining({ start: 34.96, end: 37 }))
82 | expect(cumulativeSubtitles[1]).toEqual(expect.objectContaining({ start: 35.2, end: 37 }))
83 |
84 | expect(outOfRangeSubtitles).toHaveLength(0)
85 | })
86 |
87 | it("Should fire a onSubtitlesTransformError on transform failure", () => {
88 | jest.spyOn(Plugins.interface, "onSubtitlesTransformError")
89 | Transformer().transformXML("")
90 |
91 | expect(Plugins.interface.onSubtitlesTransformError).toHaveBeenCalledWith()
92 | })
93 | })
94 |
--------------------------------------------------------------------------------
/src/testutils/geterror.js:
--------------------------------------------------------------------------------
1 | export class NoErrorThrownError extends Error {}
2 |
3 | /**
4 | * @see {@link https://github.com/jest-community/eslint-plugin-jest/blob/main/docs/rules/no-conditional-expect.md#how-to-catch-a-thrown-error-for-testing-without-violating-this-rule} [2023-06-16]
5 | *
6 | * @param {Function} call
7 | * @returns {Promise}
8 | */
9 | export default async function getError(call) {
10 | try {
11 | await call()
12 |
13 | throw new NoErrorThrownError()
14 | } catch (error) {
15 | return error
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import { ProtectionDataSet, type MediaPlayerSettingClass } from "dashjs"
2 | import { type Timeline } from "./models/timeline"
3 |
4 | export type Connection = {
5 | cdn: string
6 | url: string
7 | }
8 |
9 | export type CaptionsConnection = Connection & {
10 | segmentLength: number
11 | }
12 |
13 | type Settings = MediaPlayerSettingClass & {
14 | failoverResetTime: number
15 | failoverSort: (sources: Connection[]) => Connection[]
16 | streaming: {
17 | seekDurationPadding: number
18 | }
19 | }
20 |
21 | export type SubtitlesCustomisationOptions = Partial<{
22 | /** CSS background-color string or hex string */
23 | backgroundColor: string
24 | /** CSS font-family string */
25 | fontFamily: string
26 | /** lineHeight multiplier to authored subtitles */
27 | lineHeight: number
28 | /** size multiplier to authored subtitles */
29 | size: number
30 | }>
31 |
32 | export type PlaybackTime = {
33 | seconds: number
34 | timeline: Timeline
35 | }
36 |
37 | export type MediaProtectionData = ProtectionDataSet
38 |
39 | export type MediaDescriptor = {
40 | kind: "audio" | "video"
41 | /** f.ex. 'video/mp4' */
42 | mimeType: string
43 | /** source type. f.ex. 'application/dash+xml' */
44 | type: string
45 | urls: Connection[]
46 | protectionData?: MediaProtectionData
47 | captions?: CaptionsConnection[]
48 | audioDescribed?: Connection[]
49 | /** Location for a captions file */
50 | captionsUrl?: string
51 | playerSettings?: Partial
52 | subtitlesCustomisation?: SubtitlesCustomisationOptions
53 | subtitlesRequestTimeout?: number
54 | }
55 |
56 | export type InitData = {
57 | media: MediaDescriptor
58 | enableSubtitles?: boolean
59 | enableAudioDescribed?: boolean
60 | initialPlaybackTime?: number | PlaybackTime
61 | }
62 |
63 | export type InitCallbacks = {
64 | onError: () => void
65 | onSuccess: () => void
66 | }
67 |
--------------------------------------------------------------------------------
/src/utils/callcallbacks.test.ts:
--------------------------------------------------------------------------------
1 | import callCallbacks from "./callcallbacks"
2 |
3 | describe("callCallbacks", () => {
4 | it("calls all the callbacks once with the provided data", () => {
5 | const callbacks = [jest.fn(), jest.fn()]
6 | const data = "data"
7 |
8 | callCallbacks(callbacks, data)
9 |
10 | callbacks.forEach((callback) => {
11 | expect(callback).toHaveBeenCalledTimes(1)
12 | expect(callback).toHaveBeenCalledWith(data)
13 | })
14 | })
15 |
16 | // Note: Forgive the time hack, async deferred errors can be flakey in other tests if not caught!
17 | it("calls later callbacks if an earlier one errors", () => {
18 | jest.useFakeTimers()
19 | const callback = jest.fn()
20 |
21 | const failingCallCallbacks = () => {
22 | callCallbacks([
23 | () => {
24 | throw new Error("oops")
25 | },
26 | callback,
27 | ])
28 | jest.advanceTimersByTime(1)
29 | }
30 |
31 | expect(failingCallCallbacks).toThrow()
32 |
33 | expect(callback).toHaveBeenCalledTimes(1)
34 | jest.useRealTimers()
35 | })
36 | })
37 |
--------------------------------------------------------------------------------
/src/utils/callcallbacks.ts:
--------------------------------------------------------------------------------
1 | import deferExceptions from "./deferexceptions"
2 | import utils from "./playbackutils"
3 |
4 | export default function CallCallbacks(callbacks: ((param: unknown) => void)[], data?: unknown) {
5 | const originalCallbacks = utils.deepClone(callbacks)
6 | for (let index = callbacks.length - 1; index >= 0; index--) {
7 | const originalLength = callbacks.length
8 |
9 | deferExceptions(() => callbacks[index](data))
10 |
11 | const newLength = callbacks.length
12 | const callbackRemovedSelf = callbacks.indexOf(originalCallbacks[index]) === -1
13 |
14 | if (originalLength !== newLength && !callbackRemovedSelf) {
15 | index = index - (originalLength - newLength)
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/utils/deferexception.test.ts:
--------------------------------------------------------------------------------
1 | import deferExceptions from "./deferexceptions"
2 |
3 | describe("deferExceptions", () => {
4 | it("calls the callback once", () => {
5 | const callback = jest.fn()
6 |
7 | deferExceptions(callback)
8 |
9 | expect(callback).toHaveBeenCalledTimes(1)
10 | })
11 |
12 | it("does not let an exception through", () => {
13 | jest.useFakeTimers()
14 |
15 | const newError = new Error("oops")
16 |
17 | try {
18 | expect(() => {
19 | deferExceptions(() => {
20 | throw newError
21 | })
22 | }).not.toThrow()
23 | jest.advanceTimersByTime(1)
24 | } catch (error) {
25 | // eslint-disable-next-line jest/no-conditional-expect
26 | expect(error).toBe(newError)
27 | }
28 |
29 | jest.useRealTimers()
30 | })
31 | })
32 |
--------------------------------------------------------------------------------
/src/utils/deferexceptions.ts:
--------------------------------------------------------------------------------
1 | export default function DeferExceptions(callback: () => void) {
2 | try {
3 | callback()
4 | } catch (error: unknown) {
5 | setTimeout(() => {
6 | throw error
7 | }, 0)
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/utils/findtemplate.test.ts:
--------------------------------------------------------------------------------
1 | import findTemplate from "./findtemplate"
2 |
3 | describe("strings without segment template pattern", () => {
4 | it.each(["mock://some.media/subtitles/captions.xml", "string"])("returns null for %s", (string) => {
5 | expect(findTemplate(string)).toBeNull()
6 | })
7 | })
8 |
9 | describe("strings with segment template pattern", () => {
10 | it.each([
11 | ["mock://some.media/subtitles/$segment$.m4s", "$segment$"],
12 | ["$segment$", "$segment$"],
13 | ["mock://subtitles/$Number$.ext", "$Number$"],
14 | ])("returns segment template substring for %s", (string, segmentTemplate) => {
15 | expect(findTemplate(string)).toBe(segmentTemplate)
16 | })
17 | })
18 |
--------------------------------------------------------------------------------
/src/utils/findtemplate.ts:
--------------------------------------------------------------------------------
1 | const SEGMENT_TEMPLATE_MATCHER = /\$[A-Za-z]+\$/g
2 |
3 | export default function findSegmentTemplate(url: string) {
4 | const matches = url.match(SEGMENT_TEMPLATE_MATCHER)
5 |
6 | if (matches == null) {
7 | return null
8 | }
9 |
10 | return matches[matches.length - 1]
11 | }
12 |
--------------------------------------------------------------------------------
/src/utils/get-values.ts:
--------------------------------------------------------------------------------
1 | export default function getValues(obj: { [s: string]: Value }): Value[] {
2 | const values = []
3 |
4 | for (const key in obj) {
5 | if (!Object.prototype.hasOwnProperty.call(obj, key)) {
6 | continue
7 | }
8 |
9 | values.push(obj[key])
10 | }
11 |
12 | return values
13 | }
14 |
--------------------------------------------------------------------------------
/src/utils/handleplaypromise.ts:
--------------------------------------------------------------------------------
1 | export default function handlePlayPromise(playPromise?: Promise) {
2 | if (!playPromise || typeof playPromise.catch !== "function") return
3 |
4 | playPromise.catch((error?: Error) => {
5 | if (error && error.name === "AbortError") {
6 | return
7 | }
8 | throw error
9 | })
10 | }
11 |
--------------------------------------------------------------------------------
/src/utils/iserror.ts:
--------------------------------------------------------------------------------
1 | export default function isError(obj: unknown): obj is Error {
2 | return obj != null && typeof obj === "object" && "name" in obj && "message" in obj
3 | }
4 |
--------------------------------------------------------------------------------
/src/utils/loadurl.ts:
--------------------------------------------------------------------------------
1 | import isError from "./iserror"
2 |
3 | type LoadUrlOpts = {
4 | headers: { [key: string]: string }
5 | timeout?: number
6 | onTimeout?: XMLHttpRequest["ontimeout"]
7 | onLoad?: (responseXML: Document | null, responseText: string, status: number) => void
8 | onError?: (params: { errorType: string; statusCode: number }) => void
9 | method?: string
10 | data?: Document | XMLHttpRequestBodyInit | null
11 | }
12 |
13 | export default function LoadUrl(url: string | URL, opts: LoadUrlOpts) {
14 | const xhr = new XMLHttpRequest()
15 |
16 | if (opts.timeout) {
17 | xhr.timeout = opts.timeout
18 | }
19 |
20 | if (opts.onTimeout) {
21 | xhr.ontimeout = opts.onTimeout
22 | }
23 |
24 | xhr.addEventListener("readystatechange", function listener() {
25 | if (xhr.readyState === 4) {
26 | xhr.removeEventListener("readystatechange", listener)
27 | if (xhr.status >= 200 && xhr.status < 300) {
28 | if (opts.onLoad) {
29 | opts.onLoad(xhr.responseXML, xhr.responseText, xhr.status)
30 | }
31 | } else {
32 | if (opts.onError) {
33 | opts.onError({ errorType: "NON_200_ERROR", statusCode: xhr.status })
34 | }
35 | }
36 | }
37 | })
38 |
39 | try {
40 | xhr.open(opts.method || "GET", url, true)
41 |
42 | if (opts.headers) {
43 | for (const header in opts.headers) {
44 | if (Object.prototype.hasOwnProperty.call(opts.headers, header)) {
45 | xhr.setRequestHeader(header, opts.headers[header])
46 | }
47 | }
48 | }
49 |
50 | xhr.send(opts.data || null)
51 | } catch (error: unknown) {
52 | if (opts.onError) {
53 | opts.onError({ errorType: isError(error) ? error.name : "unknown", statusCode: xhr.status })
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/utils/mse/convert-timeranges-to-array.ts:
--------------------------------------------------------------------------------
1 | type Range = [start: number, end: number]
2 |
3 | export default function convertTimeRangesToArray(ranges: TimeRanges): Range[] {
4 | const array: Range[] = []
5 |
6 | for (let rangesSoFar = 0; rangesSoFar < ranges.length; rangesSoFar += 1) {
7 | array.push([ranges.start(rangesSoFar), ranges.end(rangesSoFar)])
8 | }
9 |
10 | return array
11 | }
12 |
--------------------------------------------------------------------------------
/src/utils/playbackutils.js:
--------------------------------------------------------------------------------
1 | export default {
2 | clone: (args) => {
3 | const clone = {}
4 | for (const prop in args) {
5 | if (args.hasOwnProperty(prop)) {
6 | clone[prop] = args[prop]
7 | }
8 | }
9 | return clone
10 | },
11 |
12 | deepClone: function (objectToClone) {
13 | if (!objectToClone) {
14 | return objectToClone
15 | }
16 |
17 | let clone, propValue, propName
18 | clone = Array.isArray(objectToClone) ? [] : {}
19 | for (propName in objectToClone) {
20 | propValue = objectToClone[propName]
21 |
22 | // check for date
23 | if (propValue && Object.prototype.toString.call(propValue) === "[object Date]") {
24 | clone[propName] = new Date(propValue)
25 | continue
26 | }
27 |
28 | clone[propName] = typeof propValue === "object" ? this.deepClone(propValue) : propValue
29 | }
30 | return clone
31 | },
32 |
33 | cloneArray: function (arr) {
34 | const clone = []
35 |
36 | for (let i = 0, n = arr.length; i < n; i++) {
37 | clone.push(this.clone(arr[i]))
38 | }
39 |
40 | return clone
41 | },
42 |
43 | merge: function () {
44 | const merged = {}
45 |
46 | for (let i = 0; i < arguments.length; i++) {
47 | const obj = arguments[i]
48 | for (const prop in obj) {
49 | if (obj.hasOwnProperty(prop)) {
50 | if (Object.prototype.toString.call(obj[prop]) === "[object Object]") {
51 | merged[prop] = this.merge(merged[prop], obj[prop])
52 | } else {
53 | merged[prop] = obj[prop]
54 | }
55 | }
56 | }
57 | }
58 |
59 | return merged
60 | },
61 |
62 | arrayStartsWith: (array, partial) => {
63 | for (let i = 0; i < partial.length; i++) {
64 | if (array[i] !== partial[i]) {
65 | return false
66 | }
67 | }
68 |
69 | return true
70 | },
71 |
72 | find: (array, predicate) => {
73 | return array.reduce((acc, it, i) => {
74 | return acc !== false ? acc : predicate(it) && it
75 | }, false)
76 | },
77 |
78 | findIndex: (array, predicate) => {
79 | return array.reduce((acc, it, i) => {
80 | return acc !== false ? acc : predicate(it) && i
81 | }, false)
82 | },
83 |
84 | swap: (array, i, j) => {
85 | const arr = array.slice()
86 | const temp = arr[i]
87 |
88 | arr[i] = arr[j]
89 | arr[j] = temp
90 |
91 | return arr
92 | },
93 |
94 | pluck: (array, property) => {
95 | const plucked = []
96 |
97 | for (let i = 0; i < array.length; i++) {
98 | plucked.push(array[i][property])
99 | }
100 |
101 | return plucked
102 | },
103 |
104 | flatten: (arr) => [].concat.apply([], arr),
105 |
106 | without: (arr, value) => {
107 | const newArray = []
108 |
109 | for (let i = 0; i < arr.length; i++) {
110 | if (arr[i] !== value) {
111 | newArray.push(arr[i])
112 | }
113 | }
114 |
115 | return newArray
116 | },
117 |
118 | contains: (arr, subset) => {
119 | return [].concat(subset).every((item) => {
120 | return [].concat(arr).indexOf(item) > -1
121 | })
122 | },
123 |
124 | pickRandomFromArray: (arr) => {
125 | return arr[Math.floor(Math.random() * arr.length)]
126 | },
127 |
128 | filter: (arr, predicate) => {
129 | const filteredArray = []
130 |
131 | for (let i = 0; i < arr.length; i++) {
132 | if (predicate(arr[i])) {
133 | filteredArray.push(arr[i])
134 | }
135 | }
136 |
137 | return filteredArray
138 | },
139 |
140 | noop: () => {},
141 |
142 | generateUUID: () => {
143 | let d = new Date().getTime()
144 |
145 | return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
146 | const r = (d + Math.random() * 16) % 16 | 0
147 | d = Math.floor(d / 16)
148 | return (c === "x" ? r : (r & 0x3) | 0x8).toString(16)
149 | })
150 | },
151 |
152 | path: (object, keys) => {
153 | return (keys || []).reduce((accum, key) => {
154 | return (accum || {})[key]
155 | }, object || {})
156 | },
157 | }
158 |
--------------------------------------------------------------------------------
/src/utils/playbackutils.test.js:
--------------------------------------------------------------------------------
1 | import PlaybackUtils from "./playbackutils"
2 |
3 | describe("Playback utils", () => {
4 | describe("Clone", () => {
5 | it("Makes a shallow clone of an object", () => {
6 | const input = {
7 | foo: 1,
8 | bar: "foo bar",
9 | }
10 |
11 | const clone = PlaybackUtils.clone(input)
12 |
13 | input.foo = 2
14 | input.bar = "boo far"
15 |
16 | expect(clone.foo).toBe(1)
17 | expect(clone.bar).toBe("foo bar")
18 | })
19 | })
20 |
21 | describe("Clone array", () => {
22 | it("Makes a shallow clone of an array", () => {
23 | const input = [
24 | {
25 | foo: 1,
26 | bar: "foo bar",
27 | },
28 | {
29 | foo: 2,
30 | bar: "foo bar 2",
31 | },
32 | ]
33 |
34 | const clone = PlaybackUtils.cloneArray(input)
35 |
36 | input[0].foo = 100
37 | input[0].bar = "boo far"
38 |
39 | input[1].foo = 200
40 | input[1].bar = "2 boo far"
41 |
42 | expect(clone[0].foo).toBe(1)
43 | expect(clone[0].bar).toBe("foo bar")
44 |
45 | expect(clone[1].foo).toBe(2)
46 | expect(clone[1].bar).toBe("foo bar 2")
47 | })
48 | })
49 |
50 | describe("Merge", () => {
51 | it("Creates a new object with properties merged from all supplied objects", () => {
52 | const obj1 = { obj1a: "a", obj1b: "b" }
53 | const obj2 = { obj2a: "a", obj2b: "b" }
54 | const obj3 = { obj3a: "a", obj3b: "b" }
55 |
56 | const merged = PlaybackUtils.merge(obj1, obj2, obj3)
57 |
58 | expect(merged).toEqual({
59 | obj1a: "a",
60 | obj1b: "b",
61 | obj2a: "a",
62 | obj2b: "b",
63 | obj3a: "a",
64 | obj3b: "b",
65 | })
66 | })
67 |
68 | it("Should merge deep objects and overwrite with the latest argument", () => {
69 | const obj1 = {
70 | data: {
71 | test1: "test1",
72 | propToBeChanged: "test2",
73 | },
74 | }
75 | const obj2 = {
76 | data: {
77 | propToBeChanged: "PropHasBeenChanged",
78 | test2: "test2",
79 | },
80 | }
81 |
82 | const merged = PlaybackUtils.merge(obj1, obj2)
83 |
84 | expect(merged).toEqual({
85 | data: {
86 | test1: "test1",
87 | propToBeChanged: "PropHasBeenChanged",
88 | test2: "test2",
89 | },
90 | })
91 | })
92 | })
93 |
94 | describe("Array start with", () => {
95 | it("Returns true if the supplied array starts with the items in the partial array", () => {
96 | expect(PlaybackUtils.arrayStartsWith(["x", "y", "z"], ["x", "y", "z"])).toBe(true)
97 | expect(PlaybackUtils.arrayStartsWith(["x", "y", "z"], ["x", "y"])).toBe(true)
98 | expect(PlaybackUtils.arrayStartsWith(["x", "y", "z"], ["x"])).toBe(true)
99 | expect(PlaybackUtils.arrayStartsWith(["x", "y", "z"], [])).toBe(true)
100 | })
101 |
102 | it("Returns false if the supplied array does not start with the items in the partial array", () => {
103 | expect(PlaybackUtils.arrayStartsWith(["x", "y", "z"], ["x", "z"])).toBe(false)
104 | expect(PlaybackUtils.arrayStartsWith(["x", "y"], ["x", "y", "z"])).toBe(false)
105 | expect(PlaybackUtils.arrayStartsWith(["x", "y"], ["x", "z"])).toBe(false)
106 | expect(PlaybackUtils.arrayStartsWith([], ["x"])).toBe(false)
107 | })
108 | })
109 |
110 | describe("Pluck", () => {
111 | it("Returns an array of attribute values requested", () => {
112 | const array = [
113 | {
114 | foo: 1,
115 | bar: 2,
116 | },
117 | {
118 | foo: 3,
119 | bar: 4,
120 | },
121 | {
122 | foo: 5,
123 | bar: 6,
124 | },
125 | ]
126 |
127 | const expectedFoo = [1, 3, 5]
128 | const expectedBar = [2, 4, 6]
129 |
130 | expect(PlaybackUtils.pluck(array, "foo")).toEqual(expectedFoo)
131 | expect(PlaybackUtils.pluck(array, "bar")).toEqual(expectedBar)
132 | })
133 | })
134 |
135 | describe("Flatten", () => {
136 | it("Returns the new flattened array", () => {
137 | expect(PlaybackUtils.flatten([1, [2, [3, [4]], 5]])).toEqual([1, 2, [3, [4]], 5])
138 | })
139 | })
140 |
141 | describe("Without", () => {
142 | it("Returns new array excluding all occurrences of given value", () => {
143 | expect(PlaybackUtils.without(["a", "", "b", "", "c"], "")).toEqual(["a", "b", "c"])
144 | expect(PlaybackUtils.without(["a", "b", "c", "d"], "c")).toEqual(["a", "b", "d"])
145 | expect(PlaybackUtils.without(["a", "b", "c"], "d")).toEqual(["a", "b", "c"])
146 | })
147 | })
148 |
149 | describe("Swap", () => {
150 | it("should swap two items", () => {
151 | expect(PlaybackUtils.swap([1, 2, 3, 4], 1, 2)).toEqual([1, 3, 2, 4])
152 | })
153 |
154 | it("should not modify the original array", () => {
155 | const orig = [1, 2, 3, 4]
156 | PlaybackUtils.swap(orig, 1, 2)
157 |
158 | expect(orig).toEqual([1, 2, 3, 4])
159 | })
160 | })
161 | })
162 |
--------------------------------------------------------------------------------
/src/utils/timeconverter.test.ts:
--------------------------------------------------------------------------------
1 | import { TimeInfo } from "../manifest/manifestparser"
2 | import { ManifestType } from "../models/manifesttypes"
3 | import createTimeConverter from "./timeconverter"
4 |
5 | const someUtcTime = Date.now()
6 |
7 | function createTimeInfo(partial: Partial): TimeInfo {
8 | return {
9 | manifestType: "static",
10 | availabilityStartTimeInMilliseconds: 0,
11 | presentationTimeOffsetInMilliseconds: 0,
12 | timeShiftBufferDepthInMilliseconds: 0,
13 | ...partial,
14 | }
15 | }
16 |
17 | describe("presentation time to availability time", () => {
18 | it("returns `null` for static manifest", () => {
19 | expect(
20 | createTimeConverter(createTimeInfo({ manifestType: "static" })).presentationTimeToAvailabilityTimeInMilliseconds(
21 | 15
22 | )
23 | ).toBeNull()
24 | })
25 |
26 | it.each([
27 | [someUtcTime, 0, someUtcTime],
28 | [someUtcTime + 14000, 14, someUtcTime],
29 | ])(
30 | "returns %i for presentation time %i given availability start time %i",
31 | (expectedAvailabilityTimeInMilliseconds, presentationTimeInSeconds, availabilityStartTimeInMilliseconds) => {
32 | expect(
33 | createTimeConverter(
34 | createTimeInfo({ availabilityStartTimeInMilliseconds, manifestType: "dynamic" })
35 | ).presentationTimeToAvailabilityTimeInMilliseconds(presentationTimeInSeconds)
36 | ).toBe(expectedAvailabilityTimeInMilliseconds)
37 | }
38 | )
39 | })
40 |
41 | describe("availability time to presentation time", () => {
42 | it("returns `null` for a static manifest", () => {
43 | expect(
44 | createTimeConverter(createTimeInfo({ manifestType: "static" })).availabilityTimeToPresentationTimeInSeconds(
45 | someUtcTime
46 | )
47 | ).toBeNull()
48 | })
49 |
50 | it.each([
51 | [0, someUtcTime, someUtcTime],
52 | [14, someUtcTime + 14000, someUtcTime],
53 | [0, someUtcTime - 10000, someUtcTime], // clamp to zero if requested time is prior to the availabilityStartTime
54 | ])(
55 | "returns %i for availability time %i given availability start time %i",
56 | (expectedPresentationTimeInSeconds, availabilityTimeInMilliseconds, availabilityStartTimeInMilliseconds) => {
57 | expect(
58 | createTimeConverter(
59 | createTimeInfo({ availabilityStartTimeInMilliseconds, manifestType: "dynamic" })
60 | ).availabilityTimeToPresentationTimeInSeconds(availabilityTimeInMilliseconds)
61 | ).toBe(expectedPresentationTimeInSeconds)
62 | }
63 | )
64 | })
65 |
66 | describe("presentation time to media sample time", () => {
67 | it.each([
68 | [10, 10, 0, "static"],
69 | [26, 16, 10000, "static"],
70 | [1731326236, 10, 1731326226000, "dynamic"],
71 | ] satisfies [number, number, number, ManifestType][])(
72 | "returns %i for presentation time %i given presentation time offset %i",
73 | (
74 | expectedMediaSampleTimeInSeconds,
75 | presentationTimeInSeconds,
76 | presentationTimeOffsetInMilliseconds,
77 | manifestType
78 | ) => {
79 | expect(
80 | createTimeConverter(
81 | createTimeInfo({ presentationTimeOffsetInMilliseconds, manifestType })
82 | ).presentationTimeToMediaSampleTimeInSeconds(presentationTimeInSeconds)
83 | ).toBe(expectedMediaSampleTimeInSeconds)
84 | }
85 | )
86 | })
87 |
88 | describe("media sample time to presentation time", () => {
89 | it.each([
90 | [10, 10, 0, "static"],
91 | [16, 26, 10000, "static"],
92 | [10, 1731326236, 1731326226000, "dynamic"],
93 | ] satisfies [number, number, number, ManifestType][])(
94 | "returns %i for media sample time %i given presentation time offset %i",
95 | (
96 | expectedPresentationTimeInSeconds,
97 | mediaSampleTimeInSeconds,
98 | presentationTimeOffsetInMilliseconds,
99 | manifestType
100 | ) => {
101 | expect(
102 | createTimeConverter(
103 | createTimeInfo({ presentationTimeOffsetInMilliseconds, manifestType })
104 | ).mediaSampleTimeToPresentationTimeInSeconds(mediaSampleTimeInSeconds)
105 | ).toBe(expectedPresentationTimeInSeconds)
106 | }
107 | )
108 | })
109 |
--------------------------------------------------------------------------------
/src/utils/timeconverter.ts:
--------------------------------------------------------------------------------
1 | import { TimeInfo } from "../manifest/manifestparser"
2 | import { ManifestType } from "../models/manifesttypes"
3 |
4 | export default function createTimeConverter(timeInfo: TimeInfo) {
5 | function presentationTimeToAvailabilityTimeInMilliseconds(presentationTimeInSeconds: number): number | null {
6 | if (timeInfo.manifestType === ManifestType.STATIC) {
7 | return null
8 | }
9 |
10 | return presentationTimeInSeconds * 1000 + timeInfo.availabilityStartTimeInMilliseconds
11 | }
12 |
13 | function availabilityTimeToPresentationTimeInSeconds(availabilityTimeInMilliseconds: number): number | null {
14 | if (timeInfo.manifestType === ManifestType.STATIC) {
15 | return null
16 | }
17 |
18 | return availabilityTimeInMilliseconds < timeInfo.availabilityStartTimeInMilliseconds
19 | ? 0
20 | : (availabilityTimeInMilliseconds - timeInfo.availabilityStartTimeInMilliseconds) / 1000
21 | }
22 |
23 | function presentationTimeToMediaSampleTimeInSeconds(presentationTimeInSeconds: number): number {
24 | return presentationTimeInSeconds + timeInfo.presentationTimeOffsetInMilliseconds / 1000
25 | }
26 |
27 | function mediaSampleTimeToPresentationTimeInSeconds(mediaSampleTimeInSeconds: number): number {
28 | return mediaSampleTimeInSeconds - timeInfo.presentationTimeOffsetInMilliseconds / 1000
29 | }
30 |
31 | return {
32 | presentationTimeToAvailabilityTimeInMilliseconds,
33 | availabilityTimeToPresentationTimeInSeconds,
34 | presentationTimeToMediaSampleTimeInSeconds,
35 | mediaSampleTimeToPresentationTimeInSeconds,
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/utils/timeshiftdetector.test.ts:
--------------------------------------------------------------------------------
1 | import createTimeShiftDetector from "./timeshiftdetector"
2 |
3 | beforeAll(() => {
4 | jest.useFakeTimers()
5 | })
6 |
7 | beforeEach(() => {
8 | jest.clearAllMocks()
9 | jest.clearAllTimers()
10 | })
11 |
12 | it("triggers the callback once time shift is detected on the observed player", () => {
13 | const mockGetSeekableRange = jest.fn().mockReturnValueOnce({ start: 0, end: 100 })
14 |
15 | const onceTimeShiftDetected = jest.fn()
16 |
17 | const timeShiftDetector = createTimeShiftDetector(onceTimeShiftDetected)
18 |
19 | timeShiftDetector.observe(mockGetSeekableRange)
20 |
21 | expect(onceTimeShiftDetected).not.toHaveBeenCalled()
22 |
23 | mockGetSeekableRange.mockReturnValueOnce({ start: 100, end: 200 })
24 |
25 | jest.advanceTimersToNextTimer()
26 |
27 | expect(onceTimeShiftDetected).toHaveBeenCalled()
28 | })
29 |
30 | it("does not trigger the callback when only seekable range end changes", () => {
31 | const mockGetSeekableRange = jest.fn().mockReturnValueOnce({ start: 0, end: 100 })
32 |
33 | const onceTimeShiftDetected = jest.fn()
34 |
35 | const timeShiftDetector = createTimeShiftDetector(onceTimeShiftDetected)
36 |
37 | timeShiftDetector.observe(mockGetSeekableRange)
38 |
39 | expect(onceTimeShiftDetected).not.toHaveBeenCalled()
40 |
41 | mockGetSeekableRange.mockReturnValueOnce({ start: 0, end: 200 })
42 |
43 | jest.advanceTimersToNextTimer()
44 |
45 | expect(onceTimeShiftDetected).not.toHaveBeenCalled()
46 | })
47 |
48 | it("only triggers callback once", () => {
49 | const mockGetSeekableRange = jest.fn().mockReturnValueOnce({ start: 0, end: 100 })
50 |
51 | const onceTimeShiftDetected = jest.fn()
52 |
53 | const timeShiftDetector = createTimeShiftDetector(onceTimeShiftDetected)
54 |
55 | timeShiftDetector.observe(mockGetSeekableRange)
56 |
57 | expect(onceTimeShiftDetected).not.toHaveBeenCalled()
58 |
59 | mockGetSeekableRange.mockReturnValueOnce({ start: 50, end: 200 })
60 |
61 | jest.advanceTimersToNextTimer()
62 |
63 | expect(onceTimeShiftDetected).toHaveBeenCalledTimes(1)
64 |
65 | mockGetSeekableRange.mockReturnValueOnce({ start: 100, end: 200 })
66 |
67 | jest.advanceTimersToNextTimer()
68 |
69 | expect(onceTimeShiftDetected).toHaveBeenCalledTimes(1)
70 | })
71 |
72 | it("reports seekable range as sliding once time shift is detected on the observed player", () => {
73 | const mockGetSeekableRange = jest.fn().mockReturnValueOnce({ start: 0, end: 100 })
74 |
75 | const timeShiftDetector = createTimeShiftDetector(jest.fn())
76 |
77 | timeShiftDetector.observe(mockGetSeekableRange)
78 |
79 | expect(timeShiftDetector.isSeekableRangeSliding()).toBe(false)
80 |
81 | mockGetSeekableRange.mockReturnValueOnce({ start: 50, end: 150 })
82 |
83 | jest.advanceTimersToNextTimer()
84 |
85 | expect(timeShiftDetector.isSeekableRangeSliding()).toBe(true)
86 | })
87 |
88 | it("does not trigger the callback when timeshift occurs on a disconnected player", () => {
89 | const mockGetSeekableRange = jest.fn().mockReturnValueOnce({ start: 0, end: 100 })
90 |
91 | const onceTimeShiftDetected = jest.fn()
92 |
93 | const timeShiftDetector = createTimeShiftDetector(onceTimeShiftDetected)
94 |
95 | timeShiftDetector.observe(mockGetSeekableRange)
96 |
97 | expect(onceTimeShiftDetected).not.toHaveBeenCalled()
98 |
99 | timeShiftDetector.disconnect()
100 |
101 | mockGetSeekableRange.mockReturnValueOnce({ start: 50, end: 200 })
102 |
103 | jest.advanceTimersToNextTimer()
104 |
105 | expect(onceTimeShiftDetected).not.toHaveBeenCalled()
106 | })
107 |
108 | it("overwrite the currently observed seekable range with a new seekable range", () => {
109 | const someSeekableRange = jest.fn().mockReturnValueOnce({ start: 0, end: 100 })
110 |
111 | const onceTimeShiftDetected = jest.fn()
112 |
113 | const timeShiftDetector = createTimeShiftDetector(onceTimeShiftDetected)
114 |
115 | timeShiftDetector.observe(someSeekableRange)
116 |
117 | expect(onceTimeShiftDetected).not.toHaveBeenCalled()
118 |
119 | const otherSeekableRange = jest.fn().mockReturnValueOnce({ start: 30, end: 90 })
120 |
121 | timeShiftDetector.observe(otherSeekableRange)
122 |
123 | someSeekableRange.mockReturnValueOnce({ start: 50, end: 150 }) // sliding
124 | otherSeekableRange.mockReturnValueOnce({ start: 30, end: 100 }) // not sliding
125 |
126 | jest.advanceTimersToNextTimer()
127 |
128 | expect(onceTimeShiftDetected).not.toHaveBeenCalled()
129 | })
130 |
131 | it("only set/trigger isSliding once device is reporting seekable range reliably", () => {
132 | const mockGetSeekableRange = jest.fn()
133 |
134 | const onceTimeShiftDetected = jest.fn()
135 |
136 | const timeShiftDetector = createTimeShiftDetector(onceTimeShiftDetected)
137 |
138 | timeShiftDetector.observe(mockGetSeekableRange)
139 |
140 | jest.advanceTimersToNextTimer()
141 |
142 | expect(onceTimeShiftDetected).not.toHaveBeenCalled()
143 | expect(timeShiftDetector.isSeekableRangeSliding()).toBe(false)
144 |
145 | mockGetSeekableRange.mockReturnValueOnce({ start: 50, end: 200 })
146 | jest.advanceTimersToNextTimer()
147 |
148 | expect(onceTimeShiftDetected).not.toHaveBeenCalled()
149 | expect(timeShiftDetector.isSeekableRangeSliding()).toBe(false)
150 |
151 | mockGetSeekableRange.mockReturnValueOnce({ start: 55, end: 200 })
152 | jest.advanceTimersToNextTimer()
153 |
154 | expect(onceTimeShiftDetected).toHaveBeenCalled()
155 | expect(timeShiftDetector.isSeekableRangeSliding()).toBe(true)
156 | })
157 |
--------------------------------------------------------------------------------
/src/utils/timeshiftdetector.ts:
--------------------------------------------------------------------------------
1 | export type TimeShiftDetector = ReturnType
2 |
3 | const TEN_SECONDS_IN_MILLISECONDS = 10000
4 |
5 | type SeekableRange = {
6 | start: number
7 | end: number
8 | }
9 |
10 | function isValidSeekableRange(obj: unknown): obj is SeekableRange {
11 | return (
12 | obj != null &&
13 | typeof obj === "object" &&
14 | "start" in obj &&
15 | "end" in obj &&
16 | typeof obj.start === "number" &&
17 | typeof obj.end === "number" &&
18 | isFinite(obj.start) &&
19 | obj.end > obj.start
20 | )
21 | }
22 |
23 | export default function createTimeShiftDetector(onceDetected: () => void) {
24 | let currentIntervalId: ReturnType | undefined
25 | let lastSeekableRangeStart: number | undefined
26 | let isSliding: boolean = false
27 |
28 | function observe(getSeekableRange: () => unknown) {
29 | if (currentIntervalId != null) {
30 | disconnect()
31 | }
32 |
33 | const initialRange = getSeekableRange()
34 |
35 | lastSeekableRangeStart = isValidSeekableRange(initialRange) ? initialRange.start : undefined
36 |
37 | currentIntervalId = setInterval(() => {
38 | const currentRange = getSeekableRange()
39 |
40 | const currentSeekableRangeStart = isValidSeekableRange(currentRange) ? currentRange.start : undefined
41 |
42 | if (
43 | typeof lastSeekableRangeStart === "number" &&
44 | typeof currentSeekableRangeStart === "number" &&
45 | currentSeekableRangeStart > lastSeekableRangeStart
46 | ) {
47 | isSliding = true
48 |
49 | onceDetected()
50 |
51 | disconnect()
52 | }
53 |
54 | lastSeekableRangeStart = currentSeekableRangeStart
55 | }, TEN_SECONDS_IN_MILLISECONDS)
56 | }
57 |
58 | function disconnect() {
59 | clearInterval(currentIntervalId)
60 |
61 | currentIntervalId = undefined
62 | }
63 |
64 | function isSeekableRangeSliding() {
65 | return isSliding
66 | }
67 |
68 | return { disconnect, isSeekableRangeSliding, observe }
69 | }
70 |
--------------------------------------------------------------------------------
/src/utils/timeutils.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | availabilityTimeToPresentationTimeInSeconds,
3 | durationToSeconds,
4 | mediaSampleTimeToPresentationTimeInSeconds,
5 | presentationTimeToAvailabilityTimeInMilliseconds,
6 | presentationTimeToMediaSampleTimeInSeconds,
7 | } from "./timeutils"
8 |
9 | describe("Duration to seconds", () => {
10 | const testCases: [string, number | undefined][] = [
11 | ["PT2H", 7200],
12 | ["PT2H30S", 7230],
13 | ["PT2H30M30S", 9030],
14 | ["PT30M30S", 1830],
15 | ["PT30S", 30],
16 | ["PT58M59.640S", 3539.64],
17 | ["P1DT12H", undefined], // Technically valid, but code does not handle days
18 | ["PT1D", undefined],
19 | ["", undefined],
20 | ["foobar", undefined],
21 | ]
22 |
23 | it.each(testCases)("Converts duration of %s to %s seconds", (duration: string, expected?: number) => {
24 | expect(durationToSeconds(duration)).toBe(expected)
25 | })
26 | })
27 |
28 | describe("converting between timelines", () => {
29 | it.each([
30 | [0, 0, 0],
31 | [10, 10000, 0],
32 | [10, 1732633437000, 1732633427000],
33 | ])(
34 | "converts a presentation time %d to availability time %d given availability start time %d",
35 | (presentationTimeInSeconds, expectedAvailabilityTimeInMilliseconds, availabilityStartTimeInMilliseconds) => {
36 | const availabilityTimeInMilliseconds = presentationTimeToAvailabilityTimeInMilliseconds(
37 | presentationTimeInSeconds,
38 | availabilityStartTimeInMilliseconds
39 | )
40 |
41 | expect(availabilityTimeInMilliseconds).toBe(expectedAvailabilityTimeInMilliseconds)
42 | }
43 | )
44 |
45 | it.each([
46 | [0, 0, 0],
47 | [10000, 10, 0],
48 | [1732633437000, 10, 1732633427000],
49 | ])(
50 | "converts an availability time %d to presentation time %d given availability start time %d",
51 | (availabilityTimeInMilliseconds, expectedPresentationTimeInSeconds, availabilityStartTimeInMilliseconds) => {
52 | const presentationTimeInSeconds = availabilityTimeToPresentationTimeInSeconds(
53 | availabilityTimeInMilliseconds,
54 | availabilityStartTimeInMilliseconds
55 | )
56 |
57 | expect(presentationTimeInSeconds).toBe(expectedPresentationTimeInSeconds)
58 | }
59 | )
60 |
61 | it("converts availability time to presentation time 0 if it's less than the availability start time", () => {
62 | expect(availabilityTimeToPresentationTimeInSeconds(1732633427000, 1732633437000)).toBe(0)
63 | })
64 |
65 | it.each([
66 | [0, 0, 0],
67 | [10, 10, 0],
68 | [1732633437, 10, 1732633427000],
69 | ])(
70 | "converts a media sample time %d to presentation time %d given presentation time offset %d",
71 | (mediaSampleTimeInSeconds, expectedPresentationTimeInSeconds, presentationTimeOffsetInMilliseconds) => {
72 | const presentationTimeInSeconds = mediaSampleTimeToPresentationTimeInSeconds(
73 | mediaSampleTimeInSeconds,
74 | presentationTimeOffsetInMilliseconds
75 | )
76 |
77 | expect(presentationTimeInSeconds).toBe(expectedPresentationTimeInSeconds)
78 | }
79 | )
80 |
81 | it.each([
82 | [0, 0, 0],
83 | [10, 10, 0],
84 | [10, 1732633437, 1732633427000],
85 | ])(
86 | "converts a presentation time %d to media sample time %d given presentation time offset %d",
87 | (presentationTimeInSeconds, expectedMediaSampleTimeInSeconds, presentationTimeOffsetInMilliseconds) => {
88 | const mediaSampleTimeInSeconds = presentationTimeToMediaSampleTimeInSeconds(
89 | presentationTimeInSeconds,
90 | presentationTimeOffsetInMilliseconds
91 | )
92 |
93 | expect(mediaSampleTimeInSeconds).toBe(expectedMediaSampleTimeInSeconds)
94 | }
95 | )
96 |
97 | it("converts media sample time to presentation time 0 if it's less than presentation time offset", () => {
98 | expect(mediaSampleTimeToPresentationTimeInSeconds(1732633427, 1732633437000)).toBe(0)
99 | })
100 | })
101 |
--------------------------------------------------------------------------------
/src/utils/timeutils.ts:
--------------------------------------------------------------------------------
1 | export function durationToSeconds(duration: string) {
2 | const matches = duration.match(/^PT(\d+(?:[,.]\d+)?H)?(\d+(?:[,.]\d+)?M)?(\d+(?:[,.]\d+)?S)?/) || []
3 |
4 | const hours = parseFloat(matches[1] || "0") * 60 * 60
5 | const mins = parseFloat(matches[2] || "0") * 60
6 | const secs = parseFloat(matches[3] || "0")
7 |
8 | return hours + mins + secs || undefined
9 | }
10 |
11 | export function presentationTimeToAvailabilityTimeInMilliseconds(
12 | presentationTimeInSeconds: number,
13 | availabilityStartTimeInMilliseconds: number
14 | ): number {
15 | return presentationTimeInSeconds * 1000 + availabilityStartTimeInMilliseconds
16 | }
17 |
18 | export function availabilityTimeToPresentationTimeInSeconds(
19 | availabilityTimeInMilliseconds: number,
20 | availabilityStartTimeInMilliseconds: number
21 | ): number {
22 | return availabilityTimeInMilliseconds < availabilityStartTimeInMilliseconds
23 | ? 0
24 | : (availabilityTimeInMilliseconds - availabilityStartTimeInMilliseconds) / 1000
25 | }
26 |
27 | export function presentationTimeToMediaSampleTimeInSeconds(
28 | presentationTimeInSeconds: number,
29 | presentationTimeOffsetInMilliseconds: number
30 | ): number {
31 | return presentationTimeInSeconds + presentationTimeOffsetInMilliseconds / 1000
32 | }
33 |
34 | export function mediaSampleTimeToPresentationTimeInSeconds(
35 | mediaSampleTimeInSeconds: number,
36 | presentationTimeOffsetInMilliseconds: number
37 | ): number {
38 | const presentationTimeOffsetInSeconds = presentationTimeOffsetInMilliseconds / 1000
39 |
40 | return mediaSampleTimeInSeconds < presentationTimeOffsetInSeconds
41 | ? 0
42 | : mediaSampleTimeInSeconds - presentationTimeOffsetInSeconds
43 | }
44 |
--------------------------------------------------------------------------------
/src/utils/types.ts:
--------------------------------------------------------------------------------
1 | export type Extends = Subset
2 |
--------------------------------------------------------------------------------
/src/version.test.ts:
--------------------------------------------------------------------------------
1 | import { version } from "../package.json"
2 |
3 | describe("Version", () => {
4 | it("should return a semver string", () => {
5 | expect(version).toMatch(/^\d+\.\d+\.\d+$/)
6 | })
7 | })
8 |
--------------------------------------------------------------------------------
/src/version.ts:
--------------------------------------------------------------------------------
1 | export default "__VERSION__"
2 |
--------------------------------------------------------------------------------
/tsconfig.dist.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": ["./src/**/*.ts", "./src/**/*.js"],
4 | "exclude": ["./src/**/*.test.js", "./src/**/*.test.ts"],
5 | "compilerOptions": {
6 | "outDir": "./dist/esm",
7 | "declaration": true,
8 | "declarationDir": "./dist/esm/__tmp/dts"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compileOnSave": true,
3 | "include": ["./"],
4 | "exclude": ["node_modules", "dist", "dist-local", "docs"],
5 | "compilerOptions": {
6 | "outDir": "./dist/esm",
7 | "module": "ESNext",
8 | "target": "es6",
9 | "moduleResolution": "Bundler",
10 | "resolveJsonModule": true,
11 | "allowJs": true,
12 | "alwaysStrict": true,
13 | "noImplicitAny": true,
14 | "noImplicitOverride": true,
15 | "noImplicitThis": true,
16 | "noImplicitReturns": true,
17 | "noUnusedLocals": true,
18 | "strictNullChecks": true
19 | }
20 | }
21 |
--------------------------------------------------------------------------------