mirror of
https://github.com/ragestudio/comty.git
synced 2025-06-09 10:34:17 +00:00
Merge pull request #133 from ragestudio/dev
This commit is contained in:
commit
81b0c72d1a
2
comty.js
2
comty.js
@ -1 +1 @@
|
|||||||
Subproject commit f408866ac27f7eb0ce2dd6abe1cfe4b1902cd7e8
|
Subproject commit 94c8d7383e84a2de4b193d27adfcb1baa4163f68
|
@ -1 +1 @@
|
|||||||
Subproject commit bc9b82dab1767b2fa1085fcf22336c455a7f89c1
|
Subproject commit fa61273d5b4b40a22d97c7773321d8ca6c985fd7
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@comty/app",
|
"name": "@comty/app",
|
||||||
"version": "1.40.0@alpha",
|
"version": "1.41.0@alpha",
|
||||||
"license": "ComtyLicense",
|
"license": "ComtyLicense",
|
||||||
"main": "electron/main",
|
"main": "electron/main",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@ -33,7 +33,7 @@
|
|||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"bear-react-carousel": "^4.0.10-alpha.0",
|
"bear-react-carousel": "^4.0.10-alpha.0",
|
||||||
"classnames": "2.3.1",
|
"classnames": "2.3.1",
|
||||||
"comty.js": "^0.65.0",
|
"comty.js": "^0.65.5",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"dashjs": "^5.0.0",
|
"dashjs": "^5.0.0",
|
||||||
"dompurify": "^3.0.0",
|
"dompurify": "^3.0.0",
|
||||||
|
@ -195,9 +195,11 @@ export default class ChunkedUpload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
waitOnSSE(data) {
|
waitOnSSE(data) {
|
||||||
console.log(`[UPLOADER] Connecting to SSE channel >`, data.sseUrl)
|
// temporal solution until comty.js manages this
|
||||||
|
const url = `${app.cores.api.client().mainOrigin}/upload/sse_events/${data.sseChannelId}`
|
||||||
|
|
||||||
const eventSource = new EventSource(data.sseUrl)
|
console.log(`[UPLOADER] Connecting to SSE channel >`, url)
|
||||||
|
const eventSource = new EventSource(url)
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
eventSource.onerror = (error) => {
|
||||||
this.events.emit("error", error)
|
this.events.emit("error", error)
|
||||||
|
@ -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,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>
|
||||||
|
@ -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,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
7
packages/server/extra-proxies.js
Normal file
7
packages/server/extra-proxies.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
"/spectrum/*": {
|
||||||
|
target: process.env.SPECTRUM_API ?? "https://live.ragestudio.net",
|
||||||
|
pathRewrite: { "^/spectrum/(.*)": "/$1", "^/spectrum": "/" },
|
||||||
|
websocket: true,
|
||||||
|
},
|
||||||
|
}
|
@ -165,6 +165,92 @@ export default class Gateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads and registers additional proxy routes from ../../extra-proxies.js
|
||||||
|
*/
|
||||||
|
async registerExtraProxies() {
|
||||||
|
try {
|
||||||
|
// Dynamic import is relative to the current file.
|
||||||
|
// extra-proxies.js can be CJS (module.exports = ...) or ESM (export default ...)
|
||||||
|
const extraProxiesModule = require(
|
||||||
|
path.resolve(process.cwd(), "extra-proxies.js"),
|
||||||
|
)
|
||||||
|
const extraProxies = extraProxiesModule.default // Node's CJS/ESM interop puts module.exports on .default
|
||||||
|
|
||||||
|
if (
|
||||||
|
!extraProxies ||
|
||||||
|
typeof extraProxies !== "object" ||
|
||||||
|
Object.keys(extraProxies).length === 0
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
"[Gateway] No extra proxies defined in `extra-proxies.js`, file is empty, or format is invalid. Skipping.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Gateway] Registering extra proxies from 'extra-proxies.js'...`,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const proxyPathKey in extraProxies) {
|
||||||
|
if (
|
||||||
|
Object.prototype.hasOwnProperty.call(
|
||||||
|
extraProxies,
|
||||||
|
proxyPathKey,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const config = extraProxies[proxyPathKey]
|
||||||
|
if (!config || typeof config.target !== "string") {
|
||||||
|
console.warn(
|
||||||
|
`[Gateway] Skipping invalid extra proxy config for path: '${proxyPathKey}' in 'extra-proxies.js'. Target is missing or not a string.`,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let registrationPath = proxyPathKey
|
||||||
|
|
||||||
|
// Normalize paths ending with /*
|
||||||
|
// e.g., "/spectrum/*" becomes "/spectrum"
|
||||||
|
// e.g., "/*" becomes "/"
|
||||||
|
if (registrationPath.endsWith("/*")) {
|
||||||
|
registrationPath = registrationPath.slice(0, -2)
|
||||||
|
if (registrationPath === "") {
|
||||||
|
registrationPath = "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Gateway] Registering extra proxy: '${proxyPathKey}' (as '${registrationPath}') -> ${config.target}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
await this.gateway.register({
|
||||||
|
serviceId: `extra-proxy:${registrationPath}`, // Unique ID for this proxy rule
|
||||||
|
path: registrationPath,
|
||||||
|
target: config.target,
|
||||||
|
pathRewrite: config.pathRewrite, // undefined if not present
|
||||||
|
websocket: !!config.websocket, // false if not present or falsy
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Handle cases where the extra-proxies.js file might not exist
|
||||||
|
if (
|
||||||
|
error.code === "ERR_MODULE_NOT_FOUND" ||
|
||||||
|
(error.message &&
|
||||||
|
error.message.toLowerCase().includes("cannot find module"))
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
"[Gateway] `extra-proxies.js` not found. Skipping extra proxy registration.",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"[Gateway] Error loading or registering extra proxies from `extra-proxies.js`:",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle both router and websocket registration requests from services
|
* Handle both router and websocket registration requests from services
|
||||||
* @param {Service} service - Service registering a route or websocket
|
* @param {Service} service - Service registering a route or websocket
|
||||||
@ -304,6 +390,9 @@ export default class Gateway {
|
|||||||
await this.gateway.initialize()
|
await this.gateway.initialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register any externally defined proxies before services start
|
||||||
|
await this.registerExtraProxies()
|
||||||
|
|
||||||
// Watch for service state changes
|
// Watch for service state changes
|
||||||
Observable.observe(this.serviceRegistry, (changes) => {
|
Observable.observe(this.serviceRegistry, (changes) => {
|
||||||
this.checkAllServicesReady()
|
this.checkAllServicesReady()
|
||||||
|
@ -371,19 +371,12 @@ http {
|
|||||||
|
|
||||||
if (route.pathRewrite && Object.keys(route.pathRewrite).length > 0) {
|
if (route.pathRewrite && Object.keys(route.pathRewrite).length > 0) {
|
||||||
rewriteConfig += "# Path rewrite rules\n"
|
rewriteConfig += "# Path rewrite rules\n"
|
||||||
|
|
||||||
for (const [pattern, replacement] of Object.entries(
|
for (const [pattern, replacement] of Object.entries(
|
||||||
route.pathRewrite,
|
route.pathRewrite,
|
||||||
)) {
|
)) {
|
||||||
// Improved rewrite pattern that preserves query parameters
|
// Improved rewrite pattern that preserves query parameters
|
||||||
rewriteConfig += `\trewrite ${pattern} ${replacement}$is_args$args break;`
|
rewriteConfig += `\nrewrite ${pattern} ${replacement} break;`
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If no explicit rewrite is defined, but we need to strip the path prefix,
|
|
||||||
// Generate a default rewrite that preserves the URL structure
|
|
||||||
if (path !== "/") {
|
|
||||||
rewriteConfig += "# Default path rewrite to strip prefix\n"
|
|
||||||
rewriteConfig += `\trewrite ^${path}(/.*)$ $1$is_args$args break;\n`
|
|
||||||
rewriteConfig += `\trewrite ^${path}$ / break;`
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -423,6 +416,8 @@ ${locationDirective} {
|
|||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
${rewriteConfig}
|
||||||
|
|
||||||
# Proxy pass to service
|
# Proxy pass to service
|
||||||
proxy_pass ${route.target};
|
proxy_pass ${route.target};
|
||||||
}
|
}
|
||||||
|
@ -105,7 +105,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
uploadId: payload.uploadId,
|
uploadId: payload.uploadId,
|
||||||
sseChannelId: job.sseChannelId,
|
sseChannelId: job.sseChannelId,
|
||||||
sseUrl: `${req.headers["x-forwarded-proto"] || req.protocol}://${req.get("host")}/upload/sse_events/${job.sseChannelId}`,
|
sseUrl: `${req.headers["x-forwarded-proto"] || req.protocol}://${req.get("x-forwarded-host") ?? req.get("host")}/upload/sse_events/${job.sseChannelId}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user