├── .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 | Bigscreen Player logo 2 | 3 | [![Build Status](https://github.com/bbc/bigscreen-player/actions/workflows/pull-requests.yml/badge.svg)](https://github.com/bbc/bigscreen-player/actions/workflows/npm-publish.yml) [![npm](https://img.shields.io/npm/v/bigscreen-player)](https://www.npmjs.com/package/bigscreen-player) [![GitHub](https://img.shields.io/github/license/bbc/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 | Bigscreen-player logo 17 |
18 |

Bigscreen-player Build status badge

19 |

20 |

21 |

Read the docs

22 |
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 | ![Bigscreen Player Image](../static/bsp_arch.svg) 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 | ![State Changes](../static/bsp_state_changes_august_2019.png) -------------------------------------------------------------------------------- /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": "The logo for bigscreen player", 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 |