diff --git a/comty.js b/comty.js
index f408866a..94c8d738 160000
--- a/comty.js
+++ b/comty.js
@@ -1 +1 @@
-Subproject commit f408866ac27f7eb0ce2dd6abe1cfe4b1902cd7e8
+Subproject commit 94c8d7383e84a2de4b193d27adfcb1baa4163f68
diff --git a/linebridge b/linebridge
index bc9b82da..fa61273d 160000
--- a/linebridge
+++ b/linebridge
@@ -1 +1 @@
-Subproject commit bc9b82dab1767b2fa1085fcf22336c455a7f89c1
+Subproject commit fa61273d5b4b40a22d97c7773321d8ca6c985fd7
diff --git a/packages/app/package.json b/packages/app/package.json
index 5f9de4f3..fe225cb5 100755
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@comty/app",
- "version": "1.40.0@alpha",
+ "version": "1.41.0@alpha",
"license": "ComtyLicense",
"main": "electron/main",
"type": "module",
@@ -33,7 +33,7 @@
"axios": "^1.7.7",
"bear-react-carousel": "^4.0.10-alpha.0",
"classnames": "2.3.1",
- "comty.js": "^0.65.0",
+ "comty.js": "^0.65.5",
"d3": "^7.9.0",
"dashjs": "^5.0.0",
"dompurify": "^3.0.0",
diff --git a/packages/app/src/classes/ChunkedUpload/index.js b/packages/app/src/classes/ChunkedUpload/index.js
index 5e93c5ab..22cc95cb 100644
--- a/packages/app/src/classes/ChunkedUpload/index.js
+++ b/packages/app/src/classes/ChunkedUpload/index.js
@@ -195,9 +195,11 @@ export default class ChunkedUpload {
}
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) => {
this.events.emit("error", error)
diff --git a/packages/app/src/pages/studio/tv/[profile_id]/components/StreamPreview/index.jsx b/packages/app/src/pages/studio/tv/[profile_id]/components/StreamPreview/index.jsx
new file mode 100644
index 00000000..a90bcb94
--- /dev/null
+++ b/packages/app/src/pages/studio/tv/[profile_id]/components/StreamPreview/index.jsx
@@ -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 (
+
+ )
+}
+
+export default StreamPreview
diff --git a/packages/app/src/pages/studio/tv/[profile_id]/header.jsx b/packages/app/src/pages/studio/tv/[profile_id]/header.jsx
index 94da6088..3d010fc8 100644
--- a/packages/app/src/pages/studio/tv/[profile_id]/header.jsx
+++ b/packages/app/src/pages/studio/tv/[profile_id]/header.jsx
@@ -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()}`
}
diff --git a/packages/app/src/pages/studio/tv/[profile_id]/index.jsx b/packages/app/src/pages/studio/tv/[profile_id]/index.jsx
index 27a77f23..55656b26 100644
--- a/packages/app/src/pages/studio/tv/[profile_id]/index.jsx
+++ b/packages/app/src/pages/studio/tv/[profile_id]/index.jsx
@@ -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)
diff --git a/packages/app/src/pages/studio/tv/[profile_id]/index.less b/packages/app/src/pages/studio/tv/[profile_id]/index.less
index 5e9770d0..f7e31276 100644
--- a/packages/app/src/pages/studio/tv/[profile_id]/index.less
+++ b/packages/app/src/pages/studio/tv/[profile_id]/index.less
@@ -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;
diff --git a/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.jsx b/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.jsx
index 0e94c799..04fcd0e5 100644
--- a/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.jsx
+++ b/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.jsx
@@ -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 }) => {
- Stream Status:{" "}
+ Stream Status:
{streamHealth?.online ? (
Online
) : (
@@ -155,9 +156,14 @@ const Live = ({ profile, loading, handleProfileUpdate, streamHealth }) => {
- {streamHealth?.online
- ? "Video Preview Area"
- : "Stream is Offline"}
+ {streamHealth?.online ? (
+
+ ) : (
+ "Stream is Offline"
+ )}
diff --git a/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.less b/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.less
index 3add9d24..0d9157b4 100644
--- a/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.less
+++ b/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.less
@@ -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 {
diff --git a/packages/app/src/pages/studio/tv/components/ProfileCreator/index.jsx b/packages/app/src/pages/studio/tv/components/ProfileCreator/index.jsx
index ccb587d2..8344992a 100644
--- a/packages/app/src/pages/studio/tv/components/ProfileCreator/index.jsx
+++ b/packages/app/src/pages/studio/tv/components/ProfileCreator/index.jsx
@@ -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 (
@@ -55,12 +52,12 @@ const ProfileCreator = (props) => {
{
- handleSubmit(name)
+ handleSubmit(title)
}}
- disabled={!name || loading}
+ disabled={!title || loading}
loading={loading}
>
- {props.editValue ? "Update" : "Create"}
+ Create
diff --git a/packages/app/src/pages/studio/tv/components/ProfileItem/index.jsx b/packages/app/src/pages/studio/tv/components/ProfileItem/index.jsx
new file mode 100644
index 00000000..d15775a0
--- /dev/null
+++ b/packages/app/src/pages/studio/tv/components/ProfileItem/index.jsx
@@ -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 (
+
+
+
+
+
+
+
+
{profile.info.title}
+
+
+
+ {profile.info.description ?? "No description"}
+
+
+
+
+ Manage
+
+
+ Delete
+
+
+
+
+ )
+}
+
+export default Profile
diff --git a/packages/app/src/pages/studio/tv/components/ProfileItem/index.less b/packages/app/src/pages/studio/tv/components/ProfileItem/index.less
new file mode 100644
index 00000000..0520a8ad
--- /dev/null
+++ b/packages/app/src/pages/studio/tv/components/ProfileItem/index.less
@@ -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;
+ }
+ }
+ }
+}
diff --git a/packages/app/src/pages/studio/tv/index.jsx b/packages/app/src/pages/studio/tv/index.jsx
index 03db7019..93f0e44e 100644
--- a/packages/app/src/pages/studio/tv/index.jsx
+++ b/packages/app/src/pages/studio/tv/index.jsx
@@ -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 {profile.profile_name}
-}
-
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 (
+
+ )
+ }
+
if (loading) {
return
}
return (
+
+
TV Studio
+
+
- {list.length > 0 &&
- list.map((profile, index) => {
- return (
-
handleProfileClick(profile._id)}
- />
- )
- })}
+
+ {list.length > 0 &&
+ list.map((profile, index) => {
+ return (
+
+ handleManageProfileClick(profile._id)
+ }
+ onClickDelete={() =>
+ handleDeleteProfileClick(profile._id)
+ }
+ />
+ )
+ })}
+
)
}
diff --git a/packages/app/src/pages/studio/tv/index.less b/packages/app/src/pages/studio/tv/index.less
index 2ceb17cf..8d6328b4 100644
--- a/packages/app/src/pages/studio/tv/index.less
+++ b/packages/app/src/pages/studio/tv/index.less
@@ -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;
- }
-}
\ No newline at end of file
+ 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;
+ }
+}
diff --git a/packages/server/extra-proxies.js b/packages/server/extra-proxies.js
new file mode 100644
index 00000000..b484397e
--- /dev/null
+++ b/packages/server/extra-proxies.js
@@ -0,0 +1,7 @@
+export default {
+ "/spectrum/*": {
+ target: process.env.SPECTRUM_API ?? "https://live.ragestudio.net",
+ pathRewrite: { "^/spectrum/(.*)": "/$1", "^/spectrum": "/" },
+ websocket: true,
+ },
+}
diff --git a/packages/server/gateway/index.js b/packages/server/gateway/index.js
index fba8d70c..e6c30a52 100755
--- a/packages/server/gateway/index.js
+++ b/packages/server/gateway/index.js
@@ -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
* @param {Service} service - Service registering a route or websocket
@@ -304,6 +390,9 @@ export default class Gateway {
await this.gateway.initialize()
}
+ // Register any externally defined proxies before services start
+ await this.registerExtraProxies()
+
// Watch for service state changes
Observable.observe(this.serviceRegistry, (changes) => {
this.checkAllServicesReady()
diff --git a/packages/server/gateway/managers/nginx/index.js b/packages/server/gateway/managers/nginx/index.js
index ec9d6127..8511c16d 100755
--- a/packages/server/gateway/managers/nginx/index.js
+++ b/packages/server/gateway/managers/nginx/index.js
@@ -371,19 +371,12 @@ http {
if (route.pathRewrite && Object.keys(route.pathRewrite).length > 0) {
rewriteConfig += "# Path rewrite rules\n"
+
for (const [pattern, replacement] of Object.entries(
route.pathRewrite,
)) {
// Improved rewrite pattern that preserves query parameters
- rewriteConfig += `\trewrite ${pattern} ${replacement}$is_args$args 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;`
+ rewriteConfig += `\nrewrite ${pattern} ${replacement} break;`
}
}
@@ -423,6 +416,8 @@ ${locationDirective} {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
+ ${rewriteConfig}
+
# Proxy pass to service
proxy_pass ${route.target};
}
diff --git a/packages/server/services/files/routes/upload/chunk/post.js b/packages/server/services/files/routes/upload/chunk/post.js
index 9fd08b53..27615fc9 100644
--- a/packages/server/services/files/routes/upload/chunk/post.js
+++ b/packages/server/services/files/routes/upload/chunk/post.js
@@ -105,7 +105,7 @@ export default {
return {
uploadId: payload.uploadId,
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}`,
}
}