├── .babelrc ├── .browserslistrc ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .prettierrc ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── bin └── release.sh ├── dev ├── AdManager.js ├── bbb-de-subs.vtt ├── bbb-en-subs.vtt ├── bbb-fr-subs.vtt ├── en-subs.vtt ├── index.tsx ├── styles.scss ├── template.html ├── thumbnails-sprite.jpg ├── thumbnails.bif └── thumbnails.vtt ├── docs ├── .nojekyll ├── API.md ├── Configuration.md ├── CreateAModule.md ├── Features.md ├── ModuleStructure.md ├── README.md ├── Theming.md ├── _coverpage.md ├── _sidebar.md ├── index.html ├── indigo-player-screencap.png ├── indigo-player.png └── player-assets │ ├── bbb-de-subs.vtt │ ├── bbb-en-subs.vtt │ ├── bbb-fr-subs.vtt │ ├── bbb-thumbnails-sprite.jpg │ └── bbb-thumbnails.vtt ├── jest.config.js ├── lib ├── c836126221e1fcba656bb5bc645f4d96.woff2 ├── e8ab1669c31e698989c318911f4893b0.ttf ├── indigo-player.js └── indigo-theme.css ├── package.json ├── src ├── Hooks.ts ├── Instance.ts ├── Module.ts ├── ModuleLoader.ts ├── PlayerError.ts ├── controller │ ├── BaseController │ │ ├── BaseController.ts │ │ └── BaseControllerLoader.ts │ └── Controller.ts ├── createAPI.ts ├── createConfig.ts ├── extensions │ ├── BenchmarkExtension │ │ ├── BenchmarkExtension.ts │ │ └── BenchmarkExtensionLoader.ts │ ├── ContextMenuExtension │ │ ├── ContextMenuExtension.ts │ │ ├── ContextMenuExtensionLoader.ts │ │ ├── context-menu.scss │ │ └── indigo-logo-small.png │ ├── DimensionsExtension │ │ ├── DimensionsExtension.ts │ │ └── DimensionsExtensionLoader.ts │ ├── FreeWheelExtension │ │ ├── FreeWheelExtension.ts │ │ └── FreeWheelExtensionLoader.ts │ ├── FullscreenExtension │ │ ├── FullscreenExtension.ts │ │ └── FullscreenExtensionLoader.ts │ ├── GoogleIMAExtension │ │ ├── GoogleIMAExtension.ts │ │ └── GoogleIMAExtensionLoader.ts │ ├── KeyboardNavigationExtension │ │ ├── KeyboardNavigationExtension.ts │ │ └── KeyboardNavigationExtensionLoader.ts │ ├── PipExtension │ │ ├── PipExtension.ts │ │ ├── PipExtensionLoader.ts │ │ └── pip.scss │ ├── StateExtension │ │ ├── StateExtension.ts │ │ └── StateExtensionLoader.ts │ ├── SubtitlesExtension │ │ ├── SubtitlesExtension.ts │ │ ├── SubtitlesExtensionLoader.ts │ │ └── subtitles.scss │ └── ThumbnailsExtension │ │ ├── BIFParser.js │ │ ├── ThumbnailsExtension.ts │ │ └── ThumbnailsExtensionLoader.ts ├── index.d.ts ├── index.ts ├── media │ ├── BaseMedia │ │ ├── BaseMedia.ts │ │ └── BaseMediaLoader.ts │ ├── DashMedia │ │ ├── DashMedia.ts │ │ ├── DashMediaLoader.ts │ │ └── isBrowserSupported.ts │ ├── HlsMedia │ │ ├── HlsMedia.ts │ │ └── HlsMediaLoader.ts │ └── Media.ts ├── player │ ├── HTML5Player │ │ ├── HTML5Player.ts │ │ └── HTML5PlayerLoader.ts │ └── Player.ts ├── selectModule.ts ├── styles.scss ├── types.ts ├── ui │ ├── State.tsx │ ├── UiExtension.ts │ ├── UiExtensionLoader.ts │ ├── components │ │ ├── Button.tsx │ │ ├── Center.tsx │ │ ├── ControlsView.tsx │ │ ├── ErrorView.tsx │ │ ├── Icon.tsx │ │ ├── LoadingView.tsx │ │ ├── Main.tsx │ │ ├── Nod.tsx │ │ ├── Rebuffer.tsx │ │ ├── Seekbar.tsx │ │ ├── Settings.tsx │ │ ├── Spinner.tsx │ │ ├── Sprite.tsx │ │ ├── StartView.tsx │ │ ├── TimeStat.tsx │ │ └── VolumeButton.tsx │ ├── i18n.ts │ ├── render.tsx │ ├── theme │ │ ├── button.scss │ │ ├── element-center.scss │ │ ├── element-image.scss │ │ ├── element-nod.scss │ │ ├── element-rebuffer.scss │ │ ├── element-seekbar.scss │ │ ├── element-settings.scss │ │ ├── element-spinner.scss │ │ ├── element-volume.scss │ │ ├── fonts │ │ │ ├── icons.scss │ │ │ ├── icons.ttf │ │ │ └── icons.woff2 │ │ ├── index.scss │ │ ├── mixins.scss │ │ ├── root.scss │ │ ├── view-controls.scss │ │ ├── view-error.scss │ │ ├── view-loading.scss │ │ └── view-start.scss │ ├── triggerEvent.ts │ ├── types.ts │ ├── utils │ │ ├── attachEvents.ts │ │ ├── secondsToHMS.ts │ │ └── useSlider.ts │ └── withState.tsx └── utils │ ├── defineProperty.ts │ ├── deprecate.ts │ ├── dom.ts │ ├── getDrmSupport.ts │ ├── getEnv.ts │ ├── log.ts │ ├── storage.ts │ └── webpack.ts ├── tests ├── StateExtension.test.ts ├── __mocks__ │ └── Instance.ts ├── __snapshots__ │ └── hooks.test.ts.snap ├── hooks.test.ts └── verify-i18n.test.ts ├── tsconfig.json ├── tslint.json ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/env", { 4 | "modules": false, 5 | }], 6 | "@babel/react", 7 | ], 8 | "plugins": [ 9 | "@babel/plugin-syntax-dynamic-import", 10 | ["@babel/transform-runtime", { 11 | "helpers": true, 12 | "regenerator": true, 13 | "useESModules": true, 14 | }], 15 | ] 16 | } -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | IE 11 2 | last 3 Chrome versions 3 | last 2 Firefox versions 4 | last 2 Edge versions 5 | last 2 Safari versions 6 | last 2 iOS versions -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [matvp91] 4 | patreon: matvp91 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | compilation_stats.json 5 | yarn-error.log 6 | bin/release-beta.sh 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/ 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "printWidth": 80 6 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "11.10.1" -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at matvp91@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!TIP] 2 | > Hey friend, I'm working on an end-to-end solution nowadays. Check https://github.com/matvp91/mixwave for more info! 3 | 4 | **Note:** Due to other commitments, I'm having a hard time responding to issues (& actually getting them fixed for you guys). I'd be more than happy to accept PR's. 5 | 6 |

7 | 8 | 9 | 10 |

11 | 12 | # indigo-player 13 | 14 | Highly extensible, modern, JavaScript player. 👊 15 | 16 | ![Travis CI](https://img.shields.io/travis/matvp91/indigo-player/master.svg) 17 | [![](https://img.shields.io/npm/v/indigo-player.svg)](https://www.npmjs.com/package/indigo-player) 18 | [![](https://img.shields.io/github/license/matvp91/indigo-player.svg)](https://github.com/matvp91/indigo-player) 19 | [![](https://img.shields.io/snyk/vulnerabilities/github/matvp91/indigo-player.svg)](https://github.com/matvp91/indigo-player) 20 | [![](https://img.shields.io/npm/types/indigo-player.svg)](https://www.npmjs.com/package/indigo-player) 21 | ![jsdelivr](https://img.shields.io/jsdelivr/npm/hy/indigo-player) 22 | 23 | * **Strict defined API**, which makes it easy to build analytics and various other plugins on top of indigo-player. 24 | * **Dynamic bundle loading**, automatically determines and loads which modules are needed for playback. 25 | * **Highly modular** plugin system to extend functionality without modifying it's core. 26 | * **Out-of-the-box** features such as subtitles, thumbnails, quality selection if applicable, ... 27 | * **React** based UI. 28 | 29 |

30 | 31 | 32 | 33 |

