From 6c722d2de4958769d7d905454fb53e0a3e4fd601 Mon Sep 17 00:00:00 2001 From: SrGooglo Date: Tue, 29 Nov 2022 07:27:22 +0000 Subject: [PATCH] added `TV` space --- packages/app/src/pages/tv/[type].jsx | 107 +++++ .../tv/components/controlPanel/index.jsx | 395 ++++++++++++++++++ .../tv/components/controlPanel/index.less | 146 +++++++ .../src/pages/tv/components/explore/index.jsx | 109 +++++ .../pages/tv/components/explore/index.less | 119 ++++++ .../src/pages/tv/components/feed/index.jsx | 14 + .../src/pages/tv/components/feed/index.less | 1 + packages/app/src/pages/tv/index.jsx | 7 + packages/app/src/pages/tv/index.less | 47 +++ 9 files changed, 945 insertions(+) create mode 100644 packages/app/src/pages/tv/[type].jsx create mode 100644 packages/app/src/pages/tv/components/controlPanel/index.jsx create mode 100644 packages/app/src/pages/tv/components/controlPanel/index.less create mode 100644 packages/app/src/pages/tv/components/explore/index.jsx create mode 100644 packages/app/src/pages/tv/components/explore/index.less create mode 100644 packages/app/src/pages/tv/components/feed/index.jsx create mode 100644 packages/app/src/pages/tv/components/feed/index.less create mode 100644 packages/app/src/pages/tv/index.jsx create mode 100644 packages/app/src/pages/tv/index.less diff --git a/packages/app/src/pages/tv/[type].jsx b/packages/app/src/pages/tv/[type].jsx new file mode 100644 index 00000000..096974e7 --- /dev/null +++ b/packages/app/src/pages/tv/[type].jsx @@ -0,0 +1,107 @@ +import React from "react" +import * as antd from "antd" +import classnames from "classnames" + +import { Icons, createIconRender } from "components/Icons" + +import FeedTab from "./components/feed" +import ExploreTab from "./components/explore" +import ControlPanelTab from "./components/controlPanel" + +import "./index.less" + +const Tabs = { + "feed": { + title: "Feed", + icon: "Rss", + component: FeedTab + }, + "explore": { + title: "Explore", + icon: "Search", + component: ExploreTab + }, + "controlPanel": { + title: "Control Panel", + icon: "Settings", + component: ControlPanelTab + } +} + +export default class TVDashboard extends React.Component { + state = { + activeTab: this.props.match.params.type ?? "feed" + } + + primaryPanelRef = React.createRef() + + componentDidMount() { + app.eventBus.emit("style.compactMode", false) + } + + renderActiveTab() { + const tab = Tabs[this.state.activeTab] + + if (!tab) { + return + } + + return React.createElement(tab.component) + } + + handleTabChange = (key) => { + if (this.state.activeTab === key) return + + // set to primary panel fade-opacity-leave class + this.primaryPanelRef.current.classList.add("fade-opacity-leave") + + setTimeout(() => { + this.setState({ activeTab: key }) + // update location + app.history.replace(key) + }, 200) + + // remove fade-opacity-leave class after animation + setTimeout(() => { + this.primaryPanelRef.current.classList.remove("fade-opacity-leave") + }, 300) + } + + render() { + return
+
+ {this.renderActiveTab()} +
+ +
+
+

TV

+ this.handleTabChange(key)} + > + {Object.keys(Tabs).map((key) => { + const tab = Tabs[key] + + return + {tab.title} + + })} + +
+
+
+ } +} \ No newline at end of file diff --git a/packages/app/src/pages/tv/components/controlPanel/index.jsx b/packages/app/src/pages/tv/components/controlPanel/index.jsx new file mode 100644 index 00000000..d1dbd192 --- /dev/null +++ b/packages/app/src/pages/tv/components/controlPanel/index.jsx @@ -0,0 +1,395 @@ +import React from "react" +import * as antd from "antd" + +import { Icons } from "components/Icons" + +import Livestream from "../../../../models/livestream" + +import "./index.less" + +const StreamingKeyView = (props) => { + const [streamingKeyVisibility, setStreamingKeyVisibility] = React.useState(false) + + const toogleVisibility = (to) => { + setStreamingKeyVisibility(to ?? !streamingKeyVisibility) + } + + return
+ {streamingKeyVisibility ? + <> + toogleVisibility()} /> + + {props.streamingKey ?? "No streaming key available"} + + : +
toogleVisibility()} + > + + Click to show key +
+ } +
+} + +const LivestreamsCategoriesSelector = (props) => { + const [categories, setCategories] = React.useState([]) + const [loading, setLoading] = React.useState(true) + + const loadData = async () => { + setLoading(true) + + const categories = await Livestream.getCategories().catch((err) => { + console.error(err) + + app.message.error("Failed to load categories") + + return null + }) + + console.log(`Loaded categories >`, categories) + + setLoading(false) + + if (categories) { + setCategories(categories) + } + } + + React.useEffect(() => { + loadData() + }, []) + + if (loading) { + return + } + + return props.updateStreamInfo("category", value)} + > + { + categories.map((category) => { + return {category?.label ?? "No category"} + }) + } + +} + +const StreamInfoEditor = (props) => { + const [streamInfo, setStreamInfo] = React.useState(props.defaultStreamInfo ?? {}) + + const updateStreamInfo = (key, value) => { + setStreamInfo({ + ...streamInfo, + [key]: value, + }) + } + + const saveStreamInfo = async () => { + if (typeof props.onSave === "function") { + return await props.onSave(streamInfo) + } + + // peform default save + const result = await Livestream.updateLivestreamInfo(streamInfo).catch((err) => { + console.error(err) + + app.message.error("Failed to update stream info") + + return false + }) + + if (result) { + app.message.success("Stream info updated") + } + + if (typeof props.onSaveComplete === "function") { + await props.onSaveComplete(result) + } + + return result + } + + return
+
+ + Title + +
+ updateStreamInfo("title", e.target.value)} + /> +
+
+
+ + Description + +
+ updateStreamInfo("description", e.target.value)} + /> +
+
+
+ + Category + +
+ +
+
+ + Save + +
+} + +export default (props) => { + const [streamInfo, setStreamInfo] = React.useState({}) + const [addresses, setAddresses] = React.useState({}) + + const [isConnected, setIsConnected] = React.useState(false) + const [streamingKey, setStreamingKey] = React.useState(null) + + const onClickEditInfo = () => { + app.ModalController.open(() => { + if (result) { + app.ModalController.close() + + fetchStreamInfo() + } + }} + />) + } + + const regenerateStreamingKey = async () => { + antd.Modal.confirm({ + title: "Regenerate streaming key", + content: "Are you sure you want to regenerate the streaming key? After this, all other generated keys will be deleted.", + onOk: async () => { + const result = await Livestream.regenerateStreamingKey().catch((err) => { + app.message.error(`Failed to regenerate streaming key`) + console.error(err) + + return null + }) + + if (result) { + setStreamingKey(result.key) + } + } + }) + } + + const fetchStreamingKey = async () => { + const streamingKey = await Livestream.getStreamingKey().catch((err) => { + console.error(err) + return false + }) + + if (streamingKey) { + setStreamingKey(streamingKey.key) + } + } + + const fetchAddresses = async () => { + const addresses = await Livestream.getAddresses().catch((error) => { + app.message.error(`Failed to fetch addresses`) + console.error(error) + + return null + }) + + if (addresses) { + setAddresses(addresses) + } + } + + const fetchStreamInfo = async () => { + const result = await Livestream.getStreamInfo().catch((err) => { + console.error(err) + return false + }) + + console.log("Stream info", result) + + if (result) { + setStreamInfo(result) + } + } + + React.useEffect(() => { + fetchAddresses() + fetchStreamInfo() + fetchStreamingKey() + }, []) + + return
+
+
+ +
+ +
+
+ : } + > + {isConnected ? "Connected" : "Disconnected"} + +
+
+ + Title + +

