merge from local

This commit is contained in:
SrGooglo 2024-09-09 18:32:45 +00:00
parent b2dbc3cc9c
commit 03badcbfd9
38 changed files with 1867 additions and 215 deletions

View File

@ -11,15 +11,21 @@ import Tabs from "./tabs"
import "./index.less"
console.log(MusicModel.deleteRelease)
const ReleaseEditor = (props) => {
const { release_id } = props
const basicInfoRef = React.useRef()
const [submitting, setSubmitting] = React.useState(false)
const [submitError, setSubmitError] = React.useState(null)
const [loading, setLoading] = React.useState(true)
const [loadError, setLoadError] = React.useState(null)
const [globalState, setGlobalState] = React.useState(DefaultReleaseEditorState)
const [selectedTab, setSelectedTab] = React.useState("info")
const [customPage, setCustomPage] = React.useState(null)
async function initialize() {
setLoading(true)
@ -42,7 +48,55 @@ const ReleaseEditor = (props) => {
}
async function handleSubmit() {
console.log("Submit >", globalState)
setSubmitting(true)
setSubmitError(null)
try {
// first sumbit tracks
console.time("submit:tracks:")
const tracks = await MusicModel.putTrack({
list: globalState.list,
})
console.timeEnd("submit:tracks:")
// then submit release
console.time("submit:release:")
await MusicModel.putRelease({
_id: globalState._id,
title: globalState.title,
description: globalState.description,
public: globalState.public,
cover: globalState.cover,
explicit: globalState.explicit,
type: globalState.type,
list: tracks.list,
})
console.timeEnd("submit:release:")
} catch (error) {
console.error(error)
app.message.error(error.message)
setSubmitError(error)
setSubmitting(false)
return false
}
setSubmitting(false)
app.message.success("Release saved")
return release
}
async function handleDelete() {
app.layout.modal.confirm({
headerText: "Are you sure you want to delete this release?",
descriptionText: "This action cannot be undone.",
onConfirm: async () => {
await MusicModel.deleteRelease(globalState._id)
app.location.push(window.location.pathname.split("/").slice(0, -1).join("/"))
},
})
}
async function onFinish(values) {
@ -71,8 +125,48 @@ const ReleaseEditor = (props) => {
const Tab = Tabs.find(({ key }) => key === selectedTab)
return <ReleaseEditorStateContext.Provider value={globalState}>
return <ReleaseEditorStateContext.Provider
value={{
...globalState,
setCustomPage,
}}
>
<div className="music-studio-release-editor">
{
customPage && <div className="music-studio-release-editor-custom-page">
{
customPage.header && <div className="music-studio-release-editor-custom-page-header">
<div className="music-studio-release-editor-custom-page-header-title">
<antd.Button
icon={<Icons.IoIosArrowBack />}
onClick={() => setCustomPage(null)}
/>
<h2>{customPage.header}</h2>
</div>
{
customPage.props?.onSave && <antd.Button
type="primary"
icon={<Icons.Save />}
onClick={() => customPage.props.onSave()}
>
Save
</antd.Button>
}
</div>
}
{
React.cloneElement(customPage.content, {
...customPage.props,
close: () => setCustomPage(null),
})
}
</div>
}
{
!customPage && <>
<div className="music-studio-release-editor-menu">
<antd.Menu
onClick={(e) => setSelectedTab(e.key)}
@ -86,7 +180,8 @@ const ReleaseEditor = (props) => {
type="primary"
onClick={handleSubmit}
icon={<Icons.Save />}
disabled={loading || !canFinish()}
disabled={submitting || loading || !canFinish()}
loading={submitting}
>
Save
</antd.Button>
@ -95,6 +190,7 @@ const ReleaseEditor = (props) => {
release_id !== "new" ? <antd.Button
icon={<Icons.IoMdTrash />}
disabled={loading}
onClick={handleDelete}
>
Delete
</antd.Button> : null
@ -133,6 +229,8 @@ const ReleaseEditor = (props) => {
})
}
</div>
</>
}
</div>
</ReleaseEditorStateContext.Provider>
}

View File

@ -4,10 +4,53 @@
width: 100%;
padding: 20px;
//padding: 20px;
gap: 20px;
.music-studio-release-editor-custom-page {
display: flex;
flex-direction: column;
width: 100%;
gap: 20px;
.music-studio-release-editor-custom-page-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
background-color: var(--background-color-accent);
padding: 10px;
border-radius: 12px;
h1,
h2,
h3,
h4,
h5,
h6,
p,
span {
margin: 0;
}
.music-studio-release-editor-custom-page-header-title {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
}
}
.music-studio-release-editor-header {
display: flex;
flex-direction: row;

View File

@ -7,29 +7,27 @@ import Image from "@components/Image"
import { Icons } from "@components/Icons"
import TrackEditor from "@components/MusicStudio/TrackEditor"
import { ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
import "./index.less"
const TrackListItem = (props) => {
const context = React.useContext(ReleaseEditorStateContext)
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState(null)
const { track } = props
async function onClickEditTrack() {
app.layout.drawer.open("track_editor", TrackEditor, {
type: "drawer",
context.setCustomPage({
header: "Track Editor",
content: <TrackEditor track={track} />,
props: {
width: "600px",
headerStyle: {
display: "none",
}
},
componentProps: {
track,
onSave: (newTrackData) => {
console.log("Saving track", newTrackData)
},
},
}
})
}
@ -57,9 +55,9 @@ const TrackListItem = (props) => {
<Image
src={track.cover}
height={25}
width={25}
style={{
width: 25,
height: 25,
borderRadius: 8,
}}
/>

View File

@ -4,6 +4,8 @@
display: flex;
flex-direction: row;
align-items: center;
padding: 10px;
gap: 10px;

View File

@ -32,9 +32,35 @@ class TrackManifest {
constructor(params) {
this.params = params
if (params.uid) {
this.uid = params.uid
}
if (params.cover) {
this.cover = params.cover
}
if (params.title) {
this.title = params.title
}
if (params.album) {
this.album = params.album
}
if (params.artist) {
this.artist = params.artist
}
if (params.source) {
this.source = params.source
}
return this
}
uid = null
cover = "https://storage.ragestudio.net/comty-static-assets/default_song.png"
title = "Untitled"
@ -137,6 +163,25 @@ class TracksManager extends React.Component {
})
}
modifyTrackByUid = (uid, track) => {
if (!uid || !track) {
return false
}
this.setState({
list: this.state.list.map((item) => {
if (item.uid === uid) {
return {
...item,
...track,
}
}
return item
}),
})
}
addTrackUIDToPendingUploads = (uid) => {
if (!uid) {
return false
@ -160,24 +205,28 @@ class TracksManager extends React.Component {
}
handleUploaderStateChange = async (change) => {
const uid = change.file.uid
switch (change.file.status) {
case "uploading": {
this.addTrackUIDToPendingUploads(change.file.uid)
this.addTrackUIDToPendingUploads(uid)
const trackManifest = new TrackManifest({
uid: change.file.uid,
uid: uid,
file: change.file,
})
await trackManifest.initialize()
this.addTrackToList(trackManifest)
const trackData = await trackManifest.initialize()
this.modifyTrackByUid(uid, trackData)
break
}
case "done": {
// remove pending file
this.removeTrackUIDFromPendingUploads(change.file.uid)
this.removeTrackUIDFromPendingUploads(uid)
const trackIndex = this.state.list.findIndex((item) => item.uid === uid)
@ -187,24 +236,22 @@ class TracksManager extends React.Component {
}
// update track list
this.setState((state) => {
state.list[trackIndex].source = change.file.response.url
return state
await this.modifyTrackByUid(uid, {
source: change.file.response.url
})
break
}
case "error": {
// remove pending file
this.removeTrackUIDFromPendingUploads(change.file.uid)
this.removeTrackUIDFromPendingUploads(uid)
// remove from tracklist
await this.removeTrackByUid(change.file.uid)
await this.removeTrackByUid(uid)
}
case "removed": {
// stop upload & delete from pending list and tracklist
await this.removeTrackByUid(change.file.uid)
await this.removeTrackByUid(uid)
}
default: {
break
@ -253,6 +300,7 @@ class TracksManager extends React.Component {
}
render() {
console.log(`Tracks List >`, this.state.list)
return <div className="music-studio-release-editor-tracks">
<antd.Upload
className="music-studio-tracks-uploader"
@ -267,7 +315,9 @@ class TracksManager extends React.Component {
<UploadHint /> : <antd.Button
className="uploadMoreButton"
icon={<Icons.Plus />}
/>
>
Add another
</antd.Button>
}
</antd.Upload>

View File

@ -1,3 +1,30 @@
.music-studio-release-editor-tracks {
display: flex;
flex-direction: column;
gap: 10px;
.music-studio-tracks-uploader {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
.ant-upload {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
}
}
}
.music-studio-release-editor-tracks-list {
display: flex;
flex-direction: column;

View File

@ -7,9 +7,12 @@ import { Icons } from "@components/Icons"
import LyricsEditor from "@components/MusicStudio/LyricsEditor"
import VideoEditor from "@components/MusicStudio/VideoEditor"
import { ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
import "./index.less"
const TrackEditor = (props) => {
const context = React.useContext(ReleaseEditorStateContext)
const [track, setTrack] = React.useState(props.track ?? {})
async function handleChange(key, value) {
@ -22,38 +25,26 @@ const TrackEditor = (props) => {
}
async function openLyricsEditor() {
app.layout.drawer.open("lyrics_editor", LyricsEditor, {
type: "drawer",
context.setCustomPage({
header: "Lyrics Editor",
content: <LyricsEditor track={track} />,
props: {
width: "600px",
headerStyle: {
display: "none",
onSave: () => {
console.log("Saved lyrics")
},
}
},
componentProps: {
track,
onSave: (lyrics) => {
console.log("Saving lyrics for track >", lyrics)
},
},
})
}
async function openVideoEditor() {
app.layout.drawer.open("video_editor", VideoEditor, {
type: "drawer",
context.setCustomPage({
header: "Video Editor",
content: <VideoEditor track={track} />,
props: {
width: "600px",
headerStyle: {
display: "none",
onSave: () => {
console.log("Saved video")
},
}
},
componentProps: {
track,
onSave: (video) => {
console.log("Saving video for track", video)
},
},
})
}
@ -109,7 +100,7 @@ const TrackEditor = (props) => {
</div>
<antd.Input
value={track.artists.join(", ")}
value={track.artists?.join(", ")}
placeholder="Artist"
onChange={(e) => handleChange("artist", e.target.value)}
/>
@ -184,24 +175,6 @@ const TrackEditor = (props) => {
Edit
</antd.Button>
</div>
<div className="track-editor-actions">
<antd.Button
type="text"
icon={<Icons.MdClose />}
onClick={onClose}
>
Cancel
</antd.Button>
<antd.Button
type="primary"
icon={<Icons.MdCheck />}
onClick={onSave}
>
Save
</antd.Button>
</div>
</div>
}

View File

@ -0,0 +1,11 @@
import React from "react"
import "./index.less"
const SelectableText = (props) => {
return <p className="selectable-text">
{props.children}
</p>
}
export default SelectableText

View File

@ -0,0 +1,12 @@
.selectable-text {
user-select: text;
--webkit-user-select: text;
background-color: rgba(var(--bg_color_3), 0.8);
padding: 5px;
border-radius: 8px;
color: var(--text-color);
font-size: 0.9rem;
}

View File

@ -8,6 +8,8 @@ export const DefaultReleaseEditorState = {
list: [],
pendingUploads: [],
setCustomPage: () => {},
}
export const ReleaseEditorStateContext = React.createContext(DefaultReleaseEditorState)

View File

@ -0,0 +1,21 @@
import React from "react"
const useGetMainOrigin = () => {
const [mainOrigin, setMainOrigin] = React.useState(null)
React.useEffect(() => {
const instance = app.cores.api.client()
if (instance) {
setMainOrigin(instance.mainOrigin)
}
return () => {
setMainOrigin(null)
}
}, [])
return mainOrigin
}
export default useGetMainOrigin

View File

@ -1,9 +1,14 @@
import React from "react"
import classnames from "classnames"
import { Motion, spring } from "react-motion"
import * as antd from "antd"
import { AnimatePresence, motion } from "framer-motion"
import "./index.less"
function transformTemplate({ x }) {
return `translateX(${x}px)`
}
export class Drawer extends React.Component {
options = this.props.options ?? {}
@ -25,7 +30,7 @@ export class Drawer extends React.Component {
this.toggleVisibility(false)
this.props.controller.close(this.props.id, {
delay: 500
transition: 150
})
}
@ -55,37 +60,36 @@ export class Drawer extends React.Component {
render() {
const componentProps = {
...this.options.componentProps,
...this.options.props,
close: this.close,
handleDone: this.handleDone,
handleFail: this.handleFail,
}
return <Motion
key={this.props.id}
style={{
x: spring(!this.state.visible ? 100 : 0),
opacity: spring(!this.state.visible ? 0 : 1),
}}
>
{({ x, opacity }) => {
return <div
return <AnimatePresence>
{
this.state.visible && <motion.div
key={this.props.id}
id={this.props.id}
className="drawer"
style={{
...this.options.style,
transform: `translateX(-${x}%)`,
opacity: opacity,
}}
transformTemplate={transformTemplate}
animate={{
x: [-100, 0],
opacity: [0, 1],
}}
exit={{
x: [0, -100],
opacity: [1, 0],
}}
>
{
React.createElement(this.props.children, componentProps)
}
</div>
}}
</Motion>
</motion.div>
}
</AnimatePresence>
}
}
@ -99,7 +103,6 @@ export default class DrawerController extends React.Component {
drawers: [],
maskVisible: false,
maskRender: false,
}
this.interface = {
@ -125,10 +128,10 @@ export default class DrawerController extends React.Component {
componentWillUpdate = (prevProps, prevState) => {
// is mask visible, hide sidebar with `app.layout.sidebar.toggleVisibility(false)`
if (app.layout.sidebar) {
if (prevState.maskVisible !== this.state.maskVisible) {
app.layout.sidebar.toggleVisibility(false)
} else if (prevState.maskRender !== this.state.maskRender) {
app.layout.sidebar.toggleVisibility(true)
app.layout.sidebar.toggleVisibility(this.state.maskVisible)
}
}
}
@ -166,30 +169,23 @@ export default class DrawerController extends React.Component {
const lastDrawer = this.getLastDrawer()
if (lastDrawer) {
if (app.layout.modal && lastDrawer.options.confirmOnOutsideClick) {
return app.layout.modal.confirm({
descriptionText: lastDrawer.options.confirmOnOutsideClickText ?? "Are you sure you want to close this drawer?",
onConfirm: () => {
lastDrawer.close()
}
})
}
lastDrawer.close()
}
}
toggleMaskVisibility = async (to) => {
to = to ?? !this.state.maskVisible
this.setState({
maskVisible: to,
maskVisible: to ?? !this.state.maskVisible,
})
if (to === true) {
this.setState({
maskRender: true
})
} else {
await new Promise((resolve) => {
setTimeout(resolve, 500)
})
this.setState({
maskRender: false
})
}
}
open = (id, component, options) => {
@ -198,21 +194,26 @@ export default class DrawerController extends React.Component {
const addresses = this.state.addresses ?? {}
const instance = {
id,
key: id,
id: id,
ref: React.createRef(),
children: component,
options,
options: options,
controller: this,
}
if (typeof addresses[id] === "undefined") {
drawers.push(<Drawer {...instance} />)
drawers.push(<Drawer
key={id}
{...instance}
/>)
addresses[id] = drawers.length - 1
refs[id] = instance.ref
} else {
drawers[addresses[id]] = <Drawer {...instance} />
drawers[addresses[id]] = <Drawer
key={id}
{...instance}
/>
refs[id] = instance.ref
}
@ -225,7 +226,7 @@ export default class DrawerController extends React.Component {
this.toggleMaskVisibility(true)
}
close = async (id, { delay = 0 }) => {
close = async (id, { transition = 0 } = {}) => {
let { addresses, drawers, refs } = this.state
const index = addresses[id]
@ -239,9 +240,9 @@ export default class DrawerController extends React.Component {
this.toggleMaskVisibility(false)
}
if (delay > 0) {
if (transition > 0) {
await new Promise((resolve) => {
setTimeout(resolve, delay)
setTimeout(resolve, transition)
})
}
@ -267,22 +268,23 @@ export default class DrawerController extends React.Component {
render() {
return <>
<Motion
style={{
opacity: spring(this.state.maskVisible ? 1 : 0),
}}
>
{({ opacity }) => {
return <div
<AnimatePresence>
{
this.state.maskVisible && <motion.div
className="drawers-mask"
onClick={() => this.closeLastDrawer()}
style={{
opacity,
display: this.state.maskRender ? "block" : "none",
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
/>
}}
</Motion>
}
</AnimatePresence>
<div
className={classnames(
@ -292,7 +294,9 @@ export default class DrawerController extends React.Component {
}
)}
>
<AnimatePresence>
{this.state.drawers}
</AnimatePresence>
</div>
</>
}

View File

@ -36,8 +36,6 @@
background-color: rgba(0, 0, 0, 0.1);
backdrop-filter: blur(4px);
}
.drawer {
@ -64,3 +62,26 @@
overflow-x: hidden;
overflow-y: overlay;
}
.drawer_close_confirm {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
.drawer_close_confirm_content {
display: flex;
flex-direction: column;
gap: 2px;
}
.drawer_close_confirm_actions {
display: flex;
flex-direction: row;
gap: 10px;
}
}

View File

@ -1,9 +1,77 @@
import React from "react"
import Modal from "./modal"
import { Button } from "antd"
import useLayoutInterface from "@hooks/useLayoutInterface"
function ConfirmModal(props) {
const [loading, setLoading] = React.useState(false)
async function close({ confirm } = {}) {
props.close()
if (typeof props.onClose === "function") {
props.onClose()
}
if (confirm === true) {
if (typeof props.onConfirm === "function") {
if (props.onConfirm.constructor.name === "AsyncFunction") {
setLoading(true)
}
await props.onConfirm()
setLoading(false)
}
} else {
if (typeof props.onCancel === "function") {
props.onCancel()
}
}
}
return <div className="drawer_close_confirm">
<div className="drawer_close_confirm_content">
<h1>{props.headerText ?? "Are you sure?"} Are you sure?</h1>
{
props.descriptionText && <p>{props.descriptionText}</p>
}
</div>
<div className="drawer_close_confirm_actions">
<Button
onClick={() => close({ confirm: false })}
disabled={loading}
>
Cancel
</Button>
<Button
onClick={() => close({ confirm: true })}
disabled={loading}
loading={loading}
>
Yes
</Button>
</div>
</div>
}
export default () => {
function confirm(options = {}) {
open("confirm", ConfirmModal, {
props: {
onConfirm: options.onConfirm,
onCancel: options.onCancel,
onClose: options.onClose,
headerText: options.headerText,
descriptionText: options.descriptionText,
}
})
}
function open(
id,
render,
@ -41,6 +109,7 @@ export default () => {
useLayoutInterface("modal", {
open: open,
close: close,
confirm: confirm,
})
return null

View File

@ -3,7 +3,7 @@ import config from "@config"
import classnames from "classnames"
import { Translation } from "react-i18next"
import { Motion, spring } from "react-motion"
import { Menu, Avatar, Dropdown } from "antd"
import { Menu, Avatar, Dropdown, Tag } from "antd"
import Drawer from "@layouts/components/drawer"
import { Icons, createIconRender } from "@components/Icons"
@ -491,6 +491,8 @@ export default class Sidebar extends React.Component {
src={config.logo?.alt}
onClick={() => app.navigation.goMain()}
/>
<Tag>Beta</Tag>
</div>
</div>

View File

@ -98,9 +98,17 @@
.app_sidebar_header_logo {
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
justify-content: center;
.ant-tag {
margin: 0;
}
img {
user-select: none;
--webkit-user-select: none;

View File

@ -1,10 +0,0 @@
import React from "react"
import LiveChat from "@components/LiveChat"
const RoomChat = (props) => {
return <LiveChat
id={props.params["roomID"]}
/>
}
export default RoomChat

View File

@ -123,11 +123,15 @@ export default () => {
}
React.useEffect(() => {
if (app.layout.tools_bar) {
app.layout.tools_bar.toggleVisibility(false)
}
return () => {
if (app.layout.tools_bar) {
app.layout.tools_bar.toggleVisibility(true)
}
}
}, [])
return <div className="settings_wrapper">

View File

@ -0,0 +1,80 @@
import React from "react"
import * as antd from "antd"
import classnames from "classnames"
import { MdSave, MdEdit, MdClose } from "react-icons/md"
import "./index.less"
const EditableText = (props) => {
const [loading, setLoading] = React.useState(false)
const [isEditing, setEditing] = React.useState(false)
const [value, setValue] = React.useState(props.value)
async function handleSave(newValue) {
setLoading(true)
if (typeof props.onSave === "function") {
await props.onSave(newValue)
setEditing(false)
setLoading(false)
} else {
setValue(newValue)
setLoading(false)
}
}
function handleCancel() {
setValue(props.value)
setEditing(false)
}
React.useEffect(() => {
setValue(props.value)
}, [props.value])
return <div
style={props.style}
className={classnames("editable-text", props.className)}
>
{
!isEditing && <span
onClick={() => setEditing(true)}
className="editable-text-value"
>
<MdEdit />
{value}
</span>
}
{
isEditing && <div className="editable-text-input-container">
<antd.Input
className="editable-text-input"
value={value}
onChange={(e) => setValue(e.target.value)}
loading={loading}
disabled={loading}
onPressEnter={() => handleSave(value)}
/>
<antd.Button
type="primary"
onClick={() => handleSave(value)}
icon={<MdSave />}
loading={loading}
disabled={loading}
size="small"
/>
<antd.Button
onClick={handleCancel}
disabled={loading}
icon={<MdClose />}
size="small"
/>
</div>
}
</div>
}
export default EditableText

View File

@ -0,0 +1,44 @@
.editable-text {
border-radius: 12px;
font-size: 14px;
--fontSize: 14px;
--fontWeight: normal;
.editable-text-value {
display: flex;
flex-direction: row;
align-items: center;
gap: 7px;
font-size: var(--fontSize);
font-weight: var(--fontWeight);
svg {
font-size: 1rem;
opacity: 0.6;
}
}
.editable-text-input-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
.ant-input {
background-color: transparent;
font-family: "DM Mono", sans-serif;
font-size: var(--fontSize);
font-weight: var(--fontWeight);
padding: 0 10px;
}
}
}

View File

@ -0,0 +1,57 @@
import React from "react"
import * as antd from "antd"
import { IoMdClipboard, IoMdEye, IoMdEyeOff } from "react-icons/io"
const HiddenText = (props) => {
const [visible, setVisible] = React.useState(false)
function copyToClipboard() {
try {
navigator.clipboard.writeText(props.value)
antd.message.success("Copied to clipboard")
} catch (error) {
console.error(error)
antd.message.error("Failed to copy to clipboard")
}
}
return <div
style={{
width: "100%",
position: "relative",
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: "10px",
...props.style
}}
>
<antd.Button
icon={<IoMdClipboard />}
type="ghost"
size="small"
onClick={copyToClipboard}
/>
<span>
{
visible ? props.value : "********"
}
</span>
<antd.Button
style={{
position: "absolute",
right: 0,
top: 0
}}
icon={visible ? <IoMdEye /> : <IoMdEyeOff />}
type="ghost"
size="small"
onClick={() => setVisible(!visible)}
/>
</div>
}
export default HiddenText

View File

@ -0,0 +1,39 @@
import React from "react"
import * as antd from "antd"
import useRequest from "comty.js/dist/hooks/useRequest"
import Streaming from "@models/spectrum"
const ProfileConnection = (props) => {
const [loading, result, error, repeat] = useRequest(Streaming.getConnectionStatus, {
profile_id: props.profile_id
})
React.useEffect(() => {
repeat({
profile_id: props.profile_id
})
}, [props.profile_id])
if (error) {
return <antd.Tag
color="error"
>
<span>Disconnected</span>
</antd.Tag>
}
if (loading) {
return <antd.Tag>
<span>Loading</span>
</antd.Tag>
}
return <antd.Tag
color="green"
>
<span>Connected</span>
</antd.Tag>
}
export default ProfileConnection

View File

@ -0,0 +1,74 @@
import React from "react"
import * as antd from "antd"
import Streaming from "@models/spectrum"
import "./index.less"
const ProfileCreator = (props) => {
const [loading, setLoading] = React.useState(false)
const [name, setName] = React.useState(props.editValue ?? null)
function handleChange(e) {
setName(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.createOrUpdateStream({ profile_name: name }).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)
}
}
props.close()
setLoading(false)
}
return <div
className="profile-creator"
>
<antd.Input
value={name}
placeholder="Enter a profile name"
onChange={handleChange}
/>
<div className="profile-creator-actions">
<antd.Button
onClick={props.close}
>
Cancel
</antd.Button>
<antd.Button
type="primary"
onClick={() => {
handleSubmit(name)
}}
disabled={!name || loading}
loading={loading}
>
{
props.editValue ? "Update" : "Create"
}
</antd.Button>
</div>
</div>
}
export default ProfileCreator

View File

@ -0,0 +1,19 @@
.profile-creator {
display: flex;
flex-direction: column;
width: 100%;
gap: 10px;
.profile-creator-actions {
display: flex;
flex-direction: row;
width: 100%;
justify-content: flex-end;
gap: 10px;
}
}

View File

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

View File

@ -0,0 +1,66 @@
.profile-data {
display: flex;
flex-direction: column;
gap: 20px;
.profile-data-header {
position: relative;
max-height: 200px;
background-repeat: no-repeat;
background-position: center;
background-size: cover;
border-radius: 12px;
overflow: hidden;
.profile-data-header-image {
position: absolute;
left: 0;
top: 0;
z-index: 10;
width: 100%;
}
.profile-data-header-content {
position: relative;
display: flex;
flex-direction: column;
z-index: 20;
padding: 30px 10px;
gap: 5px;
background-color: rgba(0, 0, 0, 0.5);
}
}
.profile-data-field {
display: flex;
flex-direction: column;
gap: 10px;
.profile-data-field-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
span {
font-size: 1.5rem;
}
}
}
}

View File

@ -0,0 +1,87 @@
import React from "react"
import * as antd from "antd"
import Streaming from "@models/spectrum"
const ProfileSelector = (props) => {
const [loading, result, error, repeat] = app.cores.api.useRequest(Streaming.getOwnProfiles)
const [selectedProfileId, setSelectedProfileId] = React.useState(null)
function handleOnChange(value) {
if (typeof props.onChange === "function") {
props.onChange(value)
}
setSelectedProfileId(value)
}
const handleOnCreateNewProfile = async (data) => {
await repeat()
handleOnChange(data._id)
}
const handleOnDeletedProfile = async (profile_id) => {
await repeat()
handleOnChange(result[0]._id)
}
React.useEffect(() => {
app.eventBus.on("app:new_profile", handleOnCreateNewProfile)
app.eventBus.on("app:profile_deleted", handleOnDeletedProfile)
app.eventBus.on("app:profiles_updated", repeat)
return () => {
app.eventBus.off("app:new_profile", handleOnCreateNewProfile)
app.eventBus.off("app:profile_deleted", handleOnDeletedProfile)
app.eventBus.off("app:profiles_updated", repeat)
}
}, [])
if (error) {
return <antd.Result
status="warning"
title="Error"
subTitle={error.message}
extra={[
<antd.Button
type="primary"
onClick={repeat}
>
Retry
</antd.Button>
]}
/>
}
if (loading) {
return <antd.Select
disabled
placeholder="Loading"
style={props.style}
className="profile-selector"
/>
}
return <antd.Select
placeholder="Select a profile"
value={selectedProfileId}
onChange={handleOnChange}
style={props.style}
className="profile-selector"
>
{
result.map((profile) => {
return <antd.Select.Option
key={profile._id}
value={profile._id}
>
{profile.profile_name ?? String(profile._id)}
</antd.Select.Option>
})
}
</antd.Select>
}
//const ProfileSelectorForwardRef = React.forwardRef(ProfileSelector)
export default ProfileSelector

View File

@ -0,0 +1,64 @@
import React from "react"
import * as antd from "antd"
import ProfileSelector from "./components/ProfileSelector"
import ProfileData from "./components/ProfileData"
import ProfileCreator from "./components/ProfileCreator"
import "./index.less"
const TVStudioPage = (props) => {
const [selectedProfileId, setSelectedProfileId] = React.useState(null)
function newProfileModal() {
const modal = app.modal.info({
title: "Create new profile",
content: <ProfileCreator
close={() => modal.destroy()}
onCreate={(id, data) => {
setSelectedProfileId(id)
}}
/>,
footer: null
})
}
return <div className="main-page">
<div className="main-page-actions">
<ProfileSelector
onChange={setSelectedProfileId}
/>
<antd.Button
type="primary"
onClick={newProfileModal}
>
Create new
</antd.Button>
</div>
{
selectedProfileId && <ProfileData
profile_id={selectedProfileId}
/>
}
{
!selectedProfileId && <div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "70vh"
}}
>
<h3>
Select profile or create new
</h3>
</div>
}
</div>
}
export default TVStudioPage

View File

@ -0,0 +1,24 @@
.main-page {
display: flex;
flex-direction: column;
height: 100%;
gap: 10px;
.main-page-actions {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 10px;
.profile-selector {
display: flex;
flex-direction: row;
width: 100%;
}
}
}

View File

@ -26,6 +26,14 @@
grid-template-columns: repeat(7, minmax(0, 1fr));
}
&.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.livestream_item {
display: flex;
flex-direction: column;

View File

@ -72,7 +72,10 @@ export default {
/>
</div>
<div className="texts">
<div className="sitename-text">
<h2>{config.app.siteName}</h2>
<antd.Tag>Beta</antd.Tag>
</div>
<span>{config.author}</span>
<span>Licensed with {config.package?.license ?? "unlicensed"} </span>
</div>

View File

@ -30,7 +30,17 @@
align-items: center;
.ant-tag {
margin: 0;
width: fit-content;
}
.logo {
display: flex;
flex-direction: row;
align-items: center;
width: 60px;
height: 100%;
@ -45,6 +55,23 @@
.texts {
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
.sitename-text {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
h2 {
margin: 0;
}
}
}
h1,

View File

@ -1,24 +1,233 @@
import React from "react"
import * as antd from "antd"
import { Icons } from "@components/Icons"
import SelectableText from "@components/SelectableText"
import useGetMainOrigin from "@hooks/useGetMainOrigin"
import textToDownload from "@utils/textToDownload"
import ServerKeysModel from "@models/api"
import "./index.less"
const useGetMainOrigin = () => {
const [mainOrigin, setMainOrigin] = React.useState(null)
const ServerKeyCreator = (props) => {
const [name, setName] = React.useState("")
const [access, setAccess] = React.useState(null)
React.useEffect(() => {
const instance = app.cores.api.client()
const [result, setResult] = React.useState(null)
const [error, setError] = React.useState(null)
if (instance) {
setMainOrigin(instance.mainOrigin)
const canSubmit = () => {
return name && access
}
return () => {
setMainOrigin(null)
const onSubmit = async () => {
if (!canSubmit()) {
return
}
const result = await ServerKeysModel.createNewServerKey({
name,
access
})
if (result) {
setResult(result)
}
}
const onRegenerate = async () => {
app.layout.modal.confirm({
headerText: "Regenerate secret token",
descriptionText: "When a key is regenerated, the old secret token will be replaced with a new one. This action cannot be undone.",
onConfirm: async () => {
await ServerKeysModel.regenerateSecretToken(result.access_id)
.then((data) => {
app.message.info("Secret token regenerated")
setResult(data)
})
.catch((error) => {
app.message.error(error.message)
setError(error.message)
})
}
})
}
const onDelete = async () => {
app.layout.modal.confirm({
headerText: "Delete server key",
descriptionText: "Deleting this server key will remove it from your account. This action cannot be undone.",
onConfirm: async () => {
await ServerKeysModel.deleteServerKey(result.access_id)
.then(() => {
app.message.info("Server key deleted")
props.close()
})
.catch((error) => {
app.message.error(error.message)
setError(error.message)
})
},
})
}
async function generateAuthJSON() {
const data = {
name: result.name,
access: result.access,
access_id: result.access_id,
secret_token: result.secret_token
}
await textToDownload(JSON.stringify(data), `comtyapi-${result.name}-auth.json`)
}
React.useEffect(() => {
if (props.data) {
setResult(props.data)
}
}, [])
return mainOrigin
if (result) {
return <div className="server-key-creator">
<h1>Your server key</h1>
<p>Name: {result.name}</p>
<div className="server-key-creator-info">
<span>Access ID:</span>
<SelectableText>{result.access_id}</SelectableText>
</div>
{
result.secret_token && <div className="server-key-creator-info">
<span>Secret:</span>
<SelectableText>{result.secret_token}</SelectableText>
</div>
}
{
result.secret_token && <antd.Alert
type="warning"
message="Save these credentials in a safe place. You can't see them again."
/>
}
{
result.secret_token && <antd.Button
onClick={generateAuthJSON}
type="primary"
>
Save JSON
</antd.Button>
}
{
!result.secret_token && <antd.Button
type="primary"
onClick={() => onRegenerate()}
>
Regenerate secret
</antd.Button>
}
<antd.Button
danger
onClick={() => onDelete()}
>
Delete
</antd.Button>
<antd.Button
onClick={() => props.close()}
>
Ok
</antd.Button>
</div>
}
return <>
<h1>Create a server key</h1>
<antd.Form
layout="vertical"
onFinish={onSubmit}
>
<antd.Form.Item
label="Name"
name="name"
rules={[
{
required: true,
message: "Name is required"
}
]}
>
<antd.Input
onChange={(e) => setName(e.target.value)}
/>
</antd.Form.Item>
<antd.Form.Item
label="Access"
name="access"
rules={[
{
required: true,
message: "Access is required"
}
]}
>
<antd.Select
onChange={(e) => setAccess(e)}
>
<antd.Select.Option value="read">Read</antd.Select.Option>
<antd.Select.Option value="write">Write</antd.Select.Option>
<antd.Select.Option value="readWrite">Read/Write</antd.Select.Option>
</antd.Select>
</antd.Form.Item>
<antd.Form.Item>
<antd.Button
type="primary"
htmlType="submit"
disabled={!canSubmit()}
>
Create
</antd.Button>
</antd.Form.Item>
{error && <antd.Form.Item>
<antd.Alert
type="error"
message={error}
/>
</antd.Form.Item>}
</antd.Form>
</>
}
const ServerKeyItem = (props) => {
const { name, access_id } = props.data
return <div className="server-key-item">
<div clas className="server-key-item-info">
<p>{name}</p>
<span>{access_id}</span>
</div>
<div className="server-key-item-actions">
<antd.Button
size="small"
icon={<Icons.TbEdit />}
onClick={() => props.onEdit(props.data)}
/>
</div>
</div>
}
export default {
@ -28,7 +237,29 @@ export default {
group: "advanced",
render: () => {
const mainOrigin = useGetMainOrigin()
const [keys, setKeys] = React.useState([])
const [L_Keys, R_Keys, E_Keys, F_Keys] = app.cores.api.useRequest(ServerKeysModel.getMyServerKeys)
async function onClickCreateNewKey() {
app.layout.drawer.open("server_key_creator", ServerKeyCreator, {
onClose: () => {
F_Keys()
},
confirmOnOutsideClick: true,
confirmOnOutsideClickText: "All changes will be lost."
})
}
async function onClickEditKey(key) {
app.layout.drawer.open("server_key_creator", ServerKeyCreator, {
props: {
data: key,
},
onClose: () => {
F_Keys()
}
})
}
return <div className="developer-settings">
<div className="card">
@ -49,6 +280,7 @@ export default {
<antd.Button
type="primary"
onClick={onClickCreateNewKey}
>
Create new
</antd.Button>
@ -56,13 +288,34 @@ export default {
<div className="api_keys_list">
{
keys.map((key) => {
return null
L_Keys && <antd.Skeleton active />
}
{
E_Keys && <antd.Result
status="warning"
title="Failed to retrieve keys"
subTitle={E_Keys.message}
/>
}
{
!E_Keys && !L_Keys && <>
{
R_Keys.map((data, index) => {
return <ServerKeyItem
key={index}
data={data}
onEdit={onClickEditKey}
/>
})
}
{
keys.length === 0 && <antd.Empty />
R_Keys.length === 0 && <antd.Empty />
}
</>
}
</div>
</div>

View File

@ -22,6 +22,44 @@
align-items: center;
justify-content: space-between;
}
.api_keys_list {
display: flex;
flex-direction: column;
gap: 10px;
}
}
.server-key-item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
background-color: var(--background-color-primary);
border-radius: 12px;
padding: 10px;
p {
margin: 0;
}
.server-key-item-info {
display: flex;
flex-direction: column;
gap: 5px;
}
.server-key-item-actions {
display: flex;
flex-direction: row;
gap: 5px;
}
}
.links {
@ -30,3 +68,22 @@
gap: 5px;
}
.server-key-creator {
display: flex;
flex-direction: column;
gap: 10px;
.server-key-creator-info {
display: flex;
flex-direction: column;
gap: 5px;
.selectable-text {
font-family: "DM Mono", monospace;
font-size: 0.7rem;
}
}
}

View File

@ -0,0 +1,14 @@
export default (text, filename) => {
const element = document.createElement("a")
const file = new Blob([text], { type: "text/plain" })
element.href = URL.createObjectURL(file)
element.download = filename ?? "download.txt"
document.body.appendChild(element) // Required for this to work in FireFox
element.click()
document.body.removeChild(element)
}

View File

@ -3,12 +3,18 @@ import requiredFields from "@shared-utils/requiredFields"
import MusicMetadata from "music-metadata"
import axios from "axios"
import ModifyTrack from "./modify"
export default async (payload = {}) => {
requiredFields(["title", "source", "user_id"], payload)
let stream = null
let headers = null
if (typeof payload._id === "string") {
return await ModifyTrack(payload._id, payload)
}
try {
const sourceStream = await axios({
url: payload.source,

View File

@ -0,0 +1,25 @@
import { Track } from "@db_models"
export default async (track_id, payload) => {
if (!track_id) {
throw new OperationError(400, "Missing track_id")
}
const track = await Track.findById(track_id)
if (!track) {
throw new OperationError(404, "Track not found")
}
if (track.publisher.user_id !== payload.user_id) {
throw new PermissionError(403, "You dont have permission to edit this track")
}
for (const field of Object.keys(payload)) {
track[field] = payload[field]
}
track.modified_at = Date.now()
return await track.save()
}

View File

@ -4,6 +4,25 @@ import TrackClass from "@classes/track"
export default {
middlewares: ["withAuthentication"],
fn: async (req) => {
if (Array.isArray(req.body.list)) {
let results = []
for await (const item of req.body.list) {
requiredFields(["title", "source"], item)
const track = await TrackClass.create({
...item,
user_id: req.auth.session.user_id,
})
results.push(track)
}
return {
list: results
}
}
requiredFields(["title", "source"], req.body)
const track = await TrackClass.create({