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)}
+ />
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
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