diff --git a/packages/app/src/pages/tv/components/controlPanel/index.jsx b/packages/app/src/pages/tv/components/controlPanel/index.jsx deleted file mode 100755 index 0ad182e4..00000000 --- a/packages/app/src/pages/tv/components/controlPanel/index.jsx +++ /dev/null @@ -1,430 +0,0 @@ -import React from "react" -import * as antd from "antd" - -import { Icons, createIconRender } from "components/Icons" - -import Livestream from "../../../../models/livestream" - -import "./index.less" - -const CategoryView = (props) => { - const category = props.category - - const [categoryData, setCategoryData] = React.useState(null) - - const loadData = async () => { - const categoryData = await Livestream.getCategories(category).catch((err) => { - console.error(err) - - app.message.error("Failed to load category") - - return null - }) - - setCategoryData(categoryData) - } - - React.useEffect(() => { - if (props.category) { - loadData() - } - }, [props.category]) - - return
- { - categoryData?.icon && -
- {createIconRender(categoryData.icon)} -
- } - -
- {categoryData?.label ?? "No category"} -
-
-} - -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 - - -
-
- -
- } - 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

- -
- AAC URL (Only Audio) - - - {addresses.aacURL ?? "No AAC 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/tabs.jsx b/packages/app/src/pages/tv/tabs.jsx index f1f40c2e..2470b638 100755 --- a/packages/app/src/pages/tv/tabs.jsx +++ b/packages/app/src/pages/tv/tabs.jsx @@ -1,5 +1,5 @@ -import FeedTab from "./components/feed" -import ControlPanelTab from "./components/controlPanel" +import FeedTab from "./tabs/feed" +import ControlPanelTab from "./tabs/livestreamControlPanel" export default [ { diff --git a/packages/app/src/pages/tv/components/feed/index.jsx b/packages/app/src/pages/tv/tabs/feed/index.jsx similarity index 100% rename from packages/app/src/pages/tv/components/feed/index.jsx rename to packages/app/src/pages/tv/tabs/feed/index.jsx diff --git a/packages/app/src/pages/tv/components/feed/index.less b/packages/app/src/pages/tv/tabs/feed/index.less similarity index 100% rename from packages/app/src/pages/tv/components/feed/index.less rename to packages/app/src/pages/tv/tabs/feed/index.less diff --git a/packages/app/src/pages/tv/tabs/livestreamControlPanel/components/CategoriesSelector/index.jsx b/packages/app/src/pages/tv/tabs/livestreamControlPanel/components/CategoriesSelector/index.jsx new file mode 100644 index 00000000..bdf3c73a --- /dev/null +++ b/packages/app/src/pages/tv/tabs/livestreamControlPanel/components/CategoriesSelector/index.jsx @@ -0,0 +1,49 @@ +import React from "react" +import * as antd from "antd" + +import Livestream from "models/livestream" + +export default (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"} + }) + } + +} \ No newline at end of file diff --git a/packages/app/src/pages/tv/tabs/livestreamControlPanel/components/CategoryViewResolver/index.jsx b/packages/app/src/pages/tv/tabs/livestreamControlPanel/components/CategoryViewResolver/index.jsx new file mode 100644 index 00000000..1a1fc973 --- /dev/null +++ b/packages/app/src/pages/tv/tabs/livestreamControlPanel/components/CategoryViewResolver/index.jsx @@ -0,0 +1,41 @@ +import React from "react" +import { createIconRender } from "components/Icons" + +import Livestream from "models/livestream" + +export default (props) => { + const category = props.category + + const [categoryData, setCategoryData] = React.useState(null) + + const loadData = async () => { + const categoryData = await Livestream.getCategories(category).catch((err) => { + console.error(err) + + app.message.error("Failed to load category") + + return null + }) + + setCategoryData(categoryData) + } + + React.useEffect(() => { + if (props.category) { + loadData() + } + }, [props.category]) + + return
+ { + categoryData?.icon && +
+ {createIconRender(categoryData.icon)} +
+ } + +
+ {categoryData?.label ?? "No category"} +
+
+} diff --git a/packages/app/src/pages/tv/tabs/livestreamControlPanel/components/ProfileEditor/index.jsx b/packages/app/src/pages/tv/tabs/livestreamControlPanel/components/ProfileEditor/index.jsx new file mode 100644 index 00000000..cafc1a63 --- /dev/null +++ b/packages/app/src/pages/tv/tabs/livestreamControlPanel/components/ProfileEditor/index.jsx @@ -0,0 +1,143 @@ +import React from "react" +import * as antd from "antd" +import { Icons } from "components/Icons" +import UploadButton from "components/UploadButton" + +import CategoriesSelector from "../CategoriesSelector" + +import "./index.less" + +export default (props) => { + const [profileData, setProfileData] = React.useState(props.profileData ?? {}) + + const updateStreamInfo = (key, value) => { + setProfileData((oldData) => { + return { + ...oldData, + info: { + ...oldData.info, + [key]: value, + } + } + }) + } + + const handleClickSave = async () => { + if (typeof props.onSave === "function") { + return await props.onSave(profileData) + } + } + + const handleClickDelete = async () => { + if (typeof props.onDelete === "function") { + return await props.onDelete(profileData._id) + } + } + + return
+
+ + Profile Name + + +
+ setProfileData((oldData) => { + return { + ...oldData, + profile_name: e.target.value, + } + })} + /> +
+
+
+ + Thumbnail + + +
+ { + profileData.info?.thumbnail && + } + +
+ { + console.log(`Uploaded file >`, file) + + updateStreamInfo("thumbnail", file.url) + }} + /> + + { + profileData.info?.thumbnail && } + onClick={() => updateStreamInfo("thumbnail", null)} + danger + > + Delete + + } +
+
+
+
+ + Title + + +
+ updateStreamInfo("title", e.target.value)} + /> +
+
+
+ + Description + + +
+ updateStreamInfo("description", e.target.value)} + /> +
+
+
+ + Category + + +
+ +
+
+ + + Save + + + + Delete + +
+} \ No newline at end of file diff --git a/packages/app/src/pages/tv/tabs/livestreamControlPanel/components/ProfileEditor/index.less b/packages/app/src/pages/tv/tabs/livestreamControlPanel/components/ProfileEditor/index.less new file mode 100644 index 00000000..21f41192 --- /dev/null +++ b/packages/app/src/pages/tv/tabs/livestreamControlPanel/components/ProfileEditor/index.less @@ -0,0 +1,48 @@ +.profileEditor { + 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; + } + + &.thumbnail { + display: flex; + flex-direction: column; + + align-items: center; + justify-content: center; + + width: 100%; + + gap: 10px; + + img { + border-radius: 12px; + + max-width: 600px; + max-height: 400px; + + object-fit: cover; + } + + .controls { + display: flex; + flex-direction: row; + + gap: 10px; + } + } + } + } +} \ No newline at end of file diff --git a/packages/app/src/pages/tv/tabs/livestreamControlPanel/components/ProfileSelector/index.jsx b/packages/app/src/pages/tv/tabs/livestreamControlPanel/components/ProfileSelector/index.jsx new file mode 100644 index 00000000..56961e1d --- /dev/null +++ b/packages/app/src/pages/tv/tabs/livestreamControlPanel/components/ProfileSelector/index.jsx @@ -0,0 +1,65 @@ +import React from "react" +import * as antd from "antd" +import { Icons } from "components/Icons" +//import { useTranslation } from "react-i18next" + +const ProfilesDropboxCustomRender = (props) => { + return <> + {props.menu} + + + + + + } + onClick={props.onClickAdd} + > + Create + + + +} + +export default (props) => { + const [inputValue, setInputValue] = React.useState("") + + const onInputChange = (e) => { + setInputValue(e.target.value) + } + + const onAddNewProfile = async () => { + const value = inputValue.trim() + + if (!value) { + return + } + + setInputValue("") + + props.onCreateProfile(value) + } + + return props.onChangeProfile(_id, item)} + dropdownRender={(menu) => } + options={props.profiles.map((item) => ({ + label: item.profile_name, + value: item._id, + }))} + /> +} \ No newline at end of file diff --git a/packages/app/src/pages/tv/tabs/livestreamControlPanel/components/StreamingKeyViewer/index.jsx b/packages/app/src/pages/tv/tabs/livestreamControlPanel/components/StreamingKeyViewer/index.jsx new file mode 100644 index 00000000..147066bb --- /dev/null +++ b/packages/app/src/pages/tv/tabs/livestreamControlPanel/components/StreamingKeyViewer/index.jsx @@ -0,0 +1,29 @@ +import React from "react" +import { Icons } from "components/Icons" + +import "./index.less" + +export default (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 +
+ } +
+} diff --git a/packages/app/src/pages/tv/tabs/livestreamControlPanel/components/StreamingKeyViewer/index.less b/packages/app/src/pages/tv/tabs/livestreamControlPanel/components/StreamingKeyViewer/index.less new file mode 100644 index 00000000..622f83f8 --- /dev/null +++ b/packages/app/src/pages/tv/tabs/livestreamControlPanel/components/StreamingKeyViewer/index.less @@ -0,0 +1,23 @@ +.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; + } +} \ No newline at end of file diff --git a/packages/app/src/pages/tv/tabs/livestreamControlPanel/index.jsx b/packages/app/src/pages/tv/tabs/livestreamControlPanel/index.jsx new file mode 100755 index 00000000..41438061 --- /dev/null +++ b/packages/app/src/pages/tv/tabs/livestreamControlPanel/index.jsx @@ -0,0 +1,344 @@ +import React from "react" +import * as antd from "antd" +import { Icons } from "components/Icons" + +import ProfileSelector from "./components/ProfileSelector" +import CategoryViewResolver from "./components/CategoryViewResolver" +import StreamingKeyViewer from "./components/StreamingKeyViewer" +import ProfileEditor from "./components/ProfileEditor" + +import Livestream from "models/livestream" + +import "./index.less" + +export default (props) => { + const [L_Profiles, R_Profiles, E_Profiles, M_Profiles] = app.cores.api.useRequest(Livestream.getProfiles) + + const [selectedProfileId, setSelectedProfileId] = React.useState(null) + + const [addresses, setAddresses] = React.useState({}) + + const [isConnected, setIsConnected] = React.useState(false) + + 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) + } + } + + React.useEffect(() => { + if (R_Profiles) { + fetchAddresses() + + if (!selectedProfileId) { + setSelectedProfileId(R_Profiles[0]?._id) + } + } + }, [R_Profiles]) + + if (E_Profiles) { + console.error(E_Profiles) + + return + } + + if (L_Profiles) { + return + } + + const profileData = R_Profiles.find((profile) => profile._id === selectedProfileId) + + const handleCreateProfile = async (profile_name) => { + if (!profile_name) { + return false + } + + const result = await Livestream.postProfile({ + profile_name: profile_name, + }).catch((err) => { + console.error(err) + + app.message.error("Failed to add new profile") + + return false + }) + + if (result) { + app.message.success("New profile added") + + await M_Profiles() + + setSelectedProfileId(result._id) + } + } + + const handleCurrentProfileDataUpdate = async (newProfileData) => { + if (!profileData) { + return + } + + const result = await Livestream.postProfile({ + ...newProfileData, + profile_id: profileData._id, + }).catch((err) => { + console.error(err) + + app.message.error("Failed to update profile") + + return false + }) + + if (result) { + app.message.success("Profile updated") + + app.ModalController.close() + + M_Profiles() + } + } + + const handleCurrentProfileDelete = async () => { + if (!profileData) { + return + } + + // open confirm modal + antd.Modal.confirm({ + title: "Delete profile", + content: "Are you sure you want to delete this profile?", + onOk: async () => { + const result = await Livestream.deleteProfile(profileData._id).catch((err) => { + console.error(err) + + app.message.error("Failed to delete profile") + + return false + }) + + if (result) { + app.message.success("Profile deleted") + + app.ModalController.close() + + setSelectedProfileId(null) + + M_Profiles() + } + } + }) + } + + const onClickEditInfo = () => { + if (!profileData) { + return + } + + app.ModalController.open(() => ) + } + + const regenerateStreamingKey = async () => { + if (!profileData) { + return + } + + antd.Modal.confirm({ + title: "Regenerate streaming key", + content: "Are you sure you want to regenerate the streaming key? After this, the old stream key will no longer be valid.", + onOk: async () => { + const result = await Livestream.regenerateStreamingKey(profileData._id).catch((err) => { + app.message.error(`Failed to regenerate streaming key`) + console.error(err) + + return null + }) + + if (result) { + M_Profiles() + } + } + }) + } + + return
+
+
+ +
+ +
+
+ : } + > + {isConnected ? "Connected" : "Disconnected"} + +
+ +
+ + Title + +

