├── .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 | 
17 | [](https://www.npmjs.com/package/indigo-player)
18 | [](https://github.com/matvp91/indigo-player)
19 | [](https://github.com/matvp91/indigo-player)
20 | [](https://www.npmjs.com/package/indigo-player)
21 | 
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 |
location.reload()}>Reload
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 |
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 | 
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 | 
--------------------------------------------------------------------------------
/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 |
48 | {!!props.children && props.children}
49 | {props.icon && }
50 | {hover && props.tooltip && (
51 | {props.tooltip}
52 | )}
53 |
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 |
49 |
50 |
51 |
52 |
53 |
54 | {props.showSubtitlesToggle && (
55 |
62 | )}
63 | {props.showPip && (
64 |
70 | )}
71 |
props.toggleSettings()}
75 | tooltip={props.settingsTooltipText}
76 | active={props.isSettingsTabActive}
77 | />
78 |
85 |
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 |
143 | )}
144 | {props.title}
145 | {!!props.onOptionsClick && (
146 |
147 | Options
148 |
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 | props.onClick(item.item)}
170 | active={item.item === props.selected}
171 | >
172 | <>
173 | {item.label}
174 | {item.info && (
175 |
176 | {item.info}
177 |
178 | )}
179 | >
180 |
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 |
196 | ×
197 |
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 |
18 | {!!props.image && (
19 |
23 | )}
24 |
25 |
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 |
36 |
54 |
55 | );
56 | }, mapProps);
57 |
58 | function mapProps(info: IInfo): VolumeButtonProps {
59 | let volumeIcon: string = 'volume-off';
60 | if (info.data.volumeBarPercentage > 0.5) {
61 | volumeIcon = 'volume-2';
62 | } else if (info.data.volumeBarPercentage > 0) {
63 | volumeIcon = 'volume-1';
64 | }
65 |
66 | return {
67 | volumeIcon,
68 | tooltipText: `${info.data.getTranslation(
69 | info.data.volumeBarPercentage === 0 ? 'Unmute' : 'Mute',
70 | )} (m)`,
71 | toggleMute: info.actions.toggleMute,
72 | setVolumeControlsOpen: info.actions.setVolumeControlsOpen,
73 | setVolumebarState: info.actions.setVolumebarState,
74 | isVolumeControlsOpen: info.data.isVolumeControlsOpen,
75 | volumeBarPercentage: info.data.volumeBarPercentage,
76 | };
77 | }
78 |
--------------------------------------------------------------------------------
/src/ui/i18n.ts:
--------------------------------------------------------------------------------
1 | // List of language codes: http://www.lingoes.net/en/translator/langcode.htm
2 |
3 | export const translations = {
4 | 'en-US': {
5 | Play: 'Play',
6 | Pause: 'Pause',
7 | Mute: 'Mute',
8 | Unmute: 'Unmute',
9 | Miniplayer: 'Miniplayer',
10 | Settings: 'Settings',
11 | 'Full screen': 'Full screen',
12 | 'Exit full screen': 'Exit full screen',
13 | Speed: 'Speed',
14 | 'Normal speed': 'Normal',
15 | Subtitles: 'Subtitles',
16 | 'No subtitles': 'None',
17 | Quality: 'Quality',
18 | 'Automatic quality': 'Auto',
19 | 'Enable subtitles': 'Enable subtitles',
20 | 'Disable subtitles': 'Disable subtitles',
21 | },
22 | 'nl-BE': {
23 | Play: 'Afspelen',
24 | Pause: 'Pauzeren',
25 | Mute: 'Dempen',
26 | Unmute: 'Unmute',
27 | Miniplayer: 'Mini speler',
28 | Settings: 'Instellingen',
29 | 'Full screen': 'Volledig scherm',
30 | 'Exit full screen': 'Volledig scherm afsluiten',
31 | Speed: 'Snelheid',
32 | 'Normal speed': 'Normale snelheid',
33 | Subtitles: 'Ondertitels',
34 | 'No subtitles': 'Geen',
35 | Quality: 'Kwaliteit',
36 | 'Automatic quality': 'Automatisch',
37 | 'Enable subtitles': 'Ondertitels aan',
38 | 'Disable subtitles': 'Ondertitels uit',
39 | },
40 | 'de-DE': {
41 | Play: 'Wiedergabe',
42 | Pause: 'Pause',
43 | Mute: 'Stummschalten',
44 | Unmute: 'Stummschaltung aufheben',
45 | Miniplayer: 'Miniplayer',
46 | Settings: 'Einstellungen',
47 | 'Full screen': 'Vollbild',
48 | 'Exit full screen': 'Vollbildmodus verlassen',
49 | Speed: 'Geschwindigkeit',
50 | 'Normal speed': 'Normal',
51 | Subtitles: 'Untertitel',
52 | 'No subtitles': 'Aus',
53 | Quality: 'Qualität',
54 | 'Automatic quality': 'Automatisch',
55 | 'Enable subtitles': 'Untertitel an',
56 | 'Disable subtitles': 'Untertitel aus',
57 | },
58 | 'hi-IN': {
59 | Play: 'चलाएँ',
60 | Pause: 'रोकें',
61 | Mute: 'ध्वनि बंद करें',
62 | Unmute: 'ध्वनि शुरू करें',
63 | Miniplayer: 'लघु संस्करण',
64 | Settings: 'नियंत्रण',
65 | 'Full screen': 'पूर्ण संस्करण',
66 | 'Exit full screen': 'पूर्ण संस्करण से बाहर निकलें',
67 | Speed: 'गति',
68 | 'Normal speed': 'सामान्य',
69 | Subtitles: 'उपशीर्षक',
70 | 'No subtitles': 'उपशीर्षक उपलब्ध नहीं है',
71 | Quality: 'गुणवत्ता',
72 | 'Automatic quality': 'स्वचालित',
73 | 'Enable subtitles': 'उपशीर्षक जारी रखें',
74 | 'Disable subtitles': 'उपशीर्षक बंद करें',
75 | },
76 | 'mr-IN': {
77 | Play: 'चालू करा',
78 | Pause: 'थांबवा',
79 | Mute: 'आवाज बंद करा ',
80 | Unmute: 'आवाज सुरू करा',
81 | Miniplayer: 'लघु आवृत्ती',
82 | Settings: 'नियंत्रणे',
83 | 'Full screen': 'पूर्ण आवृत्ती',
84 | 'Exit full screen': 'पूर्ण आवृत्तीतून बाहेर पडा',
85 | Speed: 'गति',
86 | 'Normal speed': 'सामान्य',
87 | Subtitles: 'उपशीर्षके',
88 | 'No subtitles': 'उपशीर्षके उपलब्ध नाहीत',
89 | Quality: 'गुणवत्ता',
90 | 'Automatic quality': 'स्वयंचलित',
91 | 'Enable subtitles': 'उपशीर्षके सूरू करा',
92 | 'Disable subtitles': 'उपशीर्षके बंद करा',
93 | },
94 | 'pt-BR': {
95 | Play: 'Reproduzir',
96 | Pause: 'Pausa',
97 | Mute: 'Mudo',
98 | Unmute: 'Ativar som',
99 | Miniplayer: 'Miniatura',
100 | Settings: 'Configurações',
101 | 'Full screen': 'Tela cheia',
102 | 'Exit full screen': 'Sair da tela cheia',
103 | Speed: 'Velocidade',
104 | 'Normal speed': 'Normal',
105 | Subtitles: 'Legenda',
106 | 'No subtitles': 'Sem legenda',
107 | Quality: 'Qualidade',
108 | 'Automatic quality': 'Automática',
109 | 'Enable subtitles': 'Habilitar legenda',
110 | 'Disable subtitles': 'Desabilitar legenda',
111 | },
112 | };
113 |
114 | export const getTranslation = languageCode => text => {
115 | if (translations[languageCode] && translations[languageCode][text]) {
116 | return translations[languageCode][text];
117 | }
118 | return text;
119 | };
120 |
--------------------------------------------------------------------------------
/src/ui/render.tsx:
--------------------------------------------------------------------------------
1 | import { IInstance } from '@src/types';
2 | import { Main } from '@src/ui/components/Main';
3 | import { StateStore } from '@src/ui/State';
4 | import React, { RefObject } from 'react';
5 | import * as ReactDOM from 'react-dom';
6 | import { IStateStore } from '@src/ui/types';
7 |
8 | export const render = (
9 | container: HTMLElement,
10 | state: any,
11 | instance: IInstance,
12 | ref: RefObject
13 | ) => {
14 | ReactDOM.render(
15 |
16 |
17 | ,
18 | container,
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/src/ui/theme/button.scss:
--------------------------------------------------------------------------------
1 | .igui_button {
2 | @include reset-button();
3 | user-select: none;
4 | cursor: pointer;
5 | outline: none;
6 |
7 | &.igui_button_state-disabled {
8 | pointer-events: none;
9 | opacity: 0.5 !important;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/ui/theme/element-center.scss:
--------------------------------------------------------------------------------
1 | .igui_center {
2 | @include stretch();
3 |
4 | .igui_center_preview {
5 | width: 100%;
6 | height: 100%;
7 | position: relative;
8 |
9 | &:before {
10 | content: '';
11 | position: absolute;
12 | @include stretch();
13 | background-color: rgba(0, 0, 0, .5);
14 | }
15 | }
16 | }
--------------------------------------------------------------------------------
/src/ui/theme/element-image.scss:
--------------------------------------------------------------------------------
1 | .igui_image {
2 | @include stretch();
3 | background-size: cover;
4 | background-position: center;
5 |
6 | &:after {
7 | content: '';
8 | background-color: rgba(0, 0, 0, .5);
9 | @include stretch();
10 | }
11 | }
--------------------------------------------------------------------------------
/src/ui/theme/element-nod.scss:
--------------------------------------------------------------------------------
1 | .igui_nod {
2 | pointer-events: none;
3 | position: absolute;
4 | left: 50%;
5 | top: 50%;
6 | font-size: 24px;
7 | width: 48px;
8 | height: 48px;
9 | margin-left: -(48px / 2);
10 | margin-top: -(48px / 2);
11 | background-color: rgba(0, 0, 0, .5);
12 | display: flex;
13 | align-items: center;
14 | justify-content: center;
15 | border-radius: 100%;
16 | animation-duration: 500ms;
17 | opacity: 0;
18 |
19 | &.igui_nod-active {
20 | animation-name: nod-zoomin;
21 | }
22 | }
23 |
24 | @keyframes nod-zoomin {
25 | 0% {
26 | opacity: 0;
27 | transform: scale(0);
28 | }
29 |
30 | 70% { opacity: 1; }
31 |
32 | 100% {
33 | opacity: 0;
34 | transform: scale(1.5);
35 | }
36 | }
--------------------------------------------------------------------------------
/src/ui/theme/element-rebuffer.scss:
--------------------------------------------------------------------------------
1 | .igui_rebuffer {
2 | @include stretch();
3 | display: flex;
4 | align-items: center;
5 | justify-content: center;
6 | pointer-events: none;
7 | }
--------------------------------------------------------------------------------
/src/ui/theme/element-seekbar.scss:
--------------------------------------------------------------------------------
1 | $seekbar-bars-height: 5px;
2 | $seekbar-scrubber-radius: 13px;
3 |
4 | .igui_seekbar {
5 | cursor: pointer;
6 | width: 100%;
7 | position: relative;
8 | display: flex;
9 | align-items: center;
10 | padding-bottom: 6px;
11 | padding-top: 16px;
12 | }
13 |
14 | .igui_seekbar_bars {
15 | height: $seekbar-bars-height;
16 | background-color: rgba(255, 255, 255, .3);;
17 | position: relative;
18 | width: 100%;
19 | transform: scaleY(0.6);
20 | transition: transform 75ms linear;
21 |
22 | .igui_seekbar_state-active & {
23 | transform: scaleY(1);
24 | }
25 | }
26 |
27 | .igui_seekbar_progress {
28 | @include stretch();
29 | transform-origin: left;
30 | background-color: #ffffff;
31 | }
32 |
33 | .igui_seekbar_ahead {
34 | @include stretch();
35 | transform-origin: left;
36 | background-color: rgba(255, 255, 255, .3);
37 | z-index: -1;
38 | }
39 |
40 | .igui_seekbar_buffered {
41 | @include stretch();
42 | transform-origin: left;
43 | background-color: rgba(255, 255, 255, .3);
44 | }
45 |
46 | .igui_seekbar_scrubber {
47 | width: $seekbar-scrubber-radius;
48 | height: $seekbar-scrubber-radius;
49 | background-color: #ffffff;
50 | border-radius: 100%;
51 | position: absolute;
52 | transition: transform 75ms linear;
53 | transform-origin: center;
54 | transform: scale(0);
55 | margin-left: -($seekbar-scrubber-radius / 2);
56 | z-index: 1;
57 |
58 | .igui_seekbar_state-active & {
59 | transform: scale(1);
60 | }
61 | }
62 |
63 | .igui_seekbar_state-playingad {
64 | pointer-events: none;
65 |
66 | .igui_seekbar_progress {
67 | background-color: #ffe600;
68 | }
69 | }
70 |
71 | .igui_seekbar_cuepoint {
72 | position: absolute;
73 | top: 0;
74 | bottom: 0;
75 | width: 5px;
76 | background-color: #ffe600;
77 | }
78 |
79 | .igui_seekbar_tooltip {
80 | position: absolute;
81 | bottom: 24px;
82 | transform: translateX(-50%);
83 | padding: 4px 6px;
84 | background-color: #222222;
85 | border-radius: 2px;
86 | opacity: 0;
87 | pointer-events: none;
88 |
89 | .igui_seekbar_state-active & {
90 | opacity: 1;
91 | }
92 | }
93 |
94 | .igui_seekbar_thumbnail {
95 | position: absolute;
96 | bottom: 24px;
97 | transform: translateX(-50%);
98 | opacity: 0;
99 | pointer-events: none;
100 | border-radius: 2px;
101 | overflow: hidden;
102 |
103 | .igui_seekbar_thumbnail_sprite {
104 | width: 100px;
105 | height: 100px * (9 / 16);
106 | }
107 |
108 | .igui_seekbar_state-active & {
109 | opacity: 1;
110 | }
111 | }
--------------------------------------------------------------------------------
/src/ui/theme/element-settings.scss:
--------------------------------------------------------------------------------
1 | .igui_settings {
2 | position: absolute;
3 | right: 9px;
4 | bottom: 58px;
5 | background-color: #222222;
6 | border-radius: 2px;
7 | color: #fff;
8 | z-index: 1;
9 | min-width: 180px;
10 | overflow-y: auto;
11 |
12 | .igui_state-fullscreen & {
13 | bottom: big-mode(58px);
14 | }
15 |
16 | .igui_state-mobile & {
17 | @include stretch();
18 | }
19 | }
20 |
21 | .igui_button_name-mobile-close {
22 | float: right;
23 | width: 31px;
24 | height: 31px;
25 | font-size: 18px;
26 | position: relative;
27 | z-index: 1;
28 | }
29 |
30 | .igui_settings_nooptions {
31 | padding: 8px 16px;
32 | }
33 |
34 | .igui_settings_select {
35 | margin: 3px 0;
36 |
37 | .igui_button_name-select-option {
38 | display: block;
39 | padding: 8px 16px;
40 | width: 100%;
41 | text-align: left;
42 |
43 | &.igui_button_state-active {
44 | text-decoration: underline;
45 | }
46 |
47 | &:hover {
48 | background-color: #333333;
49 | }
50 |
51 | .igui_settings_select_option_info {
52 | float: right;
53 | }
54 | }
55 | }
56 |
57 | .igui_settings_header {
58 | padding: 8px;
59 | text-align: center;
60 | border-bottom: 1px solid #555555;
61 | display: flex;
62 | align-items: center;
63 |
64 | .igui_button_name-settings-back {
65 | margin-right: 8px;
66 | font-size: 16px;
67 | }
68 |
69 | .igui_button_name-settings-options {
70 | margin-left: auto;
71 | font-size: 11px;
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/ui/theme/element-spinner.scss:
--------------------------------------------------------------------------------
1 | .igui_spinner {
2 | width: 32px;
3 | height: 32px;
4 | border-radius: 100%;
5 | border: 4px solid rgba(255, 255, 255, 0.2);
6 | border-top-color: #ffffff;
7 | animation: spin 1s infinite linear;
8 | }
9 |
10 | @keyframes spin {
11 | 100% {
12 | transform: rotate(360deg);
13 | }
14 | }
--------------------------------------------------------------------------------
/src/ui/theme/element-volume.scss:
--------------------------------------------------------------------------------
1 | $volumebar-height: 3px;
2 | $volumebar-scrubber-radius: 14px;
3 |
4 | .igui_volume {
5 | display: flex;
6 | align-items: center;
7 |
8 | .igui_button {
9 | position: relative;
10 |
11 | .igui_icon_volume-1 {
12 | position: relative;
13 | left: -2px;
14 | }
15 | }
16 | }
17 |
18 | .igui_volume_container {
19 | padding: 0 7px;
20 | }
21 |
22 | .igui_volume_collapse {
23 | overflow: hidden;
24 | width: 0;
25 | transition: width 150ms ease-in-out;
26 |
27 | .igui_volume_state-open & {
28 | width: 64px + (5px + 7px);
29 | }
30 | }
31 |
32 | .igui_volumebar {
33 | cursor: pointer;
34 | width: 100%;
35 | height: 16px;
36 | display: flex;
37 | align-items: center;
38 | }
39 |
40 | .igui_volumebar_container {
41 | height: $volumebar-height;
42 | background-color: rgba(255, 255, 255, .5);
43 | position: relative;
44 | width: 50px;
45 | }
46 |
47 | .igui_volumebar_progress {
48 | position: absolute;
49 | left: 0;
50 | right: 0;
51 | top: 0;
52 | bottom: 0;
53 | transform-origin: left;
54 | background-color: #ffffff;
55 | height: 100%;
56 | }
57 |
58 | .igui_volumebar_scrubber {
59 | width: $volumebar-scrubber-radius;
60 | height: $volumebar-scrubber-radius;
61 | background-color: #ffffff;
62 | border-radius: 100%;
63 | position: relative;
64 | top: (($volumebar-height / 2) - ($volumebar-scrubber-radius / 2));
65 | margin-left: -($volumebar-scrubber-radius / 2);
66 | }
--------------------------------------------------------------------------------
/src/ui/theme/fonts/icons.scss:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'igui-icon-font';
3 | src: url('./fonts/icons.ttf') format('truetype'),
4 | url('./fonts/icons.woff2') format('woff');
5 | font-weight: normal;
6 | font-style: normal;
7 | }
8 |
9 | .igui-icon-font {
10 | font-family: 'igui-icon-font';
11 | speak: none;
12 | font-style: normal;
13 | font-weight: normal;
14 | font-variant: normal;
15 | text-transform: none;
16 | line-height: 1;
17 | -webkit-font-smoothing: antialiased;
18 | -moz-osx-font-smoothing: grayscale;
19 | }
20 |
21 | .igui_icon_back:before {
22 | content: "\e90c";
23 | }
24 | .igui_icon_settings:before {
25 | content: "\e90b";
26 | }
27 | .igui_icon_pip:before {
28 | content: "\e900";
29 | }
30 | .igui_icon_hd:before {
31 | content: "\e901";
32 | }
33 | .igui_icon_cc:before {
34 | content: "\e902";
35 | }
36 | .igui_icon_fullscreen-exit:before {
37 | content: "\e903";
38 | }
39 | .igui_icon_fullscreen:before {
40 | content: "\e904";
41 | }
42 | .igui_icon_pause:before {
43 | content: "\e905";
44 | }
45 | .igui_icon_volume-2:before {
46 | content: "\e906";
47 | }
48 | .igui_icon_volume-off:before {
49 | content: "\e907";
50 | }
51 | .igui_icon_volume-1:before {
52 | content: "\e908";
53 | }
54 | .igui_icon_play-rounded:before {
55 | content: "\e909";
56 | }
57 | .igui_icon_play:before {
58 | content: "\e90a";
59 | }
60 |
--------------------------------------------------------------------------------
/src/ui/theme/fonts/icons.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/matvp91/indigo-player/32ee84248df280afd84b53a8af1096c374ae0f2b/src/ui/theme/fonts/icons.ttf
--------------------------------------------------------------------------------
/src/ui/theme/fonts/icons.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/matvp91/indigo-player/32ee84248df280afd84b53a8af1096c374ae0f2b/src/ui/theme/fonts/icons.woff2
--------------------------------------------------------------------------------
/src/ui/theme/index.scss:
--------------------------------------------------------------------------------
1 | @import './mixins.scss';
2 | @import './root.scss';
3 |
4 | @import './button.scss';
5 |
6 | @import './view-controls.scss';
7 | @import './view-loading.scss';
8 | @import './view-start.scss';
9 | @import './view-error.scss';
10 |
11 | @import './element-seekbar.scss';
12 | @import './element-volume.scss';
13 | @import './element-spinner.scss';
14 | @import './element-rebuffer.scss';
15 | @import './element-center.scss';
16 | @import './element-settings.scss';
17 | @import './element-image.scss';
18 | @import './element-nod.scss';
19 |
20 | @import './fonts/icons.scss';
21 |
--------------------------------------------------------------------------------
/src/ui/theme/mixins.scss:
--------------------------------------------------------------------------------
1 | @mixin stretch() {
2 | position: absolute;
3 | top: 0;
4 | left: 0;
5 | right: 0;
6 | bottom: 0;
7 | }
8 |
9 | @mixin reset-button() {
10 | border: none;
11 | margin: 0;
12 | padding: 0;
13 | width: auto;
14 | overflow: visible;
15 | background: transparent;
16 | color: inherit;
17 | font: inherit;
18 | line-height: normal;
19 | -webkit-font-smoothing: inherit;
20 | -moz-osx-font-smoothing: inherit;
21 | -webkit-appearance: none;
22 |
23 | &::-moz-focus-inner {
24 | border: 0;
25 | padding: 0;
26 | }
27 | }
28 |
29 | @mixin shaded() {
30 | position: relative;
31 |
32 | &:after {
33 | content: '';
34 | @include stretch();
35 | background-color: rgba(0, 0, 0, 0.5);
36 | }
37 | }
38 |
39 | /* Credits to: https://css-tricks.com/glitch-effect-text-images-svg/ */
40 | @mixin text-glitch(
41 | $name,
42 | $intensity,
43 | $textColor,
44 | $background,
45 | $highlightColor1,
46 | $highlightColor2,
47 | $width,
48 | $height
49 | ) {
50 | color: $textColor;
51 | position: relative;
52 | $steps: $intensity;
53 |
54 | @at-root {
55 | @for $i from 1 through 2 {
56 | @keyframes #{$name}-anim-#{$i} {
57 | @for $i from 0 through $steps {
58 | #{percentage($i*(1/$steps))} {
59 | clip: rect(
60 | random($height) + px,
61 | $width + px,
62 | random($height) + px,
63 | 0
64 | );
65 | }
66 | }
67 | }
68 | }
69 | }
70 |
71 | &:before,
72 | &:after {
73 | content: attr(data-text);
74 | position: absolute;
75 | top: 0;
76 | left: 0;
77 | width: 100%;
78 | background: $background;
79 | clip: rect(0, 0, 0, 0);
80 | }
81 |
82 | &:after {
83 | left: 2px;
84 | text-shadow: -1px 0 $highlightColor1;
85 | animation: #{$name}-anim-1 2s 3 1.5s linear alternate-reverse;
86 | }
87 |
88 | &:before {
89 | left: -2px;
90 | text-shadow: 2px 0 $highlightColor2;
91 | animation: #{$name}-anim-2 3s 3 1.5s linear alternate-reverse;
92 | }
93 | }
94 |
95 | @function big-mode($i) {
96 | @return $i + 8px;
97 | }
98 |
--------------------------------------------------------------------------------
/src/ui/theme/root.scss:
--------------------------------------------------------------------------------
1 | .ig-container {
2 | color: #fff;
3 | -webkit-tap-highlight-color: transparent;
4 | font-family: Verdana, Geneva, sans-serif;
5 | font-size: 13px;
6 |
7 | * {
8 | box-sizing: border-box;
9 | }
10 | }
11 |
12 | .igui_state-fullscreen {
13 | font-size: 16px;
14 | }
15 |
--------------------------------------------------------------------------------
/src/ui/theme/view-controls.scss:
--------------------------------------------------------------------------------
1 | .igui_container_controls {
2 | position: absolute;
3 | left: 0;
4 | right: 0;
5 | bottom: 0;
6 | z-index: 0;
7 | display: flex;
8 |
9 | opacity: 0;
10 | transition: opacity 100ms linear;
11 |
12 | &:before {
13 | content: '';
14 | position: absolute;
15 | background-image: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.8));
16 | left: 0;
17 | right: 0;
18 | bottom: 0;
19 | height: 80px;
20 | z-index: -1;
21 | pointer-events: none;
22 | }
23 |
24 | .igui_button {
25 | width: 36px;
26 | height: 40px;
27 | display: inline-flex;
28 | align-items: center;
29 | justify-content: center;
30 | position: relative;
31 | opacity: 0.9;
32 |
33 | .igui_state-fullscreen & {
34 | width: big-mode(36px);
35 | height: big-mode(40px);
36 | }
37 |
38 | &:hover {
39 | opacity: 1;
40 | }
41 |
42 | .igui_icon {
43 | font-size: 26px;
44 |
45 | .igui_state-fullscreen & {
46 | font-size: big-mode(26px);
47 | }
48 | }
49 |
50 | .igui_button_tooltip {
51 | position: absolute;
52 | bottom: 58px;
53 | left: 50%;
54 | pointer-events: none;
55 | transform: translateX(-50%);
56 | background-color: #222222;
57 | border-radius: 2px;
58 | padding: 4px 6px;
59 | white-space: nowrap;
60 |
61 | .igui_state-fullscreen & {
62 | bottom: big-mode(58px);
63 | }
64 | }
65 | }
66 |
67 | .igui_button_name-play {
68 | margin-left: 9px;
69 |
70 | .igui_button_tooltip {
71 | left: 2px;
72 | transform: translateX(0);
73 | }
74 | }
75 |
76 | .igui_timestat {
77 | user-select: none;
78 | display: flex;
79 | align-items: center;
80 | margin-right: auto;
81 |
82 | .igui_timestat_duration {
83 | position: relative;
84 | margin-left: 8px;
85 |
86 | &:before {
87 | position: absolute;
88 | content: '/';
89 | left: -6px;
90 | }
91 | }
92 | }
93 |
94 | .igui_button_name-fullscreen {
95 | margin-right: 9px;
96 |
97 | @keyframes fullscreen-hover {
98 | 0% {
99 | transform: scale(1);
100 | }
101 | 50% {
102 | transform: scale(1.1);
103 | }
104 | 100% {
105 | transform: scale(1);
106 | }
107 | }
108 |
109 | &:hover {
110 | .igui_icon {
111 | animation-name: fullscreen-hover;
112 | animation-duration: 350ms;
113 | }
114 | }
115 |
116 | .igui_button_tooltip {
117 | right: 2px;
118 | left: auto;
119 | transform: translateX(0);
120 | }
121 | }
122 |
123 | .igui_button_name-settings {
124 | .igui_icon {
125 | transform: rotate(0deg);
126 | transition: transform 150ms ease-in-out;
127 | }
128 |
129 | &.igui_button_state-active .igui_icon {
130 | transform: rotate(35deg);
131 | }
132 | }
133 |
134 | .igui_button_name-subtitle {
135 | &:before {
136 | content: '';
137 | position: absolute;
138 | height: 2px;
139 | height: 2px;
140 | background-color: #ffffff;
141 | bottom: 7px;
142 | left: 50%;
143 | transform: translateX(-50%);
144 | width: 0px;
145 | transition: width 120ms linear;
146 | }
147 |
148 | &.igui_button_state-active {
149 | position: relative;
150 |
151 | &:before {
152 | width: 21px;
153 | }
154 | }
155 | }
156 |
157 | .igui_state-active & {
158 | opacity: 1;
159 | }
160 | }
161 |
162 | .igui_container_controls_seekbar {
163 | padding: 0 12px;
164 | display: flex;
165 | align-items: center;
166 | position: absolute;
167 | left: 0;
168 | right: 0;
169 | bottom: 34px;
170 |
171 | .igui_state-fullscreen & {
172 | bottom: big-mode(34px);
173 | }
174 | }
175 |
--------------------------------------------------------------------------------
/src/ui/theme/view-error.scss:
--------------------------------------------------------------------------------
1 | .igui_view_error {
2 | @include stretch();
3 | display: flex;
4 | align-items: center;
5 | justify-content: center;
6 | }
7 |
8 | .igui_view_error-title {
9 | text-align: center;
10 | font-weight: bold;
11 | font-size: 16px;
12 | margin-bottom: 3px;
13 | @include text-glitch(
14 | 'error-glitch',
15 | 17,
16 | #ffffff,
17 | #000000,
18 | #ff0000,
19 | #0000ff,
20 | 170,
21 | 25
22 | );
23 | }
--------------------------------------------------------------------------------
/src/ui/theme/view-loading.scss:
--------------------------------------------------------------------------------
1 | .igui_view_loading {
2 | @include stretch();
3 | display: flex;
4 | align-items: center;
5 | justify-content: center;
6 | }
--------------------------------------------------------------------------------
/src/ui/theme/view-start.scss:
--------------------------------------------------------------------------------
1 | .igui_view_start {
2 | @include stretch();
3 | @include reset-button();
4 | width: 100%;
5 | display: flex;
6 | align-items: center;
7 | justify-content: center;
8 | cursor: pointer;
9 |
10 | .igui_icon_play {
11 | animation: 250ms ease-out 0s 1 play-appear;
12 | font-size: 48px;
13 | z-index: 1;
14 |
15 | @keyframes play-appear {
16 | 0% {
17 | transform: translateY(10px);
18 | opacity: 0;
19 | }
20 | 100% {
21 | transform: translateY(0);
22 | opacity: 1;
23 | }
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/ui/triggerEvent.ts:
--------------------------------------------------------------------------------
1 | import { Events, IInstance } from '@src/types';
2 | import { IData } from '@src/ui/types';
3 |
4 | const createEventQueue = (instance: IInstance, { state, prevState }) => (eventName: string, data: any) => {
5 | instance.emit(eventName, data);
6 | instance.emit(Events.UI_STATE_CHANGE, {
7 | state,
8 | prevState,
9 | });
10 | };
11 |
12 | export function triggerEvent(
13 | instance: IInstance,
14 | data: IData,
15 | prevData: IData,
16 | ) {
17 | if (!prevData) {
18 | return;
19 | }
20 |
21 | const eventQueue:{ eventName: string, data: any }[] = [];
22 | const queueEvent = (eventName: string, data?: any) => {
23 | eventQueue.push({ eventName, data });
24 | };
25 |
26 | // UI state management
27 |
28 | // Trigger the controls visibility.
29 | if (data.visibleControls && !prevData.visibleControls) {
30 | queueEvent(Events.UI_VISIBLECONTROLS_CHANGE, {
31 | visibleControls: true,
32 | });
33 | } else if (!data.visibleControls && prevData.visibleControls) {
34 | queueEvent(Events.UI_VISIBLECONTROLS_CHANGE, {
35 | visibleControls: false,
36 | });
37 | }
38 |
39 | if (data.view !== prevData.view) {
40 | queueEvent(Events.UI_VIEW_CHANGE, {
41 | view: data.view,
42 | });
43 | }
44 |
45 | // Release the queue
46 | if (Boolean(eventQueue.length)) {
47 | eventQueue.forEach(({ eventName, data }) => {
48 | instance.emit(eventName, data);
49 | });
50 |
51 | instance.emit(Events.UI_STATE_CHANGE, {
52 | state: data,
53 | prevState: prevData,
54 | });
55 | }
56 |
57 | // Extension triggers
58 |
59 | // Trigger subtitles to move up.
60 | const subtitlesExtension = instance.getModule('SubtitlesExtension');
61 | if (subtitlesExtension) {
62 | if (data.visibleControls && !prevData.visibleControls) {
63 | (subtitlesExtension as any).setOffset(42);
64 | } else if (!data.visibleControls && prevData.visibleControls) {
65 | (subtitlesExtension as any).setOffset(0);
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/ui/types.ts:
--------------------------------------------------------------------------------
1 | import { PlayerError } from '@src/PlayerError';
2 | import { IThumbnail, ITrack, Subtitle } from '@src/types';
3 |
4 | export interface IData {
5 | paused: boolean;
6 | view: ViewTypes;
7 | visibleControls: boolean;
8 | progressPercentage: number;
9 | bufferedPercentage: number;
10 | volumeBarPercentage: number;
11 | isVolumeControlsOpen: boolean;
12 | isFullscreen: boolean;
13 | fullscreenSupported: boolean;
14 | playRequested: boolean;
15 | adBreakData?: {
16 | progressPercentage: number;
17 | };
18 | cuePoints: number[];
19 | rebuffering: boolean;
20 | timeStatPosition: string;
21 | timeStatDuration: string;
22 | error?: PlayerError;
23 | isCenterClickAllowed: boolean;
24 | isSeekbarHover: boolean;
25 | isSeekbarSeeking: boolean;
26 | seekbarPercentage: number;
27 | seekbarTooltipText: string;
28 | seekbarTooltipPercentage: number;
29 | seekbarThumbnailPercentage: number;
30 | tracks: ITrack[];
31 | activeTrack: ITrack;
32 | selectedTrack: ITrack | string;
33 | settingsTab: SettingsTabs;
34 | visibleSettingsTabs: SettingsTabs[];
35 | subtitles: Subtitle[];
36 | activeSubtitle: Subtitle;
37 | playbackRate: number;
38 | pip: boolean;
39 | pipSupported: boolean;
40 | activeThumbnail: IThumbnail;
41 | isMobile: boolean;
42 | image: string;
43 | nodIcon: string;
44 | getTranslation(text: string): string;
45 | }
46 |
47 | export interface IActions {
48 | playOrPause(origin?: string);
49 | startSeeking();
50 | seekToPercentage(percentage: number);
51 | setVolume(volume: number);
52 | setVolumeControlsOpen(isVolumeControlsOpen: boolean);
53 | startVolumebarSeeking();
54 | stopVolumebarSeeking();
55 | toggleMute();
56 | toggleFullscreen();
57 | setSeekbarState(state: any);
58 | setVolumebarState(state: any);
59 | selectTrack(track: ITrack);
60 | setSettingsTab(tab: SettingsTabs);
61 | toggleSettings();
62 | selectSubtitle(subtitle: Subtitle);
63 | setPlaybackRate(playbackRate: number);
64 | togglePip();
65 | toggleActiveSubtitle();
66 | }
67 |
68 | export interface IInfo {
69 | data: IData;
70 | actions: IActions;
71 | }
72 |
73 | export enum ViewTypes {
74 | ERROR = 'error',
75 | LOADING = 'loading',
76 | START = 'start',
77 | CONTROLS = 'controls',
78 | }
79 |
80 | export enum SettingsTabs {
81 | NONE,
82 | OPTIONS,
83 | TRACKS,
84 | SUBTITLES,
85 | PLAYBACKRATES,
86 | }
87 |
88 | export interface IStateStore {
89 | showControls();
90 | }
--------------------------------------------------------------------------------
/src/ui/utils/attachEvents.ts:
--------------------------------------------------------------------------------
1 | interface EventDefinition {
2 | element: HTMLElement;
3 | events: string[];
4 | callback: any;
5 | passive?: boolean;
6 | }
7 |
8 | export type EventUnsubscribeFn = () => void;
9 |
10 | export function attachEvents(defs: EventDefinition[]) {
11 | const unsubscribers = [];
12 |
13 | defs.forEach(def => {
14 | def.events.forEach(name => {
15 | // Register the event listener.
16 | def.element.addEventListener(name, def.callback, def.passive);
17 |
18 | // Create an unsubscribe method.
19 | const unsubscribe = () =>
20 | def.element.removeEventListener(name, def.callback);
21 | unsubscribers.push(unsubscribe);
22 | });
23 | });
24 |
25 | // Return a function that unsubscribes the entire batch at once.
26 | return (() =>
27 | unsubscribers.forEach(unsubscribe => unsubscribe())) as EventUnsubscribeFn;
28 | }
29 |
--------------------------------------------------------------------------------
/src/ui/utils/secondsToHMS.ts:
--------------------------------------------------------------------------------
1 | export function secondsToHMS(seconds: number): string {
2 | const pad = num => (10 ** 2 + Math.floor(num)).toString().substring(1);
3 |
4 | seconds = Math.ceil(seconds);
5 |
6 | let display: string = '';
7 |
8 | const h = Math.trunc(seconds / 3600) % 24;
9 | if (h) {
10 | display += `${pad(h)}:`;
11 | }
12 |
13 | const m = Math.trunc(seconds / 60) % 60;
14 | display += `${pad(m)}:`;
15 |
16 | const s = Math.trunc(seconds % 60);
17 | display += `${pad(s)}`;
18 |
19 | return display;
20 | }
21 |
--------------------------------------------------------------------------------
/src/ui/utils/useSlider.ts:
--------------------------------------------------------------------------------
1 | import { attachEvents } from '@src/ui/utils/attachEvents';
2 | import { useEffect } from 'react';
3 |
4 | function initSlider(
5 | element: HTMLElement,
6 | onChange: (state: any, prevState: any) => void,
7 | ) {
8 | if (!element) {
9 | return;
10 | }
11 |
12 | let state = {
13 | hover: false,
14 | seeking: false,
15 | percentage: 0,
16 | };
17 |
18 | const setState = newState => {
19 | const prevState = state;
20 | state = {
21 | ...state,
22 | ...newState,
23 | };
24 | return prevState;
25 | };
26 |
27 | const calcSliderPercentage = (pageX: number): number => {
28 | const scrollX = window.scrollX || window.pageXOffset;
29 |
30 | const bounding = element.getBoundingClientRect();
31 | let percentage = (pageX - (bounding.left + scrollX)) / bounding.width;
32 | percentage = Math.min(Math.max(percentage, 0), 1);
33 |
34 | return percentage;
35 | };
36 |
37 | const onMouseEnter = () => {
38 | const prevState = setState({ hover: true });
39 | onChange(state, prevState);
40 | };
41 |
42 | const onMouseLeave = () => {
43 | const prevState = setState({ hover: false });
44 | onChange(state, prevState);
45 | };
46 |
47 | const onMouseDown = event => {
48 | event.preventDefault();
49 |
50 | const prevState = setState({
51 | seeking: true,
52 | percentage: calcSliderPercentage(event.pageX),
53 | });
54 |
55 | onChange(state, prevState);
56 | };
57 |
58 | const onWindowMouseMove = event => {
59 | if (state.hover || state.seeking) {
60 | const prevState = setState({
61 | percentage: calcSliderPercentage(event.pageX),
62 | });
63 | onChange(state, prevState);
64 | }
65 | };
66 |
67 | const onWindowMouseUp = () => {
68 | if (state.seeking) {
69 | const prevState = setState({
70 | seeking: false,
71 | });
72 | onChange(state, prevState);
73 | }
74 | };
75 |
76 | const onTouchStart = event => {
77 | event.preventDefault();
78 |
79 | if (event.touches.length) {
80 | const prevState = setState({
81 | hover: true,
82 | seeking: true,
83 | percentage: calcSliderPercentage(event.touches[0].pageX),
84 | });
85 |
86 | onChange(state, prevState);
87 | }
88 | };
89 |
90 | const onWindowTouchMove = event => {
91 | if (event.touches.length) {
92 | const prevState = setState({
93 | percentage: calcSliderPercentage(event.touches[0].pageX),
94 | });
95 |
96 | onChange(state, prevState);
97 | }
98 | };
99 |
100 | const onWindowTouchEnd = () => {
101 | if (state.seeking) {
102 | const prevState = setState({
103 | hover: false,
104 | seeking: false,
105 | });
106 |
107 | onChange(state, prevState);
108 | }
109 | };
110 |
111 | const removeEvents = attachEvents([
112 | {
113 | element,
114 | events: ['mouseenter'],
115 | callback: onMouseEnter,
116 | },
117 | {
118 | element,
119 | events: ['mouseleave'],
120 | callback: onMouseLeave,
121 | },
122 | {
123 | element,
124 | events: ['mousedown'],
125 | callback: onMouseDown,
126 | },
127 | {
128 | element,
129 | events: ['touchstart'],
130 | callback: onTouchStart,
131 | passive: false,
132 | },
133 | {
134 | element: window as any,
135 | events: ['mousemove'],
136 | callback: onWindowMouseMove,
137 | },
138 | {
139 | element: window as any,
140 | events: ['touchmove'],
141 | callback: onWindowTouchMove,
142 | passive: false,
143 | },
144 | {
145 | element: window as any,
146 | events: ['mouseup'],
147 | callback: onWindowMouseUp,
148 | },
149 | {
150 | element: window as any,
151 | events: ['touchend'],
152 | callback: onWindowTouchEnd,
153 | passive: false,
154 | },
155 | ]);
156 |
157 | return () => removeEvents();
158 | }
159 |
160 | export function useSlider(element: HTMLElement, setSeekbarState) {
161 | useEffect(() => initSlider(element, setSeekbarState), [element]);
162 | }
163 |
--------------------------------------------------------------------------------
/src/ui/withState.tsx:
--------------------------------------------------------------------------------
1 | import { StateContext } from '@src/ui/State';
2 | import { IInfo } from '@src/ui/types';
3 | import React, { memo } from 'react';
4 |
5 | export function withState(WrappedComponent, mapProps = null) {
6 | const MemoizedWrappedComponent = memo(WrappedComponent);
7 |
8 | return class extends React.PureComponent {
9 | public render() {
10 | return (
11 |
12 | {(info: IInfo) => {
13 | if (mapProps) {
14 | info = mapProps(info, this.props);
15 | } // This is temporary, once all components are integrated with mapProps, remove.
16 | return ;
17 | }}
18 |
19 | );
20 | }
21 | };
22 | }
23 |
--------------------------------------------------------------------------------
/src/utils/defineProperty.ts:
--------------------------------------------------------------------------------
1 | export const createFunctionFn = target => (key, value) => {
2 | Object.defineProperty(target, key, {
3 | configurable: false,
4 | enumerable: true,
5 | writable: false,
6 | value,
7 | });
8 | };
9 |
--------------------------------------------------------------------------------
/src/utils/deprecate.ts:
--------------------------------------------------------------------------------
1 | export function deprecate(str) {
2 | console.warn(`[Deprecation] ${str}`);
3 | }
4 |
--------------------------------------------------------------------------------
/src/utils/dom.ts:
--------------------------------------------------------------------------------
1 | export function createElement(
2 | tag: string,
3 | style?: any,
4 | attributes?: any,
5 | ): T {
6 | const element: HTMLElement = document.createElement(tag);
7 |
8 | applyStyle(element, style);
9 | applyAttributes(element, attributes);
10 |
11 | return (element as unknown) as T;
12 | }
13 |
14 | export function applyStyle(element: HTMLElement, style?: any) {
15 | if (style) {
16 | Object.entries(style).forEach(([key, value]) => {
17 | element.style[key] = value;
18 | });
19 | }
20 | }
21 |
22 | export function applyAttributes(element: HTMLElement, attributes?: any) {
23 | if (attributes) {
24 | Object.entries(attributes).forEach(([key, value]) => {
25 | element.setAttribute(key, value as string);
26 | });
27 | }
28 | }
29 |
30 | export function insertAfter(node: Node, ref: Node) {
31 | ref.parentNode.insertBefore(node, ref.nextSibling);
32 | }
33 |
--------------------------------------------------------------------------------
/src/utils/getDrmSupport.ts:
--------------------------------------------------------------------------------
1 | const keySystems = [
2 | ['widevine', 'com.widevine.alpha'],
3 |
4 | ['playready', 'com.microsoft.playready'],
5 | ['playready', 'com.youtube.playready'],
6 |
7 | ['clearkey', 'webkit-org.w3.clearkey'],
8 | ['clearkey', 'org.w3.clearkey'],
9 |
10 | ['primetime', 'com.adobe.primetime'],
11 | ['primetime', 'com.adobe.access'],
12 |
13 | ['fairplay', 'com.apple.fairplay'],
14 | ];
15 |
16 | export const getDrmSupport = async () => {
17 | const video = document.createElement('video');
18 |
19 | if (video.mediaKeys) {
20 | return false;
21 | }
22 |
23 | const isKeySystemSupported = async keySystem => {
24 | if (
25 | !window.navigator.requestMediaKeySystemAccess ||
26 | typeof window.navigator.requestMediaKeySystemAccess !== 'function'
27 | ) {
28 | return false;
29 | }
30 |
31 | try {
32 | await window.navigator.requestMediaKeySystemAccess(keySystem, [
33 | {
34 | initDataTypes: ['cenc'],
35 | videoCapabilities: [
36 | {
37 | contentType: 'video/mp4;codecs="avc1.42E01E"',
38 | robustness: 'SW_SECURE_CRYPTO',
39 | },
40 | ],
41 | },
42 | ]);
43 |
44 | return true;
45 | } catch (error) {
46 | return false;
47 | }
48 | };
49 |
50 | const drmSupport = {};
51 | const keySystemsSupported = [];
52 |
53 | await Promise.all(
54 | keySystems.map(async ([drm, keySystem]) => {
55 | const supported = await isKeySystemSupported(keySystem);
56 |
57 | if (supported) {
58 | keySystemsSupported.push(keySystem);
59 |
60 | if (!drmSupport[drm]) {
61 | drmSupport[drm] = true;
62 | }
63 | }
64 | }),
65 | );
66 |
67 | return {
68 | drmSupport,
69 | keySystemsSupported,
70 | };
71 | };
72 |
--------------------------------------------------------------------------------
/src/utils/getEnv.ts:
--------------------------------------------------------------------------------
1 | import { IEnv, Config } from '@src/types';
2 | import canAutoplayLib from 'can-autoplay';
3 |
4 | export async function getEnv(config: Config): Promise {
5 | const userAgent: string = navigator.userAgent;
6 |
7 | const canAutoplay: boolean = (await canAutoplayLib.video({
8 | muted: config.volume === 0,
9 | })).result;
10 |
11 | const isSafari: boolean =
12 | /safari/i.test(userAgent) && userAgent.indexOf('Chrome') === -1;
13 |
14 | const isEdge: boolean = /edge/i.test(userAgent);
15 |
16 | const isIE: boolean =
17 | Boolean((window as any).ActiveXObject) ||
18 | /trident.*rv:1\d/i.test(userAgent);
19 |
20 | const isChrome: boolean = /chrome/i.test(userAgent) && !isEdge;
21 |
22 | const isMobile: boolean = /Android|webOS|iPhone|iPad|iPod|BlackBerry|Windows Phone|IEMobile|Opera Mini/i.test(
23 | userAgent,
24 | );
25 |
26 | const isIOS: boolean = /iPad|iPhone|iPod/i.test(userAgent);
27 |
28 | const isFacebook: boolean =
29 | /FBAN/i.test(userAgent) && /FBAV/i.test(userAgent);
30 |
31 | return {
32 | isSafari,
33 | isEdge,
34 | isIE,
35 | isChrome,
36 | isMobile,
37 | isIOS,
38 | isFacebook,
39 | canAutoplay,
40 | };
41 | }
42 |
--------------------------------------------------------------------------------
/src/utils/log.ts:
--------------------------------------------------------------------------------
1 | let lastTime: number;
2 |
3 | let isEnabled: boolean = false;
4 |
5 | /**
6 | * Generate a fixed color for a specific string.
7 | * @param {string} str Input string
8 | */
9 | function stringToColor(str: string) {
10 | let hash = 0;
11 |
12 | if (str.length === 0) {
13 | return hash;
14 | }
15 |
16 | for (let i = 0; i < str.length; i++) {
17 | hash = str.charCodeAt(i) + ((hash << 5) - hash);
18 | hash = hash & hash;
19 | }
20 |
21 | let color = '#';
22 | for (let i = 0; i < 3; i++) {
23 | const value = (hash >> (i * 8)) & 255;
24 | color += ('00' + value.toString(16)).substr(-2);
25 | }
26 |
27 | return color;
28 | }
29 |
30 | /**
31 | * Enable or disable logs.
32 | * @param {boolean} consoleLogsEnabled Enabled or not
33 | */
34 | export function setConsoleLogs(consoleLogsEnabled: boolean) {
35 | isEnabled = consoleLogsEnabled;
36 | }
37 |
38 | /**
39 | * Creates a logger function for a specific namespace.
40 | * @param {string} namespace Namespace
41 | */
42 | export function log(namespace: string) {
43 | const color = stringToColor(namespace);
44 |
45 | return (first: any, ...args) => {
46 | if (!isEnabled) {
47 | return;
48 | }
49 |
50 | if (lastTime === undefined) {
51 | lastTime = performance.now();
52 | }
53 |
54 | const colorArgs = [`color: ${color}`, 'color: #333333', `color: ${color}`];
55 |
56 | const diff = Math.trunc(performance.now() - lastTime);
57 | lastTime = performance.now();
58 |
59 | console.log(`%c${namespace}%c ${first} %c${diff}ms`, ...colorArgs, ...args);
60 | };
61 | }
62 |
--------------------------------------------------------------------------------
/src/utils/storage.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * The name of the localStorage key.
3 | * @type {string}
4 | */
5 | const prefix: string = 'indigo';
6 |
7 | /**
8 | * Store a key value pair locally.
9 | * @param {string} key The key
10 | * @param {any} value Any serializable value
11 | */
12 | function setLocalStorage(key: string, value: any) {
13 | try {
14 | (window as any).localStorage[`${prefix}.${key}`] = value;
15 | } catch (error) {}
16 | }
17 |
18 | /**
19 | * Get value by key locally
20 | * @param {string} key The key
21 | */
22 | function getLocalStorage(key: string, defaultValue: any) {
23 | try {
24 | const value = (window as any).localStorage[`${prefix}.${key}`];
25 | return value === undefined ? defaultValue : value;
26 | } catch (error) {
27 | return defaultValue;
28 | }
29 | }
30 |
31 | export const storage = {
32 | set: setLocalStorage,
33 | get: getLocalStorage,
34 | };
35 |
--------------------------------------------------------------------------------
/src/utils/webpack.ts:
--------------------------------------------------------------------------------
1 | export function resolveScriptPath(scriptName) {
2 | const scripts = document.getElementsByTagName('script');
3 | for (let i = 0; i < scripts.length; i++) {
4 | const src = scripts[i].src;
5 | if (src) {
6 | const index = src.lastIndexOf('/' + scriptName);
7 | if (index >= 0) {
8 | return src.substr(0, index + 1);
9 | }
10 | }
11 | }
12 | return '';
13 | }
14 |
--------------------------------------------------------------------------------
/tests/StateExtension.test.ts:
--------------------------------------------------------------------------------
1 | import { StateExtension } from '../src/extensions/StateExtension/StateExtension';
2 | import { Instance } from './__mocks__/Instance';
3 | import { Events, AdBreakType } from '../src/types';
4 |
5 | /**
6 | * Avoid snapshotting state, we don't want tests to fail
7 | just because we add a state property. Explicitly stating the state value
8 | also makes these tests a lot more readable.
9 | */
10 | let instance;
11 | let state;
12 | beforeEach(() => {
13 | instance = new Instance();
14 | state = new StateExtension(instance);
15 | });
16 |
17 | test('handle ready', () => {
18 | instance.emit(Events.READY);
19 | expect(state.getState().ready).toBe(true);
20 | expect(state.getState().waitingForUser).toBe(true);
21 | expect(instance.calledEvents).toContain(Events.STATE_READY);
22 | });
23 |
24 | test('handle play content', () => {
25 | instance.emit(Events.READY);
26 |
27 | instance.emit(Events.PLAYER_STATE_PLAY);
28 | expect(state.getState().playRequested).toBe(true);
29 | expect(state.getState().playing).toBe(false);
30 | expect(state.getState().paused).toBe(false);
31 | expect(state.getState().videoSessionStarted).toBe(true);
32 | expect(state.getState().waitingForUser).toBe(false);
33 | expect(instance.calledEvents).toContain(Events.STATE_PLAY_REQUESTED);
34 | expect(instance.calledEvents).not.toContain(Events.STATE_PLAYING);
35 |
36 | instance.emit(Events.PLAYER_STATE_PLAYING);
37 | expect(state.getState().playRequested).toBe(true);
38 | expect(state.getState().playing).toBe(true);
39 | expect(state.getState().started).toBe(true);
40 | expect(instance.calledEvents).toContain(Events.STATE_PLAYING);
41 | });
42 |
43 | test('handle pause content', () => {
44 | expect(state.getState().paused).toBe(false);
45 | instance.emit(Events.PLAYER_STATE_PAUSE);
46 | expect(state.getState().paused).toBe(true);
47 | expect(instance.calledEvents).toContain(Events.STATE_PAUSED);
48 | instance.emit(Events.PLAYER_STATE_PLAY);
49 | expect(state.getState().paused).toBe(false);
50 | });
51 |
52 | test('handle buffering', () => {
53 | expect(state.getState().buffering).toBe(false);
54 | instance.emit(Events.PLAYER_STATE_WAITING);
55 | expect(state.getState().buffering).toBe(true);
56 | expect(instance.calledEvents).toContain(Events.STATE_BUFFERING);
57 | instance.emit(Events.PLAYER_STATE_PLAYING);
58 | expect(state.getState().buffering).toBe(false);
59 | });
60 |
61 | test('handle time update', () => {
62 | instance.emit(Events.PLAYER_STATE_TIMEUPDATE, {
63 | currentTime: 20,
64 | });
65 | expect(state.getState().currentTime).toBe(20);
66 | expect(instance.calledEvents).toContain(Events.STATE_CURRENTTIME_CHANGE);
67 | });
68 |
69 | test('handle duration update', () => {
70 | instance.emit(Events.PLAYER_STATE_DURATIONCHANGE, {
71 | duration: 60,
72 | });
73 | expect(state.getState().duration).toBe(60);
74 | expect(instance.calledEvents).toContain(Events.STATE_DURATION_CHANGE);
75 | });
76 |
77 | test('content started without a preroll', () => {
78 | instance.emit(Events.PLAYER_STATE_PLAYING);
79 | expect(state.getState().contentStarted).toBe(true);
80 | });
81 |
--------------------------------------------------------------------------------
/tests/__mocks__/Instance.ts:
--------------------------------------------------------------------------------
1 | import { IInstance } from '../../src/types';
2 | import { any } from 'prop-types';
3 |
4 | /**
5 | config: Config;
6 | container: HTMLElement;
7 | playerContainer: HTMLElement;
8 | uiContainer: HTMLElement;
9 | adsContainer: HTMLElement;
10 |
11 | env: IEnv;
12 | controller: IController;
13 | player: IPlayer;
14 | media: IMedia;
15 | format: Format;
16 | extensions: IModule[];
17 |
18 | storage: any; // TODO: Proper type
19 | log(namespace: string): LogFunction;
20 |
21 | // Methods
22 | play();
23 | pause();
24 | seekTo(time: number);
25 | setVolume(volume: number);
26 | selectTrack(track: ITrack);
27 | selectAudioLanguage(language: string);
28 | setPlaybackRate(playbackRate: number);
29 | destroy();
30 |
31 | on(name: string, callback: EventCallback);
32 | once(name: string, callback: EventCallback);
33 | removeListener(name: string, callback: EventCallback);
34 | emit(name: string, eventData?: IEventData);
35 |
36 | setError(error: IPlayerError);
37 | canAutoplay(): boolean;
38 |
39 | getModule(name: string): IModule;
40 | getStats(): any;
41 | */
42 |
43 | export class Instance implements IInstance {
44 | config = {} as any;
45 | container = null as any;
46 | playerContainer = null as any;
47 | uiContainer = null as any;
48 | adsContainer = null as any;
49 | env = null as any;
50 | controller = null as any;
51 | player = null as any;
52 | media = null as any;
53 | format = null as any;
54 | extensions = [];
55 | storage = null as any;
56 | log = () => () => {};
57 |
58 | play() {}
59 | pause() {}
60 | seekTo(time: number) {}
61 | setVolume(volume: number) {}
62 | selectTrack(track: any) {}
63 | selectAudioLanguage(language: string) {}
64 | setPlaybackRate(playbackRate: number) {}
65 | destroy() {}
66 |
67 | setError(error: any) {}
68 | canAutoplay() {
69 | return null;
70 | }
71 |
72 | getModule(name: string) {
73 | return null;
74 | }
75 | getStats() {
76 | return null;
77 | }
78 |
79 | events = {};
80 | calledEvents = [];
81 | on(name, callback) {
82 | this.events[name] = callback;
83 | }
84 | once(name, callback) {
85 | const onceCallback = (...args) => {
86 | delete this.events[name];
87 | callback(...args);
88 | };
89 | this.on(name, onceCallback);
90 | }
91 | removeListener(name, callback) {
92 | delete this.events[name];
93 | }
94 |
95 | emit = jest.fn((name, data) => {
96 | this.calledEvents.push(name);
97 | if (this.events[name]) {
98 | this.events[name](data);
99 | }
100 | });
101 | }
102 |
--------------------------------------------------------------------------------
/tests/__snapshots__/hooks.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`can install a hook that exists 1`] = `
4 | Object {
5 | "callback": [MockFunction],
6 | "name": "sayHello",
7 | }
8 | `;
9 |
--------------------------------------------------------------------------------
/tests/hooks.test.ts:
--------------------------------------------------------------------------------
1 | import { Hookable } from '../src/Hooks';
2 |
3 | @Hookable
4 | class Mock {
5 | public sayHello() {}
6 | }
7 |
8 | let mock;
9 |
10 | beforeEach(() => {
11 | mock = new Mock();
12 | });
13 |
14 | test('can access hooks', () => {
15 | expect(mock.hooks).toBeDefined();
16 | });
17 |
18 | test('can access hooks.create', () => {
19 | expect(mock.hooks.create).toBeInstanceOf(Function);
20 | });
21 |
22 | test('can install a hook that exists', () => {
23 | const callback = jest.fn();
24 |
25 | mock.hooks.create('sayHello', callback);
26 |
27 | expect(mock.hooks.hooks.length).toBe(1);
28 | expect(mock.hooks.hooks[0]).toMatchSnapshot();
29 | expect(mock.hooks.hooks[0].callback).toBe(callback);
30 | });
31 |
32 | test('can install multiple hooks', () => {
33 | mock.hooks.create('sayHello', jest.fn());
34 | mock.hooks.create('sayHello', jest.fn());
35 | mock.hooks.create('sayHello', jest.fn());
36 | expect(mock.hooks.hooks.length).toBe(3);
37 | });
38 |
39 | test('installing a hook overwrites the original function', () => {
40 | const orig = mock.sayHello;
41 | mock.hooks.create('sayHello', jest.fn());
42 | expect(mock.sayHello).not.toBe(orig);
43 | });
44 |
45 | test('fails when installing a hook that does not exist', () => {
46 | expect(() => {
47 | mock.hooks.create('screamHello', jest.fn());
48 | }).toThrowError();
49 | });
50 |
51 | test('invokes a hook if the original function is invoked', () => {
52 | const callback = jest.fn();
53 | mock.hooks.create('sayHello', callback);
54 | mock.sayHello();
55 | expect(callback).toHaveBeenCalled();
56 | });
57 |
58 | test('an invoked hook has a next function', () => {
59 | const callback = jest.fn();
60 | mock.hooks.create('sayHello', callback);
61 | mock.sayHello();
62 | expect(callback).toHaveBeenCalledWith(expect.any(Function));
63 | });
64 |
65 | test('an invoked hook includes additional parameters after the next parameter', () => {
66 | const callback = jest.fn();
67 | mock.hooks.create('sayHello', callback);
68 | mock.sayHello(1, 'hello', true);
69 | expect(callback).toHaveBeenCalledWith(expect.any(Function), 1, 'hello', true);
70 | });
71 |
72 | test('calls the orig function after a hook calls next', () => {
73 | const spy = jest.spyOn(mock, 'sayHello');
74 | mock.hooks.create('sayHello', next => next());
75 | mock.sayHello();
76 | expect(spy).toHaveBeenCalled();
77 | });
78 |
79 | test('does not call the orig function after a hook does not call next', () => {
80 | const spy = jest.spyOn(mock, 'sayHello');
81 | mock.hooks.create('sayHello', next => {});
82 | mock.sayHello();
83 | expect(spy).not.toHaveBeenCalled();
84 | });
85 |
86 | test('calls the orig function after multiple hooks call next', () => {
87 | const spy = jest.spyOn(mock, 'sayHello');
88 | mock.hooks.create('sayHello', next => next());
89 | mock.hooks.create('sayHello', next => next());
90 | mock.hooks.create('sayHello', next => next());
91 | mock.sayHello();
92 | expect(spy).toHaveBeenCalled();
93 | });
94 |
95 | test('does not call the orig function after one hook does not call next', () => {
96 | const spy = jest.spyOn(mock, 'sayHello');
97 | mock.hooks.create('sayHello', next => next());
98 | mock.hooks.create('sayHello', next => {});
99 | mock.hooks.create('sayHello', next => next());
100 | mock.sayHello();
101 | expect(spy).not.toHaveBeenCalled();
102 | });
103 |
--------------------------------------------------------------------------------
/tests/verify-i18n.test.ts:
--------------------------------------------------------------------------------
1 | import { translations } from '../src/ui/i18n';
2 | import difference from 'lodash/difference';
3 |
4 | const keys = Object.keys(translations['en-US']);
5 | const languages = Object.keys(translations);
6 |
7 | languages.forEach(lang => {
8 | test(`has equal mappings for ${lang}`, () => {
9 | const diff = difference(keys, Object.keys(translations[lang]));
10 | expect(diff.length).toBe(0);
11 | });
12 | });
13 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "removeComments": false,
5 | "moduleResolution": "node",
6 | "noEmit": true,
7 | "strict": false,
8 | "esModuleInterop": true,
9 | "experimentalDecorators": true,
10 | "jsx": "react",
11 | "baseUrl": ".",
12 | "module": "esnext",
13 | "paths": {
14 | "@src/*": ["src/*"]
15 | },
16 | "lib": ["dom", "es2018"],
17 | "sourceMap": true,
18 | "resolveJsonModule": true
19 | },
20 | "include": [
21 | "./src/**/*"
22 | ],
23 | "awesomeTypescriptLoaderOptions": {
24 | "useBabel": true,
25 | "babelCore": "@babel/core"
26 | }
27 | }
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "defaultSeverity": "error",
3 | "extends": [
4 | "tslint:recommended",
5 | "tslint-config-prettier",
6 | "tslint-react"
7 | ],
8 | "jsRules": {},
9 | "rules": {
10 | "quotemark": [true, "single"],
11 | "object-literal-sort-keys": false,
12 | "no-empty": false,
13 | "interface-over-type-literal": false
14 | },
15 | "rulesDirectory": []
16 | }
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 | const path = require('path');
3 | const { TsConfigPathsPlugin } = require('awesome-typescript-loader');
4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
5 | const HtmlWebpackPlugin = require('html-webpack-plugin');
6 | const TerserPlugin = require('terser-webpack-plugin');
7 | const WebpackShellPlugin = require('webpack-shell-plugin');
8 | const pkg = require('./package.json');
9 |
10 | function createWebpackConfig(build, argv) {
11 | const isProduction = argv.mode === 'production';
12 | const isDevelopment = !isProduction;
13 | let config;
14 |
15 | switch (build) {
16 | case 'player':
17 | config = {
18 | entry: './src/index.ts',
19 | output: {
20 | path: path.resolve(__dirname, 'lib'),
21 | filename: `${pkg.name}.js`,
22 | chunkFilename: '[name].[chunkhash].js',
23 | libraryExport: 'default',
24 | library: 'IndigoPlayer',
25 | libraryTarget: 'umd',
26 | },
27 | module: {
28 | rules: [
29 | {
30 | test: /\.tsx?$/,
31 | use: 'awesome-typescript-loader',
32 | },
33 | {
34 | test: /\.scss$/,
35 | use: ['style-loader', 'css-loader', 'sass-loader'],
36 | },
37 | {
38 | test: /\.(woff(2)?|ttf|eot)(\?v=\d+\.\d+\.\d+)?$/,
39 | use: ['file-loader'],
40 | },
41 | {
42 | test: /\.(png|jp(e*)g)$/,
43 | use: {
44 | loader: 'url-loader',
45 | options: {
46 | limit: 8000,
47 | },
48 | },
49 | },
50 | ],
51 | },
52 | resolve: {
53 | extensions: ['.tsx', '.ts', '.js'],
54 | plugins: [
55 | new TsConfigPathsPlugin(),
56 | ],
57 | },
58 | plugins: [
59 | new webpack.DefinePlugin({
60 | VERSION: JSON.stringify(pkg.version),
61 | }),
62 | new webpack.BannerPlugin(`indigo-player v${pkg.version} - [name] - ${+new Date()}`),
63 | ],
64 | optimization: {
65 | splitChunks: {
66 | chunks: 'async',
67 | cacheGroups: {
68 | vendors: false,
69 | },
70 | },
71 | minimizer: [],
72 | },
73 | };
74 |
75 | if (isProduction) {
76 | config.optimization.minimizer.push(new TerserPlugin({
77 | terserOptions: {
78 | output: {
79 | comments: /indigo-player/i,
80 | },
81 | },
82 | }));
83 | }
84 |
85 | return config;
86 |
87 | case 'theme':
88 | config = {
89 | entry: {
90 | theme: './src/ui/theme/index.scss',
91 | },
92 | output: {
93 | path: path.resolve(__dirname, 'lib'),
94 | },
95 | module: {
96 | rules: [
97 | {
98 | test: /\.scss$/,
99 | use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
100 | },
101 | {
102 | test: /\.(woff(2)?|ttf|eot)(\?v=\d+\.\d+\.\d+)?$/,
103 | use: ['file-loader'],
104 | },
105 | ],
106 | },
107 | resolve: {
108 | extensions: ['.tsx', '.ts', '.js'],
109 | plugins: [
110 | new TsConfigPathsPlugin(),
111 | ],
112 | },
113 | plugins: [
114 | new MiniCssExtractPlugin({
115 | filename: 'indigo-theme.css',
116 | }),
117 | ],
118 | };
119 |
120 | if (isProduction) {
121 | config.plugins.push(new WebpackShellPlugin({
122 | onBuildEnd: [
123 | 'rm ./lib/theme.js',
124 | 'perfectionist ./lib/indigo-theme.css ./lib/indigo-theme.css --indentSize=2',
125 | ],
126 | }));
127 | }
128 |
129 | return config;
130 |
131 | case 'dev':
132 | return {
133 | entry: {
134 | dev: './dev/index.tsx',
135 | },
136 | devtool: 'inline-source-map',
137 | output: {
138 | path: path.resolve(__dirname, 'lib'),
139 | },
140 | module: {
141 | rules: [
142 | {
143 | test: /\.tsx?$/,
144 | use: 'awesome-typescript-loader',
145 | },
146 | {
147 | test: /\.scss$/,
148 | use: ['style-loader', 'css-loader', 'sass-loader'],
149 | },
150 | ],
151 | },
152 | plugins: [
153 | new HtmlWebpackPlugin({
154 | filename: 'index.html',
155 | template: path.resolve(__dirname, 'dev/template.html'),
156 | }),
157 | ],
158 | devServer: {
159 | contentBase: './dev',
160 | },
161 | };
162 |
163 | default:
164 | throw new Error('Specify a build...');
165 | }
166 |
167 | return config;
168 | }
169 |
170 | module.exports = (env, argv) => {
171 | let builds = ['player', 'theme']; // Default builds
172 | if (argv.builds) {
173 | builds = argv.builds.split(',');
174 | }
175 |
176 | return builds.map(build => createWebpackConfig(build, argv));
177 | };
178 |
--------------------------------------------------------------------------------