├── 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 | 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 | 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 | {`Thumbnail 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 | 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 |
83 | 90 |
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 | 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 |
185 |

Loading ...

186 |
187 | ) 188 | 189 | return ( 190 | <> 191 | 192 | {response ? ( 193 | <> 194 | 195 |
196 |

Admin panel

197 |

Edit video details

198 |
199 | 200 | 209 | 210 | 211 | 212 | 221 |
222 |
223 |
224 | 225 |
226 | 234 | 242 | 250 |
251 |
252 |
253 | 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 `<subtitle>` with the subtitle for your channel. 39 | - Replace `<my-region>` with the AWS Region where your bucket resides. 40 | - Replace `<my-bucket-name>` with the name of the S3 bucket you created. 41 | 42 | ```console 43 | aws cloudformation deploy --s3-bucket <my-bucket-name> --template-file r2s3-serverless.yaml --stack-name IVS-R2S3 --parameter-overrides Title=<title> Subtitle=<subtitle> --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM --region=<my-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 | ![CloudFormation](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/images/console-create-stack-stacks-create-stack.png) 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 | <Navbar /> 223 | <SaveFooter visible={formChanged} onSave={handleSave} /> 224 | <AlertPopover visible={copyConfirm} text={alertMessage} error={alertError}/> 225 | <section className="pd-t-3 pd-b-3 pd-l-2 pd-r-2"> 226 | <p><Link to="/admin">Admin panel</Link></p> 227 | <h1 className="mg-b-3">Edit live stream</h1> 228 | <fieldset> 229 | <label htmlFor="stream-title">Stream title</label> 230 | <input className={styles.field} 231 | type="text" 232 | name="stream-title" 233 | id="stream-title" 234 | placeholder="Title" 235 | onChange={handleOnChange} 236 | onKeyDown={handleKeyPress} 237 | value={streamTitle} 238 | ></input> 239 | 240 | <label htmlFor="stream-subtitle">Stream subtitle</label> 241 | <input className={styles.field} 242 | type="text" 243 | name="stream-subtitle" 244 | id="stream-subtitle" 245 | placeholder="Subtitle" 246 | onChange={handleOnChange} 247 | onKeyDown={handleKeyPress} 248 | value={streamSubtitle} 249 | ></input> 250 | </fieldset> 251 | </section> 252 | <section className="pd-t-3 pd-b-5 pd-l-2 pd-r-2"> 253 | <fieldset> 254 | <label htmlFor="stream-ingest">Ingest server</label> 255 | <div className={styles.inlineButtons}> 256 | <input 257 | type="text" 258 | name="stream-ingest" 259 | id="stream-ingest" 260 | placeholder="Ingest server" 261 | value={ingestServer} 262 | readOnly 263 | ></input> 264 | <button 265 | className="btn btn--primary" 266 | onClick={handleIngestCopy} 267 | > 268 | Copy 269 | </button> 270 | </div> 271 | 272 | <label htmlFor="stream-key">Stream key</label> 273 | <div className={styles.inlineButtons}> 274 | <input 275 | type="password" 276 | name="stream-key" 277 | id="stream-key" 278 | placeholder="Stream key" 279 | value={streamKey} 280 | readOnly 281 | ></input> 282 | <button className="btn btn--secondary" onClick={handleKeyReset}> 283 | Reset 284 | </button> 285 | <button 286 | className="btn btn--primary" 287 | onClick={handleStreamKeyCopy} 288 | > 289 | Copy 290 | </button> 291 | </div> 292 | </fieldset> 293 | </section> 294 | <section className="pd-2"> 295 | <p className={styles.label}>Stream status</p> 296 | <p>{`${liveResponse.isLive === "Yes" ? "Live" : "Offline" }`}</p> 297 | </section> 298 | <section className="pd-2"> 299 | <button 300 | onClick={handlePreviewClick} 301 | className="btn btn--secondary full-width" 302 | > 303 | {showPreview ? "Hide video preview" : "Show video preview"} 304 | </button> 305 | {showPreview ? ( 306 | <div className="pd-t-2"> 307 | <VideoPlayer 308 | controls={true} 309 | muted={true} 310 | videoStream={liveResponse.playbackUrl} 311 | /> 312 | <div className="mg-t-2"> 313 | <h3 className={styles.h1}>{streamTitle}</h3> 314 | </div> 315 | <p className="mg-t-1 color-alt">{streamSubtitle}</p> 316 | </div> 317 | ) : ( 318 | <></> 319 | )} 320 | </section> 321 | <div style={{ height: "12rem" }}></div> 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 | --------------------------------------------------------------------------------