├── serverless
├── README.md
├── app_screenshot.png
├── THIRD-PARTY-LICENSES.txt
└── serverless
│ ├── lambda
│ ├── put-live-channel.js
│ ├── delete-video.js
│ ├── get-live-details.js
│ ├── put-video.js
│ ├── live-cron-event.js
│ ├── get-videos.js
│ ├── get-live-channels.js
│ ├── stream-state-change-event.js
│ ├── reset-stream-key.js
│ └── index.js
│ └── README.md
├── .gitattributes
├── public
├── robots.txt
├── favicon.ico
├── logo192.png
├── logo512.png
├── manifest.json
└── index.html
├── src
├── components
│ ├── AlertPopover
│ │ ├── AlertPopover.module.css
│ │ └── AlertPopover.jsx
│ ├── Navbar
│ │ ├── Navbar.module.css
│ │ └── Navbar.jsx
│ ├── VideoPlayer
│ │ ├── VideoPlayer.css
│ │ └── VideoPlayer.jsx
│ ├── SaveFooter
│ │ ├── SaveFooter.jsx
│ │ └── SaveFooter.module.css
│ ├── LiveCard
│ │ ├── LiveCard.jsx
│ │ └── LiveCard.module.css
│ ├── VodCard
│ │ ├── VodCard.jsx
│ │ └── VodCard.module.css
│ └── VodCardController.jsx
├── config.js
├── stream-details-api.json
├── setupTests.js
├── App.test.js
├── pages
│ ├── AdminHome.module.css
│ ├── Home.module.css
│ ├── AdminLive.module.css
│ ├── AdminVideo.module.css
│ ├── AdminHome.jsx
│ ├── Home.jsx
│ ├── Video.jsx
│ ├── AdminVideo.jsx
│ └── AdminLive.jsx
├── reportWebVitals.js
├── mock-api.json
├── live-stream-api.json
├── index.js
├── utility
│ └── FormatTimestamp.js
├── App.css
├── get-video-api.json
├── App.js
├── logo.svg
└── index.css
├── .gitignore
├── package.json
└── THIRD-PARTY-LICENSES.txt
/serverless/README.md:
--------------------------------------------------------------------------------
1 | # serverless
2 | launch aws s3 cloudformation
3 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dreamfullstacker/Liveshopping-AWS-IVS-autorecord-to-S3-bucket-simple-demo/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dreamfullstacker/Liveshopping-AWS-IVS-autorecord-to-S3-bucket-simple-demo/HEAD/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dreamfullstacker/Liveshopping-AWS-IVS-autorecord-to-S3-bucket-simple-demo/HEAD/public/logo512.png
--------------------------------------------------------------------------------
/src/components/AlertPopover/AlertPopover.module.css:
--------------------------------------------------------------------------------
1 | .showPopover {
2 | display: block;
3 | }
4 |
5 | .hidePopover {
6 | display: none;
7 | }
8 |
--------------------------------------------------------------------------------
/serverless/app_screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dreamfullstacker/Liveshopping-AWS-IVS-autorecord-to-S3-bucket-simple-demo/HEAD/serverless/app_screenshot.png
--------------------------------------------------------------------------------
/src/config.js:
--------------------------------------------------------------------------------
1 | export const API_URL = "https://fnxl5h1bmh.execute-api.eu-west-1.amazonaws.com/api"
2 | export const USE_MOCK_DATA = false;
3 | export const POLL_DELAY_MS = 5000;
4 |
--------------------------------------------------------------------------------
/src/stream-details-api.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": {
3 | "ingest": "rtmps://XXXXXXXXXX.global-contribute.live-video.net:443/app/",
4 | "key": "sk_us-west-2_XXXXXXXX_XXXXXXXXXXXXXXX"
5 | }
6 | }
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import App from './App';
3 |
4 | test('renders learn react link', () => {
5 | render( );
6 | const linkElement = screen.getByText(/learn react/i);
7 | expect(linkElement).toBeInTheDocument();
8 | });
9 |
--------------------------------------------------------------------------------
/src/pages/AdminHome.module.css:
--------------------------------------------------------------------------------
1 | .h1 {
2 | font-weight: 800;
3 | font-size: 2.4rem;
4 | line-height: 1.1875;
5 | margin-bottom: 3.2rem;
6 | }
7 |
8 | .h2 {
9 | font-weight: 400;
10 | font-size: 1.6rem;
11 | line-height: 1.1875;
12 | margin-bottom: 1.2rem;
13 | }
14 |
--------------------------------------------------------------------------------
/src/pages/Home.module.css:
--------------------------------------------------------------------------------
1 | .h1 {
2 | font-weight: 800;
3 | font-size: 2.4rem;
4 | line-height: 1.1875;
5 | }
6 |
7 | .h2 {
8 | font-weight: 400;
9 | font-size: 1.6rem;
10 | line-height: 1.1875;
11 | }
12 |
13 | .offline {
14 | font-weight: 800;
15 | font-size: 1.6rem;
16 | display: flex;
17 | justify-content: center;
18 | }
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /web-ui/node_modules
5 |
6 | # production
7 | /web-ui/build
8 |
9 | # misc/OS files
10 | ehthumbs.db
11 | Thumbs.db
12 | .DS_Store
13 | .DS_Store?
14 | ._*
15 | .Spotlight-V100
16 | .Trashes
17 | .eslintcache
18 | node_modules
19 |
--------------------------------------------------------------------------------
/src/reportWebVitals.js:
--------------------------------------------------------------------------------
1 | const reportWebVitals = onPerfEntry => {
2 | if (onPerfEntry && onPerfEntry instanceof Function) {
3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
4 | getCLS(onPerfEntry);
5 | getFID(onPerfEntry);
6 | getFCP(onPerfEntry);
7 | getLCP(onPerfEntry);
8 | getTTFB(onPerfEntry);
9 | });
10 | }
11 | };
12 |
13 | export default reportWebVitals;
14 |
--------------------------------------------------------------------------------
/src/components/Navbar/Navbar.module.css:
--------------------------------------------------------------------------------
1 | .header {
2 | display: flex;
3 | justify-content: space-between;
4 | align-items: center;
5 | }
6 |
7 | .link {
8 | color: var(--color-text-base);
9 | display: inline-block;
10 | padding: 0 1rem;
11 | margin-left: -1rem;
12 | line-height: var(--header-height);
13 | }
14 |
15 | .link:visited {
16 | color: var(--color-text-base);
17 | }
18 |
19 | .link:hover {
20 | background: var(--color-bg-alt);
21 | }
22 |
23 | .adminActive {
24 | display: none;
25 | }
26 |
--------------------------------------------------------------------------------
/src/mock-api.json:
--------------------------------------------------------------------------------
1 | {
2 | "vods": [
3 | {
4 | "id": "st-1234567890TEST",
5 | "title": "Title 1",
6 | "subtitle": "Subtitle 1",
7 | "views": "27",
8 | "length": "2:48",
9 | "thumbnail": "https://d39ii5l128t5ul.cloudfront.net/assets/R2S3/media/thumbnails/thumb0.jpg",
10 | "created_on": "2020-06-24T07:51:32Z",
11 | "playbackUrl": "https://d39ii5l128t5ul.cloudfront.net/assets/R2S3/media/hls/master.m3u8"
12 | }
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/Navbar/Navbar.jsx:
--------------------------------------------------------------------------------
1 | import { NavLink } from "react-router-dom";
2 | import styles from "./Navbar.module.css";
3 |
4 | function Navbar() {
5 | return (
6 |
7 |
8 |
9 | Home
10 |
11 |
12 |
13 | Admin Panel →
14 |
15 |
16 | );
17 | }
18 |
19 | export default Navbar;
20 |
--------------------------------------------------------------------------------
/src/live-stream-api.json:
--------------------------------------------------------------------------------
1 | {
2 | "data":
3 | {
4 | "id": "st-1234567891TEST",
5 | "title": "A day in Seattle",
6 | "subtitle": "Looping footage of Seattle, WA",
7 | "thumbnail": "https://d39ii5l128t5ul.cloudfront.net/assets/R2S3/media/thumbnails/thumb0.jpg",
8 | "isLive": "Yes",
9 | "viewers": 8,
10 | "playbackUrl": "https://fcc3ddae59ed.us-west-2.playback.live-video.net/api/video/v1/us-west-2.893648527354.channel.DmumNckWFTqz.m3u8"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './App';
5 | import reportWebVitals from './reportWebVitals';
6 |
7 | ReactDOM.render(
8 |
9 |
10 | ,
11 | document.getElementById('root')
12 | );
13 |
14 | // If you want to start measuring performance in your app, pass a function
15 | // to log results (for example: reportWebVitals(console.log))
16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
17 | reportWebVitals();
18 |
--------------------------------------------------------------------------------
/src/utility/FormatTimestamp.js:
--------------------------------------------------------------------------------
1 | export default function FormatTimestamp(timestamp) {
2 | if (!timestamp) return;
3 |
4 | var splitArray = timestamp.split(':');
5 | var hours = parseInt(splitArray[0]);
6 | var minutes = parseInt(splitArray[1]);
7 | var seconds = parseInt(splitArray[2]);
8 |
9 | if (hours <= 0) {
10 | hours = "";
11 | } else {
12 | hours = `${hours}h `
13 | }
14 | if (minutes <= 0) {
15 | minutes = ""
16 | } else {
17 | minutes = `${minutes}m `
18 | }
19 | seconds = `${seconds}s`
20 |
21 | return `${hours}${minutes}${seconds}`
22 | }
23 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Demo App",
3 | "name": "Amazon IVS Auto-record to S3 Web Demo",
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 |
--------------------------------------------------------------------------------
/src/components/VideoPlayer/VideoPlayer.css:
--------------------------------------------------------------------------------
1 | /* Variable Overrides */
2 | :root {
3 | --video-width: 88.4rem;
4 | }
5 |
6 | /* Align the quality menu to right side of video container */
7 | .video-js .vjs-menu-button-popup .vjs-menu {
8 | left: auto;
9 | right: 0;
10 | }
11 |
12 | .video-js .vjs-tech {
13 | outline: none;
14 | }
15 |
16 | .video-js .vjs-control-bar {
17 | border-radius: 0 0 5px 5px;
18 | }
19 |
20 | @media (max-width: 480px) {
21 | /* Smaller Screens */
22 | :root {
23 | --video-width: 100%;
24 | }
25 | }
26 |
27 | @media (min-width: 1024px) and (max-width: 1280px) {
28 | /* Large Screens */
29 | :root {
30 | --video-width: 64rem;
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | text-align: center;
3 | }
4 |
5 | .App-logo {
6 | height: 40vmin;
7 | pointer-events: none;
8 | }
9 |
10 | @media (prefers-reduced-motion: no-preference) {
11 | .App-logo {
12 | animation: App-logo-spin infinite 20s linear;
13 | }
14 | }
15 |
16 | .App-header {
17 | background-color: #282c34;
18 | min-height: 100vh;
19 | display: flex;
20 | flex-direction: column;
21 | align-items: center;
22 | justify-content: center;
23 | font-size: calc(10px + 2vmin);
24 | color: white;
25 | }
26 |
27 | .App-link {
28 | color: #61dafb;
29 | }
30 |
31 | @keyframes App-logo-spin {
32 | from {
33 | transform: rotate(0deg);
34 | }
35 | to {
36 | transform: rotate(360deg);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/SaveFooter/SaveFooter.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 | import styles from "./SaveFooter.module.css";
3 |
4 | function SaveFooter(props) {
5 | return (
6 |
11 |
12 | You have unsaved changes
13 |
14 | Save
15 |
16 |
17 |
18 | );
19 | }
20 |
21 | SaveFooter.propTypes = {
22 | visible: PropTypes.bool,
23 | onSave: PropTypes.func,
24 | };
25 |
26 | export default SaveFooter;
27 |
--------------------------------------------------------------------------------
/src/components/SaveFooter/SaveFooter.module.css:
--------------------------------------------------------------------------------
1 | .wrapper {
2 | display: flex;
3 | position: fixed;
4 | bottom: 4rem;
5 | width: 100%;
6 | transform: translateY(0);
7 | transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
8 | z-index: 9;
9 | }
10 |
11 | .container {
12 | margin: 0 auto;
13 | width: 100%;
14 | max-width: var(--section-max-width);
15 | border-radius: var(--radius);
16 | background: var(--color-bg-inverted);
17 | box-shadow: 0rem 0.5rem 1rem var(--color-black-10);
18 | display: flex;
19 | align-items: center;
20 | justify-content: space-between;
21 | padding: 1.2rem 1.2rem 1.2rem 1.6rem;
22 | }
23 |
24 | .hidden {
25 | transform: translateY(20rem);
26 | transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
27 | }
28 |
--------------------------------------------------------------------------------
/src/pages/AdminLive.module.css:
--------------------------------------------------------------------------------
1 | .h1 {
2 | font-weight: 800;
3 | font-size: 2.4rem;
4 | line-height: 1.1875;
5 | }
6 |
7 | .inlineButtons {
8 | display: flex;
9 | align-items: center;
10 | }
11 |
12 | .inlineButtons > input {
13 | flex-grow: 1;
14 | flex-shrink: 1;
15 | margin-right: 1.2rem;
16 | }
17 |
18 | .inlineButtons > button {
19 | flex-grow: 0;
20 | flex-shrink: 0;
21 | max-width: 8rem;
22 | margin-right: 1.2rem;
23 | }
24 |
25 | .inlineButtons > input:last-child,
26 | .inlineButtons > button:last-child {
27 | margin-right: 0;
28 | }
29 |
30 | .field {
31 | border-color: darkgrey;
32 | border-width: 1;
33 | border-radius: 8px;
34 | transition: 0.3s all;
35 | }
36 |
37 | .field:hover {
38 | background-color: rgba(255, 255, 255, 0.45);
39 | box-shadow: 0px 4px 20px 0px rgba(0, 0, 0, 0.05);
40 | }
41 |
42 | .label {
43 | font-weight: 500;
44 | }
45 |
--------------------------------------------------------------------------------
/src/get-video-api.json:
--------------------------------------------------------------------------------
1 | {
2 | "vods": [
3 | {
4 | "id": "st-1234567890TEST",
5 | "title": "This is a test title",
6 | "subtitle": "This is a test subtitle",
7 | "views": "27",
8 | "length": "2:48",
9 | "thumbnail": "https://d39ii5l128t5ul.cloudfront.net/assets/R2S3/media/thumbnails/thumb0.jpg",
10 | "thumbnails": [
11 | "https://d39ii5l128t5ul.cloudfront.net/assets/R2S3/media/thumbnails/thumb0.jpg",
12 | "https://d39ii5l128t5ul.cloudfront.net/assets/R2S3/media/thumbnails/thumb1.jpg",
13 | "https://d39ii5l128t5ul.cloudfront.net/assets/R2S3/media/thumbnails/thumb2.jpg"
14 | ],
15 | "created_on": "2020-06-24T07:51:32Z",
16 | "playbackUrl": "https://d39ii5l128t5ul.cloudfront.net/assets/R2S3/media/hls/master.m3u8"
17 | }
18 | ]
19 | }
20 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Home from "./pages/Home";
3 | import Video from "./pages/Video";
4 | import AdminHome from "./pages/AdminHome";
5 | import AdminLive from "./pages/AdminLive";
6 | import AdminVideo from "./pages/AdminVideo";
7 | import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
8 |
9 | function App() {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | );
31 | }
32 |
33 | export default App;
34 |
--------------------------------------------------------------------------------
/src/pages/AdminVideo.module.css:
--------------------------------------------------------------------------------
1 | .thumbnailRadio {
2 | display: none;
3 | }
4 |
5 | .thumbnailRadioImage {
6 | width: 100%;
7 | height: auto;
8 | }
9 |
10 | .thumbnailRadioImage:hover {
11 | cursor: pointer;
12 | }
13 |
14 | .thumbnailSelectors {
15 | display: grid;
16 | grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
17 | grid-gap: 1rem;
18 | }
19 |
20 | .thumbnailRadio + label {
21 | display: block;
22 | border-radius: var(--radius-small);
23 | border: 2px solid transparent;
24 | overflow: hidden;
25 | }
26 |
27 | .thumbnailRadio:checked + label {
28 | border: 2px solid var(--color-text-primary);
29 | box-shadow: inset 0 0 2px var(--color-bg-base);
30 | }
31 |
32 | .field {
33 | border-color: darkgrey;
34 | border-width: 1;
35 | border-radius: 8px;
36 | transition: 0.3s all;
37 | }
38 |
39 | .field:hover {
40 | background-color: rgba(255, 255, 255, 0.45);
41 | box-shadow: 0px 4px 20px 0px rgba(0, 0, 0, 0.05);
42 | }
--------------------------------------------------------------------------------
/src/components/LiveCard/LiveCard.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 | import styles from "./LiveCard.module.css";
3 | import { Link } from "react-router-dom";
4 |
5 | function LiveCard(props) {
6 | let prefix = props.linkType === "admin" ? "/admin" : "/video";
7 | return (
8 |
9 |
10 |
{props.children}
11 |
12 |
13 | {props.title}
14 |
15 | {props.subtitle}
16 |
17 | {props.hint}
18 |
19 |
20 |
21 | );
22 | }
23 |
24 | LiveCard.propTypes = {
25 | id: PropTypes.string,
26 | thumbnailUrl: PropTypes.string,
27 | title: PropTypes.string,
28 | subtitle: PropTypes.string,
29 | hint: PropTypes.string,
30 | linkType: PropTypes.string,
31 | };
32 |
33 | export default LiveCard;
34 |
--------------------------------------------------------------------------------
/src/components/VodCard/VodCard.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 | import styles from "./VodCard.module.css";
3 | import { Link } from "react-router-dom";
4 |
5 | function VodCard(props) {
6 | let prefix = props.linkType === "admin" ? "/admin" : "/video";
7 | return (
8 |
9 |
10 |
11 |
15 |
16 |
17 |
18 | {props.title}
19 |
20 | {props.subtitle}
21 |
22 | {props.hint}
23 |
24 |
25 |
26 | );
27 | }
28 |
29 | VodCard.propTypes = {
30 | id: PropTypes.string,
31 | thumbnailUrl: PropTypes.string,
32 | title: PropTypes.string,
33 | subtitle: PropTypes.string,
34 | hint: PropTypes.string,
35 | linkType: PropTypes.string,
36 | };
37 |
38 | export default VodCard;
39 |
--------------------------------------------------------------------------------
/src/components/LiveCard/LiveCard.module.css:
--------------------------------------------------------------------------------
1 | .thumbnail {
2 | --width: 16.8rem;
3 | --height: 9.4rem;
4 | }
5 |
6 | .wrapper {
7 | display: flex;
8 | flex-wrap: nowrap;
9 | width: 100%;
10 | margin-bottom: 1.6rem;
11 | background: transparent;
12 | border-radius: var(--radius);
13 | padding: 1rem;
14 | margin: -0.5rem -1rem;
15 | }
16 |
17 | .wrapper:hover {
18 | background: var(--color-bg-alt);
19 | }
20 |
21 | .thumbnail {
22 | flex-grow: 0;
23 | flex-shrink: 0;
24 | width: var(--width);
25 | height: var(--height);
26 | margin-right: 1.6rem;
27 | border-radius: var(--radius-small);
28 | overflow: hidden;
29 | }
30 |
31 | .metaWrapper {
32 | display: flex;
33 | width: 100%;
34 | flex-wrap: wrap;
35 | align-items: flex-start;
36 | align-content: space-between;
37 | }
38 |
39 | .metaWrapper > span {
40 | width: 100%;
41 | }
42 |
43 | .title {
44 | font-weight: 800;
45 | color: var(--color-text-base);
46 | align-self: flex-start;
47 | }
48 |
49 | .wrapper:hover .title {
50 | color: var(--color-text-primary);
51 | }
52 |
53 | .subtitle {
54 | color: var(--color-text-alt);
55 | align-self: flex-start;
56 | }
57 |
58 | .hint {
59 | color: var(--color-text-hint);
60 | align-self: flex-end;
61 | }
62 |
--------------------------------------------------------------------------------
/src/components/VodCard/VodCard.module.css:
--------------------------------------------------------------------------------
1 | .thumbnail {
2 | --width: 16.8rem;
3 | --height: 9.4rem;
4 | }
5 |
6 | .wrapper {
7 | display: flex;
8 | flex-wrap: nowrap;
9 | width: calc(100% + 2rem);
10 | margin-bottom: 1.6rem;
11 | background: transparent;
12 | border-radius: var(--radius);
13 | padding: 1rem;
14 | margin: 0 0 0 -1rem;
15 | }
16 |
17 | .wrapper:hover {
18 | background: var(--color-bg-alt);
19 | }
20 |
21 | .thumbnail {
22 | flex-grow: 0;
23 | flex-shrink: 0;
24 | width: var(--width);
25 | height: var(--height);
26 | margin-right: 1.6rem;
27 | border-radius: var(--radius-small);
28 | overflow: hidden;
29 | }
30 |
31 | .metaWrapper {
32 | display: flex;
33 | width: 100%;
34 | flex-wrap: wrap;
35 | align-items: flex-start;
36 | align-content: space-between;
37 | }
38 |
39 | .metaWrapper > span {
40 | width: 100%;
41 | }
42 |
43 | .title {
44 | font-weight: 800;
45 | color: var(--color-text-base);
46 | align-self: flex-start;
47 | }
48 |
49 | .wrapper:hover .title {
50 | color: var(--color-text-primary);
51 | }
52 |
53 | .subtitle {
54 | color: var(--color-text-alt);
55 | align-self: flex-start;
56 | }
57 |
58 | .hint {
59 | color: var(--color-text-hint);
60 | align-self: flex-end;
61 | }
62 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "r2s3-demo-client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@testing-library/jest-dom": "^5.15.1",
7 | "@testing-library/react": "^12.1.2",
8 | "@testing-library/user-event": "^13.5.0",
9 | "lodash.isempty": "^4.4.0",
10 | "react": "^17.0.2",
11 | "react-dom": "^17.0.2",
12 | "react-router-dom": "^5.3.0",
13 | "react-scripts": "5.0.0",
14 | "web-vitals": "^2.1.2",
15 | "yarn": "^1.22.18"
16 | },
17 | "scripts": {
18 | "start": "react-scripts start",
19 | "build": "react-scripts build",
20 | "test": "react-scripts test",
21 | "eject": "react-scripts eject"
22 | },
23 | "resolutions": {
24 | "browserslist": "^4.16.5",
25 | "dns-packet": "^1.3.2",
26 | "ws": "^7.4.6",
27 | "normalize-url": "^4.5.1",
28 | "glob-parent": "5.1.2"
29 | },
30 | "eslintConfig": {
31 | "extends": [
32 | "react-app",
33 | "react-app/jest"
34 | ]
35 | },
36 | "browserslist": {
37 | "production": [
38 | ">0.2%",
39 | "not dead",
40 | "not op_mini all"
41 | ],
42 | "development": [
43 | "last 1 chrome version",
44 | "last 1 firefox version",
45 | "last 1 safari version"
46 | ]
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/AlertPopover/AlertPopover.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 | import styles from "./AlertPopover.module.css"
3 |
4 | function AlertPopover(props) {
5 | const icon = props.error ?
6 | ()
7 | :
8 | ()
9 | return (
10 |
11 |
12 |
13 | {icon}
14 |
{props.text}
15 |
16 |
17 |
18 | );
19 | }
20 |
21 | AlertPopover.propTypes = {
22 | visible: PropTypes.bool,
23 | text: PropTypes.string,
24 | error: PropTypes.bool
25 | };
26 |
27 | export default AlertPopover;
28 |
--------------------------------------------------------------------------------
/THIRD-PARTY-LICENSES.txt:
--------------------------------------------------------------------------------
1 | This project uses the following resources:
2 |
3 | ** React; version 16.13.1 -- https://github.com/facebook/react
4 |
5 | MIT License
6 |
7 | Copyright (c) Facebook, Inc. and its affiliates.
8 |
9 | Permission is hereby granted, free of charge, to any person obtaining a copy
10 | of this software and associated documentation files (the "Software"), to deal
11 | in the Software without restriction, including without limitation the rights
12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 | copies of the Software, and to permit persons to whom the Software is
14 | furnished to do so, subject to the following conditions:
15 |
16 | The above copyright notice and this permission notice shall be included in all
17 | copies or substantial portions of the Software.
18 |
19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 | SOFTWARE.
26 |
27 | ** Big Buck Bunny, licensed under the Creative Commons Attribution 3.0 license. (c) copyright 2008, Blender Foundation / www.bigbuckbunny.org
28 | Referenced in the following files:
29 | - web-ui/src/mock-api.json
30 | - web-ui/src/get-video-api.json
31 | - web-ui/src/live-stream-api.json
32 | - web-ui/src/config.js
33 |
--------------------------------------------------------------------------------
/serverless/THIRD-PARTY-LICENSES.txt:
--------------------------------------------------------------------------------
1 | This project uses the following resources:
2 |
3 | ** React; version 16.13.1 -- https://github.com/facebook/react
4 |
5 | MIT License
6 |
7 | Copyright (c) Facebook, Inc. and its affiliates.
8 |
9 | Permission is hereby granted, free of charge, to any person obtaining a copy
10 | of this software and associated documentation files (the "Software"), to deal
11 | in the Software without restriction, including without limitation the rights
12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 | copies of the Software, and to permit persons to whom the Software is
14 | furnished to do so, subject to the following conditions:
15 |
16 | The above copyright notice and this permission notice shall be included in all
17 | copies or substantial portions of the Software.
18 |
19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 | SOFTWARE.
26 |
27 | ** Big Buck Bunny, licensed under the Creative Commons Attribution 3.0 license. (c) copyright 2008, Blender Foundation / www.bigbuckbunny.org
28 | Referenced in the following files:
29 | - web-ui/src/mock-api.json
30 | - web-ui/src/get-video-api.json
31 | - web-ui/src/live-stream-api.json
32 | - web-ui/src/config.js
33 |
--------------------------------------------------------------------------------
/serverless/serverless/lambda/put-live-channel.js:
--------------------------------------------------------------------------------
1 |
2 | const AWS = require('aws-sdk');
3 |
4 | const {
5 | REGION,
6 | CHANNELS_TABLE_NAME
7 |
8 | } = process.env;
9 |
10 |
11 | const ddb = new AWS.DynamoDB();
12 |
13 | const response = (body, statusCode = 200) => {
14 | return {
15 | statusCode,
16 | headers: {
17 | 'Content-Type': 'application/json',
18 | 'Access-Control-Allow-Origin': '*'
19 | },
20 | body: JSON.stringify(body)
21 | };
22 | };
23 |
24 | // PUT /live
25 | exports.putLiveChannel = async (event) => {
26 | console.log("putLiveChannel:", JSON.stringify(event, null, 2));
27 |
28 | try {
29 |
30 | const body = JSON.parse(event.body);
31 |
32 | const params = {
33 | TableName: CHANNELS_TABLE_NAME,
34 | Key: {
35 | 'Id': {
36 | S: body.channelName
37 | }
38 | },
39 | ExpressionAttributeNames: {
40 | '#Title': 'Title',
41 | '#Subtitle': 'Subtitle'
42 | },
43 | ExpressionAttributeValues: {
44 | ':title': {
45 | S: body.title
46 | },
47 | ':subtitle': {
48 | S: body.subtitle
49 | }
50 | },
51 | UpdateExpression: 'SET #Title = :title, #Subtitle = :subtitle',
52 | ReturnValues: "ALL_NEW"
53 | };
54 |
55 | console.info("putLiveChannel > params:", JSON.stringify(params, null, 2));
56 |
57 | const result = await ddb.updateItem(params).promise();
58 |
59 | console.info("putLiveChannel > result:", JSON.stringify(result, null, 2));
60 |
61 | return response(result);
62 |
63 | } catch (err) {
64 |
65 | console.info("putLiveChannel > err:", err);
66 | return response(err, 500);
67 |
68 | }
69 | };
70 |
--------------------------------------------------------------------------------
/serverless/serverless/lambda/delete-video.js:
--------------------------------------------------------------------------------
1 | const AWS = require('aws-sdk');
2 |
3 | const {
4 | REGION,
5 | VIDEOS_TABLE_NAME
6 |
7 | } = process.env;
8 |
9 | const ivs = new AWS.IVS({
10 | apiVersion: '2020-07-14',
11 | REGION // Must be in one of the supported regions
12 | });
13 |
14 | const S3 = new AWS.S3({
15 | apiVersion: '2006-03-01'
16 | });
17 |
18 | const ddb = new AWS.DynamoDB();
19 |
20 | const response = (body, statusCode = 200) => {
21 | return {
22 | statusCode,
23 | headers: {
24 | 'Content-Type': 'application/json',
25 | 'Access-Control-Allow-Origin': '*'
26 | },
27 | body: JSON.stringify(body)
28 | };
29 | };
30 |
31 | // DELETE /video/:id
32 | exports.deleteRecordedVideo = async (event) => {
33 | try {
34 | if (!event.pathParameters.id) {
35 | return response({ message: 'Missing id' }, 400);
36 | }
37 |
38 | let params = {
39 | TableName: VIDEOS_TABLE_NAME,
40 | Key: {
41 | "Id": {
42 | S: event.pathParameters.id
43 | }
44 | }
45 |
46 | };
47 |
48 | console.info("deleteRecordedVideo > params:", params);
49 |
50 | let dbResult = await ddb.getItem(params).promise();
51 |
52 | if ((!result.Item.RecordingConfiguration || !result.Item.RecordingConfiguration.S) || (!result.Item.RecordedFilename || !result.Items.RecordedFilename.S)) {
53 | return response("No recording!", 500);
54 | }
55 |
56 | const r2s3 = JSON.parse(result.Item.RecordingConfiguration.S);
57 |
58 | params = {
59 | Bucket: r2s3.bucketName,
60 | Key: result.Item.RecordedFilename.S
61 | };
62 | const s3Result = await S3.deleteObject(params).promise();
63 |
64 |
65 | dbResult = await ddb.deleteItem(params).promise();
66 |
67 | return response({ dbResult, s3Result });
68 |
69 | } catch (err) {
70 |
71 | console.info("deleteRecordedVideo > err:", err);
72 | return response(err, 500);
73 |
74 | }
75 | };
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
14 |
15 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | Amazon IVS - Auto-record to S3
32 |
33 |
34 | You need to enable JavaScript to run this app.
35 |
36 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/serverless/serverless/lambda/get-live-details.js:
--------------------------------------------------------------------------------
1 |
2 | const AWS = require('aws-sdk');
3 |
4 | const {
5 | REGION,
6 | CHANNELS_TABLE_NAME
7 |
8 | } = process.env;
9 |
10 | const ivs = new AWS.IVS({
11 | apiVersion: '2020-07-14',
12 | REGION // Must be in one of the supported regions
13 | });
14 |
15 | const ddb = new AWS.DynamoDB();
16 |
17 | const response = (body, statusCode = 200) => {
18 | return {
19 | statusCode,
20 | headers: {
21 | 'Content-Type': 'application/json',
22 | 'Access-Control-Allow-Origin': '*'
23 | },
24 | body: JSON.stringify(body)
25 | };
26 | };
27 | // GET /live-details
28 | exports.getLiveChannelDetails = async (event) => {
29 | console.log("getLiveChannelDetails:", JSON.stringify(event, null, 2));
30 |
31 | try {
32 |
33 | if (!event.queryStringParameters.channelName) {
34 | return response({ message: 'Missing channelName' }, 400);
35 | }
36 |
37 | let params = {
38 | TableName: CHANNELS_TABLE_NAME,
39 | Key: {
40 | "Id": {
41 | S: event.queryStringParameters.channelName
42 | }
43 | }
44 | };
45 |
46 | console.info("getLiveChannelDetails > by channelName > params:", JSON.stringify(params, null, 2));
47 |
48 | const result = await ddb.getItem(params).promise();
49 |
50 | console.info("getLiveChannelDetails > by channelName > result:", JSON.stringify(result, null, 2));
51 |
52 | // empty
53 | if (!result.Item) {
54 | return response({});
55 | }
56 |
57 | console.log(`channel ${JSON.stringify(result)}`);
58 |
59 | const channel = result.Item;
60 |
61 | const streamObj = await ivs.getStreamKey({ arn: channel.StreamArn.S }).promise();
62 | const channelObj = await ivs.getChannel({ arn: channel.ChannelArn.S }).promise();
63 |
64 | console.log(`stream object ${JSON.stringify(streamObj)}`);
65 | console.log(`channel object ${JSON.stringify(channelObj)}`);
66 |
67 | const finalResult = {
68 | "data": {
69 | ingest: channelObj.channel.ingestEndpoint,
70 | key: streamObj.streamKey.value
71 | }
72 | };
73 |
74 | console.info("getLiveChannelDetails > by channelName > response:", JSON.stringify(finalResult, null, 2));
75 | return response(finalResult, 200);
76 |
77 |
78 | } catch (err) {
79 |
80 | console.info("getLiveChannelDetails > err:", err);
81 | return response(err, 500);
82 |
83 | }
84 | };
--------------------------------------------------------------------------------
/src/components/VideoPlayer/VideoPlayer.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import "./VideoPlayer.css";
5 |
6 | class VideoPlayer extends Component {
7 | componentDidMount() {
8 | this.initVideo();
9 | }
10 |
11 | componentDidUpdate(prevProps) {
12 | // Change player src when props change
13 | if (this.props.videoStream !== prevProps.videoStream) {
14 | this.player.src(this.props.videoStream);
15 | }
16 | }
17 |
18 | componentWillUnmount() {
19 | this.destroyVideo();
20 | }
21 |
22 | destroyVideo() {
23 | if (this.player) {
24 | this.player.dispose();
25 | this.player = null;
26 | }
27 | }
28 |
29 | initVideo() {
30 | // Here, we load videojs, IVS tech, and the IVS quality plugin
31 | // These must be prefixed with window. because they are loaded to the window context
32 | // in web-ui/public.
33 | const videojs = window.videojs,
34 | registerIVSTech = window.registerIVSTech,
35 | registerIVSQualityPlugin = window.registerIVSQualityPlugin;
36 | console.log(videojs);
37 | // Set up IVS playback tech and quality plugin
38 | if (registerIVSTech && registerIVSQualityPlugin) {
39 | registerIVSTech(videojs);
40 | registerIVSQualityPlugin(videojs);
41 | }
42 |
43 | const videoJsOptions = {
44 | techOrder: ["AmazonIVS"],
45 | autoplay: true,
46 | muted: this.props.muted,
47 | controlBar: {
48 | pictureInPictureToggle: false,
49 | },
50 | };
51 |
52 | // instantiate video.js
53 | this.player = videojs("amazon-ivs-videojs", videoJsOptions);
54 | this.player.ready(this.handlePlayerReady);
55 | // expose event for other components using it
56 | this.player.ready(this.props.onPlay);
57 |
58 | }
59 |
60 | handlePlayerReady = () => {
61 | this.player.enableIVSQualityPlugin();
62 | this.player.src(this.props.videoStream);
63 | this.player.play();
64 | };
65 |
66 | render() {
67 | return (
68 |
69 |
75 |
76 | );
77 | }
78 | }
79 |
80 | VideoPlayer.propTypes = {
81 | videoStream: PropTypes.string,
82 | controls: PropTypes.bool,
83 | muted: PropTypes.bool,
84 | onPlay : PropTypes.func
85 | };
86 |
87 | export default VideoPlayer;
88 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/serverless/serverless/lambda/put-video.js:
--------------------------------------------------------------------------------
1 | const AWS = require('aws-sdk');
2 |
3 | const {
4 | REGION,
5 | VIDEOS_TABLE_NAME
6 |
7 | } = process.env;
8 |
9 | const ddb = new AWS.DynamoDB();
10 |
11 | const response = (body, statusCode = 200) => {
12 | return {
13 | statusCode,
14 | headers: {
15 | 'Content-Type': 'application/json',
16 | 'Access-Control-Allow-Origin': '*'
17 | },
18 | body: JSON.stringify(body)
19 | };
20 | };
21 |
22 | /* PUT /Video/:id */
23 | exports.putVideo = async (event) => {
24 | console.log("putVideo:", JSON.stringify(event, null, 2));
25 |
26 | if (!event.pathParameters.id) {
27 | return response({ message: 'Missing id' }, 400);
28 | }
29 |
30 | try {
31 | const payload = JSON.parse(event.body);
32 | const params = {
33 | TableName: VIDEOS_TABLE_NAME,
34 | Key: {
35 | 'Id': {
36 | S: event.pathParameters.id
37 | }
38 | },
39 | ExpressionAttributeNames: {
40 | '#Title': 'Title',
41 | '#Subtitle': 'Subtitle'
42 | },
43 | ExpressionAttributeValues: {
44 | ':title': {
45 | S: payload.title
46 | },
47 | ':subtitle': {
48 | S: payload.subtitle
49 | },
50 | },
51 | UpdateExpression: 'SET #Title = :title, #Subtitle = :subtitle',
52 | ReturnValues: "ALL_NEW"
53 | };
54 |
55 |
56 | if (payload.viewers) {
57 | params.ExpressionAttributeNames['#Viewers'] = 'Viewers';
58 | params.ExpressionAttributeValues[':viewers'] = {
59 | N: String(payload.viewers)
60 | };
61 |
62 | params.UpdateExpression = 'SET #Title = :title, #Subtitle = :subtitle, #Viewers = :viewers';
63 | }
64 |
65 |
66 | console.info("putVideo > params:", JSON.stringify(params, null, 2));
67 |
68 | const result = await ddb.updateItem(params).promise();
69 |
70 | console.info("putVideo > result:", JSON.stringify(result, null, 2));
71 |
72 | const updateResponse = {
73 | Id: result.Attributes.Id.S ? result.Attributes.Id.S : '',
74 | Title: result.Attributes.Title.S ? result.Attributes.Title.S : '',
75 | Subtitle: result.Attributes.Subtitle.S ? result.Attributes.Subtitle.S : '',
76 | Viewers: result.Attributes.Viewers.N ? parseInt(result.Attributes.Viewers.N, 10) : 0
77 | };
78 |
79 | console.info("putVideo > updateResponse :", JSON.stringify(updateResponse, null, 2));
80 |
81 | return response(updateResponse);
82 |
83 | } catch (err) {
84 |
85 | console.info("putVideo > err:", err);
86 | return response(err, 500);
87 | }
88 | };
89 |
--------------------------------------------------------------------------------
/src/pages/AdminHome.jsx:
--------------------------------------------------------------------------------
1 | import Navbar from "../components/Navbar/Navbar";
2 | import VideoPlayer from "../components/VideoPlayer/VideoPlayer";
3 | import VodCardController from "../components/VodCardController";
4 | import LiveCard from "../components/LiveCard/LiveCard";
5 | import styles from "./AdminHome.module.css";
6 |
7 | import LiveAPI from "../live-stream-api";
8 |
9 | import * as config from "../config";
10 | import { useEffect, useState } from "react";
11 |
12 | function AdminHome() {
13 | const [response, setResponse] = useState({});
14 | const [timerID, setTimerID] = useState(false);
15 |
16 | const fetchLiveAPI = () => {
17 | if (config.USE_MOCK_DATA && config.USE_MOCK_DATA === true){
18 | const LIVE_API = LiveAPI.data;
19 | setResponse(LIVE_API);
20 | } else {
21 | // Call API and set the matched value if we're mounted
22 | const getLiveChannelUrl = `${config.API_URL}/live`;
23 | fetch(getLiveChannelUrl)
24 | .then(response => response.json())
25 | .then((res) => {
26 | setResponse(res.data);
27 | })
28 | .catch((error) => {
29 | console.error(error);
30 | });
31 | }
32 | };
33 |
34 | useEffect(() => {
35 | // Set mounted to true so that we know when first mount has happened
36 | let mounted = true;
37 |
38 | if (!timerID && mounted) {
39 | fetchLiveAPI();
40 | const timer = setInterval(() => {
41 | fetchLiveAPI();
42 | }, config.POLL_DELAY_MS)
43 | setTimerID(timer);
44 | }
45 |
46 | // Set mounted to false & clear the interval when the component is unmounted
47 | return () => {
48 | mounted = false;
49 | clearInterval(timerID);
50 | }
51 | }, [timerID])
52 |
53 |
54 | const hintText = (response.isLive && response.isLive === "Yes") ? `LIVE • ${response.viewers}` : "Offline";
55 | return (
56 | <>
57 |
58 |
59 | Admin panel
60 | Live stream
61 |
70 |
75 |
76 |
77 |
78 | Recorded streams
79 |
80 |
81 | >
82 | );
83 | }
84 |
85 | export default AdminHome;
86 |
--------------------------------------------------------------------------------
/src/components/VodCardController.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 | import PropTypes from "prop-types";
3 | import VodCard from "./VodCard/VodCard";
4 | import API from "../get-video-api";
5 | import FormatTimestamp from "../utility/FormatTimestamp";
6 | import * as config from "../config";
7 |
8 | function sortByKey(array, key) {
9 | return array.sort(function(a, b) {
10 | var x = a[key]; var y = b[key];
11 | return ((x > y) ? -1 : ((x < y) ? 1 : 0));
12 | });
13 | }
14 |
15 | function VodCardController(props) {
16 | const [response, setResponse] = useState({});
17 | const [timerID, setTimerID] = useState(false);
18 |
19 | const fetchAPI = () => {
20 | // Call API and set the matched value if we're mounted
21 | if (config.USE_MOCK_DATA && config.USE_MOCK_DATA === true){
22 | const vods = API.vods;
23 | setResponse(vods);
24 | } else {
25 | const getVideosUrl = `${config.API_URL}/videos`;
26 |
27 | fetch(getVideosUrl)
28 | .then(response => response.json())
29 | .then((res) => {
30 | const sortedVods = sortByKey(res.vods, "created_on")
31 | setResponse(sortedVods);
32 | })
33 | .catch((error) => {
34 | console.error(error);
35 | });
36 | }
37 | }
38 |
39 | useEffect(() => {
40 | // Set mounted to true so that we know when first mount has happened
41 | let mounted = true;
42 |
43 | if (!timerID && mounted) {
44 | fetchAPI();
45 | const timer = setInterval(() => {
46 | fetchAPI();
47 | }, config.POLL_DELAY_MS)
48 | setTimerID(timer);
49 | }
50 |
51 | // Set mounted to false & clear the interval when the component is unmounted
52 | return () => {
53 | mounted = false;
54 | clearInterval(timerID);
55 | }
56 | }, [timerID])
57 |
58 | const formattedAPIResponse = [];
59 |
60 | // Format Thumbnail, title, subtitle, hint into array of objects
61 | for (let index = 0; index < response.length; index++) {
62 | const vod = response[index];
63 | const time = FormatTimestamp(vod.length);
64 | const hintMeta = `${vod.views} views • ${time}`;
65 | formattedAPIResponse.push({
66 | id: vod.id,
67 | title: vod.title,
68 | subtitle: vod.subtitle,
69 | hint: hintMeta,
70 | thumbnailUrl: vod.thumbnail,
71 | });
72 | }
73 |
74 | return (
75 | <>
76 | {formattedAPIResponse.map((v, i) => {
77 | return (
78 |
87 | );
88 | })}
89 | >
90 | );
91 | }
92 |
93 | VodCardController.propTypes = {
94 | offset: PropTypes.string,
95 | linkType: PropTypes.string,
96 | };
97 |
98 | export default VodCardController;
99 |
--------------------------------------------------------------------------------
/src/pages/Home.jsx:
--------------------------------------------------------------------------------
1 | import Navbar from "../components/Navbar/Navbar";
2 | import VideoPlayer from "../components/VideoPlayer/VideoPlayer";
3 | import VodCardController from "../components/VodCardController";
4 | import styles from "./Home.module.css";
5 |
6 | import * as config from "../config";
7 |
8 | import LiveAPI from "../live-stream-api";
9 |
10 | import React, { useEffect, useState } from "react";
11 |
12 | function LiveComponent(props) {
13 | return (
14 | <>
15 |
20 | { props.isLive === "Yes" ? (
21 |
22 |
{props.title}
23 |
24 | {props.subtitle} • {`${props.viewers} viewers`}
25 |
26 |
27 | ) : (
28 | <>
29 |
30 |
Channel Offline
31 |
32 | >
33 | )}
34 | >
35 | );
36 | }
37 |
38 | function Home() {
39 | const [response, setResponse] = useState(false);
40 | const [timerID, setTimerID] = useState(false);
41 |
42 | const fetchAPI = () => {
43 | if (config.USE_MOCK_DATA === true){
44 | const API_RETURN = LiveAPI.data;
45 | setResponse(API_RETURN);
46 | } else {
47 | // Call API and set the matched value if we're mounted
48 | const getLiveChannelUrl = `${config.API_URL}/live`;
49 | fetch(getLiveChannelUrl)
50 | .then(response => response.json())
51 | .then((res) => {
52 | setResponse(res.data);
53 | })
54 | .catch((error) => {
55 | console.error(error);
56 | });
57 | }
58 | }
59 |
60 | useEffect(() => {
61 | // Set mounted to true so that we know when first mount has happened
62 | let mounted = true;
63 |
64 | if (!timerID && mounted) {
65 | fetchAPI();
66 | const timer = setInterval(() => {
67 | fetchAPI();
68 | }, config.POLL_DELAY_MS)
69 | setTimerID(timer);
70 | }
71 |
72 | // Set mounted to false & clear the interval when the component is unmounted
73 | return () => {
74 | mounted = false;
75 | clearInterval(timerID);
76 | }
77 | }, [timerID])
78 |
79 | return (
80 | <>
81 |
82 |
91 |
92 | Recorded streams
93 |
94 |
95 | >
96 | );
97 | }
98 |
99 | export default Home;
100 |
--------------------------------------------------------------------------------
/serverless/serverless/lambda/live-cron-event.js:
--------------------------------------------------------------------------------
1 |
2 |
3 | const AWS = require('aws-sdk');
4 |
5 | const {
6 | REGION,
7 | CHANNELS_TABLE_NAME
8 |
9 | } = process.env;
10 |
11 |
12 | const ivs = new AWS.IVS({
13 | apiVersion: '2020-07-14',
14 | REGION // Must be in one of the supported regions
15 | });
16 |
17 |
18 | const ddb = new AWS.DynamoDB();
19 |
20 | const response = (body, statusCode = 200) => {
21 | return {
22 | statusCode,
23 | headers: {
24 | 'Content-Type': 'application/json',
25 | 'Access-Control-Allow-Origin': '*'
26 | },
27 | body: JSON.stringify(body)
28 | };
29 | };
30 |
31 | const _updateDDBChannelIsLive = async (isLive, id, stream) => {
32 |
33 | try {
34 | const params = {
35 | TableName: CHANNELS_TABLE_NAME,
36 | Key: {
37 | 'Id': {
38 | S: id
39 | },
40 | },
41 | ExpressionAttributeNames: {
42 | '#IsLive': 'IsLive',
43 | '#ChannelStatus': 'ChannelStatus',
44 | '#Viewers': 'Viewers'
45 | },
46 | ExpressionAttributeValues: {
47 | ':isLive': {
48 | BOOL: isLive
49 | },
50 | ':channelStatus': {
51 | S: stream ? JSON.stringify(stream) : '{}'
52 | },
53 | ':viewers': {
54 | N: stream ? String(stream.viewerCount) : String(0)
55 | }
56 | },
57 | UpdateExpression: 'SET #IsLive = :isLive, #ChannelStatus = :channelStatus, #Viewers = :viewers',
58 | ReturnValues: "ALL_NEW"
59 | };
60 |
61 | console.info("_updateDDBChannelIsLive > params:", JSON.stringify(params, null, 2));
62 |
63 | const result = await ddb.updateItem(params).promise();
64 |
65 | return result;
66 | } catch (err) {
67 | console.info("_updateDDBChannelIsLive > err:", err, err.stack);
68 | throw new Error(err);
69 | }
70 |
71 | };
72 |
73 | const _isLive = async (counter) => {
74 | console.info("_isLive > counter:", counter);
75 |
76 | const liveStreams = await ivs.listStreams({}).promise();
77 | console.info("_isLive > liveStreams:", liveStreams);
78 |
79 | if (!liveStreams) {
80 | console.log("_isLive: No live streams. Nothing to check");
81 | return;
82 | }
83 |
84 | const result = await ddb.scan({ TableName: CHANNELS_TABLE_NAME }).promise();
85 | if (!result.Items) {
86 | console.log("_isLive: No channels. Nothing to check");
87 | return;
88 | }
89 |
90 | let len = result.Items.length;
91 | while (--len >= 0) {
92 |
93 | const channelArn = result.Items[len].ChannelArn.S;
94 |
95 | console.log("_isLive > channel:", channelArn);
96 | const liveStream = liveStreams.streams.find(obj => obj.channelArn === channelArn);
97 | console.log("_isLive > liveStream:", JSON.stringify(liveStream, null, 2));
98 |
99 | await _updateDDBChannelIsLive((liveStream ? true : false), result.Items[len].Id.S, liveStream);
100 |
101 | }
102 | };
103 | /* Cloudwatch event */
104 | exports.isLiveCron = async (event) => {
105 | console.log("isLiveCron event:", JSON.stringify(event, null, 2));
106 |
107 | // Run three times before the next scheduled event every 1 minute
108 | const waitTime = 3 * 1000; // 3 seconds
109 | let i = 0;
110 | _isLive(i + 1); // run immediately
111 | for (i; i < 2; i++) {
112 | await new Promise(r => setTimeout(r, waitTime)); // wait 3 seconds
113 | console.log("isLiveCron event: waited 3 seconds");
114 | _isLive(i + 1);
115 | }
116 |
117 | console.log("isLiveCron event: end");
118 |
119 | return;
120 | };
--------------------------------------------------------------------------------
/serverless/serverless/lambda/get-videos.js:
--------------------------------------------------------------------------------
1 |
2 | const AWS = require('aws-sdk');
3 |
4 | const {
5 | REGION,
6 | VIDEOS_TABLE_NAME
7 |
8 | } = process.env;
9 |
10 | const ddb = new AWS.DynamoDB();
11 |
12 | const response = (body, statusCode = 200) => {
13 | return {
14 | statusCode,
15 | headers: {
16 | 'Content-Type': 'application/json',
17 | 'Access-Control-Allow-Origin': '*'
18 | },
19 | body: JSON.stringify(body)
20 | };
21 | };
22 |
23 | // GET /videos and /video/:id
24 | exports.getVideos = async (event) => {
25 | console.log("getVideos:", JSON.stringify(event, null, 2));
26 |
27 | try {
28 |
29 |
30 | if (event.pathParameters && event.pathParameters.id) {
31 | console.log("getVideos > by id");
32 |
33 | const params = {
34 | TableName: VIDEOS_TABLE_NAME,
35 | Key: {
36 | 'Id': {
37 | 'S': event.pathParameters.id
38 | }
39 | }
40 | };
41 |
42 | console.info("getVideos > by id > params:", JSON.stringify(params, null, 2));
43 |
44 | const result = await ddb.getItem(params).promise();
45 |
46 | console.info("getVideos > by id > result:", JSON.stringify(result, null, 2));
47 |
48 | // empty
49 | if (!result.Item) {
50 | return response(null, 404);
51 | }
52 |
53 | // removes types
54 | const filtered = {
55 | title: result.Item.Title ? result.Item.Title.S : '',
56 | subtitle: result.Item.Subtitle ? result.Item.Subtitle.S : '',
57 | id: result.Item.Id.S,
58 | created_on: result.Item.CreatedOn ? result.Item.CreatedOn.S : '',
59 | playbackUrl: result.Item.PlaybackUrl ? result.Item.PlaybackUrl.S : '',
60 | thumbnail: result.Item.Thumbnail ? result.Item.Thumbnail.S : '',
61 | thumbnails: result.Item.Thumbnails ? result.Item.Thumbnails.SS : [],
62 | views: result.Item.Viewers ? result.Item.Viewers.N : 0,
63 | length: result.Item.Length ? result.Item.Length.S : ''
64 | };
65 |
66 |
67 |
68 | console.info("getVideos > by Id > response:", JSON.stringify(filtered, null, 2));
69 |
70 | return response(filtered);
71 |
72 | }
73 |
74 | const result = await ddb.scan({ TableName: VIDEOS_TABLE_NAME }).promise();
75 |
76 |
77 | console.info("getVideos > result:", JSON.stringify(result, null, 2));
78 |
79 | // empty
80 | if (!result.Items) {
81 | return response({ "vods": [] });
82 | }
83 |
84 | // removes types
85 | let filteredItem;
86 | let filteredItems = [];
87 | let prop;
88 | for (prop in result.Items) {
89 | filteredItem = {
90 | id: result.Items[prop].Id.S,
91 | title: result.Items[prop].Title.S,
92 | subtitle: result.Items[prop].Subtitle.S,
93 | created_on: result.Items[prop].CreatedOn.S,
94 | playbackUrl: result.Items[prop].PlaybackUrl.S,
95 | thumbnail: result.Items[prop].Thumbnail ? result.Items[prop].Thumbnail.S : '',
96 | thumbnails: result.Items[prop].Thumbnails ? result.Items[prop].Thumbnails.SS : [],
97 | views: result.Items[prop].Viewers ? result.Items[prop].Viewers.N : 0,
98 | length: result.Items[prop].Length ? result.Items[prop].Length.S : ''
99 | };
100 |
101 |
102 | filteredItems.push(filteredItem);
103 |
104 | }
105 |
106 | console.info("getVideos > response:", JSON.stringify(filteredItems, null, 2));
107 | return response({ "vods": filteredItems });
108 |
109 | } catch (err) {
110 |
111 | console.info("getVideos > err:", err);
112 | return response(err, 500);
113 |
114 | }
115 | };
--------------------------------------------------------------------------------
/serverless/serverless/lambda/get-live-channels.js:
--------------------------------------------------------------------------------
1 | const AWS = require('aws-sdk');
2 |
3 | const {
4 | REGION,
5 | CHANNELS_TABLE_NAME
6 |
7 | } = process.env;
8 |
9 |
10 | const ddb = new AWS.DynamoDB();
11 |
12 | const response = (body, statusCode = 200) => {
13 | return {
14 | statusCode,
15 | headers: {
16 | 'Content-Type': 'application/json',
17 | 'Access-Control-Allow-Origin': '*'
18 | },
19 | body: JSON.stringify(body)
20 | };
21 | };
22 |
23 | // GET /live
24 | exports.getLiveChannels = async (event) => {
25 | console.log("getLiveChannels:", JSON.stringify(event, null, 2));
26 |
27 | try {
28 |
29 |
30 |
31 | if (event.queryStringParameters && event.queryStringParameters.channelName) {
32 | console.log("getLiveChannels > by channelName");
33 | let params = {
34 | TableName: CHANNELS_TABLE_NAME,
35 | Key: {
36 | "Id": {
37 | S: event.queryStringParameters.channelName
38 | }
39 | }
40 |
41 | };
42 |
43 | console.info("getLiveChannels > by channelName > params:", JSON.stringify(params, null, 2));
44 |
45 | const result = await ddb.getItem(params).promise();
46 |
47 | console.info("getLiveChannels > by channelName > result:", JSON.stringify(result, null, 2));
48 |
49 | // empty
50 | if (!result.Item) {
51 | return response({});
52 | }
53 |
54 | // there is only one live stream per channel at time
55 | const stream = JSON.parse(result.Item.ChannelStatus.S);
56 | console.log(JSON.stringify(stream));
57 | // removes types
58 | const data = {
59 | "data": {
60 | id : result.Item.Id ? result.Item.Id.S : '',
61 | channelArn: result.Item.ChannelArn ? result.Item.ChannelArn.S : '',
62 | title: result.Item.Title ? result.Item.Title.S : '',
63 | subtitle: result.Item.Subtitle ? result.Item.Subtitle.S : '',
64 | thumbnail: '',
65 | isLive: result.Item.IsLive && result.Item.IsLive.BOOL ? 'Yes' : 'No',
66 | viewers: stream.viewerCount ? stream.viewerCount : 0,
67 | playbackUrl: result.Item.PlaybackUrl ? result.Item.PlaybackUrl.S : ''
68 | }
69 | };
70 |
71 | console.info("getLiveChannels > by channelName > response:", JSON.stringify(data, null, 2));
72 |
73 | return response(data);
74 | }
75 |
76 | console.log("getLiveChannels > list");
77 |
78 | const scanParams = {
79 | "TableName": CHANNELS_TABLE_NAME
80 | };
81 |
82 |
83 |
84 | console.info("getLiveChannels > list > params:", JSON.stringify(scanParams, null, 2));
85 |
86 | const result = await ddb.scan(scanParams).promise();
87 |
88 | console.info("getLiveChannels > list > result:", JSON.stringify(result, null, 2));
89 |
90 | // empty
91 | if (!result.Items) {
92 | return response([]);
93 | }
94 |
95 | // removes types
96 | let channelLive = result.Items[0];
97 | let stream = {};
98 | try {
99 | stream = JSON.parse(channelLive.ChannelStatus.S);
100 | } catch (err) { }
101 |
102 | const data = {
103 | "data": {
104 | id : channelLive.Id ? channelLive.Id.S : '',
105 | channelArn: channelLive.ChannelArn ? channelLive.ChannelArn.S : '',
106 | title: channelLive.Title ? channelLive.Title.S : '',
107 | subtitle: channelLive.Subtitle ? channelLive.Subtitle.S : '',
108 | thumbnail: '',
109 | isLive: channelLive.IsLive && channelLive.IsLive.BOOL ? 'Yes' : 'No',
110 | viewers: stream.viewerCount ? stream.viewerCount : 0,
111 | playbackUrl: result.Items[0].PlaybackUrl ? result.Items[0].PlaybackUrl.S : ''
112 | }
113 | };
114 |
115 | console.info("getLiveChannels > list > response:", JSON.stringify(data, null, 2));
116 |
117 | return response(data);
118 |
119 | } catch (err) {
120 | console.info("getLiveChannels > err:", err);
121 | return response(err, 500);
122 | }
123 | };
124 |
--------------------------------------------------------------------------------
/src/pages/Video.jsx:
--------------------------------------------------------------------------------
1 | import Navbar from "../components/Navbar/Navbar";
2 | import VideoPlayer from "../components/VideoPlayer/VideoPlayer";
3 | import styles from "./Home.module.css";
4 |
5 | import React, { useEffect, useState } from "react";
6 | import { useParams } from "react-router-dom";
7 | import FormatTimestamp from "../utility/FormatTimestamp";
8 | import API from "../get-video-api";
9 |
10 | import * as config from "../config";
11 |
12 |
13 | // Function to fetch video data from the API
14 | // This implementation is a bit lazy, as it parses for the matched
15 | // video id client-side, but ideally the API should find and return
16 | // the correct video given an id.
17 |
18 | function NotFoundError() {
19 | return (
20 | <>
21 | Error: Video not found
22 | >
23 | );
24 | }
25 |
26 | function Video() {
27 | let { id } = useParams();
28 |
29 | const [videoViews, setVideoViews] = useState(0);
30 | const [response, setResponse] = useState({});
31 | const [apiFetched, setApiFetched] = useState(false);
32 |
33 | const fetchAPI = () => {
34 | if (config.USE_MOCK_DATA && config.USE_MOCK_DATA === true){
35 | const API_RETURN = API.vods.find((vod) => vod.id === id);
36 | setResponse(API_RETURN);
37 | setVideoViews(API_RETURN.views);
38 | setApiFetched(true);
39 | } else {
40 | const getVideoUrl = `${config.API_URL}/video/${id}`;
41 | fetch(getVideoUrl)
42 | .then(response => response.json())
43 | .then((res) => {
44 | setResponse(res);
45 | setVideoViews(res.views)
46 | setApiFetched(true);
47 | })
48 | .catch((error) => {
49 | console.error(error);
50 | });
51 | }
52 | }
53 |
54 | useEffect(() => {
55 | // Set mounted to true so that we know when first mount has happened
56 | let mounted = true;
57 |
58 | if (mounted && !apiFetched) {
59 | fetchAPI()
60 | }
61 |
62 | // Set mounted to false when the component is unmounted
63 | return () => { mounted = false };
64 | });
65 |
66 | function VideoMatched(props) {
67 | return (
68 | <>
69 |
75 |
76 |
{props.title}
77 |
{props.subtitle}
78 |
79 | { props.views ? (
80 |
81 |
{`${props.views} views • ${props.length}`}
82 |
83 | ): (
84 | <>
85 | >
86 | )}
87 |
88 | >
89 | );
90 | }
91 |
92 | const handleOnPlay = () => {
93 | // update number of views
94 | const putVideoUrl = `${config.API_URL}/video/${response.id}`;
95 | const currentViews = parseInt(response.views, 10);
96 |
97 | fetch(putVideoUrl, {
98 | method: 'PUT',
99 | body: JSON.stringify({
100 | title: response.title,
101 | subtitle: response.subtitle,
102 | viewers: currentViews + 1
103 | })
104 | })
105 | .then(response => response.json())
106 | .then((res) => {
107 | setVideoViews(res.Viewers)
108 | })
109 | .catch((error) => {
110 | console.error(error);
111 | });
112 | }
113 |
114 | return (
115 | <>
116 |
117 |
118 | {response ? (
119 |
127 | ) : (
128 |
129 | )}
130 |
131 | >
132 | );
133 | }
134 |
135 | export default Video;
136 |
--------------------------------------------------------------------------------
/serverless/serverless/lambda/stream-state-change-event.js:
--------------------------------------------------------------------------------
1 | const AWS = require('aws-sdk');
2 | const {
3 | REGION,
4 | CHANNELS_TABLE_NAME,
5 | STORAGE_URL,
6 | VIDEOS_TABLE_NAME
7 | } = process.env;
8 | const ddb = new AWS.DynamoDB();
9 |
10 | exports.customEventFromEventBridge = async (event) => {
11 | console.log("Stream State Change:", JSON.stringify(event, null, 2));
12 | const params = {TableName: CHANNELS_TABLE_NAME, Key: {'Id': {S: event.detail.channel_name}}};
13 | const channel = await ddb.getItem(params).promise();
14 |
15 | if (event.detail.event_name == "Stream Start") {
16 | try {
17 | await _updateDDBChannelIsLive(true, event.detail.channel_name);
18 | return;
19 | } catch (err) {
20 | console.info("Stream Start>err:", err, err.stack);
21 | throw new Error(err);
22 | }
23 | }
24 |
25 | if (event.detail.event_name == "Stream End") {
26 | try {
27 | await _updateDDBChannelIsLive(false, event.detail.channel_name);
28 | return;
29 | } catch (err) {
30 | console.info("Stream End> err:", err, err.stack);
31 | throw new Error(err);
32 | }
33 | }
34 |
35 | if (event.detail.recording_status == "Recording End") {
36 | try {
37 | let payload = {
38 | id: event.detail.stream_id,
39 | channelName: event.detail.channel_name,
40 | title: channel.Item.Title.S,
41 | subtitle: channel.Item.Subtitle.S,
42 | length: msToTime(event.detail.recording_duration_ms),
43 | createOn: event.time,
44 | playbackUrl: `${STORAGE_URL}/${event.detail.recording_s3_key_prefix}/media/hls/master.m3u8`,
45 | viewers: channel.Item.Viewers.N,
46 | thumbnail: `${STORAGE_URL}/${event.detail.recording_s3_key_prefix}/media/thumbnails/thumb0.jpg`,
47 | thumbnails: [
48 | `${STORAGE_URL}/${event.detail.recording_s3_key_prefix}/media/thumbnails/thumb0.jpg`,
49 | `${STORAGE_URL}/${event.detail.recording_s3_key_prefix}/media/thumbnails/thumb1.jpg`,
50 | `${STORAGE_URL}/${event.detail.recording_s3_key_prefix}/media/thumbnails/thumb2.jpg`,
51 | ]
52 | };
53 | await _createDdbVideo(payload);
54 | return;
55 | } catch (err) {
56 | console.info("Recording End > err:", err, err.stack);
57 | throw new Error(err);
58 | }
59 | }
60 | return;
61 | };
62 | const _createDdbVideo = async (payload) => {
63 | try {
64 | const result = await ddb.putItem({
65 | TableName: VIDEOS_TABLE_NAME,
66 | Item: {'Id': { S: payload.id }, 'Channel': { S: payload.channelName },'Title': { S: payload.title },'Subtitle': { S: payload.subtitle },'CreatedOn': { S: payload.createOn },'PlaybackUrl': { S: payload.playbackUrl },'Viewers': { N: payload.viewers },'Length': { S: payload.length },'Thumbnail': { S: payload.thumbnail },'Thumbnails': { SS: payload.thumbnails },}}).promise();
67 | return result;
68 | } catch (err) {
69 | console.info("_createDdbVideo > err:", err, err.stack);
70 | throw new Error(err);
71 | }
72 | };
73 | const _updateDDBChannelIsLive = async (isLive, id, stream) => {
74 | try {
75 | const params = {
76 | TableName: CHANNELS_TABLE_NAME,
77 | Key: {
78 | 'Id': {
79 | S: id
80 | }
81 | },
82 | ExpressionAttributeNames: {'#IsLive': 'IsLive','#ChannelStatus': 'ChannelStatus','#Viewers': 'Viewers'},
83 | ExpressionAttributeValues: {
84 | ':isLive': { BOOL: isLive},
85 | ':channelStatus': { S: stream ? JSON.stringify(stream) : '{}'},
86 | ':viewers': { N: stream ? String(stream.viewerCount) : String(0)}
87 | },
88 | UpdateExpression: 'SET #IsLive = :isLive, #ChannelStatus = :channelStatus, #Viewers = :viewers',
89 | ReturnValues: "ALL_NEW"
90 | };
91 | const result = await ddb.updateItem(params).promise();
92 | return result;
93 | } catch (err) {
94 | console.info("Update Channel > err:", err, err.stack);
95 | throw new Error(err);
96 | }
97 | };
98 |
99 | function msToTime(e){function n(e,n){return("00"+e).slice(-(n=n||2))}var r=e%1e3,i=(e=(e-r)/1e3)%60,t=(e=(e-i)/60)%60;return n((e-t)/60)+":"+n(t)+":"+n(i)+"."+n(r,3)}
--------------------------------------------------------------------------------
/serverless/serverless/lambda/reset-stream-key.js:
--------------------------------------------------------------------------------
1 | const AWS = require('aws-sdk');
2 |
3 | const {
4 | REGION,
5 | CHANNELS_TABLE_NAME
6 |
7 | } = process.env;
8 |
9 | const ivs = new AWS.IVS({
10 | apiVersion: '2020-07-14',
11 | REGION // Must be in one of the supported regions
12 | });
13 |
14 | const ddb = new AWS.DynamoDB();
15 |
16 | const response = (body, statusCode = 200) => {
17 | return {
18 | statusCode,
19 | headers: {
20 | 'Content-Type': 'application/json',
21 | 'Access-Control-Allow-Origin': '*'
22 | },
23 | body: JSON.stringify(body)
24 | };
25 | };
26 |
27 | exports.resetStreamKey = async (event) => {
28 | console.log("resetDefaultStreamKey event:", JSON.stringify(event, null, 2));
29 | let payload;
30 | try {
31 |
32 | payload = JSON.parse(event.body);
33 | console.log(`payload `, JSON.stringify(payload));
34 | let params = {
35 | TableName: CHANNELS_TABLE_NAME,
36 | Key: {
37 | 'Id': {
38 | 'S': payload.channelName
39 | }
40 | }
41 | };
42 |
43 | console.log('resetDefaultStreamKey event > getChannel params', JSON.stringify(params, '', 2));
44 |
45 | const result = await ddb.getItem(params).promise();
46 |
47 | if (!result.Item) {
48 | console.log('Channel not found');
49 | return response({});
50 | }
51 |
52 | const channel = result.Item;
53 |
54 | const stopStreamParams = {
55 | channelArn: channel.ChannelArn.S
56 | };
57 | console.log("resetDefaultStreamKey event > stopStreamParams:", JSON.stringify(stopStreamParams, '', 2));
58 |
59 | await _stopStream(stopStreamParams);
60 |
61 | const deleteStreamKeyParams = {
62 | arn: channel.StreamArn.S
63 | };
64 | console.log("resetDefaultStreamKey event > deleteStreamKeyParams:", JSON.stringify(deleteStreamKeyParams, '', 2));
65 |
66 | // Quota limit 1 - delete then add
67 |
68 | await ivs.deleteStreamKey(deleteStreamKeyParams).promise();
69 |
70 | const createStreamKeyParams = {
71 | channelArn: channel.ChannelArn.S
72 | };
73 | console.log("resetDefaultStreamKey event > createStreamKeyParams:", JSON.stringify(createStreamKeyParams, '', 2));
74 |
75 | const newStreamKey = await ivs.createStreamKey(createStreamKeyParams).promise();
76 |
77 | console.log(" resetDefaultStreamKey event > newStreamKey ", JSON.stringify(newStreamKey));
78 |
79 | params = {
80 | TableName: CHANNELS_TABLE_NAME,
81 | Key: {
82 | 'Id': {
83 | S: payload.channelName
84 | }
85 | },
86 | ExpressionAttributeNames: {
87 | '#StreamArn': 'StreamArn',
88 | '#StreamKey': 'StreamKey'
89 | },
90 | ExpressionAttributeValues: {
91 | ':streamArn': {
92 | S: newStreamKey.streamKey.arn
93 | },
94 | ':streamKey': {
95 | S: newStreamKey.streamKey.value
96 | }
97 | },
98 | UpdateExpression: 'SET #StreamArn = :streamArn, #StreamKey = :streamKey',
99 | ReturnValues: "ALL_NEW"
100 | };
101 |
102 | console.info("resetDefaultStreamKey > params:", JSON.stringify(params, null, 2));
103 |
104 | await ddb.updateItem(params).promise();
105 |
106 | const key = {
107 | "data": {
108 | "ingest": channel.IngestServer.S,
109 | "key": newStreamKey.streamKey.value
110 | }
111 | };
112 |
113 | return response(key, 200);
114 |
115 | } catch (err) {
116 |
117 | console.info("resetDefaultStreamKey > err:", err);
118 | return response(err, 500);
119 |
120 | }
121 | };
122 |
123 | const _stopStream = async (params) => {
124 |
125 | console.log("_stopStream > params:", JSON.stringify(params, null, 2));
126 |
127 | try {
128 |
129 | const result = await ivs.stopStream(params).promise();
130 |
131 | return result;
132 |
133 | } catch (err) {
134 |
135 | console.info("_stopStream > err:", err);
136 | console.info("_stopStream > err.stack:", err.stack);
137 |
138 | // Ignore error
139 | if (/ChannelNotBroadcasting/.test(err)) {
140 | return;
141 | }
142 |
143 | throw new Error(err);
144 |
145 | }
146 | };
--------------------------------------------------------------------------------
/src/pages/AdminVideo.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 | import { useParams, Link } from "react-router-dom";
3 | import { isEmpty } from "lodash"
4 |
5 | import Navbar from "../components/Navbar/Navbar";
6 | import VideoPlayer from "../components/VideoPlayer/VideoPlayer";
7 | import SaveFooter from "../components/SaveFooter/SaveFooter";
8 | import styles from "./AdminVideo.module.css";
9 |
10 | import API from "../get-video-api";
11 |
12 | import * as config from "../config";
13 |
14 | function putAPI(payload) {
15 | console.log("SAMPLE: PUT changes to api...");
16 | console.log(payload);
17 | console.log("=============================");
18 | }
19 |
20 | function NotFoundError() {
21 | return (
22 | <>
23 | Error: Video not found
24 | >
25 | );
26 | }
27 |
28 | function ThumbnailRadio(props) {
29 |
30 | const [imgError, setImageError] = useState(false);
31 |
32 | if(imgError) {
33 | return (
34 |
35 | )
36 | }
37 | return (
38 | <>
39 |
48 |
49 | setImageError(true)}
54 | />
55 |
56 | >
57 | );
58 | }
59 |
60 | function AdminVideo() {
61 |
62 | let { id } = useParams();
63 |
64 | const [videoTitle, setVideoTitle] = useState("");
65 | const [videoSubtitle, setVideoSubtitle] = useState("");
66 | const [formChanged, setFormChanged] = useState(false);
67 | const [selectedThumbnail, setSelectedThumbnail] = useState("");
68 | const [showPreview, setShowPreview] = useState(false);
69 |
70 | const [response, setResponse] = useState(false);
71 | const [apiFetched, setApiFetched] = useState(false);
72 |
73 | const fetchAPI = () => {
74 | // Call API and set the matched value if we're mounted
75 | if (config.USE_MOCK_DATA && config.USE_MOCK_DATA === true){
76 | const API_RETURN = API.vods.find((vod) => vod.id === id);;
77 | setResponse(API_RETURN);
78 | setVideoTitle(API_RETURN.title);
79 | setVideoSubtitle(API_RETURN.subtitle);
80 | setSelectedThumbnail(API_RETURN.thumbnail);
81 | setApiFetched(true);
82 | } else {
83 | const getVideoUrl = `${config.API_URL}/video/${id}`;
84 | fetch(getVideoUrl)
85 | .then(function (response) {
86 | if (response.ok) {
87 | setApiFetched(true);
88 | return response.json()
89 | }
90 | else {
91 | return null;
92 | }
93 | })
94 | .then((res) => {
95 | if (!response && res) {
96 | setResponse(res);
97 | setVideoTitle(res.title);
98 | setVideoSubtitle(res.subtitle);
99 | setSelectedThumbnail(res.thumbnail);
100 | setApiFetched(true);
101 | console.log(res.playbackUrl)
102 | }
103 | else {
104 | setResponse(null)
105 | }
106 | })
107 | .catch((error) => {
108 | console.error(error);
109 | });
110 | }
111 | }
112 |
113 | useEffect(() => {
114 | // Set mounted to true so that we know when first mount has happened
115 | let mounted = true;
116 | if (mounted && !apiFetched) {
117 | fetchAPI()
118 | }
119 | // Set mounted to false when the component is unmounted
120 | return () => { mounted = false };
121 | }, [fetchAPI]);
122 |
123 | const handleOnChange = (e) => {
124 | setFormChanged(true);
125 | switch (e.currentTarget.id) {
126 | case "title":
127 | setVideoTitle(e.currentTarget.value);
128 | break;
129 | case "subtitle":
130 | setVideoSubtitle(e.currentTarget.value);
131 | break;
132 | default:
133 | break;
134 | }
135 | };
136 |
137 | const handleThumbnailChange = (e) => {
138 | setFormChanged(true);
139 | setSelectedThumbnail(`${e.currentTarget.value}`);
140 | };
141 |
142 | const handleSave = () => {
143 | const payload = {
144 | title: videoTitle,
145 | subtitle: videoSubtitle,
146 | thumbnail: selectedThumbnail,
147 | };
148 | // Update API
149 |
150 | if (config.USE_MOCK_DATA && config.USE_MOCK_DATA === true) {
151 | putAPI(payload);
152 | } else {
153 | const putVideoUrl = `${config.API_URL}/video/${id}`;
154 | fetch(putVideoUrl, {
155 | method: 'PUT',
156 | body: JSON.stringify(payload)
157 | })
158 | .then(response => response.json())
159 | .then((res) => {
160 | setVideoTitle(res.title);
161 | setVideoSubtitle(res.subtitle);
162 | })
163 | .catch((error) => {
164 | console.error(error);
165 | });
166 | }
167 |
168 | // Hide save
169 | setFormChanged(false);
170 | };
171 |
172 | const handlePreviewClick = () => {
173 | setShowPreview(!showPreview);
174 | };
175 |
176 | const handleKeyPress = (event) => {
177 | if (event.key === "Enter") {
178 | handleSave();
179 | }
180 | };
181 |
182 | if (response === null) return
183 | if (isEmpty(response)) return (
184 |
187 | )
188 |
189 | return (
190 | <>
191 |
192 | {response ? (
193 | <>
194 |
195 |
223 |
224 | Thumbnail
225 |
226 |
234 |
242 |
250 |
251 |
252 |
253 |
257 | {showPreview ? "Hide video preview" : "Show video preview"}
258 |
259 | {showPreview ? (
260 |
261 |
266 |
267 |
{response.videoTitle}
268 |
269 |
{response.videoSubtitle}
270 |
271 | ) : (
272 | <>>
273 | )}
274 |
275 |
276 | >
277 | ) : (
278 | <>
279 | >
280 | )}
281 |
282 | >
283 | );
284 | }
285 |
286 | export default AdminVideo;
287 |
--------------------------------------------------------------------------------
/serverless/serverless/README.md:
--------------------------------------------------------------------------------
1 | # Amazon IVS Auto-record to Amazon S3 serverless installation instructions
2 |
3 | This file includes instructions for installing the Amazon IVS Auto-record to S3 web demo serverless infrastructure. The serverless infrastructure runs the backend code for this demo and is deployed using an AWS CloudFormation template: [r2s3-serverless.yaml](./r2s3-serverless.yaml).
4 |
5 | You can view the source code for the template in `./lambda`, but to make deployable changes, you will need to update the `r2s3-serverless.yaml` CloudFormation template file.
6 |
7 | There are two methods you can use to deploy this app:
8 | - [Use a command-line tool (AWS CLI or AWS Cloudshell)](#use-the-cli-or-cloudshell)
9 | - [Use the AWS web console](#use-the-aws-web-console)
10 |
11 |
12 | ## Use the CLI or Cloudshell
13 | These are instructions for using the command-line (AWS CLI tool or AWS Cloudshell) to deploy this app. We recommend having a text editor handy for making changes to commands and keeping track of the commands you need to run.
14 |
15 | ### 1. Create an Amazon S3 bucket
16 | First, create an Amazon S3 bucket to upload the template file. Your S3 bucket name must be all lowercase characters or numbers, and unique across all of Amazon S3. Use dashes `-` or underscores `_` instead of spaces. More information is available in the [naming rules](#amazon-s3-bucket-naming-rules) section.
17 |
18 | To create the bucket, execute the following command:
19 |
20 | ```console
21 | aws s3api create-bucket --bucket --region --create-bucket-configuration LocationConstraint=
22 | ```
23 |
24 | - Replace `` with the name you chose for your bucket.
25 | - Replace `` with the **AWS Region** where you want the bucket to reside. An example of an AWS region is: `us-west-2`.
26 |
27 | ### 2. Upload the CloudFormation Template to the S3 Bucket
28 | Navigate to the `amazon-ivs-auto-record-to-s3-web-demo/serverless` folder and run the following command. Make sure to replace `` with the name of the S3 bucket you created.
29 |
30 | ```console
31 | aws s3 cp ./r2s3-serverless.yaml s3:///
32 | ```
33 |
34 | ### 3. Deploy the Template Using CloudFormation
35 | Execute the following command to deploy the serverless backend. Make sure to replace the following items:
36 |
37 | - Replace `` with the title for your channel.
38 | - Replace `` with the subtitle for your channel.
39 | - Replace `` with the AWS Region where your bucket resides.
40 | - Replace `` with the name of the S3 bucket you created.
41 |
42 | ```console
43 | aws cloudformation deploy --s3-bucket --template-file r2s3-serverless.yaml --stack-name IVS-R2S3 --parameter-overrides Title= Subtitle= --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM --region=
44 | ```
45 |
46 | ### 4. Take note of the stack outputs
47 | Execute the following command to see the outputs of the CloudFormation stack.
48 |
49 | ```console
50 | aws cloudformation describe-stacks --stack-name "IVS-R2S3"
51 | ```
52 |
53 | The command will return a set of information about the stack, including the output keys and values. You may need some of the following values to finish run the included [client app](../web-ui).
54 |
55 | - ApiGatewayStageUrl
56 | - IvsChannelArn
57 | - IvsStorageBucketName
58 | - IvsStreamKey
59 | - IvsChannelIngestEndpointOutput
60 |
61 | ### 5. Configure the client application
62 | Now that the serverless app is deployed, set up the [client app](../web-ui) included in the `web-ui` folder of this repository.
63 |
64 | **Important CloudFront information:**
65 | Given the distributed nature of the CloudFront distribution used in the serverless backend, you may get 404 errors if you try to use it before CloudFront has completed its propagation to all EDGE locations. If you are experiencing errors with the application, wait a least an hour before you try to use the web-ui client on a new stack.
66 |
67 | ## Use the AWS Web Console
68 | ### 1. Create an Amazon S3 bucket
69 | Complete the following steps to create an Amazon S3 bucket:
70 | 1. Sign in to the AWS Management Console and open the [Amazon S3 console](https://console.aws.amazon.com/s3/).
71 | 2. Choose **Create bucket**. The Create bucket wizard will open.
72 | 3. In Bucket name, enter a name for your bucket.
73 | 4. In Region, choose the AWS Region where you want your bucket to reside.
74 | 5. In Bucket settings for Block Public Access, choose the **Block Public Access** settings that you want to apply to the bucket. As a best practice, it is recommended to block _all_ public access.
75 |
76 | When you finish, choose **Create bucket** to create the bucket.
77 |
78 | ### 2. Upload the CloudFormation Template to the S3 Bucket
79 | Complete the following steps to upload the CloudFormation template to your S3 bucket.
80 | 1. In the Buckets list, choose the bucket you created in the previous step.
81 | 2. Choose **Upload**.
82 | 3. In the Upload window, select and upload the `r2s3-serverless.yaml` file from the `serverless` folder. You can also drag and drop the file on the browser window
83 | 4. Scroll to the bottom of the page and choose **Upload**.
84 |
85 | ### 3. Deploy the template using AWS CloudFormation
86 | Complete the following steps to deploy the serverless backend:
87 | 1. Sign in to the AWS Management Console and open the [Amazon CloudFormation console](https://console.aws.amazon.com/cloudformation/).
88 | 2. Create a new stack by using one of the following options:
89 | - Choose **Create Stack**. *This is the only option if you have a currently running stack.*
90 | - Choose **Create Stack** on the Stacks page. *This option is visible only if you have no running stacks.*
91 | 
92 | 3. On the Specify template page, choose a stack template selecting the **Template is ready** option.
93 | 4. Select the **Upload a template file** option.
94 | 5. Click on the **Choose file** button and upload the `r2s3-serverless.yaml` template from the `serverless` folder.
95 | 6. Click **Next**.
96 | - In the **Stack name** field, enter **IVS-R2S3**.
97 | - In the **Title** field, enter a title for your channel.
98 | - In the **Subtitle** field, enter a subtitle for your channel.
99 | 7. Click **Next** to advance to the **Configure Stack Options / Advanced Options page**, then click **Next** again to advance to the **Review "Stackname" page**.
100 | 8. Review the settings and use the **Edit** button to make any changes you want to make.
101 | 9. Scroll to the bottom of the page and select all of the checkboxes in the **Capabilities and Transforms** section.
102 | 10. Click on **Create stack** to finalize the settings and create the stack. You will see the **Stack Details page**, and the **Events / Status field** will display the progress of the stack creation process.
103 |
104 | Once the stack creation process is complete, the **Events report** will list all of the creation events and their status.
105 |
106 | ### 4. Take note of the stack outputs
107 | Once your stack is finished creating, click the **Outputs** tab. You may need some of the following values to finish run the included [client app](../web-ui).
108 | - ApiGatewayStageUrl
109 | - IvsChannelArn
110 | - IvsStorageBucketName
111 | - IvsStreamKey
112 | - IvsChannelIngestEndpointOutput
113 |
114 | ### 5. Configure the client application
115 | Now that the serverless app is deployed, set up the [client app](../web-ui) included in the `web-ui` folder of this repository.
116 |
117 | **Important CloudFront information:**
118 | Given the distributed nature of the CloudFront distribution used in the serverless backend, you may get 404 errors if you try to use it before CloudFront has completed its propagation to all EDGE locations. If you are experiencing errors with the application, wait a least an hour before you try to use the web-ui client on a new stack.
119 |
120 | # Application Removal and Cleanup.
121 | The application uses a small amount of AWS resources even when not in active use. If you wish to uninstall the software to prevent ongoing AWS charges or just to clean up your AWS account, just follow these steps to remove the installed components.
122 |
123 | ## Empty the S3 Bucket created by CloudFormation for the IVS software.
124 | 1. Sign in to the AWS Management Console and open the [S3 Management Console](https://console.aws.amazon.com/s3/).
125 | 2. Select the bucket for the IVS software. (The bucket name is also viewable as the value of **IVSStorageBucketName** shown in Step 2, "...Outputs of the CloudFormation Stack".)
126 | 3. Click on the **Empty** button, type "**permanently delete**" in the confirmation textbox and then click on the **Empty** button again.
127 |
128 | ## Delete the IVS software's CloudFormation Stack.
129 | 1. Sign in to the AWS Management Console and open the [Amazon CloudFormation console](https://console.aws.amazon.com/cloudformation/).
130 | 2. Select the stack you created in step # (default name **IVS-R2S3**).
131 | 3. Click on the "Delete" button.
132 | 4. Click on the "Delete stack" button.
133 |
134 | ## Delete the Recording Configuration.
135 | 1. After you have deleted the CloudFormation stack, sign in to the AWS Management Console and open the [Amazon IVS console](https://console.aws.amazon.com/ivs/channels).
136 | 2. Click on the **Recording configurations** section.
137 | 3. Select the recording configuration that you created on step #4.
138 | 4. Click on the **Delete** button.
139 | - Type **Delete** in the confirmation textbox.
140 | - Click on the **Delete** button.
141 |
142 | That's it! You have successfully removed all of the IVS R2S3 software components from your AWS account.
143 |
144 | ## Appendix
145 | ### Amazon S3 bucket naming rules
146 | Here are some rules to follow when naming your Amazon S3 bucket:
147 | - All lowercase letters or numbers, with dashes allowed (but no other special characters).
148 | - Must begin and end with a letter or number.
149 | - Must not be formatted as an IP address (for example, 192.168.5.4).
150 | - Between 3 and 63 characters long.
151 | - Unique across all of Amazon S3 (you may need a couple of tries to create a unique one).
152 | [View all rules](https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html)
153 |
--------------------------------------------------------------------------------
/src/pages/AdminLive.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 | import { useParams, Link } from "react-router-dom";
3 |
4 | import Navbar from "../components/Navbar/Navbar";
5 | import VideoPlayer from "../components/VideoPlayer/VideoPlayer";
6 | import SaveFooter from "../components/SaveFooter/SaveFooter";
7 | import AlertPopover from "../components/AlertPopover/AlertPopover";
8 | import styles from "./AdminLive.module.css";
9 |
10 | import LiveAPI from "../live-stream-api";
11 | import StreamDetailsAPI from "../stream-details-api";
12 |
13 | import * as config from "../config";
14 |
15 | function resetKeyAPI() {
16 | // For use with mock data
17 | return StreamDetailsAPI.data.key;
18 | }
19 |
20 | function putLiveAPI(payload) {
21 | // For use with mock data
22 | return false;
23 | }
24 |
25 | function fetchLiveAPI() {
26 | // For use with mock data
27 | return LiveAPI.data;
28 | }
29 |
30 | function fetchDetailsAPI() {
31 | // For use with mock data
32 | return StreamDetailsAPI.data;
33 | }
34 |
35 | function AdminLive() {
36 |
37 | let { id } = useParams();
38 |
39 | const [streamTitle, setStreamTitle] = useState("");
40 | const [streamSubtitle, setStreamSubtitle] = useState("");
41 | const [formChanged, setFormChanged] = useState(false);
42 | const [copyConfirm, setCopyConfirm] = useState(false);
43 | const [alertMessage, setAlertMessage] = useState("");
44 | const [alertError, setAlertError] = useState(false);
45 | const [showPreview, setShowPreview] = useState(false);
46 |
47 | const [alertTimeout, setAlertTimeout] = useState(null);
48 |
49 | const [ingestServer, setIngestServer] = useState("");
50 | const [streamKey, setStreamKey] = useState("");
51 |
52 | const [liveResponse, setLiveResponse] = useState(false);
53 | const [detailsResponse, setDetailsResponse] = useState(false);
54 |
55 | useEffect(() => {
56 | // Set mounted to true so that we know when first mount has happened
57 | let mounted = true;
58 |
59 | if (config.USE_MOCK_DATA && config.USE_MOCK_DATA === true) {
60 | // Call mock API and set the matched value if we're mounted
61 | const LIVE_API_RETURN = fetchLiveAPI();
62 | if (mounted && !liveResponse) {
63 | setLiveResponse(LIVE_API_RETURN);
64 | setStreamTitle(LIVE_API_RETURN.title);
65 | setStreamSubtitle(LIVE_API_RETURN.subtitle);
66 | }
67 |
68 | const DETAILS_API_RETURN = fetchDetailsAPI();
69 | if (mounted && !detailsResponse) {
70 | setDetailsResponse(DETAILS_API_RETURN);
71 | setIngestServer(DETAILS_API_RETURN.ingest);
72 | setStreamKey(DETAILS_API_RETURN.key);
73 | }
74 | } else {
75 | const getLiveChannelUrl = `${config.API_URL}/live?channelName=${id}`;
76 | fetch(getLiveChannelUrl)
77 | .then(response => response.json())
78 | .then((res) => {
79 | if (mounted && !liveResponse) {
80 | setLiveResponse(res.data);
81 | setStreamTitle(res.data.title);
82 | setStreamSubtitle(res.data.subtitle);
83 | }
84 | })
85 | .catch((error) => {
86 | console.error(error);
87 | });
88 |
89 | // Get Live Details
90 | const getLiveDetailsUrl = `${config.API_URL}/live-details?channelName=${id}`;
91 | fetch(getLiveDetailsUrl)
92 | .then(response => response.json())
93 | .then((liveDetailsResponse) => {
94 | if (mounted && !detailsResponse) {
95 | setDetailsResponse(liveDetailsResponse.data);
96 | setIngestServer(`rtmps://${liveDetailsResponse.data.ingest}/443/app/`);
97 | setStreamKey(liveDetailsResponse.data.key);
98 | }
99 |
100 | })
101 | .catch((error) => {
102 | console.error(error);
103 | });
104 |
105 | }
106 |
107 | // Set mounted to false when the component is unmounted
108 | return () => (mounted = false);
109 | }, [id, liveResponse, detailsResponse]);
110 |
111 | const handleOnChange = (e) => {
112 | setFormChanged(true);
113 | switch (e.currentTarget.id) {
114 | case "stream-title":
115 | setStreamTitle(e.currentTarget.value);
116 | break;
117 | case "stream-subtitle":
118 | setStreamSubtitle(e.currentTarget.value);
119 | break;
120 | default:
121 | break;
122 | }
123 | };
124 |
125 | const handleSave = () => {
126 | const payload = {
127 | channelName: id,
128 | title: streamTitle,
129 | subtitle: streamSubtitle,
130 | };
131 | if (config.USE_MOCK_DATA && config.USE_MOCK_DATA === true){
132 | // Update Mock API
133 | putLiveAPI(payload);
134 | } else {
135 | // Update API
136 | const apiUrl = `${config.API_URL}/live`;
137 | fetch(apiUrl, {
138 | method: 'PUT',
139 | body: JSON.stringify(payload)
140 | })
141 | .then(response => response.json())
142 | .then((res) => {
143 | setStreamKey(res.data);
144 | })
145 | .catch((error) => {
146 | console.error(error);
147 | });
148 | }
149 |
150 | // Hide save
151 | setFormChanged(false);
152 | };
153 |
154 | const handlePreviewClick = () => {
155 | setShowPreview(!showPreview);
156 | };
157 |
158 | const handleKeyPress = (event) => {
159 | if (event.key === "Enter") {
160 | handleSave();
161 | }
162 | };
163 |
164 | const flashAlertPopover = (message) => {
165 | if (alertTimeout) {
166 | clearTimeout(alertTimeout);
167 | }
168 |
169 | const alert_duration = 5;
170 | setCopyConfirm(true);
171 |
172 | const timer = setTimeout(() => {
173 | setCopyConfirm(false);
174 | }, alert_duration * 1000);
175 | setAlertTimeout(timer);
176 | }
177 |
178 | const handleIngestCopy = () => {
179 | navigator.clipboard.writeText(ingestServer);
180 | setAlertMessage("Copied ingest server");
181 | setAlertError(false);
182 | flashAlertPopover();
183 | }
184 |
185 | const handleStreamKeyCopy = () => {
186 | navigator.clipboard.writeText(streamKey);
187 | setAlertMessage("Copied stream key");
188 | setAlertError(false);
189 | flashAlertPopover();
190 | }
191 |
192 | const handleKeyReset = () => {
193 |
194 | if (config.USE_MOCK_DATA && config.USE_MOCK_DATA === true){
195 | resetKeyAPI();
196 | } else {
197 | const resetStreamKeyUrl = `${config.API_URL}/reset-key`;
198 | fetch(resetStreamKeyUrl, {
199 | method: 'PUT',
200 | body: JSON.stringify({
201 | channelName: id
202 | })
203 | })
204 | .then(response => response.json())
205 | .then((res) => {
206 | setStreamKey(res.data.key);
207 | setAlertMessage("Stream key reset");
208 | setAlertError(false);
209 | flashAlertPopover();
210 | })
211 | .catch((error) => {
212 | setAlertMessage("Failed to reset stream key");
213 | setAlertError(true);
214 | flashAlertPopover();
215 | console.error(error);
216 | });
217 | }
218 | };
219 |
220 | return (
221 | <>
222 |
223 |
224 |
225 |
252 |
294 |
295 | Stream status
296 | {`${liveResponse.isLive === "Yes" ? "Live" : "Offline" }`}
297 |
298 |
299 |
303 | {showPreview ? "Hide video preview" : "Show video preview"}
304 |
305 | {showPreview ? (
306 |
307 |
312 |
313 |
{streamTitle}
314 |
315 |
{streamSubtitle}
316 |
317 | ) : (
318 | <>>
319 | )}
320 |
321 |
322 | >
323 | );
324 | }
325 |
326 | export default AdminLive;
327 |
--------------------------------------------------------------------------------
/serverless/serverless/lambda/index.js:
--------------------------------------------------------------------------------
1 | const AWS = require('aws-sdk');
2 |
3 | const {
4 | REGION,
5 | CHANNELS_TABLE_NAME
6 |
7 | } = process.env;
8 |
9 | const VIDEOS_TABLE_NAME = process.env.VIDEOS_TABLE_NAME ? process.env.VIDEOS_TABLE_NAME : null;
10 |
11 | const STORAGE_URL = process.env.STORAGE_URL ? process.env.STORAGE_URL : null;
12 |
13 | const ivs = new AWS.IVS({
14 | apiVersion: '2020-07-14',
15 | REGION // Must be in one of the supported regions
16 | });
17 |
18 | const S3 = new AWS.S3({
19 | apiVersion: '2006-03-01'
20 | });
21 |
22 | const ddb = new AWS.DynamoDB();
23 |
24 | const response = (body, statusCode = 200) => {
25 | return {
26 | statusCode,
27 | headers: {
28 | 'Content-Type': 'application/json',
29 | 'Access-Control-Allow-Origin': '*'
30 | },
31 | body: JSON.stringify(body)
32 | };
33 | };
34 |
35 | // GET /live
36 | exports.getLiveChannels = async (event) => {
37 | console.log("getLiveChannels:", JSON.stringify(event, null, 2));
38 |
39 | try {
40 |
41 |
42 |
43 | if (event.queryStringParameters && event.queryStringParameters.channelName) {
44 | console.log("getLiveChannels > by channelName");
45 | let params = {
46 | TableName: CHANNELS_TABLE_NAME,
47 | Key: {
48 | "Id": {
49 | S: event.queryStringParameters.channelName
50 | }
51 | }
52 |
53 | };
54 |
55 | console.info("getLiveChannels > by channelName > params:", JSON.stringify(params, null, 2));
56 |
57 | const result = await ddb.getItem(params).promise();
58 |
59 | console.info("getLiveChannels > by channelName > result:", JSON.stringify(result, null, 2));
60 |
61 | // empty
62 | if (!result.Item) {
63 | return response({});
64 | }
65 |
66 | // there is only one live stream per channel at time
67 | const stream = JSON.parse(result.Item.ChannelStatus.S);
68 | console.log(JSON.stringify(stream));
69 | // removes types
70 | const data = {
71 | "data": {
72 | id : result.Item.Id ? result.Item.Id.S : '',
73 | channelArn: result.Item.ChannelArn ? result.Item.ChannelArn.S : '',
74 | title: result.Item.Title ? result.Item.Title.S : '',
75 | subtitle: result.Item.Subtitle ? result.Item.Subtitle.S : '',
76 | thumbnail: '',
77 | isLive: result.Item.IsLive && result.Item.IsLive.BOOL ? 'Yes' : 'No',
78 | viewers: stream.viewerCount ? stream.viewerCount : 0,
79 | playbackUrl: result.Item.PlaybackUrl ? result.Item.PlaybackUrl.S : ''
80 | }
81 | };
82 |
83 | console.info("getLiveChannels > by channelName > response:", JSON.stringify(data, null, 2));
84 |
85 | return response(data);
86 | }
87 |
88 | console.log("getLiveChannels > list");
89 |
90 | const scanParams = {
91 | "TableName": CHANNELS_TABLE_NAME
92 | };
93 |
94 |
95 |
96 | console.info("getLiveChannels > list > params:", JSON.stringify(scanParams, null, 2));
97 |
98 | const result = await ddb.scan(scanParams).promise();
99 |
100 | console.info("getLiveChannels > list > result:", JSON.stringify(result, null, 2));
101 |
102 | // empty
103 | if (!result.Items) {
104 | return response([]);
105 | }
106 |
107 | // removes types
108 | let channelLive = result.Items[0];
109 | let stream = {};
110 | try {
111 | stream = JSON.parse(channelLive.ChannelStatus.S);
112 | } catch (err) { }
113 |
114 | const data = {
115 | "data": {
116 | id : channelLive.Id ? channelLive.Id.S : '',
117 | channelArn: channelLive.ChannelArn ? channelLive.ChannelArn.S : '',
118 | title: channelLive.Title ? channelLive.Title.S : '',
119 | subtitle: channelLive.Subtitle ? channelLive.Subtitle.S : '',
120 | thumbnail: '',
121 | isLive: channelLive.IsLive && channelLive.IsLive.BOOL ? 'Yes' : 'No',
122 | viewers: stream.viewerCount ? stream.viewerCount : 0,
123 | playbackUrl: result.Items[0].PlaybackUrl ? result.Items[0].PlaybackUrl.S : ''
124 | }
125 | };
126 |
127 | console.info("getLiveChannels > list > response:", JSON.stringify(data, null, 2));
128 |
129 | return response(data);
130 |
131 | } catch (err) {
132 | console.info("getLiveChannels > err:", err);
133 | return response(err, 500);
134 | }
135 | };
136 |
137 |
138 | // GET /live-details
139 | exports.getLiveChannelDetails = async (event) => {
140 | console.log("getLiveChannelDetails:", JSON.stringify(event, null, 2));
141 |
142 | try {
143 |
144 | if (!event.queryStringParameters.channelName) {
145 | return response({ message: 'Missing channelName' }, 400);
146 | }
147 |
148 | let params = {
149 | TableName: CHANNELS_TABLE_NAME,
150 | Key: {
151 | "Id": {
152 | S: event.queryStringParameters.channelName
153 | }
154 | }
155 | };
156 |
157 | console.info("getLiveChannelDetails > by channelName > params:", JSON.stringify(params, null, 2));
158 |
159 | const result = await ddb.getItem(params).promise();
160 |
161 | console.info("getLiveChannelDetails > by channelName > result:", JSON.stringify(result, null, 2));
162 |
163 | // empty
164 | if (!result.Item) {
165 | return response({});
166 | }
167 |
168 | console.log(`channel ${JSON.stringify(result)}`);
169 |
170 | const channel = result.Item;
171 |
172 | const streamObj = await ivs.getStreamKey({ arn: channel.StreamArn.S }).promise();
173 | const channelObj = await ivs.getChannel({ arn: channel.ChannelArn.S }).promise();
174 |
175 | console.log(`stream object ${JSON.stringify(streamObj)}`);
176 | console.log(`channel object ${JSON.stringify(channelObj)}`);
177 |
178 | const finalResult = {
179 | "data": {
180 | ingest: channelObj.channel.ingestEndpoint,
181 | key: streamObj.streamKey.value
182 | }
183 | };
184 |
185 | console.info("getLiveChannelDetails > by channelName > response:", JSON.stringify(finalResult, null, 2));
186 | return response(finalResult, 200);
187 |
188 |
189 | } catch (err) {
190 |
191 | console.info("getLiveChannelDetails > err:", err);
192 | return response(err, 500);
193 |
194 | }
195 | };
196 | // PUT /live
197 | exports.putLiveChannel = async (event) => {
198 | console.log("putLiveChannel:", JSON.stringify(event, null, 2));
199 |
200 | try {
201 |
202 | const body = JSON.parse(event.body);
203 |
204 | const params = {
205 | TableName: CHANNELS_TABLE_NAME,
206 | Key: {
207 | 'Id': {
208 | S: body.channelName
209 | }
210 | },
211 | ExpressionAttributeNames: {
212 | '#Title': 'Title',
213 | '#Subtitle': 'Subtitle'
214 | },
215 | ExpressionAttributeValues: {
216 | ':title': {
217 | S: body.title
218 | },
219 | ':subtitle': {
220 | S: body.subtitle
221 | }
222 | },
223 | UpdateExpression: 'SET #Title = :title, #Subtitle = :subtitle',
224 | ReturnValues: "ALL_NEW"
225 | };
226 |
227 | console.info("putLiveChannel > params:", JSON.stringify(params, null, 2));
228 |
229 | const result = await ddb.updateItem(params).promise();
230 |
231 | console.info("putLiveChannel > result:", JSON.stringify(result, null, 2));
232 |
233 | return response(result);
234 |
235 | } catch (err) {
236 |
237 | console.info("putLiveChannel > err:", err);
238 | return response(err, 500);
239 |
240 | }
241 | };
242 |
243 | // GET /videos and /video/:id
244 | exports.getVideos = async (event) => {
245 | console.log("getVideos:", JSON.stringify(event, null, 2));
246 |
247 | try {
248 |
249 |
250 | if (event.pathParameters && event.pathParameters.id) {
251 | console.log("getVideos > by id");
252 |
253 | const params = {
254 | TableName: VIDEOS_TABLE_NAME,
255 | Key: {
256 | 'Id': {
257 | 'S': event.pathParameters.id
258 | }
259 | }
260 | };
261 |
262 | console.info("getVideos > by id > params:", JSON.stringify(params, null, 2));
263 |
264 | const result = await ddb.getItem(params).promise();
265 |
266 | console.info("getVideos > by id > result:", JSON.stringify(result, null, 2));
267 |
268 | // empty
269 | if (!result.Item) {
270 | return response(null, 404);
271 | }
272 |
273 | // removes types
274 | const filtered = {
275 | title: result.Item.Title ? result.Item.Title.S : '',
276 | subtitle: result.Item.Subtitle ? result.Item.Subtitle.S : '',
277 | id: result.Item.Id.S,
278 | created_on: result.Item.CreatedOn ? result.Item.CreatedOn.S : '',
279 | playbackUrl: result.Item.PlaybackUrl ? result.Item.PlaybackUrl.S : '',
280 | thumbnail: result.Item.Thumbnail ? result.Item.Thumbnail.S : '',
281 | thumbnails: result.Item.Thumbnails ? result.Item.Thumbnails.SS : [],
282 | views: result.Item.Viewers ? result.Item.Viewers.N : 0,
283 | length: result.Item.Length ? result.Item.Length.S : ''
284 | };
285 |
286 |
287 |
288 | console.info("getLiveChannels > by channelName > response:", JSON.stringify(filtered, null, 2));
289 |
290 | return response(filtered);
291 |
292 | }
293 |
294 | const result = await ddb.scan({ TableName: VIDEOS_TABLE_NAME }).promise();
295 |
296 |
297 | console.info("getVideos > result:", JSON.stringify(result, null, 2));
298 |
299 | // empty
300 | if (!result.Items) {
301 | return response({ "vods": [] });
302 | }
303 |
304 | // removes types
305 | let filteredItem;
306 | let filteredItems = [];
307 | let prop;
308 | for (prop in result.Items) {
309 | filteredItem = {
310 | id: result.Items[prop].Id.S,
311 | title: result.Items[prop].Title.S,
312 | subtitle: result.Items[prop].Subtitle.S,
313 | created_on: result.Items[prop].CreatedOn.S,
314 | playbackUrl: result.Items[prop].PlaybackUrl.S,
315 | thumbnail: result.Items[prop].Thumbnail ? result.Items[prop].Thumbnail.S : '',
316 | thumbnails: result.Items[prop].Thumbnails ? result.Items[prop].Thumbnails.SS : [],
317 | views: result.Items[prop].Viewers ? result.Items[prop].Viewers.N : 0,
318 | length: result.Items[prop].Length ? result.Items[prop].Length.S : ''
319 | };
320 |
321 |
322 | filteredItems.push(filteredItem);
323 |
324 | }
325 |
326 | console.info("getLiveChannelDetails > by channelName > response:", JSON.stringify(filteredItems, null, 2));
327 | return response({ "vods": filteredItems });
328 |
329 | } catch (err) {
330 |
331 | console.info("getLiveChannelDetails > err:", err);
332 | return response(err, 500);
333 |
334 | }
335 | };
336 |
337 | const _stopStream = async (params) => {
338 |
339 | console.log("_stopStream > params:", JSON.stringify(params, null, 2));
340 |
341 | try {
342 |
343 | const result = await ivs.stopStream(params).promise();
344 | // console.info("_stopStream > result:", result);
345 | return result;
346 |
347 | } catch (err) {
348 |
349 | console.info("_stopStream > err:", err);
350 | console.info("_stopStream > err.stack:", err.stack);
351 |
352 | // Ignore error
353 | if (/ChannelNotBroadcasting/.test(err)) {
354 | return;
355 | }
356 |
357 | throw new Error(err);
358 |
359 | }
360 | };
361 |
362 | const _createRecordingConfiguration = async (payload) => {
363 | if (!payload) {
364 | return response("Empty request", 400);
365 | }
366 |
367 | if (!payload.name) {
368 | return response("Must configuration name.", 400);
369 | }
370 |
371 | if (!payload.bucketName) {
372 | return response("Must bucket name.", 400);
373 | }
374 |
375 | const params = {
376 | recordingConfiguration: {
377 | name: payload.name,
378 | destinationConfiguration: {
379 | s3: {
380 | bucketName: payload.bucketName,
381 | // bucketPrefix: payload.bucketPrefix // ?
382 | }
383 | },
384 | tags: payload.tags
385 | }
386 | };
387 |
388 | try {
389 | return await ivs.createRecordingConfiguration(params).promise();
390 | } catch (err) {
391 | throw err;
392 | }
393 | };
394 |
395 | const _createDdbChannel = async (payload) => {
396 |
397 | try {
398 | const result = await ddb.putItem({
399 | TableName: CHANNELS_TABLE_NAME,
400 | Item: {
401 | 'Id': { S: payload.Id },
402 | 'ChannelArn': { S: payload.channelArn },
403 | 'IngestServer': { S: payload.ingestServer },
404 | 'PlaybackUrl': { S: payload.playbackUrl },
405 | 'Title': { S: payload.title },
406 | 'Subtitle': { S: payload.subtitle },
407 | 'StreamKey': { S: payload.streamKey },
408 | 'StreamArn': { S: payload.streamArn },
409 | 'IsLive': { BOOL: false }
410 | }
411 | }).promise();
412 |
413 | console.info("_createDdbChannel > result:", result);
414 |
415 | return result;
416 | } catch (err) {
417 | console.info("_createDdbChannel > err:", err, err.stack);
418 | throw new Error(err);
419 | }
420 |
421 | };
422 |
423 | const _createDdbVideo = async (payload) => {
424 |
425 | try {
426 | const result = await ddb.putItem({
427 | TableName: VIDEOS_TABLE_NAME,
428 | Item: {
429 | 'Id': { S: payload.id },
430 | 'Channel': { S: payload.channelName },
431 | 'Title': { S: payload.title },
432 | 'Subtitle': { S: payload.subtitle },
433 | 'CreatedOn': { S: payload.createOn },
434 | 'PlaybackUrl': { S: payload.playbackUrl },
435 | 'Viewers': { N: payload.viewers },
436 | 'Length': { S: payload.length },
437 | 'Thumbnail': { S: payload.thumbnail },
438 | 'Thumbnails': { SS: payload.thumbnails },
439 | }
440 | }).promise();
441 |
442 | console.info("_createDdbVideo > result:", JSON.stringify(result));
443 |
444 | return result;
445 | } catch (err) {
446 | console.info("_createDdbVideo > err:", err, err.stack);
447 | throw new Error(err);
448 | }
449 |
450 | };
451 |
452 | exports.createChannel = async (event) => {
453 | console.log("createChannel event:", JSON.stringify(event, null, 2));
454 |
455 | let payload;
456 |
457 | try {
458 | payload = JSON.parse(event.body);
459 | } catch (err) {
460 | return response(err, 500);
461 | }
462 |
463 | if (!payload || !payload.name) {
464 | return response("Must provide name.", 400);
465 | }
466 |
467 | const params = {
468 | latencyMode: payload.latencyMode || 'NORMAL',
469 | name: payload.name,
470 | tags: payload.tags || {},
471 | type: payload.type || 'BASIC'
472 | };
473 |
474 | try {
475 | const createChannelResult = await ivs.createChannel(params).promise();
476 |
477 | if (payload.recordingConfiguration) {
478 | try {
479 | const createRecordingConfigurationResult = await _createRecordingConfiguration(payload.recordingConfiguration);
480 | return response({
481 | createChannelResult,
482 | createRecordingConfigurationResult
483 | });
484 | } catch (err) {
485 | return response({
486 | createChannelResult,
487 | createRecordingConfigurationResult: err
488 | }, 500);
489 | }
490 | }
491 |
492 | return response(createChannelResult);
493 | } catch (err) {
494 | return response(err, 500);
495 | }
496 |
497 | };
498 |
499 | exports.resetStreamKey = async (event) => {
500 | console.log("resetDefaultStreamKey event:", JSON.stringify(event, null, 2));
501 | let payload;
502 | try {
503 |
504 | payload = JSON.parse(event.body);
505 | console.log(`payload `, JSON.stringify(payload));
506 | let params = {
507 | TableName: CHANNELS_TABLE_NAME,
508 | Key: {
509 | 'Id': {
510 | 'S': payload.channelName
511 | }
512 | }
513 | };
514 |
515 | console.log('resetDefaultStreamKey event > getChannel params', JSON.stringify(params, '', 2));
516 |
517 | const result = await ddb.getItem(params).promise();
518 |
519 | if (!result.Item) {
520 | console.log('Channel not found');
521 | return response({});
522 | }
523 |
524 | const channel = result.Item;
525 |
526 | const stopStreamParams = {
527 | channelArn: channel.ChannelArn.S
528 | };
529 | console.log("resetDefaultStreamKey event > stopStreamParams:", JSON.stringify(stopStreamParams, '', 2));
530 |
531 | await _stopStream(stopStreamParams);
532 |
533 | const deleteStreamKeyParams = {
534 | arn: channel.StreamArn.S
535 | };
536 | console.log("resetDefaultStreamKey event > deleteStreamKeyParams:", JSON.stringify(deleteStreamKeyParams, '', 2));
537 |
538 | // Quota limit 1 - delete then add
539 |
540 | await ivs.deleteStreamKey(deleteStreamKeyParams).promise();
541 |
542 | const createStreamKeyParams = {
543 | channelArn: channel.ChannelArn.S
544 | };
545 | console.log("resetDefaultStreamKey event > createStreamKeyParams:", JSON.stringify(createStreamKeyParams, '', 2));
546 |
547 | const newStreamKey = await ivs.createStreamKey(createStreamKeyParams).promise();
548 |
549 | console.log(" resetDefaultStreamKey event > newStreamKey ", JSON.stringify(newStreamKey));
550 |
551 | params = {
552 | TableName: CHANNELS_TABLE_NAME,
553 | Key: {
554 | 'Id': {
555 | S: payload.channelName
556 | }
557 | },
558 | ExpressionAttributeNames: {
559 | '#StreamArn': 'StreamArn',
560 | '#StreamKey': 'StreamKey'
561 | },
562 | ExpressionAttributeValues: {
563 | ':streamArn': {
564 | S: newStreamKey.streamKey.arn
565 | },
566 | ':streamKey': {
567 | S: newStreamKey.streamKey.value
568 | }
569 | },
570 | UpdateExpression: 'SET #StreamArn = :streamArn, #StreamKey = :streamKey',
571 | ReturnValues: "ALL_NEW"
572 | };
573 |
574 | console.info("resetDefaultStreamKey > params:", JSON.stringify(params, null, 2));
575 |
576 | await ddb.updateItem(params).promise();
577 |
578 | const key = {
579 | "data": {
580 | "ingest": channel.IngestServer.S,
581 | "key": newStreamKey.streamKey.value
582 | }
583 | }
584 |
585 | return response(key, 200);
586 |
587 | } catch (err) {
588 |
589 | console.info("resetDefaultStreamKey > err:", err);
590 | return response(err, 500);
591 |
592 | }
593 | };
594 |
595 |
596 | // DELETE /video/:id
597 | exports.deleteRecordedVideo = async (event) => {
598 | try {
599 | if (!event.pathParameters.id) {
600 | return response({ message: 'Missing id' }, 400);
601 | }
602 |
603 | let params = {
604 | TableName: VIDEOS_TABLE_NAME,
605 | Key: {
606 | "Id": {
607 | S: event.pathParameters.id
608 | }
609 | }
610 |
611 | };
612 |
613 | console.info("deleteRecordedVideo > params:", params);
614 |
615 | let dbResult = await ddb.getItem(params).promise();
616 |
617 | if ((!result.Item.RecordingConfiguration || !result.Item.RecordingConfiguration.S) || (!result.Item.RecordedFilename || !result.Items.RecordedFilename.S)) {
618 | return response("No recording!", 500);
619 | }
620 |
621 | const r2s3 = JSON.parse(result.Item.RecordingConfiguration.S);
622 |
623 | params = {
624 | Bucket: r2s3.bucketName,
625 | Key: result.Item.RecordedFilename.S
626 | };
627 | const s3Result = await S3.deleteObject(params).promise();
628 |
629 |
630 | dbResult = await ddb.deleteItem(params).promise();
631 |
632 | return response({ dbResult, s3Result });
633 |
634 | } catch (err) {
635 |
636 | console.info("deleteRecordedVideo > err:", err);
637 | return response(err, 500);
638 |
639 | }
640 | };
641 |
642 | /* Cloudwatch event */
643 |
644 | const _updateDDBChannelIsLive = async (isLive, id, stream) => {
645 |
646 | try {
647 | const params = {
648 | TableName: CHANNELS_TABLE_NAME,
649 | Key: {
650 | 'Id': {
651 | S: id
652 | },
653 | },
654 | ExpressionAttributeNames: {
655 | '#IsLive': 'IsLive',
656 | '#ChannelStatus': 'ChannelStatus',
657 | '#Viewers': 'Viewers'
658 | },
659 | ExpressionAttributeValues: {
660 | ':isLive': {
661 | BOOL: isLive
662 | },
663 | ':channelStatus': {
664 | S: stream ? JSON.stringify(stream) : '{}'
665 | },
666 | ':viewers': {
667 | N: stream ? String(stream.viewerCount) : String(0)
668 | }
669 | },
670 | UpdateExpression: 'SET #IsLive = :isLive, #ChannelStatus = :channelStatus, #Viewers = :viewers',
671 | ReturnValues: "ALL_NEW"
672 | };
673 |
674 | console.info("_updateDDBChannelIsLive > params:", JSON.stringify(params, null, 2));
675 |
676 | const result = await ddb.updateItem(params).promise();
677 |
678 | return result;
679 | } catch (err) {
680 | console.info("_updateDDBChannelIsLive > err:", err, err.stack);
681 | throw new Error(err);
682 | }
683 |
684 | };
685 |
686 | const _isLive = async (counter) => {
687 | console.info("_isLive > counter:", counter);
688 |
689 | const liveStreams = await ivs.listStreams({}).promise();
690 | console.info("_isLive > liveStreams:", liveStreams);
691 |
692 | if (!liveStreams) {
693 | console.log("_isLive: No live streams. Nothing to check");
694 | return;
695 | }
696 |
697 | const result = await ddb.scan({ TableName: CHANNELS_TABLE_NAME }).promise();
698 | if (!result.Items) {
699 | console.log("_isLive: No channels. Nothing to check");
700 | return;
701 | }
702 |
703 | let len = result.Items.length;
704 | while (--len >= 0) {
705 |
706 | const channelArn = result.Items[len].ChannelArn.S;
707 |
708 | console.log("_isLive > channel:", channelArn);
709 | const liveStream = liveStreams.streams.find(obj => obj.channelArn === channelArn);
710 | console.log("_isLive > liveStream:", JSON.stringify(liveStream, null, 2));
711 |
712 | await _updateDDBChannelIsLive((liveStream ? true : false), result.Items[len].Id.S, liveStream);
713 |
714 | }
715 | };
716 |
717 | exports.isLiveCron = async (event) => {
718 | console.log("isLiveCron event:", JSON.stringify(event, null, 2));
719 |
720 | // Run three times before the next scheduled event every 1 minute
721 | const waitTime = 3 * 1000; // 3 seconds
722 | let i = 0;
723 | _isLive(i + 1); // run immediately
724 | for (i; i < 2; i++) {
725 | await new Promise(r => setTimeout(r, waitTime)); // wait 3 seconds
726 | console.log("isLiveCron event: waited 3 seconds");
727 | _isLive(i + 1);
728 | }
729 |
730 | console.log("isLiveCron event: end");
731 |
732 | return;
733 | };
734 |
735 | /* EventBridge */
736 |
737 | exports.customEventFromEventBridge = async (event) => {
738 | console.log("customEventFromEventBridge:", JSON.stringify(event, null, 2));
739 |
740 | const params = {
741 | TableName: CHANNELS_TABLE_NAME,
742 | Key: {
743 | 'Id': {
744 | S: event.detail.channel_name
745 | },
746 | }
747 | };
748 |
749 |
750 | const channel = await ddb.getItem(params).promise();
751 |
752 | console.log("customEventFromEventBridge > getChannel :", JSON.stringify(channel));
753 |
754 | if (event.detail.event_name == "Stream Start") {
755 | try {
756 | await _updateDDBChannelIsLive(true, event.detail.channel_name);
757 |
758 | return;
759 |
760 | } catch (err) {
761 | console.info("_customEventFromEventBridge > Stream Start > err:", err, err.stack);
762 | throw new Error(err);
763 | }
764 | }
765 |
766 | if (event.detail.event_name == "Stream End") {
767 | try {
768 | await _updateDDBChannelIsLive(false, event.detail.channel_name);
769 |
770 | return;
771 |
772 | } catch (err) {
773 | console.info("_customEventFromEventBridge > Stream End> err:", err, err.stack);
774 | throw new Error(err);
775 | }
776 | }
777 |
778 | if (event.detail.recording_status == "Recording End") {
779 | try {
780 | console.log("customEventFromEventBridge > Recording End > getChannel :", JSON.stringify(channel));
781 | let payload = {
782 | id: event.detail.stream_id,
783 | channelName: event.detail.channel_name,
784 | title: channel.Item.Title.S,
785 | subtitle: channel.Item.Subtitle.S,
786 | length: msToTime(event.detail.recording_duration_ms),
787 | createOn: event.time,
788 | playbackUrl: `${STORAGE_URL}/${event.detail.recording_s3_key_prefix}/media/hls/master.m3u8`,
789 | viewers: channel.Item.Viewers.N,
790 | thumbnail: `${STORAGE_URL}/${event.detail.recording_s3_key_prefix}/media/thumbnails/thumb0.jpg`,
791 | thumbnails: [
792 | `${STORAGE_URL}/${event.detail.recording_s3_key_prefix}/media/thumbnails/thumb0.jpg`,
793 | `${STORAGE_URL}/${event.detail.recording_s3_key_prefix}/media/thumbnails/thumb1.jpg`,
794 | `${STORAGE_URL}/${event.detail.recording_s3_key_prefix}/media/thumbnails/thumb2.jpg`,
795 | ]
796 | };
797 |
798 |
799 | await _createDdbVideo(payload);
800 |
801 | return;
802 |
803 | } catch (err) {
804 | console.info("_customEventFromEventBridge > Recording End > err:", err, err.stack);
805 | throw new Error(err);
806 | }
807 | }
808 | return;
809 | };
810 | /* PUT /Video/:id */
811 | exports.putVideo = async (event) => {
812 | console.log("putVideo:", JSON.stringify(event, null, 2));
813 |
814 | if (!event.pathParameters.id) {
815 | return response({ message: 'Missing id' }, 400);
816 | }
817 |
818 | try {
819 |
820 | const payload = JSON.parse(event.body);
821 | const params = {
822 | TableName: VIDEOS_TABLE_NAME,
823 | Key: {
824 | 'Id': {
825 | S: event.pathParameters.id
826 | }
827 | },
828 | ExpressionAttributeNames: {
829 | '#Title': 'Title',
830 | '#Subtitle': 'Subtitle'
831 | },
832 | ExpressionAttributeValues: {
833 | ':title': {
834 | S: payload.title
835 | },
836 | ':subtitle': {
837 | S: payload.subtitle
838 | },
839 | },
840 | UpdateExpression: 'SET #Title = :title, #Subtitle = :subtitle',
841 | ReturnValues: "ALL_NEW"
842 | };
843 |
844 |
845 | if (payload.viewers) {
846 | params.ExpressionAttributeNames['#Viewers'] = 'Viewers';
847 | params.ExpressionAttributeValues[':viewers'] = {
848 | N: String(payload.viewers)
849 | };
850 |
851 | params.UpdateExpression = 'SET #Title = :title, #Subtitle = :subtitle, #Viewers = :viewers';
852 | }
853 |
854 |
855 | console.info("putVideo > params:", JSON.stringify(params, null, 2));
856 |
857 | const result = await ddb.updateItem(params).promise();
858 |
859 | console.info("putVideo > result:", JSON.stringify(result, null, 2));
860 |
861 | const updateResponse = {
862 | Id: result.Attributes.Id.S ? result.Attributes.Id.S : '',
863 | Title: result.Attributes.Title.S ? result.Attributes.Title.S : '',
864 | Subtitle: result.Attributes.Subtitle.S ? result.Attributes.Subtitle.S : '',
865 | Viewers: result.Attributes.Viewers.N ? parseInt(result.Attributes.Viewers.N, 10) : 0
866 | };
867 |
868 | console.info("putVideo > updateResponse :", JSON.stringify(updateResponse, null, 2));
869 |
870 | return response(updateResponse);
871 |
872 | } catch (err) {
873 |
874 | console.info("putVideo > err:", err);
875 | return response(err, 500);
876 | }
877 | };
878 |
879 | function msToTime(s) {
880 |
881 | // Pad to 2 or 3 digits, default is 2
882 | function pad(n, z) {
883 | z = z || 2;
884 | return ('00' + n).slice(-z);
885 | }
886 |
887 | var ms = s % 1000;
888 | s = (s - ms) / 1000;
889 | var secs = s % 60;
890 | s = (s - secs) / 60;
891 | var mins = s % 60;
892 | var hrs = (s - mins) / 60;
893 |
894 | return pad(hrs) + ':' + pad(mins) + ':' + pad(secs) + '.' + pad(ms, 3);
895 | }
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 | SPDX-License-Identifier: MIT-0
4 | */
5 |
6 | /* --------------------------------------------------------------- */
7 | /* v.1.0.11 */
8 | /* --------------------------------------------------------------- */
9 | /* Reset */
10 | *,
11 | *::before,
12 | *::after {
13 | box-sizing: border-box;
14 | }
15 | ul[class],
16 | ol[class] {
17 | padding: 0;
18 | }
19 | body,
20 | h1,
21 | h2,
22 | h3,
23 | h4,
24 | p,
25 | ul[class],
26 | ol[class],
27 | figure,
28 | blockquote,
29 | dl,
30 | dd {
31 | margin: 0;
32 | }
33 | html {
34 | scroll-behavior: smooth;
35 | }
36 | body {
37 | min-height: 100vh;
38 | text-rendering: optimizeSpeed;
39 | line-height: 1.5;
40 | }
41 | ul[class],
42 | ol[class] {
43 | list-style: none;
44 | }
45 | a:not([class]) {
46 | text-decoration-skip-ink: auto;
47 | }
48 | img {
49 | max-width: 100%;
50 | display: block;
51 | }
52 | article > * + * {
53 | margin-top: 1em;
54 | }
55 | input,
56 | button,
57 | textarea,
58 | select {
59 | font: inherit;
60 | }
61 | @media (prefers-reduced-motion: reduce) {
62 | * {
63 | animation-duration: 0.01ms !important;
64 | animation-iteration-count: 1 !important;
65 | transition-duration: 0.01ms !important;
66 | scroll-behavior: auto !important;
67 | }
68 | }
69 |
70 | /* --------------------------------------------------------------- */
71 | /* Variables */
72 | :root {
73 | /* Fonts */
74 | --font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
75 | Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif,
76 | "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
77 | --font-serif: "Iowan Old Style", "Apple Garamond", Baskerville,
78 | "Times New Roman", "Droid Serif", Times, "Source Serif Pro", serif,
79 | "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
80 | --font-mono: Consolas, monaco, "Ubuntu Mono", "Liberation Mono", "Courier New",
81 | Courier, monospace;
82 |
83 | --color-black: #000;
84 | --color-near-black: #0f1112;
85 |
86 | --color-dark-gray: #33393c;
87 | --color-mid-gray: #4b5358;
88 | --color-gray: #8d9ca7;
89 |
90 | --color-silver: #999;
91 | --color-light-silver: #a2b4c0;
92 |
93 | --color-moon-gray: #dfe5e9;
94 | --color-light-gray: #e7ecf0;
95 | --color-near-white: #f1f2f3;
96 | --color-white: #fff;
97 |
98 | --color-dark-red: #e7040f;
99 | --color-red: #fd2222;
100 | --color-light-red: #ff725c;
101 |
102 | --color-orange: #ff6300;
103 | --color-gold: #ffb700;
104 | --color-yellow: #ffd700;
105 | --color-light-yellow: #fbf1a9;
106 |
107 | --color-purple: #5e2ca5;
108 | --color-light-purple: #a463f2;
109 |
110 | --color-dark-pink: #d5008f;
111 | --color-hot-pink: #ff41b4;
112 | --color-pink: #ff80cc;
113 | --color-light-pink: #ffa3d7;
114 |
115 | --color-dark-green: #137752;
116 | --color-green: #0fd70b;
117 | --color-light-green: #9eebcf;
118 |
119 | --color-navy: #001b44;
120 | --color-dark-blue: #2026a2;
121 | --color-blue: #2b44ff;
122 | --color-light-blue: #8bb0ff;
123 | --color-lightest-blue: #e0eaff;
124 |
125 | --color-washed-blue: #f6fffe;
126 | --color-washed-green: #e8fdf5;
127 | --color-washed-yellow: #fffceb;
128 | --color-washed-red: #ffdfdf;
129 |
130 | --color-white-90: rgba(255, 255, 255, 0.9);
131 | --color-white-80: rgba(255, 255, 255, 0.8);
132 | --color-white-70: rgba(255, 255, 255, 0.7);
133 | --color-white-60: rgba(255, 255, 255, 0.6);
134 | --color-white-50: rgba(255, 255, 255, 0.5);
135 | --color-white-40: rgba(255, 255, 255, 0.4);
136 | --color-white-30: rgba(255, 255, 255, 0.3);
137 | --color-white-20: rgba(255, 255, 255, 0.2);
138 | --color-white-10: rgba(255, 255, 255, 0.1);
139 | --color-white-05: rgba(255, 255, 255, 0.05);
140 |
141 | --color-black-90: rgba(0, 0, 0, 0.9);
142 | --color-black-80: rgba(0, 0, 0, 0.8);
143 | --color-black-70: rgba(0, 0, 0, 0.7);
144 | --color-black-60: rgba(0, 0, 0, 0.6);
145 | --color-black-50: rgba(0, 0, 0, 0.5);
146 | --color-black-40: rgba(0, 0, 0, 0.4);
147 | --color-black-30: rgba(0, 0, 0, 0.3);
148 | --color-black-20: rgba(0, 0, 0, 0.2);
149 | --color-black-10: rgba(0, 0, 0, 0.1);
150 | --color-black-05: rgba(0, 0, 0, 0.05);
151 |
152 | /* Color tokens */
153 | --color--primary: var(--color-blue);
154 | --color--secondary: var(--color-dark-blue);
155 | --color--tertiary: var(--color-gray);
156 | --color--positive: var(--color-green);
157 | --color--destructive: var(--color-red);
158 |
159 | /* Sizing */
160 | --section-max-width: 800px;
161 | --input-height: 42px;
162 | --input-height-small: 36px;
163 | --radius: 10px;
164 | --radius-small: 4px;
165 | --header-height: 50px;
166 | --btn-floating-size: 56px;
167 | --btn-floating-icon-size: 40px;
168 |
169 | /* Light theme color assignment */
170 | --color-text-base: var(--color-black);
171 | --color-text-alt: var(--color-mid-gray);
172 | --color-text-inverted: var(--color-white);
173 | --color-text-hint: var(--color-gray);
174 |
175 | --color-text-primary: var(--color--primary);
176 | --color-text-secondary: var(--color--secondary);
177 | --color-text-tertiary: var(--color--tertiary);
178 | --color-text-positive: var(--color--positive);
179 | --color-text-destructive: var(--color--destructive);
180 |
181 | --color-bg-body: var(--color-white);
182 | --color-bg-base: var(--color-white);
183 | --color-bg-alt: var(--color-near-white);
184 | --color-bg-alt-2: var(--color-light-gray);
185 | --color-bg-inverted: var(--color-black);
186 |
187 | --color-bg-header: var(--color-bg-body);
188 | --color-bg-modal: var(--color-bg-body);
189 | --color-bg-modal-overlay: var(--color--secondary);
190 | --color-bg-chat: var(--color-bg-body);
191 | --color-bg-chat-bubble: var(--color-bg-alt);
192 | --color-bg-player: var(--color-bg-alt);
193 | --color-bg-placeholder: var(--color-near-white);
194 |
195 | --color-bg-button: var(--color-near-white);
196 | --color-bg-button-active: var();
197 | --color-bg-button-focus: var();
198 | --color-bg-button-hover: var();
199 |
200 | --color-bg-button-inverted: var();
201 | --color-bg-button-inverted-active: var();
202 | --color-bg-button-inverted-focus: var();
203 | --color-bg-button-inverted-hover: var();
204 |
205 | --color-bg-button-primary-default: var(--color--primary);
206 | --color-bg-button-primary-active: var();
207 | --color-bg-button-primary-hover: var();
208 |
209 | --color-bg-button-secondary-default: var(--color-near-white);
210 | --color-bg-button-secondary-active: var();
211 | --color-bg-button-secondary-hover: var();
212 |
213 | --color-bg-button-floating: var(--color--primary);
214 | --color-bg-button-floating-active: var();
215 | --color-bg-button-floating-focus: var();
216 | --color-bg-button-floating-hover: var(--color--secondary);
217 |
218 | --color-bg-input: var(--color-near-white);
219 | --color-bg-input-focus: var();
220 |
221 | --color-bg-notice-success: var(--color--positive);
222 | --color-bg-notice-error: var(--color--destructive);
223 |
224 | --color-border-base: var(--color-moon-gray);
225 | --color-border-error: var(--color--destructive);
226 |
227 | --grid-2-columns: 1fr 1fr;
228 | --grid-3-columns: 1fr 1fr 1fr;
229 | --grid-4-columns: 1fr 1fr 1fr 1fr;
230 | --grid-trio-columns: 1fr 3fr 1fr 1fr;
231 | }
232 |
233 | /* Mediaqueries */
234 | @media (prefers-color-scheme: dark) {
235 | :root {
236 | /* --color--primary: var(--color--primary-dark);
237 | --color--secondary: var(--color--secondary-dark);
238 | --color--tertiary: var(--color--tertiary-dark); */
239 | }
240 | }
241 |
242 | @media (max-width: 480px) {
243 | /* Smaller Screens */
244 | :root {
245 | --section-max-width: 800px;
246 | --input-height: 42px;
247 | --radius: 10px;
248 | --radius-small: 4px;
249 | --header-height: 50px;
250 | --btn-floating-size: 56px;
251 | --btn-floating-icon-size: 40px;
252 |
253 | --grid-2-columns: 1fr;
254 | --grid-3-columns: 1fr;
255 | --grid-4-columns: 1fr;
256 | --grid-trio-columns: 1fr 1fr 1fr 1fr;
257 | }
258 | }
259 |
260 | @media (min-width: 480px) and (max-width: 767px) {
261 | /* Small Screens */
262 | :root {
263 | --section-max-width: 800px;
264 | --input-height: 42px;
265 | --radius: 10px;
266 | --radius-small: 4px;
267 | --header-height: 50px;
268 | --btn-floating-size: 56px;
269 | --btn-floating-icon-size: 40px;
270 |
271 | --grid-2-columns: 1fr 1fr;
272 | --grid-3-columns: 1fr 1fr 1fr;
273 | --grid-4-columns: 1fr 1fr;
274 | --grid-trio-columns: 1fr 2fr 1fr 1fr;
275 | }
276 | }
277 |
278 | @media (min-width: 767px) {
279 | /* Large Screens */
280 | }
281 |
282 | /* --------------------------------------------------------------- */
283 |
284 | /* Style */
285 | html {
286 | font-size: 62.5%;
287 | }
288 |
289 | html,
290 | body {
291 | width: 100%;
292 | height: 100%;
293 | margin: 0;
294 | padding: 0;
295 | color: var(--color-text-base);
296 | background: var(--color-bg-base);
297 | line-height: 1.5;
298 | }
299 |
300 | body {
301 | font-family: var(--font-sans);
302 | font-size: 1.6rem;
303 | }
304 |
305 | ::selection {
306 | background: var(--color--primary);
307 | color: var(--color-text-inverted);
308 | }
309 |
310 | a {
311 | color: var(--color--primary);
312 | text-decoration: none;
313 | }
314 |
315 | a:hover {
316 | color: var(--color--secondary);
317 | }
318 |
319 | /* Section */
320 | section {
321 | max-width: var(--section-max-width);
322 | margin: 0 auto;
323 | }
324 |
325 | h1,
326 | .h1 {
327 | font-size: 2.4rem;
328 | }
329 |
330 | h2,
331 | .h2 {
332 | font-size: 1.8rem;
333 | }
334 |
335 | h3,
336 | .h3 {
337 | font-size: 1.6rem;
338 | font-weight: 300;
339 | }
340 |
341 | ul {
342 | margin: 0;
343 | padding: 1rem 0;
344 | list-style-position: inside;
345 | }
346 |
347 | ul li {
348 | margin: 0;
349 | }
350 |
351 | em {
352 | font-weight: 300;
353 | font-size: 1.4rem;
354 | }
355 |
356 | .formatted-text h1 {
357 | margin-bottom: 0.5rem;
358 | }
359 | .formatted-text h2 {
360 | margin-bottom: 0.5rem;
361 | }
362 | .formatted-text h3 {
363 | margin-bottom: 0.5rem;
364 | }
365 | .formatted-text ul {
366 | margin-bottom: 0.5rem;
367 | }
368 | .formatted-text p {
369 | margin-bottom: 0.5rem;
370 | }
371 | .formatted-text p:last-child {
372 | margin-bottom: 0;
373 | }
374 |
375 | /* Utility - Text */
376 | .color-base {
377 | color: var(--color-text-base);
378 | }
379 | .color-alt {
380 | color: var(--color-text-alt);
381 | }
382 | .color-inverted {
383 | color: var(--color-text-inverted);
384 | }
385 | .color-hint {
386 | color: var(--color-text-hint);
387 | }
388 | .color-primary {
389 | color: var(--color-text-primary);
390 | }
391 | .color-secondary {
392 | color: var(--color-text-secondary);
393 | }
394 | .color-tertiary {
395 | color: var(--color-text-tertiary);
396 | }
397 | .color-positive {
398 | color: var(--color-text-positive);
399 | }
400 | .color-destructive {
401 | color: var(--color-text-destructive);
402 | }
403 |
404 | .color-black {
405 | color: var(--color-black);
406 | }
407 | .color-near-black {
408 | color: var(--color-near-black);
409 | }
410 |
411 | .color-dark-gray {
412 | color: var(--color-dark-gray);
413 | }
414 | .color-mid-gray {
415 | color: var(--color-mid-gray);
416 | }
417 | .color-gray {
418 | color: var(--color-gray);
419 | }
420 |
421 | .color-silver {
422 | color: var(--color-silver);
423 | }
424 | .color-light-silver {
425 | color: var(--color-light-silver);
426 | }
427 |
428 | .color-moon-gray {
429 | color: var(--color-moon-gray);
430 | }
431 | .color-light-gray {
432 | color: var(--color-light-gray);
433 | }
434 | .color-near-white {
435 | color: var(--color-near-white);
436 | }
437 | .color-white {
438 | color: var(--color-white);
439 | }
440 |
441 | .color-dark-red {
442 | color: var(--color-dark-red);
443 | }
444 | .color-red {
445 | color: var(--color-red);
446 | }
447 | .color-light-red {
448 | color: var(--color-light-red);
449 | }
450 |
451 | .color-orange {
452 | color: var(--color-orange);
453 | }
454 | .color-gold {
455 | color: var(--color-gold);
456 | }
457 | .color-yellow {
458 | color: var(--color-yellow);
459 | }
460 | .color-light-yellow {
461 | color: var(--color-light-yellow);
462 | }
463 |
464 | .color-purple {
465 | color: var(--color-purple);
466 | }
467 | .color-light-purple {
468 | color: var(--color-light-purple);
469 | }
470 |
471 | .color-dark-pink {
472 | color: var(--color-dark-pink);
473 | }
474 | .color-hot-pink {
475 | color: var(--color-hot-pink);
476 | }
477 | .color-pink {
478 | color: var(--color-pink);
479 | }
480 | .color-light-pink {
481 | color: var(--color-light-pink);
482 | }
483 |
484 | .color-dark-green {
485 | color: var(--color-dark-green);
486 | }
487 | .color-green {
488 | color: var(--color-green);
489 | }
490 | .color-light-green {
491 | color: var(--color-light-green);
492 | }
493 |
494 | .color-navy {
495 | color: var(--color-navy);
496 | }
497 | .color-dark-blue {
498 | color: var(--color-dark-blue);
499 | }
500 | .color-blue {
501 | color: var(--color-blue);
502 | }
503 | .color-light-blue {
504 | color: var(--color-light-blue);
505 | }
506 | .color-lightest-blue {
507 | color: var(--color-lightest-blue);
508 | }
509 |
510 | .color-washed-blue {
511 | color: var(--color-washed-blue);
512 | }
513 | .color-washed-green {
514 | color: var(--color-washed-green);
515 | }
516 | .color-washed-yellow {
517 | color: var(--color-washed-yellow);
518 | }
519 | .color-washed-red {
520 | color: var(--color-washed-red);
521 | }
522 |
523 | /* Utility - Background */
524 | .bg-body {
525 | background-color: var(--color-bg-body);
526 | }
527 | .bg-base {
528 | background-color: var(--color-bg-base);
529 | }
530 | .bg-alt {
531 | background-color: var(--color-bg-alt);
532 | }
533 | .bg-alt-2 {
534 | background-color: var(--color-bg-alt-2);
535 | }
536 | .bg-inverted {
537 | background-color: var(--color-bg-inverted);
538 | }
539 |
540 | .bg-black {
541 | background-color: var(--color-black);
542 | }
543 | .bg-near-black {
544 | background-color: var(--color-near-black);
545 | }
546 |
547 | .bg-dark-gray {
548 | background-color: var(--color-dark-gray);
549 | }
550 | .bg-mid-gray {
551 | background-color: var(--color-mid-gray);
552 | }
553 | .bg-gray {
554 | background-color: var(--color-gray);
555 | }
556 |
557 | .bg-silver {
558 | background-color: var(--color-silver);
559 | }
560 | .bg-light-silver {
561 | background-color: var(--color-light-silver);
562 | }
563 |
564 | .bg-moon-gray {
565 | background-color: var(--color-moon-gray);
566 | }
567 | .bg-light-gray {
568 | background-color: var(--color-light-gray);
569 | }
570 | .bg-near-white {
571 | background-color: var(--color-near-white);
572 | }
573 | .bg-white {
574 | background-color: var(--color-white);
575 | }
576 |
577 | .bg-dark-red {
578 | background-color: var(--color-dark-red);
579 | }
580 | .bg-red {
581 | background-color: var(--color-red);
582 | }
583 | .bg-light-red {
584 | background-color: var(--color-light-red);
585 | }
586 |
587 | .bg-orange {
588 | background-color: var(--color-orange);
589 | }
590 | .bg-gold {
591 | background-color: var(--color-gold);
592 | }
593 | .bg-yellow {
594 | background-color: var(--color-yellow);
595 | }
596 | .bg-light-yellow {
597 | background-color: var(--color-light-yellow);
598 | }
599 |
600 | .bg-purple {
601 | background-color: var(--color-purple);
602 | }
603 | .bg-light-purple {
604 | background-color: var(--color-light-purple);
605 | }
606 |
607 | .bg-dark-pink {
608 | background-color: var(--color-dark-pink);
609 | }
610 | .bg-hot-pink {
611 | background-color: var(--color-hot-pink);
612 | }
613 | .bg-pink {
614 | background-color: var(--color-pink);
615 | }
616 | .bg-light-pink {
617 | background-color: var(--color-light-pink);
618 | }
619 |
620 | .bg-dark-green {
621 | background-color: var(--color-dark-green);
622 | }
623 | .bg-green {
624 | background-color: var(--color-green);
625 | }
626 | .bg-light-green {
627 | background-color: var(--color-light-green);
628 | }
629 |
630 | .bg-navy {
631 | background-color: var(--color-navy);
632 | }
633 | .bg-dark-blue {
634 | background-color: var(--color-dark-blue);
635 | }
636 | .bg-blue {
637 | background-color: var(--color-blue);
638 | }
639 | .bg-light-blue {
640 | background-color: var(--color-light-blue);
641 | }
642 | .bg-lightest-blue {
643 | background-color: var(--color-lightest-blue);
644 | }
645 |
646 | .bg-washed-blue {
647 | background-color: var(--color-washed-blue);
648 | }
649 | .bg-washed-green {
650 | background-color: var(--color-washed-green);
651 | }
652 | .bg-washed-yellow {
653 | background-color: var(--color-washed-yellow);
654 | }
655 | .bg-washed-red {
656 | background-color: var(--color-washed-red);
657 | }
658 |
659 | /* Utility - Radius */
660 | .br-all {
661 | border-radius: var(--radius);
662 | }
663 |
664 | /* Utility - Padding */
665 | .pd-0 {
666 | padding: 0;
667 | }
668 | .pd-05 {
669 | padding: 0.5rem;
670 | }
671 | .pd-1 {
672 | padding: 1rem;
673 | }
674 | .pd-15 {
675 | padding: 1.5rem;
676 | }
677 | .pd-2 {
678 | padding: 2rem;
679 | }
680 | .pd-25 {
681 | padding: 2.5rem;
682 | }
683 | .pd-3 {
684 | padding: 3rem;
685 | }
686 | .pd-35 {
687 | padding: 3.5rem;
688 | }
689 | .pd-4 {
690 | padding: 4rem;
691 | }
692 | .pd-5 {
693 | padding: 5rem;
694 | }
695 |
696 | .pd-x-0 {
697 | padding-left: 0;
698 | padding-right: 0;
699 | }
700 | .pd-x-05 {
701 | padding-left: 0.5rem;
702 | padding-right: 0.5rem;
703 | }
704 | .pd-x-1 {
705 | padding-left: 1rem;
706 | padding-right: 1rem;
707 | }
708 | .pd-x-15 {
709 | padding-left: 1.5rem;
710 | padding-right: 1.5rem;
711 | }
712 | .pd-x-2 {
713 | padding-left: 2rem;
714 | padding-right: 2rem;
715 | }
716 | .pd-x-25 {
717 | padding-left: 2.5rem;
718 | padding-right: 2.5rem;
719 | }
720 | .pd-x-3 {
721 | padding-left: 3rem;
722 | padding-right: 3rem;
723 | }
724 | .pd-x-35 {
725 | padding-left: 3.5rem;
726 | padding-right: 3rem;
727 | }
728 | .pd-x-4 {
729 | padding-left: 4rem;
730 | padding-right: 4rem;
731 | }
732 | .pd-x-5 {
733 | padding-left: 5rem;
734 | padding-right: 5rem;
735 | }
736 |
737 | .pd-y-0 {
738 | padding-top: 0;
739 | padding-bottom: 0;
740 | }
741 | .pd-y-05 {
742 | padding-top: 0.5rem;
743 | padding-bottom: 0.5rem;
744 | }
745 | .pd-y-1 {
746 | padding-top: 1rem;
747 | padding-bottom: 1rem;
748 | }
749 | .pd-y-15 {
750 | padding-top: 1.5rem;
751 | padding-bottom: 1.5rem;
752 | }
753 | .pd-y-2 {
754 | padding-top: 2rem;
755 | padding-bottom: 2rem;
756 | }
757 | .pd-y-25 {
758 | padding-top: 2.5rem;
759 | padding-bottom: 2.5rem;
760 | }
761 | .pd-y-3 {
762 | padding-top: 3rem;
763 | padding-bottom: 3rem;
764 | }
765 | .pd-y-35 {
766 | padding-top: 3.5rem;
767 | padding-bottom: 3rem;
768 | }
769 | .pd-y-4 {
770 | padding-top: 4rem;
771 | padding-bottom: 4rem;
772 | }
773 | .pd-y-5 {
774 | padding-top: 5rem;
775 | padding-bottom: 5rem;
776 | }
777 |
778 | .pd-t-0 {
779 | padding-top: 0;
780 | }
781 | .pd-t-05 {
782 | padding-top: 0.5rem;
783 | }
784 | .pd-t-1 {
785 | padding-top: 1rem;
786 | }
787 | .pd-t-15 {
788 | padding-top: 1.5rem;
789 | }
790 | .pd-t-2 {
791 | padding-top: 2rem;
792 | }
793 | .pd-t-25 {
794 | padding-top: 2.5rem;
795 | }
796 | .pd-t-3 {
797 | padding-top: 3rem;
798 | }
799 | .pd-t-35 {
800 | padding-top: 3.5rem;
801 | }
802 | .pd-t-4 {
803 | padding-top: 4rem;
804 | }
805 | .pd-t-5 {
806 | padding-top: 5rem;
807 | }
808 |
809 | .pd-r-0 {
810 | padding-right: 0;
811 | }
812 | .pd-r-05 {
813 | padding-right: 0.5rem;
814 | }
815 | .pd-r-1 {
816 | padding-right: 1rem;
817 | }
818 | .pd-r-15 {
819 | padding-right: 1.5rem;
820 | }
821 | .pd-r-2 {
822 | padding-right: 2rem;
823 | }
824 | .pd-r-25 {
825 | padding-right: 2.5rem;
826 | }
827 | .pd-r-3 {
828 | padding-right: 3rem;
829 | }
830 | .pd-r-35 {
831 | padding-right: 3.5rem;
832 | }
833 | .pd-r-4 {
834 | padding-right: 4rem;
835 | }
836 | .pd-r-5 {
837 | padding-right: 5rem;
838 | }
839 |
840 | .pd-b-0 {
841 | padding-bottom: 0;
842 | }
843 | .pd-b-05 {
844 | padding-bottom: 0.5rem;
845 | }
846 | .pd-b-1 {
847 | padding-bottom: 1rem;
848 | }
849 | .pd-b-15 {
850 | padding-bottom: 1.5rem;
851 | }
852 | .pd-b-2 {
853 | padding-bottom: 2rem;
854 | }
855 | .pd-b-25 {
856 | padding-bottom: 2.5rem;
857 | }
858 | .pd-b-3 {
859 | padding-bottom: 3rem;
860 | }
861 | .pd-b-35 {
862 | padding-bottom: 3.5rem;
863 | }
864 | .pd-b-4 {
865 | padding-bottom: 4rem;
866 | }
867 | .pd-b-5 {
868 | padding-bottom: 5rem;
869 | }
870 |
871 | .pd-l-0 {
872 | padding-left: 0;
873 | }
874 | .pd-l-05 {
875 | padding-left: 0.5rem;
876 | }
877 | .pd-l-1 {
878 | padding-left: 1rem;
879 | }
880 | .pd-l-15 {
881 | padding-left: 1.5rem;
882 | }
883 | .pd-l-2 {
884 | padding-left: 2rem;
885 | }
886 | .pd-l-25 {
887 | padding-left: 2.5rem;
888 | }
889 | .pd-l-3 {
890 | padding-left: 3rem;
891 | }
892 | .pd-l-35 {
893 | padding-left: 3.5rem;
894 | }
895 | .pd-l-4 {
896 | padding-left: 4rem;
897 | }
898 | .pd-l-5 {
899 | padding-left: 5rem;
900 | }
901 |
902 | /* Utility - Margin */
903 | .mg-0 {
904 | margin: 0;
905 | }
906 | .mg-05 {
907 | margin: 0.5rem;
908 | }
909 | .mg-1 {
910 | margin: 1rem;
911 | }
912 | .mg-15 {
913 | margin: 1.5rem;
914 | }
915 | .mg-2 {
916 | margin: 2rem;
917 | }
918 | .mg-25 {
919 | margin: 2.5rem;
920 | }
921 | .mg-3 {
922 | margin: 3rem;
923 | }
924 | .mg-35 {
925 | margin: 3.5rem;
926 | }
927 | .mg-4 {
928 | margin: 4rem;
929 | }
930 | .mg-5 {
931 | margin: 5rem;
932 | }
933 |
934 | .mg-x-0 {
935 | margin-left: 0;
936 | margin-right: 0;
937 | }
938 | .mg-x-05 {
939 | margin-left: 0.5rem;
940 | margin-right: 0.5rem;
941 | }
942 | .mg-x-1 {
943 | margin-left: 1rem;
944 | margin-right: 1rem;
945 | }
946 | .mg-x-15 {
947 | margin-left: 1.5rem;
948 | margin-right: 1.5rem;
949 | }
950 | .mg-x-2 {
951 | margin-left: 2rem;
952 | margin-right: 2rem;
953 | }
954 | .mg-x-25 {
955 | margin-left: 2.5rem;
956 | margin-right: 2.5rem;
957 | }
958 | .mg-x-3 {
959 | margin-left: 3rem;
960 | margin-right: 3rem;
961 | }
962 | .mg-x-35 {
963 | margin-left: 3.5rem;
964 | margin-right: 3rem;
965 | }
966 | .mg-x-4 {
967 | margin-left: 4rem;
968 | margin-right: 4rem;
969 | }
970 | .mg-x-5 {
971 | margin-left: 5rem;
972 | margin-right: 5rem;
973 | }
974 |
975 | .mg-y-0 {
976 | margin-top: 0;
977 | margin-bottom: 0;
978 | }
979 | .mg-y-05 {
980 | margin-top: 0.5rem;
981 | margin-bottom: 0.5rem;
982 | }
983 | .mg-y-1 {
984 | margin-top: 1rem;
985 | margin-bottom: 1rem;
986 | }
987 | .mg-y-15 {
988 | margin-top: 1.5rem;
989 | margin-bottom: 1.5rem;
990 | }
991 | .mg-y-2 {
992 | margin-top: 2rem;
993 | margin-bottom: 2rem;
994 | }
995 | .mg-y-25 {
996 | margin-top: 2.5rem;
997 | margin-bottom: 2.5rem;
998 | }
999 | .mg-y-3 {
1000 | margin-top: 3rem;
1001 | margin-bottom: 3rem;
1002 | }
1003 | .mg-y-35 {
1004 | margin-top: 3.5rem;
1005 | margin-bottom: 3rem;
1006 | }
1007 | .mg-y-4 {
1008 | margin-top: 4rem;
1009 | margin-bottom: 4rem;
1010 | }
1011 | .mg-y-5 {
1012 | margin-top: 5rem;
1013 | margin-bottom: 5rem;
1014 | }
1015 |
1016 | .mg-t-0 {
1017 | margin-top: 0;
1018 | }
1019 | .mg-t-05 {
1020 | margin-top: 0.5rem;
1021 | }
1022 | .mg-t-1 {
1023 | margin-top: 1rem;
1024 | }
1025 | .mg-t-15 {
1026 | margin-top: 1.5rem;
1027 | }
1028 | .mg-t-2 {
1029 | margin-top: 2rem;
1030 | }
1031 | .mg-t-25 {
1032 | margin-top: 2.5rem;
1033 | }
1034 | .mg-t-3 {
1035 | margin-top: 3rem;
1036 | }
1037 | .mg-t-35 {
1038 | margin-top: 3.5rem;
1039 | }
1040 | .mg-t-4 {
1041 | margin-top: 4rem;
1042 | }
1043 | .mg-t-5 {
1044 | margin-top: 5rem;
1045 | }
1046 |
1047 | .mg-r-0 {
1048 | margin-right: 0;
1049 | }
1050 | .mg-r-05 {
1051 | margin-right: 0.5rem;
1052 | }
1053 | .mg-r-1 {
1054 | margin-right: 1rem;
1055 | }
1056 | .mg-r-15 {
1057 | margin-right: 1.5rem;
1058 | }
1059 | .mg-r-2 {
1060 | margin-right: 2rem;
1061 | }
1062 | .mg-r-25 {
1063 | margin-right: 2.5rem;
1064 | }
1065 | .mg-r-3 {
1066 | margin-right: 3rem;
1067 | }
1068 | .mg-r-35 {
1069 | margin-right: 3.5rem;
1070 | }
1071 | .mg-r-4 {
1072 | margin-right: 4rem;
1073 | }
1074 | .mg-r-5 {
1075 | margin-right: 5rem;
1076 | }
1077 |
1078 | .mg-b-0 {
1079 | margin-bottom: 0;
1080 | }
1081 | .mg-b-05 {
1082 | margin-bottom: 0.5rem;
1083 | }
1084 | .mg-b-1 {
1085 | margin-bottom: 1rem;
1086 | }
1087 | .mg-b-15 {
1088 | margin-bottom: 1.5rem;
1089 | }
1090 | .mg-b-2 {
1091 | margin-bottom: 2rem;
1092 | }
1093 | .mg-b-25 {
1094 | margin-bottom: 2.5rem;
1095 | }
1096 | .mg-b-3 {
1097 | margin-bottom: 3rem;
1098 | }
1099 | .mg-b-35 {
1100 | margin-bottom: 3.5rem;
1101 | }
1102 | .mg-b-4 {
1103 | margin-bottom: 4rem;
1104 | }
1105 | .mg-b-5 {
1106 | margin-bottom: 5rem;
1107 | }
1108 |
1109 | .mg-l-0 {
1110 | margin-left: 0;
1111 | }
1112 | .mg-l-05 {
1113 | margin-left: 0.5rem;
1114 | }
1115 | .mg-l-1 {
1116 | margin-left: 1rem;
1117 | }
1118 | .mg-l-15 {
1119 | margin-left: 1.5rem;
1120 | }
1121 | .mg-l-2 {
1122 | margin-left: 2rem;
1123 | }
1124 | .mg-l-25 {
1125 | margin-left: 2.5rem;
1126 | }
1127 | .mg-l-3 {
1128 | margin-left: 3rem;
1129 | }
1130 | .mg-l-35 {
1131 | margin-left: 3.5rem;
1132 | }
1133 | .mg-l-4 {
1134 | margin-left: 4rem;
1135 | }
1136 | .mg-l-5 {
1137 | margin-left: 5rem;
1138 | }
1139 |
1140 | /* Utility - Flex */
1141 | .fl {
1142 | display: flex;
1143 | }
1144 | .fl-inline {
1145 | display: inline-flex;
1146 | }
1147 |
1148 | .fl-row {
1149 | flex-direction: row;
1150 | } /* Default */
1151 | .fl-row-rev {
1152 | flex-direction: row-reverse;
1153 | }
1154 | .fl-col {
1155 | flex-direction: column;
1156 | }
1157 | .fl-col-rev {
1158 | flex-direction: column-reverse;
1159 | }
1160 |
1161 | .fl-nowrap {
1162 | flex-wrap: nowrap;
1163 | } /* Default */
1164 | .fl-wrap {
1165 | flex-wrap: wrap;
1166 | }
1167 | .fl-wrap-rev {
1168 | flex-wrap: wrap-reverse;
1169 | }
1170 |
1171 | .fl-j-start {
1172 | justify-content: flex-start;
1173 | } /* Default */
1174 | .fl-j-end {
1175 | justify-content: flex-end;
1176 | }
1177 | .fl-j-center {
1178 | justify-content: center;
1179 | }
1180 | .fl-j-around {
1181 | justify-content: space-around;
1182 | }
1183 | .fl-j-between {
1184 | justify-content: space-between;
1185 | }
1186 |
1187 | .fl-a-stretch {
1188 | align-items: stretch;
1189 | } /* Default */
1190 | .fl-a-start {
1191 | align-items: flex-start;
1192 | }
1193 | .fl-a-center {
1194 | align-items: center;
1195 | }
1196 | .fl-a-end {
1197 | align-items: flex-end;
1198 | }
1199 | .fl-a-baseline {
1200 | align-items: baseline;
1201 | }
1202 |
1203 | .fl-grow-0 {
1204 | flex-grow: 0;
1205 | } /* Default */
1206 | .fl-grow-1 {
1207 | flex-grow: 1;
1208 | }
1209 |
1210 | .fl-shrink-1 {
1211 | flex-shrink: 1;
1212 | } /* Default */
1213 | .fl-shrink-0 {
1214 | flex-shrink: 0;
1215 | }
1216 |
1217 | .fl-b-auto {
1218 | flex-basis: auto;
1219 | } /* Default */
1220 | .fl-b-0 {
1221 | flex-basis: 0;
1222 | }
1223 |
1224 | .fl-a-auto {
1225 | align-self: auto;
1226 | } /* Default */
1227 | .fl-a-start {
1228 | align-self: flex-start;
1229 | }
1230 | .fl-a-center {
1231 | align-self: center;
1232 | }
1233 | .fl-a-end {
1234 | align-self: flex-end;
1235 | }
1236 | .fl-a-stretch {
1237 | align-self: stretch;
1238 | }
1239 | .fl-a-baseline {
1240 | align-self: baseline;
1241 | }
1242 |
1243 | /* Utility - Position */
1244 | .pos-absolute {
1245 | position: absolute !important;
1246 | }
1247 | .pos-fixed {
1248 | position: fixed !important;
1249 | }
1250 | .pos-relative {
1251 | position: relative !important;
1252 | }
1253 | .top-0 {
1254 | top: 0 !important;
1255 | }
1256 | .bottom-0 {
1257 | bottom: 0 !important;
1258 | }
1259 |
1260 | /* Utility - Width/Height */
1261 | .full-width {
1262 | width: 100%;
1263 | }
1264 | .full-height {
1265 | height: 100%;
1266 | }
1267 | .screen-width {
1268 | width: 100vw;
1269 | }
1270 | .screen-height {
1271 | height: 100vh;
1272 | }
1273 |
1274 | /* Blur */
1275 | .blur {
1276 | filter: blur(70px);
1277 | }
1278 |
1279 | /* Overflow */
1280 | .no-overflow {
1281 | overflow: hidden;
1282 | }
1283 |
1284 | /* Grid */
1285 | .grid {
1286 | width: 100%;
1287 | display: grid;
1288 | grid-gap: 1rem;
1289 | }
1290 |
1291 | .grid.grid--2 {
1292 | grid-template-columns: 1fr 1fr;
1293 | }
1294 |
1295 | .grid.grid--3 {
1296 | grid-template-columns: 1fr 1fr 1fr;
1297 | }
1298 |
1299 | .grid.grid--4 {
1300 | grid-template-columns: 1fr 1fr 1fr 1fr;
1301 | }
1302 |
1303 | .grid.grid--trio {
1304 | grid-template-columns: 1fr 3fr 1fr 1fr;
1305 | }
1306 |
1307 | /* Responsive Grid */
1308 | .grid--responsive.grid--2 {
1309 | grid-template-columns: var(--grid-2-columns);
1310 | }
1311 |
1312 | .grid--responsive.grid--3 {
1313 | grid-template-columns: var(--grid-3-columns);
1314 | }
1315 |
1316 | .grid--responsive.grid--4 {
1317 | grid-template-columns: var(--grid-4-columns);
1318 | }
1319 |
1320 | .grid--responsive.grid--trio {
1321 | grid-template-columns: var(--grid-trio-columns);
1322 | }
1323 |
1324 | /* Header bar */
1325 | header {
1326 | height: var(--header-height);
1327 | box-shadow: 0 1px 0 0 var(--color-border-base);
1328 | position: sticky;
1329 | top: 0;
1330 | left: 0;
1331 | right: 0;
1332 | padding: 0 2rem;
1333 | background: var(--color-bg-header);
1334 | z-index: 10;
1335 | }
1336 |
1337 | header h1 {
1338 | font-size: 16px;
1339 | font-weight: 800;
1340 | line-height: var(--header-height);
1341 | }
1342 |
1343 | /* Modal */
1344 | .modal {
1345 | width: 100%;
1346 | height: 100vh;
1347 | position: relative;
1348 | display: grid;
1349 | place-items: center;
1350 | }
1351 |
1352 | .modal__el {
1353 | width: 570px;
1354 | position: relative;
1355 | z-index: 2;
1356 | background: var(--color-bg-modal);
1357 | display: flex;
1358 | flex-direction: column;
1359 | padding: 2rem;
1360 | }
1361 |
1362 | .modal__overlay {
1363 | position: absolute;
1364 | top: 0;
1365 | right: 0;
1366 | bottom: 0;
1367 | left: 0;
1368 | background: var(--color-bg-modal-overlay);
1369 | opacity: 0.9;
1370 | }
1371 |
1372 | /* Code */
1373 | code,
1374 | pre,
1375 | kbd,
1376 | samp {
1377 | font-family: var(--font-mono);
1378 | }
1379 |
1380 | .codeblock {
1381 | padding: 1rem;
1382 | color: var(--color-text-alt);
1383 | background: var(--color-bg-placeholder);
1384 | border-radius: var(--radius);
1385 | }
1386 |
1387 | /* Placeholder blocks */
1388 | .placeholder {
1389 | min-height: 180px;
1390 | background: var(--color-bg-placeholder);
1391 | border-radius: var(--radius);
1392 | }
1393 |
1394 | /* Aspect ratio */
1395 | .aspect-169 {
1396 | padding-top: 56.25%;
1397 | height: 0;
1398 | overflow: hidden;
1399 | }
1400 |
1401 | .player {
1402 | background: var(--color-bg-player);
1403 | }
1404 |
1405 | /* Buttons & Forms */
1406 | form {
1407 | display: flex;
1408 | flex-direction: column;
1409 | align-items: flex-start;
1410 | }
1411 |
1412 | fieldset {
1413 | width: 100%;
1414 | border: 0;
1415 | padding: 0;
1416 | margin: 0;
1417 | display: flex;
1418 | flex-direction: column;
1419 | }
1420 |
1421 | fieldset input,
1422 | fieldset textarea,
1423 | fieldset select,
1424 | fieldset button {
1425 | width: 100%;
1426 | margin-bottom: 1rem;
1427 | }
1428 |
1429 | label {
1430 | font-weight: 500;
1431 | }
1432 |
1433 | label span {
1434 | font-weight: 200;
1435 | }
1436 |
1437 | button {
1438 | border: 2px solid transparent;
1439 | outline: none;
1440 | appearance: none;
1441 | cursor: pointer;
1442 | -webkit-appearance: none;
1443 | border-radius: var(--radius-small);
1444 | }
1445 |
1446 | input,
1447 | select,
1448 | textarea {
1449 | border: 2px solid transparent;
1450 | outline: none;
1451 | appearance: none;
1452 | resize: none;
1453 | -webkit-appearance: none;
1454 | padding: 1rem;
1455 | background: var(--color-bg-input);
1456 | border-radius: var(--radius-small);
1457 | }
1458 |
1459 | a.btn {
1460 | display: inline-block;
1461 | line-height: var(--input-height);
1462 | border-radius: var(--radius-small);
1463 | }
1464 |
1465 | .btn,
1466 | button,
1467 | select,
1468 | input[type="text"],
1469 | input[type="password"],
1470 | input[type="submit"],
1471 | input[type="reset"],
1472 | input[type="button"] {
1473 | height: var(--input-height);
1474 | }
1475 |
1476 | input:focus,
1477 | textarea:focus,
1478 | .btn:focus,
1479 | .btn:active {
1480 | border: 2px solid var(--color--primary);
1481 | }
1482 |
1483 | select {
1484 | padding: 0 20px 0 10px;
1485 | position: relative;
1486 | }
1487 |
1488 | select:focus,
1489 | select:active {
1490 | border: 2px solid var(--color--primary);
1491 | }
1492 |
1493 | .btn.rounded,
1494 | input.rounded {
1495 | border-radius: var(--input-height);
1496 | }
1497 |
1498 | .btn {
1499 | font-weight: 500;
1500 | background: var(--color-bg-button);
1501 | border: 2px solid transparent;
1502 | }
1503 |
1504 | .btn--small {
1505 | height: var(--input-height-small);
1506 | }
1507 |
1508 | a.btn--small {
1509 | line-height: calc(var(--input-height-small) - 4px);
1510 | }
1511 |
1512 | .btn--primary {
1513 | background: var(--color-bg-button-primary-default);
1514 | color: var(--color-text-inverted);
1515 | }
1516 |
1517 | .btn--primary:hover,
1518 | .btn--primary:focus {
1519 | background: var(--color--secondary);
1520 | }
1521 |
1522 | .btn--secondary {
1523 | background: var(--color-bg-button-secondary-default);
1524 | color: var(--color-text-base);
1525 | }
1526 |
1527 | .btn--destruct {
1528 | background: var(--color--destructive);
1529 | color: var(--color-text-inverted);
1530 | }
1531 |
1532 | .btn--confirm {
1533 | background: var(--color--positive);
1534 | }
1535 |
1536 | .btn--floating {
1537 | width: var(--btn-floating-size);
1538 | height: var(--btn-floating-size);
1539 | background: var(--color-bg-button-floating);
1540 | border-radius: var(--btn-floating-size);
1541 | color: var(--color-text-inverted);
1542 | display: flex;
1543 | align-items: center;
1544 | position: absolute;
1545 | bottom: 2rem;
1546 | right: 2rem;
1547 | }
1548 |
1549 | .btn--floating svg {
1550 | width: var(--btn-floating-icon-size);
1551 | height: var(--btn-floating-icon-size);
1552 | fill: var(--color-text-inverted);
1553 | }
1554 |
1555 | .btn--floating:hover,
1556 | .btn--floating:focus {
1557 | background: var(--color-bg-button-floating-hover);
1558 | }
1559 |
1560 | .btn--fixed {
1561 | position: fixed;
1562 | }
1563 |
1564 | .btn--pad {
1565 | padding: 0 1.2rem;
1566 | }
1567 |
1568 | /* Interactive */
1569 | .interactive {
1570 | cursor: pointer;
1571 | border: 2px solid transparent;
1572 | display: flex;
1573 | padding: 1rem;
1574 | flex-direction: column;
1575 | color: var(--color-text-base);
1576 | overflow: hidden;
1577 | }
1578 |
1579 | .interactive strong,
1580 | .interactive span {
1581 | text-overflow: ellipsis;
1582 | white-space: nowrap;
1583 | overflow: hidden;
1584 | }
1585 |
1586 | .interactive:focus,
1587 | .interactive:hover {
1588 | background: var(--color-bg-button);
1589 | color: var(--color-bg-button-primary-default);
1590 | }
1591 |
1592 | .interactive:focus {
1593 | border: 2px solid var(--color--primary);
1594 | outline: none;
1595 | }
1596 |
1597 | .interactive--active,
1598 | .interactive--active:hover,
1599 | .interactive--active:focus {
1600 | background: var(--color-bg-button-primary-default);
1601 | color: var(--color-text-inverted);
1602 | }
1603 |
1604 | /* Notices */
1605 | .notice {
1606 | border-radius: var(--radius-small);
1607 | position: absolute;
1608 | top: 1.5rem;
1609 | right: 1.5rem;
1610 | }
1611 |
1612 | .notice__content {
1613 | display: flex;
1614 | padding: 1.5rem 2rem;
1615 | font-weight: 600;
1616 | }
1617 |
1618 | .notice--success {
1619 | background: var(--color-bg-notice-success);
1620 | }
1621 |
1622 | .notice--error {
1623 | background: var(--color-bg-notice-error);
1624 | color: var(--color-text-inverted);
1625 | }
1626 |
1627 | .notice__icon {
1628 | margin-right: 1rem;
1629 | }
1630 |
1631 | /* Chat */
1632 | .chat-wrapper {
1633 | position: relative;
1634 | padding-bottom: calc(var(--input-height) + 30px);
1635 | display: flex;
1636 | flex-direction: column;
1637 | align-items: flex-start;
1638 | }
1639 |
1640 | .chat-line {
1641 | padding: 12px 15px;
1642 | background: var(--color-bg-chat-bubble);
1643 | border-radius: var(--input-height);
1644 | display: flex;
1645 | margin: 0 0 5px 0;
1646 | }
1647 |
1648 | .chat-line p {
1649 | display: inline;
1650 | font-weight: normal;
1651 | }
1652 |
1653 | .chat-line .username {
1654 | font-weight: 800;
1655 | padding-right: 0.1rem;
1656 | }
1657 |
1658 | .composer {
1659 | position: absolute;
1660 | bottom: 0;
1661 | left: 0;
1662 | right: 0;
1663 | padding: 15px 0;
1664 | background: var(--color-bg-chat);
1665 | }
1666 |
1667 | .composer input {
1668 | width: 100%;
1669 | }
1670 |
1671 | /* Icons */
1672 | .icon {
1673 | fill: var(--color-text-base);
1674 | }
1675 |
1676 | .icon--inverted {
1677 | fill: var(--color-text-inverted);
1678 | }
1679 |
1680 | .icon--success {
1681 | fill: var(--color--positive);
1682 | }
1683 |
1684 | .icon--error {
1685 | fill: var(--color--destructive);
1686 | }
1687 |
1688 | .icon--14 {
1689 | width: 14px;
1690 | height: 14px;
1691 | }
1692 |
1693 | .icon--24 {
1694 | width: 24px;
1695 | height: 24px;
1696 | }
1697 |
1698 | .icon--36 {
1699 | width: 36px;
1700 | height: 36px;
1701 | }
1702 |
1703 | .icon--48 {
1704 | width: 48px;
1705 | height: 48px;
1706 | }
1707 |
--------------------------------------------------------------------------------