Revamp TV Studio page and add live stream preview

The TV Studio page now features a new list design, profile
deletion, and updated profile creation (uses 'title', create-only).
A live video preview has been added to the Live tab for active
streams.

Also includes fixes for stream health updates and timed thumbnail
loading.
This commit is contained in:
SrGooglo 2025-05-12 02:25:24 +00:00
parent c91657f34d
commit 80d84b3e17
11 changed files with 356 additions and 79 deletions

View File

@ -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 (
<video
muted
autoplay
controls
ref={videoRef}
id="stream_preview_player"
/>
)
}
export default StreamPreview

View File

@ -9,7 +9,11 @@ const ProfileHeader = ({ profile, streamHealth }) => {
async function setTimedThumbnail() { async function setTimedThumbnail() {
setThumbnail(() => { setThumbnail(() => {
if (streamRef.current.online && profile.info.thumbnail) { if (
streamRef.current &&
streamRef.current.online &&
profile.info.thumbnail
) {
return `${profile.info.thumbnail}?t=${Date.now()}` return `${profile.info.thumbnail}?t=${Date.now()}`
} }

View File

@ -59,6 +59,10 @@ const ProfileData = (props) => {
return false return false
} }
if (!ws.state.connected) {
return false
}
const health = await ws.call("stream:health", profile_id) const health = await ws.call("stream:health", profile_id)
setStreamHealth(health) setStreamHealth(health)

View File

@ -60,6 +60,7 @@
top: 0; top: 0;
z-index: 10; z-index: 10;
object-fit: cover;
} }
.profile-header__content { .profile-header__content {
@ -217,9 +218,6 @@
background-color: var(--background-color-accent); background-color: var(--background-color-accent);
.ant-segmented-thumb { .ant-segmented-thumb {
left: var(--thumb-active-left);
width: var(--thumb-active-width);
background-color: var(--background-color-primary-2); background-color: var(--background-color-primary-2);
transition: all 150ms ease-in-out; transition: all 150ms ease-in-out;

View File

@ -4,6 +4,7 @@ import UploadButton from "@components/UploadButton"
import { FiImage, FiInfo } from "react-icons/fi" import { FiImage, FiInfo } from "react-icons/fi"
import { MdTextFields, MdDescription } from "react-icons/md" import { MdTextFields, MdDescription } from "react-icons/md"
import StreamPreview from "../../components/StreamPreview"
import StreamRateChart from "../../components/StreamRateChart" import StreamRateChart from "../../components/StreamRateChart"
import { formatBytes, formatBitrate } from "../../liveTabUtils" import { formatBytes, formatBitrate } from "../../liveTabUtils"
import { useStreamSignalQuality } from "../../useStreamSignalQuality" import { useStreamSignalQuality } from "../../useStreamSignalQuality"
@ -146,7 +147,7 @@ const Live = ({ profile, loading, handleProfileUpdate, streamHealth }) => {
</div> </div>
<div className="content-panel__content"> <div className="content-panel__content">
<div className="status-indicator"> <div className="status-indicator">
Stream Status:{" "} Stream Status:
{streamHealth?.online ? ( {streamHealth?.online ? (
<Tag color="green">Online</Tag> <Tag color="green">Online</Tag>
) : ( ) : (
@ -155,9 +156,14 @@ const Live = ({ profile, loading, handleProfileUpdate, streamHealth }) => {
</div> </div>
<div className="live-tab-preview"> <div className="live-tab-preview">
{streamHealth?.online {streamHealth?.online ? (
? "Video Preview Area" <StreamPreview
: "Stream is Offline"} streamHealth={streamHealth}
profile={profile}
/>
) : (
"Stream is Offline"
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -27,7 +27,10 @@
justify-content: center; justify-content: center;
width: 100%; width: 100%;
min-height: 200px; height: 250px;
max-height: 250px;
min-height: 250px;
background-color: var(--background-color-primary); background-color: var(--background-color-primary);
@ -35,6 +38,14 @@
border-radius: 4px; border-radius: 4px;
font-size: 1rem; font-size: 1rem;
overflow: hidden;
video {
width: 100%;
height: 100%;
object-fit: contain;
}
} }
.live-tab-stats { .live-tab-stats {

View File

@ -7,22 +7,19 @@ import "./index.less"
const ProfileCreator = (props) => { const ProfileCreator = (props) => {
const [loading, setLoading] = React.useState(false) 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) { function handleChange(e) {
setName(e.target.value.trim()) setTitle(e.target.value.trim())
} }
async function handleSubmit() { async function handleSubmit() {
setLoading(true) setLoading(true)
if (props.editValue) {
if (typeof props.onEdit === "function") {
await props.onEdit(name)
}
} else {
const result = await Streaming.createProfile({ const result = await Streaming.createProfile({
profile_name: name, info: {
title: title,
},
}).catch((error) => { }).catch((error) => {
console.error(error) console.error(error)
app.message.error("Failed to create") app.message.error("Failed to create")
@ -32,9 +29,9 @@ const ProfileCreator = (props) => {
if (result) { if (result) {
app.message.success("Created") app.message.success("Created")
app.eventBus.emit("app:new_profile", result) app.eventBus.emit("app:new_profile", result)
props.onCreate(result._id, result) props.onCreate(result._id, result)
} }
}
props.close() props.close()
@ -44,8 +41,8 @@ const ProfileCreator = (props) => {
return ( return (
<div className="profile-creator"> <div className="profile-creator">
<antd.Input <antd.Input
value={name} value={title}
placeholder="Enter a profile name" placeholder="Enter a profile title"
onChange={handleChange} onChange={handleChange}
/> />
@ -55,12 +52,12 @@ const ProfileCreator = (props) => {
<antd.Button <antd.Button
type="primary" type="primary"
onClick={() => { onClick={() => {
handleSubmit(name) handleSubmit(title)
}} }}
disabled={!name || loading} disabled={!title || loading}
loading={loading} loading={loading}
> >
{props.editValue ? "Update" : "Create"} Create
</antd.Button> </antd.Button>
</div> </div>
</div> </div>

View File

@ -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 (
<div className="tvstudio-page-list-item">
<div className="tvstudio-page-list-item__id">
<antd.Tag>{profile._id}</antd.Tag>
</div>
<div
className="tvstudio-page-list-item__thumbnail"
style={{
backgroundImage: `url("${profile.info.offline_thumbnail}")`,
}}
onClick={onClickManage}
/>
<div className="tvstudio-page-list-item__content">
<div className="tvstudio-page-list-item__content__title">
<h1>{profile.info.title}</h1>
</div>
<div className="tvstudio-page-list-item__content__description">
<span>{profile.info.description ?? "No description"}</span>
</div>
<div className="tvstudio-page-list-item__content__actions">
<antd.Button size="small" onClick={onClickManage}>
Manage
</antd.Button>
<antd.Button
size="small"
type="danger"
onClick={onClickDelete}
>
Delete
</antd.Button>
</div>
</div>
</div>
)
}
export default Profile

View File

@ -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;
}
}
}
}

View File

@ -1,22 +1,16 @@
import React from "react" import React from "react"
import * as antd from "antd" import * as antd from "antd"
import ProfileCreator from "./components/ProfileCreator"
import Skeleton from "@components/Skeleton" import Skeleton from "@components/Skeleton"
import ProfileCreator from "./components/ProfileCreator"
import ProfileItem from "./components/ProfileItem"
import Streaming from "@models/spectrum" import Streaming from "@models/spectrum"
import useCenteredContainer from "@hooks/useCenteredContainer"
import "./index.less" import "./index.less"
const Profile = ({ profile, onClick }) => {
return <div onClick={onClick}>{profile.profile_name}</div>
}
const TVStudioPage = (props) => { const TVStudioPage = (props) => {
useCenteredContainer(false)
const [loading, list, error, repeat] = app.cores.api.useRequest( const [loading, list, error, repeat] = app.cores.api.useRequest(
Streaming.getOwnProfiles, Streaming.getOwnProfiles,
) )
@ -25,39 +19,75 @@ const TVStudioPage = (props) => {
app.layout.modal.open("tv_profile_creator", ProfileCreator, { app.layout.modal.open("tv_profile_creator", ProfileCreator, {
props: { props: {
onCreate: (id, data) => { 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}`) app.location.push(`/studio/tv/${id}`)
} }
if (error) {
return (
<antd.Result
status="warning"
title="Error"
subTitle="Failed to fetch profiles"
/>
)
}
if (loading) { if (loading) {
return <Skeleton /> return <Skeleton />
} }
return ( return (
<div className="tvstudio-page"> <div className="tvstudio-page">
<div className="tvstudio-page-header">
<h1>TV Studio</h1>
</div>
<div className="tvstudio-page-actions"> <div className="tvstudio-page-actions">
<antd.Button type="primary" onClick={handleNewProfileClick}> <antd.Button type="primary" onClick={handleNewProfileClick}>
Create new Create new
</antd.Button> </antd.Button>
</div> </div>
<div className="tvstudio-page-list">
{list.length > 0 && {list.length > 0 &&
list.map((profile, index) => { list.map((profile, index) => {
return ( return (
<Profile <ProfileItem
key={index} key={index}
profile={profile} profile={profile}
onClick={() => handleProfileClick(profile._id)} onClickManage={() =>
handleManageProfileClick(profile._id)
}
onClickDelete={() =>
handleDeleteProfileClick(profile._id)
}
/> />
) )
})} })}
</div> </div>
</div>
) )
} }

View File

@ -6,6 +6,26 @@
gap: 10px; gap: 10px;
.tvstudio-page-header {
display: flex;
flex-direction: row;
width: fit-content;
gap: 10px;
background-color: var(--background-color-accent);
padding: 10px 20px;
border-radius: 12px;
h1 {
font-family: "Space Grotesk", sans-serif;
font-size: 1.3rem;
margin: 0;
}
}
.tvstudio-page-actions { .tvstudio-page-actions {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -24,14 +44,14 @@
} }
} }
.tvstudio-page-selector-hint { .tvstudio-page-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
justify-content: center;
width: 100%; width: 100%;
padding: 50px 0; border-radius: 12px;
overflow: hidden;
} }
} }