mirror of
https://github.com/ragestudio/comty.git
synced 2025-06-09 10:34:17 +00:00
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:
parent
c91657f34d
commit
80d84b3e17
@ -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
|
@ -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()}`
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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 {
|
||||
|
@ -7,22 +7,19 @@ 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,
|
||||
info: {
|
||||
title: title,
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.error(error)
|
||||
app.message.error("Failed to create")
|
||||
@ -32,9 +29,9 @@ const ProfileCreator = (props) => {
|
||||
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>
|
||||
|
@ -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
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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,39 +19,75 @@ 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>
|
||||
|
||||
<div className="tvstudio-page-list">
|
||||
{list.length > 0 &&
|
||||
list.map((profile, index) => {
|
||||
return (
|
||||
<Profile
|
||||
<ProfileItem
|
||||
key={index}
|
||||
profile={profile}
|
||||
onClick={() => handleProfileClick(profile._id)}
|
||||
onClickManage={() =>
|
||||
handleManageProfileClick(profile._id)
|
||||
}
|
||||
onClickDelete={() =>
|
||||
handleDeleteProfileClick(profile._id)
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -6,6 +6,26 @@
|
||||
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -24,14 +44,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tvstudio-page-selector-hint {
|
||||
.tvstudio-page-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
|
||||
padding: 50px 0;
|
||||
border-radius: 12px;
|
||||
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user