34 | 35 | ## Documentation 36 | 37 | Visit the [documentation](https://matvp91.github.io/indigo-player). 😎 38 | 39 | ## Getting started 40 | 41 | **In a browser** 42 | 43 | The example below will load a simple MP4 file, and attempt to autoplay it. 44 | 45 | ```html 46 | 47 | 48 |
49 | 50 | 65 | 66 | 67 | ``` 68 | 69 | **As a module in your bundle** 70 | 71 | The example below will add `indigo-player` as a module in your project. 72 | 73 | ``` 74 | yarn add indigo-player 75 | ``` 76 | 77 | ``` 78 | npm i indigo-player 79 | ``` 80 | 81 | ```javascript 82 | import IndigoPlayer from "indigo-player"; 83 | // Bundle the css file too, or provide your own. 84 | import "indigo-player/lib/indigo-theme.css"; 85 | 86 | const player = IndigoPlayer.init(container, config); 87 | ``` 88 | 89 | ## Mentions 90 | Much ❤️ on getting the word out! 91 | * [Hacker News](https://news.ycombinator.com/item?id=18939145) 92 | * [codrops Collective 503](https://tympanus.net/codrops/collective/collective-503/) 93 | * [Smashing Magazine](https://twitter.com/smashingmag/status/1095001768365252608) 94 | * [Web Design Weekly #345](https://web-design-weekly.com/2019/02/12/web-design-weekly-345/) 95 | * Let me know! 96 | 97 | ## Cheers 🍺 98 | * [@ambroos](https://github.com/ambroos) for being a video nerd! 99 | * [@google](https://github.com/google) for maintaining [shaka-player](https://github.com/google/shaka-player) 100 | * [@video-dev](https://github.com/video-dev) for maintaining [hls.js](https://github.com/video-dev/hls.js/) 101 | -------------------------------------------------------------------------------- /bin/release.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | 3 | echo "Enter new version (x.x.x): " 4 | read VERSION 5 | 6 | read -p "Releasing v$VERSION? (y/n) " -n 1 -r 7 | echo 8 | if [[ $REPLY =~ ^[Yy]$ ]]; then 9 | echo "Releasing v$VERSION ..." 10 | 11 | # build 12 | npm --no-git-tag-version version $VERSION 13 | VERSION=$VERSION npm run build 14 | 15 | # commit 16 | git add -A 17 | git add -f lib/ -A 18 | git commit -m "v$VERSION" 19 | 20 | # publish 21 | git tag v$VERSION 22 | git push origin refs/tags/v$VERSION 23 | git push 24 | npm publish 25 | fi -------------------------------------------------------------------------------- /dev/bbb-de-subs.vtt: -------------------------------------------------------------------------------- 1 | WEBVTT 2 | 3 | 1 4 | 00:00:00.000 --> 00:00:30.000 a:start 5 | Demo Untertitel für die ersten 30 Sekunden. -------------------------------------------------------------------------------- /dev/bbb-en-subs.vtt: -------------------------------------------------------------------------------- 1 | WEBVTT 2 | 3 | 1 4 | 00:00:00.000 --> 00:00:30.000 a:start 5 | Demo subtitle for the first 30 seconds. -------------------------------------------------------------------------------- /dev/bbb-fr-subs.vtt: -------------------------------------------------------------------------------- 1 | WEBVTT 2 | 3 | 1 4 | 00:00:00.000 --> 00:00:30.000 a:start 5 | Démo sous-titre pendant les 30 premières secondes. -------------------------------------------------------------------------------- /dev/index.tsx: -------------------------------------------------------------------------------- 1 | import filter from 'lodash/filter'; 2 | import includes from 'lodash/includes'; 3 | import omit from 'lodash/omit'; 4 | import * as React from 'react'; 5 | import * as ReactDOM from 'react-dom'; 6 | import './styles.scss'; 7 | 8 | // Start the player 9 | 10 | const IndigoPlayer = (window as any).IndigoPlayer; 11 | 12 | IndigoPlayer.setConsoleLogs(true); 13 | 14 | const player = IndigoPlayer.init( 15 | /*document.getElementById('playerContainer')*/ 'playerContainer', 16 | { 17 | autoplay: false, 18 | aspectRatio: 16 / 9, 19 | // volume: 0.5, 20 | // startPosition: 100, 21 | ui: { 22 | pip: true, 23 | lockControlsVisibility: false, 24 | locale: 'en-US', 25 | image: 'https://peach.blender.org/wp-content/uploads/rodents2.png?x11217', 26 | }, 27 | // thumbnails: { 28 | // src: './thumbnails.vtt', 29 | // }, 30 | // BIF Files 31 | thumbnails: { 32 | src: './thumbnails.bif', 33 | }, 34 | // freewheel: { 35 | // clientSide: true, 36 | // network: 96749, 37 | // server: 'https://demo.v.fwmrm.net/ad/g/1', 38 | // videoAsset: 'DemoVideoGroup.01', 39 | // // videoAsset: 'TEST_AD_BRAND_ANV_10003623', 40 | // duration: 594, 41 | // siteSection: 'DemoSiteGroup.01', 42 | // profile: 'global-js', 43 | // cuepoints: ['preroll', 12, 'postroll'], 44 | // }, 45 | // googleIMA: { 46 | // // src: 'https://pubads.g.doubleclick.net/gampad/ads?' + 47 | // // 'sz=640x480&iu=/124319096/external/single_ad_samples&ciu_szs=300x250&' + 48 | // // 'impl=s&gdfp_req=1&env=vp&output=vast&unviewed_position_start=1&' + 49 | // // 'cust_params=deployment%3Ddevsite%26sample_ct%3Dlinear&correlator=', 50 | // src: 51 | // 'https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ar%3Dpremidpostpod&cmsid=496&vid=short_onecue&correlator=', 52 | // }, 53 | sources: [ 54 | { 55 | type: 'dash', 56 | src: 57 | 'https://storage.googleapis.com/shaka-demo-assets/angel-one/dash.mpd', 58 | }, 59 | // { 60 | // type: 'hls', 61 | // src: 62 | // 'https://stream1-vod.cdn1.sbs.prd.telenet-ops.be/geo/vier/dedag/volledigeafleveringen/133fc7a62dea3da106ba0b9f54f6e83d4f6777ec/DE_DAG_1_8_F0261554/DE_DAG_1_8_F0261554.m3u8', 63 | // }, 64 | // { 65 | // type: 'hls', 66 | // src: 'https://mnmedias.api.telequebec.tv/m3u8/29880.m3u8', 67 | // }, 68 | // { 69 | // type: 'mp4', 70 | // src: 71 | // 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/WeAreGoingOnBullrun.mp4', 72 | // }, 73 | // { 74 | // type: 'mp4', 75 | // src: 76 | // 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', 77 | // }, 78 | // { 79 | // type: 'mp4', 80 | // src: 81 | // 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', 82 | // }, 83 | // { 84 | // type: 'dash', 85 | // src: 86 | // 'https://amssamples.streaming.mediaservices.windows.net/683f7e47-bd83-4427-b0a3-26a6c4547782/BigBuckBunny.ism/manifest(format=mpd-time-csf)', 87 | // }, 88 | // { 89 | // type: 'dash', 90 | // src: 91 | // 'https://bitmovin-a.akamaihd.net/content/art-of-motion_drm/mpds/11331.mpd', 92 | // drm: { 93 | // widevine: { 94 | // licenseUrl: 'https://widevine-proxy.appspot.com/proxy', 95 | // }, 96 | // playready: { 97 | // licenseUrl: 98 | // 'https://playready.directtaps.net/pr/svc/rightsmanager.asmx?PlayRight=1&ContentKey=EAtsIJQPd5pFiRUrV9Layw==', 99 | // }, 100 | // }, 101 | // }, 102 | // { 103 | // type: 'dash', 104 | // src: 'http://dash.edgesuite.net/akamai/bbb_30fps/bbb_30fps.mpd', 105 | // }, 106 | // { 107 | // type: 'hls', 108 | // src: 109 | // 'https://bitmovin-a.akamaihd.net/content/art-of-motion_drm/m3u8s/11331.m3u8', 110 | // }, 111 | // { 112 | // type: 'webm', 113 | // src: 'http://ptgmedia.pearsoncmg.com/imprint_downloads/peachpit/peachpit/downloads/0321793935/media//elephants-dream-medium.webm', 114 | // }, 115 | // { 116 | // type: 'mp4', 117 | // src: 'http://techslides.com/demos/sample-videos/small.mp4', 118 | // }, 119 | ], 120 | subtitles: [ 121 | { 122 | label: 'Demo', 123 | srclang: 'demo', 124 | src: 125 | 'https://raw.githubusercontent.com/andreyvit/subtitle-tools/master/sample.srt', 126 | }, 127 | { 128 | label: 'English', 129 | srclang: 'en', 130 | src: './bbb-en-subs.vtt', 131 | }, 132 | { 133 | label: 'French', 134 | srclang: 'fr', 135 | src: './bbb-fr-subs.vtt', 136 | }, 137 | { 138 | label: 'German', 139 | srclang: 'de', 140 | src: './bbb-de-subs.vtt', 141 | }, 142 | ], 143 | }, 144 | ); 145 | (window as any).player = player; 146 | 147 | player.on(IndigoPlayer.Events.STATE_CHANGE, ({ state }) => { 148 | render(state); 149 | }); 150 | 151 | // Object.values(IndigoPlayer.Events).forEach((name: string) => { 152 | // if (name.startsWith('ui:')) { 153 | // player.on(name, data => { 154 | // console.log('UI event', name, data); 155 | // }); 156 | // } 157 | // }); 158 | 159 | // player.on(IndigoPlayer.Events.UI_STATE_CHANGE, ({ state }) => { 160 | // const classes = [ 161 | // `view-${state.view}`, 162 | // ]; 163 | 164 | // if (state.visibleControls) { 165 | // classes.push('controls-visible'); 166 | // } 167 | 168 | // document.getElementById('playerRoot').setAttribute('class', classes.map(className => `igo_${className}`).join(' ')); 169 | // }); 170 | 171 | // document.getElementsByClassName('player-overlay')[0].addEventListener('mousemove', () => { 172 | // const uiExtension = player.getModule('UiExtension'); 173 | // if (uiExtension) { 174 | // uiExtension.triggerMouseMove(); 175 | // } 176 | // }); 177 | 178 | export interface StateProps { 179 | state: any; 180 | } 181 | 182 | export const State = (props: StateProps) => { 183 | const state = omit(props.state, ['ad.freewheelAdInstance']); 184 | return ( 185 |
186 |
{JSON.stringify(state, null, 2)}
187 | 188 |
189 | ); 190 | }; 191 | 192 | function render(state) { 193 | ReactDOM.render(, document.getElementById('state')); 194 | } 195 | -------------------------------------------------------------------------------- /dev/styles.scss: -------------------------------------------------------------------------------- 1 | #playerRoot { 2 | margin-left: 100px; 3 | position: relative; 4 | overflow: hidden; 5 | } 6 | 7 | .player-overlay { 8 | position: absolute; 9 | left: 50%; 10 | transform: translateX(-50%); 11 | z-index: 1; 12 | top: 16px; 13 | color: #fff; 14 | display: block; 15 | font-family: Tahoma, Geneva, sans-serif; 16 | font-weight: bold; 17 | transition: transform 150ms ease-in-out, opacity 150ms ease-in-out; 18 | transform: translateY(0); 19 | opacity: 1; 20 | 21 | .igo_view-controls:not(.igo_controls-visible) & { 22 | transform: translateY(-15px); 23 | opacity: 0; 24 | } 25 | } -------------------------------------------------------------------------------- /dev/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 |
9 |
10 |
11 | 12 | 13 | 14 | 15 | 16 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /dev/thumbnails-sprite.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matvp91/indigo-player/32ee84248df280afd84b53a8af1096c374ae0f2b/dev/thumbnails-sprite.jpg -------------------------------------------------------------------------------- /dev/thumbnails.bif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matvp91/indigo-player/32ee84248df280afd84b53a8af1096c374ae0f2b/dev/thumbnails.bif -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matvp91/indigo-player/32ee84248df280afd84b53a8af1096c374ae0f2b/docs/.nojekyll -------------------------------------------------------------------------------- /docs/CreateAModule.md: -------------------------------------------------------------------------------- 1 | # Create a module 2 | 3 | !> **This is a beta feature**. Small changes can happen in the future before marked as stable. 4 | 5 | ?> You can start with the example project: https://github.com/matvp91/indigo-player-extension-example 6 | 7 | Registering your own module within the player is one of the core principles of indigo-player. We aim to make it as easy as possible for developers to extend functionality as plugins, or as we call them, modules. A detailed explanation of a module structure can be found [here](./ModuleStructure.md). 8 | 9 | A custom module is hosted on your own GitHub account, published on npm and is versioned. Once published on npm, the awesome jsdeliver CDN makes it automatically available for others to integrate. 10 | 11 | The following format is suggested: `indigo-player--`. For example: indigo-player-extension-drmtoday, indigo-player-media-dashjs 12 | 13 | ## Prerequisites 14 | 15 | * A TypeScript project with a bundler, we use webpack. 16 | * A basic webpack config. 17 | 18 | ## Setup 19 | 20 | Add the latest version to your project, which includes the `addModuleLoader(moduleLoader: IModuleLoader)` API. 21 | 22 | ``` 23 | npm install indigo-player 24 | ``` 25 | 26 | You don't want to bundle indigo-player player in your custom module, but merely reference it. Add the following external to your `webpack.config.js` file: 27 | 28 | ```javascript 29 | module.exports = { 30 | ... 31 | externals: { 32 | 'indigo-player': 'IndigoPlayer', 33 | }, 34 | }; 35 | ``` 36 | 37 | Make sure you load your module **after** loading indigo-player.js. If the global `IndigoPlayer` variable is not available when your module executes, it's not going to register the module and you'll see an error. 38 | 39 | ```html 40 | 41 | 42 |
43 | 44 | 45 | 58 | 59 | 60 | ``` 61 | 62 | ## Write your module 63 | 64 | Now we're ready to start developing a custom module. Your entry file will be responsible for registering the module loader and your module loader will load the module. In the example below, we will create a custom extension with the following use case: 65 | 66 | > When the user clicks on the play button, we want to confirm that the user actually wants to play by asking the question. 67 | 68 | ##### 1. ExampleExtension.ts 69 | 70 | Let's start by writing our extension: 71 | 72 | ```typescript 73 | import { Module, Events, IInstance } from 'indigo-player'; 74 | 75 | export class ExampleExtension extends Module { 76 | public name: string = 'ExampleExtension'; 77 | 78 | constructor(instance: IInstance) { 79 | super(instance); 80 | 81 | instance.controller.hooks.create('play', this.onControllerPlay.bind(this)); 82 | } 83 | 84 | onControllerPlay(next) { 85 | const confirmStart = (window as any).confirm('Do you really want to play?'); 86 | if (confirmStart) { 87 | next(); 88 | } 89 | } 90 | } 91 | ``` 92 | 93 | ##### 2. ExampleExtensionLoader.ts 94 | 95 | Now we need to create a loader that is responsible for loading our extension inside of the player. 96 | 97 | ```typescript 98 | import { Config, ModuleLoaderTypes } from 'indigo-player'; 99 | import { ExampleExtension } from './ExampleExtension'; 100 | 101 | export const ExampleExtensionLoader = { 102 | // We're going to write a custom extension, 103 | // you can also use MEDIA, CONTROLLER or PLAYER here. 104 | type: ModuleLoaderTypes.EXTENSION, 105 | 106 | create: instance => new ExampleExtension(instance), 107 | 108 | // Let's always load our extension. You can add additional logic here whether 109 | // or not the extension is supported. 110 | isSupported: ({ config: Config }): boolean => true, 111 | }; 112 | ``` 113 | 114 | ##### 3. index.ts 115 | 116 | Finally, let's register the module loader in the entry point of our package. 117 | 118 | ```typescript 119 | import { ExampleExtensionLoader } from './ExampleExtensionLoader'; 120 | import { addModuleLoader } from 'indigo-player'; 121 | 122 | addModuleLoader(ExampleExtensionLoader); 123 | ``` 124 | 125 | If you need more references, take a look at the default extensions, media and controllers shipped with the indigo-player core. 126 | 127 | * Extensions: https://github.com/matvp91/indigo-player/tree/master/src/extensions 128 | * Media: https://github.com/matvp91/indigo-player/tree/master/src/media 129 | * Player: https://github.com/matvp91/indigo-player/tree/master/src/player 130 | * Controllers: https://github.com/matvp91/indigo-player/tree/master/src/controller -------------------------------------------------------------------------------- /docs/Features.md: -------------------------------------------------------------------------------- 1 | # Features 2 | 3 | indigo-player has a wide variety of additional features listed below. 4 | 5 | ## Keyboard navigation 6 | 7 | Support for keyboard navigation. Behavior can be controlled with a [config variable](Configuration.md?id=keyboard-navigation). 8 | 9 | | key | description | 10 | | ------------- | --------------------------------------------------------------------------- | 11 | | `m` | Toggle mute | 12 | | `space` | Toggle play / pause | 13 | | `up arrow` | Increase volume by 0.1 | 14 | | `down arrow` | Decrease volume by 0.1 | 15 | | `left arrow` | Go back 15 seconds in time (until 0 is reached) | 16 | | `right arrow` | Go forwards 15 seconds in time (until the duration of the asset is reached) | 17 | | `f` | Toggle fullscreen | 18 | -------------------------------------------------------------------------------- /docs/ModuleStructure.md: -------------------------------------------------------------------------------- 1 | # Module structure 2 | 3 | This page should get you familiar with the structure of the player and the different type of modules. The entire player base consists of multiple modules and each module has it's own set of events. In order to proceed, it is important to keep the following structure in mind: 4 | 5 | `instance` → `controller` → `media` → `player base` 6 | 7 | `instance` → `controller` → `extensions` 8 | 9 | It's best to browse the source code and get yourself familiar with the code base. 10 | 11 | ## Module types 12 | 13 | ### Controller 14 | 15 | This is the highest level of all module types. There is a small chance you'll have to write your own controller but the API is available anyways. 16 | 17 | * Example 1) In order to play a video, we need to fetch the streams from somewhere. We can inherit the `async controller.load()` method in order to populate the config with the proper streams. 18 | * Example 2) A third party SDK requires it's own implementation of `controller.play()` and various other methods. 19 | 20 | Each controller module inherits the base Controller class, it provides a basic set of functionality (such as loading the proper player & media): https://github.com/matvp91/indigo-player/blob/master/src/controller/Controller.ts 21 | 22 | ### Media 23 | 24 | Media provides support for multiple formats (eg: MPEG-Dash / HLS / ...). 25 | 26 | * Example 1) We have a MPEG-Dash format, but the browser does not natively support Dash playback. This is the reason we create a Dash Media module that implements Shaka Player. 27 | * Example 2) HLS cannot be played natively on Chrome / Firefox / Edge, thus we create a HLS Media module that implements HLS.js in order to provide HLS playback. 28 | 29 | Each media module inherits the base Media class, it provides a basic set of functionality: https://github.com/matvp91/indigo-player/blob/master/src/media/Media.ts 30 | 31 | ### Player 32 | 33 | This is the lowest level of all module types. By default, the HTML5 video element is used to play video in the browser. 34 | 35 | * Example 1) A player module could be built to load an underlying Flash player, in order to support Windows 7 + IE11 + DRM. 36 | * Example 2) Using Silverlight as the underlying video player (this is merely an example, nobody will do this obviously...). 37 | 38 | Each player module inherits the base Player class, this is merely an interface of the methods that need to be implemented by the actual player: https://github.com/matvp91/indigo-player/blob/master/src/player/Player.ts 39 | 40 | ### Extension 41 | 42 | Extensions are used to, well, extend functionality. They can hook into the controller, the media or the player and they can manipulate methods. 43 | 44 | * Example 1) Implement the fullscreen API in the player. 45 | * Example 2) Create support for an ad provider (Google IMA / FreeWheel / ...). 46 | 47 | ## Module loader 48 | 49 | Each module must have it's own module loader. The sole purpose of the module loader is to figure out if the module needs to be loaded or not. And if it has to be loaded, it will provide an instance of the module. 50 | 51 | Let's take the BaseModule loader as an example: 52 | 53 | ```javascript 54 | import { Instance } from '@src/Instance'; 55 | import { BaseMedia } from '@src/media/BaseMedia/BaseMedia'; 56 | import { 57 | Format, 58 | FormatTypes, 59 | ModuleLoader, 60 | ModuleLoaderTypes, 61 | } from '@src/types'; 62 | 63 | export const BaseMediaLoader = { 64 | // Let the player know which module type we're about to load 65 | type: ModuleLoaderTypes.MEDIA, 66 | 67 | // Only when isSupported(...) below is true, 68 | // the create function will be executed and 69 | // it is only responsible for providing an instance of the module. 70 | create: (instance: Instance) => new BaseMedia(instance), 71 | 72 | // ---OR--- create a chunk and return the instance asynchronously. 73 | // IMPORTANT: IF YOU LOAD AS A CHUNK, REMOVE THE IMPORT 74 | // AT THE TOP OF THE FILE 75 | // (or it will not be chunked but included right away). 76 | create: async (instance: Instance) => { 77 | const { BaseMedia } = await import('@src/media/BaseMedia/BaseMedia'); 78 | return new BaseMedia(instance); 79 | }, 80 | 81 | // isSupported(...) will decided whether we need to create the module or not. 82 | // If false is returned, the player will ignore this module. 83 | isSupported: (instance: Instance, format: Format): boolean => { 84 | if (format.type === FormatTypes.MP4 || format.type === FormatTypes.MOV) { 85 | return true; 86 | } 87 | return false; 88 | }, 89 | } as ModuleLoader; 90 | ``` 91 | 92 | The implementation in the player can be found here: https://github.com/matvp91/indigo-player/blob/master/src/media/BaseMedia/BaseMediaLoader.ts 93 | 94 | Or if you'd like to see an async (chunked) implementation: https://github.com/matvp91/indigo-player/blob/master/src/media/HlsMedia/HlsMediaLoader.ts -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Hello! 2 | 3 | **indigo-player** is an **extensible**, **modern**, JavaScript player. With our module system, it's childsplay for developers to add their own logic. Whether it's ads, custom business rules or supporting a new stream format, this documentation should get you started in no time. 4 | 5 | ## Hosting 6 | 7 | * jsdelivr.net: https://cdn.jsdelivr.net/npm/indigo-player/lib/indigo-player.js 8 | * by using your own host: 9 | * 1) Let's say you host it at *https://mysite.com/js/indigo-player.js*, 10 | * 2) Make sure you set *IndigoPlayer.setChunksPath('https://mysite.com/js/')** before calling `init(...)` as the chunks path. 11 | 12 | ## Getting started 13 | 14 | The example below will load a simple mp4 file and attempt to autoplay the video. 15 | 16 | ```html 17 | 18 | 19 |
20 | 21 | 36 | 37 | 38 | ``` 39 | 40 | ## Example 41 | 42 | The example below will load a dash file, has demo subtitles, thumbnails, and attempt to autoplay it. In order to interact with the player, you can use the `player` object returned when initializing indigo-player. 43 | 44 |
45 | { 46 | sources: [ 47 | { 48 | type: 'dash', 49 | src: 'https://amssamples.streaming.mediaservices.windows.net/683f7e47-bd83-4427-b0a3-26a6c4547782/BigBuckBunny.ism/manifest(format=mpd-time-csf)', 50 | }, 51 | { 52 | type: 'hls', 53 | src: 'https://video-dev.github.io/streams/x36xhzz/x36xhzz.m3u8', 54 | }, 55 | ], 56 | thumbnails: { 57 | src: './player-assets/bbb-thumbnails.vtt', 58 | }, 59 | captions: [ 60 | { 61 | label: 'English', 62 | srclang: 'en', 63 | src: './player-assets/bbb-en-subs.vtt', 64 | }, 65 | { 66 | label: 'French', 67 | srclang: 'fr', 68 | src: './player-assets/bbb-fr-subs.vtt', 69 | }, 70 | { 71 | label: 'German', 72 | srclang: 'de', 73 | src: './player-assets/bbb-de-subs.vtt', 74 | }, 75 | ], 76 | } 77 |
78 | 79 | ?> Open up the console, and use `window.player` to interact with the player above. 80 | 81 | 82 | 83 | 84 | ## Features 85 | 86 | * **media** - mp4 87 | * **media** - Dash (+ DRM / Widevine & PlayReady) - *shaka-player* 88 | * **media** - HLS - *hls.js* 89 | * **media** - Native HLS (+ FairPlay) - *work in progress* 90 | * **player** - HTML5 video element 91 | * **ads** - FreeWheel (client-side) 92 | * **ads** - Google IMA (client-side) 93 | 94 | ## Supported browsers 95 | 96 | * Chrome 71+ 97 | * Firefox 64+ 98 | * Edge 44+ on Windows 10 99 | * IE11 on Windows 7 except for DRM content 100 | 101 | Previous browser versions will most likely work because we rely heavily on feature detection based on the given configuration. -------------------------------------------------------------------------------- /docs/Theming.md: -------------------------------------------------------------------------------- 1 | # Theming 2 | 3 | The default UI that ships with indigo-player is defined by plenty of straight forward classnames, you can use these classnames to override, for example, the default colors. This is the current theme that we use in the documentation that you're reading right now: 4 | 5 | ```html 6 | 7 | 8 | 9 | 15 | 16 | 17 | ``` 18 | 19 | !> **Important**, it is advised to load your stylesheet (or inline style tag) **AFTER** you've imported `indigo-player.js`. If you don't, CSS precedence will ignore your own styles. -------------------------------------------------------------------------------- /docs/_coverpage.md: -------------------------------------------------------------------------------- 1 | ![logo](./indigo-player.png) 2 | 3 | # indigo-player 4 | 5 | > Highly extensible, modern, JavaScript video player. 6 | 7 | - MPEG-Dash / HLS / mp4, ... (+ DRM) 8 | - Advertisement support out-of-the-box 9 | - Modular and very easy to configure 10 | 11 | [GitHub](https://github.com/matvp91/indigo-player) 12 | [Get Started](#hello) 13 | 14 | ![color](#f1f1f1) -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | - [Home](/) 2 | - Getting started 3 | - [Configuration](Configuration.md) 4 | - [API](API.md) 5 | - [Features](Features.md) 6 | - Development 7 | - [Create a module](CreateAModule.md) 8 | - [Theming](Theming.md) 9 | - [Module structure](ModuleStructure.md) 10 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Document 6 | 7 | 8 | 12 | 13 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
44 | 99 | 100 | 101 | 102 | 103 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /docs/indigo-player-screencap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matvp91/indigo-player/32ee84248df280afd84b53a8af1096c374ae0f2b/docs/indigo-player-screencap.png -------------------------------------------------------------------------------- /docs/indigo-player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matvp91/indigo-player/32ee84248df280afd84b53a8af1096c374ae0f2b/docs/indigo-player.png -------------------------------------------------------------------------------- /docs/player-assets/bbb-de-subs.vtt: -------------------------------------------------------------------------------- 1 | WEBVTT 2 | 3 | 1 4 | 00:00:00.000 --> 00:00:30.000 a:start 5 | Demo Untertitel für die ersten 30 Sekunden. -------------------------------------------------------------------------------- /docs/player-assets/bbb-en-subs.vtt: -------------------------------------------------------------------------------- 1 | WEBVTT 2 | 3 | 1 4 | 00:00:00.000 --> 00:00:30.000 a:start 5 | Demo subtitle for the first 30 seconds. -------------------------------------------------------------------------------- /docs/player-assets/bbb-fr-subs.vtt: -------------------------------------------------------------------------------- 1 | WEBVTT 2 | 3 | 1 4 | 00:00:00.000 --> 00:00:30.000 a:start 5 | Démo sous-titre pendant les 30 premières secondes. -------------------------------------------------------------------------------- /docs/player-assets/bbb-thumbnails-sprite.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matvp91/indigo-player/32ee84248df280afd84b53a8af1096c374ae0f2b/docs/player-assets/bbb-thumbnails-sprite.jpg -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.tsx?$': 'ts-jest', 4 | }, 5 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', 6 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 7 | moduleNameMapper: { 8 | '@src/(.*)$': '/src/$1', 9 | }, 10 | }; -------------------------------------------------------------------------------- /lib/c836126221e1fcba656bb5bc645f4d96.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matvp91/indigo-player/32ee84248df280afd84b53a8af1096c374ae0f2b/lib/c836126221e1fcba656bb5bc645f4d96.woff2 -------------------------------------------------------------------------------- /lib/e8ab1669c31e698989c318911f4893b0.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matvp91/indigo-player/32ee84248df280afd84b53a8af1096c374ae0f2b/lib/e8ab1669c31e698989c318911f4893b0.ttf -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "indigo-player", 3 | "description": "Extensible, modern, JS video player", 4 | "version": "1.5.1", 5 | "homepage": "https://github.com/matvp91/indigo-player", 6 | "author": "Matthias Van Parijs", 7 | "maintainers": [ 8 | { 9 | "name": "Matthias Van Parijs", 10 | "email": "matthias@codemash.be" 11 | } 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/matvp91/indigo-player.git" 16 | }, 17 | "devDependencies": { 18 | "@babel/core": "^7.2.2", 19 | "@babel/plugin-syntax-dynamic-import": "^7.2.0", 20 | "@babel/plugin-transform-runtime": "^7.2.0", 21 | "@babel/preset-env": "^7.3.1", 22 | "@babel/preset-react": "^7.0.0", 23 | "@babel/runtime": "^7.3.1", 24 | "@types/jest": "^23.3.12", 25 | "@types/lodash": "^4.14.120", 26 | "@types/node": "^10.12.18", 27 | "@types/react": "^16.7.18", 28 | "@types/react-dom": "^16.0.11", 29 | "awesome-typescript-loader": "^5.2.1", 30 | "css-loader": "^2.1.0", 31 | "file-loader": "^3.0.1", 32 | "html-webpack-plugin": "^4.0.0-beta.5", 33 | "jest": "^23.6.0", 34 | "mini-css-extract-plugin": "^0.5.0", 35 | "node-sass": "^4.11.0", 36 | "perfectionist": "^2.4.0", 37 | "prettier": "^1.15.3", 38 | "prettier-tslint": "^0.4.2", 39 | "sass-loader": "^7.1.0", 40 | "style-loader": "^0.23.1", 41 | "terser-webpack-plugin": "^1.2.1", 42 | "ts-jest": "^23.10.5", 43 | "tslint": "^5.12.0", 44 | "tslint-config-prettier": "^1.17.0", 45 | "tslint-react": "^3.6.0", 46 | "typescript": "^3.2.2", 47 | "url-loader": "^1.1.2", 48 | "webpack": "^4.28.3", 49 | "webpack-cli": "^3.2.0", 50 | "webpack-dev-server": "^3.1.14", 51 | "webpack-shell-plugin": "^0.5.0" 52 | }, 53 | "scripts": { 54 | "dev": "webpack-dev-server --host 0.0.0.0 --mode development --hot --builds dev,player,theme", 55 | "build": "rm -rf lib && webpack --mode production", 56 | "lint": "prettier-tslint fix '**/*.ts{,x}'", 57 | "test": "jest" 58 | }, 59 | "main": "lib/indigo-player.js", 60 | "types": "src/index.d.ts", 61 | "dependencies": { 62 | "can-autoplay": "^3.0.0", 63 | "classnames": "^2.2.6", 64 | "deepmerge": "^3.1.0", 65 | "eventemitter3": "^3.1.0", 66 | "hls.js": "^0.12.2", 67 | "immer": "^1.10.0", 68 | "jdataview": "^2.5.0", 69 | "lodash": "^4.17.11", 70 | "react": "^16.8.0-alpha.1", 71 | "react-dom": "^16.8.0-alpha.1", 72 | "request-frame": "^1.5.3", 73 | "screenfull": "^4.0.0", 74 | "shaka-player": "^2.5.0-beta2", 75 | "simple-element-resize-detector": "^1.2.0", 76 | "subtitle": "^2.0.2", 77 | "ts-polyfill": "^3.0.1", 78 | "url-parse": "^1.4.4", 79 | "vtt-to-json": "^0.1.1" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Hooks.ts: -------------------------------------------------------------------------------- 1 | import { IHooks, IModule, NextHook } from '@src/types'; 2 | import filter from 'lodash/filter'; 3 | 4 | interface IHook { 5 | name: string; 6 | callback: NextHook; 7 | } 8 | 9 | /** 10 | * @Hookable 11 | * Decorator to let a class know that methods inside can 12 | * be hooked. 13 | */ 14 | export function Hookable {}>(constructor: T) { 15 | return class extends constructor { 16 | public hooks = new Hooks((this as unknown) as IModule); 17 | }; 18 | } 19 | 20 | class Hooks implements IHooks { 21 | private module: IModule; 22 | 23 | private hooks: IHook[] = []; 24 | 25 | private origFunctions: any = {}; 26 | 27 | constructor(module: IModule) { 28 | this.module = module; 29 | } 30 | 31 | public create(name: string, callback: NextHook) { 32 | this.hookFunction(name); 33 | 34 | this.hooks.push({ 35 | name, 36 | callback, 37 | }); 38 | } 39 | 40 | private hookFunction(name: string) { 41 | if (typeof this.module[name] !== 'function') { 42 | throw new Error( 43 | `The method "${name}" does not exist in ${ 44 | this.module.constructor.name 45 | }`, 46 | ); 47 | } 48 | 49 | if (this.origFunctions[name]) { 50 | return; 51 | } 52 | 53 | // Store the original function and apply a hook. 54 | this.origFunctions[name] = this.module[name]; 55 | this.module[name] = this.hookedFunction(name); 56 | } 57 | 58 | private hookedFunction = (name: string) => (...args: any) => { 59 | const selectedHooks = filter(this.hooks, { name }); 60 | let index = -1; 61 | 62 | const runOrigFunction = () => 63 | this.origFunctions[name].call(this.module, ...args); 64 | 65 | const runNextHook = () => { 66 | const hook = selectedHooks[(index += 1)]; 67 | 68 | // If we have no hook to call anymore, call the original function. 69 | if (!hook) { 70 | runOrigFunction(); 71 | return; 72 | } 73 | 74 | let proceed = false; 75 | const next = () => { 76 | proceed = true; 77 | }; 78 | 79 | // We've got a hook to call, call it. 80 | hook.callback.call(null, next, ...args); 81 | 82 | // Did the hook proceed? 83 | if (proceed) { 84 | runNextHook(); 85 | } 86 | }; 87 | 88 | runNextHook(); 89 | }; 90 | } 91 | -------------------------------------------------------------------------------- /src/Module.ts: -------------------------------------------------------------------------------- 1 | import { Hookable } from '@src/Hooks'; 2 | import { EventCallback, IEventData, IInstance, IModule } from '@src/types'; 3 | 4 | @Hookable 5 | export class Module implements IModule { 6 | public name = 'Unknown'; 7 | 8 | public hooks: any; 9 | 10 | public instance: IInstance; 11 | 12 | constructor(instance: IInstance) { 13 | this.instance = instance; 14 | } 15 | 16 | public on(name: string, callback: EventCallback) { 17 | this.instance.on(name, callback); 18 | } 19 | 20 | public once(name: string, callback: EventCallback) { 21 | this.instance.on(name, callback); 22 | } 23 | 24 | public emit(name: string, eventData?: IEventData) { 25 | this.instance.emit(name, eventData); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/ModuleLoader.ts: -------------------------------------------------------------------------------- 1 | import { BaseControllerLoader } from '@src/controller/BaseController/BaseControllerLoader'; 2 | import { BenchmarkExtensionLoader } from '@src/extensions/BenchmarkExtension/BenchmarkExtensionLoader'; 3 | import { ContextMenuExtensionLoader } from '@src/extensions/ContextMenuExtension/ContextMenuExtensionLoader'; 4 | import { FreeWheelExtensionLoader } from '@src/extensions/FreeWheelExtension/FreeWheelExtensionLoader'; 5 | import { FullscreenExtensionLoader } from '@src/extensions/FullscreenExtension/FullscreenExtensionLoader'; 6 | import { GoogleIMAExtensionLoader } from '@src/extensions/GoogleIMAExtension/GoogleIMAExtensionLoader'; 7 | import { KeyboardNavigationExtensionLoader } from '@src/extensions/KeyboardNavigationExtension/KeyboardNavigationExtensionLoader'; 8 | import { PipExtensionLoader } from '@src/extensions/PipExtension/PipExtensionLoader'; 9 | import { StateExtensionLoader } from '@src/extensions/StateExtension/StateExtensionLoader'; 10 | import { SubtitlesExtensionLoader } from '@src/extensions/SubtitlesExtension/SubtitlesExtensionLoader'; 11 | import { ThumbnailsExtensionLoader } from '@src/extensions/ThumbnailsExtension/ThumbnailsExtensionLoader'; 12 | import { DimensionsExtensionLoader } from '@src/extensions/DimensionsExtension/DimensionsExtensionLoader'; 13 | import { BaseMediaLoader } from '@src/media/BaseMedia/BaseMediaLoader'; 14 | import { DashMediaLoader } from '@src/media/DashMedia/DashMediaLoader'; 15 | import { HlsMediaLoader } from '@src/media/HlsMedia/HlsMediaLoader'; 16 | import { HTML5PlayerLoader } from '@src/player/HTML5Player/HTML5PlayerLoader'; 17 | import { 18 | IInstance, 19 | IModule, 20 | IModuleLoader, 21 | ModuleLoaderTypes, 22 | } from '@src/types'; 23 | import { UiExtensionLoader } from '@src/ui/UiExtensionLoader'; 24 | import find from 'lodash/find'; 25 | 26 | const modules: Array> = [ 27 | BaseControllerLoader, 28 | 29 | DashMediaLoader, 30 | HlsMediaLoader, 31 | BaseMediaLoader, 32 | 33 | HTML5PlayerLoader, 34 | 35 | PipExtensionLoader, 36 | UiExtensionLoader, 37 | StateExtensionLoader, 38 | BenchmarkExtensionLoader, 39 | FreeWheelExtensionLoader, 40 | FullscreenExtensionLoader, 41 | SubtitlesExtensionLoader, 42 | GoogleIMAExtensionLoader, 43 | ThumbnailsExtensionLoader, 44 | KeyboardNavigationExtensionLoader, 45 | ContextMenuExtensionLoader, 46 | DimensionsExtensionLoader, 47 | ]; 48 | 49 | export async function createFirstSupported( 50 | type: ModuleLoaderTypes, 51 | instance: IInstance, 52 | isSupportedArgs?: any, 53 | ): Promise { 54 | const items = modules.filter(item => item.type === type); 55 | 56 | for (const loader of items) { 57 | if (await loader.isSupported(instance, isSupportedArgs)) { 58 | return ((await loader.create(instance)) as unknown) as T; 59 | } 60 | } 61 | 62 | return null; 63 | } 64 | 65 | export async function createAllSupported( 66 | type: ModuleLoaderTypes, 67 | instance: IInstance, 68 | isSupportedArgs?: any, 69 | ): Promise { 70 | const items = modules.filter(item => item.type === type); 71 | 72 | const instances: T[] = []; 73 | 74 | for (const loader of items) { 75 | if (await loader.isSupported(instance, isSupportedArgs)) { 76 | instances.push(((await loader.create(instance)) as unknown) as T); 77 | } 78 | } 79 | 80 | return instances; 81 | } 82 | 83 | export function addModuleLoader(mod: IModuleLoader) { 84 | modules.push(mod); 85 | } 86 | -------------------------------------------------------------------------------- /src/PlayerError.ts: -------------------------------------------------------------------------------- 1 | import { ErrorCodes, IPlayerError } from '@src/types'; 2 | import isString from 'lodash/isString'; 3 | 4 | export class PlayerError extends Error implements IPlayerError { 5 | public code: ErrorCodes; 6 | 7 | public underlyingError: any; 8 | 9 | constructor(input: ErrorCodes | string, error?: any) { 10 | super(); 11 | 12 | if (isString(input)) { 13 | this.message = input; 14 | } else { 15 | this.code = input as ErrorCodes; 16 | } 17 | 18 | this.underlyingError = error; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/controller/BaseController/BaseController.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@src/controller/Controller'; 2 | import { PlayerError } from '@src/PlayerError'; 3 | import { selectMedia, selectPlayer } from '@src/selectModule'; 4 | import { ErrorCodes, ITrack } from '@src/types'; 5 | 6 | export class BaseController extends Controller { 7 | public name: string = 'BaseController'; 8 | 9 | public async load() { 10 | this.instance.player = await selectPlayer(this.instance); 11 | 12 | const [format, media] = await selectMedia(this.instance); 13 | if (!media) { 14 | throw new PlayerError(ErrorCodes.NO_SUPPORTED_FORMAT_FOUND); 15 | } 16 | 17 | this.instance.format = format; 18 | this.instance.media = media; 19 | 20 | this.instance.player.load(); 21 | await this.instance.media.load(); 22 | } 23 | 24 | public unload() { 25 | if (this.instance.media) { 26 | this.instance.media.unload(); 27 | } 28 | if (this.instance.player) { 29 | this.instance.player.unload(); 30 | } 31 | } 32 | 33 | public play() { 34 | this.instance.media.play(); 35 | } 36 | 37 | public pause() { 38 | this.instance.media.pause(); 39 | } 40 | 41 | public seekTo(time: number) { 42 | this.instance.media.seekTo(time); 43 | } 44 | 45 | public setVolume(volume: number) { 46 | this.instance.media.setVolume(volume); 47 | } 48 | 49 | public selectTrack(track: ITrack) { 50 | this.instance.media.selectTrack(track); 51 | } 52 | 53 | public selectAudioLanguage(language: string) { 54 | this.instance.media.selectAudioLanguage(language); 55 | } 56 | 57 | public setPlaybackRate(playbackRate: number) { 58 | this.instance.media.setPlaybackRate(playbackRate); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/controller/BaseController/BaseControllerLoader.ts: -------------------------------------------------------------------------------- 1 | import { BaseController } from '@src/controller/BaseController/BaseController'; 2 | import { 3 | Config, 4 | IInstance, 5 | IModuleLoader, 6 | ModuleLoaderTypes, 7 | } from '@src/types'; 8 | 9 | export const BaseControllerLoader = { 10 | type: ModuleLoaderTypes.CONTROLLER, 11 | 12 | create: (instance: IInstance) => new BaseController(instance), 13 | 14 | isSupported: ({ config }: { config: Config }): boolean => { 15 | return config.sources.length > 0; 16 | }, 17 | } as IModuleLoader; 18 | -------------------------------------------------------------------------------- /src/controller/Controller.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@src/Module'; 2 | import { PlayerError } from '@src/PlayerError'; 3 | import { ErrorCodes, IController, ITrack } from '@src/types'; 4 | 5 | export class Controller extends Module implements IController { 6 | public async load() {} 7 | 8 | public unload() {} 9 | 10 | public play() {} 11 | 12 | public pause() {} 13 | 14 | public seekTo(time: number) {} 15 | 16 | public setVolume(volume: number) {} 17 | 18 | public selectTrack(track: ITrack) {} 19 | 20 | public selectAudioLanguage(language: string) {} 21 | 22 | public setPlaybackRate(playbackRate: number) {} 23 | } 24 | -------------------------------------------------------------------------------- /src/createAPI.ts: -------------------------------------------------------------------------------- 1 | import { PlayerError } from '@src/PlayerError'; 2 | import { EventCallback, IEventData, IInstance, ITrack } from '@src/types'; 3 | import { createFunctionFn } from '@src/utils/defineProperty'; 4 | 5 | /** 6 | * Defines the public API, this is the return value of init(). 7 | * @param {IInstance} instance 8 | * @return {Object} External API. 9 | */ 10 | export function createAPI(instance: IInstance) { 11 | const api: any = {}; 12 | 13 | const createFunction = createFunctionFn(api); 14 | 15 | [ 16 | // Bind listeners continuously 17 | [ 18 | 'on', 19 | (name: string, callback: EventCallback) => instance.on(name, callback), 20 | ], 21 | 22 | // Bind listeners once 23 | [ 24 | 'once', 25 | (name: string, callback: EventCallback) => instance.once(name, callback), 26 | ], 27 | 28 | // Remove a listener 29 | [ 30 | 'removeListener', 31 | (name: string, callback: EventCallback) => 32 | instance.removeListener(name, callback), 33 | ], 34 | 35 | // Emit an event on the listeners 36 | [ 37 | 'emit', 38 | (name: string, eventData?: IEventData) => instance.emit(name, eventData), 39 | ], 40 | 41 | // Play 42 | ['play', () => instance.play()], 43 | 44 | // Pause 45 | ['pause', () => instance.pause()], 46 | 47 | // Seek to a time 48 | ['seekTo', (time: number) => instance.seekTo(time)], 49 | 50 | // Set the volume 51 | ['setVolume', (volume: number) => instance.setVolume(volume)], 52 | 53 | // Select a track 54 | ['selectTrack', (track: ITrack) => instance.selectTrack(track)], 55 | 56 | // Select an audio language 57 | [ 58 | 'selectAudioLanguage', 59 | (language: string) => instance.selectAudioLanguage(language), 60 | ], 61 | 62 | // Select playback rate 63 | [ 64 | 'setPlaybackRate', 65 | (playbackRate: number) => instance.setPlaybackRate(playbackRate), 66 | ], 67 | 68 | // Set a fatal error 69 | ['setError', (error: PlayerError) => instance.setError(error)], 70 | 71 | // Destroy the player 72 | ['destroy', () => instance.destroy()], 73 | 74 | // Get stats 75 | ['getStats', () => instance.getStats()], 76 | 77 | // Get a specific module by name 78 | ['getModule', (name: string) => instance.getModule(name)], 79 | ].forEach(tuple => createFunction(tuple[0], tuple[1])); 80 | 81 | api._getInstanceForDev = () => instance; 82 | 83 | return api; 84 | } 85 | -------------------------------------------------------------------------------- /src/createConfig.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '@src/types'; 2 | import { deprecate } from '@src/utils/deprecate'; 3 | import merge from 'deepmerge'; 4 | 5 | export function createConfig(input: Config): Config { 6 | if (typeof input.ui !== 'object') { 7 | (input.ui as any) = {}; 8 | deprecate( 9 | 'Using config.ui as a boolean is deprecated, use an object with the UI specific options instead. See docs for your migration.', 10 | ); 11 | } 12 | 13 | if (typeof (input as any).uiOptions === 'object') { 14 | (input as any).ui = (input as any).uiOptions; 15 | deprecate('Using config.uiOptions has been removed in favor of config.ui.'); 16 | 17 | if ((input as any).uiOptions.enablePip) { 18 | input.ui.pip = (input as any).uiOptions.enablePip; 19 | } 20 | } 21 | 22 | if (typeof input.thumbnails !== 'object') { 23 | input.thumbnails = { 24 | src: input.thumbnails, 25 | }; 26 | deprecate( 27 | 'Using config.thumbnails as a string is deprecated, use { src: "./thumbnails.vtt" } instead.', 28 | ); 29 | } 30 | 31 | if ((input as any).captions) { 32 | input.subtitles = (input as any).captions; 33 | deprecate('config.captions has been changed to config.subtitles'); 34 | } 35 | 36 | return merge( 37 | { 38 | enableLogs: false, 39 | autoplay: false, 40 | keyboardNavigation: 'focus', 41 | aspectRatio: 16 / 9, 42 | volume: 1, 43 | ui: { 44 | enabled: true, 45 | lockControlsVisibility: false, 46 | locale: 'en-US', 47 | pip: false, 48 | }, 49 | sources: [], 50 | subtitles: [], 51 | }, 52 | input, 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/extensions/BenchmarkExtension/BenchmarkExtension.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@src/Module'; 2 | import { Events, IInstance } from '@src/types'; 3 | 4 | const startTime = performance.now(); 5 | 6 | export class BenchmarkExtension extends Module { 7 | public name: string = 'BenchmarkExtension'; 8 | 9 | public startupTimeExtension: number; 10 | 11 | public startupTimePlayer: number; 12 | 13 | public startupTimeInstance: number; 14 | 15 | public startupTimeWithAutoplay: number; 16 | 17 | constructor(instance: IInstance) { 18 | super(instance); 19 | 20 | this.startupTimeExtension = performance.now() - startTime; 21 | 22 | this.once(Events.INSTANCE_INITIALIZED, () => { 23 | this.startupTimeInstance = performance.now() - startTime; 24 | }); 25 | 26 | if (instance.canAutoplay()) { 27 | this.once(Events.PLAYER_STATE_PLAYING, () => { 28 | this.startupTimeWithAutoplay = performance.now() - startTime; 29 | }); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/extensions/BenchmarkExtension/BenchmarkExtensionLoader.ts: -------------------------------------------------------------------------------- 1 | import { BenchmarkExtension } from '@src/extensions/BenchmarkExtension/BenchmarkExtension'; 2 | import { 3 | Config, 4 | IInstance, 5 | IModuleLoader, 6 | ModuleLoaderTypes, 7 | } from '@src/types'; 8 | 9 | export const BenchmarkExtensionLoader = { 10 | type: ModuleLoaderTypes.EXTENSION, 11 | 12 | create: (instance: IInstance) => new BenchmarkExtension(instance), 13 | 14 | isSupported: ({ config }: { config: Config }): boolean => true, 15 | } as IModuleLoader; 16 | -------------------------------------------------------------------------------- /src/extensions/ContextMenuExtension/ContextMenuExtension.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@src/Module'; 2 | import { Events, IInstance } from '@src/types'; 3 | import pkgInfo from '../../../package.json'; 4 | import './context-menu.scss'; 5 | 6 | const logo = require('./indigo-logo-small.png'); 7 | 8 | export class ContextMenuExtension extends Module { 9 | public name: string = 'ContextMenuExtension'; 10 | 11 | private contextMenu: HTMLDivElement; 12 | 13 | constructor(instance: IInstance) { 14 | super(instance); 15 | 16 | instance.container.addEventListener('contextmenu', this.onContextMenu); 17 | 18 | this.contextMenu = document.createElement('div'); 19 | this.contextMenu.classList.add('ig_contextmenu'); 20 | this.contextMenu.style.opacity = '0'; 21 | instance.container.appendChild(this.contextMenu); 22 | 23 | this.addItem( 24 | ` Powered by indigo-player v${ 25 | pkgInfo.version 26 | }`, 27 | () => { 28 | (window as any).open( 29 | 'https://matvp91.github.io/indigo-player', 30 | '_blank', 31 | ); 32 | }, 33 | ); 34 | } 35 | 36 | public addItem(html: string, onClick: any) { 37 | const item = document.createElement('button'); 38 | item.innerHTML = html; 39 | item.addEventListener('click', onClick); 40 | this.contextMenu.appendChild(item); 41 | } 42 | 43 | private onContextMenu = event => { 44 | event.preventDefault(); 45 | 46 | this.contextMenu.style.left = 'initial'; 47 | this.contextMenu.style.right = 'initial'; 48 | this.contextMenu.style.top = 'initial'; 49 | this.contextMenu.style.bottom = 'initial'; 50 | this.contextMenu.style.opacity = '1'; 51 | this.contextMenu.style.pointerEvents = 'auto'; 52 | 53 | const rect = this.instance.container.getBoundingClientRect(); 54 | 55 | const x = event.clientX - rect.left; 56 | const y = event.clientY - rect.top; 57 | 58 | if (x + this.contextMenu.offsetWidth >= rect.width) { 59 | this.contextMenu.style.right = rect.width - x + 'px'; 60 | } else { 61 | this.contextMenu.style.left = x + 'px'; 62 | } 63 | 64 | if (y + this.contextMenu.offsetHeight >= rect.height) { 65 | this.contextMenu.style.bottom = rect.height - y + 'px'; 66 | } else { 67 | this.contextMenu.style.top = y + 'px'; 68 | } 69 | 70 | window.addEventListener('click', this.onClick); 71 | }; 72 | 73 | private onClick = () => { 74 | this.contextMenu.style.opacity = '0'; 75 | this.contextMenu.style.pointerEvents = 'none'; 76 | window.removeEventListener('click', this.onClick); 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /src/extensions/ContextMenuExtension/ContextMenuExtensionLoader.ts: -------------------------------------------------------------------------------- 1 | import { ContextMenuExtension } from '@src/extensions/ContextMenuExtension/ContextMenuExtension'; 2 | import { 3 | Config, 4 | IInstance, 5 | IModuleLoader, 6 | ModuleLoaderTypes, 7 | } from '@src/types'; 8 | 9 | export const ContextMenuExtensionLoader = { 10 | type: ModuleLoaderTypes.EXTENSION, 11 | 12 | create: (instance: IInstance) => new ContextMenuExtension(instance), 13 | 14 | isSupported: ({ config }: { config: Config }): boolean => true, 15 | } as IModuleLoader; 16 | -------------------------------------------------------------------------------- /src/extensions/ContextMenuExtension/context-menu.scss: -------------------------------------------------------------------------------- 1 | .ig_contextmenu { 2 | min-width: 150px; 3 | position: absolute; 4 | background: rgba(0, 0, 0, .5); 5 | color: #ffffff; 6 | border-radius: 2px; 7 | padding: 4px 0; 8 | z-index: 12; 9 | 10 | button { 11 | background: none; 12 | color: inherit; 13 | border: none; 14 | padding: 6px 12px; 15 | font: 12px -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Ubuntu, Droid Sans, Helvetica Neue, sans-serif; 16 | cursor: pointer; 17 | outline: inherit; 18 | display: flex; 19 | align-items: center; 20 | white-space: pre-wrap; 21 | 22 | &:hover { 23 | background-color: rgba(255, 255, 255, .07); 24 | } 25 | 26 | img { 27 | width: 12px; 28 | margin-right: 3px; 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /src/extensions/ContextMenuExtension/indigo-logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matvp91/indigo-player/32ee84248df280afd84b53a8af1096c374ae0f2b/src/extensions/ContextMenuExtension/indigo-logo-small.png -------------------------------------------------------------------------------- /src/extensions/DimensionsExtension/DimensionsExtension.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@src/Module'; 2 | import { Events, IInstance, IDimensionsChangeEventData } from '@src/types'; 3 | import debounce from 'lodash/debounce'; 4 | 5 | import observeResize from 'simple-element-resize-detector'; 6 | 7 | export class DimensionsExtension extends Module { 8 | public name: string = 'DimensionsExtension'; 9 | 10 | private observer: any; 11 | 12 | constructor(instance: IInstance) { 13 | super(instance); 14 | 15 | const debouncedOnResizeContainer = debounce(this.onResizeContainer, 250); 16 | this.observer = observeResize( 17 | instance.container, 18 | debouncedOnResizeContainer.bind(this), 19 | ); 20 | 21 | this.on(Events.INSTANCE_INITIALIZED, this.onInstanceInitialized.bind(this)); 22 | } 23 | 24 | destroy() { 25 | this.observer.remove(); 26 | } 27 | 28 | onInstanceInitialized() { 29 | this.onResizeContainer(); 30 | } 31 | 32 | onResizeContainer() { 33 | const rect = this.instance.container.getBoundingClientRect(); 34 | this.emit(Events.DIMENSIONS_CHANGE, { 35 | width: rect.width, 36 | height: rect.height, 37 | } as IDimensionsChangeEventData); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/extensions/DimensionsExtension/DimensionsExtensionLoader.ts: -------------------------------------------------------------------------------- 1 | import { DimensionsExtension } from '@src/extensions/DimensionsExtension/DimensionsExtension'; 2 | import { 3 | Config, 4 | IInstance, 5 | IModuleLoader, 6 | ModuleLoaderTypes, 7 | } from '@src/types'; 8 | 9 | export const DimensionsExtensionLoader = { 10 | type: ModuleLoaderTypes.EXTENSION, 11 | 12 | create: (instance: IInstance) => new DimensionsExtension(instance), 13 | 14 | isSupported: ({ config }: { config: Config }): boolean => true, 15 | } as IModuleLoader; 16 | -------------------------------------------------------------------------------- /src/extensions/FreeWheelExtension/FreeWheelExtensionLoader.ts: -------------------------------------------------------------------------------- 1 | import { FreeWheelExtension } from '@src/extensions/FreeWheelExtension/FreeWheelExtension'; 2 | import { 3 | Config, 4 | IInstance, 5 | IModuleLoader, 6 | ModuleLoaderTypes, 7 | } from '@src/types'; 8 | 9 | export const FreeWheelExtensionLoader = { 10 | type: ModuleLoaderTypes.EXTENSION, 11 | 12 | create: (instance: IInstance) => new FreeWheelExtension(instance), 13 | 14 | isSupported: ({ config }: { config: Config }): boolean => { 15 | return config.freewheel && config.freewheel.clientSide; 16 | }, 17 | } as IModuleLoader; 18 | -------------------------------------------------------------------------------- /src/extensions/FullscreenExtension/FullscreenExtension.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@src/Module'; 2 | import { Events, IEventData, IInstance } from '@src/types'; 3 | 4 | import * as sfDefault from 'screenfull'; 5 | const screenfull = sfDefault as sfDefault.Screenfull; 6 | 7 | interface IFullscreenEventData extends IEventData { 8 | fullscreen: boolean; 9 | } 10 | 11 | export class FullscreenExtension extends Module { 12 | public name: string = 'FullscreenExtension'; 13 | 14 | private documentPos: { 15 | x: number; 16 | y: number; 17 | }; 18 | 19 | constructor(instance: IInstance) { 20 | super(instance); 21 | 22 | if (screenfull.enabled) { 23 | this.emit(Events.FULLSCREEN_SUPPORTED); 24 | 25 | screenfull.on('change', () => { 26 | const fullscreen: boolean = screenfull.isFullscreen; 27 | 28 | this.handleDocumentPos(fullscreen); 29 | 30 | this.emit(Events.FULLSCREEN_CHANGE, { 31 | fullscreen, 32 | } as IFullscreenEventData); 33 | }); 34 | } 35 | } 36 | 37 | public toggleFullscreen() { 38 | if (screenfull.enabled) { 39 | screenfull.toggle(this.instance.container); 40 | } 41 | } 42 | 43 | // Code below evades the following Chromium bug: 44 | // https://bugs.chromium.org/p/chromium/issues/detail?id=142427. 45 | private handleDocumentPos(isFullscreen: boolean) { 46 | if (isFullscreen) { 47 | const x = window.pageXOffset; 48 | const y = window.pageYOffset; 49 | if (x || y) { 50 | this.documentPos = { 51 | x: x || 0, 52 | y: y || 0, 53 | }; 54 | } 55 | } else { 56 | if (!this.documentPos) { 57 | return; 58 | } 59 | 60 | window.scrollTo(this.documentPos.x, this.documentPos.y); 61 | this.documentPos = null; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/extensions/FullscreenExtension/FullscreenExtensionLoader.ts: -------------------------------------------------------------------------------- 1 | import { FullscreenExtension } from '@src/extensions/FullscreenExtension/FullscreenExtension'; 2 | import { 3 | Config, 4 | IInstance, 5 | IModuleLoader, 6 | ModuleLoaderTypes, 7 | } from '@src/types'; 8 | 9 | export const FullscreenExtensionLoader = { 10 | type: ModuleLoaderTypes.EXTENSION, 11 | 12 | create: (instance: IInstance) => new FullscreenExtension(instance), 13 | 14 | isSupported: ({ config }: { config: Config }): boolean => true, 15 | } as IModuleLoader; 16 | -------------------------------------------------------------------------------- /src/extensions/GoogleIMAExtension/GoogleIMAExtensionLoader.ts: -------------------------------------------------------------------------------- 1 | import { GoogleIMAExtension } from '@src/extensions/GoogleIMAExtension/GoogleIMAExtension'; 2 | import { 3 | Config, 4 | IInstance, 5 | IModuleLoader, 6 | ModuleLoaderTypes, 7 | } from '@src/types'; 8 | 9 | export const GoogleIMAExtensionLoader = { 10 | type: ModuleLoaderTypes.EXTENSION, 11 | 12 | create: (instance: IInstance) => new GoogleIMAExtension(instance), 13 | 14 | isSupported: ({ config }: { config: Config }): boolean => { 15 | return !!config.googleIMA; 16 | }, 17 | } as IModuleLoader; 18 | -------------------------------------------------------------------------------- /src/extensions/KeyboardNavigationExtension/KeyboardNavigationExtension.ts: -------------------------------------------------------------------------------- 1 | import { FullscreenExtension } from '@src/extensions/FullscreenExtension/FullscreenExtension'; 2 | import { StateExtension } from '@src/extensions/StateExtension/StateExtension'; 3 | import { Module } from '@src/Module'; 4 | import { 5 | Events, 6 | IInstance, 7 | IKeyboardNavigationKeyDownEventData, 8 | KeyboardNavigationPurpose, 9 | } from '@src/types'; 10 | 11 | enum KeyCodes { 12 | SPACEBAR = 32, 13 | K = 75, 14 | LEFT_ARROW = 37, 15 | RIGHT_ARROW = 39, 16 | UP_ARROW = 38, 17 | DOWN_ARROW = 40, 18 | M = 77, 19 | F = 70, 20 | C = 67, 21 | I = 73, 22 | } 23 | 24 | const SKIP_CURRENTTIME_OFFSET: number = 5; 25 | 26 | const SKIP_VOLUME_OFFSET: number = 0.1; 27 | 28 | export class KeyboardNavigationExtension extends Module { 29 | public name: string = 'KeyboardNavigationExtension'; 30 | 31 | private hasFocus: boolean; 32 | 33 | constructor(instance: IInstance) { 34 | super(instance); 35 | 36 | (window as any).addEventListener('keydown', this.onKeyDown); 37 | (window as any).addEventListener('mousedown', this.onMouseDown); 38 | } 39 | 40 | public triggerFocus() { 41 | this.hasFocus = true; 42 | } 43 | 44 | private onKeyDown = (event: KeyboardEvent) => { 45 | if (this.instance.config.keyboardNavigation === 'focus' && !this.hasFocus) { 46 | return; 47 | } 48 | 49 | switch (event.which || event.keyCode) { 50 | // Toggles play and pause. 51 | case KeyCodes.SPACEBAR: 52 | case KeyCodes.K: 53 | if (this.getState().playRequested) { 54 | this.instance.pause(); 55 | this.emitPurpose(KeyboardNavigationPurpose.PAUSE); 56 | } else { 57 | this.instance.play(); 58 | this.emitPurpose(KeyboardNavigationPurpose.PLAY); 59 | } 60 | event.preventDefault(); 61 | break; 62 | 63 | // Seeks back x seconds. 64 | case KeyCodes.LEFT_ARROW: 65 | let prevTime = this.getState().currentTime - SKIP_CURRENTTIME_OFFSET; 66 | if (prevTime < 0) { 67 | prevTime = 0; 68 | } 69 | this.instance.seekTo(prevTime); 70 | this.emitPurpose(KeyboardNavigationPurpose.PREV_SEEK); 71 | event.preventDefault(); 72 | break; 73 | 74 | // Seeks forward x seconds. 75 | case KeyCodes.RIGHT_ARROW: 76 | let nextTime = this.getState().currentTime + SKIP_CURRENTTIME_OFFSET; 77 | if (nextTime > this.getState().duration) { 78 | nextTime = this.getState().duration; 79 | } 80 | this.instance.seekTo(nextTime); 81 | this.emitPurpose(KeyboardNavigationPurpose.NEXT_SEEK); 82 | event.preventDefault(); 83 | break; 84 | 85 | // Increases the volume. 86 | case KeyCodes.UP_ARROW: 87 | let nextVolume = this.getState().volume + SKIP_VOLUME_OFFSET; 88 | if (nextVolume > 1) { 89 | nextVolume = 1; 90 | } 91 | this.instance.setVolume(nextVolume); 92 | this.emitPurpose(KeyboardNavigationPurpose.VOLUME_UP); 93 | event.preventDefault(); 94 | break; 95 | 96 | // Decreases the volume. 97 | case KeyCodes.DOWN_ARROW: 98 | let prevVolume = this.getState().volume - SKIP_VOLUME_OFFSET; 99 | if (prevVolume < 0) { 100 | prevVolume = 0; 101 | } 102 | this.instance.setVolume(prevVolume); 103 | this.emitPurpose(KeyboardNavigationPurpose.VOLUME_DOWN); 104 | event.preventDefault(); 105 | break; 106 | 107 | // Toggles mute. 108 | case KeyCodes.M: 109 | if (this.getState().volume > 0) { 110 | this.instance.setVolume(0); 111 | this.emitPurpose(KeyboardNavigationPurpose.VOLUME_MUTED); 112 | } else { 113 | this.instance.setVolume(1); 114 | this.emitPurpose(KeyboardNavigationPurpose.VOLUME_UNMUTED); 115 | } 116 | event.preventDefault(); 117 | break; 118 | 119 | // Toggles fullscreen. 120 | case KeyCodes.F: 121 | const fullscreenExtension: FullscreenExtension = this.instance.getModule( 122 | 'FullscreenExtension', 123 | ) as FullscreenExtension; 124 | if (fullscreenExtension) { 125 | fullscreenExtension.toggleFullscreen(); 126 | this.emitPurpose(KeyboardNavigationPurpose.TOGGLE_FULLSCREEN); 127 | event.preventDefault(); 128 | } 129 | break; 130 | 131 | case KeyCodes.C: 132 | this.emitPurpose(KeyboardNavigationPurpose.REQUEST_TOGGLE_SUBTITLES); 133 | event.preventDefault(); 134 | break; 135 | 136 | case KeyCodes.I: 137 | this.emitPurpose(KeyboardNavigationPurpose.REQUEST_TOGGLE_MINIPLAYER); 138 | event.preventDefault(); 139 | break; 140 | } 141 | }; 142 | 143 | private onMouseDown = (event: MouseEvent) => { 144 | this.hasFocus = this.instance.container.contains(event.target as Node); 145 | }; 146 | 147 | private getState() { 148 | return (this.instance.getModule( 149 | 'StateExtension', 150 | ) as StateExtension).getState(); 151 | } 152 | 153 | private emitPurpose(purpose: KeyboardNavigationPurpose) { 154 | this.instance.emit(Events.KEYBOARDNAVIGATION_KEYDOWN, { 155 | purpose, 156 | } as IKeyboardNavigationKeyDownEventData); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/extensions/KeyboardNavigationExtension/KeyboardNavigationExtensionLoader.ts: -------------------------------------------------------------------------------- 1 | import { KeyboardNavigationExtension } from '@src/extensions/KeyboardNavigationExtension/KeyboardNavigationExtension'; 2 | import { 3 | Config, 4 | IInstance, 5 | IModuleLoader, 6 | ModuleLoaderTypes, 7 | } from '@src/types'; 8 | 9 | export const KeyboardNavigationExtensionLoader = { 10 | type: ModuleLoaderTypes.EXTENSION, 11 | 12 | create: (instance: IInstance) => 13 | new KeyboardNavigationExtension(instance), 14 | 15 | isSupported: ({ config }: { config: Config }): boolean => 16 | !!config.keyboardNavigation, 17 | } as IModuleLoader; 18 | -------------------------------------------------------------------------------- /src/extensions/PipExtension/PipExtension.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@src/Module'; 2 | import { Events, IEventData, IInstance } from '@src/types'; 3 | import './pip.scss'; 4 | 5 | interface IPipChangeEventData extends IEventData { 6 | pip: boolean; 7 | } 8 | 9 | export class PipExtension extends Module { 10 | public name: string = 'PipExtension'; 11 | 12 | public pip: boolean = false; 13 | 14 | private playerContainer: HTMLElement; 15 | 16 | private playerContainerParent: HTMLElement; 17 | 18 | private pipPlaceholder: HTMLElement; 19 | 20 | private pipContainer: HTMLElement; 21 | 22 | private moveStartX: number; 23 | private moveStartY: number; 24 | 25 | private internalMoveDragging: any; 26 | private internalStopDragging: any; 27 | 28 | constructor(instance: IInstance) { 29 | super(instance); 30 | 31 | this.playerContainer = this.instance.container; 32 | this.playerContainerParent = this.instance.container.parentElement; 33 | } 34 | 35 | public enablePip() { 36 | const container = document.createElement('div'); 37 | container.classList.add('ig_pip-container'); 38 | 39 | const handler = document.createElement('div'); 40 | handler.classList.add('ig_pip-handler'); 41 | handler.addEventListener('mousedown', event => this.startDragging(event)); 42 | container.appendChild(handler); 43 | 44 | const close = document.createElement('div'); 45 | close.innerHTML = '×'; 46 | close.classList.add('ig_pip-close'); 47 | close.addEventListener('click', () => this.disablePip()); 48 | container.appendChild(close); 49 | 50 | const placeholder = document.createElement('div'); 51 | placeholder.classList.add('ig_pip-placeholder'); 52 | this.playerContainerParent.appendChild(placeholder); 53 | 54 | this.pipPlaceholder = placeholder; 55 | this.pipContainer = container; 56 | 57 | container.appendChild(this.playerContainer); 58 | document.body.appendChild(container); 59 | 60 | this.pip = true; 61 | 62 | this.emit(Events.PIP_CHANGE, { 63 | pip: this.pip, 64 | } as IPipChangeEventData); 65 | } 66 | 67 | public disablePip() { 68 | this.playerContainerParent.appendChild(this.playerContainer); 69 | this.pipPlaceholder.parentElement.removeChild(this.pipPlaceholder); 70 | this.pipContainer.parentElement.removeChild(this.pipContainer); 71 | 72 | this.pip = false; 73 | 74 | this.emit(Events.PIP_CHANGE, { 75 | pip: this.pip, 76 | } as IPipChangeEventData); 77 | } 78 | 79 | public togglePip() { 80 | if (this.pip) { 81 | this.disablePip(); 82 | } else { 83 | this.enablePip(); 84 | } 85 | } 86 | 87 | private startDragging(event) { 88 | event.preventDefault(); 89 | 90 | this.moveStartX = event.clientX; 91 | this.moveStartY = event.clientY; 92 | 93 | this.internalMoveDragging = event => this.moveDragging(event); 94 | document.addEventListener('mousemove', this.internalMoveDragging); 95 | this.internalStopDragging = event => this.stopDragging(event); 96 | document.addEventListener('mouseup', this.internalStopDragging); 97 | } 98 | 99 | private moveDragging(event) { 100 | event.preventDefault(); 101 | 102 | const diffX = this.moveStartX - event.clientX; 103 | this.pipContainer.style.left = `${this.pipContainer.offsetLeft - diffX}px`; 104 | 105 | const diffY = this.moveStartY - event.clientY; 106 | this.pipContainer.style.top = `${this.pipContainer.offsetTop - diffY}px`; 107 | 108 | this.moveStartX = event.clientX; 109 | this.moveStartY = event.clientY; 110 | } 111 | 112 | private stopDragging(event) { 113 | document.removeEventListener('mousemove', this.internalMoveDragging); 114 | document.removeEventListener('mouseup', this.internalStopDragging); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/extensions/PipExtension/PipExtensionLoader.ts: -------------------------------------------------------------------------------- 1 | import { PipExtension } from '@src/extensions/PipExtension/PipExtension'; 2 | import { 3 | Config, 4 | IInstance, 5 | IModuleLoader, 6 | ModuleLoaderTypes, 7 | } from '@src/types'; 8 | 9 | export const PipExtensionLoader = { 10 | type: ModuleLoaderTypes.EXTENSION, 11 | 12 | create: (instance: IInstance) => new PipExtension(instance), 13 | 14 | isSupported: ({ config }: { config: Config }): boolean => true, 15 | } as IModuleLoader; 16 | -------------------------------------------------------------------------------- /src/extensions/PipExtension/pip.scss: -------------------------------------------------------------------------------- 1 | .ig_pip-container { 2 | position: fixed; 3 | z-index: 91337; 4 | bottom: 12px; 5 | right: 12px; 6 | width: 400px; 7 | height: 400px / 16 * 9; 8 | box-shadow: 0px 0px 28px rgba(0, 0, 0, .3); 9 | } 10 | 11 | .ig_pip-handler { 12 | position: absolute; 13 | top: 0; 14 | left: 0; 15 | right: 0; 16 | height: 25px; 17 | z-index: 1; 18 | cursor: grabbing; 19 | } 20 | 21 | .ig_pip-close { 22 | position: absolute; 23 | width: 28px; 24 | height: 28px; 25 | right: 0; 26 | top: 0px; 27 | font-size: 28px; 28 | cursor: pointer; 29 | color: #fff; 30 | text-shadow: 0px 0px rgba(0, 0, 0, .5); 31 | z-index: 1; 32 | display: flex; 33 | align-items: center; 34 | justify-content: center; 35 | } 36 | 37 | .ig_pip-placeholder { 38 | padding-bottom: 56.25%; 39 | background-color: #222222; 40 | } -------------------------------------------------------------------------------- /src/extensions/StateExtension/StateExtensionLoader.ts: -------------------------------------------------------------------------------- 1 | import { StateExtension } from '@src/extensions/StateExtension/StateExtension'; 2 | import { Instance } from '@src/Instance'; 3 | import { Config, IModuleLoader, ModuleLoaderTypes } from '@src/types'; 4 | 5 | export const StateExtensionLoader = { 6 | type: ModuleLoaderTypes.EXTENSION, 7 | 8 | create: (instance: Instance) => new StateExtension(instance), 9 | 10 | isSupported: ({ config }: { config: Config }): boolean => true, 11 | } as IModuleLoader; 12 | -------------------------------------------------------------------------------- /src/extensions/SubtitlesExtension/SubtitlesExtension.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@src/Module'; 2 | import { HTML5Player } from '@src/player/HTML5Player/HTML5Player'; 3 | import { Events, IEventData, IInstance, Subtitle } from '@src/types'; 4 | import { applyStyle, insertAfter } from '@src/utils/dom'; 5 | import * as SubtitleParser from 'subtitle'; 6 | import './subtitles.scss'; 7 | 8 | interface ITrackTiming { 9 | start: number; 10 | end: number; 11 | text: string; 12 | } 13 | 14 | interface ITrackTimingCache { 15 | [key: string]: ITrackTiming[]; 16 | } 17 | 18 | export class SubtitlesExtension extends Module { 19 | public name: string = 'SubtitlesExtension'; 20 | 21 | private timingsCache: ITrackTimingCache = {}; 22 | 23 | private timings: ITrackTiming[] = null; 24 | 25 | private activeTiming: ITrackTiming = null; 26 | 27 | private currentTimeMs: number = 0; 28 | 29 | private text: HTMLSpanElement; 30 | 31 | constructor(instance: IInstance) { 32 | super(instance); 33 | 34 | const container = document.createElement('div'); 35 | container.classList.add('ig_subtitles'); 36 | insertAfter(container, this.instance.playerContainer); 37 | 38 | this.text = document.createElement('span'); 39 | container.appendChild(this.text); 40 | 41 | this.instance.on(Events.PLAYER_STATE_TIMEUPDATE, this.onTimeUpdate); 42 | 43 | this.instance.on(Events.DIMENSIONS_CHANGE, this.onDimensionsChange); 44 | } 45 | 46 | public async setSubtitle(srclang: string) { 47 | const subtitle = 48 | this.instance.config.subtitles.find( 49 | subtitle => subtitle.srclang === srclang, 50 | ) || null; 51 | 52 | this.emit(Events.PLAYER_STATE_SUBTITLECHANGE, { 53 | subtitle, 54 | }); 55 | 56 | if (!srclang) { 57 | this.setActiveTimings(null); 58 | } else { 59 | const subtitle = this.instance.config.subtitles.find( 60 | subtitle => subtitle.srclang === srclang, 61 | ); 62 | 63 | if (!subtitle) { 64 | this.setActiveTimings(null); 65 | return; 66 | } 67 | 68 | const timings = await this.parseSubtitleFile(subtitle.src); 69 | this.setActiveTimings(timings); 70 | } 71 | } 72 | 73 | public setOffset(offset: number) { 74 | applyStyle(this.text, { 75 | transform: `translateY(-${offset}px)`, 76 | }); 77 | } 78 | 79 | private onTimeUpdate = data => { 80 | this.currentTimeMs = data.currentTime * 1000; 81 | 82 | if (this.timings) { 83 | this.selectActiveTiming(); 84 | } 85 | }; 86 | 87 | private onDimensionsChange = data => { 88 | const FONT_SIZE_PERCENT: number = 0.05; 89 | let fontSize = Math.round(data.height * FONT_SIZE_PERCENT * 100) / 100; 90 | 91 | if (fontSize > 45) { 92 | fontSize = 45; 93 | } else if (fontSize < 15) { 94 | fontSize = 15; 95 | } 96 | 97 | applyStyle(this.text, { 98 | fontSize: `${fontSize}px`, 99 | }); 100 | }; 101 | 102 | private selectActiveTiming() { 103 | let activeTiming: ITrackTiming = null; 104 | 105 | if (this.timings) { 106 | const timing = this.timings.find( 107 | track => 108 | this.currentTimeMs >= track.start && this.currentTimeMs < track.end, 109 | ); 110 | 111 | if (timing) { 112 | activeTiming = timing; 113 | } 114 | } 115 | 116 | if (activeTiming !== this.activeTiming) { 117 | this.activeTiming = activeTiming; 118 | 119 | const text = this.activeTiming ? this.activeTiming.text : null; 120 | 121 | this.text.innerHTML = text ? text : ''; 122 | this.text.style.display = text ? 'inline-block' : 'none'; 123 | 124 | this.emit(Events.PLAYER_STATE_SUBTITLETEXTCHANGE, { 125 | text, 126 | }); 127 | } 128 | } 129 | 130 | private setActiveTimings(timings: ITrackTiming[]) { 131 | this.timings = timings; 132 | this.selectActiveTiming(); 133 | } 134 | 135 | private async parseSubtitleFile(url: string): Promise { 136 | const log = this.instance.log('SubtitlesExtension.parseSubtitleFile'); 137 | 138 | if (!this.timingsCache[url]) { 139 | try { 140 | const content = await fetch(url).then(response => response.text()); 141 | this.timingsCache[url] = SubtitleParser.parse(content); 142 | log(`Parsed ${url}`, { trackTimings: this.timingsCache[url] }); 143 | } catch (error) { 144 | this.timingsCache[url] = []; 145 | log(`Failed to parse ${url}`); 146 | } 147 | } 148 | 149 | return this.timingsCache[url]; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/extensions/SubtitlesExtension/SubtitlesExtensionLoader.ts: -------------------------------------------------------------------------------- 1 | import { SubtitlesExtension } from '@src/extensions/SubtitlesExtension/SubtitlesExtension'; 2 | import { 3 | Config, 4 | IInstance, 5 | IModuleLoader, 6 | ModuleLoaderTypes, 7 | } from '@src/types'; 8 | 9 | export const SubtitlesExtensionLoader = { 10 | type: ModuleLoaderTypes.EXTENSION, 11 | 12 | create: (instance: IInstance) => new SubtitlesExtension(instance), 13 | 14 | isSupported: ({ config }: { config: Config }): boolean => { 15 | return config.subtitles && config.subtitles.length > 0; 16 | }, 17 | } as IModuleLoader; 18 | -------------------------------------------------------------------------------- /src/extensions/SubtitlesExtension/subtitles.scss: -------------------------------------------------------------------------------- 1 | .ig_subtitles { 2 | position: absolute; 3 | bottom: 0; 4 | left: 0; 5 | right: 0; 6 | padding: 22px; 7 | text-align: center; 8 | 9 | span { 10 | transition: transform 200ms ease-in-out; 11 | transform: translateY(0px); 12 | color: #ffffff; 13 | text-shadow: #000000 0px 0px 7px; 14 | font-family: Arial, Helvetica, sans-serif; 15 | max-width: 580px; 16 | font-size: 17px; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/extensions/ThumbnailsExtension/BIFParser.js: -------------------------------------------------------------------------------- 1 | // Base BIF Parser from https://github.com/chemoish/videojs-bif/blob/master/src/parser.js 2 | // LICENSE: https://github.com/chemoish/videojs-bif/blob/master/LICENSE 3 | 4 | import jDataView from 'jdataview'; 5 | 6 | // Offsets 7 | 8 | export const BIF_INDEX_OFFSET = 64; 9 | export const NUMBER_OF_BIF_IMAGES_OFFSET = 12; 10 | export const VERSION_OFFSET = 8; 11 | 12 | // Metadata 13 | 14 | export const BIF_INDEX_ENTRY_LENGTH = 8; 15 | 16 | // Magic Number 17 | // SEE: https://sdkdocs.roku.com/display/sdkdoc/Trick+Mode+Support#TrickModeSupport-MagicNumber 18 | export const MAGIC_NUMBER = new Uint8Array([ 19 | '0x89', 20 | '0x42', 21 | '0x49', 22 | '0x46', 23 | '0x0d', 24 | '0x0a', 25 | '0x1a', 26 | '0x0a', 27 | ]); 28 | 29 | /** 30 | * Validate the file identifier against the magic number. 31 | * 32 | * @returns {boolean} isValid 33 | */ 34 | function validate(magicNumber) { 35 | let isValid = true; 36 | 37 | MAGIC_NUMBER.forEach((byte, i) => { 38 | if (byte !== magicNumber[i]) { 39 | isValid = false; 40 | 41 | return; 42 | } 43 | }); 44 | 45 | return isValid; 46 | } 47 | 48 | /** 49 | * Parsing and read BIF file format. 50 | * 51 | * @param {ArrayBuffer} arrayBuffer 52 | */ 53 | export default class BIFParser { 54 | constructor(arrayBuffer) { 55 | // Magic Number 56 | // SEE: https://sdkdocs.roku.com/display/sdkdoc/Trick+Mode+Support#TrickModeSupport-MagicNumber 57 | const magicNumber = new Uint8Array(arrayBuffer).slice(0, 8); 58 | 59 | if (!validate(magicNumber)) { 60 | throw new Error('Invalid BIF file.'); 61 | } 62 | 63 | this.arrayBuffer = arrayBuffer; 64 | this.data = new jDataView(arrayBuffer); // eslint-disable-line new-cap 65 | 66 | // Number of BIF images 67 | // SEE: https://sdkdocs.roku.com/display/sdkdoc/Trick+Mode+Support#TrickModeSupport-NumberofBIFimages 68 | this.numberOfBIFImages = this.data.getUint32( 69 | NUMBER_OF_BIF_IMAGES_OFFSET, 70 | true, 71 | ); 72 | 73 | // Version 74 | // SEE: https://sdkdocs.roku.com/display/sdkdoc/Trick+Mode+Support#TrickModeSupport-Version 75 | this.version = this.data.getUint32(VERSION_OFFSET, true); 76 | 77 | this.bifIndex = this.generateBIFIndex(true); 78 | 79 | this.bifDimensions = { width: 240, height: 180 }; 80 | 81 | try { 82 | this.getInitialImageDimensions(); 83 | } catch (e) { 84 | console.warn('BIF Parser', e.stack); 85 | } 86 | } 87 | 88 | /** 89 | * Create the BIF index 90 | * SEE: https://sdkdocs.roku.com/display/sdkdoc/Trick+Mode+Support#TrickModeSupport-BIFindex 91 | * 92 | * @returns {Array} bifIndex 93 | */ 94 | generateBIFIndex() { 95 | const bifIndex = []; 96 | 97 | for ( 98 | // BIF index starts at byte 64 (BIF_INDEX_OFFSET) 99 | let i = 0, bifIndexEntryOffset = BIF_INDEX_OFFSET; 100 | i < this.numberOfBIFImages; 101 | i += 1, bifIndexEntryOffset += BIF_INDEX_ENTRY_LENGTH 102 | ) { 103 | const bifIndexEntryTimestampOffset = bifIndexEntryOffset; 104 | const bifIndexEntryAbsoluteOffset = bifIndexEntryOffset + 4; 105 | 106 | const nextBifIndexEntryAbsoluteOffset = 107 | bifIndexEntryAbsoluteOffset + BIF_INDEX_ENTRY_LENGTH; 108 | 109 | // Documented example, items within `[]`are used to generate the frame. 110 | // 64, 65, 66, 67 | 68, 69, 70, 71 111 | // [Frame 0 timestamp] | [absolute offset of frame] 112 | // 72, 73, 74, 75 | 76, 77, 78, 79 113 | // Frame 1 timestamp | [absolute offset of frame] 114 | const offset = this.data.getUint32(bifIndexEntryAbsoluteOffset, true); 115 | const nextOffset = this.data.getUint32( 116 | nextBifIndexEntryAbsoluteOffset, 117 | true, 118 | ); 119 | const timestamp = this.data.getUint32(bifIndexEntryTimestampOffset, true); 120 | 121 | bifIndex.push({ 122 | offset, 123 | timestamp, 124 | length: nextOffset - offset, 125 | }); 126 | } 127 | return bifIndex; 128 | } 129 | 130 | /** 131 | * Return image dimension data for a specific image source 132 | * 133 | * @returns {object} Promise 134 | */ 135 | getInitialImageDimensions() { 136 | const image = 'data:image/jpeg;base64,'; 137 | const src = `${image}${btoa( 138 | String.fromCharCode.apply( 139 | null, 140 | new Uint8Array( 141 | this.arrayBuffer.slice( 142 | this.bifIndex[0].offset, 143 | this.bifIndex[0].offset + this.bifIndex[0].length, 144 | ), 145 | ), 146 | ), 147 | )}`; 148 | const img = new Image(); 149 | img.src = src; 150 | img.onload = () => { 151 | if (!img.width || !img.height) throw 'Missing image dimensions'; 152 | this.bifDimensions = { 153 | width: img.width, 154 | height: img.height, 155 | }; 156 | }; 157 | } 158 | 159 | /** 160 | * Return image data for a specific frame of a movie. 161 | * 162 | * @param {number} second 163 | * @returns {string} imageData 164 | */ 165 | getImageDataAtSecond(second) { 166 | const image = 'data:image/jpeg;base64,'; 167 | 168 | const frame = this.bifIndex.find(bif => { 169 | return bif.timestamp / 1000 > second; 170 | }); 171 | 172 | if (!frame) { 173 | return image; 174 | } 175 | 176 | const src = `${image}${btoa( 177 | String.fromCharCode.apply( 178 | null, 179 | new Uint8Array( 180 | this.arrayBuffer.slice(frame.offset, frame.offset + frame.length), 181 | ), 182 | ), 183 | )}`; 184 | 185 | // Build our image object using image dimensions and our newest source 186 | return { 187 | start: frame.timestamp / 1000, 188 | src, 189 | x: 0, 190 | y: 0, 191 | width: this.bifDimensions.width, 192 | height: this.bifDimensions.height, 193 | }; 194 | } 195 | 196 | /** 197 | * Return image data for all BIF files. 198 | * 199 | * @returns {string} imageData 200 | */ 201 | getImageData() { 202 | const images = []; 203 | this.bifIndex.forEach(frame => { 204 | const image = 'data:image/jpeg;base64,'; 205 | 206 | images.push({ 207 | start: frame.timestamp / 1000, 208 | src: `${image}${btoa( 209 | String.fromCharCode.apply( 210 | null, 211 | new Uint8Array( 212 | this.arrayBuffer.slice(frame.offset, frame.offset + frame.length), 213 | ), 214 | ), 215 | )}`, 216 | x: 0, 217 | y: 0, 218 | width: this.bifDimensions.width, 219 | height: this.bifDimensions.height, 220 | }); 221 | }); 222 | return images; 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/extensions/ThumbnailsExtension/ThumbnailsExtension.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@src/Module'; 2 | import { IInstance, IThumbnail } from '@src/types'; 3 | import BIFParser from './BIFParser'; 4 | import parse from 'url-parse'; 5 | import vttToJson from 'vtt-to-json'; 6 | 7 | export class ThumbnailsExtension extends Module { 8 | public name: string = 'ThumbnailsExtension'; 9 | 10 | private thumbnails: IThumbnail[] = []; 11 | 12 | private extension: string = ''; 13 | 14 | private bifParser: BIFParser; 15 | 16 | constructor(instance: IInstance) { 17 | super(instance); 18 | 19 | this.load(); 20 | } 21 | 22 | private async loadVttThumbs(file) { 23 | const response = await fetch(file); 24 | const data = await response.text(); 25 | 26 | const json = await vttToJson(data); 27 | 28 | this.thumbnails = json 29 | .map(item => { 30 | const url = parse(item.part); 31 | const parts = parse(url.hash.replace('#', '?'), true); 32 | const [x, y, width, height] = parts.query.xywh.split(',').map(Number); 33 | 34 | url.set('hash', null); 35 | const src = url.toString(); 36 | 37 | return { 38 | start: Math.trunc(item.start / 1000), 39 | src, 40 | x, 41 | y, 42 | width, 43 | height, 44 | }; 45 | }) 46 | .sort((a, b) => b.start - a.start); 47 | } 48 | 49 | private async loadBifThumbs(file) { 50 | const response = await fetch(file); 51 | const data = await response.arrayBuffer(); 52 | 53 | // Since we already have functionality to grab the bif image that we 54 | // need at a given second, we are only prepping the parser class and 55 | // do not need to create an array of thumbs 56 | this.bifParser = new BIFParser(data); 57 | } 58 | 59 | public async load() { 60 | const file = this.instance.config.thumbnails.src; 61 | 62 | // Get the file extension for conditional processing 63 | this.extension = file.split('.').pop(); 64 | 65 | if (this.extension === 'vtt') { 66 | this.loadVttThumbs(file); 67 | } else if (this.extension === 'bif') { 68 | this.loadBifThumbs(file); 69 | } else { 70 | // We shouldn't get here, but still 71 | this.instance.log('ThumbnailsExtension')( 72 | 'Invalid file type passed for thumbnails. Acceptable file types: vtt, bif', 73 | ); 74 | } 75 | } 76 | 77 | public getThumbnail(seconds: number): IThumbnail { 78 | if (this.extension === 'vtt') { 79 | return this.thumbnails.find(thumbnail => thumbnail.start <= seconds); 80 | } else if (this.extension === 'bif') { 81 | return this.bifParser.getImageDataAtSecond(seconds); 82 | } else { 83 | return null; 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/extensions/ThumbnailsExtension/ThumbnailsExtensionLoader.ts: -------------------------------------------------------------------------------- 1 | import { ThumbnailsExtension } from '@src/extensions/ThumbnailsExtension/ThumbnailsExtension'; 2 | import { 3 | Config, 4 | IInstance, 5 | IModuleLoader, 6 | ModuleLoaderTypes, 7 | } from '@src/types'; 8 | 9 | export const ThumbnailsExtensionLoader = { 10 | type: ModuleLoaderTypes.EXTENSION, 11 | 12 | create: (instance: IInstance) => new ThumbnailsExtension(instance), 13 | 14 | isSupported: ({ config }: { config: Config }): boolean => { 15 | if (!config.thumbnails || !config.thumbnails.src) { 16 | return false; 17 | } 18 | 19 | const ext = config.thumbnails.src.split('.').pop(); 20 | if (ext !== 'vtt' && ext !== 'bif') { 21 | return false; 22 | } 23 | 24 | return true; 25 | }, 26 | } as IModuleLoader; 27 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IController, 3 | IInstance, 4 | IMedia, 5 | IModule, 6 | IModuleLoader, 7 | IPlayer, 8 | } from './types'; 9 | 10 | /** 11 | * Export all the internal types to module developers. 12 | */ 13 | export * from './types'; 14 | 15 | type ModuleConstructor = new (instance: IInstance) => T; 16 | 17 | /** 18 | * The current version of indigo-player 19 | * @type {string} 20 | */ 21 | export const VERSION: string; 22 | 23 | /** 24 | * Set the chunks path, this will tell the player where to get the chunks. 25 | * Defaults to: https://cdn.jsdelivr.net/npm/indigo-player@/lib/ 26 | * @param {string} chunksPath string 27 | */ 28 | export function setChunksPath(chunksPath: string); 29 | 30 | /** 31 | * Registers an external module within the player eco system. 32 | * @param {IModuleLoader} moduleLoader The module loader 33 | */ 34 | export function addModuleLoader(moduleLoader: IModuleLoader); 35 | 36 | /** 37 | * Enable console logs. 38 | * @param {boolean} enableConsoleLogs true/false 39 | */ 40 | export function setConsoleLogs(enableConsoleLogs: boolean); 41 | 42 | /** 43 | * Class constructor for a Module 44 | * @prop Module 45 | * @type {ModuleConstructor} 46 | */ 47 | export const Module: ModuleConstructor; 48 | 49 | /** 50 | * Class constructor for a Controller 51 | * @prop Controller 52 | * @type {ModuleConstructor} 53 | */ 54 | export const Controller: ModuleConstructor; 55 | 56 | /** 57 | * Class constructor for Media 58 | * @prop Media 59 | * @type {ModuleConstructor} 60 | */ 61 | export const Media: ModuleConstructor; 62 | 63 | /** 64 | * Class constructor for Player 65 | * @prop Player 66 | * @type {ModuleConstructor} 67 | */ 68 | export const Player: ModuleConstructor; 69 | 70 | export as namespace IndigoPlayer; 71 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@src/controller/Controller'; 2 | import { createAPI } from '@src/createAPI'; 3 | import { Instance } from '@src/Instance'; 4 | import { Media } from '@src/media/Media'; 5 | import { Module } from '@src/Module'; 6 | import { addModuleLoader } from '@src/ModuleLoader'; 7 | import { Player } from '@src/player/Player'; 8 | import { Config, ErrorCodes, Events, ModuleLoaderTypes } from '@src/types'; 9 | import { setConsoleLogs } from '@src/utils/log'; 10 | import { resolveScriptPath } from '@src/utils/webpack'; 11 | 12 | declare var __webpack_public_path__: string; 13 | declare var VERSION: string; 14 | 15 | __webpack_public_path__ = resolveScriptPath('indigo-player.js'); 16 | 17 | export default { 18 | VERSION, 19 | setChunksPath(chunksPath: string) { 20 | __webpack_public_path__ = chunksPath; 21 | }, 22 | setConsoleLogs, 23 | init(element: HTMLElement | string, config: Config) { 24 | const instance = new Instance(element, config); 25 | return createAPI(instance); 26 | }, 27 | addModuleLoader, 28 | 29 | // Export enums 30 | Events, 31 | ErrorCodes, 32 | ModuleLoaderTypes, 33 | 34 | // Export class constructors 35 | Module, 36 | Controller, 37 | Media, 38 | Player, 39 | }; 40 | -------------------------------------------------------------------------------- /src/media/BaseMedia/BaseMedia.ts: -------------------------------------------------------------------------------- 1 | import { Media } from '@src/media/Media'; 2 | 3 | export class BaseMedia extends Media { 4 | public name: string = 'BaseMedia'; 5 | 6 | public async load() { 7 | await super.load(); 8 | 9 | this.instance.player.setSource(this.instance.format.src); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/media/BaseMedia/BaseMediaLoader.ts: -------------------------------------------------------------------------------- 1 | import { BaseMedia } from '@src/media/BaseMedia/BaseMedia'; 2 | import { 3 | Format, 4 | FormatTypes, 5 | IInstance, 6 | IModuleLoader, 7 | ModuleLoaderTypes, 8 | } from '@src/types'; 9 | 10 | export const BaseMediaLoader = { 11 | type: ModuleLoaderTypes.MEDIA, 12 | 13 | create: (instance: IInstance) => new BaseMedia(instance), 14 | 15 | isSupported: (instance: IInstance, format: Format): boolean => { 16 | if ( 17 | format.type === FormatTypes.MP4 || 18 | format.type === FormatTypes.MOV || 19 | format.type === FormatTypes.WEBM 20 | ) { 21 | return true; 22 | } 23 | 24 | if ( 25 | format.type === FormatTypes.HLS && 26 | (instance.env.isSafari || instance.env.isIOS) 27 | ) { 28 | return true; 29 | } 30 | 31 | return false; 32 | }, 33 | } as IModuleLoader; 34 | -------------------------------------------------------------------------------- /src/media/DashMedia/DashMedia.ts: -------------------------------------------------------------------------------- 1 | import { Media } from '@src/media/Media'; 2 | import { HTML5Player } from '@src/player/HTML5Player/HTML5Player'; 3 | import { PlayerError } from '@src/PlayerError'; 4 | import { 5 | ErrorCodes, 6 | Events, 7 | Format, 8 | IAudioLanguagesEventData, 9 | IEventData, 10 | IInstance, 11 | ITrack, 12 | ITrackChangeEventData, 13 | ITracksEventData, 14 | } from '@src/types'; 15 | import * as shaka from 'shaka-player'; 16 | 17 | interface IShakaInstEventData extends IEventData { 18 | shaka: any; 19 | player: any; 20 | } 21 | 22 | export class DashMedia extends Media { 23 | public name: string = 'DashMedia'; 24 | 25 | public player: any; 26 | 27 | private track: ITrack; 28 | 29 | constructor(instance: IInstance) { 30 | super(instance); 31 | 32 | shaka.polyfill.installAll(); 33 | } 34 | 35 | public formatTrack = (track: any): ITrack => ({ 36 | id: track.id, 37 | width: track.width, 38 | height: track.height, 39 | bandwidth: track.bandwidth, 40 | }); 41 | 42 | public async load() { 43 | await super.load(); 44 | 45 | const mediaElement: HTMLMediaElement = (this.instance.getModule( 46 | 'HTML5Player', 47 | ) as any).mediaElement; 48 | 49 | this.player = new shaka.Player(mediaElement); 50 | 51 | this.player.addEventListener('error', this.onErrorEvent.bind(this)); 52 | this.player.addEventListener( 53 | 'adaptation', 54 | this.onAdaptationEvent.bind(this), 55 | ); 56 | 57 | this.emit(Events.SHAKA_INSTANCE, { 58 | shaka, 59 | player: this.player, 60 | } as IShakaInstEventData); 61 | 62 | const configuration: any = { 63 | abr: { 64 | enabled: true, 65 | defaultBandwidthEstimate: 66 | Number(this.instance.storage.get('estimatedBandwidth', 0)) || 67 | 1024 * 1000, 68 | }, 69 | }; 70 | 71 | if (this.instance.format.drm) { 72 | configuration.drm = { 73 | servers: { 74 | 'com.widevine.alpha': this.instance.format.drm.widevine.licenseUrl, 75 | 'com.microsoft.playready': this.instance.format.drm.playready 76 | .licenseUrl, 77 | }, 78 | advanced: { 79 | 'com.widevine.alpha': { 80 | audioRobustness: 'SW_SECURE_CRYPTO', 81 | videoRobustness: 'SW_SECURE_DECODE', 82 | }, 83 | }, 84 | }; 85 | } 86 | 87 | this.instance.log('dash.load')('Starting Shaka', { configuration }); 88 | 89 | this.player.configure(configuration); 90 | 91 | try { 92 | await this.player.load(this.instance.format.src); 93 | 94 | const tracks = this.player 95 | .getVariantTracks() 96 | .filter(track => track.type === 'variant') 97 | .sort((a, b) => b.bandwidth - a.bandwidth) 98 | .map(this.formatTrack); 99 | 100 | this.emit(Events.MEDIA_STATE_TRACKS, { 101 | tracks, 102 | } as ITracksEventData); 103 | 104 | const audioLanguages = this.player.getAudioLanguages(); 105 | 106 | this.emit(Events.MEDIA_STATE_AUDIOLANGUAGES, { 107 | audioLanguages, 108 | } as IAudioLanguagesEventData); 109 | } catch (error) { 110 | this.onError(error); 111 | } 112 | } 113 | 114 | public unload() { 115 | if (this.player) { 116 | this.player.destroy(); 117 | this.player = null; 118 | } 119 | } 120 | 121 | public selectTrack(track: ITrack | string) { 122 | if (track === 'auto') { 123 | this.player.configure({ abr: { enabled: true } }); 124 | this.emitTrackChange(); 125 | } else { 126 | this.player.configure({ abr: { enabled: false } }); 127 | 128 | this.track = track as ITrack; 129 | this.emitTrackChange(); 130 | 131 | const variantTrack = this.player 132 | .getVariantTracks() 133 | .find(variantTrack => variantTrack.id === (track as ITrack).id); 134 | 135 | if (variantTrack) { 136 | this.player.selectVariantTrack(variantTrack, true); 137 | } 138 | } 139 | } 140 | 141 | public selectAudioLanguage(language: string) { 142 | this.player.selectAudioLanguage(language); 143 | } 144 | 145 | private emitTrackChange() { 146 | this.emit(Events.MEDIA_STATE_TRACKCHANGE, { 147 | track: this.track, 148 | auto: this.player.getConfiguration().abr.enabled, 149 | } as ITrackChangeEventData); 150 | } 151 | 152 | private onErrorEvent(event) { 153 | this.onError(event.detail); 154 | } 155 | 156 | private onError(error) { 157 | if (error.severity === 2) { 158 | this.instance.setError( 159 | new PlayerError(ErrorCodes.SHAKA_CRITICAL_ERROR, error), 160 | ); 161 | } 162 | } 163 | 164 | private onAdaptationEvent() { 165 | const track = this.formatTrack( 166 | this.player.getVariantTracks().find(track => track.active), 167 | ); 168 | 169 | this.track = track; 170 | this.emitTrackChange(); 171 | 172 | const estimatedBandwidth = this.player.getStats().estimatedBandwidth; 173 | this.instance.storage.set('estimatedBandwidth', estimatedBandwidth); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/media/DashMedia/DashMediaLoader.ts: -------------------------------------------------------------------------------- 1 | import { DashMedia } from '@src/media/DashMedia/DashMedia'; 2 | import { 3 | isBrowserSupported, 4 | isBrowserSupportedDRM, 5 | } from '@src/media/DashMedia/isBrowserSupported'; 6 | import { 7 | Format, 8 | FormatTypes, 9 | IInstance, 10 | IModuleLoader, 11 | ModuleLoaderTypes, 12 | } from '@src/types'; 13 | import { getDrmSupport } from '@src/utils/getDrmSupport'; 14 | 15 | export const DashMediaLoader = { 16 | type: ModuleLoaderTypes.MEDIA, 17 | 18 | create: (instance: IInstance) => new DashMedia(instance), 19 | 20 | isSupported: async ( 21 | instance: IInstance, 22 | format: Format, 23 | ): Promise => { 24 | if (instance.player.name !== 'HTML5Player') { 25 | return false; 26 | } 27 | 28 | if (format.type !== FormatTypes.DASH) { 29 | return false; 30 | } 31 | 32 | if (!isBrowserSupported()) { 33 | return false; 34 | } 35 | 36 | if (format.drm) { 37 | if (!isBrowserSupportedDRM()) { 38 | return false; 39 | } 40 | 41 | const support: any = await getDrmSupport(); 42 | if (!support.drmSupport.widevine && !support.drmSupport.playready) { 43 | return false; 44 | } 45 | } 46 | 47 | return true; 48 | }, 49 | } as IModuleLoader; 50 | -------------------------------------------------------------------------------- /src/media/DashMedia/isBrowserSupported.ts: -------------------------------------------------------------------------------- 1 | function mediaSourceEngineSupported(): boolean { 2 | return !!(window as any).MediaSource && !!MediaSource.isTypeSupported; 3 | } 4 | 5 | function drmEngineSupported(): boolean { 6 | const basic = 7 | !!(window as any).MediaKeys && 8 | !!(window as any).navigator && 9 | !!(window as any).navigator.requestMediaKeySystemAccess && 10 | !!(window as any).MediaKeySystemAccess && 11 | !!(window as any).MediaKeySystemAccess.prototype.getConfiguration; 12 | 13 | return basic; 14 | } 15 | 16 | export const isBrowserSupported = (): boolean => { 17 | const basic = 18 | !!(window as any).Promise && 19 | !!(window as any).Uint8Array && 20 | !!Array.prototype.forEach; 21 | 22 | return basic && mediaSourceEngineSupported(); 23 | }; 24 | 25 | export const isBrowserSupportedDRM = (): boolean => { 26 | return isBrowserSupported() && drmEngineSupported(); 27 | }; 28 | -------------------------------------------------------------------------------- /src/media/HlsMedia/HlsMedia.ts: -------------------------------------------------------------------------------- 1 | import { Media } from '@src/media/Media'; 2 | import { HTML5Player } from '@src/player/HTML5Player/HTML5Player'; 3 | import { PlayerError } from '@src/PlayerError'; 4 | import { 5 | ErrorCodes, 6 | Events, 7 | Format, 8 | ITrack, 9 | ITrackChangeEventData, 10 | ITracksEventData, 11 | } from '@src/types'; 12 | import HlsJs from 'hls.js'; 13 | 14 | export class HlsMedia extends Media { 15 | public name: string = 'HlsMedia'; 16 | 17 | public player: any; 18 | 19 | public async load() { 20 | await super.load(); 21 | 22 | this.player = new HlsJs({ 23 | autoStartLoad: false, 24 | }); 25 | 26 | const mediaElement: HTMLMediaElement = (this.instance.getModule( 27 | 'HTML5Player', 28 | ) as any).mediaElement; 29 | 30 | this.player.attachMedia(mediaElement); 31 | 32 | this.player.on(HlsJs.Events.MANIFEST_PARSED, (event, data) => { 33 | const tracks = data.levels 34 | .map(this.formatTrack) 35 | .sort((a, b) => b.bandwidth - a.bandwidth); 36 | 37 | this.emit(Events.MEDIA_STATE_TRACKS, { 38 | tracks, 39 | } as ITracksEventData); 40 | }); 41 | 42 | this.player.on(HlsJs.Events.LEVEL_SWITCHED, (event, data) => { 43 | const level = data.level; 44 | 45 | this.emit(Events.MEDIA_STATE_TRACKCHANGE, { 46 | track: this.formatTrack(this.player.levels[data.level], data.level), 47 | auto: this.player.autoLevelEnabled, 48 | } as ITrackChangeEventData); 49 | }); 50 | 51 | this.player.on(HlsJs.Events.ERROR, (event, data) => { 52 | if (!data.fatal) { 53 | return; 54 | } 55 | 56 | if (data.type === HlsJs.ErrorTypes.NETWORK_ERROR) { 57 | this.player.startLoad(); 58 | } else if (data.type === HlsJs.ErrorTypes.MEDIA_ERROR) { 59 | this.player.recoverMediaError(); 60 | } else { 61 | this.instance.setError( 62 | new PlayerError(ErrorCodes.HLSJS_CRITICAL_ERROR, data), 63 | ); 64 | } 65 | }); 66 | 67 | this.player.loadSource(this.instance.format.src); 68 | 69 | this.player.startLoad(); 70 | } 71 | 72 | public seekTo(time: number) { 73 | if (time === Infinity) { 74 | this.instance.player.seekTo(this.player.liveSyncPosition); 75 | return; 76 | } 77 | super.seekTo(time); 78 | } 79 | 80 | public unload() { 81 | this.player.destroy(); 82 | this.player = null; 83 | } 84 | 85 | public selectTrack(track: ITrack | string) { 86 | if (track === 'auto') { 87 | this.player.currentLevel = -1; 88 | } else { 89 | this.player.currentLevel = (track as ITrack).id; 90 | } 91 | } 92 | 93 | private formatTrack = (track: any, id: number): ITrack => ({ 94 | id, 95 | width: track.width, 96 | height: track.height, 97 | bandwidth: track.bitrate, 98 | }); 99 | } 100 | -------------------------------------------------------------------------------- /src/media/HlsMedia/HlsMediaLoader.ts: -------------------------------------------------------------------------------- 1 | import { HlsMedia } from '@src/media/HlsMedia/HlsMedia'; 2 | import { 3 | Format, 4 | FormatTypes, 5 | IInstance, 6 | IModuleLoader, 7 | ModuleLoaderTypes, 8 | } from '@src/types'; 9 | import { isSupported } from 'hls.js/src/is-supported'; 10 | 11 | export const HlsMediaLoader = { 12 | type: ModuleLoaderTypes.MEDIA, 13 | 14 | create: (instance: IInstance) => new HlsMedia(instance), 15 | 16 | isSupported: (instance: IInstance, format: Format): boolean => { 17 | if (instance.player.name !== 'HTML5Player') { 18 | return false; 19 | } 20 | 21 | if (format.type !== FormatTypes.HLS) { 22 | return false; 23 | } 24 | 25 | if (instance.env.isSafari || instance.env.isIOS) { 26 | return false; 27 | } 28 | 29 | if (!isSupported()) { 30 | return false; 31 | } 32 | 33 | return true; 34 | }, 35 | } as IModuleLoader; 36 | -------------------------------------------------------------------------------- /src/media/Media.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@src/Module'; 2 | import { IMedia, ITrack } from '@src/types'; 3 | 4 | export class Media extends Module implements IMedia { 5 | public async load() {} 6 | 7 | public unload() {} 8 | 9 | public play() { 10 | this.instance.player.play(); 11 | } 12 | 13 | public pause() { 14 | this.instance.player.pause(); 15 | } 16 | 17 | public seekTo(time: number) { 18 | this.instance.player.seekTo(time); 19 | } 20 | 21 | public setVolume(volume: number) { 22 | this.instance.player.setVolume(volume); 23 | } 24 | 25 | public selectTrack(track: ITrack) {} 26 | 27 | public selectAudioLanguage(language: string) {} 28 | 29 | public setPlaybackRate(playbackRate: number) { 30 | this.instance.player.setPlaybackRate(playbackRate); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/player/HTML5Player/HTML5Player.ts: -------------------------------------------------------------------------------- 1 | import { Player } from '@src/player/Player'; 2 | import { 3 | Events, 4 | IBufferedChangeEventData, 5 | IDurationChangeEventData, 6 | IPlaybackRateChangeEventData, 7 | ITimeUpdateEventData, 8 | IVolumeChangeEventData, 9 | } from '@src/types'; 10 | import requestFrame from 'request-frame'; 11 | 12 | export class HTML5Player extends Player { 13 | public name: string = 'HTML5Player'; 14 | 15 | public mediaElement: HTMLVideoElement; 16 | 17 | public load() { 18 | super.load(); 19 | 20 | this.mediaElement = document.createElement('video'); 21 | this.mediaElement.style.width = '100%'; 22 | this.mediaElement.style.height = '100%'; 23 | this.mediaElement.preload = 'metadata'; 24 | this.mediaElement.crossOrigin = 'anonymous'; 25 | this.mediaElement.volume = 1; 26 | this.mediaElement.setAttribute('playsinline', ''); 27 | this.mediaElement.setAttribute('preload', 'auto'); 28 | this.instance.playerContainer.appendChild(this.mediaElement); 29 | 30 | this.mediaElement.addEventListener('playing', () => { 31 | this.emit(Events.PLAYER_STATE_PLAYING); 32 | }); 33 | 34 | this.mediaElement.addEventListener('ended', () => { 35 | this.emit(Events.PLAYER_STATE_ENDED); 36 | }); 37 | 38 | this.mediaElement.addEventListener('seeked', () => { 39 | this.emit(Events.PLAYER_STATE_SEEKED); 40 | }); 41 | 42 | this.mediaElement.addEventListener('durationchange', () => { 43 | this.emit(Events.PLAYER_STATE_DURATIONCHANGE, { 44 | duration: this.mediaElement.duration, 45 | } as IDurationChangeEventData); 46 | }); 47 | 48 | this.mediaElement.addEventListener('waiting', () => { 49 | this.emit(Events.PLAYER_STATE_WAITING); 50 | }); 51 | 52 | this.mediaElement.addEventListener('volumechange', () => { 53 | let volume = this.mediaElement.volume; 54 | if (this.mediaElement.muted) { 55 | volume = 0; 56 | } 57 | this.emit(Events.PLAYER_STATE_VOLUMECHANGE, { 58 | volume, 59 | } as IVolumeChangeEventData); 60 | }); 61 | 62 | this.mediaElement.addEventListener('loadeddata', () => 63 | this.monitorProgress(), 64 | ); 65 | this.mediaElement.addEventListener('progress', () => 66 | this.monitorProgress(), 67 | ); 68 | 69 | this.mediaElement.addEventListener('ratechange', () => { 70 | this.emit(Events.PLAYER_STATE_RATECHANGE, { 71 | playbackRate: this.mediaElement.playbackRate, 72 | } as IPlaybackRateChangeEventData); 73 | }); 74 | 75 | this.mediaElement.addEventListener('timeupdate', () => { 76 | this.emit(Events.PLAYER_STATE_TIMEUPDATE, { 77 | currentTime: this.mediaElement.currentTime, 78 | } as ITimeUpdateEventData); 79 | }); 80 | } 81 | 82 | public unload() { 83 | if (this.mediaElement) { 84 | this.mediaElement.pause(); 85 | this.mediaElement.removeAttribute('src'); 86 | this.mediaElement.load(); 87 | this.mediaElement.remove(); 88 | } 89 | super.unload(); 90 | } 91 | 92 | public setSource(src: string) { 93 | this.mediaElement.src = src; 94 | } 95 | 96 | public play() { 97 | this.emit(Events.PLAYER_STATE_PLAY); 98 | this.mediaElement.play(); 99 | } 100 | 101 | public pause() { 102 | this.emit(Events.PLAYER_STATE_PAUSE); 103 | this.mediaElement.pause(); 104 | } 105 | 106 | public seekTo(time: number) { 107 | this.emit(Events.PLAYER_STATE_TIMEUPDATE, { 108 | currentTime: time, 109 | } as ITimeUpdateEventData); 110 | 111 | this.mediaElement.currentTime = time; 112 | } 113 | 114 | public setVolume(volume: number) { 115 | this.mediaElement.volume = volume; 116 | this.mediaElement.muted = volume === 0; 117 | } 118 | 119 | public setPlaybackRate(playbackRate: number) { 120 | this.mediaElement.playbackRate = playbackRate; 121 | } 122 | 123 | private monitorProgress() { 124 | const buffered: any = this.mediaElement.buffered; 125 | const time: number = this.mediaElement.currentTime; 126 | let percentage: number; 127 | 128 | for (let range = 0; range < buffered.length; range += 1) { 129 | if (buffered.start(range) <= time && buffered.end(range) > time) { 130 | percentage = buffered.end(range) / this.mediaElement.duration; 131 | break; 132 | } 133 | } 134 | 135 | if (percentage !== undefined) { 136 | this.emit(Events.PLAYER_STATE_BUFFEREDCHANGE, { 137 | percentage, 138 | } as IBufferedChangeEventData); 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/player/HTML5Player/HTML5PlayerLoader.ts: -------------------------------------------------------------------------------- 1 | import { HTML5Player } from '@src/player/HTML5Player/HTML5Player'; 2 | import { 3 | Config, 4 | IInstance, 5 | IModuleLoader, 6 | ModuleLoaderTypes, 7 | } from '@src/types'; 8 | 9 | export const HTML5PlayerLoader = { 10 | type: ModuleLoaderTypes.PLAYER, 11 | 12 | create: (instance: IInstance) => new HTML5Player(instance), 13 | 14 | isSupported: ({ config }: { config: Config }): boolean => true, 15 | } as IModuleLoader; 16 | -------------------------------------------------------------------------------- /src/player/Player.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@src/Module'; 2 | import { IPlayer } from '@src/types'; 3 | 4 | export class Player extends Module implements IPlayer { 5 | public load() {} 6 | 7 | public unload() {} 8 | 9 | public play() {} 10 | 11 | public pause() {} 12 | 13 | public seekTo(time: number) {} 14 | 15 | public setVolume(volume: number) {} 16 | 17 | public setSource(src: string) {} 18 | 19 | public setPlaybackRate(playbackRate: number) {} 20 | } 21 | -------------------------------------------------------------------------------- /src/selectModule.ts: -------------------------------------------------------------------------------- 1 | import { createAllSupported, createFirstSupported } from '@src/ModuleLoader'; 2 | import { 3 | Format, 4 | IController, 5 | IInstance, 6 | IMedia, 7 | IModule, 8 | IPlayer, 9 | ModuleLoaderTypes, 10 | } from '@src/types'; 11 | 12 | export async function selectMedia( 13 | instance: IInstance, 14 | ): Promise<[Format, IMedia]> { 15 | const sources: Format[] = instance.config.sources; 16 | 17 | for (const format of sources) { 18 | const media = await createFirstSupported( 19 | ModuleLoaderTypes.MEDIA, 20 | instance, 21 | format, 22 | ); 23 | if (media) { 24 | return [format, media]; 25 | } 26 | } 27 | 28 | return [null, null]; 29 | } 30 | 31 | export async function selectPlayer(instance: IInstance): Promise { 32 | return createFirstSupported(ModuleLoaderTypes.PLAYER, instance); 33 | } 34 | 35 | export async function selectExtensions( 36 | instance: IInstance, 37 | ): Promise { 38 | return createAllSupported(ModuleLoaderTypes.EXTENSION, instance); 39 | } 40 | 41 | export async function selectController( 42 | instance: IInstance, 43 | ): Promise { 44 | return createFirstSupported( 45 | ModuleLoaderTypes.CONTROLLER, 46 | instance, 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | .ig-container { 2 | position: relative; 3 | background-color: #000; 4 | overflow: hidden; 5 | } 6 | 7 | .ig-player { 8 | position: absolute; 9 | left: 0; 10 | right: 0; 11 | top: 0; 12 | bottom: 0; 13 | } -------------------------------------------------------------------------------- /src/ui/UiExtension.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@src/Module'; 2 | import { Events, IInstance } from '@src/types'; 3 | import { render } from '@src/ui/render'; 4 | import { IStateStore } from '@src/ui/types'; 5 | import React, { RefObject } from 'react'; 6 | 7 | declare var __webpack_public_path__: string; 8 | 9 | let loadedThemeStylesheet = false; 10 | 11 | export class UiExtension extends Module { 12 | public name: string = 'UiExtension'; 13 | 14 | private ref: RefObject = React.createRef(); 15 | 16 | constructor(instance: IInstance) { 17 | super(instance); 18 | 19 | this.setTheme(); 20 | 21 | const container = this.instance.container.querySelector( 22 | '.ig-ui', 23 | ) as HTMLElement; 24 | 25 | this.instance.on(Events.STATE_CHANGE, state => 26 | render(container, state, this.instance, this.ref), 27 | ); 28 | } 29 | 30 | private setTheme() { 31 | if (this.instance.config.ui.ignoreStylesheet || loadedThemeStylesheet) { 32 | return; 33 | } 34 | 35 | const regex: RegExp = /indigo-theme-[a-zA-Z]+\.css/; 36 | for (const link of Array.from(document.querySelectorAll('link'))) { 37 | if (regex.test(link.href)) { 38 | return; 39 | } 40 | } 41 | 42 | loadedThemeStylesheet = true; 43 | 44 | const link = document.createElement('link'); 45 | link.setAttribute('rel', 'stylesheet'); 46 | link.setAttribute('type', 'text/css'); 47 | link.setAttribute('href', `${__webpack_public_path__}indigo-theme.css`); 48 | link.setAttribute('data-indigo', 'internal'); 49 | document.getElementsByTagName('head')[0].appendChild(link); 50 | } 51 | 52 | // Provide a way for overlays to trigger a mouse move on it's own elements. 53 | public triggerMouseMove() { 54 | if (this.ref && this.ref.current) { 55 | this.ref.current.showControls(); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/ui/UiExtensionLoader.ts: -------------------------------------------------------------------------------- 1 | import { Instance } from '@src/Instance'; 2 | import { Config, IModuleLoader, ModuleLoaderTypes } from '@src/types'; 3 | import { UiExtension } from '@src/ui/UiExtension'; 4 | 5 | export const UiExtensionLoader = { 6 | type: ModuleLoaderTypes.EXTENSION, 7 | 8 | create: (instance: Instance) => new UiExtension(instance), 9 | 10 | isSupported: ({ config }: { config: Config }): boolean => 11 | config.ui && config.ui.enabled, 12 | } as IModuleLoader; 13 | -------------------------------------------------------------------------------- /src/ui/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@src/ui/components/Icon'; 2 | import cx from 'classnames'; 3 | import React, { useState } from 'react'; 4 | 5 | interface ButtonProps { 6 | children?: JSX.Element | string; 7 | icon?: string; 8 | name?: string; 9 | disabled?: boolean; 10 | active?: boolean; 11 | tooltip?: string; 12 | onClick(); 13 | } 14 | 15 | export const Button = (props: ButtonProps) => { 16 | const [hover, setHover] = useState(false); 17 | const [isTouch, setIsTouch] = useState(false); 18 | 19 | const events = { 20 | onMouseEnter: () => { 21 | if (isTouch) return; 22 | setHover(true); 23 | }, 24 | onMouseLeave: () => setHover(false), 25 | onTouchStart: () => { 26 | setIsTouch(true); 27 | setHover(true); 28 | }, 29 | onTouchEnd: () => setHover(false), 30 | onTouchCancel: () => setHover(false), 31 | onClick: () => { 32 | setIsTouch(false); 33 | props.onClick(); 34 | }, 35 | }; 36 | 37 | return ( 38 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/ui/components/Center.tsx: -------------------------------------------------------------------------------- 1 | import { IThumbnail } from '@src/types'; 2 | import { Sprite } from '@src/ui/components/Sprite'; 3 | import { IInfo } from '@src/ui/types'; 4 | import { withState } from '@src/ui/withState'; 5 | import * as React from 'react'; 6 | 7 | interface CenterProps { 8 | seekingThumbnail?: IThumbnail; 9 | playOrPause(); 10 | toggleFullscreen(); 11 | } 12 | 13 | export const Center = withState((props: CenterProps) => { 14 | return ( 15 |
20 | {!!props.seekingThumbnail && ( 21 | 22 | )} 23 |
24 | ); 25 | }, mapProps); 26 | 27 | function mapProps(info: IInfo): CenterProps { 28 | return { 29 | playOrPause: () => info.actions.playOrPause('center'), 30 | seekingThumbnail: info.data.isSeekbarSeeking 31 | ? info.data.activeThumbnail 32 | : null, 33 | toggleFullscreen: info.actions.toggleFullscreen, 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/ui/components/ControlsView.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@src/ui/components/Button'; 2 | import { Center } from '@src/ui/components/Center'; 3 | import { Nod } from '@src/ui/components/Nod'; 4 | import { Rebuffer } from '@src/ui/components/Rebuffer'; 5 | import { Seekbar } from '@src/ui/components/Seekbar'; 6 | import { Settings } from '@src/ui/components/Settings'; 7 | import { TimeStat } from '@src/ui/components/TimeStat'; 8 | import { VolumeButton } from '@src/ui/components/VolumeButton'; 9 | import { IInfo, SettingsTabs } from '@src/ui/types'; 10 | import { withState } from '@src/ui/withState'; 11 | import * as React from 'react'; 12 | 13 | interface ControlsViewProps { 14 | isCenterClickAllowed: boolean; 15 | showRebuffer: boolean; 16 | playIcon: string; 17 | playTooltipText: string; 18 | showSubtitlesToggle: boolean; 19 | isSubtitleActive: boolean; 20 | subtitleToggleTooltipText: string; 21 | showPip: boolean; 22 | pipTooltipText: string; 23 | settingsTooltipText: string; 24 | fullscreenIcon: string; 25 | isFullscreenSupported: boolean; 26 | fullscreenTooltipText: string; 27 | isSettingsTabActive: boolean; 28 | playOrPause(); 29 | toggleActiveSubtitle(); 30 | togglePip(); 31 | toggleSettings(); 32 | toggleFullscreen(); 33 | } 34 | 35 | export const ControlsView = withState((props: ControlsViewProps) => { 36 | return ( 37 | <> 38 | 39 | 40 | {props.isCenterClickAllowed &&
} 41 | {props.showRebuffer && } 42 |
43 |
86 | 87 | ); 88 | }, mapProps); 89 | 90 | function mapProps(info: IInfo): ControlsViewProps { 91 | const createTooltipText = (text: string, shortcut?: string) => { 92 | return `${info.data.getTranslation(text)} ${ 93 | shortcut ? `(${shortcut})` : '' 94 | }`.trim(); 95 | }; 96 | 97 | return { 98 | isCenterClickAllowed: info.data.isCenterClickAllowed, 99 | isSettingsTabActive: info.data.settingsTab !== SettingsTabs.NONE, 100 | showRebuffer: info.data.rebuffering, 101 | playIcon: info.data.playRequested ? 'pause' : 'play', 102 | playOrPause: info.actions.playOrPause, 103 | playTooltipText: createTooltipText( 104 | info.data.playRequested ? 'Pause' : 'Play', 105 | 'k', 106 | ), 107 | showSubtitlesToggle: !!info.data.subtitles.length, 108 | isSubtitleActive: !!info.data.activeSubtitle, 109 | toggleActiveSubtitle: info.actions.toggleActiveSubtitle, 110 | subtitleToggleTooltipText: createTooltipText( 111 | !!info.data.activeSubtitle ? 'Disable subtitles' : 'Enable subtitles', 112 | 'c', 113 | ), 114 | showPip: info.data.pipSupported && !info.data.pip, 115 | togglePip: info.actions.togglePip, 116 | pipTooltipText: createTooltipText('Miniplayer', 'i'), 117 | toggleSettings: info.actions.toggleSettings, 118 | settingsTooltipText: createTooltipText('Settings'), 119 | fullscreenIcon: !info.data.isFullscreen ? 'fullscreen' : 'fullscreen-exit', 120 | toggleFullscreen: info.actions.toggleFullscreen, 121 | isFullscreenSupported: info.data.fullscreenSupported, 122 | fullscreenTooltipText: createTooltipText( 123 | info.data.isFullscreen ? 'Exit full screen' : 'Full screen', 124 | 'f', 125 | ), 126 | }; 127 | } 128 | -------------------------------------------------------------------------------- /src/ui/components/ErrorView.tsx: -------------------------------------------------------------------------------- 1 | import { IPlayerError } from '@src/types'; 2 | import { IInfo } from '@src/ui/types'; 3 | import { withState } from '@src/ui/withState'; 4 | import * as React from 'react'; 5 | 6 | interface ErrorViewProps { 7 | error: IPlayerError; 8 | } 9 | 10 | export const ErrorView = withState((props: ErrorViewProps) => { 11 | const title = 'Uh oh!'; 12 | const message = `Something went wrong (${props.error.code})`; 13 | return ( 14 |
15 |
16 |
17 | {title} 18 |
19 |
{message}
20 |
21 |
22 | ); 23 | }, mapProps); 24 | 25 | function mapProps(info: IInfo): ErrorViewProps { 26 | return { 27 | error: info.data.error, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/ui/components/Icon.tsx: -------------------------------------------------------------------------------- 1 | import cx from 'classnames'; 2 | import * as React from 'react'; 3 | 4 | interface IconProps { 5 | icon: string; 6 | } 7 | 8 | export const Icon = (props: IconProps) => ( 9 | 19 | ); 20 | -------------------------------------------------------------------------------- /src/ui/components/LoadingView.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@src/ui/components/Icon'; 2 | import { Spinner } from '@src/ui/components/Spinner'; 3 | import { IInfo } from '@src/ui/types'; 4 | import { withState } from '@src/ui/withState'; 5 | import * as React from 'react'; 6 | 7 | interface LoadingViewProps { 8 | image: string; 9 | } 10 | 11 | export const LoadingView = withState((props: LoadingViewProps) => { 12 | return ( 13 |
14 | {!!props.image && ( 15 |
19 | )} 20 | 21 |
22 | ); 23 | }, mapProps); 24 | 25 | function mapProps(info: IInfo): LoadingViewProps { 26 | return { 27 | image: info.data.image, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/ui/components/Main.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@src/ui/components/Button'; 2 | import { ControlsView } from '@src/ui/components/ControlsView'; 3 | import { ErrorView } from '@src/ui/components/ErrorView'; 4 | import { LoadingView } from '@src/ui/components/LoadingView'; 5 | import { StartView } from '@src/ui/components/StartView'; 6 | import { IInfo, ViewTypes } from '@src/ui/types'; 7 | import { attachEvents, EventUnsubscribeFn } from '@src/ui/utils/attachEvents'; 8 | import { withState } from '@src/ui/withState'; 9 | import cx from 'classnames'; 10 | import * as React from 'react'; 11 | 12 | interface MainProps { 13 | view: ViewTypes; 14 | isMobile: boolean; 15 | visibleControls: boolean; 16 | isPip: boolean; 17 | isFullscreen: boolean; 18 | } 19 | 20 | export const Main = withState( 21 | (props: MainProps) => ( 22 |
30 | {props.view === ViewTypes.ERROR && } 31 | {props.view === ViewTypes.LOADING && } 32 | {props.view === ViewTypes.START && } 33 | {props.view === ViewTypes.CONTROLS && } 34 |
35 | ), 36 | mapProps, 37 | ); 38 | 39 | function mapProps(info: IInfo): MainProps { 40 | return { 41 | view: info.data.view, 42 | isMobile: info.data.isMobile, 43 | visibleControls: info.data.visibleControls, 44 | isPip: info.data.pip, 45 | isFullscreen: info.data.isFullscreen, 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /src/ui/components/Nod.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@src/ui/components/Icon'; 2 | import { IInfo } from '@src/ui/types'; 3 | import { withState } from '@src/ui/withState'; 4 | import cx from 'classnames'; 5 | import React from 'react'; 6 | 7 | interface NodProps { 8 | icon: string; 9 | } 10 | 11 | export const Nod = withState((props: NodProps) => { 12 | return ( 13 |
18 | 19 |
20 | ); 21 | }, mapProps); 22 | 23 | function mapProps(info: IInfo): NodProps { 24 | return { 25 | icon: info.data.nodIcon, 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/ui/components/Rebuffer.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from '@src/ui/components/Spinner'; 2 | import * as React from 'react'; 3 | 4 | export const Rebuffer = () => ( 5 |
6 | 7 |
8 | ); 9 | -------------------------------------------------------------------------------- /src/ui/components/Seekbar.tsx: -------------------------------------------------------------------------------- 1 | import { IThumbnail } from '@src/types'; 2 | import { Sprite } from '@src/ui/components/Sprite'; 3 | import { 4 | seekbarRef, 5 | seekbarThumbnailRef, 6 | seekbarTooltipRef, 7 | } from '@src/ui/State'; 8 | import { IInfo } from '@src/ui/types'; 9 | import { useSlider } from '@src/ui/utils/useSlider'; 10 | import { withState } from '@src/ui/withState'; 11 | import cx from 'classnames'; 12 | import React, { useEffect } from 'react'; 13 | 14 | interface SeekbarProps { 15 | isActive: boolean; 16 | adBreakData: any; 17 | seekbarThumbnailPercentage: number; 18 | activeThumbnail?: IThumbnail; 19 | seekbarTooltipPercentage: number; 20 | seekbarTooltipText: string; 21 | progressPercentage: number; 22 | bufferedPercentage: number; 23 | seekbarPercentage: number; 24 | showSeekAhead: boolean; 25 | showCuepoints: boolean; 26 | cuePoints: number[]; 27 | setSeekbarState(state: any); 28 | } 29 | 30 | export const Seekbar = withState((props: SeekbarProps) => { 31 | useSlider(seekbarRef.current as HTMLElement, props.setSeekbarState); 32 | 33 | return ( 34 |
41 |
46 | {!!props.activeThumbnail && ( 47 | 51 | )} 52 |
53 |
58 | {props.seekbarTooltipText} 59 |
60 |
64 |
65 |
69 |
73 | {props.showSeekAhead && ( 74 |
78 | )} 79 | {props.showCuepoints && ( 80 |
81 | {props.cuePoints.map(cuePoint => ( 82 |
87 | ))} 88 |
89 | )} 90 |
91 |
92 | ); 93 | }, mapProps); 94 | 95 | function mapProps(info: IInfo): SeekbarProps { 96 | return { 97 | setSeekbarState: info.actions.setSeekbarState, 98 | isActive: info.data.isSeekbarHover || info.data.isSeekbarSeeking, 99 | adBreakData: info.data.adBreakData, 100 | seekbarThumbnailPercentage: info.data.seekbarThumbnailPercentage, 101 | seekbarTooltipPercentage: info.data.seekbarTooltipPercentage, 102 | seekbarTooltipText: info.data.seekbarTooltipText, 103 | progressPercentage: info.data.progressPercentage, 104 | activeThumbnail: info.data.activeThumbnail, 105 | bufferedPercentage: info.data.bufferedPercentage, 106 | seekbarPercentage: info.data.seekbarPercentage, 107 | showSeekAhead: info.data.isSeekbarHover && !info.data.isSeekbarSeeking, 108 | showCuepoints: !info.data.adBreakData && !!info.data.cuePoints.length, 109 | cuePoints: info.data.cuePoints, 110 | }; 111 | } 112 | -------------------------------------------------------------------------------- /src/ui/components/Settings.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@src/ui/components/Button'; 2 | import { IActions, IData, SettingsTabs } from '@src/ui/types'; 3 | import { withState } from '@src/ui/withState'; 4 | import * as React from 'react'; 5 | 6 | const tabs = {}; 7 | 8 | tabs[SettingsTabs.OPTIONS] = (props: SettingsProps) => ( 9 | <> 10 | {!!props.data.visibleSettingsTabs.length ? ( 11 | !!item)} 36 | /> 37 | ) : ( 38 |
No settings available
39 | )} 40 | 41 | ); 42 | 43 | tabs[SettingsTabs.TRACKS] = (props: SettingsProps) => ( 44 | <> 45 | props.actions.setSettingsTab(SettingsTabs.OPTIONS)} 48 | /> 49 | { 52 | props.actions.selectTrack(track); 53 | props.actions.toggleSettings(); 54 | }} 55 | items={[ 56 | ...props.data.tracks.map(track => ({ 57 | item: track, 58 | label: `${track.height}`, 59 | })), 60 | { 61 | item: 'auto', 62 | label: props.data.getTranslation('Automatic quality'), 63 | }, 64 | ]} 65 | /> 66 | 67 | ); 68 | 69 | tabs[SettingsTabs.SUBTITLES] = (props: SettingsProps) => ( 70 | <> 71 | props.actions.setSettingsTab(SettingsTabs.OPTIONS)} 74 | /> 75 | { 78 | props.actions.selectSubtitle(subtitle); 79 | props.actions.toggleSettings(); 80 | }} 81 | items={[ 82 | ...props.data.subtitles.map(subtitle => ({ 83 | item: subtitle, 84 | label: subtitle.label, 85 | })), 86 | { 87 | item: null, 88 | label: props.data.getTranslation('No subtitles'), 89 | }, 90 | ]} 91 | /> 92 | 93 | ); 94 | 95 | tabs[SettingsTabs.PLAYBACKRATES] = (props: SettingsProps) => ( 96 | <> 97 | props.actions.setSettingsTab(SettingsTabs.OPTIONS)} 100 | /> 101 | { 104 | props.actions.setPlaybackRate(playbackRate); 105 | props.actions.toggleSettings(); 106 | }} 107 | items={[ 108 | { 109 | item: 0.25, 110 | label: '0.25', 111 | }, 112 | { 113 | item: 0.5, 114 | label: '0.5', 115 | }, 116 | { 117 | item: 1, 118 | label: props.data.getTranslation('Normal speed'), 119 | }, 120 | { 121 | item: 1.5, 122 | label: '1.5', 123 | }, 124 | { 125 | item: 2, 126 | label: '2', 127 | }, 128 | ]} 129 | /> 130 | 131 | ); 132 | 133 | interface SettingsHeaderProps { 134 | title: string; 135 | onBackClick?(); 136 | onOptionsClick?(); 137 | } 138 | 139 | const SettingsHeader = (props: SettingsHeaderProps) => ( 140 |
141 | {!!props.onBackClick && ( 142 | 149 | )} 150 |
151 | ); 152 | 153 | interface SettingsSelectProps { 154 | selected?: any; 155 | items: Array<{ 156 | item: any; 157 | label: string; 158 | info?: string; 159 | }>; 160 | onClick(item: any); 161 | } 162 | 163 | const SettingsSelect = (props: SettingsSelectProps) => ( 164 |
165 | {props.items.map(item => ( 166 | 181 | ))} 182 |
183 | ); 184 | 185 | interface SettingsProps { 186 | data: IData; 187 | actions: IActions; 188 | } 189 | 190 | export const Settings = withState((props: SettingsProps) => { 191 | const renderTab = tabs[props.data.settingsTab]; 192 | return renderTab ? ( 193 |
194 | {props.data.isMobile && ( 195 | 198 | )} 199 | {renderTab(props)} 200 |
201 | ) : null; 202 | }); 203 | -------------------------------------------------------------------------------- /src/ui/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export const Spinner = () =>
; 4 | -------------------------------------------------------------------------------- /src/ui/components/Sprite.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@src/ui/components/Icon'; 2 | import { IData } from '@src/ui/types'; 3 | import { withState } from '@src/ui/withState'; 4 | import * as React from 'react'; 5 | 6 | interface SpriteProps { 7 | src: string; 8 | x: number; 9 | y: number; 10 | width: number; 11 | height: number; 12 | className?: string; 13 | } 14 | 15 | export const Sprite = (props: SpriteProps) => ( 16 |
17 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 31 | 32 | 33 |
34 | ); 35 | -------------------------------------------------------------------------------- /src/ui/components/StartView.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@src/ui/components/Icon'; 2 | import { IInfo } from '@src/ui/types'; 3 | import { withState } from '@src/ui/withState'; 4 | import * as React from 'react'; 5 | 6 | interface StartViewProps { 7 | image: string; 8 | playOrPause(); 9 | } 10 | 11 | export const StartView = withState((props: StartViewProps) => { 12 | return ( 13 | 26 | ); 27 | }, mapProps); 28 | 29 | function mapProps(info: IInfo): StartViewProps { 30 | return { 31 | playOrPause: info.actions.playOrPause, 32 | image: info.data.image, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/ui/components/TimeStat.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@src/ui/components/Icon'; 2 | import { IInfo } from '@src/ui/types'; 3 | import { withState } from '@src/ui/withState'; 4 | import React from 'react'; 5 | 6 | interface TimeStatProps { 7 | timeStatPosition: string; 8 | timeStatDuration: string; 9 | } 10 | 11 | export const TimeStat = withState((props: TimeStatProps) => { 12 | return ( 13 |
14 |
{props.timeStatPosition}
15 |
{props.timeStatDuration}
16 |
17 | ); 18 | }, mapProps); 19 | 20 | function mapProps(info: IInfo): TimeStatProps { 21 | return { 22 | timeStatPosition: info.data.timeStatPosition, 23 | timeStatDuration: info.data.timeStatDuration, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/ui/components/VolumeButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@src/ui/components/Button'; 2 | import { IInfo } from '@src/ui/types'; 3 | import { useSlider } from '@src/ui/utils/useSlider'; 4 | import { withState } from '@src/ui/withState'; 5 | import cx from 'classnames'; 6 | import React from 'react'; 7 | 8 | interface VolumeButtonProps { 9 | volumeIcon: string; 10 | tooltipText: string; 11 | isVolumeControlsOpen: boolean; 12 | volumeBarPercentage: number; 13 | toggleMute(); 14 | setVolumeControlsOpen(open: boolean); 15 | setVolumebarState(state: any); 16 | } 17 | 18 | const ref = React.createRef(); 19 | 20 | export const VolumeButton = withState((props: VolumeButtonProps) => { 21 | useSlider(ref.current as HTMLElement, props.setVolumebarState); 22 | 23 | return ( 24 |
props.setVolumeControlsOpen(true)} 29 | onMouseLeave={() => props.setVolumeControlsOpen(false)} 30 | > 31 |