support for spectrum 6

This commit is contained in:
SrGooglo 2025-04-08 15:16:53 +00:00
parent 6e38d41293
commit eeb84add9e
2 changed files with 340 additions and 293 deletions

View File

@ -15,334 +15,346 @@ import { FiLink } from "react-icons/fi"
import "./index.less" import "./index.less"
const ProfileData = (props) => { const ProfileData = (props) => {
if (!props.profile_id) { if (!props.profile_id) {
return null return null
} }
const [loading, setLoading] = React.useState(false) const [loading, setLoading] = React.useState(false)
const [fetching, setFetching] = React.useState(true) const [fetching, setFetching] = React.useState(true)
const [error, setError] = React.useState(null) const [error, setError] = React.useState(null)
const [profile, setProfile] = React.useState(null) const [profile, setProfile] = React.useState(null)
async function fetchData(profile_id) { async function fetchData(profile_id) {
setFetching(true) setFetching(true)
const result = await Streaming.getProfile({ profile_id }).catch((error) => { const result = await Streaming.getProfile({ profile_id }).catch(
console.error(error) (error) => {
setError(error) console.error(error)
return null setError(error)
}) return null
},
)
if (result) { if (result) {
setProfile(result) setProfile(result)
} }
setFetching(false) setFetching(false)
} }
async function handleChange(key, value) { async function handleChange(key, value) {
setLoading(true) setLoading(true)
const result = await Streaming.createOrUpdateStream({ const result = await Streaming.createOrUpdateStream({
[key]: value, [key]: value,
_id: profile._id, _id: profile._id,
}).catch((error) => { }).catch((error) => {
console.error(error) console.error(error)
antd.message.error("Failed to update") antd.message.error("Failed to update")
return false return false
}) })
if (result) { if (result) {
antd.message.success("Updated") antd.message.success("Updated")
setProfile(result) setProfile(result)
} }
setLoading(false) setLoading(false)
} }
async function handleDelete() { async function handleDelete() {
setLoading(true) setLoading(true)
const result = await Streaming.deleteProfile({ profile_id: profile._id }).catch((error) => { const result = await Streaming.deleteProfile({
console.error(error) profile_id: profile._id,
antd.message.error("Failed to delete") }).catch((error) => {
return false console.error(error)
}) antd.message.error("Failed to delete")
return false
})
if (result) { if (result) {
antd.message.success("Deleted") antd.message.success("Deleted")
app.eventBus.emit("app:profile_deleted", profile._id) app.eventBus.emit("app:profile_deleted", profile._id)
} }
setLoading(false) setLoading(false)
} }
async function handleEditName() { async function handleEditName() {
app.layout.modal.open("name_editor", ProfileCreator, { app.layout.modal.open("name_editor", ProfileCreator, {
props: { props: {
editValue: profile.profile_name, editValue: profile.profile_name,
onEdit: async (value) => { onEdit: async (value) => {
await handleChange("profile_name", value) await handleChange("profile_name", value)
app.eventBus.emit("app:profiles_updated", profile._id) app.eventBus.emit("app:profiles_updated", profile._id)
}, },
} },
}) })
} }
React.useEffect(() => { React.useEffect(() => {
fetchData(props.profile_id) fetchData(props.profile_id)
}, [props.profile_id]) }, [props.profile_id])
if (error) { if (error) {
return <antd.Result return (
status="warning" <antd.Result
title="Error" status="warning"
subTitle={error.message} title="Error"
extra={[ subTitle={error.message}
<antd.Button extra={[
type="primary" <antd.Button
onClick={() => fetchData(props.profile_id)} type="primary"
> onClick={() => fetchData(props.profile_id)}
Retry >
</antd.Button> Retry
]} </antd.Button>,
/> ]}
} />
)
}
if (fetching) { if (fetching) {
return <antd.Skeleton return <antd.Skeleton active />
active }
/>
}
return <div className="tvstudio-profile-data"> return (
<div <div className="tvstudio-profile-data">
className="tvstudio-profile-data-header" <div className="tvstudio-profile-data-header">
> <img
<img className="tvstudio-profile-data-header-image"
className="tvstudio-profile-data-header-image" src={profile.info?.thumbnail}
src={profile.info?.thumbnail} />
/> <div className="tvstudio-profile-data-header-content">
<div className="tvstudio-profile-data-header-content"> <EditableText
<EditableText value={profile.info?.title ?? "Untitled"}
value={profile.info?.title ?? "Untitled"} className="tvstudio-profile-data-header-title"
className="tvstudio-profile-data-header-title" style={{
style={{ "--fontSize": "2rem",
"--fontSize": "2rem", "--fontWeight": "800",
"--fontWeight": "800" }}
}} onSave={(newValue) => {
onSave={(newValue) => { return handleChange("title", newValue)
return handleChange("title", newValue) }}
}} disabled={loading}
disabled={loading} />
/> <EditableText
<EditableText value={profile.info?.description ?? "No description"}
value={profile.info?.description ?? "No description"} className="tvstudio-profile-data-header-description"
className="tvstudio-profile-data-header-description" style={{
style={{ "--fontSize": "1rem",
"--fontSize": "1rem", }}
}} onSave={(newValue) => {
onSave={(newValue) => { return handleChange("description", newValue)
return handleChange("description", newValue) }}
}} disabled={loading}
disabled={loading} />
/> </div>
</div> </div>
</div>
<div className="tvstudio-profile-data-field"> <div className="tvstudio-profile-data-field">
<div className="tvstudio-profile-data-field-header"> <div className="tvstudio-profile-data-field-header">
<MdOutlineWifiTethering /> <MdOutlineWifiTethering />
<span>Server</span> <span>Server</span>
</div> </div>
<div className="key-value-field"> <div className="key-value-field">
<div className="key-value-field-key"> <div className="key-value-field-key">
<span>Ingestion URL</span> <span>Ingestion URL</span>
</div> </div>
<div className="key-value-field-value"> <div className="key-value-field-value">
<span> <span>{profile.ingestion_url}</span>
{profile.ingestion_url} </div>
</span> </div>
</div>
</div>
<div className="key-value-field"> <div className="key-value-field">
<div className="key-value-field-key"> <div className="key-value-field-key">
<span>Stream Key</span> <span>Stream Key</span>
</div> </div>
<div className="key-value-field-value"> <div className="key-value-field-value">
<HiddenText <HiddenText value={profile.stream_key} />
value={profile.stream_key} </div>
/> </div>
</div> </div>
</div>
</div>
<div className="tvstudio-profile-data-field"> <div className="tvstudio-profile-data-field">
<div className="tvstudio-profile-data-field-header"> <div className="tvstudio-profile-data-field-header">
<GrConfigure /> <GrConfigure />
<span>Configuration</span> <span>Configuration</span>
</div> </div>
<div className="key-value-field"> <div className="key-value-field">
<div className="key-value-field-key"> <div className="key-value-field-key">
<IoMdEyeOff /> <IoMdEyeOff />
<span> Private Mode</span> <span> Private Mode</span>
</div> </div>
<div className="key-value-field-description"> <div className="key-value-field-description">
<p>When this is enabled, only users with the livestream url can access the stream.</p> <p>
</div> When this is enabled, only users with the livestream
url can access the stream.
</p>
</div>
<div className="key-value-field-content"> <div className="key-value-field-content">
<antd.Switch <antd.Switch
checked={profile.options.private} checked={profile.options.private}
loading={loading} loading={loading}
onChange={(value) => handleChange("private", value)} onChange={(value) => handleChange("private", value)}
/> />
</div> </div>
<div className="key-value-field-description"> <div className="key-value-field-description">
<p style={{ fontWeight: "bold" }}>Must restart the livestream to apply changes</p> <p style={{ fontWeight: "bold" }}>
</div> Must restart the livestream to apply changes
</div> </p>
</div>
</div>
<div className="key-value-field"> <div className="key-value-field">
<div className="key-value-field-key"> <div className="key-value-field-key">
<GrStorage /> <GrStorage />
<span> DVR [beta]</span> <span> DVR [beta]</span>
</div> </div>
<div className="key-value-field-description"> <div className="key-value-field-description">
<p>Save a copy of your stream with its entire duration. You can download this copy after finishing this livestream.</p> <p>
</div> Save a copy of your stream with its entire duration.
You can download this copy after finishing this
livestream.
</p>
</div>
<div className="key-value-field-content"> <div className="key-value-field-content">
<antd.Switch <antd.Switch disabled loading={loading} />
disabled </div>
loading={loading} </div>
/> </div>
</div>
</div>
</div>
{ {profile.sources && (
profile.sources && <div className="tvstudio-profile-data-field"> <div className="tvstudio-profile-data-field">
<div className="tvstudio-profile-data-field-header"> <div className="tvstudio-profile-data-field-header">
<FiLink /> <FiLink />
<span>Media URL</span> <span>Media URL</span>
</div> </div>
<div className="key-value-field"> <div className="key-value-field">
<div className="key-value-field-key"> <div className="key-value-field-key">
<span>HLS</span> <span>HLS</span>
</div> </div>
<div className="key-value-field-description"> <div className="key-value-field-description">
<p>This protocol is highly compatible with a multitude of devices and services. Recommended for general use.</p> <p>
</div> This protocol is highly compatible with a
multitude of devices and services. Recommended
for general use.
</p>
</div>
<div className="key-value-field-value"> <div className="key-value-field-value">
<span> <span>{profile.sources.hls}</span>
{profile.sources.hls} </div>
</span> </div>
</div> <div className="key-value-field">
</div> <div className="key-value-field-key">
<div className="key-value-field"> <span>RTSP [tcp]</span>
<div className="key-value-field-key"> </div>
<span>FLV</span>
</div>
<div className="key-value-field-description"> <div className="key-value-field-description">
<p>This protocol operates at better latency and quality than HLS, but is less compatible for most devices.</p> <p>
</div> This protocol has the lowest possible latency
and the best quality. A compatible player is
required.
</p>
</div>
<div className="key-value-field-value"> <div className="key-value-field-value">
<span> <span>{profile.sources.rtsp}</span>
{profile.sources.flv} </div>
</span> </div>
</div> <div className="key-value-field">
</div> <div className="key-value-field-key">
<div className="key-value-field"> <span>RTSPT [vrchat]</span>
<div className="key-value-field-key"> </div>
<span>RTSP [tcp]</span>
</div>
<div className="key-value-field-description"> <div className="key-value-field-description">
<p>This protocol has the lowest possible latency and the best quality. A compatible player is required.</p> <p>
</div> This protocol has the lowest possible latency
and the best quality available. Only works for
VRChat video players.
</p>
</div>
<div className="key-value-field-value"> <div className="key-value-field-value">
<span> <span>
{profile.sources.rtsp} {profile.sources.rtsp.replace(
</span> "rtsp://",
</div> "rtspt://",
</div> )}
<div className="key-value-field"> </span>
<div className="key-value-field-key"> </div>
<span>HTML Viewer</span> </div>
</div> <div className="key-value-field">
<div className="key-value-field-key">
<span>HTML Viewer</span>
</div>
<div className="key-value-field-description"> <div className="key-value-field-description">
<p>Share a link to easily view your stream on any device with a web browser.</p> <p>
</div> Share a link to easily view your stream on any
device with a web browser.
</p>
</div>
<div className="key-value-field-value"> <div className="key-value-field-value">
<span> <span>{profile.sources.html}</span>
{profile.sources.html} </div>
</span> </div>
</div> </div>
</div> )}
</div>
}
<div className="tvstudio-profile-data-field"> <div className="tvstudio-profile-data-field">
<div className="tvstudio-profile-data-field-header"> <div className="tvstudio-profile-data-field-header">
<span>Other</span> <span>Other</span>
</div> </div>
<div className="key-value-field"> <div className="key-value-field">
<div className="key-value-field-key"> <div className="key-value-field-key">
<span>Delete profile</span> <span>Delete profile</span>
</div> </div>
<div className="key-value-field-content"> <div className="key-value-field-content">
<antd.Popconfirm <antd.Popconfirm
title="Delete the profile" title="Delete the profile"
description="Once deleted, the profile cannot be recovered." description="Once deleted, the profile cannot be recovered."
onConfirm={handleDelete} onConfirm={handleDelete}
okText="Yes" okText="Yes"
cancelText="No" cancelText="No"
> >
<antd.Button <antd.Button danger loading={loading}>
danger Delete
loading={loading} </antd.Button>
> </antd.Popconfirm>
Delete </div>
</antd.Button> </div>
</antd.Popconfirm>
</div>
</div>
<div className="key-value-field"> <div className="key-value-field">
<div className="key-value-field-key"> <div className="key-value-field-key">
<span>Change profile name</span> <span>Change profile name</span>
</div> </div>
<div className="key-value-field-content"> <div className="key-value-field-content">
<antd.Button <antd.Button loading={loading} onClick={handleEditName}>
loading={loading} Change
onClick={handleEditName} </antd.Button>
> </div>
Change </div>
</antd.Button> </div>
</div> </div>
</div> )
</div>
</div>
} }
export default ProfileData export default ProfileData

