├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── THIRD-PARTY-LICENSES.txt ├── amazon-ivs-dvr-demo.png ├── architecture.png ├── cdk ├── .gitignore ├── .npmignore ├── Makefile ├── bin │ └── cdk.ts ├── cdk.json ├── config.json ├── lambdas │ ├── getLatestRecordingStartMeta.ts │ ├── saveRecordingStartMeta.ts │ └── utils.ts ├── lib │ ├── utils.ts │ └── vod-rendition-playlist-stack.ts ├── package-lock.json ├── package.json ├── sanitize-output.js └── tsconfig.json ├── docs ├── multi-channel-architecture.png └── multi-channel.md └── web-ui ├── .eslintrc.json ├── .gitignore ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt └── src ├── App.css ├── App.jsx ├── Player ├── Controls │ ├── BackToLiveBtn │ │ ├── BackToLiveBtn.css │ │ ├── BackToLiveBtn.jsx │ │ └── index.js │ ├── Controls.css │ ├── Controls.jsx │ └── index.js ├── FadeInOut │ ├── FadeInOut.jsx │ └── index.js ├── Notification │ ├── Notification.css │ ├── Notification.jsx │ └── index.js ├── Player.css ├── Player.jsx ├── Spinner │ ├── Spinner.css │ ├── Spinner.jsx │ └── index.js ├── StatusPill │ ├── StatusPill.css │ ├── StatusPill.jsx │ └── index.js └── index.js ├── SinglePlayer ├── Controls │ ├── Controls.jsx │ └── index.js ├── Player.jsx ├── StatusPill │ ├── StatusPill.css │ ├── StatusPill.jsx │ └── index.js └── index.js ├── assets └── icons │ ├── backward-60.jsx │ ├── forward-60.jsx │ ├── live.jsx │ ├── pause.jsx │ ├── play.jsx │ ├── recorded.jsx │ ├── spinner.jsx │ └── warning.jsx ├── constants.js ├── contexts ├── Controls │ ├── context.js │ ├── provider.jsx │ └── useControls.js ├── MobileBreakpoint │ ├── context.js │ ├── provider.jsx │ └── useMobileBreakpoint.js ├── Playback │ ├── context.js │ ├── provider.jsx │ ├── usePlayback.js │ └── utils.js └── SinglePlayerPlayback │ ├── context.js │ ├── provider.jsx │ └── usePlayback.js ├── hooks ├── useFirstMountState.js ├── usePlayer.js ├── usePrevious.js ├── useSeekBar.js ├── useSinglePlayer.js ├── useSinglePlayerSeekBar.js └── useStateWithCallback.js ├── index.css ├── index.js ├── utils.js └── workers └── amazon-ivs-service-worker-loader.js /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *master* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | 61 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | software and associated documentation files (the "Software"), to deal in the Software 5 | without restriction, including without limitation the rights to use, copy, modify, 6 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Amazon IVS DVR web demo 2 | 3 | A demo web application intended as an educational tool for demonstrating how you can implement a Live to VOD (DVR) experience using [Amazon IVS](https://aws.amazon.com/ivs/) and the auto-record-to-s3 feature using [Amazon S3](https://aws.amazon.com/s3/). The VOD content is served with [Amazon CloudFront](https://aws.amazon.com/cloudfront/) and [Amazon Lambda@Edge](https://aws.amazon.com/lambda/edge/). 4 | 5 | This demo also uses [AWS Cloud Development Kit](https://aws.amazon.com/cdk/) (AWS CDK v2). 6 | 7 | ![Amazon IVS DVR demo](amazon-ivs-dvr-demo.png) 8 | 9 | **This project is intended for education purposes only and not for production usage.** 10 | 11 | ## Prerequisites 12 | 13 | - AWS CLI ([Installing the AWS CLI version 2](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html)) 14 | - NodeJS ([Installing Node.js](https://nodejs.org/)) 15 | 16 | ## To use and deploy this project 17 | 18 | **IMPORTANT NOTE:** this demo will create and use AWS resources on your AWS account, which will cost money. 19 | 20 | Deploying the CDK stack will: 21 | 22 | - create an Amazon IVS channel 23 | - set up auto-record-to-S3 for that channel 24 | - create Lambda and Lambda@Edge resources to process VOD content 25 | - create a CloudFront distribution to serve the VOD content 26 | 27 | ### Architecture 28 | 29 | ![architecture](architecture.png) 30 |
31 | 32 | ### Configuration 33 | 34 | The `cdk/config.json` file provides two configurable options: 35 | 36 | - `channelType` can be set to `BASIC`, `STANDARD`, `ADVANCED_SD`, or `ADVANCED_HD` 37 | - `allowedOrigins` is a list of origins (domain names) that CloudFront uses as the value for the `Access-Control-Allow-Origin` HTTP response header. You can add your custom domain to this list, or specify `['*']` to allow all origins. 38 | - `insecureRTMPIngest` is a boolean flag that, when set to true, allows the channel to accept RTMP streams without requiring a secure connection. 39 | - `transcodePreset` can only be set for `ADVANCED_HD` and `ADVANCED_SD` channel types, with allowed values `HIGHER_BANDWIDTH_DELIVERY` and `CONSTRAINED_BANDWIDTH_DELIVERY`. For `BASIC` and `STANDARD` channel types, preset should be an empty string (`""`). 40 | 41 | By default, the demo will expect you to deploy the backend prior to running the frontend application, which will create a `cdk_output.json` file containing the CloudFront distribution domain name outputted from the deployment. However, if you wish to run your frontend against a different DVR demo backend, this behavior can be overridden by setting the following environment variable: 42 | 43 | ```shell 44 | REACT_APP_DISTRIBUTION_DOMAIN_NAME= 45 | ``` 46 | 47 | Where `` is the domain name of the CloudFront distribution, such as `d111111abcdef8.cloudfront.net`. If you have chosen to set this environment variable, you may proceed straight to step 2 in the _Deployment_ section below. 48 | 49 | **NOTE:** You can add or modify the allowed origins for CORS requests by modifying the ResponseHeaders policy through the CloudFront console, however this is not advisable and we recommend you make such changes by re-deploying the CDK stack with your changes in the `allowedOrigins` list in the `config.json` file. 50 | 51 | ### Deployment 52 | 53 | 1. To set up the backend, in the `cdk` directory, run: 54 | 55 | ```shell 56 | make app 57 | ``` 58 | 59 | **NOTE:** this demo uses [AWS Lambda@Edge](https://aws.amazon.com/lambda/edge/), which is currently only available in the US East, N. Virginia (us-east-1) region. To comply with this requirement, this demo is configured to deploy to the us-east-1 region of the account specified in your AWS CLI profile. 60 | 61 | _In the `cdk` directory, run `make help` to see a list of available targets and other configuration options._ 62 | 63 | The script will give you 2 important pieces of information: 64 | 65 | - `DVRdemoStack.ingestServer`, the ingest server address to use in your broadcasting software ([learn how to stream to Amazon IVS](https://aws.amazon.com/blogs/media/setting-up-for-streaming-with-amazon-ivs/)) 66 | - `DVRdemoStack.streamKey`, the stream key for your newly created Amazon IVS channel 67 |
68 | 69 | At this point, you may configure your broadcasting software and begin streaming before moving on to the next step. 70 | 71 | 2. Go to the `web-ui` directory and run the following commands to start the React frontend host: 72 | 73 | ```shell 74 | npm install 75 | npm start 76 | ``` 77 | 78 | Once you go live, you will be able to see your live video stream on the hosted frontend. About 50 seconds since you started broadcasting, the VOD will become available and you will be able to seamlessly scrub between Live and VOD in the player. 79 | 80 | ## Frontend Player Options 81 | 82 | The app supports two options for running the frontend: a dual-player option and a single-player option. 83 | 84 | ### Dual-Player Option (Default) 85 | Available at the `/` route, this option switches between two player instances - one for live playback and one for VOD playback. When a viewer is watching content on one player, the other player remains hidden but continues to buffer and play content in the background. This ensures immediate content availability when switching modes, providing the smoothest possible user experience with no interruptions during transitions. 86 | 87 | While this approach provides optimal playback continuity, it will: 88 | - Incur higher costs due to concurrent streaming of both live and VOD content 89 | - Require more system resources 90 | - Increase data usage 91 | 92 | ### Single-Player Option 93 | Available at the `/alt` route, this option uses a single player instance and loads the appropriate playback URL when switching between live and VOD modes. The player reinitializes with the new content when transitioning between modes, which maintains efficient resource usage but may result in brief loading periods. 94 | 95 | This approach provides a balanced experience that: 96 | - Reduces operational costs through single-stream playback 97 | - Minimizes system resource consumption 98 | - Optimizes data usage efficiency 99 | 100 | Choose the option that best balances your needs for user experience and resource optimization. 101 | 102 | ## Backend Specification 103 | 104 | ``` 105 | GET https:///recording-started-latest.json 106 | ``` 107 | 108 | Response Schema: 109 | 110 | ```ts 111 | { 112 | isChannelLive: boolean, 113 | livePlaybackUrl?: string, 114 | masterKey?: string, 115 | playlistDuration: number | null, 116 | recordingStartedAt?: string, 117 | } 118 | ``` 119 | 120 | - `isChannelLive` is an indicator of the current status of the IVS channel 121 | - `livePlaybackUrl` is the playback URL for the livestream. 122 | - `masterKey` is the S3 key path for the `byte-range-multivariant.m3u8` file of the VOD playback 123 | - `playlistDuration` is the duration of the latest VOD playlist (used only on iOS mobile browsers) 124 | - `recordingStartedAt` is the date time that the stream recording started at, in ISO 8601 format 125 | 126 | The response object returned by this endpoint will only contain the relevant properties based on the current state of the channel. 127 | 128 | ## Backend Teardown 129 | 130 | To avoid unexpected charges to your account, be sure to destroy your CDK stack when you are finished: 131 | 132 | 1. If you are currently streaming, stop the stream 133 | 134 | 2. In the `cdk` directory, run: 135 | 136 | ```shell 137 | make destroy 138 | ``` 139 | 140 | After running this command, you will notice that the stack deletion process will fail. This is expected, as only the _associations_ between the Lambda@Edge functions and the CloudFront distribution are removed. 141 | 142 | The remaining Lambda@Edge function replicas will typically be automatically deleted by CloudFront within a few hours, at which point you will be able to run `make destroy` once again to complete deleting the stack, along with the Lambda functions that failed to delete from earlier. 143 | 144 | Alternatively, you may choose to manually delete the CloudFormation stack from the AWS console while retaining the Lambda@Edge functions for you to delete at a later time, allowing you to immediately re-deploy the stack if needed. 145 | 146 | ## Limitations and Known Issues 147 | 148 | - Full functionality for iOS mobile browsers is limited due to player-related constraints. As a consequence, on iOS devices only, the user _may not_ be able to seek within the last 30 seconds of the VOD content. 149 | - This demo uses Lambda@Edge, which is currently only supported in the us-east-1 (N.Virginia) region. 150 | 151 | ## About Amazon IVS 152 | 153 | Amazon Interactive Video Service (Amazon IVS) is a managed live streaming solution that is quick and easy to set up, and ideal for creating interactive video experiences. [Learn more](https://aws.amazon.com/ivs/). 154 | 155 | - [Amazon IVS docs](https://docs.aws.amazon.com/ivs/) 156 | - [User Guide](https://docs.aws.amazon.com/ivs/latest/userguide/) 157 | - [API Reference](https://docs.aws.amazon.com/ivs/latest/APIReference/) 158 | - [Setting Up for Streaming with Amazon Interactive Video Service](https://aws.amazon.com/blogs/media/setting-up-for-streaming-with-amazon-ivs/) 159 | - [Learn more about Amazon IVS on IVS.rocks](https://ivs.rocks/) 160 | - [View more demos like this](https://ivs.rocks/examples) 161 | 162 | ## Security 163 | 164 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 165 | 166 | ## License 167 | 168 | This library is licensed under the MIT-0 License. See the LICENSE file. 169 | -------------------------------------------------------------------------------- /THIRD-PARTY-LICENSES.txt: -------------------------------------------------------------------------------- 1 | The following assets are licensed under Apache 2.0 2 | ** web-ui/src/assets/icons/warning.jsx 3 | 4 | The following open-source libraries are used in this application: 5 | ** React; version 17.0.2 -- https://github.com/facebook/react 6 | 7 | MIT License 8 | 9 | Copyright (c) Facebook, Inc. and its affiliates. 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a copy 12 | of this software and associated documentation files (the "Software"), to deal 13 | in the Software without restriction, including without limitation the rights 14 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | copies of the Software, and to permit persons to whom the Software is 16 | furnished to do so, subject to the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included in all 19 | copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 27 | SOFTWARE. -------------------------------------------------------------------------------- /amazon-ivs-dvr-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-dvr-web-demo/a2682c616b8f35f729d78903123b62fb77f607d0/amazon-ivs-dvr-demo.png -------------------------------------------------------------------------------- /architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-dvr-web-demo/a2682c616b8f35f729d78903123b62fb77f607d0/architecture.png -------------------------------------------------------------------------------- /cdk/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | !sanitize-output.js 4 | *.d.ts 5 | node_modules 6 | 7 | # temp 8 | temp_out.json 9 | 10 | # CDK asset staging directory 11 | .cdk.staging 12 | cdk.out 13 | -------------------------------------------------------------------------------- /cdk/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /cdk/Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | .PHONY: help app install bootstrap deploy destroy clean 4 | 5 | AWS_PROFILE_FLAG = --profile $(AWS_PROFILE) 6 | CDK_OPTIONS := $(if $(AWS_PROFILE),$(AWS_PROFILE_FLAG)) 7 | STACK ?= DVRdemoStack 8 | 9 | help: ## Shows this help message 10 | @echo "\n$$(tput bold)Available Rules:$$(tput sgr0)\n" 11 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST)\ 12 | | sort \ 13 | | awk \ 14 | 'BEGIN {FS = ":.*?## "}; \ 15 | {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' 16 | @echo "\n$$(tput bold)IMPORTANT!$$(tput sgr0)\n" 17 | @echo "1. If AWS_PROFILE is not exported as an environment variable or provided through the command line, then the default AWS profile is used. \n" | fold -s 18 | @echo " Option 1: export AWS_PROFILE=user1\n" 19 | @echo " Option 2: make AWS_PROFILE=user1\n" 20 | @echo "2. Destroying the stack will result in a partial deletion of the stack resources as only the associations between the Lambda@Edge functions and CloudFront will be deleted, along with the CloudFront distribution. The remaining Lambda@Edge replicas will typically be automatically deleted by CloudFront within a few hours, at which point you will be able to run the 'destroy' target again to delete the stack and remaining Lambda functions or manually delete them from the console." | fold -s 21 | 22 | app: install bootstrap deploy ## Installs NPM dependencies, bootstraps, and deploys the stack 23 | 24 | install: ## Installs NPM dependencies 25 | @echo "Installing NPM dependencies..." 26 | npm install 27 | 28 | bootstrap: ## Deploys the CDK Toolkit staging stack 29 | @echo "Bootstrapping..." 30 | npx cdk bootstrap $(CDK_OPTIONS) 31 | 32 | deploy: ## Deploys the stack and sanitizes the stack output 33 | @echo "Deploying $(STACK)..." 34 | npx cdk deploy $(STACK) --outputs-file temp_out.json $(CDK_OPTIONS) 35 | @echo "Sanitizing CDK output..." 36 | node sanitize-output 37 | rm temp_out.json 38 | @echo "\n$$(tput bold) ✅ $(STACK) Deployed Successfully $$(tput sgr0)\n" 39 | 40 | destroy: clean ## Destroys the stack and cleans up 41 | @echo "Destroying $(STACK)..." 42 | npx cdk destroy $(STACK) $(CDK_OPTIONS) 43 | 44 | clean: ## Deletes the cloud assembly directory (cdk.out) 45 | @echo "Cleaning..." 46 | rm -r cdk.out 47 | -------------------------------------------------------------------------------- /cdk/bin/cdk.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import { App } from 'aws-cdk-lib'; 4 | 5 | import { DVRdemoStack } from '../lib/vod-rendition-playlist-stack'; 6 | import { region } from '../config.json'; 7 | 8 | new DVRdemoStack(new App(), 'DVRdemoStack', { 9 | env: { 10 | account: process.env.CDK_DEFAULT_ACCOUNT, 11 | region // Note: Lambda@Edge is currently only supported in the us-east-1 region 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /cdk/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/cdk.ts", 3 | "watch": { 4 | "include": ["**"], 5 | "exclude": [ 6 | "README.md", 7 | "cdk*.json", 8 | "**/*.d.ts", 9 | "**/*.js", 10 | "tsconfig.json", 11 | "package*.json", 12 | "yarn.lock", 13 | "node_modules", 14 | "test" 15 | ] 16 | }, 17 | "context": { 18 | "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, 19 | "@aws-cdk/core:stackRelativeExports": true, 20 | "@aws-cdk/aws-rds:lowercaseDbIdentifier": true, 21 | "@aws-cdk/aws-lambda:recognizeVersionProps": true, 22 | "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true, 23 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 24 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 25 | "@aws-cdk/core:target-partitions": ["aws", "aws-cn"] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /cdk/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "region": "us-east-1", 3 | "channelType": "BASIC", 4 | "reconnectWindowSeconds": 60, 5 | "allowedOrigins": ["http://localhost:3000"], 6 | "insecureRTMPIngest": false, 7 | "transcodePreset": "" 8 | } 9 | -------------------------------------------------------------------------------- /cdk/lambdas/getLatestRecordingStartMeta.ts: -------------------------------------------------------------------------------- 1 | import { CloudFrontRequestEvent } from 'aws-lambda'; 2 | import { StreamState } from '@aws-sdk/client-ivs'; 3 | 4 | import { 5 | getS3Object, 6 | getActiveStream, 7 | createResponse, 8 | isS3Error 9 | } from './utils'; 10 | 11 | interface RecordingStartedMetadata { 12 | isChannelLive: boolean; 13 | livePlaybackUrl?: string; 14 | masterKey?: string; 15 | recordingStartedAt?: string; 16 | playlistDuration?: number; 17 | } 18 | 19 | /** 20 | * Triggered on Origin Requests to retrieve the recording-started-latest.json metadata file 21 | * from the VOD S3 bucket. A response is generated to contain only the required fields from 22 | * the metadata file, along with the live playbackUrl and an indicator for whether or not 23 | * the channel has an active (live) stream. 24 | * 25 | * Responses are sent with a no-cache Cache-Control directive to ensure that the browser does 26 | * not cache the response object (note that this Lambda(at)Edge function is already configured 27 | * with the managed CachingDisabled cache policy to ensure that CloudFront does not cache this 28 | * object either). 29 | * 30 | * If S3 throws a NoSuchKey (404) error, this is likely because we requested the metadata file before 31 | * the recording-started.json file was created by Amazon IVS and saved into the S3 VOD bucket by the S3 Event 32 | * Notification Lambda function (saveRecordingStartMeta). Therefore, instead of responding with the 404 33 | * status code, we send a 200 status code and an empty body, which will be handled accordingly on the 34 | * client-side. This prevents any unnecessary errors from being thrown on the client-side from this type 35 | * of event, which we know will occur frequently when the channel first starts recording. 36 | * 37 | * @param event CloudFront Origin Request event 38 | */ 39 | const getLatestRecordingStartMeta = async (event: CloudFrontRequestEvent) => { 40 | const { origin, uri } = event.Records[0].cf.request; 41 | const customHeaders = origin!.s3!.customHeaders; 42 | const channelArn = customHeaders['channel-arn'][0].value || ''; 43 | const bucketName = customHeaders['vod-record-bucket-name'][0].value || ''; 44 | const key = uri.slice(1); 45 | let response; 46 | 47 | try { 48 | // Get recording-started-latest.json file 49 | const { body: jsonBody } = await getS3Object(key, bucketName); 50 | const { 51 | media: { 52 | hls: { path, byte_range_playlist, renditions } 53 | }, 54 | recording_started_at: recordingStartedAt, 55 | streamId: recordedStreamId 56 | } = JSON.parse(jsonBody); 57 | const activeStream = await getActiveStream(channelArn); 58 | const { 59 | playbackUrl: livePlaybackUrl, 60 | state: channelState, 61 | streamId: activeStreamId 62 | } = activeStream || {}; 63 | const isChannelLive = channelState === StreamState.StreamLive; 64 | 65 | // Build response body 66 | const recordingStartedMetadata: RecordingStartedMetadata = { 67 | isChannelLive, 68 | livePlaybackUrl: isChannelLive ? livePlaybackUrl : '' 69 | }; 70 | 71 | // Only return VOD metadata if the recorded stream metadata is for the currently live stream (if one exists) 72 | if (recordedStreamId === activeStreamId) { 73 | recordingStartedMetadata.masterKey = `${path}/${byte_range_playlist}`; 74 | recordingStartedMetadata.recordingStartedAt = recordingStartedAt; 75 | 76 | try { 77 | const [highestRendition] = renditions; 78 | const renditionKey = `${path}/${highestRendition.path}/${highestRendition.playlist}`; 79 | const { body: textBody } = await getS3Object(renditionKey, bucketName); 80 | const regExp = /EXT-X-TWITCH-TOTAL-SECS:(.+)$/m; 81 | const match = textBody.match(regExp)?.[1]; 82 | 83 | if (match) { 84 | recordingStartedMetadata.playlistDuration = parseInt(match); 85 | } 86 | } catch (error) { 87 | /** 88 | * Error out silently: 89 | * playlistDuration is only required for iOS devices when working with an open VOD playlist, other devices will get this value from the player instance on the FE. 90 | */ 91 | } 92 | } 93 | 94 | response = createResponse(200, { 95 | body: JSON.stringify(recordingStartedMetadata), 96 | contentType: 'application/json', 97 | maxAge: 1 98 | }); 99 | } catch (error) { 100 | if (isS3Error(error) && error.Code === 'NoSuchKey') { 101 | response = createResponse(200, { maxAge: 1, body: JSON.stringify(null) }); 102 | } else { 103 | console.error(error); 104 | response = createResponse(500, { maxAge: 1 }); 105 | } 106 | } 107 | 108 | return response; 109 | }; 110 | 111 | export const handler = getLatestRecordingStartMeta; 112 | -------------------------------------------------------------------------------- /cdk/lambdas/saveRecordingStartMeta.ts: -------------------------------------------------------------------------------- 1 | import { S3Event } from 'aws-lambda'; 2 | 3 | import { getS3Object, putS3Object, getActiveStream } from './utils'; 4 | 5 | /** 6 | * Receives the incoming s3:ObjectCreated:Put event containing the key and VOD bucket details 7 | * of the latest recording-start.json metadata file. The file is fetched from the bucket and 8 | * modified to include the full S3 recording prefix in the media/hls path, before being saved 9 | * at the top level of the same VOD bucket under the filename "recording-started-latest.json" 10 | * 11 | * @param event s3:ObjectCreated:Put event notification 12 | */ 13 | const saveRecordingStartMeta = async (event: S3Event) => { 14 | const { 15 | object: { key }, 16 | bucket: { name: bucketName } 17 | } = event.Records[0].s3; 18 | const s3RecordingKeyPrefix = key.split('/events')[0]; 19 | 20 | try { 21 | const { body } = await getS3Object(key, bucketName); 22 | const metadata = JSON.parse(body); 23 | const relativePath = metadata.media.hls.path; 24 | const absolutePath = `${s3RecordingKeyPrefix}/${relativePath}`; 25 | metadata.media.hls.path = absolutePath; 26 | 27 | // add the active stream id to the metadata response 28 | const stream = await getActiveStream(metadata.channel_arn); 29 | metadata.streamId = stream?.streamId || ''; 30 | 31 | // saves the latest recording-start.json metadata file at the top-level of the VOD S3 bucket 32 | await putS3Object( 33 | 'recording-started-latest.json', 34 | bucketName, 35 | JSON.stringify(metadata) 36 | ); 37 | } catch (error) { 38 | console.error(error); 39 | throw error; 40 | } 41 | }; 42 | 43 | export const handler = saveRecordingStartMeta; 44 | -------------------------------------------------------------------------------- /cdk/lambdas/utils.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from 'stream'; 2 | 3 | import { CloudFrontResultResponse } from 'aws-lambda'; 4 | import { 5 | S3Client, 6 | GetObjectCommand, 7 | PutObjectCommand, 8 | _Error as S3Error 9 | } from '@aws-sdk/client-s3'; 10 | import { 11 | IvsClient, 12 | GetStreamCommand, 13 | ChannelNotBroadcasting, 14 | StreamState 15 | } from '@aws-sdk/client-ivs'; 16 | 17 | import { region } from '../config.json'; 18 | 19 | const s3 = new S3Client({ region }); 20 | const ivs = new IvsClient({ region }); 21 | 22 | const streamToString = async (stream: Readable) => { 23 | let result = ''; 24 | 25 | for await (const chunk of stream) { 26 | result += chunk; 27 | } 28 | 29 | return result; 30 | }; 31 | 32 | interface ResponseOptions { 33 | body?: string; 34 | contentType?: string; 35 | maxAge?: number; 36 | noCache?: boolean; 37 | } 38 | 39 | export const createResponse = ( 40 | status: number, 41 | options: ResponseOptions = {} 42 | ) => { 43 | const { 44 | body = '', 45 | contentType = 'text/plain', 46 | maxAge = 0, 47 | noCache = false 48 | } = options; 49 | 50 | const response: CloudFrontResultResponse = { 51 | body, 52 | bodyEncoding: 'text', 53 | headers: { 'content-type': [{ value: `${contentType}` }] }, 54 | status: status.toString() 55 | }; 56 | 57 | if (noCache) { 58 | response.headers!['cache-control'] = [{ value: `no-cache` }]; 59 | } else if (maxAge && maxAge >= 0) { 60 | response.headers!['cache-control'] = [{ value: `max-age=${maxAge}` }]; 61 | } 62 | 63 | return response; 64 | }; 65 | 66 | export const isS3Error = (error: any): error is S3Error => { 67 | return error && error.Code && error.Key; 68 | }; 69 | 70 | export const getS3Object = async (key: string, bucket: string) => { 71 | const params = { Key: key, Bucket: bucket }; 72 | const command = new GetObjectCommand(params); 73 | const { Body, LastModified } = await s3.send(command); 74 | const body = await streamToString(Body as Readable); 75 | 76 | return { body, LastModified }; 77 | }; 78 | 79 | export const putS3Object = async ( 80 | key: string, 81 | bucket: string, 82 | body: string 83 | ) => { 84 | const params = { 85 | Body: body, 86 | Bucket: bucket, 87 | Key: key, 88 | ContentType: 'application/json' 89 | }; 90 | const command = new PutObjectCommand(params); 91 | 92 | await s3.send(command); 93 | }; 94 | 95 | export const getActiveStream = async (channelArn: string) => { 96 | const params = { channelArn }; 97 | const command = new GetStreamCommand(params); 98 | 99 | try { 100 | const { stream } = await ivs.send(command); 101 | return stream || null; 102 | } catch (error) { 103 | if (error instanceof ChannelNotBroadcasting) { 104 | return { state: StreamState.StreamOffline }; 105 | } else throw error; 106 | } 107 | }; 108 | -------------------------------------------------------------------------------- /cdk/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | 3 | export const getLambdaEntryPath = (functionName: string) => 4 | join(__dirname, '..', 'lambdas', `${functionName}.ts`); 5 | -------------------------------------------------------------------------------- /cdk/lib/vod-rendition-playlist-stack.ts: -------------------------------------------------------------------------------- 1 | import { 2 | aws_cloudfront as cloudfront, 3 | aws_cloudfront_origins as origins, 4 | aws_iam as iam, 5 | aws_ivs as ivs, 6 | aws_lambda as lambda, 7 | aws_lambda_nodejs as lambdan, 8 | aws_s3 as s3, 9 | aws_s3_notifications as s3n, 10 | CfnOutput, 11 | Stack, 12 | StackProps, 13 | Duration 14 | } from 'aws-cdk-lib'; 15 | import { Construct } from 'constructs'; 16 | 17 | import { 18 | allowedOrigins, 19 | channelType, 20 | reconnectWindowSeconds, 21 | insecureRTMPIngest, 22 | transcodePreset 23 | } from '../config.json'; 24 | import { getLambdaEntryPath } from './utils'; 25 | 26 | export class DVRdemoStack extends Stack { 27 | constructor(scope: Construct, id: string, props?: StackProps) { 28 | super(scope, id, props); 29 | 30 | /** 31 | * S3 bucket where the VOD content will be stored 32 | */ 33 | const bucket = new s3.Bucket(this, 'vod-record-bucket'); 34 | const { bucketName: vodBucketName } = bucket; 35 | 36 | /** 37 | * IVS Channel Recording Configuration 38 | */ 39 | const recordingConfig = new ivs.CfnRecordingConfiguration( 40 | this, 41 | 'dvr-recording-config', 42 | { 43 | name: 'dvr-recording-config', 44 | destinationConfiguration: { s3: { bucketName: vodBucketName } }, 45 | recordingReconnectWindowSeconds: reconnectWindowSeconds, 46 | thumbnailConfiguration: { recordingMode: 'DISABLED' } 47 | } 48 | ); 49 | const { attrArn: recordingConfigurationArn } = recordingConfig; 50 | 51 | /** 52 | * IVS Channel 53 | */ 54 | const channel = new ivs.CfnChannel(this, 'DVR-demo-channel', { 55 | latencyMode: 'LOW', 56 | name: 'DVR-demo-channel', 57 | recordingConfigurationArn, 58 | type: channelType, 59 | insecureIngest: insecureRTMPIngest, 60 | preset: transcodePreset 61 | }); 62 | const { 63 | attrArn: channelArn, 64 | attrPlaybackUrl: playbackUrl, 65 | attrIngestEndpoint: ingestEndpoint 66 | } = channel; 67 | 68 | // Stream configuration values 69 | const ingestServer = `rtmps://${ingestEndpoint}:443/app/`; 70 | const { attrValue: streamKey } = new ivs.CfnStreamKey( 71 | this, 72 | 'dvr-streamkey', 73 | { channelArn } 74 | ); 75 | 76 | // IAM policy statement with GetStream permissions to the IVS channel (attached to the Lambda functions that require it) 77 | const getStreamPolicy = new iam.PolicyStatement({ 78 | actions: ['ivs:GetStream'], 79 | effect: iam.Effect.ALLOW, 80 | resources: [channelArn] 81 | }); 82 | 83 | // Lambda default configuration 84 | const defaultLambdaConfig = { 85 | runtime: lambda.Runtime.NODEJS_20_X, 86 | bundling: { minify: true } 87 | }; 88 | 89 | /** 90 | * Lambda function invoked by an S3 Event Notification to save the latest recording-started.json file 91 | */ 92 | const saveRecordingStartMetaLambda = new lambdan.NodejsFunction( 93 | this, 94 | 'SaveRecordingStartMetaHandler', 95 | { 96 | ...defaultLambdaConfig, 97 | entry: getLambdaEntryPath('saveRecordingStartMeta') 98 | } 99 | ); 100 | 101 | // Grant the Lambda execution role Read and Put permissions to the VOD S3 bucket 102 | bucket.grantRead(saveRecordingStartMetaLambda, '*/recording-started.json'); 103 | bucket.grantPut( 104 | saveRecordingStartMetaLambda, 105 | 'recording-started-latest.json' 106 | ); 107 | 108 | // Grant the Lambda execution role GetStream permissions to the IVS channel 109 | saveRecordingStartMetaLambda.addToRolePolicy(getStreamPolicy); 110 | 111 | // Add an S3 Event Notification that invokes the saveRecordingStartMeta Lambda function when a recording-started.json object is created in the VOD S3 bucket 112 | bucket.addEventNotification( 113 | s3.EventType.OBJECT_CREATED_PUT, 114 | new s3n.LambdaDestination(saveRecordingStartMetaLambda), 115 | { suffix: 'recording-started.json' } 116 | ); 117 | 118 | /** 119 | * Lambda(at)Edge function triggered on Origin Requests to retrieve the recording-started-latest.json metadata file 120 | */ 121 | const getLatestRecordingStartMetaLambda = new lambdan.NodejsFunction( 122 | this, 123 | 'GetLatestRecordingStartMetaHandler', 124 | { 125 | ...defaultLambdaConfig, 126 | entry: getLambdaEntryPath('getLatestRecordingStartMeta') 127 | } 128 | ); 129 | 130 | // Grant the Lambda execution role Read permissions to the VOD S3 bucket 131 | bucket.grantRead( 132 | getLatestRecordingStartMetaLambda, 133 | 'recording-started-latest.json' 134 | ); 135 | bucket.grantRead(getLatestRecordingStartMetaLambda, '*/playlist.m3u8'); 136 | 137 | // Grant the Lambda execution role GetStream permissions to the IVS channel 138 | getLatestRecordingStartMetaLambda.addToRolePolicy(getStreamPolicy); 139 | 140 | /** 141 | * Origin Access Identity (OAI) that CloudFront will use to access the S3 bucket 142 | */ 143 | const oai = new cloudfront.OriginAccessIdentity(this, 'vod-oai'); 144 | const origin = new origins.S3Origin(bucket, { 145 | originAccessIdentity: oai, 146 | customHeaders: { 147 | 'vod-record-bucket-name': vodBucketName, 148 | 'channel-arn': channelArn 149 | } 150 | }); 151 | 152 | /** 153 | * Custom Cache Policy to allow max-age caching values between 0 seconds and 1 year 154 | */ 155 | const byteRangeVariantCachePolicy = new cloudfront.CachePolicy( 156 | this, 157 | 'VOD-ByteRangeVariantCaching', 158 | { 159 | cachePolicyName: 'VOD-ByteRangeVariantCaching', 160 | comment: 'Policy for VOD Byte Range Variant Origin', 161 | defaultTtl: Duration.seconds(12), 162 | maxTtl: Duration.days(365), 163 | minTtl: Duration.seconds(0), 164 | enableAcceptEncodingGzip: true 165 | } 166 | ); 167 | 168 | /** 169 | * Custom Response Headers Policy to allow only the required origins for CORS requests 170 | */ 171 | const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy( 172 | this, 173 | 'VOD-ResponseHeaders', 174 | { 175 | responseHeadersPolicyName: 'VOD-ResponseHeaders', 176 | comment: 'Allows only the required origins for CORS requests', 177 | corsBehavior: { 178 | accessControlAllowCredentials: false, 179 | accessControlAllowHeaders: ['*'], 180 | accessControlAllowMethods: ['GET'], 181 | accessControlAllowOrigins: allowedOrigins, 182 | originOverride: true 183 | } 184 | } 185 | ); 186 | 187 | /** 188 | * CloudFront Distribution for accessing video content from the VOD S3 Origin 189 | */ 190 | const distribution = new cloudfront.Distribution(this, 'vod-cdn', { 191 | // Default caching behaviour for fetching video content files (.ts) directly from the VOD S3 bucket 192 | defaultBehavior: { 193 | origin, 194 | originRequestPolicy: cloudfront.OriginRequestPolicy.CORS_S3_ORIGIN, 195 | responseHeadersPolicy 196 | }, 197 | additionalBehaviors: { 198 | // Caching behaviour for fetching a byte range variant file from the VOD S3 bucket 199 | '*/byte-range-variant.m3u8': { 200 | origin, 201 | originRequestPolicy: cloudfront.OriginRequestPolicy.CORS_S3_ORIGIN, 202 | responseHeadersPolicy, 203 | cachePolicy: byteRangeVariantCachePolicy 204 | }, 205 | // Caching behaviour for invoking a Lambda@Edge function on Origin Requests to fetch the recording-started-latest.json metadata file from the VOD S3 bucket with caching DISABLED 206 | '/recording-started-latest.json': { 207 | origin, 208 | originRequestPolicy: cloudfront.OriginRequestPolicy.CORS_S3_ORIGIN, 209 | responseHeadersPolicy, 210 | cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED, 211 | edgeLambdas: [ 212 | { 213 | functionVersion: getLatestRecordingStartMetaLambda.currentVersion, 214 | eventType: cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST 215 | } 216 | ] 217 | } 218 | } 219 | }); 220 | const { domainName } = distribution; 221 | 222 | /** 223 | * Stack Outputs 224 | */ 225 | new CfnOutput(this, 'ingestServer', { value: ingestServer }); 226 | new CfnOutput(this, 'streamKey', { value: streamKey }); 227 | new CfnOutput(this, 'playbackUrl', { value: playbackUrl }); 228 | new CfnOutput(this, 'distributionDomainName', { value: domainName }); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /cdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cdk", 3 | "version": "0.1.0", 4 | "bin": { 5 | "cdk": "bin/cdk.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "cdk": "cdk" 11 | }, 12 | "devDependencies": { 13 | "@types/aws-lambda": "^8.10.141", 14 | "@types/node": "^20.14.10", 15 | "aws-cdk": "^2.148.0", 16 | "esbuild": "^0.23.0", 17 | "ts-node": "^10.9.1", 18 | "typescript": "^5.5.3" 19 | }, 20 | "dependencies": { 21 | "@aws-sdk/client-ivs": "^3.613.0", 22 | "@aws-sdk/client-s3": "^3.613.0", 23 | "aws-cdk-lib": "^2.148.0", 24 | "constructs": "^10.2.44", 25 | "source-map-support": "^0.5.21" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /cdk/sanitize-output.js: -------------------------------------------------------------------------------- 1 | const { readFileSync, writeFileSync } = require('fs'); 2 | 3 | const inputPath = 'temp_out.json'; 4 | const json = readFileSync(inputPath); 5 | const output = JSON.parse(json); 6 | 7 | const { distributionDomainName } = output.DVRdemoStack; 8 | const publicCdkOutput = { distributionDomainName }; 9 | 10 | const outputPath = '../web-ui/src/cdk_output.json'; 11 | writeFileSync(outputPath, JSON.stringify(publicCdkOutput)); 12 | -------------------------------------------------------------------------------- /cdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2018", "dom"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": false, 13 | "noUnusedParameters": false, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization": false, 20 | "resolveJsonModule": true, 21 | "esModuleInterop": true, 22 | "typeRoots": ["./node_modules/@types"] 23 | }, 24 | "exclude": ["node_modules", "cdk.out"] 25 | } 26 | -------------------------------------------------------------------------------- /docs/multi-channel-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-dvr-web-demo/a2682c616b8f35f729d78903123b62fb77f607d0/docs/multi-channel-architecture.png -------------------------------------------------------------------------------- /docs/multi-channel.md: -------------------------------------------------------------------------------- 1 | # Multi-channel Live-to-VOD 2 | 3 | The current Live-to-VOD (DVR) demo has been designed to support a single Amazon IVS channel. However, with some modifications to the infrastructure, it is possible to expand the functionality to support multiple Amazon IVS channels while keeping most of the existing functional components intact. 4 | 5 | This guide does not make any assumptions for how the multiple Amazon IVS channels are created and configured; this implementation detail is left to the reader. Instead, this guide is an overview of the necessary changes to adapt the Live-to-VOD system for multi-channel support. 6 | 7 | **1. Recording Configuration and Amazon S3 Bucket Structure** 8 | 9 | - The existing recording configuration can be utilized to save recordings from multiple Amazon IVS channels to the same Amazon S3 bucket. 10 | - Amazon IVS automatically creates a dedicated path within the S3 bucket for each channel, ensuring that recordings from different channels are stored separately. 11 | 12 | **2. Modifications to the `SaveRecordingStartMeta` Lambda Function** 13 | 14 | - The `SaveRecordingStartMeta` Lambda function currently modifies and saves the latest `recording-started.json` file for a single Amazon IVS channel. 15 | - To support multiple channels, the function needs to be modified to handle recording start metadata from all the configured channels. 16 | - When a live stream starts for any of the channels, an `ObjectCreated` S3 event notification will be triggered, invoking the `SaveRecordingStartMeta` Lambda function for each of the channels that went live. 17 | - The Lambda function should save the recording metadata for each channel separately, using appropriate naming conventions to distinguish between the files for different channels. For instance, you may adopt the naming convention of appending the channel ID to the end of the file name (i.e. `recording-started-{CHANNEL_ID}.json`). 18 | 19 | **3. Modifications to the `GetLatestRecordingStartMeta` Lambda@Edge Function** 20 | 21 | - The `GetLatestRecordingStartMeta` Lambda@Edge function currently returns the recording metadata for the single Amazon IVS channel in use. 22 | - To accommodate multiple channels, the function should be updated to accept a parameter specifying the desired channel (i.e. the channel ID), rather than using the `channel-arn` custom header to identify the channel. 23 | - Based on the provided channel parameter, the function should retrieve and return the recording metadata specific to that particular channel. For instance, following the channel ID naming convention from step 2, we can build the respective channel ARN and use it to retrieve metadata about the active session. 24 | 25 | **4. CloudFront Distribution and Behavior Patterns** 26 | 27 | - The existing CloudFront distribution can be used to support multiple Amazon IVS channels without requiring separate distributions for each channel. 28 | - However, the current `/recording-started-latest.json` behavior pattern may need to be adjusted to handle the saved metadata file names for each channel. 29 | - The behavior pattern should be modified to match the naming convention used for the metadata files of each channel, ensuring that the correct file is served based on the requested channel. For instance, following the channel ID naming convention from step 2, the behavior pattern would need to be changed to `/recording-started-*.json` 30 | 31 |
32 | 33 | ![Multi-channel architecture](multi-channel-architecture.png) 34 | -------------------------------------------------------------------------------- /web-ui/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["react-app", "plugin:jsx-a11y/recommended"], 3 | "plugins": ["jsx-a11y"], 4 | "env": { 5 | "worker": true 6 | }, 7 | "rules": { 8 | "no-unused-vars": "error", 9 | "react-hooks/exhaustive-deps": "error", 10 | "react/prop-types": "error", 11 | "react/require-default-props": [ 12 | 2, 13 | { 14 | "functions": "defaultArguments" 15 | } 16 | ], 17 | "react/no-unused-prop-types": "error" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /web-ui/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # CDK stack output 4 | */cdk_output.json 5 | 6 | # dependencies 7 | /node_modules 8 | /.pnp 9 | .pnp.js 10 | 11 | # testing 12 | /coverage 13 | 14 | # production 15 | /build 16 | 17 | # misc 18 | .DS_Store 19 | .env 20 | .env.local 21 | .env.development.local 22 | .env.test.local 23 | .env.production.local 24 | 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | -------------------------------------------------------------------------------- /web-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "react": "^18.3.1", 7 | "react-dom": "^18.3.1", 8 | "react-scripts": "^5.0.1", 9 | "react-transition-group": "^4.4.5", 10 | "swr": "^2.2.5", 11 | "web-vitals": "^2.1.4" 12 | }, 13 | "scripts": { 14 | "start": "react-scripts start", 15 | "build": "react-scripts build", 16 | "test": "react-scripts test", 17 | "eject": "react-scripts eject" 18 | }, 19 | "eslintConfig": { 20 | "extends": [ 21 | "react-app", 22 | "react-app/jest" 23 | ] 24 | }, 25 | "browserslist": { 26 | "production": [ 27 | ">0.2%", 28 | "not dead", 29 | "not op_mini all" 30 | ], 31 | "development": [ 32 | "last 1 chrome version", 33 | "last 1 firefox version", 34 | "last 1 safari version" 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /web-ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-dvr-web-demo/a2682c616b8f35f729d78903123b62fb77f607d0/web-ui/public/favicon.ico -------------------------------------------------------------------------------- /web-ui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | Amazon IVS DVR Demo 18 | 19 | 20 | 21 | 22 |
23 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /web-ui/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-dvr-web-demo/a2682c616b8f35f729d78903123b62fb77f607d0/web-ui/public/logo192.png -------------------------------------------------------------------------------- /web-ui/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-dvr-web-demo/a2682c616b8f35f729d78903123b62fb77f607d0/web-ui/public/logo512.png -------------------------------------------------------------------------------- /web-ui/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /web-ui/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /web-ui/src/App.css: -------------------------------------------------------------------------------- 1 | .app { 2 | align-items: center; 3 | background-color: var(--color-zinc-200); 4 | display: flex; 5 | justify-content: center; 6 | min-height: 100vh; 7 | } 8 | 9 | @media (prefers-color-scheme: dark) { 10 | .app { 11 | background-color: var(--color-zinc-800); 12 | } 13 | } 14 | 15 | @media (max-width: 875px) { 16 | .app { 17 | background-color: var(--color-black); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /web-ui/src/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './App.css'; 4 | import ControlsProvider from './contexts/Controls/provider'; 5 | import MobileBreakpointProvider from './contexts/MobileBreakpoint/provider'; 6 | import PlaybackProvider from './contexts/Playback/provider'; 7 | import SPPlaybackProvider from './contexts/SinglePlayerPlayback/provider'; 8 | import Player from './Player'; 9 | import SinglePlayer from './SinglePlayer'; 10 | 11 | const PLAYER_MODES = { 12 | SINGLE: 'SINGLE', 13 | DOUBLE: 'DOUBLE' 14 | }; 15 | const PLAYER_MODE = 16 | window.location.pathname === '/alt' 17 | ? PLAYER_MODES.SINGLE 18 | : PLAYER_MODES.DOUBLE; 19 | 20 | const App = () => ( 21 |
22 | 23 | {PLAYER_MODE === PLAYER_MODES.SINGLE ? ( 24 | 25 | 26 | 27 | 28 | 29 | ) : ( 30 | 31 | 32 | 33 | 34 | 35 | )} 36 | 37 |
38 | ); 39 | 40 | export default App; 41 | -------------------------------------------------------------------------------- /web-ui/src/Player/Controls/BackToLiveBtn/BackToLiveBtn.css: -------------------------------------------------------------------------------- 1 | .back-to-live-btn { 2 | background-color: rgba(0, 0, 0, 0.7); 3 | border-radius: 30px; 4 | border: none; 5 | bottom: 98px; 6 | color: var(--color-white); 7 | cursor: pointer; 8 | display: flex; 9 | font-family: Inter; 10 | font-size: 16px; 11 | font-style: normal; 12 | font-weight: 600; 13 | line-height: 13px; 14 | padding: 14px 18px 14px 17px; 15 | position: absolute; 16 | z-index: 1; 17 | user-select: none; 18 | } 19 | 20 | .inner-content { 21 | align-items: center; 22 | display: flex; 23 | height: 18px; 24 | justify-content: space-between; 25 | width: 116px; 26 | } 27 | 28 | @media (max-width: 875px) and (orientation: portrait) { 29 | .back-to-live-btn { 30 | bottom: 210px; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /web-ui/src/Player/Controls/BackToLiveBtn/BackToLiveBtn.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | 4 | import './BackToLiveBtn.css'; 5 | import LiveSVG from '../../../assets/icons/live'; 6 | 7 | const BackToLiveBtn = ({ onPointerDownHandler }) => { 8 | return ( 9 | 15 | ); 16 | }; 17 | 18 | BackToLiveBtn.propTypes = { 19 | onPointerDownHandler: PropTypes.func.isRequired 20 | }; 21 | 22 | export default BackToLiveBtn; 23 | -------------------------------------------------------------------------------- /web-ui/src/Player/Controls/BackToLiveBtn/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './BackToLiveBtn'; 2 | -------------------------------------------------------------------------------- /web-ui/src/Player/Controls/Controls.css: -------------------------------------------------------------------------------- 1 | .player-controls-container.fade-exited { 2 | pointer-events: none; 3 | } 4 | 5 | .player-controls-container { 6 | align-items: flex-end; 7 | background: linear-gradient( 8 | 180deg, 9 | rgba(0, 0, 0, 0) 0%, 10 | rgba(0, 0, 0, 0.00796593) 11.79%, 11 | rgba(0, 0, 0, 0.0307941) 21.38%, 12 | rgba(0, 0, 0, 0.06688) 29.12%, 13 | rgba(0, 0, 0, 0.114619) 35.34%, 14 | rgba(0, 0, 0, 0.172407) 40.37%, 15 | rgba(0, 0, 0, 0.23864) 44.56%, 16 | rgba(0, 0, 0, 0.311713) 48.24%, 17 | rgba(0, 0, 0, 0.390021) 51.76%, 18 | rgba(0, 0, 0, 0.47196) 55.44%, 19 | rgba(0, 0, 0, 0.555926) 59.63%, 20 | rgba(0, 0, 0, 0.640314) 64.66%, 21 | rgba(0, 0, 0, 0.72352) 70.88%, 22 | rgba(0, 0, 0, 0.803939) 78.62%, 23 | rgba(0, 0, 0, 0.879967) 88.21%, 24 | rgba(0, 0, 0, 0.95) 100% 25 | ); 26 | border-radius: 20px; 27 | bottom: 0; 28 | display: flex; 29 | flex-direction: column; 30 | height: 35%; 31 | justify-content: flex-end; 32 | left: 0; 33 | padding: 0 32px 24px 20px; 34 | position: absolute; 35 | width: 100%; 36 | z-index: 10; 37 | } 38 | 39 | .player-controls-wrapper { 40 | align-items: center; 41 | display: flex; 42 | width: 100%; 43 | } 44 | 45 | .player-controls-btn { 46 | background: none; 47 | border: none; 48 | cursor: pointer; 49 | } 50 | 51 | .player-controls-btn[aria-disabled='true'], 52 | #player-controls-seek-bar-wrapper[aria-disabled='true'], 53 | .player-controls-scrub[aria-disabled='true'] { 54 | cursor: auto; 55 | } 56 | 57 | .player-controls-scrub[aria-disabled='true'] { 58 | display: none; 59 | } 60 | 61 | .player-controls-btn[aria-disabled='true'] svg { 62 | opacity: 0.5; 63 | } 64 | 65 | .player-controls-btn-container { 66 | display: flex; 67 | margin-right: 8px; 68 | } 69 | 70 | #player-controls-seek-bar-wrapper { 71 | align-items: center; 72 | cursor: pointer; 73 | display: flex; 74 | height: 14px; 75 | position: relative; 76 | width: 100%; 77 | } 78 | 79 | .player-controls-seek-bar, 80 | .player-controls-seek-bar-back-to-live { 81 | background: var(--color-pill-red); 82 | border-radius: 10px; 83 | height: 4px; 84 | width: 100%; 85 | } 86 | 87 | .player-controls-seek-bar-back-to-live { 88 | position: absolute; 89 | transition: width 0.3s ease-in-out; 90 | } 91 | 92 | .player-controls-scrub { 93 | appearance: none; 94 | background-color: var(--color-white); 95 | border-radius: 50%; 96 | border: none; 97 | cursor: pointer; 98 | float: left; 99 | height: 14px; 100 | position: absolute; 101 | right: -7px; 102 | touch-action: none; 103 | transition: transform 0.3s ease-in-out; 104 | width: 14px; 105 | z-index: 1; 106 | } 107 | 108 | .play-btn svg { 109 | padding-left: 4px; 110 | } 111 | 112 | .time-since-live { 113 | bottom: 32px; 114 | color: var(--color-white); 115 | cursor: auto; 116 | float: left; 117 | font-family: Inter; 118 | font-size: 14px; 119 | font-weight: 500; 120 | line-height: 13px; 121 | pointer-events: none; 122 | position: absolute; 123 | user-select: none; 124 | } 125 | 126 | @media (max-width: 875px) { 127 | .player-controls-container { 128 | border-radius: 0; 129 | } 130 | } 131 | 132 | @media (max-width: 875px) and (orientation: portrait) { 133 | .player-controls-wrapper { 134 | flex-direction: column-reverse; 135 | } 136 | 137 | .player-controls-btn-container { 138 | justify-content: center; 139 | margin: 24px 0 0; 140 | width: 100%; 141 | } 142 | 143 | .play-pause-btn { 144 | align-items: center; 145 | background: rgba(256, 256, 256, 0.2); 146 | border-radius: 50%; 147 | display: flex; 148 | height: 60px; 149 | justify-content: center; 150 | margin: 0 50px; 151 | width: 60px; 152 | } 153 | 154 | .player-controls-container { 155 | align-items: center; 156 | padding: 0 24px 66px; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /web-ui/src/Player/Controls/Controls.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, { useCallback, useEffect, useRef, useState } from 'react'; 3 | 4 | import './Controls.css'; 5 | import { formatTime } from '../../utils'; 6 | import { LIVE, VOD, VOD_LOADING_TIMEOUT, VOD_STEP_SIZE } from '../../constants'; 7 | import BackToLiveBtn from './BackToLiveBtn'; 8 | import Backward60SVG from '../../assets/icons/backward-60'; 9 | import Forward60SVG from '../../assets/icons/forward-60'; 10 | import PauseSVG from '../../assets/icons/pause'; 11 | import PlaySVG from '../../assets/icons/play'; 12 | import useControls from '../../contexts/Controls/useControls'; 13 | import usePlayback from '../../contexts/Playback/usePlayback'; 14 | import usePrevious from '../../hooks/usePrevious'; 15 | import useFirstMountState from '../../hooks/useFirstMountState'; 16 | import useSeekBar, { 17 | playerControlSeekBarWrapperId 18 | } from '../../hooks/useSeekBar'; 19 | 20 | const Controls = ({ isLive = true }) => { 21 | const { stopPropagAndResetTimeout } = useControls(); 22 | const { 23 | activePlayer, 24 | bufferPercent, 25 | currProgress, 26 | getVodDuration, 27 | getVodPosition, 28 | isLiveAvailable, 29 | isVodAvailable, 30 | recordingStartTime, 31 | resetVOD, 32 | seekVodToPos, 33 | setActivePlayerType, 34 | vodPlayerInstance 35 | } = usePlayback(); 36 | const prevProgress = usePrevious(currProgress); 37 | const { 38 | error, 39 | isLoading, 40 | isPaused, 41 | pause, 42 | play, 43 | type: activePlayerType 44 | } = activePlayer; 45 | const { 46 | isMouseDown, 47 | onPointerDownHandler, 48 | scrubRef, 49 | seekBarRef, 50 | updateProgress, 51 | timeSinceLive, 52 | timeSinceLiveRef 53 | } = useSeekBar(); 54 | const hasError = !!error; 55 | const isLiveAvailablePrev = usePrevious(isLiveAvailable); 56 | const isBackwardsDisabled = hasError || !isVodAvailable || currProgress === 0; 57 | const isForwardsDisabled = hasError || isLive; 58 | const isSeekBarDisabled = hasError; 59 | const isFirstMount = useFirstMountState(); 60 | const prevLivePosition = useRef(null); 61 | const [backToLiveStartProgress, setBackToLiveStartProgress] = useState(null); 62 | const [backToLivePosDiff, setBackToLivePosDiff] = useState(null); 63 | const isBackToLiveTransition = 64 | backToLiveStartProgress !== null && backToLivePosDiff !== null; 65 | 66 | const goBackwards = useCallback( 67 | (event, seconds = 60) => { 68 | stopPropagAndResetTimeout(event); 69 | 70 | if (!isBackwardsDisabled) { 71 | const currentVODDuration = getVodDuration(); 72 | const currentVODPosition = getVodPosition(); 73 | const nextPosition = Math.max(0, currentVODPosition - seconds); // position in seconds 74 | const nextProgress = (nextPosition / currentVODDuration) * 100; 75 | 76 | updateProgress(nextProgress); 77 | } 78 | }, 79 | [ 80 | getVodDuration, 81 | getVodPosition, 82 | isBackwardsDisabled, 83 | stopPropagAndResetTimeout, 84 | updateProgress 85 | ] 86 | ); 87 | const goForwards = useCallback( 88 | (event, seconds = 60) => { 89 | stopPropagAndResetTimeout(event); 90 | 91 | if (!isForwardsDisabled) { 92 | const currentVODDuration = getVodDuration(); 93 | const currentVODPosition = getVodPosition(); 94 | let nextPosition = Math.min( 95 | currentVODPosition + seconds, 96 | currentVODDuration 97 | ); // position in seconds 98 | let nextProgress = (nextPosition / currentVODDuration) * 100; 99 | 100 | updateProgress(nextProgress); 101 | } 102 | }, 103 | [ 104 | getVodDuration, 105 | getVodPosition, 106 | isForwardsDisabled, 107 | stopPropagAndResetTimeout, 108 | updateProgress 109 | ] 110 | ); 111 | const onKeyDownHandler = useCallback( 112 | (event) => { 113 | if (event.key === 'ArrowRight' && !event.repeat) { 114 | goForwards(null, VOD_STEP_SIZE); 115 | } else if (event.key === 'ArrowLeft' && !event.repeat) { 116 | goBackwards(null, VOD_STEP_SIZE); 117 | } 118 | }, 119 | [goBackwards, goForwards] 120 | ); 121 | const onPointerDownPlayPauseHandler = useCallback( 122 | (event) => { 123 | if (hasError) return; 124 | 125 | stopPropagAndResetTimeout(event); 126 | 127 | const currentVODDuration = getVodDuration() || 0; 128 | 129 | if (isPaused) { 130 | if ( 131 | activePlayer.type === LIVE && 132 | prevLivePosition.current && 133 | prevLivePosition.current < currentVODDuration 134 | ) { 135 | seekVodToPos(prevLivePosition.current); 136 | setActivePlayerType(VOD); 137 | } 138 | 139 | play(); 140 | } else { 141 | const now = Date.now(); 142 | prevLivePosition.current = (now - recordingStartTime) / 1000; 143 | 144 | pause(); 145 | } 146 | }, 147 | [ 148 | activePlayer.type, 149 | getVodDuration, 150 | hasError, 151 | isPaused, 152 | pause, 153 | play, 154 | recordingStartTime, 155 | seekVodToPos, 156 | setActivePlayerType, 157 | stopPropagAndResetTimeout 158 | ] 159 | ); 160 | const backToLive = useCallback( 161 | (event) => { 162 | if (event) { 163 | stopPropagAndResetTimeout(event); 164 | } 165 | 166 | const scrubStyle = getComputedStyle(scrubRef.current); 167 | const seekbarStyle = getComputedStyle(seekBarRef.current); 168 | 169 | setBackToLivePosDiff( 170 | parseFloat(seekbarStyle.width) - parseFloat(scrubStyle.left) - 14 171 | ); 172 | setBackToLiveStartProgress(currProgress); 173 | updateProgress(100, false); 174 | }, 175 | [ 176 | currProgress, 177 | scrubRef, 178 | seekBarRef, 179 | stopPropagAndResetTimeout, 180 | updateProgress 181 | ] 182 | ); 183 | const onTransitionEndHandler = useCallback(() => { 184 | setBackToLivePosDiff(null); 185 | setBackToLiveStartProgress(null); 186 | updateProgress(100); 187 | }, [setBackToLiveStartProgress, updateProgress]); 188 | 189 | // Set the playback position based on currProgress, defined by user input 190 | useEffect(() => { 191 | const playerType = currProgress < 100 ? VOD : LIVE; 192 | 193 | if (!isMouseDown && !isFirstMount && vodPlayerInstance) { 194 | const currentVODDuration = getVodDuration(); 195 | const currentVODPosition = getVodPosition(); 196 | const currentScrubberPosition = Math.max( 197 | 0.01, 198 | (currProgress / 100) * currentVODDuration 199 | ); 200 | const seekPosition = 201 | playerType === VOD ? currentScrubberPosition : currentVODDuration; 202 | 203 | // This condition prevents from calling the seekTo function more than necessary 204 | if (Math.abs(currentScrubberPosition - currentVODPosition) > 1) { 205 | seekVodToPos(seekPosition); 206 | } 207 | } 208 | 209 | setActivePlayerType(playerType); 210 | }, [ 211 | currProgress, 212 | getVodDuration, 213 | getVodPosition, 214 | isFirstMount, 215 | isMouseDown, 216 | seekVodToPos, 217 | setActivePlayerType, 218 | vodPlayerInstance 219 | ]); 220 | 221 | // Switch to the LIVE player if the VOD loading has stalled for longer than VOD_LOADING_TIMEOUT seconds 222 | const timeoutId = useRef(); 223 | useEffect(() => { 224 | if (activePlayerType === VOD && isLoading && !timeoutId.current) { 225 | timeoutId.current = setTimeout(backToLive, VOD_LOADING_TIMEOUT); 226 | } 227 | 228 | return () => { 229 | if (activePlayerType === LIVE || !isLoading) { 230 | clearTimeout(timeoutId.current); 231 | timeoutId.current = null; 232 | } 233 | }; 234 | }, [activePlayerType, backToLive, isLoading]); 235 | 236 | // Switch to the LIVE player if a new livestream 237 | useEffect(() => { 238 | if (isLiveAvailablePrev === false && isLiveAvailable) { 239 | backToLive(); 240 | resetVOD(); 241 | } 242 | }, [backToLive, isLiveAvailable, isLiveAvailablePrev, resetVOD]); 243 | 244 | const bufferPercentBg = 245 | prevProgress > currProgress ? currProgress : bufferPercent; 246 | const darkGrayColour = 'rgba(255, 255, 255, 0.3)'; 247 | const prevBgPercent = isBackToLiveTransition 248 | ? backToLiveStartProgress 249 | : currProgress; 250 | let seekbarBg = `linear-gradient( 251 | to right, 252 | ${!isPaused ? 'var(--color-pill-red)' : darkGrayColour} ${prevBgPercent}%, 253 | rgba(255, 255, 255, 0.5) ${prevBgPercent}% ${bufferPercentBg}%, 254 | ${darkGrayColour} ${bufferPercentBg}% 255 | )`; 256 | 257 | return ( 258 | <> 259 | {!isLive && } 260 |
261 |
262 | 269 | 278 | 285 |
286 |
296 |

307 | -{formatTime(timeSinceLive)} 308 |

309 |
313 |
324 | 338 |
339 |
340 | 341 | ); 342 | }; 343 | 344 | Controls.propTypes = { 345 | isLive: PropTypes.bool 346 | }; 347 | 348 | export default Controls; 349 | -------------------------------------------------------------------------------- /web-ui/src/Player/Controls/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Controls'; 2 | -------------------------------------------------------------------------------- /web-ui/src/Player/FadeInOut/FadeInOut.jsx: -------------------------------------------------------------------------------- 1 | import { Transition } from 'react-transition-group'; 2 | import PropTypes from 'prop-types'; 3 | import React, { useRef } from 'react'; 4 | 5 | const transitionStyles = { 6 | entering: { opacity: 1 }, 7 | entered: { opacity: 1 }, 8 | exiting: { opacity: 0 }, 9 | exited: { opacity: 0 } 10 | }; 11 | 12 | const FadeInOut = ({ 13 | children, 14 | className = '', 15 | inProp = false, 16 | timeout = 300, 17 | ...rest 18 | }) => { 19 | const nodeRef = useRef(null); 20 | const defaultStyle = { 21 | transition: `opacity ${timeout}ms ease-in-out`, 22 | opacity: 0 23 | }; 24 | 25 | return ( 26 | 27 | {(state) => ( 28 |
36 | {children} 37 |
38 | )} 39 |
40 | ); 41 | }; 42 | 43 | FadeInOut.propTypes = { 44 | children: PropTypes.node.isRequired, 45 | className: PropTypes.string, 46 | inProp: PropTypes.bool, 47 | timeout: PropTypes.number 48 | }; 49 | 50 | export default FadeInOut; 51 | -------------------------------------------------------------------------------- /web-ui/src/Player/FadeInOut/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './FadeInOut'; 2 | -------------------------------------------------------------------------------- /web-ui/src/Player/Notification/Notification.css: -------------------------------------------------------------------------------- 1 | .notification-container { 2 | display: flex; 3 | justify-content: center; 4 | margin-top: 34px; 5 | position: absolute; 6 | top: 0; 7 | width: 100%; 8 | z-index: 100; 9 | } 10 | 11 | .notification-content { 12 | background: #cc422f; 13 | border-radius: 8px; 14 | font-family: Inter; 15 | font-size: 15px; 16 | font-style: normal; 17 | font-weight: 400; 18 | line-height: 22px; 19 | max-width: 300px; 20 | padding: 8px 16px; 21 | width: 90%; 22 | } 23 | 24 | .notification-content > div { 25 | align-items: center; 26 | display: flex; 27 | } 28 | 29 | .notification-content svg { 30 | margin-right: 4px; 31 | } 32 | 33 | .notification-type { 34 | text-transform: capitalize; 35 | } 36 | 37 | @media (max-width: 875px) and (orientation: portrait) { 38 | .notification-container { 39 | margin-top: 66px; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /web-ui/src/Player/Notification/Notification.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | 4 | import Warning from '../../assets/icons/warning'; 5 | import './Notification.css'; 6 | 7 | const notifTypes = ['ERROR']; 8 | 9 | const Notification = ({ message, type }) => ( 10 |
11 |
12 | 13 |

{type.toLowerCase()}

14 |
15 |

{message}

16 |
17 | ); 18 | 19 | Notification.propTypes = { 20 | message: PropTypes.string.isRequired, 21 | type: PropTypes.oneOf(notifTypes).isRequired 22 | }; 23 | 24 | export default Notification; 25 | -------------------------------------------------------------------------------- /web-ui/src/Player/Notification/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Notification'; 2 | -------------------------------------------------------------------------------- /web-ui/src/Player/Player.css: -------------------------------------------------------------------------------- 1 | .player-section { 2 | height: 100%; 3 | max-width: 1080px; 4 | width: 80%; 5 | } 6 | 7 | .video-container { 8 | border-radius: 20px; 9 | box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.05), 0px 6px 16px rgba(0, 0, 0, 0.2); 10 | height: 0; 11 | overflow: hidden; 12 | padding-top: 56.25%; 13 | position: relative; 14 | width: 100%; 15 | } 16 | 17 | video { 18 | background-color: var(--color-black); 19 | border-radius: 20px; 20 | display: none; 21 | height: 100%; 22 | left: 0; 23 | position: absolute; 24 | top: 0; 25 | width: 100%; 26 | } 27 | 28 | video.active-player { 29 | display: block; 30 | } 31 | 32 | .black-cover { 33 | background: var(--color-black); 34 | bottom: 0; 35 | height: 100%; 36 | left: 0; 37 | position: absolute; 38 | width: 100%; 39 | z-index: 1; 40 | } 41 | 42 | @media (max-width: 875px) { 43 | .player-section { 44 | height: 100%; 45 | padding: 0; 46 | width: 100%; 47 | } 48 | 49 | .video-container { 50 | border-radius: 0; 51 | height: auto; 52 | overflow: auto; 53 | padding: 0; 54 | position: static; 55 | } 56 | 57 | video { 58 | background-color: var(--color-black); 59 | border-radius: 0; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /web-ui/src/Player/Player.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | 3 | import './Player.css'; 4 | import { LIVE } from '../constants'; 5 | import Controls from './Controls'; 6 | import FadeInOut from './FadeInOut/FadeInOut'; 7 | import Notification from './Notification'; 8 | import Spinner from './Spinner'; 9 | import StatusPill from './StatusPill'; 10 | import useControls from '../contexts/Controls/useControls'; 11 | import useMobileBreakpoint from '../contexts/MobileBreakpoint/useMobileBreakpoint'; 12 | import usePlayback from '../contexts/Playback/usePlayback'; 13 | 14 | const Player = () => { 15 | const { isMobileView } = useMobileBreakpoint(); 16 | const { isControlsOpen, setIsHovered } = useControls(); 17 | const { activePlayer, liveVideoRef, vodVideoRef } = usePlayback(); 18 | const { isInitialLoading, isLoading, type: playerType, error } = activePlayer; 19 | const isLive = playerType === LIVE; 20 | const hasError = !!error; 21 | 22 | const onMouseEnterHandler = useCallback(() => { 23 | setIsHovered(true); 24 | }, [setIsHovered]); 25 | 26 | const onMouseLeaveHandler = useCallback(() => { 27 | setIsHovered(false); 28 | }, [setIsHovered]); 29 | 30 | return ( 31 |
32 |
41 | 42 | 43 | 44 | 45 | 46 | 53 | 54 | 55 | {hasError &&
} 56 |
79 |
80 | ); 81 | }; 82 | 83 | export default Player; 84 | -------------------------------------------------------------------------------- /web-ui/src/Player/Spinner/Spinner.css: -------------------------------------------------------------------------------- 1 | @keyframes rotate { 2 | from { 3 | transform: rotate(360deg); 4 | } 5 | to { 6 | transform: rotate(0deg); 7 | } 8 | } 9 | 10 | .spinner { 11 | animation: rotate 1s linear infinite; 12 | height: 42px; 13 | left: calc(50% - 24px); 14 | position: absolute; 15 | top: calc(50% - 24px); 16 | width: 42px; 17 | z-index: 20; 18 | } 19 | -------------------------------------------------------------------------------- /web-ui/src/Player/Spinner/Spinner.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | 4 | import SpinnerSVG from '../../assets/icons/spinner'; 5 | 6 | import './Spinner.css'; 7 | 8 | const Spinner = ({ loading = false }) => 9 | loading && ( 10 |
11 | 12 |
13 | ); 14 | 15 | Spinner.propTypes = { 16 | loading: PropTypes.bool 17 | }; 18 | 19 | export default Spinner; 20 | -------------------------------------------------------------------------------- /web-ui/src/Player/Spinner/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Spinner'; 2 | -------------------------------------------------------------------------------- /web-ui/src/Player/StatusPill/StatusPill.css: -------------------------------------------------------------------------------- 1 | .status-pill { 2 | align-items: center; 3 | border-radius: 40px; 4 | display: flex; 5 | font-family: Inter; 6 | font-size: 14px; 7 | font-style: normal; 8 | font-weight: 600; 9 | right: 32px; 10 | line-height: 17px; 11 | padding: 3px 8px 4px; 12 | position: absolute; 13 | user-select: none; 14 | top: 31px; 15 | z-index: 1; 16 | } 17 | 18 | .status-pill > *:first-child { 19 | margin-right: 8px; 20 | } 21 | 22 | .status-pill.is-live { 23 | background-color: var(--color-pill-red); 24 | } 25 | 26 | .status-pill.is-recorded { 27 | background-color: var(--color-pill-yellow); 28 | color: var(--color-black); 29 | } 30 | 31 | @media (max-width: 875px) and (orientation: portrait) { 32 | .status-pill { 33 | right: 24px; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /web-ui/src/Player/StatusPill/StatusPill.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | 4 | import './StatusPill.css'; 5 | import FadeInOut from '../FadeInOut/FadeInOut'; 6 | import LiveSVG from '../../assets/icons/live'; 7 | import RecordedSVG from '../../assets/icons/recorded'; 8 | 9 | const StatusPill = ({ isLive = false, isOpen = false }) => ( 10 | <> 11 | 12 | 13 | LIVE 14 | 15 | 16 | 17 | RECORDED 18 | 19 | 20 | ); 21 | 22 | StatusPill.propTypes = { 23 | isLive: PropTypes.bool, 24 | isOpen: PropTypes.bool 25 | }; 26 | 27 | export default StatusPill; 28 | -------------------------------------------------------------------------------- /web-ui/src/Player/StatusPill/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './StatusPill'; 2 | -------------------------------------------------------------------------------- /web-ui/src/Player/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Player'; 2 | -------------------------------------------------------------------------------- /web-ui/src/SinglePlayer/Controls/Controls.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, { useCallback, useEffect, useRef, useState } from 'react'; 3 | 4 | import '../../Player/Controls/Controls.css'; 5 | import { formatTime } from '../../utils'; 6 | import { LIVE, VOD, VOD_LOADING_TIMEOUT, VOD_STEP_SIZE } from '../../constants'; 7 | import BackToLiveBtn from '../../Player/Controls/BackToLiveBtn'; 8 | import Backward60SVG from '../../assets/icons/backward-60'; 9 | import Forward60SVG from '../../assets/icons/forward-60'; 10 | import PauseSVG from '../../assets/icons/pause'; 11 | import PlaySVG from '../../assets/icons/play'; 12 | import useControls from '../../contexts/Controls/useControls'; 13 | import usePlayback from '../../contexts/SinglePlayerPlayback/usePlayback'; 14 | import usePrevious from '../../hooks/usePrevious'; 15 | import useFirstMountState from '../../hooks/useFirstMountState'; 16 | import useSeekBar, { 17 | playerControlSeekBarWrapperId 18 | } from '../../hooks/useSinglePlayerSeekBar'; 19 | 20 | const Controls = ({ isLive = true }) => { 21 | const { stopPropagAndResetTimeout } = useControls(); 22 | const { 23 | activePlayer, 24 | bufferPercent, 25 | currProgress, 26 | getVodDuration, 27 | getVodPosition, 28 | isLiveAvailable, 29 | isVodAvailable, 30 | recordingStartTime, 31 | resetVOD, 32 | seekVodToPos, 33 | setActivePlayerType, 34 | vodPlayerInstance 35 | } = usePlayback(); 36 | const prevProgress = usePrevious(currProgress); 37 | const { 38 | error, 39 | isLoading, 40 | isPaused, 41 | pause, 42 | play, 43 | type: activePlayerType 44 | } = activePlayer; 45 | const { 46 | isMouseDown, 47 | onPointerDownHandler, 48 | scrubRef, 49 | seekBarRef, 50 | updateProgress, 51 | timeSinceLive, 52 | timeSinceLiveRef 53 | } = useSeekBar(); 54 | const hasError = !!error; 55 | const isLiveAvailablePrev = usePrevious(isLiveAvailable); 56 | const isBackwardsDisabled = hasError || !isVodAvailable || currProgress === 0; 57 | const isForwardsDisabled = hasError || isLive; 58 | const isSeekBarDisabled = hasError; 59 | const isFirstMount = useFirstMountState(); 60 | const prevLivePosition = useRef(null); 61 | const [backToLiveStartProgress, setBackToLiveStartProgress] = useState(null); 62 | const [backToLivePosDiff, setBackToLivePosDiff] = useState(null); 63 | const isBackToLiveTransition = 64 | backToLiveStartProgress !== null && backToLivePosDiff !== null; 65 | 66 | const goBackwards = useCallback( 67 | (event, seconds = 60) => { 68 | stopPropagAndResetTimeout(event); 69 | 70 | if (!isBackwardsDisabled) { 71 | const currentVODDuration = getVodDuration(); 72 | const currentVODPosition = getVodPosition(); 73 | const nextPosition = Math.max(0, currentVODPosition - seconds); // position in seconds 74 | const nextProgress = (nextPosition / currentVODDuration) * 100; 75 | 76 | updateProgress(nextProgress); 77 | } 78 | }, 79 | [ 80 | getVodDuration, 81 | getVodPosition, 82 | isBackwardsDisabled, 83 | stopPropagAndResetTimeout, 84 | updateProgress 85 | ] 86 | ); 87 | const goForwards = useCallback( 88 | (event, seconds = 60) => { 89 | stopPropagAndResetTimeout(event); 90 | 91 | if (!isForwardsDisabled) { 92 | const currentVODDuration = getVodDuration(); 93 | const currentVODPosition = getVodPosition(); 94 | let nextPosition = Math.min( 95 | currentVODPosition + seconds, 96 | currentVODDuration 97 | ); // position in seconds 98 | let nextProgress = (nextPosition / currentVODDuration) * 100; 99 | 100 | updateProgress(nextProgress); 101 | } 102 | }, 103 | [ 104 | getVodDuration, 105 | getVodPosition, 106 | isForwardsDisabled, 107 | stopPropagAndResetTimeout, 108 | updateProgress 109 | ] 110 | ); 111 | const onKeyDownHandler = useCallback( 112 | (event) => { 113 | if (event.key === 'ArrowRight' && !event.repeat) { 114 | goForwards(null, VOD_STEP_SIZE); 115 | } else if (event.key === 'ArrowLeft' && !event.repeat) { 116 | goBackwards(null, VOD_STEP_SIZE); 117 | } 118 | }, 119 | [goBackwards, goForwards] 120 | ); 121 | const onPointerDownPlayPauseHandler = useCallback( 122 | (event) => { 123 | if (hasError) return; 124 | 125 | stopPropagAndResetTimeout(event); 126 | 127 | const currentVODDuration = getVodDuration() || 0; 128 | 129 | if (isPaused) { 130 | if ( 131 | activePlayer.type === LIVE && 132 | prevLivePosition.current && 133 | prevLivePosition.current < currentVODDuration 134 | ) { 135 | seekVodToPos(prevLivePosition.current); 136 | setActivePlayerType(VOD); 137 | } 138 | 139 | play(); 140 | } else { 141 | const now = Date.now(); 142 | prevLivePosition.current = (now - recordingStartTime) / 1000; 143 | 144 | pause(); 145 | } 146 | }, 147 | [ 148 | activePlayer.type, 149 | getVodDuration, 150 | hasError, 151 | isPaused, 152 | pause, 153 | play, 154 | recordingStartTime, 155 | seekVodToPos, 156 | setActivePlayerType, 157 | stopPropagAndResetTimeout 158 | ] 159 | ); 160 | const backToLive = useCallback( 161 | (event) => { 162 | if (event) { 163 | stopPropagAndResetTimeout(event); 164 | } 165 | 166 | const scrubStyle = getComputedStyle(scrubRef.current); 167 | const seekbarStyle = getComputedStyle(seekBarRef.current); 168 | 169 | setBackToLivePosDiff( 170 | parseFloat(seekbarStyle.width) - parseFloat(scrubStyle.left) - 14 171 | ); 172 | setBackToLiveStartProgress(currProgress); 173 | updateProgress(100, false); 174 | }, 175 | [ 176 | currProgress, 177 | scrubRef, 178 | seekBarRef, 179 | stopPropagAndResetTimeout, 180 | updateProgress 181 | ] 182 | ); 183 | const onTransitionEndHandler = useCallback(() => { 184 | setBackToLivePosDiff(null); 185 | setBackToLiveStartProgress(null); 186 | updateProgress(100); 187 | }, [setBackToLiveStartProgress, updateProgress]); 188 | 189 | // Set the playback position based on currProgress, defined by user input 190 | useEffect(() => { 191 | const playerType = currProgress < 100 ? VOD : LIVE; 192 | 193 | if ( 194 | !isMouseDown && 195 | !isFirstMount && 196 | vodPlayerInstance && 197 | playerType === VOD 198 | ) { 199 | const currentVODDuration = getVodDuration(); 200 | const currentScrubberPosition = Math.max( 201 | 0.01, 202 | (currProgress / 100) * currentVODDuration 203 | ); 204 | 205 | seekVodToPos(currentScrubberPosition); 206 | } 207 | 208 | setActivePlayerType(playerType); 209 | }, [ 210 | currProgress, 211 | getVodDuration, 212 | getVodPosition, 213 | isFirstMount, 214 | isMouseDown, 215 | seekVodToPos, 216 | setActivePlayerType, 217 | vodPlayerInstance 218 | ]); 219 | 220 | // Switch to the LIVE player if the VOD loading has stalled for longer than VOD_LOADING_TIMEOUT seconds 221 | const timeoutId = useRef(); 222 | useEffect(() => { 223 | if (activePlayerType === VOD && isLoading && !timeoutId.current) { 224 | timeoutId.current = setTimeout(backToLive, VOD_LOADING_TIMEOUT); 225 | } 226 | 227 | return () => { 228 | if (activePlayerType === LIVE || !isLoading) { 229 | clearTimeout(timeoutId.current); 230 | timeoutId.current = null; 231 | } 232 | }; 233 | }, [activePlayerType, backToLive, isLoading]); 234 | 235 | // Switch to the LIVE player if a new livestream 236 | useEffect(() => { 237 | if (isLiveAvailablePrev === false && isLiveAvailable) { 238 | backToLive(); 239 | resetVOD(); 240 | } 241 | }, [backToLive, isLiveAvailable, isLiveAvailablePrev, resetVOD]); 242 | 243 | const bufferPercentBg = 244 | prevProgress > currProgress ? currProgress : bufferPercent; 245 | const darkGrayColour = 'rgba(255, 255, 255, 0.3)'; 246 | const prevBgPercent = isBackToLiveTransition 247 | ? backToLiveStartProgress 248 | : currProgress; 249 | let seekbarBg = `linear-gradient( 250 | to right, 251 | ${!isPaused ? 'var(--color-pill-red)' : darkGrayColour} ${prevBgPercent}%, 252 | rgba(255, 255, 255, 0.5) ${prevBgPercent}% ${bufferPercentBg}%, 253 | ${darkGrayColour} ${bufferPercentBg}% 254 | )`; 255 | 256 | return ( 257 | <> 258 | {!isLive && } 259 |
260 |
261 | 268 | 277 | 284 |
285 |
295 |

306 | -{formatTime(timeSinceLive)} 307 |

308 |
312 |
323 | 337 |
338 |
339 | 340 | ); 341 | }; 342 | 343 | Controls.propTypes = { 344 | isLive: PropTypes.bool 345 | }; 346 | 347 | export default Controls; 348 | -------------------------------------------------------------------------------- /web-ui/src/SinglePlayer/Controls/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Controls'; 2 | -------------------------------------------------------------------------------- /web-ui/src/SinglePlayer/Player.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | 3 | import '../Player/Player.css'; 4 | import { LIVE } from '../constants'; 5 | import Controls from './Controls'; 6 | import FadeInOut from '../Player/FadeInOut/FadeInOut'; 7 | import Notification from '../Player/Notification'; 8 | import Spinner from '../Player/Spinner'; 9 | import StatusPill from './StatusPill'; 10 | import useControls from '../contexts/Controls/useControls'; 11 | import useMobileBreakpoint from '../contexts/MobileBreakpoint/useMobileBreakpoint'; 12 | import usePlayback from '../contexts/SinglePlayerPlayback/usePlayback'; 13 | 14 | const SinglePlayer = () => { 15 | const { isMobileView } = useMobileBreakpoint(); 16 | const { isControlsOpen, setIsHovered } = useControls(); 17 | const { activePlayer, vodVideoRef } = usePlayback(); 18 | const { isInitialLoading, isLoading, type: playerType, error } = activePlayer; 19 | const isLive = playerType === LIVE; 20 | const hasError = !!error; 21 | 22 | const onMouseEnterHandler = useCallback(() => { 23 | setIsHovered(true); 24 | }, [setIsHovered]); 25 | 26 | const onMouseLeaveHandler = useCallback(() => { 27 | setIsHovered(false); 28 | }, [setIsHovered]); 29 | 30 | return ( 31 |
32 |
41 | 42 | 43 | 44 | 45 | 46 | 53 | 54 | 55 | {hasError &&
} 56 |
68 |
69 | ); 70 | }; 71 | 72 | export default SinglePlayer; 73 | -------------------------------------------------------------------------------- /web-ui/src/SinglePlayer/StatusPill/StatusPill.css: -------------------------------------------------------------------------------- 1 | .status-pill { 2 | align-items: center; 3 | border-radius: 40px; 4 | display: flex; 5 | font-family: Inter; 6 | font-size: 14px; 7 | font-style: normal; 8 | font-weight: 600; 9 | right: 32px; 10 | line-height: 17px; 11 | padding: 3px 8px 4px; 12 | position: absolute; 13 | user-select: none; 14 | top: 31px; 15 | z-index: 1; 16 | } 17 | 18 | .status-pill > *:first-child { 19 | margin-right: 8px; 20 | } 21 | 22 | .status-pill.single-player-is-live { 23 | background-color: var(--color-pill-red); 24 | } 25 | 26 | .status-pill.single-player-is-recorded { 27 | background-color: #475569; 28 | color: var(--color-white); 29 | } 30 | 31 | @media (max-width: 875px) and (orientation: portrait) { 32 | .status-pill { 33 | right: 24px; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /web-ui/src/SinglePlayer/StatusPill/StatusPill.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | 4 | import './StatusPill.css'; 5 | import FadeInOut from '../../Player/FadeInOut/FadeInOut'; 6 | import LiveSVG from '../../assets/icons/live'; 7 | 8 | const StatusPill = ({ isLive = false, isOpen = false }) => ( 9 | <> 10 | 14 | 15 | LIVE 16 | 17 | 21 | 22 | LIVE 23 | 24 | 25 | ); 26 | 27 | StatusPill.propTypes = { 28 | isLive: PropTypes.bool, 29 | isOpen: PropTypes.bool 30 | }; 31 | 32 | export default StatusPill; 33 | -------------------------------------------------------------------------------- /web-ui/src/SinglePlayer/StatusPill/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './StatusPill'; 2 | -------------------------------------------------------------------------------- /web-ui/src/SinglePlayer/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Player'; 2 | -------------------------------------------------------------------------------- /web-ui/src/assets/icons/backward-60.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Backward60 = () => ( 4 | 11 | 12 | 16 | 20 | {' '} 26 | 27 | 28 | ); 29 | 30 | export default Backward60; 31 | -------------------------------------------------------------------------------- /web-ui/src/assets/icons/forward-60.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Forward60 = () => ( 4 | 11 | 12 | 16 | 20 | 26 | 27 | 28 | ); 29 | 30 | export default Forward60; 31 | -------------------------------------------------------------------------------- /web-ui/src/assets/icons/live.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Live = () => ( 4 | 11 | 20 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ); 34 | 35 | export default Live; 36 | -------------------------------------------------------------------------------- /web-ui/src/assets/icons/pause.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Pause = () => ( 4 | 11 | 15 | 16 | ); 17 | 18 | export default Pause; 19 | -------------------------------------------------------------------------------- /web-ui/src/assets/icons/play.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Play = () => ( 4 | 11 | 17 | 18 | ); 19 | 20 | export default Play; 21 | -------------------------------------------------------------------------------- /web-ui/src/assets/icons/recorded.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Recorded = () => ( 4 | 11 | 17 | 23 | 24 | ); 25 | 26 | export default Recorded; 27 | -------------------------------------------------------------------------------- /web-ui/src/assets/icons/spinner.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Spinner = () => ( 4 | 5 | 6 | 7 | 8 | 9 | 10 |
20 | 21 | 22 | ); 23 | 24 | export default Spinner; 25 | -------------------------------------------------------------------------------- /web-ui/src/assets/icons/warning.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Warning = () => ( 4 | 11 | 15 | 16 | ); 17 | 18 | export default Warning; 19 | -------------------------------------------------------------------------------- /web-ui/src/constants.js: -------------------------------------------------------------------------------- 1 | export const MOBILE_BREAKPOINT = 875; 2 | export const VOD_LOADING_TIMEOUT = 15000; // 15s 3 | export const LIVE = 'live'; 4 | export const VOD = 'vod'; 5 | export const VOD_STEP_SIZE = 4; // 4s 6 | -------------------------------------------------------------------------------- /web-ui/src/contexts/Controls/context.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | export default createContext(null); 4 | -------------------------------------------------------------------------------- /web-ui/src/contexts/Controls/provider.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, { 3 | useCallback, 4 | useEffect, 5 | useMemo, 6 | useRef, 7 | useState 8 | } from 'react'; 9 | 10 | import ControlsContext from './context'; 11 | import useMobileBreakpoint from '../MobileBreakpoint/useMobileBreakpoint'; 12 | 13 | const ControlsProvider = ({ children }) => { 14 | const { isMobileView } = useMobileBreakpoint(); 15 | const [isControlsOpen, setIsControlsOpen] = useState(false); 16 | const [isHovered, setIsHovered] = useState(false); 17 | const timeoutId = useRef(null); 18 | const clearControlsTimeout = useCallback(() => { 19 | clearTimeout(timeoutId.current); 20 | timeoutId.current = null; 21 | }, []); 22 | const resetControlsTimeout = useCallback(() => { 23 | clearControlsTimeout(); 24 | timeoutId.current = setTimeout(() => { 25 | setIsControlsOpen(false); 26 | timeoutId.current = null; 27 | }, 3000); 28 | }, [clearControlsTimeout]); 29 | const stopPropagAndResetTimeout = useCallback( 30 | (event) => { 31 | if (!isMobileView) return; 32 | 33 | if (event) { 34 | event.stopPropagation(); 35 | } 36 | resetControlsTimeout(); 37 | }, 38 | [isMobileView, resetControlsTimeout] 39 | ); 40 | 41 | // Desktop controls toggling logic 42 | useEffect(() => { 43 | if (isMobileView === false && isHovered) { 44 | clearControlsTimeout(); 45 | setIsControlsOpen(true); 46 | } else if (isMobileView === false && !isHovered) { 47 | resetControlsTimeout(); 48 | } 49 | }, [clearControlsTimeout, isHovered, isMobileView, resetControlsTimeout]); 50 | 51 | // Mobile controls toggling logic 52 | useEffect(() => { 53 | const mobileClickHandler = () => { 54 | if (!timeoutId.current) { 55 | setIsControlsOpen(true); 56 | resetControlsTimeout(); 57 | } else { 58 | setIsControlsOpen(false); 59 | clearControlsTimeout(); 60 | } 61 | }; 62 | 63 | if (isMobileView) { 64 | mobileClickHandler(); 65 | document.addEventListener('pointerdown', mobileClickHandler); 66 | } 67 | 68 | return () => { 69 | document.removeEventListener('pointerdown', mobileClickHandler); 70 | }; 71 | }, [clearControlsTimeout, isMobileView, resetControlsTimeout]); 72 | 73 | const value = useMemo( 74 | () => ({ 75 | clearControlsTimeout, 76 | isControlsOpen, 77 | resetControlsTimeout, 78 | setIsControlsOpen, 79 | setIsHovered, 80 | stopPropagAndResetTimeout 81 | }), 82 | [ 83 | clearControlsTimeout, 84 | isControlsOpen, 85 | stopPropagAndResetTimeout, 86 | resetControlsTimeout 87 | ] 88 | ); 89 | 90 | return ( 91 | 92 | {children} 93 | 94 | ); 95 | }; 96 | 97 | ControlsProvider.propTypes = { 98 | children: PropTypes.node.isRequired 99 | }; 100 | 101 | export default ControlsProvider; 102 | -------------------------------------------------------------------------------- /web-ui/src/contexts/Controls/useControls.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import ControlsContext from './context'; 3 | 4 | const useControls = () => { 5 | const context = useContext(ControlsContext); 6 | 7 | if (!context) { 8 | throw new Error( 9 | 'Playback context must be consumed inside the Controls Provider' 10 | ); 11 | } 12 | 13 | return context; 14 | }; 15 | 16 | export default useControls; 17 | -------------------------------------------------------------------------------- /web-ui/src/contexts/MobileBreakpoint/context.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | export default createContext(null); 4 | -------------------------------------------------------------------------------- /web-ui/src/contexts/MobileBreakpoint/provider.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, { useEffect, useMemo, useState } from 'react'; 3 | 4 | import { MOBILE_BREAKPOINT } from '../../constants'; 5 | import MobileBreakpointContext from './context'; 6 | 7 | const MobileBreakpointProvider = ({ children }) => { 8 | const [isMobileView, setIsMobileView] = useState(undefined); 9 | 10 | useEffect(() => { 11 | const handleWindowResize = () => { 12 | setIsMobileView(window.innerWidth <= MOBILE_BREAKPOINT); 13 | }; 14 | 15 | handleWindowResize(); 16 | window.addEventListener('resize', handleWindowResize); 17 | 18 | return () => window.removeEventListener('resize', handleWindowResize); 19 | }, []); 20 | 21 | const value = useMemo(() => ({ isMobileView }), [isMobileView]); 22 | 23 | return ( 24 | 25 | {children} 26 | 27 | ); 28 | }; 29 | 30 | MobileBreakpointProvider.propTypes = { 31 | children: PropTypes.node.isRequired 32 | }; 33 | 34 | export default MobileBreakpointProvider; 35 | -------------------------------------------------------------------------------- /web-ui/src/contexts/MobileBreakpoint/useMobileBreakpoint.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import MobileBreakpointContext from './context'; 3 | 4 | const useMobileBreakpoint = () => { 5 | const context = useContext(MobileBreakpointContext); 6 | 7 | if (!context) { 8 | throw new Error( 9 | 'MobileBreakpoint context must be consumed inside the MobileBreakpoint Provider' 10 | ); 11 | } 12 | 13 | return context; 14 | }; 15 | 16 | export default useMobileBreakpoint; 17 | -------------------------------------------------------------------------------- /web-ui/src/contexts/Playback/context.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | export default createContext(null); 4 | -------------------------------------------------------------------------------- /web-ui/src/contexts/Playback/provider.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, { 3 | useCallback, 4 | useEffect, 5 | useMemo, 6 | useRef, 7 | useState 8 | } from 'react'; 9 | import useSWR from 'swr'; 10 | 11 | import { fetchPlaybackMetadata, CFDomainError } from './utils'; 12 | import { isiOS } from '../../utils'; 13 | import { LIVE, VOD } from '../../constants'; 14 | import PlaybackContext from './context'; 15 | import usePlayer from '../../hooks/usePlayer'; 16 | import useStateWithCallback from '../../hooks/useStateWithCallback'; 17 | import useFirstMountState from '../../hooks/useFirstMountState'; 18 | 19 | const defaultPlaybackMetadata = { 20 | livePlaybackUrl: '', 21 | vodPlaybackURL: '', 22 | recordingStartTime: null, 23 | playlistDuration: null 24 | }; 25 | 26 | const PlaybackProvider = ({ children }) => { 27 | const [currProgress, setCurrProgress] = useStateWithCallback(100); 28 | const [bufferPercent, setBufferPercent] = useState(0); 29 | const retryTimeoutId = useRef(); 30 | const isiOSDevice = isiOS(); 31 | const [shouldFetch, setShouldFetch] = useState(true); 32 | const { 33 | data: { 34 | isChannelLive, 35 | playlistDuration: vodPlaylistDuration, 36 | recordingStartTime, 37 | vodPlaybackURL, 38 | livePlaybackUrl 39 | } 40 | } = useSWR( 41 | shouldFetch ? 'recording-started-latest.json' : null, 42 | fetchPlaybackMetadata, 43 | { 44 | refreshInterval: 5000, 45 | revalidateOnMount: true, 46 | fallbackData: defaultPlaybackMetadata, 47 | onErrorRetry: (error, _key, _config, revalidate) => { 48 | if (error instanceof CFDomainError) { 49 | livePlayer?.setError(error); 50 | console.error(`${error.message} \n ${error.description}`); 51 | return; 52 | } 53 | retryTimeoutId.current = setTimeout(revalidate, 3000); 54 | } 55 | } 56 | ); 57 | const [activePlayerType, setActivePlayerType] = useState(LIVE); 58 | const livePlayer = usePlayer(livePlaybackUrl, LIVE, isChannelLive); 59 | const vodPlayer = usePlayer(vodPlaybackURL, VOD, isChannelLive); 60 | const { 61 | instance: vodPlayerInstance, 62 | setCurrentTime: setVodCurrentTime, 63 | videoRef: vodVideoRef, 64 | isPaused 65 | } = vodPlayer; 66 | 67 | // When the VOD player is paused, stop fetching updated recording-started-latest.json 68 | const isFirstMount = useFirstMountState(); 69 | useEffect(() => { 70 | if (isFirstMount) return; 71 | setShouldFetch(!isPaused); 72 | }, [isPaused, isFirstMount]); 73 | 74 | /** 75 | * Known issue: getDuration, seekTo and getPosition are not working as expected on iOS. 76 | * 77 | * The workaround is to use the currentTime property of the HTML video element to get and set the current playback position. 78 | * The current VOD duration has been exposed from the backend and is fetched every second. 79 | */ 80 | const getVodDuration = useCallback(() => { 81 | if (!vodPlayerInstance) return 0; 82 | 83 | let duration = vodPlayerInstance.getDuration(); 84 | 85 | if (isiOSDevice && vodPlaylistDuration) { 86 | duration = vodPlaylistDuration; 87 | } 88 | 89 | return duration; 90 | }, [isiOSDevice, vodPlayerInstance, vodPlaylistDuration]); 91 | const seekVodToPos = useCallback( 92 | (seekPosition) => { 93 | if (isiOSDevice) { 94 | setVodCurrentTime(seekPosition); 95 | } else { 96 | vodPlayerInstance?.seekTo(seekPosition); 97 | } 98 | }, 99 | [isiOSDevice, setVodCurrentTime, vodPlayerInstance] 100 | ); 101 | const getVodPosition = useCallback( 102 | (seekPosition) => { 103 | if (!vodPlayerInstance) return 0; 104 | 105 | if (isiOSDevice) { 106 | return vodVideoRef.current.currentTime; 107 | } 108 | 109 | return vodPlayerInstance.getPosition(); 110 | }, 111 | [isiOSDevice, vodPlayerInstance, vodVideoRef] 112 | ); 113 | const isLiveAvailable = useMemo( 114 | () => isChannelLive && !livePlayer.error, 115 | [isChannelLive, livePlayer.error] 116 | ); 117 | const isVodAvailable = useMemo( 118 | () => vodPlayer.isReady && !vodPlayer.error, 119 | [vodPlayer.error, vodPlayer.isReady] 120 | ); 121 | 122 | // Ensures we restart the playback when switching player 123 | const setActivePlayerTypeWithState = useCallback( 124 | (nextType) => { 125 | setActivePlayerType((prevType) => { 126 | const nextPlayFn = nextType === LIVE ? livePlayer.play : vodPlayer.play; 127 | 128 | if (nextType !== prevType) { 129 | nextPlayFn(); 130 | } 131 | 132 | return nextType; 133 | }); 134 | }, 135 | [livePlayer.play, vodPlayer.play] 136 | ); 137 | 138 | // Clear the error retry timeout 139 | useEffect(() => { 140 | return () => clearTimeout(retryTimeoutId.current); 141 | }, []); 142 | 143 | // Switch to the LIVE player if VOD has ended (will lead to "live stream offline" error state) 144 | useEffect(() => { 145 | if (vodPlayer.hasEnded) setActivePlayerType(LIVE); 146 | }, [vodPlayer.hasEnded]); 147 | 148 | // Reset the VOD player if the LIVE player has encountered an error and is currently active (ensures that the VOD player starts fresh with the next live stream) 149 | useEffect(() => { 150 | if (activePlayerType === LIVE && livePlayer.error) { 151 | const resetVOD = vodPlayer.reset; 152 | 153 | resetVOD(); 154 | } 155 | }, [activePlayerType, vodPlayer.reset, livePlayer.error]); 156 | 157 | // Seek the VOD player to the end while the LIVE player is active so it can continue fetching new playlists 158 | useEffect(() => { 159 | if (isVodAvailable && activePlayerType === LIVE) { 160 | const vodDuration = getVodDuration(); 161 | 162 | seekVodToPos(vodDuration); // Seek VOD to the end to fetch new playlists while LIVE is active 163 | } 164 | }, [activePlayerType, getVodDuration, isVodAvailable, seekVodToPos]); 165 | 166 | // Lower the rendition of the inactive player to the lowest available resolution 167 | useEffect(() => { 168 | const activePlayerInstance = 169 | activePlayerType === LIVE ? livePlayer.instance : vodPlayerInstance; 170 | const inactivePlayerInstance = 171 | activePlayerType === LIVE ? vodPlayerInstance : livePlayer.instance; 172 | 173 | if (activePlayerInstance && inactivePlayerInstance) { 174 | const qualities = inactivePlayerInstance?.getQualities() || []; 175 | const lowestQuality = qualities.pop(); 176 | 177 | if (lowestQuality) { 178 | inactivePlayerInstance.setQuality(lowestQuality, true); 179 | } 180 | 181 | activePlayerInstance.setAutoQualityMode(true); 182 | } 183 | }, [activePlayerType, livePlayer.instance, vodPlayerInstance]); 184 | 185 | const value = useMemo( 186 | () => ({ 187 | activePlayer: 188 | activePlayerType === LIVE 189 | ? { ...livePlayer, type: activePlayerType } 190 | : { ...vodPlayer, type: activePlayerType }, 191 | bufferPercent, 192 | currProgress, 193 | getVodDuration, 194 | getVodPosition, 195 | isLiveAvailable, 196 | isVodAvailable, 197 | liveVideoRef: livePlayer.videoRef, 198 | recordingStartTime, 199 | resetVOD: vodPlayer.reset, 200 | seekVodToPos, 201 | setActivePlayerType: setActivePlayerTypeWithState, 202 | setBufferPercent, 203 | setCurrProgress, 204 | vodPlayerInstance, 205 | vodVideoRef: vodPlayer.videoRef 206 | }), 207 | [ 208 | activePlayerType, 209 | bufferPercent, 210 | currProgress, 211 | getVodDuration, 212 | getVodPosition, 213 | isLiveAvailable, 214 | isVodAvailable, 215 | livePlayer, 216 | recordingStartTime, 217 | seekVodToPos, 218 | setActivePlayerTypeWithState, 219 | setCurrProgress, 220 | vodPlayer, 221 | vodPlayerInstance 222 | ] 223 | ); 224 | 225 | return ( 226 | 227 | {children} 228 | 229 | ); 230 | }; 231 | 232 | PlaybackProvider.propTypes = { 233 | children: PropTypes.node.isRequired 234 | }; 235 | 236 | export default PlaybackProvider; 237 | -------------------------------------------------------------------------------- /web-ui/src/contexts/Playback/usePlayback.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import PlaybackContext from './context'; 3 | 4 | const usePlayback = () => { 5 | const context = useContext(PlaybackContext); 6 | 7 | if (!context) { 8 | throw new Error( 9 | 'Playback context must be consumed inside the Playback Provider' 10 | ); 11 | } 12 | 13 | return context; 14 | }; 15 | 16 | export default usePlayback; 17 | -------------------------------------------------------------------------------- /web-ui/src/contexts/Playback/utils.js: -------------------------------------------------------------------------------- 1 | export class CFDomainError extends Error { 2 | constructor(message, description = '') { 3 | super(message); 4 | this.description = description; 5 | this.name = 'DistributionError'; 6 | } 7 | } 8 | 9 | const getDistributionDomainName = async () => { 10 | let distributionDomainName = process.env.REACT_APP_DISTRIBUTION_DOMAIN_NAME; 11 | 12 | if (distributionDomainName) return distributionDomainName; // The distribution domain name was manually set in a .env file 13 | 14 | try { 15 | const STACK_OUTPUT = require('../../cdk_output.json'); 16 | distributionDomainName = STACK_OUTPUT.distributionDomainName; 17 | 18 | if (!distributionDomainName) throw new Error(); 19 | } catch (error) { 20 | const message = 'No CloudFront distribution domain name was found'; 21 | const description = ` 22 | You must bootstrap and deploy the CDK stack, or provide a domain name 23 | by setting the REACT_APP_DISTRIBUTION_DOMAIN_NAME environment variable, 24 | before running the app. 25 | `; 26 | throw new CFDomainError(message, description); 27 | } 28 | 29 | return distributionDomainName; 30 | }; 31 | 32 | export const fetchPlaybackMetadata = async (metaKey) => { 33 | const distributionDomainName = await getDistributionDomainName(); 34 | const distributionDomainURL = `https://${distributionDomainName}`; 35 | const response = await fetch(`${distributionDomainURL}/${metaKey}`); 36 | const data = await response.json(); 37 | 38 | if (!data) return { isChannelLive: false }; 39 | 40 | const { 41 | isChannelLive, 42 | livePlaybackUrl, 43 | masterKey, 44 | playlistDuration, 45 | recordingStartedAt 46 | } = data; 47 | const recordingStartTime = new Date(recordingStartedAt)?.getTime() || null; 48 | const vodPlaybackURL = masterKey 49 | ? `${distributionDomainURL}/${masterKey}` 50 | : ''; 51 | 52 | return { 53 | isChannelLive, 54 | livePlaybackUrl, 55 | playlistDuration, 56 | recordingStartTime, 57 | vodPlaybackURL 58 | }; 59 | }; 60 | -------------------------------------------------------------------------------- /web-ui/src/contexts/SinglePlayerPlayback/context.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | export default createContext(null); 4 | -------------------------------------------------------------------------------- /web-ui/src/contexts/SinglePlayerPlayback/provider.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, { 3 | useCallback, 4 | useEffect, 5 | useMemo, 6 | useRef, 7 | useState 8 | } from 'react'; 9 | import useSWR from 'swr'; 10 | 11 | import { fetchPlaybackMetadata, CFDomainError } from '../Playback/utils'; 12 | import { isiOS } from '../../utils'; 13 | import { LIVE } from '../../constants'; 14 | import PlaybackContext from './context'; 15 | import usePlayer from '../../hooks/useSinglePlayer'; 16 | import useStateWithCallback from '../../hooks/useStateWithCallback'; 17 | import useFirstMountState from '../../hooks/useFirstMountState'; 18 | 19 | const defaultPlaybackMetadata = { 20 | livePlaybackUrl: '', 21 | vodPlaybackURL: '', 22 | recordingStartTime: null, 23 | playlistDuration: null 24 | }; 25 | 26 | const SinglePlayerPlaybackProvider = ({ children }) => { 27 | const [currProgress, setCurrProgress] = useStateWithCallback(100); 28 | const [bufferPercent, setBufferPercent] = useState(0); 29 | const retryTimeoutId = useRef(); 30 | const isiOSDevice = isiOS(); 31 | const [shouldFetch, setShouldFetch] = useState(true); 32 | const { 33 | data: { 34 | isChannelLive, 35 | playlistDuration: vodPlaylistDuration, 36 | recordingStartTime, 37 | vodPlaybackURL, 38 | livePlaybackUrl 39 | } 40 | } = useSWR( 41 | shouldFetch ? 'recording-started-latest.json' : null, 42 | fetchPlaybackMetadata, 43 | { 44 | refreshInterval: 1000, 45 | revalidateOnMount: true, 46 | fallbackData: defaultPlaybackMetadata, 47 | onErrorRetry: (error, _key, _config, revalidate) => { 48 | if (error instanceof CFDomainError) { 49 | player?.setError(error); 50 | console.error(`${error.message} \n ${error.description}`); 51 | return; 52 | } 53 | retryTimeoutId.current = setTimeout(revalidate, 3000); 54 | } 55 | } 56 | ); 57 | const player = usePlayer({ livePlaybackUrl, vodPlaybackURL, isChannelLive }); 58 | const { 59 | instance: playerInstance, 60 | setCurrentTime: setVodCurrentTime, 61 | videoRef: vodVideoRef, 62 | activePlayerType, 63 | setActivePlayerType, 64 | isPaused 65 | } = player; 66 | 67 | // When the player is paused, stop fetching updated recording-started-latest.json 68 | const isFirstMount = useFirstMountState(); 69 | useEffect(() => { 70 | if (isFirstMount) return; 71 | setShouldFetch(!isPaused); 72 | }, [isPaused, isFirstMount]); 73 | 74 | /** 75 | * Known issue: getDuration, seekTo and getPosition are not working as expected on iOS. 76 | * 77 | * The workaround is to use the currentTime property of the HTML video element to get and set the current playback position. 78 | * The current VOD duration has been exposed from the backend and is fetched every second. 79 | */ 80 | const getVodDuration = useCallback(() => { 81 | if (!playerInstance) return 0; 82 | 83 | if (isiOSDevice && vodPlaylistDuration) { 84 | return vodPlaylistDuration - Number.EPSILON || 0; 85 | } 86 | 87 | const duration = playerInstance.getDuration(); 88 | 89 | return isFinite(duration) ? duration : vodPlaylistDuration || 0; 90 | }, [isiOSDevice, playerInstance, vodPlaylistDuration]); 91 | const seekVodToPos = useCallback( 92 | (seekPosition) => { 93 | if (isiOSDevice) { 94 | setVodCurrentTime(seekPosition); 95 | } else { 96 | playerInstance?.seekTo(seekPosition); 97 | } 98 | }, 99 | [isiOSDevice, setVodCurrentTime, playerInstance] 100 | ); 101 | const getVodPosition = useCallback(() => { 102 | if (!playerInstance) return 0; 103 | 104 | if (activePlayerType === LIVE) { 105 | return vodPlaylistDuration || 0; 106 | } 107 | 108 | if (isiOSDevice && vodVideoRef.current) { 109 | return vodVideoRef.current.currentTime || 0; 110 | } 111 | 112 | return playerInstance.getPosition() || 0; 113 | }, [ 114 | isiOSDevice, 115 | playerInstance, 116 | vodVideoRef, 117 | activePlayerType, 118 | vodPlaylistDuration 119 | ]); 120 | const isLiveAvailable = useMemo( 121 | () => isChannelLive && !player.error, 122 | [isChannelLive, player.error] 123 | ); 124 | const isVodAvailable = useMemo( 125 | () => player.isReady && !player.error, 126 | [player.error, player.isReady] 127 | ); 128 | 129 | // Clear the error retry timeout 130 | useEffect(() => { 131 | return () => clearTimeout(retryTimeoutId.current); 132 | }, []); 133 | 134 | // Switch to the LIVE player if VOD has ended (will lead to "live stream offline" error state) 135 | useEffect(() => { 136 | if (player.hasEnded) setActivePlayerType(LIVE); 137 | }, [player.hasEnded, setActivePlayerType]); 138 | 139 | // Reset the VOD player if the LIVE player has encountered an error and is currently active (ensures that the VOD player starts fresh with the next live stream) 140 | useEffect(() => { 141 | if (activePlayerType === LIVE && player.error) { 142 | const resetVOD = player.reset; 143 | 144 | resetVOD(); 145 | } 146 | }, [activePlayerType, player.reset, player.error]); 147 | 148 | const value = useMemo( 149 | () => ({ 150 | activePlayer: { ...player, type: activePlayerType }, 151 | bufferPercent, 152 | currProgress, 153 | getVodDuration, 154 | getVodPosition, 155 | isLiveAvailable, 156 | isVodAvailable, 157 | liveVideoRef: player.videoRef, 158 | recordingStartTime, 159 | resetVOD: player.reset, 160 | seekVodToPos, 161 | setActivePlayerType, 162 | setBufferPercent, 163 | setCurrProgress, 164 | vodPlayerInstance: playerInstance, 165 | vodVideoRef: player.videoRef 166 | }), 167 | [ 168 | activePlayerType, 169 | bufferPercent, 170 | currProgress, 171 | getVodDuration, 172 | getVodPosition, 173 | isLiveAvailable, 174 | isVodAvailable, 175 | recordingStartTime, 176 | seekVodToPos, 177 | setActivePlayerType, 178 | setCurrProgress, 179 | player, 180 | playerInstance 181 | ] 182 | ); 183 | 184 | return ( 185 | 186 | {children} 187 | 188 | ); 189 | }; 190 | 191 | SinglePlayerPlaybackProvider.propTypes = { 192 | children: PropTypes.node.isRequired 193 | }; 194 | 195 | export default SinglePlayerPlaybackProvider; 196 | -------------------------------------------------------------------------------- /web-ui/src/contexts/SinglePlayerPlayback/usePlayback.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import PlaybackContext from './context'; 3 | 4 | const usePlayback = () => { 5 | const context = useContext(PlaybackContext); 6 | 7 | if (!context) { 8 | throw new Error( 9 | 'Playback context must be consumed inside the Playback Provider' 10 | ); 11 | } 12 | 13 | return context; 14 | }; 15 | 16 | export default usePlayback; 17 | -------------------------------------------------------------------------------- /web-ui/src/hooks/useFirstMountState.js: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | 3 | const useFirstMountState = () => { 4 | const isFirst = useRef(true); 5 | 6 | if (isFirst.current) { 7 | isFirst.current = false; 8 | 9 | return true; 10 | } 11 | 12 | return isFirst.current; 13 | }; 14 | 15 | export default useFirstMountState; 16 | -------------------------------------------------------------------------------- /web-ui/src/hooks/usePlayer.js: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react'; 2 | 3 | import { LIVE } from '../constants'; 4 | 5 | const { IVSPlayer } = window; 6 | const { 7 | create: createMediaPlayer, 8 | isPlayerSupported, 9 | PlayerEventType, 10 | PlayerState 11 | } = IVSPlayer; 12 | const { ENDED, PLAYING, READY, BUFFERING } = PlayerState; 13 | const { DURATION_CHANGED, ERROR } = PlayerEventType; 14 | 15 | class PlaybackError extends Error { 16 | constructor(message, type) { 17 | super(message); 18 | this.type = type; 19 | this.name = 'PlaybackError'; 20 | } 21 | } 22 | 23 | const usePlayer = (urlToLoad, type, isChannelLive) => { 24 | const videoRef = useRef(null); 25 | const playerRef = useRef(null); 26 | const [error, setError] = useState(null); 27 | const [isInitialLoading, setIsInitialLoading] = useState(true); 28 | const [isLoading, setIsLoading] = useState(true); 29 | const [isPaused, setIsPaused] = useState(true); 30 | const [isReady, setIsReady] = useState(false); 31 | const [hasEnded, setHasEnded] = useState(false); 32 | const hasError = !!error; 33 | 34 | const setCurrentTime = useCallback((seekPosition) => { 35 | if (videoRef.current) { 36 | videoRef.current.currentTime = seekPosition; 37 | } 38 | }, []); 39 | 40 | // PlayerState event callback 41 | const onStateChange = useCallback(() => { 42 | if (!playerRef.current) return; 43 | 44 | const newState = playerRef.current.getState(); 45 | 46 | if (newState === ENDED && type === LIVE) { 47 | setError(new PlaybackError(`Live stream has ended`, LIVE)); 48 | } else setError(null); 49 | 50 | setHasEnded(newState === ENDED); 51 | setIsLoading(newState === READY || newState === BUFFERING); 52 | setIsPaused(playerRef.current?.isPaused() || false); 53 | 54 | console.log(`${type.toUpperCase()} Player State - ${newState}`); 55 | }, [type]); 56 | 57 | // PlayerEventType.ERROR event callback 58 | const onError = useCallback( 59 | (err) => { 60 | console.warn( 61 | `${type.toUpperCase()} Player Event - ERROR:`, 62 | err, 63 | playerRef.current 64 | ); 65 | 66 | setError(err); 67 | setIsLoading(false); 68 | setIsPaused(true); 69 | setIsReady(false); 70 | }, 71 | [type] 72 | ); 73 | 74 | const onDurationChanged = useCallback((duration) => { 75 | setIsReady(duration > 0); 76 | setIsInitialLoading(false); 77 | }, []); 78 | 79 | const destroy = useCallback(() => { 80 | if (!playerRef.current) return; 81 | 82 | // remove event listeners 83 | playerRef.current.removeEventListener(DURATION_CHANGED, onDurationChanged); 84 | playerRef.current.removeEventListener(READY, onStateChange); 85 | playerRef.current.removeEventListener(PLAYING, onStateChange); 86 | playerRef.current.removeEventListener(BUFFERING, onStateChange); 87 | playerRef.current.removeEventListener(ENDED, onStateChange); 88 | playerRef.current.removeEventListener(ERROR, onError); 89 | 90 | // delete and nullify player 91 | playerRef.current.pause(); 92 | playerRef.current.delete(); 93 | playerRef.current = null; 94 | videoRef.current?.removeAttribute('src'); // remove possible stale src 95 | }, [onDurationChanged, onError, onStateChange]); 96 | 97 | const create = useCallback(() => { 98 | if (!isPlayerSupported) { 99 | console.warn( 100 | 'The current browser does not support the Amazon IVS player.' 101 | ); 102 | return; 103 | } 104 | 105 | // If a player instance already exists, destroy it before creating a new one 106 | if (playerRef.current) destroy(); 107 | 108 | playerRef.current = createMediaPlayer({ 109 | serviceWorker: { 110 | url: '../workers/amazon-ivs-service-worker-loader.js' 111 | } 112 | }); 113 | 114 | playerRef.current.attachHTMLVideoElement(videoRef.current); 115 | 116 | playerRef.current.addEventListener(DURATION_CHANGED, onDurationChanged); 117 | playerRef.current.addEventListener(READY, onStateChange); 118 | playerRef.current.addEventListener(PLAYING, onStateChange); 119 | playerRef.current.addEventListener(BUFFERING, onStateChange); 120 | playerRef.current.addEventListener(ENDED, onStateChange); 121 | playerRef.current.addEventListener(ERROR, onError); 122 | }, [destroy, onDurationChanged, onError, onStateChange]); 123 | 124 | const play = useCallback(() => { 125 | if (!playerRef.current) return; 126 | 127 | if (hasError) { 128 | setIsLoading(true); 129 | playerRef.current.load(urlToLoad); 130 | } 131 | 132 | if (playerRef.current.isPaused()) { 133 | playerRef.current.play(); 134 | setIsPaused(false); 135 | } 136 | }, [hasError, urlToLoad]); 137 | 138 | const pause = useCallback(() => { 139 | if (!playerRef.current) return; 140 | 141 | if (!playerRef.current.isPaused()) { 142 | playerRef.current.pause(); 143 | setIsPaused(true); 144 | } 145 | }, []); 146 | 147 | const load = useCallback( 148 | (playbackUrl) => { 149 | if (!playbackUrl) return; 150 | 151 | if (!playerRef.current) create(); 152 | 153 | playerRef.current.load(playbackUrl); 154 | play(); 155 | }, 156 | [create, play] 157 | ); 158 | 159 | const reset = useCallback(() => { 160 | setIsPaused(true); 161 | setIsReady(false); 162 | setIsLoading(false); 163 | setIsInitialLoading(false); 164 | destroy(); 165 | }, [destroy]); 166 | 167 | useEffect(() => { 168 | if (hasError) reset(); 169 | }, [hasError, reset]); 170 | 171 | useEffect(() => { 172 | if (urlToLoad && isChannelLive) load(urlToLoad); 173 | }, [isChannelLive, load, urlToLoad]); 174 | 175 | useEffect(() => { 176 | if ( 177 | isChannelLive !== undefined && 178 | type === LIVE && 179 | (!isChannelLive || !urlToLoad) 180 | ) { 181 | setError(new PlaybackError('Live stream is offline', LIVE)); 182 | } 183 | }, [isChannelLive, isInitialLoading, type, urlToLoad]); 184 | 185 | return { 186 | error, 187 | instance: playerRef.current, 188 | hasEnded, 189 | isInitialLoading, 190 | isLoading, 191 | isPaused, 192 | isReady, 193 | pause, 194 | play, 195 | reset, 196 | setCurrentTime, 197 | setError, 198 | videoRef 199 | }; 200 | }; 201 | 202 | export default usePlayer; 203 | -------------------------------------------------------------------------------- /web-ui/src/hooks/usePrevious.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | const usePrevious = (state) => { 4 | const ref = useRef(); 5 | 6 | useEffect(() => { 7 | ref.current = state; 8 | }); 9 | 10 | return ref.current; 11 | }; 12 | 13 | export default usePrevious; 14 | -------------------------------------------------------------------------------- /web-ui/src/hooks/useSeekBar.js: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react'; 2 | 3 | import { bound } from '../utils'; 4 | import { VOD } from '../constants'; 5 | import useControls from '../contexts/Controls/useControls'; 6 | import usePlayback from '../contexts/Playback/usePlayback'; 7 | 8 | export const playerControlSeekBarWrapperId = 'player-controls-seek-bar-wrapper'; 9 | 10 | const useSeekBar = () => { 11 | const [isMouseDown, setIsMouseDown] = useState(false); 12 | const { stopPropagAndResetTimeout } = useControls(); 13 | const { 14 | activePlayer, 15 | currProgress, 16 | getVodDuration, 17 | getVodPosition, 18 | isVodAvailable, 19 | setBufferPercent, 20 | setCurrProgress, 21 | vodPlayerInstance 22 | } = usePlayback(); 23 | const scrubRef = useRef(null); 24 | const seekBarRef = useRef(null); 25 | const timeSinceLiveRef = useRef(null); 26 | const [timeSinceLive, setTimeSinceLive] = useState(0); 27 | const { isPaused, type: playerType, error } = activePlayer; 28 | const hasError = !!error; 29 | 30 | const updateScrubPosition = useCallback((nextProgress) => { 31 | const seekBarStyle = getComputedStyle(seekBarRef.current); 32 | const seekBarWidth = parseFloat(seekBarStyle.width); 33 | const scrubStyle = getComputedStyle(scrubRef.current); 34 | const scrubWidth = parseFloat(scrubStyle.width); 35 | let newPosition = Math.max( 36 | (seekBarWidth * nextProgress) / 100 - scrubWidth, 37 | 0 38 | ); 39 | 40 | if (nextProgress === 0) { 41 | newPosition = 0; 42 | } else if (nextProgress === 100) { 43 | newPosition = seekBarWidth - 14; 44 | } 45 | 46 | scrubRef.current.style.left = newPosition + 'px'; 47 | 48 | const seekBarClientRect = seekBarRef.current.getBoundingClientRect(); 49 | const timeSinceLiveStyle = getComputedStyle(timeSinceLiveRef.current); 50 | const timeSinceLiveWidth = parseInt(timeSinceLiveStyle.width, 10); 51 | const timeSinceLiveBoundedPosition = bound( 52 | newPosition - timeSinceLiveWidth / 2 + 7, 53 | 8 - seekBarClientRect.left, 54 | seekBarClientRect.right - timeSinceLiveWidth - 8 55 | ); // On mobile, keeps the time within 8px of the left and right sides of the screen 56 | 57 | timeSinceLiveRef.current.style.left = timeSinceLiveBoundedPosition + 'px'; 58 | }, []); 59 | const updateProgress = useCallback( 60 | (progress, shouldUpdateScrubPosition = true) => { 61 | if ( 62 | hasError || 63 | (progress === undefined && (isMouseDown || playerType !== VOD)) 64 | ) 65 | return; 66 | 67 | const currentVODDuration = getVodDuration(); 68 | const currentVODPosition = getVodPosition(); 69 | const currentVODBufferedDuration = 70 | vodPlayerInstance?.getBufferDuration() || 0; 71 | 72 | let nextProgress = 73 | progress !== undefined 74 | ? progress 75 | : (currentVODPosition / currentVODDuration) * 100; 76 | if (!isFinite(nextProgress)) nextProgress = 0; 77 | 78 | let nextBufferPercent = 79 | ((currentVODPosition + currentVODBufferedDuration) / 80 | currentVODDuration) * 81 | 100; 82 | if (!isFinite(nextBufferPercent)) nextBufferPercent = 0; 83 | 84 | const nextPos = (nextProgress / 100) * currentVODDuration; 85 | 86 | setCurrProgress(nextProgress, (prevProgress) => { 87 | setBufferPercent( 88 | prevProgress > nextProgress ? nextProgress : nextBufferPercent 89 | ); 90 | 91 | setTimeSinceLive(Math.floor(currentVODDuration - nextPos)); 92 | }); 93 | 94 | if (shouldUpdateScrubPosition) { 95 | updateScrubPosition(nextProgress); 96 | } 97 | }, 98 | [ 99 | getVodDuration, 100 | getVodPosition, 101 | hasError, 102 | isMouseDown, 103 | updateScrubPosition, 104 | playerType, 105 | setBufferPercent, 106 | setCurrProgress, 107 | vodPlayerInstance 108 | ] 109 | ); 110 | const onScrubHandler = useCallback( 111 | (event, override) => { 112 | // "override" is used when the user just sought by clicking on the bar 113 | if (isVodAvailable && !hasError && (isMouseDown || override)) { 114 | stopPropagAndResetTimeout(event); 115 | 116 | const seekBarClientRect = seekBarRef.current.getBoundingClientRect(); 117 | const seekBarStyle = getComputedStyle(seekBarRef.current); 118 | const seekBarWidth = parseFloat(seekBarStyle.width); 119 | const newPosition = event.clientX - seekBarClientRect.left - 7; 120 | let progress = (newPosition / seekBarWidth) * 100; 121 | 122 | if (event.clientX - 7 <= seekBarClientRect.left) { 123 | progress = 0; 124 | } else if (event.clientX + 7 >= seekBarClientRect.left + seekBarWidth) { 125 | progress = 100; 126 | } 127 | 128 | updateProgress(Math.max(0, progress)); 129 | } 130 | }, 131 | [ 132 | hasError, 133 | isMouseDown, 134 | isVodAvailable, 135 | stopPropagAndResetTimeout, 136 | updateProgress 137 | ] 138 | ); 139 | const onPointerDownHandler = useCallback( 140 | (event) => { 141 | setIsMouseDown(true); 142 | 143 | if ( 144 | // This condition is used to seek by clicking on the seek bar 145 | // The click can sometimes be on the child 146 | [event.target.id, event.target?.parentElement?.id].includes( 147 | playerControlSeekBarWrapperId 148 | ) 149 | ) { 150 | onScrubHandler(event, true); 151 | } else { 152 | stopPropagAndResetTimeout(event); 153 | } 154 | }, 155 | [onScrubHandler, stopPropagAndResetTimeout] 156 | ); 157 | const onMouseUpHandler = useCallback(() => { 158 | setIsMouseDown(false); 159 | }, []); 160 | 161 | useEffect(() => { 162 | const onResizeHandler = () => { 163 | updateScrubPosition(currProgress); 164 | }; 165 | 166 | document.addEventListener('pointermove', onScrubHandler); 167 | document.addEventListener('pointerup', onMouseUpHandler); 168 | window.addEventListener('resize', onResizeHandler); 169 | 170 | return () => { 171 | document.removeEventListener('pointermove', onScrubHandler); 172 | document.removeEventListener('pointerup', onMouseUpHandler); 173 | window.removeEventListener('resize', onResizeHandler); 174 | }; 175 | }, [currProgress, onScrubHandler, onMouseUpHandler, updateScrubPosition]); 176 | 177 | useEffect(() => { 178 | // Make sure we position the scrubber correctly on mount 179 | updateScrubPosition(100); 180 | }, [updateScrubPosition]); 181 | 182 | useEffect(() => { 183 | let intervalID; 184 | 185 | if (!isPaused && isVodAvailable) { 186 | intervalID = setInterval(updateProgress, 1000); 187 | } 188 | 189 | return () => clearInterval(intervalID); 190 | }, [isPaused, isVodAvailable, updateProgress]); 191 | 192 | return { 193 | isMouseDown, 194 | onPointerDownHandler, 195 | scrubRef, 196 | seekBarRef, 197 | timeSinceLive, 198 | timeSinceLiveRef, 199 | updateProgress 200 | }; 201 | }; 202 | 203 | export default useSeekBar; 204 | -------------------------------------------------------------------------------- /web-ui/src/hooks/useSinglePlayer.js: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react'; 2 | 3 | import { LIVE, VOD } from '../constants'; 4 | 5 | const { IVSPlayer } = window; 6 | const { 7 | create: createMediaPlayer, 8 | isPlayerSupported, 9 | PlayerEventType, 10 | PlayerState 11 | } = IVSPlayer; 12 | const { ENDED, PLAYING, READY, BUFFERING } = PlayerState; 13 | const { DURATION_CHANGED, ERROR } = PlayerEventType; 14 | 15 | class PlaybackError extends Error { 16 | constructor(message, type) { 17 | super(message); 18 | this.type = type; 19 | this.name = 'PlaybackError'; 20 | } 21 | } 22 | 23 | const useSinglePlayer = ({ 24 | livePlaybackUrl, 25 | vodPlaybackURL, 26 | isChannelLive 27 | }) => { 28 | const videoRef = useRef(null); 29 | const playerRef = useRef(null); 30 | const [error, setError] = useState(null); 31 | const [isInitialLoading, setIsInitialLoading] = useState(true); 32 | const [isLoading, setIsLoading] = useState(true); 33 | const [isPaused, setIsPaused] = useState(true); 34 | const [isReady, setIsReady] = useState(false); 35 | const [hasEnded, setHasEnded] = useState(false); 36 | const [activePlayerType, setActivePlayerType] = useState(LIVE); 37 | const hasError = !!error; 38 | const urlToLoad = 39 | activePlayerType === LIVE ? livePlaybackUrl : vodPlaybackURL; 40 | 41 | const setCurrentTime = useCallback((seekPosition) => { 42 | if (videoRef.current) { 43 | videoRef.current.currentTime = seekPosition; 44 | } 45 | }, []); 46 | 47 | // PlayerState event callback 48 | const onStateChange = useCallback(() => { 49 | if (!playerRef.current) return; 50 | 51 | const newState = playerRef.current.getState(); 52 | 53 | if (newState === ENDED && activePlayerType === LIVE) { 54 | setError(new PlaybackError(`Live stream has ended`, LIVE)); 55 | } else setError(null); 56 | 57 | setHasEnded(newState === ENDED); 58 | setIsLoading(newState === READY || newState === BUFFERING); 59 | setIsPaused(playerRef.current?.isPaused() || false); 60 | 61 | console.log(`${activePlayerType.toUpperCase()} Player State - ${newState}`); 62 | }, [activePlayerType]); 63 | 64 | // PlayerEventType.ERROR event callback 65 | const onError = useCallback( 66 | (err) => { 67 | console.warn( 68 | `${activePlayerType.toUpperCase()} Player Event - ERROR:`, 69 | err, 70 | playerRef.current 71 | ); 72 | 73 | setError(err); 74 | setIsLoading(false); 75 | setIsPaused(true); 76 | setIsReady(false); 77 | }, 78 | [activePlayerType] 79 | ); 80 | 81 | const onDurationChanged = useCallback((duration) => { 82 | setIsReady(duration > 0); 83 | setIsInitialLoading(false); 84 | setActivePlayerType(!isFinite(duration) ? LIVE : VOD); 85 | }, []); 86 | 87 | const destroy = useCallback(() => { 88 | if (!playerRef.current) return; 89 | 90 | // remove event listeners 91 | playerRef.current.removeEventListener(DURATION_CHANGED, onDurationChanged); 92 | playerRef.current.removeEventListener(READY, onStateChange); 93 | playerRef.current.removeEventListener(PLAYING, onStateChange); 94 | playerRef.current.removeEventListener(BUFFERING, onStateChange); 95 | playerRef.current.removeEventListener(ENDED, onStateChange); 96 | playerRef.current.removeEventListener(ERROR, onError); 97 | 98 | // delete and nullify player 99 | playerRef.current.pause(); 100 | playerRef.current.delete(); 101 | playerRef.current = null; 102 | videoRef.current?.removeAttribute('src'); // remove possible stale src 103 | }, [onDurationChanged, onError, onStateChange]); 104 | 105 | const create = useCallback(() => { 106 | if (!isPlayerSupported) { 107 | console.warn( 108 | 'The current browser does not support the Amazon IVS player.' 109 | ); 110 | return; 111 | } 112 | 113 | // If a player instance already exists, destroy it before creating a new one 114 | if (playerRef.current) destroy(); 115 | 116 | playerRef.current = createMediaPlayer({ 117 | serviceWorker: { 118 | url: '../workers/amazon-ivs-service-worker-loader.js' 119 | } 120 | }); 121 | 122 | playerRef.current.attachHTMLVideoElement(videoRef.current); 123 | 124 | playerRef.current.addEventListener(DURATION_CHANGED, onDurationChanged); 125 | playerRef.current.addEventListener(READY, onStateChange); 126 | playerRef.current.addEventListener(PLAYING, onStateChange); 127 | playerRef.current.addEventListener(BUFFERING, onStateChange); 128 | playerRef.current.addEventListener(ENDED, onStateChange); 129 | playerRef.current.addEventListener(ERROR, onError); 130 | }, [destroy, onDurationChanged, onError, onStateChange]); 131 | 132 | const play = useCallback(() => { 133 | if (!playerRef.current) return; 134 | 135 | if (hasError) { 136 | setIsLoading(true); 137 | playerRef.current.load(urlToLoad); 138 | } 139 | 140 | if (playerRef.current.isPaused()) { 141 | playerRef.current.play(); 142 | setIsPaused(false); 143 | } 144 | }, [hasError, urlToLoad]); 145 | 146 | const pause = useCallback(() => { 147 | if (!playerRef.current) return; 148 | 149 | if (!playerRef.current.isPaused()) { 150 | playerRef.current.pause(); 151 | setIsPaused(true); 152 | } 153 | }, []); 154 | 155 | const load = useCallback( 156 | (playbackUrl) => { 157 | if (!playbackUrl) return; 158 | 159 | if (!playerRef.current) create(); 160 | 161 | playerRef.current.load(playbackUrl); 162 | play(); 163 | }, 164 | [create, play] 165 | ); 166 | 167 | const reset = useCallback(() => { 168 | setIsPaused(true); 169 | setIsReady(false); 170 | setIsLoading(false); 171 | setIsInitialLoading(false); 172 | destroy(); 173 | }, [destroy]); 174 | 175 | useEffect(() => { 176 | if (hasError) reset(); 177 | }, [hasError, reset]); 178 | 179 | useEffect(() => { 180 | if (urlToLoad && isChannelLive) load(urlToLoad); 181 | }, [isChannelLive, load, urlToLoad]); 182 | 183 | useEffect(() => { 184 | if ( 185 | isChannelLive !== undefined && 186 | activePlayerType === LIVE && 187 | (!isChannelLive || !urlToLoad) 188 | ) { 189 | setError(new PlaybackError('Live stream is offline', LIVE)); 190 | } 191 | }, [isChannelLive, isInitialLoading, activePlayerType, urlToLoad]); 192 | 193 | return { 194 | error, 195 | instance: playerRef.current, 196 | hasEnded, 197 | isInitialLoading, 198 | isLoading, 199 | isPaused, 200 | isReady, 201 | pause, 202 | play, 203 | reset, 204 | setCurrentTime, 205 | setError, 206 | videoRef, 207 | activePlayerType, 208 | setActivePlayerType 209 | }; 210 | }; 211 | 212 | export default useSinglePlayer; 213 | -------------------------------------------------------------------------------- /web-ui/src/hooks/useSinglePlayerSeekBar.js: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react'; 2 | 3 | import { bound } from '../utils'; 4 | import { VOD } from '../constants'; 5 | import useControls from '../contexts/Controls/useControls'; 6 | import usePlayback from '../contexts/SinglePlayerPlayback/usePlayback'; 7 | 8 | export const playerControlSeekBarWrapperId = 'player-controls-seek-bar-wrapper'; 9 | 10 | const useSeekBar = () => { 11 | const [isMouseDown, setIsMouseDown] = useState(false); 12 | const { stopPropagAndResetTimeout } = useControls(); 13 | const { 14 | activePlayer, 15 | currProgress, 16 | getVodDuration, 17 | getVodPosition, 18 | isVodAvailable, 19 | setBufferPercent, 20 | setCurrProgress, 21 | vodPlayerInstance 22 | } = usePlayback(); 23 | const scrubRef = useRef(null); 24 | const seekBarRef = useRef(null); 25 | const timeSinceLiveRef = useRef(null); 26 | const [timeSinceLive, setTimeSinceLive] = useState(0); 27 | const { isPaused, type: playerType, error } = activePlayer; 28 | const hasError = !!error; 29 | 30 | const updateScrubPosition = useCallback((nextProgress) => { 31 | const seekBarStyle = getComputedStyle(seekBarRef.current); 32 | const seekBarWidth = parseFloat(seekBarStyle.width); 33 | const scrubStyle = getComputedStyle(scrubRef.current); 34 | const scrubWidth = parseFloat(scrubStyle.width); 35 | let newPosition = Math.max( 36 | (seekBarWidth * nextProgress) / 100 - scrubWidth, 37 | 0 38 | ); 39 | 40 | if (nextProgress === 0) { 41 | newPosition = 0; 42 | } else if (nextProgress === 100) { 43 | newPosition = seekBarWidth - 14; 44 | } 45 | 46 | scrubRef.current.style.left = newPosition + 'px'; 47 | 48 | const seekBarClientRect = seekBarRef.current.getBoundingClientRect(); 49 | const timeSinceLiveStyle = getComputedStyle(timeSinceLiveRef.current); 50 | const timeSinceLiveWidth = parseInt(timeSinceLiveStyle.width, 10); 51 | const timeSinceLiveBoundedPosition = bound( 52 | newPosition - timeSinceLiveWidth / 2 + 7, 53 | 8 - seekBarClientRect.left, 54 | seekBarClientRect.right - timeSinceLiveWidth - 8 55 | ); // On mobile, keeps the time within 8px of the left and right sides of the screen 56 | 57 | timeSinceLiveRef.current.style.left = timeSinceLiveBoundedPosition + 'px'; 58 | }, []); 59 | const updateProgress = useCallback( 60 | (progress, shouldUpdateScrubPosition = true) => { 61 | if ( 62 | hasError || 63 | (progress === undefined && (isMouseDown || playerType !== VOD)) 64 | ) 65 | return; 66 | 67 | const currentVODDuration = getVodDuration(); 68 | const currentVODPosition = getVodPosition(); 69 | const currentVODBufferedDuration = 70 | vodPlayerInstance?.getBufferDuration() || 0; 71 | 72 | let nextProgress = 73 | progress !== undefined 74 | ? progress 75 | : (currentVODPosition / currentVODDuration) * 100; 76 | if (!isFinite(nextProgress)) nextProgress = 0; 77 | 78 | let nextBufferPercent = 79 | ((currentVODPosition + currentVODBufferedDuration) / 80 | currentVODDuration) * 81 | 100; 82 | if (!isFinite(nextBufferPercent)) nextBufferPercent = 0; 83 | 84 | const nextPos = (nextProgress / 100) * currentVODDuration; 85 | 86 | setCurrProgress(nextProgress, (prevProgress) => { 87 | setBufferPercent( 88 | prevProgress > nextProgress ? nextProgress : nextBufferPercent 89 | ); 90 | 91 | setTimeSinceLive(Math.floor(currentVODDuration - nextPos)); 92 | }); 93 | 94 | if (shouldUpdateScrubPosition) { 95 | updateScrubPosition(nextProgress); 96 | } 97 | }, 98 | [ 99 | getVodDuration, 100 | getVodPosition, 101 | hasError, 102 | isMouseDown, 103 | updateScrubPosition, 104 | playerType, 105 | setBufferPercent, 106 | setCurrProgress, 107 | vodPlayerInstance 108 | ] 109 | ); 110 | const onScrubHandler = useCallback( 111 | (event, override) => { 112 | // "override" is used when the user just sought by clicking on the bar 113 | if (isVodAvailable && !hasError && (isMouseDown || override)) { 114 | stopPropagAndResetTimeout(event); 115 | 116 | const seekBarClientRect = seekBarRef.current.getBoundingClientRect(); 117 | const seekBarStyle = getComputedStyle(seekBarRef.current); 118 | const seekBarWidth = parseFloat(seekBarStyle.width); 119 | const newPosition = event.clientX - seekBarClientRect.left - 7; 120 | let progress = (newPosition / seekBarWidth) * 100; 121 | 122 | if (event.clientX - 7 <= seekBarClientRect.left) { 123 | progress = 0; 124 | } else if (event.clientX + 7 >= seekBarClientRect.left + seekBarWidth) { 125 | progress = 100; 126 | } 127 | 128 | updateProgress(Math.max(0, progress)); 129 | } 130 | }, 131 | [ 132 | hasError, 133 | isMouseDown, 134 | isVodAvailable, 135 | stopPropagAndResetTimeout, 136 | updateProgress 137 | ] 138 | ); 139 | const onPointerDownHandler = useCallback( 140 | (event) => { 141 | setIsMouseDown(true); 142 | 143 | if ( 144 | // This condition is used to seek by clicking on the seek bar 145 | // The click can sometimes be on the child 146 | [event.target.id, event.target?.parentElement?.id].includes( 147 | playerControlSeekBarWrapperId 148 | ) 149 | ) { 150 | onScrubHandler(event, true); 151 | } else { 152 | stopPropagAndResetTimeout(event); 153 | } 154 | }, 155 | [onScrubHandler, stopPropagAndResetTimeout] 156 | ); 157 | const onMouseUpHandler = useCallback(() => { 158 | setIsMouseDown(false); 159 | }, []); 160 | 161 | useEffect(() => { 162 | const onResizeHandler = () => { 163 | updateScrubPosition(currProgress); 164 | }; 165 | 166 | document.addEventListener('pointermove', onScrubHandler); 167 | document.addEventListener('pointerup', onMouseUpHandler); 168 | window.addEventListener('resize', onResizeHandler); 169 | 170 | return () => { 171 | document.removeEventListener('pointermove', onScrubHandler); 172 | document.removeEventListener('pointerup', onMouseUpHandler); 173 | window.removeEventListener('resize', onResizeHandler); 174 | }; 175 | }, [currProgress, onScrubHandler, onMouseUpHandler, updateScrubPosition]); 176 | 177 | useEffect(() => { 178 | // Make sure we position the scrubber correctly on mount 179 | updateScrubPosition(100); 180 | }, [updateScrubPosition]); 181 | 182 | useEffect(() => { 183 | let intervalID; 184 | 185 | if (!isPaused && isVodAvailable) { 186 | intervalID = setInterval(updateProgress, 1000); 187 | } 188 | 189 | return () => clearInterval(intervalID); 190 | }, [isPaused, isVodAvailable, updateProgress]); 191 | 192 | return { 193 | isMouseDown, 194 | onPointerDownHandler, 195 | scrubRef, 196 | seekBarRef, 197 | timeSinceLive, 198 | timeSinceLiveRef, 199 | updateProgress 200 | }; 201 | }; 202 | 203 | export default useSeekBar; 204 | -------------------------------------------------------------------------------- /web-ui/src/hooks/useStateWithCallback.js: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect, useCallback } from 'react'; 2 | import usePrevious from './usePrevious'; 3 | 4 | const useStateWithCallback = (initialValue) => { 5 | const callbackRef = useRef(null); 6 | const [value, setValue] = useState(initialValue); 7 | const prevValue = usePrevious(value); 8 | 9 | useEffect(() => { 10 | if (callbackRef.current) { 11 | callbackRef.current(prevValue); 12 | 13 | callbackRef.current = null; 14 | } 15 | }, [value, prevValue]); 16 | 17 | const setValueWithCallback = useCallback((newValue, callback) => { 18 | callbackRef.current = callback; 19 | 20 | return setValue(newValue); 21 | }, []); 22 | 23 | return [value, setValueWithCallback]; 24 | }; 25 | 26 | export default useStateWithCallback; 27 | -------------------------------------------------------------------------------- /web-ui/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --mobile-breakpoint: 875px; 3 | 4 | /* Palette colors - Based on Tachyons.css */ 5 | --color-black: #000; 6 | --color-near-black: #0f1112; 7 | 8 | --color-dark-gray: #33393c; 9 | --color-mid-gray: #4b5358; 10 | --color-gray: #8d9ca7; 11 | 12 | --color-silver: #999; 13 | --color-light-silver: #a2b4c0; 14 | 15 | --color-moon-gray: #dfe5e9; 16 | --color-light-gray: #e7ecf0; 17 | --color-near-white: #f1f2f3; 18 | --color-white: #fff; 19 | 20 | --color-dark-red: #e7040f; 21 | --color-red: #fd2222; 22 | --color-pill-red: #ff0000; 23 | --color-light-red: #ff725c; 24 | 25 | --color-orange: #ff6300; 26 | --color-gold: #ffb700; 27 | --color-yellow: #ffd700; 28 | --color-pill-yellow: #fff06a; 29 | --color-light-yellow: #fbf1a9; 30 | 31 | --color-purple: #5e2ca5; 32 | --color-light-purple: #a463f2; 33 | 34 | --color-dark-pink: #d5008f; 35 | --color-dark-mode-pink: #452142; 36 | --color-light-mode-pink: #f4e8f4; 37 | --color-hot-pink: #ff41b4; 38 | --color-pink: #ff80cc; 39 | --color-light-pink: #ffa3d7; 40 | 41 | --color-dark-green: #137752; 42 | --color-green: #0fd70b; 43 | --color-light-green: #9eebcf; 44 | 45 | --color-navy: #001b44; 46 | --color-dark-blue: #2026a2; 47 | --color-blue: #2b44ff; 48 | --color-light-blue: #8bb0ff; 49 | --color-lightest-blue: #e0eaff; 50 | 51 | --color-washed-blue: #f6fffe; 52 | --color-washed-green: #e8fdf5; 53 | --color-washed-yellow: #fffceb; 54 | --color-washed-red: #ffdfdf; 55 | 56 | --color-white-90: rgba(255, 255, 255, 0.9); 57 | --color-white-80: rgba(255, 255, 255, 0.8); 58 | --color-white-70: rgba(255, 255, 255, 0.7); 59 | --color-white-60: rgba(255, 255, 255, 0.6); 60 | --color-white-50: rgba(255, 255, 255, 0.5); 61 | --color-white-40: rgba(255, 255, 255, 0.4); 62 | --color-white-30: rgba(255, 255, 255, 0.3); 63 | --color-white-20: rgba(255, 255, 255, 0.2); 64 | --color-white-10: rgba(255, 255, 255, 0.1); 65 | --color-white-05: rgba(255, 255, 255, 0.05); 66 | 67 | --color-black-90: rgba(0, 0, 0, 0.9); 68 | --color-black-80: rgba(0, 0, 0, 0.8); 69 | --color-black-70: rgba(0, 0, 0, 0.7); 70 | --color-black-60: rgba(0, 0, 0, 0.6); 71 | --color-black-50: rgba(0, 0, 0, 0.5); 72 | --color-black-40: rgba(0, 0, 0, 0.4); 73 | --color-black-30: rgba(0, 0, 0, 0.3); 74 | --color-black-20: rgba(0, 0, 0, 0.2); 75 | --color-black-10: rgba(0, 0, 0, 0.1); 76 | --color-black-05: rgba(0, 0, 0, 0.05); 77 | 78 | /* Tailwind hex colors */ 79 | --color-zinc-200: #e4e4e7; 80 | --color-zinc-800: #27272a; 81 | } 82 | 83 | @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap'); 84 | 85 | *, 86 | *::before, 87 | *::after { 88 | margin: 0; 89 | padding: 0; 90 | box-sizing: border-box; 91 | } 92 | 93 | html { 94 | scroll-behavior: smooth; 95 | } 96 | 97 | body { 98 | overflow: hidden; 99 | color: var(--color-white); 100 | background: var(--color-black); 101 | text-rendering: optimizeSpeed; 102 | font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 103 | Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif, 104 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 105 | -webkit-font-smoothing: antialiased; 106 | -moz-osx-font-smoothing: grayscale; 107 | } 108 | 109 | @media (prefers-reduced-motion: reduce) { 110 | * { 111 | animation-duration: 0.01ms !important; 112 | animation-iteration-count: 1 !important; 113 | transition-duration: 0.01ms !important; 114 | scroll-behavior: auto !important; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /web-ui/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | 6 | const root = createRoot(document.getElementById('root')); 7 | 8 | root.render( 9 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /web-ui/src/utils.js: -------------------------------------------------------------------------------- 1 | export const formatTime = (timeInSeconds = 0) => { 2 | let seconds = Math.floor(timeInSeconds % 60); 3 | let minutes = Math.floor((timeInSeconds / 60) % 60); 4 | let hours = Math.floor(timeInSeconds / (60 * 60)); 5 | 6 | seconds = seconds.toString().padStart(2, '0'); 7 | if (timeInSeconds >= 600) minutes = minutes.toString().padStart(2, '0'); 8 | if (timeInSeconds >= 36000) hours = hours.toString().padStart(2, '0'); 9 | 10 | return timeInSeconds >= 3600 11 | ? `${hours}:${minutes}:${seconds}` 12 | : `${minutes}:${seconds}`; 13 | }; 14 | 15 | export const bound = (value, min = null, max = null) => { 16 | let boundedValue = value; 17 | 18 | if (min !== null) boundedValue = Math.max(min, value); 19 | if (max !== null) boundedValue = Math.min(max, boundedValue); 20 | 21 | return boundedValue; 22 | }; 23 | 24 | export const isiOS = () => 25 | [ 26 | 'iPad Simulator', 27 | 'iPhone Simulator', 28 | 'iPod Simulator', 29 | 'iPad', 30 | 'iPhone', 31 | 'iPod' 32 | ].includes(navigator.platform) || 33 | (navigator.userAgent.includes('Mac') && 'ontouchend' in document); 34 | -------------------------------------------------------------------------------- /web-ui/src/workers/amazon-ivs-service-worker-loader.js: -------------------------------------------------------------------------------- 1 | importScripts( 2 | 'https://player.live-video.net/1.30.0/amazon-ivs-service-worker.min.js' 3 | ); 4 | --------------------------------------------------------------------------------