+ {streamInfo?.title ?? "No title"} +

+
+ +
+ + Description + + +

+ {streamInfo?.description ?? "No description"} +

+
+ +
+ + Category + +

+ {streamInfo?.category?.label ?? "No category"} +

+
+
+ +
+ } + onClick={onClickEditInfo} + > + Edit info + +
+
+ +
+
+

Emission

+ +
+ Ingestion URL + + + {addresses.ingestURL ?? "No ingest URL available"} + +
+ +
+
+
+ Streaming key +
+
+ regenerateStreamingKey()}> + + Regenerate + +
+
+ +
+ +
+
+
+ +
+

Additional options

+ +
+ Enable DVR + +
+ +
+
+ +
+ Private mode + +
+ +
+
+
+ +
+

URL Information

+ +
+ Live URL + + + {addresses.liveURL ?? "No Live URL available"} + +
+ +
+ HLS URL + + + {addresses.hlsURL ?? "No HLS URL available"} + +
+ +
+ FLV URL + + + {addresses.flvURL ?? "No FLV URL available"} + +
+
+ +
+

Statistics

+ +
+ +

+ Cannot connect with statistics +

+
+
+
+
+
+} \ No newline at end of file diff --git a/packages/app/src/pages/tv/components/controlPanel/index.less b/packages/app/src/pages/tv/components/controlPanel/index.less new file mode 100644 index 00000000..e73da2bb --- /dev/null +++ b/packages/app/src/pages/tv/components/controlPanel/index.less @@ -0,0 +1,146 @@ +.streamingControlPanel { + display: flex; + flex-direction: column; + + width: 100%; + + transition: all 0.3s ease-in-out; + + .header { + display: flex; + flex-direction: row; + + height: fit-content; + + padding: 15px; + + border: 1px solid var(--border-color); + border-radius: 12px; + + margin-bottom: 20px; + + transition: all 0.3s ease-in-out; + + .preview { + height: 100%; + width: 300px; + + img { + width: 100%; + height: 100%; + + border-radius: 12px; + } + + margin-right: 40px; + } + + .details { + display: inline-flex; + flex-direction: column; + + padding: 20px 0; + width: 100%; + + .status { + margin-bottom: 20px; + } + } + } + + .config { + display: flex; + flex-direction: column; + + padding: 0 40px; + + transition: all 0.3s ease-in-out; + + code { + padding: 5px 8px; + font-size: 1rem; + + background-color: var(--background-color-accent); + color: var(--text-color); + + border-radius: 8px; + + font-family: "DM Mono", monospace; + + user-select: all; + } + + .panel { + display: flex; + flex-direction: column; + + margin-right: 50px; + + .content { + display: flex; + flex-direction: column; + + margin: 10px 20px 20px 0; + + width: 100%; + + .title { + display: inline-flex; + flex-direction: row; + + justify-content: space-between; + align-items: center; + + width: 100%; + margin-bottom: 8px; + } + } + } + } + +} + +.streamingKeyString { + display: inline-flex; + flex-direction: row; + + align-items: center; + justify-content: center; + + color: var(--text-color); + + svg { + font-size: 1.5rem; + + cursor: pointer; + } + + div { + display: inline-flex; + flex-direction: row; + + align-items: center; + justify-content: center; + } +} + +.streamInfoEditor { + display: flex; + flex-direction: column; + + .field { + display: flex; + flex-direction: column; + + margin-bottom: 20px; + + .value { + margin-top: 5px; + margin-left: 20px; + + .ant-select { + min-width: 200px; + } + } + } +} \ No newline at end of file diff --git a/packages/app/src/pages/tv/components/explore/index.jsx b/packages/app/src/pages/tv/components/explore/index.jsx new file mode 100644 index 00000000..9f6f0bc1 --- /dev/null +++ b/packages/app/src/pages/tv/components/explore/index.jsx @@ -0,0 +1,109 @@ +import React from "react" +import Livestream from "models/livestream" +import * as antd from "antd" + +import { UserPreview } from "components" +import { Icons } from "components/Icons" + +import "./index.less" + +const LivestreamItem = (props) => { + const { livestream = {} } = props + + const handleOnClick = async () => { + if (typeof props.onClick !== "function") { + console.warn("LivestreamItem: onClick is not a function") + return + } + + return await props.onClick(livestream) + } + + return
+
+ +
+
+ + +
+

{livestream.info?.title}

+
+
+

{livestream.info?.description ?? "No description"}

+
+
+ {livestream.info?.category?.label ?? "No category"} +
+
+
+} + +export default (props) => { + const [loading, setLoading] = React.useState(true) + const [list, setList] = React.useState([]) + + const loadStreamings = async () => { + setLoading(true) + + const livestreams = await Livestream.getLivestreams().catch((err) => { + console.error(err) + app.message.error("Failed to load livestreams") + return false + }) + + console.log("Livestreams", livestreams) + + setLoading(false) + + if (livestreams) { + if (!Array.isArray(livestreams)) { + console.error("Livestreams is not an array") + return false + } + + setList(livestreams) + } + } + + const onClickItem = (livestream) => { + app.setLocation(`/live/${livestream.username}`) + } + + const renderList = () => { + if (loading) { + return + } + + if (list.length === 0) { + return +

+ No livestreams found +

+
+ } + + return list.map((livestream) => { + return + }) + } + + React.useEffect(() => { + loadStreamings() + }, []) + + return
+
+
+

+ + Livestreams +

+
+
+ +
+ {renderList()} +
+
+} \ No newline at end of file diff --git a/packages/app/src/pages/tv/components/explore/index.less b/packages/app/src/pages/tv/components/explore/index.less new file mode 100644 index 00000000..6c27f523 --- /dev/null +++ b/packages/app/src/pages/tv/components/explore/index.less @@ -0,0 +1,119 @@ +@item_border_radius: 10px; + +.livestreamsBrowser { + display: flex; + flex-direction: column; + + width: 100%; + + .header { + display: inline-flex; + flex-direction: row; + + align-items: center; + justify-content: space-between; + + width: 100%; + + .panel { + display: inline-flex; + flex-direction: row; + + align-items: center; + } + + .title { + svg { + font-size: 2.5rem; + } + } + + font-size: 2rem; + } + + .livestream_list { + display: flex; + flex-direction: column; + padding: 0 50px; + + .livestream_item { + display: flex; + flex-direction: row; + + align-items: center; + + background-color: var(--background-color-primary2); + + border: 1px solid var(--border-color); + + padding: 10px; + margin-bottom: 20px; + + border-radius: @item_border_radius; + + cursor: pointer; + transition: all 0.2s ease-in-out; + + &:hover { + background-color: var(--background-color-accent); + } + + .livestream_thumbnail { + width: 8vw; + height: 100%; + + img { + width: 100%; + height: 100%; + border-radius: @item_border_radius; + } + } + + .livestream_info { + position: relative; + + width: 100%; + + margin-left: 20px; + font-size: 1rem; + + padding: 10px 0; + + color: var(--text-color); + + .userPreview { + font-size: 1.5rem; + } + + h1, + h2 { + margin: 0; + height: fit-content; + color: var(--text-color); + } + + .livestream_title { + margin-top: 10px; + font-size: 1.5rem; + height: fit-content; + font-family: "Space Grotesk", sans-serif; + } + + .livestream_description { + font-size: 0.6rem; + font-weight: 400; + height: fit-content; + } + + .livestream_category { + position: absolute; + + top: 0; + right: 0; + + padding: 10px; + } + } + } + } +} \ No newline at end of file diff --git a/packages/app/src/pages/tv/components/feed/index.jsx b/packages/app/src/pages/tv/components/feed/index.jsx new file mode 100644 index 00000000..3dce8530 --- /dev/null +++ b/packages/app/src/pages/tv/components/feed/index.jsx @@ -0,0 +1,14 @@ +import React from "react" +import { Result } from "antd" + +import "./index.less" + +export default (props) => { + return
+ +
+} \ No newline at end of file diff --git a/packages/app/src/pages/tv/components/feed/index.less b/packages/app/src/pages/tv/components/feed/index.less new file mode 100644 index 00000000..112802cc --- /dev/null +++ b/packages/app/src/pages/tv/components/feed/index.less @@ -0,0 +1 @@ +.livestreamsFeed {} \ No newline at end of file diff --git a/packages/app/src/pages/tv/index.jsx b/packages/app/src/pages/tv/index.jsx new file mode 100644 index 00000000..76411c7b --- /dev/null +++ b/packages/app/src/pages/tv/index.jsx @@ -0,0 +1,7 @@ +import React from "react" + +export default () => { + app.setLocation("/tv/feed") + + return <> +} \ No newline at end of file diff --git a/packages/app/src/pages/tv/index.less b/packages/app/src/pages/tv/index.less new file mode 100644 index 00000000..6fe84c74 --- /dev/null +++ b/packages/app/src/pages/tv/index.less @@ -0,0 +1,47 @@ +.dashboard { + display: grid; + + grid-template-columns: 3fr 1fr; + grid-template-rows: 1fr; + grid-column-gap: 10px; + grid-row-gap: 0px; + + width: 100%; + + padding-left: 30px; + + .panel { + position: sticky; + top: 0; + + height: fit-content; + + display: flex; + flex-direction: column; + + align-items: center; + + >div { + margin-bottom: 15px; + } + + .card { + background-color: var(--background-color-accent); + border-radius: 12px; + padding: 20px; + + min-width: 20vw; + + h1, + h2 { + font-family: "Space Grotesk", sans-serif; + } + } + } + + .ant-menu { + svg { + margin-right: 0 !important; + } + } +} \ No newline at end of file