View File

@ -50,26 +50,56 @@ const StreamDecoders = {
return decoderInstance return decoderInstance
}, },
hls: (player, source) => { hls: (player, source, options = {}) => {
if (!player) {
console.error("Player is not defined")
return false
}
if (!source) { if (!source) {
console.error("Stream source is not defined") console.error("Stream source is not defined")
return false return false
} }
const hlsInstance = new Hls({ const hlsInstance = new Hls({
autoStartLoad: true, maxLiveSyncPlaybackRate: 1.5,
strategy: "bandwidth",
autoplay: true,
xhrSetup: (xhr) => {
if (options.authToken) {
xhr.setRequestHeader(
"Authorization",
`Bearer ${options.authToken}`,
)
}
},
}) })
hlsInstance.attachMedia(player.current) if (options.authToken) {
source += `?token=${options.authToken}`
}
console.log("Loading media hls >", source, options)
hlsInstance.attachMedia(player)
// when media attached, load source
hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => { hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
hlsInstance.loadSource(source) hlsInstance.loadSource(source)
hlsInstance.on(Hls.Events.MANIFEST_PARSED, (event, data) => {
console.log(`${data.levels.length} quality levels found`)
})
}) })
// process quality and tracks levels
hlsInstance.on(Hls.Events.MANIFEST_PARSED, (event, data) => {
console.log(`${data.levels.length} quality levels found`)
})
// resume to the last position when player resume playback
player.addEventListener("play", () => {
console.log("Syncing to last position")
player.currentTime = hlsInstance.liveSyncPosition
})
// handle errors
hlsInstance.on(Hls.Events.ERROR, (event, data) => { hlsInstance.on(Hls.Events.ERROR, (event, data) => {
console.error(event, data) console.error(event, data)
@ -129,6 +159,8 @@ export default class StreamViewer extends React.Component {
const decoderInstance = await StreamDecoders[decoder](...args) const decoderInstance = await StreamDecoders[decoder](...args)
console.log(decoderInstance)
await this.setState({ await this.setState({
decoderInstance: decoderInstance, decoderInstance: decoderInstance,
}) })
@ -176,7 +208,7 @@ export default class StreamViewer extends React.Component {
attachPlayer = () => { attachPlayer = () => {
// check if user has interacted with the page // check if user has interacted with the page
const player = new Plyr("#player", { const player = new Plyr(this.videoPlayerRef.current, {
clickToPlay: false, clickToPlay: false,
autoplay: true, autoplay: true,
muted: true, muted: true,
@ -219,6 +251,8 @@ export default class StreamViewer extends React.Component {
this.enterPlayerAnimation() this.enterPlayerAnimation()
this.attachPlayer() this.attachPlayer()
console.log("custom token> ", this.props.query["token"])
// load stream // load stream
const stream = await this.loadStream(this.props.params.id) const stream = await this.loadStream(this.props.params.id)
@ -234,11 +268,12 @@ export default class StreamViewer extends React.Component {
} }
await this.loadDecoder( await this.loadDecoder(
"flv", "hls",
this.videoPlayerRef.current, this.videoPlayerRef.current,
stream.sources.flv, stream.sources.hls,
{ {
onSourceEnd: this.onSourceEnd, onSourceEnd: this.onSourceEnd,
authToken: this.props.query["token"],
}, },
) )
} }