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() {
setThumbnail(() => {
if (streamRef.current.online && profile.info.thumbnail) {
if (
streamRef.current &&
streamRef.current.online &&
profile.info.thumbnail
) {
return `${profile.info.thumbnail}?t=${Date.now()}`
}

View File

@ -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)

View File

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

View File

@ -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 }) => {
</div>
<div className="content-panel__content">
<div className="status-indicator">
Stream Status:{" "}
Stream Status:
{streamHealth?.online ? (
<Tag color="green">Online</Tag>
) : (
@ -155,9 +156,14 @@ const Live = ({ profile, loading, handleProfileUpdate, streamHealth }) => {
</div>
<div className="live-tab-preview">
{streamHealth?.online
? "Video Preview Area"
: "Stream is Offline"}
{streamHealth?.online ? (
<StreamPreview
streamHealth={streamHealth}
profile={profile}
/>
) : (
"Stream is Offline"
)}
</div>
</div>
</div>

View File

@ -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 {

View File

@ -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 (
<div className="profile-creator">
<antd.Input
value={name}
placeholder="Enter a profile name"
value={title}
placeholder="Enter a profile title"
onChange={handleChange}
/>
@ -55,12 +52,12 @@ const ProfileCreator = (props) => {
<antd.Button
type="primary"
onClick={() => {
handleSubmit(name)
handleSubmit(title)
}}
disabled={!name || loading}
disabled={!title || loading}
loading={loading}
>
{props.editValue ? "Update" : "Create"}
Create
</antd.Button>
</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 * 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 <div onClick={onClick}>{profile.profile_name}</div>
}
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 (
<antd.Result
status="warning"
title="Error"
subTitle="Failed to fetch profiles"
/>
)
}
if (loading) {
return <Skeleton />
}
return (
<div className="tvstudio-page">
<div className="tvstudio-page-header">
<h1>TV Studio</h1>
</div>
<div className="tvstudio-page-actions">
<antd.Button type="primary" onClick={handleNewProfileClick}>
Create new
</antd.Button>
</div>
{list.length > 0 &&
list.map((profile, index) => {
return (
<Profile
key={index}
profile={profile}
onClick={() => handleProfileClick(profile._id)}
/>
)
})}
<div className="tvstudio-page-list">
{list.length > 0 &&
list.map((profile, index) => {
return (
<ProfileItem
key={index}
profile={profile}
onClickManage={() =>
handleManageProfileClick(profile._id)
}
onClickDelete={() =>
handleDeleteProfileClick(profile._id)
}
/>
)
})}
</div>
</div>
)
}

View File

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