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() {
|
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()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
|
@ -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 {
|
||||||
|
@ -7,33 +7,30 @@ 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) {
|
const result = await Streaming.createProfile({
|
||||||
if (typeof props.onEdit === "function") {
|
info: {
|
||||||
await props.onEdit(name)
|
title: title,
|
||||||
}
|
},
|
||||||
} else {
|
}).catch((error) => {
|
||||||
const result = await Streaming.createProfile({
|
console.error(error)
|
||||||
profile_name: name,
|
app.message.error("Failed to create")
|
||||||
}).catch((error) => {
|
return null
|
||||||
console.error(error)
|
})
|
||||||
app.message.error("Failed to create")
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
|
|
||||||
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>
|
||||||
|
@ -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 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,38 +19,74 @@ 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>
|
||||||
|
|
||||||
{list.length > 0 &&
|
<div className="tvstudio-page-list">
|
||||||
list.map((profile, index) => {
|
{list.length > 0 &&
|
||||||
return (
|
list.map((profile, index) => {
|
||||||
<Profile
|
return (
|
||||||
key={index}
|
<ProfileItem
|
||||||
profile={profile}
|
key={index}
|
||||||
onClick={() => handleProfileClick(profile._id)}
|
profile={profile}
|
||||||
/>
|
onClickManage={() =>
|
||||||
)
|
handleManageProfileClick(profile._id)
|
||||||
})}
|
}
|
||||||
|
onClickDelete={() =>
|
||||||
|
handleDeleteProfileClick(profile._id)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,37 +1,57 @@
|
|||||||
.tvstudio-page {
|
.tvstudio-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
||||||
.tvstudio-page-actions {
|
.tvstudio-page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
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 {
|
border-radius: 12px;
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
width: 100%;
|
h1 {
|
||||||
}
|
font-family: "Space Grotesk", sans-serif;
|
||||||
}
|
font-size: 1.3rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.tvstudio-page-selector-hint {
|
.tvstudio-page-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user