+ {profileData?.info.title ?? "No title"} +

+
+ +
+ + Description + + +

+ {profileData?.info.description ?? "No description"} +

+
+ +
+ + Category + + + +
+
+ +
+ { + setSelectedProfileId(profileID) + }} + /> + + } + onClick={onClickEditInfo} + > + Edit profile + +
+
+ +
+
+

Emission

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

Additional options

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

URL Information

+ +
+ AAC URL (Only Audio) + + + {addresses.aacURL ?? "No AAC URL available"} + +
+ +
+ HLS URL + + + {addresses.hlsURL ?? "No HLS URL available"} + +
+ +
+ FLV URL + + + {addresses.flvURL ?? "No FLV URL available"} + +
+
+ +
+

Statistics

+ +
+ +

+ Cannot connect with statistics +

+
+
+
+
+
+} diff --git a/packages/app/src/pages/tv/components/controlPanel/index.less b/packages/app/src/pages/tv/tabs/livestreamControlPanel/index.less similarity index 69% rename from packages/app/src/pages/tv/components/controlPanel/index.less rename to packages/app/src/pages/tv/tabs/livestreamControlPanel/index.less index 531f2db5..95fa2dca 100755 --- a/packages/app/src/pages/tv/components/controlPanel/index.less +++ b/packages/app/src/pages/tv/tabs/livestreamControlPanel/index.less @@ -6,7 +6,7 @@ transition: all 0.3s ease-in-out; - .header { + .streamingControlPanel_header { display: flex; flex-direction: row; @@ -21,13 +21,20 @@ transition: all 0.3s ease-in-out; - .preview { + .streamingControlPanel_header_thumbnail { + align-self: center; + justify-self: center; + + border-radius: 12px; + height: 100%; - width: 300px; + width: 100%; img { - width: 100%; - height: 100%; + height: 15vh; + width: 20vw; + + object-fit: cover; border-radius: 12px; } @@ -35,16 +42,32 @@ margin-right: 40px; } - .details { + .streamingControlPanel_header_details { display: inline-flex; flex-direction: column; padding: 20px 0; width: 100%; - .status { + .streamingControlPanel_header_details_status { margin-bottom: 20px; } + + color: var(--text-color); + } + + .streamingControlPanel_header_actions { + display: flex; + flex-direction: row; + + justify-items: center; + + gap: 10px; + + .ant-select { + height: fit-content; + min-width: 300px; + } } } @@ -56,6 +79,8 @@ transition: all 0.3s ease-in-out; + gap: 20px; + code { padding: 5px 8px; font-size: 1rem; @@ -76,7 +101,6 @@ align-items: flex-start; - h1, h2, h3 { @@ -87,9 +111,9 @@ .title { display: inline-flex; flex-direction: row; - + justify-content: space-between; - + width: 100%; } @@ -102,49 +126,4 @@ } } } -} - -.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