diff --git a/packages/app/src/pages/studio/tv/[profile_id]/components/StreamPreview/index.jsx b/packages/app/src/pages/studio/tv/[profile_id]/components/StreamPreview/index.jsx
new file mode 100644
index 00000000..a90bcb94
--- /dev/null
+++ b/packages/app/src/pages/studio/tv/[profile_id]/components/StreamPreview/index.jsx
@@ -0,0 +1,44 @@
+import React from "react"
+import Hls from "hls.js"
+
+const StreamPreview = ({ profile }) => {
+ const videoRef = React.useRef(null)
+ const hlsInstance = React.useRef(null)
+
+ React.useEffect(() => {
+ hlsInstance.current = new Hls({
+ maxLiveSyncPlaybackRate: 1.5,
+ strategy: "bandwidth",
+ autoplay: true,
+ })
+
+ hlsInstance.current.attachMedia(videoRef.current)
+
+ hlsInstance.current.on(Hls.Events.MEDIA_ATTACHED, () => {
+ hlsInstance.current.loadSource(profile.sources.hls)
+ })
+
+ videoRef.current.addEventListener("play", () => {
+ console.log("[HLS] Syncing to last position")
+ videoRef.current.currentTime = hlsInstance.current.liveSyncPosition
+ })
+
+ videoRef.current.play()
+
+ return () => {
+ hlsInstance.current.destroy()
+ }
+ }, [])
+
+ return (
+
+ )
+}
+
+export default StreamPreview
diff --git a/packages/app/src/pages/studio/tv/[profile_id]/header.jsx b/packages/app/src/pages/studio/tv/[profile_id]/header.jsx
index 94da6088..3d010fc8 100644
--- a/packages/app/src/pages/studio/tv/[profile_id]/header.jsx
+++ b/packages/app/src/pages/studio/tv/[profile_id]/header.jsx
@@ -9,7 +9,11 @@ const ProfileHeader = ({ profile, streamHealth }) => {
async function setTimedThumbnail() {
setThumbnail(() => {
- if (streamRef.current.online && profile.info.thumbnail) {
+ if (
+ streamRef.current &&
+ streamRef.current.online &&
+ profile.info.thumbnail
+ ) {
return `${profile.info.thumbnail}?t=${Date.now()}`
}
diff --git a/packages/app/src/pages/studio/tv/[profile_id]/index.jsx b/packages/app/src/pages/studio/tv/[profile_id]/index.jsx
index 27a77f23..55656b26 100644
--- a/packages/app/src/pages/studio/tv/[profile_id]/index.jsx
+++ b/packages/app/src/pages/studio/tv/[profile_id]/index.jsx
@@ -59,6 +59,10 @@ const ProfileData = (props) => {
return false
}
+ if (!ws.state.connected) {
+ return false
+ }
+
const health = await ws.call("stream:health", profile_id)
setStreamHealth(health)
diff --git a/packages/app/src/pages/studio/tv/[profile_id]/index.less b/packages/app/src/pages/studio/tv/[profile_id]/index.less
index 5e9770d0..f7e31276 100644
--- a/packages/app/src/pages/studio/tv/[profile_id]/index.less
+++ b/packages/app/src/pages/studio/tv/[profile_id]/index.less
@@ -60,6 +60,7 @@
top: 0;
z-index: 10;
+ object-fit: cover;
}
.profile-header__content {
@@ -217,9 +218,6 @@
background-color: var(--background-color-accent);
.ant-segmented-thumb {
- left: var(--thumb-active-left);
- width: var(--thumb-active-width);
-
background-color: var(--background-color-primary-2);
transition: all 150ms ease-in-out;
diff --git a/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.jsx b/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.jsx
index 0e94c799..04fcd0e5 100644
--- a/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.jsx
+++ b/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.jsx
@@ -4,6 +4,7 @@ import UploadButton from "@components/UploadButton"
import { FiImage, FiInfo } from "react-icons/fi"
import { MdTextFields, MdDescription } from "react-icons/md"
+import StreamPreview from "../../components/StreamPreview"
import StreamRateChart from "../../components/StreamRateChart"
import { formatBytes, formatBitrate } from "../../liveTabUtils"
import { useStreamSignalQuality } from "../../useStreamSignalQuality"
@@ -146,7 +147,7 @@ const Live = ({ profile, loading, handleProfileUpdate, streamHealth }) => {
- Stream Status:{" "}
+ Stream Status:
{streamHealth?.online ? (
Online
) : (
@@ -155,9 +156,14 @@ const Live = ({ profile, loading, handleProfileUpdate, streamHealth }) => {
- {streamHealth?.online
- ? "Video Preview Area"
- : "Stream is Offline"}
+ {streamHealth?.online ? (
+
+ ) : (
+ "Stream is Offline"
+ )}
diff --git a/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.less b/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.less
index 3add9d24..0d9157b4 100644
--- a/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.less
+++ b/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.less
@@ -27,7 +27,10 @@
justify-content: center;
width: 100%;
- min-height: 200px;
+ height: 250px;
+
+ max-height: 250px;
+ min-height: 250px;
background-color: var(--background-color-primary);
@@ -35,6 +38,14 @@
border-radius: 4px;
font-size: 1rem;
+
+ overflow: hidden;
+
+ video {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ }
}
.live-tab-stats {
diff --git a/packages/app/src/pages/studio/tv/components/ProfileCreator/index.jsx b/packages/app/src/pages/studio/tv/components/ProfileCreator/index.jsx
index ccb587d2..8344992a 100644
--- a/packages/app/src/pages/studio/tv/components/ProfileCreator/index.jsx
+++ b/packages/app/src/pages/studio/tv/components/ProfileCreator/index.jsx
@@ -7,33 +7,30 @@ import "./index.less"
const ProfileCreator = (props) => {
const [loading, setLoading] = React.useState(false)
- const [name, setName] = React.useState(props.editValue ?? null)
+ const [title, setTitle] = React.useState(props.editValue ?? null)
function handleChange(e) {
- setName(e.target.value.trim())
+ setTitle(e.target.value.trim())
}
async function handleSubmit() {
setLoading(true)
- if (props.editValue) {
- if (typeof props.onEdit === "function") {
- await props.onEdit(name)
- }
- } else {
- const result = await Streaming.createProfile({
- profile_name: name,
- }).catch((error) => {
- console.error(error)
- app.message.error("Failed to create")
- return null
- })
+ const result = await Streaming.createProfile({
+ info: {
+ title: title,
+ },
+ }).catch((error) => {
+ console.error(error)
+ app.message.error("Failed to create")
+ return null
+ })
- if (result) {
- app.message.success("Created")
- app.eventBus.emit("app:new_profile", result)
- props.onCreate(result._id, result)
- }
+ if (result) {
+ app.message.success("Created")
+ app.eventBus.emit("app:new_profile", result)
+
+ props.onCreate(result._id, result)
}
props.close()
@@ -44,8 +41,8 @@ const ProfileCreator = (props) => {
return (
@@ -55,12 +52,12 @@ const ProfileCreator = (props) => {
{
- handleSubmit(name)
+ handleSubmit(title)
}}
- disabled={!name || loading}
+ disabled={!title || loading}
loading={loading}
>
- {props.editValue ? "Update" : "Create"}
+ Create
diff --git a/packages/app/src/pages/studio/tv/components/ProfileItem/index.jsx b/packages/app/src/pages/studio/tv/components/ProfileItem/index.jsx
new file mode 100644
index 00000000..d15775a0
--- /dev/null
+++ b/packages/app/src/pages/studio/tv/components/ProfileItem/index.jsx
@@ -0,0 +1,56 @@
+import React from "react"
+import * as antd from "antd"
+
+import "./index.less"
+
+const Profile = ({
+ profile,
+ onClickManage,
+ onClickChangeId,
+ onClickDelete,
+}) => {
+ if (!profile) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+
+
{profile.info.title}
+
+
+
+ {profile.info.description ?? "No description"}
+
+
+
+
+ Manage
+
+
+ Delete
+
+
+
+
+ )
+}
+
+export default Profile
diff --git a/packages/app/src/pages/studio/tv/components/ProfileItem/index.less b/packages/app/src/pages/studio/tv/components/ProfileItem/index.less
new file mode 100644
index 00000000..0520a8ad
--- /dev/null
+++ b/packages/app/src/pages/studio/tv/components/ProfileItem/index.less
@@ -0,0 +1,107 @@
+.tvstudio-page-list-item {
+ position: relative;
+
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+
+ height: 130px;
+
+ background-color: var(--background-color-accent);
+
+ overflow: hidden;
+
+ &:not(:last-child) {
+ border-bottom: 3px solid var(--border-color);
+ }
+
+ &__id {
+ position: absolute;
+ z-index: 100;
+
+ top: 0;
+ right: 0;
+
+ padding: 10px;
+ }
+
+ &__thumbnail {
+ position: absolute;
+ z-index: 55;
+
+ right: 0;
+ top: 0;
+
+ background-size: cover;
+ background-position: center;
+
+ opacity: 0.5;
+
+ width: 50%;
+ height: 100%;
+
+ cursor: pointer;
+
+ mask-image: linear-gradient(
+ to right,
+ rgba(0, 0, 0, 0),
+ rgba(0, 0, 0, 1)
+ );
+ }
+
+ &__content {
+ z-index: 100;
+
+ display: flex;
+ flex-direction: column;
+
+ width: 60%;
+ height: 100%;
+
+ padding: 20px 20px;
+
+ gap: 5px;
+
+ &__title {
+ display: flex;
+ flex-direction: row;
+
+ align-items: center;
+
+ gap: 10px;
+
+ font-size: 0.8rem;
+ }
+
+ &__description {
+ display: flex;
+
+ flex-direction: row;
+ align-items: center;
+
+ gap: 10px;
+
+ font-size: 0.7rem;
+ }
+
+ &__actions {
+ position: absolute;
+
+ display: flex;
+
+ flex-direction: row;
+ align-items: center;
+
+ gap: 10px;
+
+ margin: 10px 20px;
+
+ bottom: 0;
+ left: 0;
+
+ .ant-btn {
+ font-size: 0.7rem;
+ }
+ }
+ }
+}
diff --git a/packages/app/src/pages/studio/tv/index.jsx b/packages/app/src/pages/studio/tv/index.jsx
index 03db7019..93f0e44e 100644
--- a/packages/app/src/pages/studio/tv/index.jsx
+++ b/packages/app/src/pages/studio/tv/index.jsx
@@ -1,22 +1,16 @@
import React from "react"
import * as antd from "antd"
-import ProfileCreator from "./components/ProfileCreator"
import Skeleton from "@components/Skeleton"
+import ProfileCreator from "./components/ProfileCreator"
+import ProfileItem from "./components/ProfileItem"
+
import Streaming from "@models/spectrum"
-import useCenteredContainer from "@hooks/useCenteredContainer"
-
import "./index.less"
-const Profile = ({ profile, onClick }) => {
- return {profile.profile_name}
-}
-
const TVStudioPage = (props) => {
- useCenteredContainer(false)
-
const [loading, list, error, repeat] = app.cores.api.useRequest(
Streaming.getOwnProfiles,
)
@@ -25,38 +19,74 @@ const TVStudioPage = (props) => {
app.layout.modal.open("tv_profile_creator", ProfileCreator, {
props: {
onCreate: (id, data) => {
- setSelectedProfileId(id)
+ repeat()
},
},
})
}
- function handleProfileClick(id) {
+ function handleDeleteProfileClick(id) {
+ app.layout.modal.confirm({
+ headerText: "Delete profile",
+ descriptionText: "Are you sure you want to delete profile?",
+ onConfirm: async () => {
+ const result = await Streaming.deleteProfile(id)
+
+ if (result) {
+ app.message.success("Profile deleted")
+ repeat()
+ }
+ },
+ })
+ }
+
+ function handleManageProfileClick(id) {
app.location.push(`/studio/tv/${id}`)
}
+ if (error) {
+ return (
+
+ )
+ }
+
if (loading) {
return
}
return (
+
+
TV Studio
+
+
- {list.length > 0 &&
- list.map((profile, index) => {
- return (
-
handleProfileClick(profile._id)}
- />
- )
- })}
+
+ {list.length > 0 &&
+ list.map((profile, index) => {
+ return (
+
+ handleManageProfileClick(profile._id)
+ }
+ onClickDelete={() =>
+ handleDeleteProfileClick(profile._id)
+ }
+ />
+ )
+ })}
+
)
}
diff --git a/packages/app/src/pages/studio/tv/index.less b/packages/app/src/pages/studio/tv/index.less
index 2ceb17cf..8d6328b4 100644
--- a/packages/app/src/pages/studio/tv/index.less
+++ b/packages/app/src/pages/studio/tv/index.less
@@ -1,37 +1,57 @@
.tvstudio-page {
- display: flex;
- flex-direction: column;
+ display: flex;
+ flex-direction: column;
- width: 100%;
+ width: 100%;
- gap: 10px;
+ gap: 10px;
- .tvstudio-page-actions {
- display: flex;
- flex-direction: row;
+ .tvstudio-page-header {
+ display: flex;
+ flex-direction: row;
- justify-content: space-between;
+ width: fit-content;
- width: 100%;
+ gap: 10px;
- gap: 10px;
+ background-color: var(--background-color-accent);
+ padding: 10px 20px;
- .profile-selector {
- display: flex;
- flex-direction: row;
+ border-radius: 12px;
- width: 100%;
- }
- }
+ h1 {
+ font-family: "Space Grotesk", sans-serif;
+ font-size: 1.3rem;
+ margin: 0;
+ }
+ }
- .tvstudio-page-selector-hint {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
+ .tvstudio-page-actions {
+ display: flex;
+ flex-direction: row;
- width: 100%;
+ justify-content: space-between;
- padding: 50px 0;
- }
-}
\ No newline at end of file
+ width: 100%;
+
+ gap: 10px;
+
+ .profile-selector {
+ display: flex;
+ flex-direction: row;
+
+ width: 100%;
+ }
+ }
+
+ .tvstudio-page-list {
+ display: flex;
+ flex-direction: column;
+
+ width: 100%;
+
+ border-radius: 12px;
+
+ overflow: hidden;
+ }
+}