Feat: Implement Music Library and overhaul Studio TV

- Introduces a new Music Library system for managing favorites (tracks,
  playlists, releases), replacing the previous TrackLike model.
- Completely revamps the Studio TV profile page, adding live statistics,
  stream configuration, restream management, and media URL display.
- Enhances the media player with a custom seekbar and improved audio
  playback logic for MPD and non-MPD sources.
- Lays foundational groundwork for chat encryption with new models and APIs.
- Refactors critical UI components like PlaylistView and PagePanel.
- Standardizes monorepo development scripts to use npm.
- Updates comty.js submodule and adds various new UI components.
This commit is contained in:
SrGooglo 2025-05-10 02:32:41 +00:00
parent 2b8d47e18c
commit 8482f2e457
151 changed files with 6511 additions and 4084 deletions

@ -1 +1 @@
Subproject commit 0face5f004c2b1484751ea61228ec4ee226f49d4 Subproject commit 511a81e313d0723a2d4f9887c1632ff5fc19658d

View File

@ -4,8 +4,8 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "concurrently -k \"yarn dev:client\" \"yarn dev:server\"", "dev": "concurrently -k \"yarn dev:client\" \"yarn dev:server\"",
"dev:server": "cd packages/server && yarn dev", "dev:server": "cd packages/server && npm run dev",
"dev:client": "cd packages/app && yarn dev", "dev:client": "cd packages/app && npm run dev",
"postinstall": "node ./scripts/post-install.js" "postinstall": "node ./scripts/post-install.js"
}, },
"dependencies": { "dependencies": {

View File

@ -34,6 +34,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.64.0", "comty.js": "^0.64.0",
"d3": "^7.9.0",
"dashjs": "^5.0.0", "dashjs": "^5.0.0",
"dompurify": "^3.0.0", "dompurify": "^3.0.0",
"fast-average-color": "^9.2.0", "fast-average-color": "^9.2.0",

View File

@ -17,23 +17,28 @@ export default class AuthManager {
} }
} }
state = {
user: null,
}
public = { public = {
login: () => { login: () => {
app.layout.draggable.open("login", Login, { app.layout.draggable.open("login", Login, {
props: { componentProps: {
onDone: () => { onDone: this.onLoginCallback,
app.layout.draggable.destroy("login")
this._emitBehavior("onLogin")
},
}, },
}) })
}, },
logout: () => { logout: (bypass) => {
if (bypass === true) {
AuthModel.logout()
return this._emitBehavior("onLogout")
}
app.layout.modal.confirm({ app.layout.modal.confirm({
headerText: "Logout", headerText: "Logout",
descriptionText: "Are you sure you want to logout?", descriptionText: "Are you sure you want to logout?",
onConfirm: () => { onConfirm: () => {
console.log("Logout confirmed")
AuthModel.logout() AuthModel.logout()
this._emitBehavior("onLogout") this._emitBehavior("onLogout")
}, },
@ -65,10 +70,6 @@ export default class AuthManager {
}, },
} }
state = {
user: null,
}
initialize = async () => { initialize = async () => {
const token = await SessionModel.token const token = await SessionModel.token
@ -103,4 +104,6 @@ export default class AuthManager {
await this.behaviors[behavior](...args) await this.behaviors[behavior](...args)
} }
} }
//onLoginCallback = async (state, result) => {}
} }

View File

@ -3,23 +3,12 @@ import classnames from "classnames"
import "./index.less" import "./index.less"
export default (props) => { const LikeButton = (props) => {
const [liked, setLiked] = React.useState( const [liked, setLiked] = React.useState(
typeof props.liked === "function" ? false : props.liked, typeof props.liked === "function" ? false : props.liked,
) )
const [clicked, setClicked] = React.useState(false) const [clicked, setClicked] = React.useState(false)
// TODO: Support handle like change on websocket event
if (typeof props.watchWs === "object") {
// useWsEvents({
// [props.watchWs.event]: (data) => {
// handleUpdateTrackLike(data.track_id, data.action === "liked")
// }
// }, {
// socketName: props.watchWs.socket,
// })
}
async function computeLikedState() { async function computeLikedState() {
if (props.disabled) { if (props.disabled) {
return false return false
@ -48,7 +37,7 @@ export default (props) => {
}, 500) }, 500)
if (typeof props.onClick === "function") { if (typeof props.onClick === "function") {
props.onClick() props.onClick(!liked)
} }
setLiked(!liked) setLiked(!liked)
@ -74,3 +63,5 @@ export default (props) => {
</button> </button>
) )
} }
export default LikeButton

View File

@ -91,11 +91,11 @@ class Login extends React.Component {
}) })
} }
onDone = async ({ mfa_required } = {}) => { onDone = async (result = {}) => {
if (mfa_required) { if (result.mfa_required) {
this.setState({ this.setState({
loading: false, loading: false,
mfa_required: mfa_required, mfa_required: result.mfa_required,
}) })
return false return false
@ -108,7 +108,7 @@ class Login extends React.Component {
} }
if (typeof this.props.onDone === "function") { if (typeof this.props.onDone === "function") {
await this.props.onDone() await this.props.onDone(this.state, result)
} }
return true return true

View File

@ -11,7 +11,7 @@ const typeToNavigationType = {
album: "album", album: "album",
track: "track", track: "track",
single: "track", single: "track",
ep: "album" ep: "album",
} }
const Playlist = (props) => { const Playlist = (props) => {
@ -28,27 +28,22 @@ const Playlist = (props) => {
return props.onClick(playlist) return props.onClick(playlist)
} }
return app.location.push(`/music/${typeToNavigationType[playlist.type.toLowerCase()]}/${playlist._id}`) return app.location.push(`/music/list/${playlist._id}`)
} }
const onClickPlay = (e) => { const onClickPlay = (e) => {
e.stopPropagation() e.stopPropagation()
app.cores.player.start(playlist.list) app.cores.player.start(playlist.items)
} }
return (
const subtitle = playlist.type === "playlist" ? `By ${playlist.user_id}` : (playlist.description ?? (playlist.publisher && `Release from ${playlist.publisher?.fullName}`)) <div
return <div
id={playlist._id} id={playlist._id}
key={props.key} className={classnames("playlist", {
className={classnames( "cover-hovering": coverHover,
"playlist", "row-mode": props.row === true,
{ })}
"cover-hovering": coverHover
}
)}
> >
<div <div
className="playlist_cover" className="playlist_cover"
@ -57,11 +52,15 @@ const Playlist = (props) => {
onClick={onClickPlay} onClick={onClickPlay}
> >
<div className="playlist_cover_mask"> <div className="playlist_cover_mask">
<Icons.MdPlayArrow /> <Icons.FiPlay />
</div> </div>
<ImageViewer <ImageViewer
src={playlist.cover ?? playlist.thumbnail ?? "/assets/no_song.png"} src={
playlist.cover ??
playlist.thumbnail ??
"/assets/no_song.png"
}
/> />
</div> </div>
@ -69,31 +68,37 @@ const Playlist = (props) => {
<div className="playlist_info_title" onClick={onClick}> <div className="playlist_info_title" onClick={onClick}>
<h1>{playlist.title}</h1> <h1>{playlist.title}</h1>
</div> </div>
{props.row && (
{ <div className="playlist_details">
subtitle && <div className="playlist_info_subtitle">
<p> <p>
{subtitle}
</p>
</div>
}
</div>
<div className="playlist_bottom">
{
props.length && <p>
<Icons.MdLibraryMusic /> {props.length ?? playlist.total_length ?? playlist.list.length}
</p>
}
{
playlist.type && <p>
<Icons.MdAlbum /> <Icons.MdAlbum />
{playlist.type ?? "playlist"} {playlist.type ?? "playlist"}
</p> </p>
}
</div> </div>
)}
</div> </div>
{!props.row && (
<div className="playlist_details">
{props.length && (
<p>
<Icons.MdLibraryMusic />{" "}
{props.length ??
playlist.total_length ??
playlist.list.length}
</p>
)}
{playlist.type && (
<p>
<Icons.MdAlbum />
{playlist.type ?? "playlist"}
</p>
)}
</div>
)}
</div>
)
} }
export default Playlist export default Playlist

View File

@ -14,6 +14,45 @@
transition: all 150ms ease-in-out; transition: all 150ms ease-in-out;
&.row-mode {
flex-direction: row;
align-items: center;
width: fit-content;
max-width: unset;
min-width: 100px;
height: fit-content;
gap: 10px;
.playlist_cover {
width: 50px;
height: 50px;
min-width: 50px;
max-height: 50px;
}
.playlist_cover {
.playlist_cover_mask {
font-size: 2rem;
}
}
.playlist_info {
justify-content: center;
.playlist_info_title {
h1 {
word-break: break-all;
white-space: wrap;
}
}
}
}
&.cover-hovering { &.cover-hovering {
.playlist_cover { .playlist_cover {
.playlist_cover_mask { .playlist_cover_mask {
@ -29,18 +68,27 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: @playlist_cover_maxSize;
//max-height: 150px; height: @playlist_cover_maxSize;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
z-index: 50; z-index: 50;
border-radius: 12px;
overflow: hidden;
background-color: var(--background-color-accent);
.image-wrapper {
width: 100%;
height: 100%;
img { img {
width: @playlist_cover_maxSize; width: 100%;
height: @playlist_cover_maxSize; height: 100%;
object-fit: cover; object-fit: cover;
border-radius: 12px; }
} }
.playlist_cover_mask { .playlist_cover_mask {
@ -116,28 +164,7 @@
} }
} }
.playlist_actions { .playlist_details {
display: flex;
flex-direction: column;
align-self: flex-end;
justify-self: flex-end;
justify-content: center;
align-items: center;
height: 100%;
padding: 10px;
.ant-btn {
svg {
margin: 0 !important;
}
}
}
.playlist_bottom {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -0,0 +1,26 @@
import { Icons } from "@components/Icons"
const PlaylistTypeDecorators = {
single: () => (
<span className="playlistType">
<Icons.MdMusicNote /> Single
</span>
),
album: () => (
<span className="playlistType">
<Icons.MdAlbum /> Album
</span>
),
ep: () => (
<span className="playlistType">
<Icons.MdAlbum /> EP
</span>
),
mix: () => (
<span className="playlistType">
<Icons.MdMusicNote /> Mix
</span>
),
}
export default PlaylistTypeDecorators

View File

@ -0,0 +1,158 @@
import React from "react"
import * as antd from "antd"
import classnames from "classnames"
import { Icons } from "@components/Icons"
import ImageViewer from "@components/ImageViewer"
import LikeButton from "@components/LikeButton"
import seekToTimeLabel from "@utils/seekToTimeLabel"
import MusicModel from "@models/music"
import PlaylistTypeDecorators from "./decorators"
const typeToKind = {
album: "releases",
ep: "releases",
compilation: "releases",
playlist: "playlists",
}
const PlaylistHeader = ({
playlist,
owningPlaylist,
onPlayAll,
onViewDetails,
onMoreMenuClick,
}) => {
const playlistType = playlist.type?.toLowerCase() ?? "playlist"
const moreMenuItems = React.useMemo(() => {
const items = []
// Only allow editing/deleting standard playlists owned by the user
if (
owningPlaylist &&
(!playlist.type || playlist.type === "playlist")
) {
items.push({ key: "edit", label: "Edit" })
items.push({ key: "delete", label: "Delete" })
}
return items
}, [playlist.type, owningPlaylist])
const handlePublisherClick = () => {
if (playlist.publisher?.username) {
app.navigation.goToAccount(playlist.publisher.username)
}
}
const handleOnClickLike = async (to) => {
await MusicModel.toggleItemFavorite(
typeToKind[playlistType],
playlist._id,
to,
)
}
const fetchItemIsFavorite = async () => {
const isFavorite = await MusicModel.isItemFavorited(
typeToKind[playlistType],
playlist._id,
)
return isFavorite
}
return (
<div className="play_info_wrapper">
<div className="play_info">
<div className="play_info_cover">
<ImageViewer
src={
playlist.cover ??
playlist.thumbnail ??
"/assets/no_song.png"
}
/>
</div>
<div className="play_info_details">
<div className="play_info_title">
{playlist.service === "tidal" && <Icons.SiTidal />}{" "}
{typeof playlist.title === "function" ? (
playlist.title()
) : (
<h1>{playlist.title}</h1>
)}
</div>
<div className="play_info_statistics">
{PlaylistTypeDecorators[playlistType] && (
<div className="play_info_statistics_item">
{PlaylistTypeDecorators[playlistType]()}
</div>
)}
<div className="play_info_statistics_item">
<p>
<Icons.MdLibraryMusic /> {playlist.total_items}{" "}
Items
</p>
</div>
{playlist.total_duration > 0 && (
<div className="play_info_statistics_item">
<p>
<Icons.IoMdTime />{" "}
{seekToTimeLabel(playlist.total_duration)}
</p>
</div>
)}
{playlist.publisher && (
<div className="play_info_statistics_item">
<p onClick={handlePublisherClick}>
<Icons.MdPerson /> Publised by{" "}
<a>{playlist.publisher.username}</a>
</p>
</div>
)}
</div>
<div className="play_info_actions">
<antd.Button
type="primary"
shape="rounded"
size="large"
onClick={onPlayAll}
disabled={playlist.items.length === 0}
>
<Icons.MdPlayArrow /> Play
</antd.Button>
<div className="likeButtonWrapper">
<LikeButton
liked={fetchItemIsFavorite}
onClick={handleOnClickLike}
/>
</div>
{moreMenuItems.length > 0 && (
<antd.Dropdown
trigger={["click"]}
placement="bottom"
menu={{
items: moreMenuItems,
onClick: onMoreMenuClick,
}}
>
<antd.Button icon={<Icons.MdMoreVert />} />
</antd.Dropdown>
)}
</div>
</div>
</div>
</div>
)
}
export default PlaylistHeader

View File

@ -1,437 +1,213 @@
import React from "react" import React from "react"
import * as antd from "antd" import * as antd from "antd"
import classnames from "classnames" import classnames from "classnames"
import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
import fuse from "fuse.js"
import { WithPlayerContext } from "@contexts/WithPlayerContext" import { WithPlayerContext } from "@contexts/WithPlayerContext"
import { Context as PlaylistContext } from "@contexts/WithPlaylistContext" import { Context as PlaylistContext } from "@contexts/WithPlaylistContext"
import useWsEvents from "@hooks/useWsEvents"
import checkUserIdIsSelf from "@utils/checkUserIdIsSelf" import checkUserIdIsSelf from "@utils/checkUserIdIsSelf"
import LoadMore from "@components/LoadMore"
import { Icons } from "@components/Icons"
import MusicTrack from "@components/Music/Track"
import SearchButton from "@components/SearchButton"
import ImageViewer from "@components/ImageViewer"
import MusicModel from "@models/music" import MusicModel from "@models/music"
import PlaylistHeader from "./header"
import TrackList from "./list"
import "./index.less" import "./index.less"
const PlaylistTypeDecorators = { const PlaylistView = ({
single: () => ( playlist: initialPlaylist,
<span className="playlistType"> noHeader = false,
<Icons.MdMusicNote /> onLoadMore,
Single hasMore,
</span> }) => {
), const [playlist, setPlaylist] = React.useState(initialPlaylist)
album: () => (
<span className="playlistType">
<Icons.MdAlbum />
Album
</span>
),
ep: () => (
<span className="playlistType">
<Icons.MdAlbum />
EP
</span>
),
mix: () => (
<span className="playlistType">
<Icons.MdMusicNote />
Mix
</span>
),
}
const PlaylistInfo = (props) => {
return (
<div>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
children={props.data.description}
/>
</div>
)
}
const MoreMenuHandlers = {
edit: async (playlist) => {},
delete: async (playlist) => {
return antd.Modal.confirm({
title: "Are you sure you want to delete this playlist?",
onOk: async () => {
const result = await MusicModel.deletePlaylist(
playlist._id,
).catch((err) => {
console.log(err)
app.message.error("Failed to delete playlist")
return null
})
if (result) {
app.navigation.goToMusic()
}
},
})
},
}
const PlaylistView = (props) => {
const [playlist, setPlaylist] = React.useState(props.playlist)
const [searchResults, setSearchResults] = React.useState(null) const [searchResults, setSearchResults] = React.useState(null)
const [owningPlaylist, setOwningPlaylist] = React.useState( const searchTimeoutRef = React.useRef(null) // Ref for debounce timeout
checkUserIdIsSelf(props.playlist?.user_id),
// Derive ownership directly instead of using state
const isOwner = React.useMemo(
() => checkUserIdIsSelf(playlist?.user_id),
[playlist],
) )
const moreMenuItems = React.useMemo(() => { const playlistContextValue = React.useMemo(
const items = [ () => ({
{
key: "edit",
label: "Edit",
},
]
if (!playlist.type || playlist.type === "playlist") {
if (checkUserIdIsSelf(playlist.user_id)) {
items.push({
key: "delete",
label: "Delete",
})
}
}
return items
})
const contextValues = {
playlist_data: playlist, playlist_data: playlist,
owning_playlist: owningPlaylist, owning_playlist: isOwner,
add_track: (track) => {}, add_track: (track) => {
remove_track: (track) => {}, /* TODO: Implement */
} },
remove_track: (track) => {
/* TODO: Implement */
},
}),
[playlist, isOwner],
)
let debounceSearch = null // Define handlers for playlist actions (Edit, Delete)
const MoreMenuHandlers = React.useMemo(
() => ({
edit: async (pl) => {
// TODO: Implement Edit Playlist logic
console.log("Edit playlist:", pl._id)
app.message.info("Edit not implemented yet.")
},
delete: async (pl) => {
antd.Modal.confirm({
title: "Are you sure you want to delete this playlist?",
content: `Playlist: ${pl.title}`,
okText: "Delete",
okType: "danger",
cancelText: "Cancel",
onOk: async () => {
try {
await MusicModel.deletePlaylist(pl._id)
app.message.success("Playlist deleted successfully")
app.navigation.goToMusic() // Navigate away after deletion
} catch (err) {
console.error("Failed to delete playlist:", err)
app.message.error(
err.message || "Failed to delete playlist",
)
}
},
})
},
}),
[],
)
const makeSearch = (value) => { const makeSearch = (value) => {
//TODO: Implement me using API // TODO: Implement API call for search
return app.message.info("Not implemented yet...") console.log("Searching for:", value)
setSearchResults([]) // Placeholder: clear results or set loading state
return app.message.info("Search not implemented yet...")
} }
const handleOnSearchChange = (value) => { const handleSearchChange = (value) => {
debounceSearch = setTimeout(() => { if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
searchTimeoutRef.current = setTimeout(() => {
makeSearch(value) makeSearch(value)
}, 500) }, 500) // 500ms debounce
} }
const handleOnSearchEmpty = () => { const handleSearchEmpty = () => {
if (debounceSearch) { if (searchTimeoutRef.current) {
clearTimeout(debounceSearch) clearTimeout(searchTimeoutRef.current)
}
setSearchResults(null) // Clear search results when input is cleared
} }
setSearchResults(null) const handlePlayAll = () => {
} if (playlist?.items?.length > 0) {
const handleOnClickPlaylistPlay = () => {
app.cores.player.start(playlist.items) app.cores.player.start(playlist.items)
} }
const handleOnClickViewDetails = () => {
app.layout.modal.open("playlist_info", PlaylistInfo, {
props: {
data: playlist,
},
})
} }
const handleOnClickTrack = (track) => { const handleViewDetails = () => {
// search index of track if (playlist?.description) {
const index = playlist.items.findIndex((item) => { app.layout.modal.open(
return item._id === track._id "playlist_info",
}) () => (
<PlaylistInfoModalContent
description={playlist.description}
/>
),
{ title: playlist.title || "Playlist Info" }, // Add title to modal
)
}
}
const handleTrackClick = (track) => {
const index = playlist.items.findIndex((item) => item._id === track._id)
// Track not found in current playlist items
if (index === -1) { if (index === -1) {
return return false
} }
// check if clicked track is currently playing const playerCore = app.cores.player
if (app.cores.player.state.track_manifest?._id === track._id) { // Toggle playback if the clicked track is already playing
app.cores.player.playback.toggle() if (playerCore.state.track_manifest?._id === track._id) {
playerCore.playback.toggle()
} else { } else {
app.cores.player.start(playlist.items, { // Start playback from the clicked track
startIndex: index, playerCore.start(playlist.items, { startIndex: index })
})
} }
} }
const handleUpdateTrackLike = (track_id, liked) => { const handleTrackStateChange = (track_id, update) => {
setPlaylist((prev) => { setPlaylist((prev) => {
const index = prev.list.findIndex((item) => { if (!prev) return prev
return item._id === track_id const trackIndex = prev.items.findIndex(
}) (item) => item._id === track_id,
)
if (index !== -1) { if (trackIndex !== -1) {
const newState = { const updatedItems = [...prev.items]
...prev, updatedItems[trackIndex] = {
} ...updatedItems[trackIndex],
newState.list[index].liked = liked
return newState
}
return prev
})
}
const handleTrackChangeState = (track_id, update) => {
setPlaylist((prev) => {
const index = prev.list.findIndex((item) => {
return item._id === track_id
})
if (index !== -1) {
const newState = {
...prev,
}
newState.list[index] = {
...newState.list[index],
...update, ...update,
} }
return { ...prev, items: updatedItems }
return newState
} }
return prev return prev
}) })
} }
const handleMoreMenuClick = async (e) => { const handleMoreMenuClick = async (e) => {
const handler = MoreMenuHandlers[e.key] const handler = MoreMenuHandlers[e.key]
if (typeof handler === "function") {
if (typeof handler !== "function") { await handler(playlist)
throw new Error(`Invalid menu handler [${e.key}]`) } else {
console.error(`Invalid menu handler key: ${e.key}`)
} }
return await handler(playlist)
} }
useWsEvents(
{
"music:track:toggle:like": (data) => {
handleUpdateTrackLike(data.track_id, data.action === "liked")
},
},
{
socketName: "music",
},
)
React.useEffect(() => { React.useEffect(() => {
setPlaylist(props.playlist) setPlaylist(initialPlaylist)
setOwningPlaylist(checkUserIdIsSelf(props.playlist?.user_id)) setSearchResults(null)
}, [props.playlist]) }, [initialPlaylist])
React.useEffect(() => {
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
}
}, [])
if (!playlist) { if (!playlist) {
return <antd.Skeleton active /> return <antd.Skeleton active />
} }
const playlistType = playlist.type?.toLowerCase() ?? "playlist"
return ( return (
<PlaylistContext.Provider value={contextValues}> <PlaylistContext.Provider value={playlistContextValue}>
<WithPlayerContext> <WithPlayerContext>
<div className={classnames("playlist_view")}> <div className={classnames("playlist_view")}>
{!props.noHeader && ( {!noHeader && (
<div className="play_info_wrapper"> <PlaylistHeader
<div className="play_info"> playlist={playlist}
<div className="play_info_cover"> owningPlaylist={isOwner}
<ImageViewer onPlayAll={handlePlayAll}
src={ onViewDetails={handleViewDetails}
playlist.cover ?? onMoreMenuClick={handleMoreMenuClick}
playlist?.thumbnail ??
"/assets/no_song.png"
}
/>
</div>
<div className="play_info_details">
<div className="play_info_title">
{playlist.service === "tidal" && (
<Icons.SiTidal />
)}
{typeof playlist.title ===
"function" ? (
playlist.title
) : (
<h1>{playlist.title}</h1>
)}
</div>
<div className="play_info_statistics">
{playlistType &&
PlaylistTypeDecorators[
playlistType
] && (
<div className="play_info_statistics_item">
{PlaylistTypeDecorators[
playlistType
]()}
</div>
)}
<div className="play_info_statistics_item">
<p>
<Icons.MdLibraryMusic />{" "}
{props.length ??
playlist.total_length ??
playlist.items.length}{" "}
Items
</p>
</div>
{playlist.publisher && (
<div className="play_info_statistics_item">
<p
onClick={() => {
app.navigation.goToAccount(
playlist.publisher
.username,
)
}}
>
<Icons.MdPerson />
Publised by{" "}
<a>
{
playlist.publisher
.username
}
</a>
</p>
</div>
)}
</div>
<div className="play_info_actions">
<antd.Button
type="primary"
shape="rounded"
size="large"
onClick={handleOnClickPlaylistPlay}
>
<Icons.MdPlayArrow />
Play
</antd.Button>
{playlist.description && (
<antd.Button
icon={<Icons.MdInfo />}
onClick={
handleOnClickViewDetails
}
/> />
)} )}
{owningPlaylist && ( <TrackList
<antd.Dropdown tracks={playlist.items || []}
trigger={["click"]} searchResults={searchResults}
placement="bottom" onTrackClick={handleTrackClick}
menu={{ onTrackStateChange={handleTrackStateChange}
items: moreMenuItems, onSearchChange={handleSearchChange}
onClick: onSearchEmpty={handleSearchEmpty}
handleMoreMenuClick, onLoadMore={onLoadMore}
}} hasMore={hasMore}
> noHeader={noHeader}
<antd.Button
icon={<Icons.MdMoreVert />}
/> />
</antd.Dropdown>
)}
</div>
</div>
</div>
</div>
)}
<div className="list">
{!props.noHeader && playlist.items.length > 0 && (
<div className="list_header">
<h1>
<Icons.MdPlaylistPlay /> Tracks
</h1>
<SearchButton
onChange={handleOnSearchChange}
onEmpty={handleOnSearchEmpty}
disabled
/>
</div>
)}
{playlist.items.length === 0 && (
<antd.Empty
description={
<>
<Icons.MdLibraryMusic /> This playlist
its empty!
</>
}
/>
)}
{searchResults &&
searchResults.map((item) => {
return (
<MusicTrack
key={item._id}
order={item._id}
track={item}
onPlay={() => handleOnClickTrack(item)}
changeState={(update) =>
handleTrackChangeState(
item._id,
update,
)
}
/>
)
})}
{!searchResults && playlist.items.length > 0 && (
<LoadMore
className="list_content"
loadingComponent={() => <antd.Skeleton />}
onBottom={props.onLoadMore}
hasMore={props.hasMore}
>
<WithPlayerContext>
{playlist.items.map((item, index) => {
return (
<MusicTrack
order={index + 1}
track={item}
onPlay={() =>
handleOnClickTrack(item)
}
changeState={(update) =>
handleTrackChangeState(
item._id,
update,
)
}
/>
)
})}
</WithPlayerContext>
</LoadMore>
)}
</div>
</div> </div>
</WithPlayerContext> </WithPlayerContext>
</PlaylistContext.Provider> </PlaylistContext.Provider>

View File

@ -207,6 +207,13 @@ html {
align-items: center; align-items: center;
gap: 10px; gap: 10px;
.likeButtonWrapper {
background-color: var(--background-color-primary);
border-radius: 12px;
padding: 10px;
}
} }
} }
} }

View File

@ -0,0 +1,98 @@
import React from "react"
import * as antd from "antd"
import classnames from "classnames"
import { WithPlayerContext } from "@contexts/WithPlayerContext"
import { Context as PlaylistContext } from "@contexts/WithPlaylistContext"
import LoadMore from "@components/LoadMore"
import { Icons } from "@components/Icons"
import MusicTrack from "@components/Music/Track"
import SearchButton from "@components/SearchButton"
/**
* Renders the list of tracks in the playlist.
*/
const TrackList = ({
tracks,
searchResults,
onTrackClick,
onTrackStateChange,
onSearchChange,
onSearchEmpty,
onLoadMore,
hasMore,
noHeader = false,
}) => {
const showListHeader = !noHeader && (tracks.length > 0 || searchResults)
if (!searchResults && tracks.length === 0) {
return (
<div className="list">
<antd.Empty
description={
<>
<Icons.MdLibraryMusic /> This playlist is empty!
</>
}
/>
</div>
)
}
return (
<div className="list">
{showListHeader && (
<div className="list_header">
<h1>
<Icons.MdPlaylistPlay /> Tracks
</h1>
{/* TODO: Implement Search API call */}
<SearchButton
onChange={onSearchChange}
onEmpty={onSearchEmpty}
disabled // Keep disabled until implemented
/>
</div>
)}
{searchResults ? ( // Display search results if available
searchResults.map((item) => (
<MusicTrack
key={item._id}
order={item._id} // Consider using index if order matters
track={item}
onPlay={() => onTrackClick(item)}
changeState={(update) =>
onTrackStateChange(item._id, update)
}
/>
))
) : (
// Display regular track list
<LoadMore
className="list_content"
loadingComponent={() => <antd.Skeleton />}
onBottom={onLoadMore}
hasMore={hasMore}
>
<WithPlayerContext>
{tracks.map((item, index) => (
<MusicTrack
key={item._id} // Use unique ID for key
order={index + 1}
track={item}
onPlay={() => onTrackClick(item)}
changeState={(update) =>
onTrackStateChange(item._id, update)
}
/>
))}
</WithPlayerContext>
</LoadMore>
)}
</div>
)
}
export default TrackList

View File

@ -0,0 +1,57 @@
import React from "react"
import { Skeleton, Result } from "antd"
import RadioModel from "@models/radio"
import Image from "@components/Image"
import { MdPlayCircle, MdHeadphones } from "react-icons/md"
import "./index.less"
const Radio = ({ item, style }) => {
const onClickItem = () => {
app.cores.player.start(
{
title: item.name,
source: item.http_src,
cover: item.background,
},
{
radioId: item.radio_id,
},
)
}
if (!item) {
return (
<div className="radio-item empty" style={style}>
<div className="radio-item-content">
<Skeleton />
</div>
</div>
)
}
return (
<div className="radio-item" onClick={onClickItem} style={style}>
<Image className="radio-item-cover" src={item.background} />
<div className="radio-item-content">
<h1 id="title">{item.name}</h1>
<p>{item.description}</p>
<div className="radio-item-info">
<div className="radio-item-info-item" id="now_playing">
<MdPlayCircle />
<span>{item.now_playing.song.text}</span>
</div>
<div className="radio-item-info-item" id="now_playing">
<MdHeadphones />
<span>{item.listeners}</span>
</div>
</div>
</div>
</div>
)
}
export default Radio

View File

@ -0,0 +1,82 @@
.radio-item {
position: relative;
display: flex;
flex-direction: column;
min-width: 250px;
min-height: 150px;
border-radius: 16px;
background-color: var(--background-color-accent);
overflow: hidden;
&:hover {
cursor: pointer;
.radio-item-content {
backdrop-filter: blur(2px);
}
}
&.empty {
cursor: default;
}
.lazy-load-image-background,
.radio-item-cover {
position: absolute;
z-index: 1;
top: 0;
left: 0;
width: 100%;
height: 100%;
img {
object-fit: cover;
}
}
.radio-item-content {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: space-between;
width: 100%;
height: 100%;
padding: 16px;
transition: all 150ms ease-in-out;
.radio-item-info {
display: flex;
align-items: center;
gap: 8px;
.radio-item-info-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
padding: 4px;
background-color: rgba(var(--bg_color_3), 0.7);
border-radius: 8px;
font-size: 0.7rem;
}
}
}
}

View File

@ -206,7 +206,7 @@ html {
.music-track_title { .music-track_title {
font-size: 1rem; font-size: 1rem;
font-family: "Space Grotesk", sans-serif; //font-family: "Space Grotesk", sans-serif;
} }
.music-track_artist { .music-track_artist {

View File

@ -286,7 +286,7 @@ const ReleaseEditor = (props) => {
icon={<Icons.MdLink />} icon={<Icons.MdLink />}
onClick={() => onClick={() =>
app.location.push( app.location.push(
`/music/release/${globalState._id}`, `/music/list/${globalState._id}`,
) )
} }
> >

View File

@ -13,7 +13,6 @@ export class Tab extends React.Component {
error: null, error: null,
} }
// handle on error
componentDidCatch(err) { componentDidCatch(err) {
this.setState({ error: err }) this.setState({ error: err })
} }
@ -28,7 +27,6 @@ export class Tab extends React.Component {
/> />
) )
} }
return <>{this.props.children}</> return <>{this.props.children}</>
} }
} }
@ -49,7 +47,7 @@ export class PagePanelWithNavMenu extends React.Component {
activeTab: activeTab:
new URLSearchParams(window.location.search).get("type") ?? new URLSearchParams(window.location.search).get("type") ??
this.props.defaultTab ?? this.props.defaultTab ??
this.props.tabs[0].key, this.props.tabs[0]?.key,
renders: [], renders: [],
} }
@ -57,41 +55,98 @@ export class PagePanelWithNavMenu extends React.Component {
interface = { interface = {
attachComponent: (id, component, options) => { attachComponent: (id, component, options) => {
const renders = this.state.renders this.setState((prevState) => ({
renders: [
renders.push({ ...prevState.renders,
{
id: id, id: id,
component: component, component: component,
options: options, options: options,
ref: React.createRef(), ref: React.createRef(),
}) },
],
this.setState({ }))
renders: renders,
})
}, },
detachComponent: (id) => { detachComponent: (id) => {
const renders = this.state.renders this.setState((prevState) => ({
renders: prevState.renders.filter((render) => render.id !== id),
const index = renders.findIndex((render) => render.id === id) }))
renders.splice(index, 1)
this.setState({
renders: renders,
})
}, },
} }
updateLayoutHeaderAndTopBar = () => {
const navMenuItems = this.getItems([
...(this.props.tabs ?? []),
...(this.props.extraItems ?? []),
])
const mobileNavMenuItems = this.getItems(this.props.tabs ?? [])
if (app.isMobile) {
if (mobileNavMenuItems.length > 0) {
app.layout.top_bar.render(
<NavMenu
activeKey={this.state.activeTab}
items={mobileNavMenuItems}
onClickItem={(key) => this.handleTabChange(key)}
/>,
)
} else {
app.layout.top_bar.renderDefault()
}
} else {
if (
navMenuItems.length > 0 ||
this.state.renders.length > 0 ||
this.props.navMenuHeader
) {
app.layout.header.render(
<NavMenu
header={this.props.navMenuHeader}
activeKey={this.state.activeTab}
items={navMenuItems}
onClickItem={(key) => this.handleTabChange(key)}
renderNames
>
{this.state.renders.map((renderItem) =>
React.createElement(renderItem.component, {
...(renderItem.options.props ?? {}),
ref: renderItem.ref,
key: renderItem.id,
}),
)}
</NavMenu>,
)
} else {
app.layout.header.render(null)
}
}
}
componentDidMount() { componentDidMount() {
app.layout.page_panels = this.interface app.layout.page_panels = this.interface
if (app.isMobile) { if (app.isMobile) {
app.layout.top_bar.shouldUseTopBarSpacer(true) app.layout.top_bar.shouldUseTopBarSpacer(true)
app.layout.toggleCenteredContent(false) app.layout.toggleCenteredContent(false)
} else {
app.layout.toggleCenteredContent(true)
} }
app.layout.toggleCenteredContent(true) this.updateLayoutHeaderAndTopBar()
}
componentDidUpdate(prevProps, prevState) {
if (
prevState.activeTab !== this.state.activeTab ||
prevProps.tabs !== this.props.tabs ||
prevProps.extraItems !== this.props.extraItems ||
prevState.renders !== this.state.renders ||
prevProps.navMenuHeader !== this.props.navMenuHeader ||
prevProps.defaultTab !== this.props.defaultTab
) {
this.updateLayoutHeaderAndTopBar()
}
} }
componentWillUnmount() { componentWillUnmount() {
@ -102,9 +157,11 @@ export class PagePanelWithNavMenu extends React.Component {
app.layout.header.render(null) app.layout.header.render(null)
} }
} else { } else {
if (app.layout.top_bar) {
app.layout.top_bar.renderDefault() app.layout.top_bar.renderDefault()
} }
} }
}
renderActiveTab() { renderActiveTab() {
if (!Array.isArray(this.props.tabs)) { if (!Array.isArray(this.props.tabs)) {
@ -112,13 +169,23 @@ export class PagePanelWithNavMenu extends React.Component {
return <></> return <></>
} }
if (this.props.tabs.length === 0) { if (this.props.tabs.length === 0 && !this.state.activeTab) {
return <></> return <></>
} }
// slip the active tab by splitting on "."
if (!this.state.activeTab) { if (!this.state.activeTab) {
const firstTabKey = this.props.tabs[0]?.key
if (firstTabKey) {
console.error("PagePanelWithNavMenu: activeTab is not defined") console.error("PagePanelWithNavMenu: activeTab is not defined")
return (
<antd.Result
status="404"
title="404"
subTitle="Sorry, the tab you visited does not exist (activeTab not set)."
/>
)
}
return <></> return <></>
} }
@ -134,14 +201,12 @@ export class PagePanelWithNavMenu extends React.Component {
console.error( console.error(
"PagePanelWithNavMenu: tab.children is not defined", "PagePanelWithNavMenu: tab.children is not defined",
) )
return (tab = null) return (tab = null)
} }
tab = tab.children.find( tab = tab.children.find(
(children) => (children) =>
children.key === children.key ===
`${activeTabDirectory[index - 1]}.${key}`, `${activeTabDirectory.slice(0, index).join(".")}.${key}`,
) )
} }
}) })
@ -150,7 +215,6 @@ export class PagePanelWithNavMenu extends React.Component {
if (this.props.onNotFound) { if (this.props.onNotFound) {
return this.props.onNotFound() return this.props.onNotFound()
} }
return ( return (
<antd.Result <antd.Result
status="404" status="404"
@ -161,7 +225,6 @@ export class PagePanelWithNavMenu extends React.Component {
} }
const componentProps = tab.props ?? this.props.tabProps const componentProps = tab.props ?? this.props.tabProps
return React.createElement(tab.component, { return React.createElement(tab.component, {
...componentProps, ...componentProps,
}) })
@ -176,7 +239,7 @@ export class PagePanelWithNavMenu extends React.Component {
await this.props.beforeTabChange(key) await this.props.beforeTabChange(key)
} }
await this.setState({ activeTab: key }) this.setState({ activeTab: key })
if (this.props.useSetQueryType) { if (this.props.useSetQueryType) {
this.replaceQueryTypeToCurrentTab(key) this.replaceQueryTypeToCurrentTab(key)
@ -192,9 +255,11 @@ export class PagePanelWithNavMenu extends React.Component {
if (this.props.transition) { if (this.props.transition) {
if (document.startViewTransition) { if (document.startViewTransition) {
return document.startViewTransition(() => { document.startViewTransition(() => {
this.tabChange(key) this.tabChange(key)
}) })
return
} }
console.warn( console.warn(
@ -205,20 +270,17 @@ export class PagePanelWithNavMenu extends React.Component {
this.primaryPanelRef.current && this.primaryPanelRef.current &&
this.primaryPanelRef.current?.classList this.primaryPanelRef.current?.classList
) { ) {
// set to primary panel fade-opacity-leave class
this.primaryPanelRef.current.classList.add("fade-opacity-leave") this.primaryPanelRef.current.classList.add("fade-opacity-leave")
// remove fade-opacity-leave class after animation
setTimeout(() => { setTimeout(() => {
if (this.primaryPanelRef.current) {
this.primaryPanelRef.current.classList.remove( this.primaryPanelRef.current.classList.remove(
"fade-opacity-leave", "fade-opacity-leave",
) )
}
}, 300) }, 300)
} }
await new Promise((resolve) => setTimeout(resolve, 200)) await new Promise((resolve) => setTimeout(resolve, 200))
} }
return this.tabChange(key) return this.tabChange(key)
} }
@ -229,59 +291,19 @@ export class PagePanelWithNavMenu extends React.Component {
) )
return [] return []
} }
return items.map((item) => ({
items = items.map((item) => {
return {
key: item.key, key: item.key,
icon: createIconRender(item.icon), icon: createIconRender(item.icon),
label: item.label, label: item.label,
children: item.children && this.getItems(item.children), children: item.children && this.getItems(item.children),
disabled: item.disabled, disabled: item.disabled,
props: item.props ?? {}, props: item.props ?? {},
} }))
})
return items
} }
render() { render() {
return ( return (
<> <>
{app.isMobile &&
app.layout.top_bar.render(
<NavMenu
activeKey={this.state.activeTab}
items={this.getItems(this.props.tabs)}
onClickItem={(key) => this.handleTabChange(key)}
/>,
)}
{!app.isMobile &&
app.layout.header.render(
<NavMenu
header={this.props.navMenuHeader}
activeKey={this.state.activeTab}
items={this.getItems([
...(this.props.tabs ?? []),
...(this.props.extraItems ?? []),
])}
onClickItem={(key) => this.handleTabChange(key)}
renderNames
>
{Array.isArray(this.state.renders) && [
this.state.renders.map((render, index) => {
return React.createElement(
render.component,
{
...render.options.props,
ref: render.ref,
},
)
}),
]}
</NavMenu>,
)}
<div className="pagePanels"> <div className="pagePanels">
<div className="panel" ref={this.primaryPanelRef}> <div className="panel" ref={this.primaryPanelRef}>
{this.renderActiveTab()} {this.renderActiveTab()}

View File

@ -27,8 +27,8 @@ const ExtraActions = (props) => {
return false return false
} }
await trackInstance.manifest.serviceOperations.toggleItemFavourite( await trackInstance.manifest.serviceOperations.toggleItemFavorite(
"track", "tracks",
trackInstance.manifest._id, trackInstance.manifest._id,
) )
} }
@ -47,7 +47,7 @@ const ExtraActions = (props) => {
<LikeButton <LikeButton
liked={ liked={
trackInstance?.manifest?.serviceOperations trackInstance?.manifest?.serviceOperations
?.fetchLikeStatus ?.isItemFavorited
} }
onClick={handleClickLike} onClick={handleClickLike}
disabled={!trackInstance?.manifest?._id} disabled={!trackInstance?.manifest?._id}

View File

@ -4,20 +4,22 @@ import * as antd from "antd"
import "./index.less" import "./index.less"
export default (props) => { export default (props) => {
return <div className="player-volume_slider"> return (
<div className="player-volume_slider">
<antd.Slider <antd.Slider
min={0} min={0}
max={1} max={1}
step={0.01} step={0.01}
value={props.volume} value={props.volume}
onAfterChange={props.onChange} onChangeComplete={props.onChange}
defaultValue={props.defaultValue} defaultValue={props.defaultValue}
tooltip={{ tooltip={{
formatter: (value) => { formatter: (value) => {
return `${Math.round(value * 100)}%` return `${Math.round(value * 100)}%`
} },
}} }}
vertical vertical
/> />
</div> </div>
)
} }

View File

@ -39,7 +39,7 @@ const EventsHandlers = {
const track = app.cores.player.track() const track = app.cores.player.track()
return await track.manifest.serviceOperations.toggleItemFavourite( return await track.manifest.serviceOperations.toggleItemFavorite(
"track", "track",
ctx.track_manifest._id, ctx.track_manifest._id,
) )
@ -133,7 +133,7 @@ const Controls = (props) => {
<LikeButton <LikeButton
liked={ liked={
trackInstance?.manifest?.serviceOperations trackInstance?.manifest?.serviceOperations
?.fetchLikeStatus ?.isItemFavorited
} }
onClick={() => handleAction("like")} onClick={() => handleAction("like")}
disabled={!trackInstance?.manifest?._id} disabled={!trackInstance?.manifest?._id}

View File

@ -1,6 +1,5 @@
import React from "react" import React from "react"
import * as antd from "antd" import Slider from "./slider"
import Slider from "@mui/material/Slider"
import classnames from "classnames" import classnames from "classnames"
import seekToTimeLabel from "@utils/seekToTimeLabel" import seekToTimeLabel from "@utils/seekToTimeLabel"
@ -8,6 +7,8 @@ import seekToTimeLabel from "@utils/seekToTimeLabel"
import "./index.less" import "./index.less"
export default class SeekBar extends React.Component { export default class SeekBar extends React.Component {
static updateInterval = 1000
state = { state = {
playing: app.cores.player.state["playback_status"] === "playing", playing: app.cores.player.state["playback_status"] === "playing",
timeText: "00:00", timeText: "00:00",
@ -63,10 +64,16 @@ export default class SeekBar extends React.Component {
const seek = app.cores.player.controls.seek() const seek = app.cores.player.controls.seek()
const duration = app.cores.player.controls.duration() const duration = app.cores.player.controls.duration()
const percent = (seek / duration) * 100 let percent = 0 // Default to 0
// Ensure duration is a positive number to prevent division by zero or NaN results
if (typeof duration === "number" && duration > 0) {
percent = (seek / duration) * 100
}
// Ensure percent is a finite number; otherwise, default to 0.
// This handles cases like NaN (e.g., 0/0) or Infinity.
this.setState({ this.setState({
sliderTime: percent, sliderTime: Number.isFinite(percent) ? percent : 0,
}) })
} }
@ -130,7 +137,7 @@ export default class SeekBar extends React.Component {
if (this.state.playing) { if (this.state.playing) {
this.interval = setInterval(() => { this.interval = setInterval(() => {
this.updateAll() this.updateAll()
}, 1000) }, SeekBar.updateInterval)
} else { } else {
if (this.interval) { if (this.interval) {
clearInterval(this.interval) clearInterval(this.interval)
@ -173,7 +180,6 @@ export default class SeekBar extends React.Component {
})} })}
> >
<Slider <Slider
size="small"
value={this.state.sliderTime} value={this.state.sliderTime}
disabled={ disabled={
this.props.stopped || this.props.stopped ||
@ -189,18 +195,17 @@ export default class SeekBar extends React.Component {
sliderLock: true, sliderLock: true,
}) })
}} }}
onChangeCommitted={() => { onChangeCommitted={(_, value) => {
this.setState({ this.setState({
sliderLock: false, sliderLock: false,
}) })
this.handleSeek(this.state.sliderTime) this.handleSeek(value)
if (!this.props.playing) { if (!this.props.playing) {
app.cores.player.playback.play() app.cores.player.playback.play()
} }
}} }}
valueLabelDisplay="auto"
valueLabelFormat={(value) => { valueLabelFormat={(value) => {
return seekToTimeLabel( return seekToTimeLabel(
(value / 100) * (value / 100) *

View File

@ -12,7 +12,9 @@
width: 90%; width: 90%;
height: 100%; height: 100%;
margin: 0 0 10px 0; gap: 6px;
//margin: 0 0 10px 0;
border-radius: 8px; border-radius: 8px;
@ -46,30 +48,107 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
}
.MuiSlider-rail { font-family: "DM Mono", monospace;
height: 5px; font-size: 0.8rem;
}
.MuiSlider-track {
height: 5px;
background-color: var(--colorPrimary);
}
.MuiSlider-thumb {
background-color: var(--colorPrimary);
}
h1,
h2,
h3,
h4,
h5,
h6,
p,
span {
color: var(--text-color);
} }
} }
.slider-container {
position: relative;
z-index: 200;
width: 100%;
height: 8px;
transform: translateY(2px);
.slider-background-track {
position: absolute;
z-index: 100;
width: 100%;
height: 100%;
background-color: rgba(var(--layoutBackgroundColor), 0.7);
border-radius: 24px;
left: 0;
top: 0;
backdrop-filter: blur(5px);
}
.slider-progress-track {
position: absolute;
left: 0;
top: 0;
z-index: 110;
height: 100%;
background-color: var(--colorPrimary);
border-radius: 24px;
}
// This class is on the <input type="range"> itself.
// It needs to be transparent so the divs above show through.
// Its main role now is to provide the interactive thumb.
.slider-input {
position: absolute;
left: 0;
top: 0;
z-index: 120;
padding: 0;
margin: 0;
width: 100%;
height: 8px;
-webkit-appearance: none;
appearance: none;
background: transparent !important;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
background-color: transparent;
width: 8px;
height: 8px;
cursor: pointer;
}
}
}
.player-seek_bar-track-tooltip {
pointer-events: none;
position: absolute;
z-index: 10;
bottom: 100%;
margin-bottom: 6px;
padding: 4px 8px;
background-color: var(--tooltip-background, #333);
color: var(--text-color);
border-radius: 24px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
font-size: 0.8rem;
font-family: "DM Mono", monospace;
white-space: nowrap;
}

View File

@ -0,0 +1,139 @@
import React from "react"
import { motion } from "framer-motion"
const Slider = ({
min = 0,
max = 100,
step = 1,
value,
disabled = false,
onChange,
onChangeCommitted,
valueLabelFormat,
}) => {
const [internalValue, setInternalValue] = React.useState(value)
const [tooltipVisible, setTooltipVisible] = React.useState(false)
const [tooltipValue, setTooltipValue] = React.useState(value)
const [tooltipPosition, setTooltipPosition] = React.useState(0)
const sliderRef = React.useRef(null)
React.useEffect(() => {
setInternalValue(value)
}, [value])
const handleChange = React.useCallback(
(event) => {
const newValue = parseFloat(event.target.value)
setInternalValue(newValue)
if (onChange) {
onChange(event, newValue)
}
},
[onChange],
)
const handleInteractionEnd = React.useCallback(
(event) => {
if (onChangeCommitted) {
onChangeCommitted(event, parseFloat(event.target.value))
}
},
[onChangeCommitted],
)
const handleMouseMove = React.useCallback(
(event) => {
if (!sliderRef.current) {
return null
}
const rect = sliderRef.current.getBoundingClientRect()
const offsetX = event.clientX - rect.left
const width = sliderRef.current.offsetWidth
let hoverValue = min + (offsetX / width) * (max - min)
let positionPercentage = (offsetX / width) * 100
positionPercentage = Math.max(0, Math.min(100, positionPercentage))
hoverValue = Math.max(min, Math.min(max, hoverValue))
hoverValue = Math.round(hoverValue / step) * step
if (Number.isNaN(hoverValue)) {
hoverValue = min
}
if (typeof valueLabelFormat === "function") {
setTooltipValue(valueLabelFormat(hoverValue))
} else {
setTooltipValue(hoverValue.toFixed(0))
}
setTooltipPosition(positionPercentage)
},
[min, max, step],
)
const handleMouseEnter = () => {
if (!disabled) {
setTooltipVisible(true)
}
}
const handleMouseLeave = () => {
setTooltipVisible(false)
}
const progressPercentage =
max > min ? ((internalValue - min) / (max - min)) * 100 : 0
return (
<div className="slider-container">
<div className="slider-background-track" />
<motion.div
className="slider-progress-track"
initial={{ width: "0%" }}
animate={{ width: `${progressPercentage}%` }}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
duration: 0.1,
}}
/>
<input
ref={sliderRef}
className="slider-input"
type="range"
min={min}
max={max}
step={step}
value={internalValue}
disabled={disabled}
onChange={handleChange}
onMouseUp={handleInteractionEnd}
onTouchEnd={handleInteractionEnd}
onMouseMove={handleMouseMove}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
aria-valuenow={internalValue}
/>
{tooltipVisible && !disabled && (
<div
className="player-seek_bar-track-tooltip"
style={{
left: `calc(${tooltipPosition}% - ${tooltipPosition * 0.2}px)`,
zIndex: 160,
}}
>
{tooltipValue}
</div>
)}
</div>
)
}
export default Slider

View File

@ -34,7 +34,11 @@ const Indicators = ({ track, playerState }) => {
if (track.metadata) { if (track.metadata) {
if (track.metadata.lossless) { if (track.metadata.lossless) {
indicators.push(<Icons.Lossless />) indicators.push(
<antd.Tooltip title="Lossless Audio">
<Icons.Lossless />
</antd.Tooltip>,
)
} }
} }

View File

@ -35,14 +35,6 @@
color: var(--text-color-black); color: var(--text-color-black);
} }
.MuiSlider-root {
color: var(--text-color-black);
.MuiSlider-rail {
color: var(--text-color-black);
}
}
.loadCircle { .loadCircle {
svg { svg {
path { path {
@ -150,11 +142,9 @@
overflow: hidden; overflow: hidden;
font-size: 1.5rem; font-size: 1.4rem;
font-weight: 600; font-weight: 600;
font-family: "Space Grotesk", sans-serif;
transition: all 150ms ease-in-out; transition: all 150ms ease-in-out;
color: currentColor; color: currentColor;
@ -188,7 +178,7 @@
width: 100%; width: 100%;
padding: 10px; padding: 10px;
gap: 5px; gap: 10px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -1,15 +1,14 @@
import React from "react" import React from "react"
import { Skeleton } from "antd" import { Skeleton } from "antd"
import { LoadingOutlined } from "@ant-design/icons"
import "./index.less" import "./index.less"
export default () => { const SkeletonComponent = () => {
return <div className="skeleton"> return (
<div className="indicator"> <div className="skeleton">
<LoadingOutlined spin />
<h3>Loading...</h3>
</div>
<Skeleton active /> <Skeleton active />
</div> </div>
)
} }
export default SkeletonComponent

View File

@ -1,4 +1,10 @@
.skeleton { .skeleton {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
svg { svg {
margin: 0 !important; margin: 0 !important;
} }
@ -9,14 +15,7 @@
color: var(--background-color-contrast); color: var(--background-color-contrast);
} }
.indicator {
color: var(--background-color-contrast);
display: flex;
flex-direction: row;
align-items: center;
}
background-color: var(--background-color-accent); background-color: var(--background-color-accent);
border-radius: 8px; border-radius: 8px;
padding: 10px; padding: 20px;
} }

View File

@ -55,7 +55,7 @@ export class WithPlayerContext extends React.Component {
state = app.cores.player.state state = app.cores.player.state
events = { events = {
"player.state.update": (state) => { "player.state.update": async (state) => {
this.setState(state) this.setState(state)
}, },
} }

View File

@ -1,4 +1,4 @@
import { MediaPlayer } from "dashjs" import { MediaPlayer, Debug } from "dashjs"
import PlayerProcessors from "./PlayerProcessors" import PlayerProcessors from "./PlayerProcessors"
import AudioPlayerStorage from "../player.storage" import AudioPlayerStorage from "../player.storage"
@ -29,6 +29,7 @@ export default class AudioBase {
// configure some settings for audio // configure some settings for audio
this.audio.crossOrigin = "anonymous" this.audio.crossOrigin = "anonymous"
this.audio.preload = "metadata" this.audio.preload = "metadata"
this.audio.loop = this.player.state.playback_mode === "repeat"
// listen all events // listen all events
for (const [key, value] of Object.entries(this.audioEvents)) { for (const [key, value] of Object.entries(this.audioEvents)) {
@ -55,6 +56,9 @@ export default class AudioBase {
resetSourceBuffersForTrackSwitch: true, resetSourceBuffersForTrackSwitch: true,
}, },
}, },
// debug: {
// logLevel: Debug.LOG_LEVEL_DEBUG,
// },
}) })
this.demuxer.initialize(this.audio, null, false) this.demuxer.initialize(this.audio, null, false)
@ -65,7 +69,10 @@ export default class AudioBase {
this.audio.src = null this.audio.src = null
this.audio.currentTime = 0 this.audio.currentTime = 0
if (this.demuxer) {
this.demuxer.destroy() this.demuxer.destroy()
}
this.createDemuxer() this.createDemuxer()
} }

View File

@ -1,4 +1,4 @@
import ToolBarPlayer from "@components/Player/ToolBarPlayer" import Player from "@components/Player"
export default class PlayerUI { export default class PlayerUI {
constructor(player) { constructor(player) {
@ -21,7 +21,7 @@ export default class PlayerUI {
if (app.layout.tools_bar) { if (app.layout.tools_bar) {
this.currentDomWindow = app.layout.tools_bar.attachRender( this.currentDomWindow = app.layout.tools_bar.attachRender(
"mediaPlayer", "mediaPlayer",
ToolBarPlayer, Player,
undefined, undefined,
{ {
position: "bottom", position: "bottom",

View File

@ -27,34 +27,63 @@ export default class TrackInstance {
play = async (params = {}) => { play = async (params = {}) => {
const startTime = performance.now() const startTime = performance.now()
if (!this.manifest.source.endsWith(".mpd")) { const isMpd = this.manifest.source.endsWith(".mpd")
const audioEl = this.player.base.audio
if (!isMpd) {
// if a demuxer exists (from a previous MPD track), destroy it
if (this.player.base.demuxer) {
this.player.base.demuxer.destroy() this.player.base.demuxer.destroy()
this.player.base.audio.src = this.manifest.source this.player.base.demuxer = null
}
// set the audio source directly
if (audioEl.src !== this.manifest.source) {
audioEl.src = this.manifest.source
audioEl.load() // important to apply the new src and stop previous playback
}
} else { } else {
// ensure the direct 'src' attribute is removed if it was set
const currentSrc = audioEl.getAttribute("src")
if (currentSrc && !currentSrc.startsWith("blob:")) {
// blob: indicates MSE is likely already in use
audioEl.removeAttribute("src")
audioEl.load() // tell the element to update its state after src removal
}
// ensure a demuxer instance exists
if (!this.player.base.demuxer) { if (!this.player.base.demuxer) {
this.player.base.createDemuxer() this.player.base.createDemuxer()
} }
await this.player.base.demuxer.attachSource( // attach the mpd source to the demuxer
`${this.manifest.source}?t=${Date.now()}`, await this.player.base.demuxer.attachSource(this.manifest.source)
}
// reset audio properties
audioEl.currentTime = params.time ?? 0
audioEl.volume = 1
if (this.player.base.processors && this.player.base.processors.gain) {
this.player.base.processors.gain.set(this.player.state.volume)
}
if (audioEl.paused) {
try {
await audioEl.play()
} catch (error) {
console.error("[INSTANCE] Error during audio.play():", error)
}
} else {
console.log(
"[INSTANCE] Audio is already playing or will start shortly.",
) )
} }
this.player.base.audio.currentTime = params.time ?? 0 this._loadMs = performance.now() - startTime
if (this.player.base.audio.paused) { console.log(`[INSTANCE] [tooks ${this._loadMs}ms] Playing >`, this)
await this.player.base.audio.play()
}
// reset audio volume and gain
this.player.base.audio.volume = 1
this.player.base.processors.gain.set(this.player.state.volume)
const endTime = performance.now()
this._loadMs = endTime - startTime
console.log(`[INSTANCE] Playing >`, this)
} }
pause = async () => { pause = async () => {
@ -68,64 +97,4 @@ export default class TrackInstance {
this.player.base.audio.play() this.player.base.audio.play()
} }
// resolveManifest = async () => {
// if (typeof this.manifest === "string") {
// this.manifest = {
// src: this.manifest,
// }
// }
// this.manifest = new TrackManifest(this.manifest, {
// serviceProviders: this.player.serviceProviders,
// })
// if (this.manifest.service) {
// if (!this.player.serviceProviders.has(this.manifest.service)) {
// throw new Error(
// `Service ${this.manifest.service} is not supported`,
// )
// }
// // try to resolve source file
// if (!this.manifest.source) {
// console.log("Resolving manifest cause no source defined")
// this.manifest = await this.player.serviceProviders.resolve(
// this.manifest.service,
// this.manifest,
// )
// console.log("Manifest resolved", this.manifest)
// }
// }
// if (!this.manifest.source) {
// throw new Error("Manifest `source` is required")
// }
// // set empty metadata if not provided
// if (!this.manifest.metadata) {
// this.manifest.metadata = {}
// }
// // auto name if a title is not provided
// if (!this.manifest.metadata.title) {
// this.manifest.metadata.title = this.manifest.source.split("/").pop()
// }
// // process overrides
// const override = await this.manifest.serviceOperations.fetchOverride()
// if (override) {
// console.log(
// `Override found for track ${this.manifest._id}`,
// override,
// )
// this.manifest.overrides = override
// }
// return this.manifest
// }
} }

View File

@ -97,18 +97,6 @@ export default class TrackManifest {
} }
serviceOperations = { serviceOperations = {
fetchLikeStatus: async () => {
if (!this._id) {
return null
}
return await this.ctx.serviceProviders.operation(
"isItemFavourited",
this.service,
this,
"track",
)
},
fetchLyrics: async () => { fetchLyrics: async () => {
if (!this._id) { if (!this._id) {
return null return null
@ -140,19 +128,31 @@ export default class TrackManifest {
this, this,
) )
}, },
toggleItemFavourite: async (to) => { toggleItemFavorite: async (to) => {
if (!this._id) { if (!this._id) {
return null return null
} }
return await this.ctx.serviceProviders.operation( return await this.ctx.serviceProviders.operation(
"toggleItemFavourite", "toggleItemFavorite",
this.service, this.service,
this, this,
"track", "tracks",
to, to,
) )
}, },
isItemFavorited: async () => {
if (!this._id) {
return null
}
return await this.ctx.serviceProviders.operation(
"isItemFavorited",
this.service,
this,
"tracks",
)
},
} }
toSeriableObject = () => { toSeriableObject = () => {

View File

@ -119,6 +119,7 @@ export default class Player extends Core {
return this.queue.currentItem return this.queue.currentItem
} }
// TODO: Improve performance for large playlists
async start(manifest, { time, startIndex = 0, radioId } = {}) { async start(manifest, { time, startIndex = 0, radioId } = {}) {
this.ui.attachPlayerComponent() this.ui.attachPlayerComponent()
@ -150,6 +151,10 @@ export default class Player extends Core {
playlist = await this.serviceProviders.resolveMany(playlist) playlist = await this.serviceProviders.resolveMany(playlist)
} }
if (playlist.some((item) => !item.source)) {
playlist = await this.serviceProviders.resolveMany(playlist)
}
for await (let [index, _manifest] of playlist.entries()) { for await (let [index, _manifest] of playlist.entries()) {
let instance = new TrackInstance(_manifest, this) let instance = new TrackInstance(_manifest, this)
@ -176,12 +181,12 @@ export default class Player extends Core {
// similar to player.start, but add to the queue // similar to player.start, but add to the queue
// if next is true, it will add to the queue to the top of the queue // if next is true, it will add to the queue to the top of the queue
async addToQueue(manifest, { next = false }) { async addToQueue(manifest, { next = false } = {}) {
if (typeof manifest === "string") { if (typeof manifest === "string") {
manifest = await this.serviceProviders.resolve(manifest) manifest = await this.serviceProviders.resolve(manifest)
} }
let instance = await this.createInstance(manifest) let instance = new TrackInstance(manifest, this)
this.queue.add(instance, next === true ? "start" : "end") this.queue.add(instance, next === true ? "start" : "end")

View File

@ -32,11 +32,11 @@ export default class ComtyMusicServiceInterface {
return {} return {}
} }
isItemFavourited = async (manifest, itemType) => { isItemFavorited = async (manifest, itemType) => {
return await MusicModel.isItemFavourited(itemType, manifest._id) return await MusicModel.isItemFavorited(itemType, manifest._id)
} }
toggleItemFavourite = async (manifest, itemType, to) => { toggleItemFavorite = async (manifest, itemType, to) => {
return await MusicModel.toggleItemFavourite(itemType, manifest._id, to) return await MusicModel.toggleItemFavorite(itemType, manifest._id, to)
} }
} }

View File

@ -5,7 +5,11 @@ import { motion, AnimatePresence } from "motion/react"
import { Icons, createIconRender } from "@components/Icons" import { Icons, createIconRender } from "@components/Icons"
import { WithPlayerContext, Context } from "@contexts/WithPlayerContext" import {
WithPlayerContext,
Context,
usePlayerStateContext,
} from "@contexts/WithPlayerContext"
import { import {
QuickNavMenuItems, QuickNavMenuItems,
@ -36,33 +40,66 @@ const tourSteps = [
const openPlayerView = () => { const openPlayerView = () => {
app.layout.draggable.open("player", PlayerView) app.layout.draggable.open("player", PlayerView)
} }
const openCreator = () => { const openCreator = () => {
app.layout.draggable.open("creator", CreatorView) app.layout.draggable.open("creator", CreatorView)
} }
const PlayerButton = (props) => { const PlayerButton = (props) => {
const [currentManifest, setCurrentManifest] = React.useState(null)
const [coverAnalyzed, setCoverAnalyzed] = React.useState(null)
const [player] = usePlayerStateContext((state) => {
setCurrentManifest((prev) => {
if (!state.track_manifest) {
return null
}
if (prev?._id !== state.track_manifest?._id) {
return state.track_manifest
}
return prev
})
})
React.useEffect(() => { React.useEffect(() => {
if (currentManifest) {
const track = app.cores.player.track()
if (!app.layout.draggable.exists("player")) {
openPlayerView() openPlayerView()
}, []) }
if (track.manifest?.analyzeCoverColor) {
track.manifest
.analyzeCoverColor()
.then((analysis) => {
setCoverAnalyzed(analysis)
})
.catch((err) => {
console.error(err)
})
}
}
}, [currentManifest])
const isPlaying = player?.playback_status === "playing" ?? false
return ( return (
<div <div
className={classnames("player_btn", { className={classnames("player_btn", {
bounce: props.playback === "playing", bounce: isPlaying,
})} })}
style={{ style={{
"--average-color": props.colorAnalysis?.rgba, "--average-color": coverAnalyzed?.rgba,
"--color": props.colorAnalysis?.isDark "--color": coverAnalyzed?.isDark
? "var(--text-color-white)" ? "var(--text-color-white)"
: "var(--text-color-black)", : "var(--text-color-black)",
}} }}
onClick={openPlayerView} onClick={openPlayerView}
> >
{props.playback === "playing" ? ( {isPlaying ? <Icons.MdMusicNote /> : <Icons.MdPause />}
<Icons.MdMusicNote />
) : (
<Icons.MdPause />
)}
</div> </div>
) )
} }
@ -385,18 +422,7 @@ export class BottomBar extends React.Component {
{this.context.track_manifest && ( {this.context.track_manifest && (
<div className="item"> <div className="item">
<PlayerButton <PlayerButton />
manifest={
this.context.track_manifest
}
playback={
this.context.playback_status
}
colorAnalysis={
this.context.track_manifest
?.cover_analysis
}
/>
</div> </div>
)} )}

View File

@ -13,6 +13,7 @@ export class DraggableDrawerController extends React.Component {
open: this.open, open: this.open,
destroy: this.destroy, destroy: this.destroy,
actions: this.actions, actions: this.actions,
exists: this.exists,
} }
this.state = { this.state = {
@ -40,7 +41,7 @@ export class DraggableDrawerController extends React.Component {
const win = this.open("actions-menu", ActionsMenu, { const win = this.open("actions-menu", ActionsMenu, {
componentProps: { componentProps: {
...data, ...data,
} },
}) })
return win return win
@ -50,7 +51,7 @@ export class DraggableDrawerController extends React.Component {
let drawerObj = { let drawerObj = {
id: id, id: id,
render: render, render: render,
options: options options: options,
} }
const win = app.cores.window_mng.render( const win = app.cores.window_mng.render(
@ -59,12 +60,10 @@ export class DraggableDrawerController extends React.Component {
options={options} options={options}
onClosed={() => this.handleDrawerOnClosed(drawerObj)} onClosed={() => this.handleDrawerOnClosed(drawerObj)}
> >
{ {React.createElement(render, {
React.createElement(render, {
...options.componentProps, ...options.componentProps,
}) })}
} </DraggableDrawer>,
</DraggableDrawer>
) )
drawerObj.winId = win.id drawerObj.winId = win.id
@ -80,7 +79,9 @@ export class DraggableDrawerController extends React.Component {
} }
destroy = (id) => { destroy = (id) => {
const drawerIndex = this.state.drawers.findIndex((drawer) => drawer.id === id) const drawerIndex = this.state.drawers.findIndex(
(drawer) => drawer.id === id,
)
if (drawerIndex === -1) { if (drawerIndex === -1) {
console.error(`Drawer [${id}] not found`) console.error(`Drawer [${id}] not found`)
@ -103,6 +104,10 @@ export class DraggableDrawerController extends React.Component {
app.cores.window_mng.close(drawer.id ?? id) app.cores.window_mng.close(drawer.id ?? id)
} }
exists = (id) => {
return this.state.drawers.findIndex((drawer) => drawer.id === id) !== -1
}
/** /**
* This lifecycle method is called after the component has been updated. * This lifecycle method is called after the component has been updated.
* It will toggle the root scale effect based on the amount of drawers. * It will toggle the root scale effect based on the amount of drawers.
@ -141,14 +146,10 @@ export const DraggableDrawer = (props) => {
return to return to
} }
return <Drawer.Root return (
open={isOpen} <Drawer.Root open={isOpen} onOpenChange={handleOnOpenChanged}>
onOpenChange={handleOnOpenChanged}
>
<Drawer.Portal> <Drawer.Portal>
<Drawer.Overlay <Drawer.Overlay className="app-drawer-overlay" />
className="app-drawer-overlay"
/>
<Drawer.Content <Drawer.Content
className="app-drawer-content" className="app-drawer-content"
@ -156,22 +157,17 @@ export const DraggableDrawer = (props) => {
setIsOpen(false) setIsOpen(false)
}} }}
> >
<Drawer.Handle <Drawer.Handle className="app-drawer-handle" />
className="app-drawer-handle"
/>
<Drawer.Title <Drawer.Title className="app-drawer-title">
className="app-drawer-title"
>
{props.options?.title ?? "Drawer Title"} {props.options?.title ?? "Drawer Title"}
</Drawer.Title> </Drawer.Title>
{ {React.cloneElement(props.children, {
React.cloneElement(props.children, {
close: () => setIsOpen(false), close: () => setIsOpen(false),
}) })}
}
</Drawer.Content> </Drawer.Content>
</Drawer.Portal> </Drawer.Portal>
</Drawer.Root> </Drawer.Root>
)
} }

View File

@ -0,0 +1,49 @@
import React from "react"
const defaultURL = "ws://localhost:19236"
function useLoquiWs() {
const [socket, setSocket] = React.useState(null)
function create() {
const s = new WebSocket(defaultURL)
s.addEventListener("open", (event) => {
console.log("WebSocket connection opened")
})
s.addEventListener("close", (event) => {
console.log("WebSocket connection closed")
})
s.addEventListener("error", (event) => {
console.log("WebSocket error", event)
})
s.addEventListener("message", (event) => {
console.log("Message from server ", event.data)
})
setSocket(s)
}
React.useEffect(() => {
create()
return () => {
if (socket) {
socket.close()
}
}
}, [])
return [socket]
}
const Loqui = () => {
const [socket] = useLoquiWs()
return <div>{defaultURL}</div>
}
export default Loqui

View File

@ -12,7 +12,7 @@ export default (props) => {
const user_id = props.state.user._id const user_id = props.state.user._id
const [L_Releases, R_Releases, E_Releases, M_Releases] = const [L_Releases, R_Releases, E_Releases, M_Releases] =
app.cores.api.useRequest(MusicModel.getReleases, { app.cores.api.useRequest(MusicModel.getAllReleases, {
user_id: user_id, user_id: user_id,
}) })

View File

@ -7,6 +7,7 @@ import useHideOnMouseStop from "@hooks/useHideOnMouseStop"
import { Icons } from "@components/Icons" import { Icons } from "@components/Icons"
import Controls from "@components/Player/Controls" import Controls from "@components/Player/Controls"
import SeekBar from "@components/Player/SeekBar"
import LiveInfo from "@components/Player/LiveInfo" import LiveInfo from "@components/Player/LiveInfo"
import { usePlayerStateContext } from "@contexts/WithPlayerContext" import { usePlayerStateContext } from "@contexts/WithPlayerContext"
@ -130,9 +131,11 @@ const PlayerController = React.forwardRef((props, ref) => {
)} )}
</div> </div>
{playerState.track_manifest?.artist && (
<div className="lyrics-player-controller-info-details"> <div className="lyrics-player-controller-info-details">
<span>{playerState.track_manifest?.artistStr}</span> <span>{playerState.track_manifest?.artist}</span>
</div> </div>
)}
{playerState.live && ( {playerState.live && (
<LiveInfo radioId={playerState.radioId} /> <LiveInfo radioId={playerState.radioId} />
@ -141,40 +144,7 @@ const PlayerController = React.forwardRef((props, ref) => {
<Controls streamMode={playerState.live} /> <Controls streamMode={playerState.live} />
{!playerState.live && ( {!playerState.live && <SeekBar />}
<div className="lyrics-player-controller-progress-wrapper">
<div
className="lyrics-player-controller-progress"
onMouseDown={(e) => {
setDraggingTime(true)
}}
onMouseUp={(e) => {
const rect =
e.currentTarget.getBoundingClientRect()
const seekTime =
(trackDuration * (e.clientX - rect.left)) /
rect.width
onDragEnd(seekTime)
}}
onMouseMove={(e) => {
const rect =
e.currentTarget.getBoundingClientRect()
const atWidth =
((e.clientX - rect.left) / rect.width) * 100
setCurrentDragWidth(atWidth)
}}
>
<div
className="lyrics-player-controller-progress-bar"
style={{
width: `${draggingTime ? currentDragWidth : (currentTime / trackDuration) * 100}%`,
}}
/>
</div>
</div>
)}
<div className="lyrics-player-controller-tags"> <div className="lyrics-player-controller-tags">
{playerState.track_manifest?.metadata?.lossless && ( {playerState.track_manifest?.metadata?.lossless && (

View File

@ -1,65 +1,74 @@
import React from "react" import React from "react"
import HLS from "hls.js" import HLS from "hls.js"
import classnames from "classnames" import classnames from "classnames"
import { usePlayerStateContext } from "@contexts/WithPlayerContext" import { usePlayerStateContext } from "@contexts/WithPlayerContext"
const maxLatencyInMs = 55 const maxLatencyInMs = 55
const LyricsVideo = React.forwardRef((props, videoRef) => { const LyricsVideo = React.forwardRef((props, videoRef) => {
const [playerState] = usePlayerStateContext() const [playerState] = usePlayerStateContext()
const { lyrics } = props const { lyrics } = props
const [initialLoading, setInitialLoading] = React.useState(true) const [initialLoading, setInitialLoading] = React.useState(true)
const [syncInterval, setSyncInterval] = React.useState(null)
const [syncingVideo, setSyncingVideo] = React.useState(false) const [syncingVideo, setSyncingVideo] = React.useState(false)
const [currentVideoLatency, setCurrentVideoLatency] = React.useState(0) const [currentVideoLatency, setCurrentVideoLatency] = React.useState(0)
const isDebugEnabled = React.useMemo(
() => app.cores.settings.is("_debug", true),
[],
)
const hls = React.useRef(new HLS()) const hls = React.useRef(new HLS())
const syncIntervalRef = React.useRef(null)
async function seekVideoToSyncAudio() { const stopSyncInterval = React.useCallback(() => {
if (!lyrics) { setSyncingVideo(false)
return null if (syncIntervalRef.current) {
clearInterval(syncIntervalRef.current)
syncIntervalRef.current = null
} }
}, [setSyncingVideo])
const seekVideoToSyncAudio = React.useCallback(async () => {
if ( if (
!lyrics ||
!lyrics.video_source || !lyrics.video_source ||
typeof lyrics.sync_audio_at_ms === "undefined" typeof lyrics.sync_audio_at_ms === "undefined" ||
!videoRef.current
) { ) {
return null return null
} }
const currentTrackTime = app.cores.player.controls.seek() const currentTrackTime = window.app.cores.player.controls.seek()
setSyncingVideo(true) setSyncingVideo(true)
let newTime = let newTime =
currentTrackTime + lyrics.sync_audio_at_ms / 1000 + 150 / 1000 currentTrackTime + lyrics.sync_audio_at_ms / 1000 + 150 / 1000
// dec some ms to ensure the video seeks correctly
newTime -= 5 / 1000 newTime -= 5 / 1000
videoRef.current.currentTime = newTime videoRef.current.currentTime = newTime
} }, [lyrics, videoRef, setSyncingVideo])
async function syncPlayback() { const syncPlayback = React.useCallback(
// if something is wrong, stop syncing async (override = false) => {
if ( if (
videoRef.current === null || !videoRef.current ||
!lyrics || !lyrics ||
!lyrics.video_source || !lyrics.video_source ||
typeof lyrics.sync_audio_at_ms === "undefined" || typeof lyrics.sync_audio_at_ms === "undefined"
playerState.playback_status !== "playing"
) { ) {
return stopSyncInterval() stopSyncInterval()
return
} }
const currentTrackTime = app.cores.player.controls.seek() if (playerState.playback_status !== "playing" && !override) {
stopSyncInterval()
return
}
const currentTrackTime = window.app.cores.player.controls.seek()
const currentVideoTime = const currentVideoTime =
videoRef.current.currentTime - lyrics.sync_audio_at_ms / 1000 videoRef.current.currentTime - lyrics.sync_audio_at_ms / 1000
//console.log(`Current track time: ${currentTrackTime}, current video time: ${currentVideoTime}`)
const maxOffset = maxLatencyInMs / 1000 const maxOffset = maxLatencyInMs / 1000
const currentVideoTimeDiff = Math.abs( const currentVideoTimeDiff = Math.abs(
currentVideoTime - currentTrackTime, currentVideoTime - currentTrackTime,
@ -68,112 +77,157 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
setCurrentVideoLatency(currentVideoTimeDiff) setCurrentVideoLatency(currentVideoTimeDiff)
if (syncingVideo === true) { if (syncingVideo === true) {
return false return
} }
if (currentVideoTimeDiff > maxOffset) { if (currentVideoTimeDiff > maxOffset) {
seekVideoToSyncAudio() seekVideoToSyncAudio()
} }
} },
[
function startSyncInterval() { videoRef,
setSyncInterval(setInterval(syncPlayback, 300)) lyrics,
} playerState.playback_status,
setCurrentVideoLatency,
function stopSyncInterval() { syncingVideo,
setSyncingVideo(false) seekVideoToSyncAudio,
setSyncInterval(null) stopSyncInterval,
clearInterval(syncInterval) ],
}
//* handle when player is loading
React.useEffect(() => {
if (
lyrics?.video_source &&
playerState.loading === true &&
playerState.playback_status === "playing"
) {
videoRef.current.pause()
}
if (
lyrics?.video_source &&
playerState.loading === false &&
playerState.playback_status === "playing"
) {
videoRef.current.play()
}
}, [playerState.loading])
//* Handle when playback status change
React.useEffect(() => {
if (initialLoading === false) {
console.log(
`VIDEO:: Playback status changed to ${playerState.playback_status}`,
) )
if (lyrics && lyrics.video_source) { const startSyncInterval = React.useCallback(() => {
if (playerState.playback_status === "playing") { if (syncIntervalRef.current) {
videoRef.current.play() clearInterval(syncIntervalRef.current)
startSyncInterval()
} else {
videoRef.current.pause()
stopSyncInterval()
} }
} syncIntervalRef.current = setInterval(syncPlayback, 300)
} }, [syncPlayback])
}, [playerState.playback_status])
//* Handle when lyrics object change
React.useEffect(() => { React.useEffect(() => {
setCurrentVideoLatency(0) setCurrentVideoLatency(0)
stopSyncInterval() const videoElement = videoRef.current
if (!videoElement) return
if (lyrics) { if (lyrics && lyrics.video_source) {
if (lyrics.video_source) { console.log("VIDEO:: Loading video source >", lyrics.video_source)
console.log("Loading video source >", lyrics.video_source)
if (
hls.current.media === videoElement &&
(lyrics.video_source.endsWith(".mp4") || !lyrics.video_source)
) {
hls.current.stopLoad()
}
if (lyrics.video_source.endsWith(".mp4")) { if (lyrics.video_source.endsWith(".mp4")) {
videoRef.current.src = lyrics.video_source if (hls.current.media === videoElement) {
hls.current.detachMedia()
}
videoElement.src = lyrics.video_source
} else { } else {
if (HLS.isSupported()) {
if (hls.current.media !== videoElement) {
hls.current.attachMedia(videoElement)
}
hls.current.loadSource(lyrics.video_source) hls.current.loadSource(lyrics.video_source)
} else if (
videoElement.canPlayType("application/vnd.apple.mpegurl")
) {
videoElement.src = lyrics.video_source
}
} }
if (typeof lyrics.sync_audio_at_ms !== "undefined") { if (typeof lyrics.sync_audio_at_ms !== "undefined") {
videoRef.current.loop = false videoElement.loop = false
videoRef.current.currentTime = syncPlayback(true)
lyrics.sync_audio_at_ms / 1000
startSyncInterval()
} else { } else {
videoRef.current.loop = true videoElement.loop = true
videoRef.current.currentTime = 0 videoElement.currentTime = 0
} }
} else {
if (playerState.playback_status === "playing") { videoElement.src = ""
videoRef.current.play() if (hls.current) {
hls.current.stopLoad()
if (hls.current.media) {
hls.current.detachMedia()
} }
} }
} }
setInitialLoading(false) setInitialLoading(false)
}, [lyrics]) }, [lyrics, videoRef, hls, setCurrentVideoLatency, setInitialLoading])
React.useEffect(() => { React.useEffect(() => {
videoRef.current.addEventListener("seeked", (event) => { stopSyncInterval()
setSyncingVideo(false)
})
hls.current.attachMedia(videoRef.current) if (initialLoading || !videoRef.current) {
return
}
const videoElement = videoRef.current
const canPlayVideo = lyrics && lyrics.video_source
if (!canPlayVideo) {
videoElement.pause()
return
}
if (
playerState.loading === true &&
playerState.playback_status === "playing"
) {
videoElement.pause()
return
}
const shouldSync = typeof lyrics.sync_audio_at_ms !== "undefined"
if (playerState.playback_status === "playing") {
videoElement
.play()
.catch((error) =>
console.error("VIDEO:: Error playing video:", error),
)
if (shouldSync) {
startSyncInterval()
}
} else {
videoElement.pause()
}
}, [
lyrics,
playerState.playback_status,
playerState.loading,
initialLoading,
videoRef,
startSyncInterval,
stopSyncInterval,
])
React.useEffect(() => {
const videoElement = videoRef.current
const hlsInstance = hls.current
const handleSeeked = () => {
setSyncingVideo(false)
}
if (videoElement) {
videoElement.addEventListener("seeked", handleSeeked)
}
return () => { return () => {
stopSyncInterval() stopSyncInterval()
if (videoElement) {
videoElement.removeEventListener("seeked", handleSeeked)
} }
}, []) if (hlsInstance) {
hlsInstance.destroy()
}
}
}, [videoRef, hls, stopSyncInterval, setSyncingVideo])
return ( return (
<> <>
{props.lyrics?.sync_audio_at && ( {isDebugEnabled && (
<div className={classnames("videoDebugOverlay")}> <div className={classnames("videoDebugOverlay")}>
<div> <div>
<p>Maximun latency</p> <p>Maximun latency</p>
@ -195,6 +249,7 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
controls={false} controls={false}
muted muted
preload="auto" preload="auto"
playsInline
/> />
</> </>
) )

View File

@ -24,8 +24,8 @@
height: 100%; height: 100%;
background: background:
linear-gradient(0deg, rgba(var(--dominant-color), 1), rgba(0, 0, 0, 0)), linear-gradient(0deg, rgb(var(--dominant-color)), rgba(0, 0, 0, 0)),
url("data:image/svg+xml,%3Csvg viewBox='0 0 284 284' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='7.59' numOctaves='5' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E"); url("data:image/svg+xml,%3Csvg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0' numOctaves='10' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
} }
.lyrics-background-wrapper { .lyrics-background-wrapper {
@ -152,7 +152,7 @@
width: 300px; width: 300px;
padding: 30px; padding: 20px;
border-radius: 12px; border-radius: 12px;
@ -183,12 +183,12 @@
width: 100%; width: 100%;
gap: 10px; gap: 5px;
transition: all 150ms ease-in-out; transition: all 150ms ease-in-out;
.lyrics-player-controller-info-title { .lyrics-player-controller-info-title {
font-size: 1.5rem; font-size: 1.4rem;
font-weight: 600; font-weight: 600;
width: 100%; width: 100%;
@ -224,7 +224,7 @@
gap: 7px; gap: 7px;
font-size: 0.6rem; font-size: 0.7rem;
font-weight: 400; font-weight: 400;
@ -243,43 +243,6 @@
transition: all 150ms ease-in-out; transition: all 150ms ease-in-out;
} }
.lyrics-player-controller-progress-wrapper {
width: 100%;
.lyrics-player-controller-progress {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
margin: auto;
transition: all 150ms ease-in-out;
border-radius: 12px;
background-color: rgba(var(--background-color-accent-values), 0.8);
&:hover {
.lyrics-player-controller-progress-bar {
height: 10px;
}
}
.lyrics-player-controller-progress-bar {
height: 5px;
background-color: white;
border-radius: 12px;
transition: all 150ms ease-in-out;
}
}
}
.lyrics-player-controller-tags { .lyrics-player-controller-tags {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -327,3 +290,9 @@
} }
} }
} }
.lyrics-text .line .word.current-word {
/* Styling for the currently active word */
font-weight: bold;
color: yellow; /* Example highlight */
}

View File

@ -23,13 +23,11 @@ const ChatPage = (props) => {
const [L_User, R_User, E_User, M_User] = app.cores.api.useRequest( const [L_User, R_User, E_User, M_User] = app.cores.api.useRequest(
UserService.data, UserService.data,
{ {
user_id: to_user_id user_id: to_user_id,
} },
)
const [L_History, R_History, E_History, M_History] = app.cores.api.useRequest(
ChatsService.getChatHistory,
to_user_id
) )
const [L_History, R_History, E_History, M_History] =
app.cores.api.useRequest(ChatsService.getChatHistory, to_user_id)
const { const {
sendMessage, sendMessage,
@ -61,21 +59,22 @@ const ChatPage = (props) => {
if (value === "") { if (value === "") {
emitTypingEvent(false) emitTypingEvent(false)
} { }
{
emitTypingEvent(true) emitTypingEvent(true)
} }
} }
React.useEffect(() => { // React.useEffect(() => {
if (R_History) { // if (R_History) {
setMessages(R_History.list) // setMessages(R_History.list)
// scroll to bottom // // scroll to bottom
messagesRef.current?.scrollTo({ // messagesRef.current?.scrollTo({
top: messagesRef.current.scrollHeight, // top: messagesRef.current.scrollHeight,
behavior: "smooth", // behavior: "smooth",
}) // })
} // }
}, [R_History]) // }, [R_History])
React.useEffect(() => { React.useEffect(() => {
if (isOnBottomView === true) { if (isOnBottomView === true) {
@ -86,73 +85,54 @@ const ChatPage = (props) => {
}, [isOnBottomView]) }, [isOnBottomView])
if (E_History) { if (E_History) {
return <antd.Result return (
<antd.Result
status="warning" status="warning"
title="Error" title="Error"
subTitle={E_History.message} subTitle={E_History.message}
/> />
)
} }
if (L_History) { if (L_History) {
return <antd.Skeleton active /> return <antd.Skeleton active />
} }
return <div return (
className="chat-page" <div className="chat-page">
>
<div className="chat-page-header"> <div className="chat-page-header">
<UserPreview <UserPreview user={R_User} />
user={R_User}
/>
</div> </div>
<div <div
className={classnames( className={classnames("chat-page-messages", {
"chat-page-messages", ["empty"]: messages.length === 0,
{ })}
["empty"]: messages.length === 0
}
)}
ref={messagesRef} ref={messagesRef}
> >
{ {messages.length === 0 && <antd.Empty />}
messages.length === 0 && <antd.Empty />
}
{ {messages.map((line, index) => {
messages.map((line, index) => { return (
return <div <div
key={index} key={index}
className={classnames( className={classnames("chat-page-line-wrapper", {
"chat-page-line-wrapper", ["self"]: line.user._id === app.userData._id,
{ })}
["self"]: line.user._id === app.userData._id
}
)}
> >
<div className="chat-page-line"> <div className="chat-page-line">
<div <div className="chat-page-line-avatar">
className="chat-page-line-avatar" <img src={line.user.avatar} />
> <span>{line.user.username}</span>
<img
src={line.user.avatar}
/>
<span>
{line.user.username}
</span>
</div> </div>
<div <div className="chat-page-line-text">
className="chat-page-line-text" <p>{line.content}</p>
>
<p>
{line.content}
</p>
</div> </div>
</div> </div>
</div> </div>
}) )
} })}
</div> </div>
<div className="chat-page-input-wrapper"> <div className="chat-page-input-wrapper">
@ -173,13 +153,14 @@ const ChatPage = (props) => {
/> />
</div> </div>
{ {isRemoteTyping && R_User && (
isRemoteTyping && R_User && <div className="chat-page-remote-typing"> <div className="chat-page-remote-typing">
<span>{R_User.username} is typing...</span> <span>{R_User.username} is typing...</span>
</div> </div>
} )}
</div> </div>
</div> </div>
)
} }
export default ChatPage export default ChatPage

View File

@ -1,36 +0,0 @@
import React from "react"
import * as antd from "antd"
import PlaylistView from "@components/Music/PlaylistView"
import MusicService from "@models/music"
import "./index.less"
const Item = (props) => {
const { type, id } = props.params
const [loading, result, error, makeRequest] = app.cores.api.useRequest(MusicService.getReleaseData, id)
if (error) {
return <antd.Result
status="warning"
title="Error"
subTitle={error.message}
/>
}
if (loading) {
return <antd.Skeleton active />
}
return <div className="track-page">
<PlaylistView
playlist={result}
centered={app.isMobile}
hasMore={false}
/>
</div>
}
export default Item

View File

@ -3,6 +3,8 @@ import React from "react"
import { Icons } from "@components/Icons" import { Icons } from "@components/Icons"
import { PagePanelWithNavMenu } from "@components/PagePanels" import { PagePanelWithNavMenu } from "@components/PagePanels"
import useCenteredContainer from "@hooks/useCenteredContainer"
import Tabs from "./tabs" import Tabs from "./tabs"
const NavMenuHeader = ( const NavMenuHeader = (
@ -13,6 +15,8 @@ const NavMenuHeader = (
) )
export default () => { export default () => {
useCenteredContainer(false)
return ( return (
<PagePanelWithNavMenu <PagePanelWithNavMenu
tabs={Tabs} tabs={Tabs}

View File

@ -0,0 +1,41 @@
import React from "react"
import * as antd from "antd"
import PlaylistView from "@components/Music/PlaylistView"
import MusicService from "@models/music"
import "./index.less"
const ListView = (props) => {
const { type, id } = props.params
const [loading, result, error, makeRequest] = app.cores.api.useRequest(
MusicService.getReleaseData,
id,
)
if (error) {
return (
<antd.Result
status="warning"
title="Error"
subTitle={error.message}
/>
)
}
if (loading) {
return <antd.Skeleton active />
}
return (
<PlaylistView
playlist={result}
centered={app.isMobile}
hasMore={false}
/>
)
}
export default ListView

View File

@ -0,0 +1,129 @@
import React from "react"
import classnames from "classnames"
import * as antd from "antd"
import { Translation } from "react-i18next"
import { Icons } from "@components/Icons"
import Playlist from "@components/Music/Playlist"
import Track from "@components/Music/Track"
import Radio from "@components/Music/Radio"
import "./index.less"
const FeedItems = (props) => {
const maxItems = props.itemsPerPage ?? 10
const [page, setPage] = React.useState(0)
const [ended, setEnded] = React.useState(false)
const [loading, result, error, makeRequest] = app.cores.api.useRequest(
props.fetchMethod,
{
limit: maxItems,
page: page,
},
)
const handlePageChange = (newPage) => {
// check if newPage is NaN
if (newPage !== newPage) {
return false
}
if (typeof makeRequest === "function") {
makeRequest({
limit: maxItems,
page: newPage,
})
}
return newPage
}
const onClickPrev = () => {
if (page === 0) {
return
}
setPage((currentPage) => handlePageChange(currentPage - 1))
}
const onClickNext = () => {
if (ended) {
return
}
setPage((currentPage) => handlePageChange(currentPage + 1))
}
React.useEffect(() => {
if (result) {
if (typeof result.has_more !== "undefined") {
setEnded(!result.has_more)
} else {
setEnded(result.items.length < maxItems)
}
}
}, [result, maxItems])
if (error) {
console.error(error)
return (
<div className="music-feed-items">
<antd.Result
status="warning"
title="Failed to load"
subTitle="We are sorry, but we could not load this requests. Please try again later."
/>
</div>
)
}
return (
<div className={classnames("music-feed-items", props.type)}>
<div className="music-feed-items-header">
<h1>
{props.headerIcon}
<Translation>{(t) => t(props.headerTitle)}</Translation>
</h1>
{!props.disablePagination && (
<div className="music-feed-items-actions">
<antd.Button
icon={<Icons.MdChevronLeft />}
onClick={onClickPrev}
disabled={page === 0 || loading}
/>
<antd.Button
icon={<Icons.MdChevronRight />}
onClick={onClickNext}
disabled={ended || loading}
/>
</div>
)}
</div>
<div className="music-feed-items-content">
{loading && <antd.Skeleton active />}
{!loading &&
result?.items?.map((item, index) => {
if (props.type === "radios") {
return <Radio row key={index} item={item} />
}
if (props.type === "tracks") {
return <Track key={index} track={item} />
}
return <Playlist row key={index} playlist={item} />
})}
</div>
</div>
)
}
export default FeedItems

View File

@ -0,0 +1,63 @@
.music-feed-items {
display: flex;
flex-direction: column;
width: 100%;
gap: 10px;
&.tracks {
.music-feed-items-content {
display: flex;
flex-direction: column;
gap: 10px;
}
}
&.playlists {
.music-feed-items-content {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 30px;
}
}
&.radios {
.music-feed-items-content {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 30px;
}
}
.music-feed-items-header {
display: flex;
flex-direction: row;
align-items: center;
/* h1 {
font-size: 1.5rem;
margin: 0;
} */
.music-feed-items-actions {
display: flex;
flex-direction: row;
gap: 10px;
align-self: center;
margin-left: auto;
}
}
.music-feed-items-content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(4, 1fr));
gap: 10px;
}
}

View File

@ -3,7 +3,7 @@ import React from "react"
import Searcher from "@components/Searcher" import Searcher from "@components/Searcher"
import SearchModel from "@models/search" import SearchModel from "@models/search"
const MusicNavbar = (props) => { const MusicNavbar = React.forwardRef((props, ref) => {
return ( return (
<div className="music_navbar"> <div className="music_navbar">
<Searcher <Searcher
@ -17,6 +17,6 @@ const MusicNavbar = (props) => {
/> />
</div> </div>
) )
} })
export default MusicNavbar export default MusicNavbar

View File

@ -10,7 +10,8 @@ import "./index.less"
const RecentlyPlayedItem = (props) => { const RecentlyPlayedItem = (props) => {
const { track } = props const { track } = props
return <div return (
<div
className="recently_played-item" className="recently_played-item"
onClick={() => app.cores.player.start(track._id)} onClick={() => app.cores.player.start(track._id)}
> >
@ -19,50 +20,53 @@ const RecentlyPlayedItem = (props) => {
</div> </div>
<div className="recently_played-item-cover"> <div className="recently_played-item-cover">
<Image <Image src={track.cover} />
src={track.cover}
/>
</div> </div>
<div className="recently_played-item-content"> <div className="recently_played-item-content">
<h3>{track.title}</h3> <h3>{track.title}</h3>
</div> </div>
</div> </div>
)
} }
const RecentlyPlayedList = (props) => { const RecentlyPlayedList = (props) => {
const [L_Tracks, R_Tracks, E_Tracks, M_Tracks] = app.cores.api.useRequest(MusicModel.getRecentyPlayed, { const [L_Tracks, R_Tracks, E_Tracks, M_Tracks] = app.cores.api.useRequest(
limit: 7 MusicModel.getRecentyPlayed,
}) {
limit: 6,
},
)
if (E_Tracks) { if (E_Tracks) {
return null return null
} }
return <div className="recently_played"> return (
<div className="recently_played">
<div className="recently_played-header"> <div className="recently_played-header">
<h1><Icons.MdHistory /> Recently played</h1> <h1>
<Icons.MdHistory /> Recently played
</h1>
</div> </div>
<div className="recently_played-content"> <div className="recently_played-content">
{ {L_Tracks && <antd.Skeleton active />}
L_Tracks && <antd.Skeleton active />
}
{ {R_Tracks && R_Tracks.lenght === 0 && <antd.Skeleton active />}
!L_Tracks && <div className="recently_played-content-items">
{ {!L_Tracks && (
R_Tracks.map((track, index) => { <div className="recently_played-content-items">
return <RecentlyPlayedItem {R_Tracks.map((track, index) => {
key={index} return (
track={track} <RecentlyPlayedItem key={index} track={track} />
/> )
}) })}
}
</div> </div>
} )}
</div> </div>
</div> </div>
)
} }
export default RecentlyPlayedList export default RecentlyPlayedList

View File

@ -1,129 +0,0 @@
import React from "react"
import * as antd from "antd"
import { Translation } from "react-i18next"
import { Icons } from "@components/Icons"
import Playlist from "@components/Music/Playlist"
import "./index.less"
const ReleasesList = (props) => {
const hopNumber = props.hopsPerPage ?? 9
const [offset, setOffset] = React.useState(0)
const [ended, setEnded] = React.useState(false)
const [loading, result, error, makeRequest] = app.cores.api.useRequest(
props.fetchMethod,
{
limit: hopNumber,
trim: offset,
},
)
const onClickPrev = () => {
if (offset === 0) {
return
}
setOffset((value) => {
const newOffset = value - hopNumber
// check if newOffset is NaN
if (newOffset !== newOffset) {
return false
}
if (typeof makeRequest === "function") {
makeRequest({
trim: newOffset,
limit: hopNumber,
})
}
return newOffset
})
}
const onClickNext = () => {
if (ended) {
return
}
setOffset((value) => {
const newOffset = value + hopNumber
// check if newOffset is NaN
if (newOffset !== newOffset) {
return false
}
if (typeof makeRequest === "function") {
makeRequest({
trim: newOffset,
limit: hopNumber,
})
}
return newOffset
})
}
React.useEffect(() => {
if (result) {
if (typeof result.has_more !== "undefined") {
setEnded(!result.has_more)
} else {
setEnded(result.items.length < hopNumber)
}
}
}, [result])
if (error) {
console.error(error)
return (
<div className="playlistExplorer_section">
<antd.Result
status="warning"
title="Failed to load"
subTitle="We are sorry, but we could not load this requests. Please try again later."
/>
</div>
)
}
return (
<div className="music-releases-list">
<div className="music-releases-list-header">
<h1>
{props.headerIcon}
<Translation>{(t) => t(props.headerTitle)}</Translation>
</h1>
<div className="music-releases-list-actions">
<antd.Button
icon={<Icons.MdChevronLeft />}
onClick={onClickPrev}
disabled={offset === 0 || loading}
/>
<antd.Button
icon={<Icons.MdChevronRight />}
onClick={onClickNext}
disabled={ended || loading}
/>
</div>
</div>
<div className="music-releases-list-items">
{loading && <antd.Skeleton active />}
{!loading &&
result.items.map((playlist, index) => {
return <Playlist key={index} playlist={playlist} />
})}
</div>
</div>
)
}
export default ReleasesList

View File

@ -1,74 +0,0 @@
@min-item-size: 200px;
.music-releases-list {
display: flex;
flex-direction: column;
overflow-x: visible;
.music-releases-list-header {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 20px;
h1 {
font-size: 1.5rem;
margin: 0;
}
.music-releases-list-actions {
display: flex;
flex-direction: row;
gap: 10px;
align-self: center;
margin-left: auto;
}
}
.music-releases-list-items {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(@min-item-size, 1fr));
gap: 10px;
/* display: grid;
grid-gap: 20px;
grid-template-columns: repeat(2, 1fr);
@media (min-width: 768px) {
grid-template-columns: repeat(3, 1fr);
}
@media (min-width: 1000px) {
grid-template-columns: repeat(4, 1fr);
}
@media (min-width: 1500px) {
grid-template-columns: repeat(7, 1fr);
}
@media (min-width: 1600px) {
grid-template-columns: repeat(7, 1fr);
}
@media (min-width: 1920px) {
grid-template-columns: repeat(9, 1fr);
} */
.playlist {
justify-self: center;
//min-width: 372px !important;
width: unset;
height: unset;
min-width: @min-item-size;
min-height: @min-item-size;
}
}
}

View File

@ -8,33 +8,30 @@ import MusicTrack from "@components/Music/Track"
import Playlist from "@components/Music/Playlist" import Playlist from "@components/Music/Playlist"
const ResultGroupsDecorators = { const ResultGroupsDecorators = {
"playlists": { playlists: {
icon: "MdPlaylistPlay", icon: "MdPlaylistPlay",
label: "Playlists", label: "Playlists",
renderItem: (props) => { renderItem: (props) => {
return <Playlist return <Playlist key={props.key} playlist={props.item} />
key={props.key}
playlist={props.item}
/>
}
}, },
"tracks": { },
tracks: {
icon: "MdMusicNote", icon: "MdMusicNote",
label: "Tracks", label: "Tracks",
renderItem: (props) => { renderItem: (props) => {
return <MusicTrack return (
<MusicTrack
key={props.key} key={props.key}
track={props.item} track={props.item}
onClickPlayBtn={() => app.cores.player.start(props.item)} //onClickPlayBtn={() => app.cores.player.start(props.item)}
onClick={() => app.location.push(`/play/${props.item._id}`)} onClick={() => app.location.push(`/play/${props.item._id}`)}
/> />
} )
} },
},
} }
const SearchResults = ({ const SearchResults = ({ data }) => {
data
}) => {
if (typeof data !== "object") { if (typeof data !== "object") {
return null return null
} }
@ -56,37 +53,38 @@ const SearchResults = ({
}) })
if (groupsKeys.length === 0) { if (groupsKeys.length === 0) {
return <div className="music-explorer_search_results no_results"> return (
<div className="music-explorer_search_results no_results">
<antd.Result <antd.Result
status="info" status="info"
title="No results" title="No results"
subTitle="We are sorry, but we could not find any results for your search." subTitle="We are sorry, but we could not find any results for your search."
/> />
</div> </div>
)
} }
return <div return (
className={classnames( <div
"music-explorer_search_results", className={classnames("music-explorer_search_results", {
{
["one_column"]: groupsKeys.length === 1, ["one_column"]: groupsKeys.length === 1,
} })}
)}
> >
{ {groupsKeys.map((key, index) => {
groupsKeys.map((key, index) => {
const decorator = ResultGroupsDecorators[key] ?? { const decorator = ResultGroupsDecorators[key] ?? {
icon: null, icon: null,
label: key, label: key,
renderItem: () => null renderItem: () => null,
} }
return <div className="music-explorer_search_results_group" key={index}> return (
<div
className="music-explorer_search_results_group"
key={index}
>
<div className="music-explorer_search_results_group_header"> <div className="music-explorer_search_results_group_header">
<h1> <h1>
{ {createIconRender(decorator.icon)}
createIconRender(decorator.icon)
}
<Translation> <Translation>
{(t) => t(decorator.label)} {(t) => t(decorator.label)}
</Translation> </Translation>
@ -94,19 +92,18 @@ const SearchResults = ({
</div> </div>
<div className="music-explorer_search_results_group_list"> <div className="music-explorer_search_results_group_list">
{ {data[key].items.map((item, index) => {
data[key].items.map((item, index) => {
return decorator.renderItem({ return decorator.renderItem({
key: index, key: index,
item item,
}) })
}) })}
}
</div> </div>
</div> </div>
}) )
} })}
</div> </div>
)
} }
export default SearchResults export default SearchResults

View File

@ -1,32 +1,28 @@
import React from "react" import React from "react"
import classnames from "classnames" import classnames from "classnames"
import useCenteredContainer from "@hooks/useCenteredContainer"
import Searcher from "@components/Searcher" import Searcher from "@components/Searcher"
import { Icons } from "@components/Icons" import { Icons } from "@components/Icons"
import FeedModel from "@models/feed"
import SearchModel from "@models/search" import SearchModel from "@models/search"
import MusicModel from "@models/music"
import RadioModel from "@models/radio"
import Navbar from "./components/Navbar" import Navbar from "./components/Navbar"
import RecentlyPlayedList from "./components/RecentlyPlayedList" import RecentlyPlayedList from "./components/RecentlyPlayedList"
import SearchResults from "./components/SearchResults" import SearchResults from "./components/SearchResults"
import ReleasesList from "./components/ReleasesList" import FeedItems from "./components/FeedItems"
import FeaturedPlaylist from "./components/FeaturedPlaylist"
import "./index.less" import "./index.less"
const MusicExploreTab = (props) => { const MusicExploreTab = (props) => {
const [searchResults, setSearchResults] = React.useState(false) const [searchResults, setSearchResults] = React.useState(false)
useCenteredContainer(false)
React.useEffect(() => { React.useEffect(() => {
app.layout.page_panels.attachComponent("music_navbar", Navbar, { app.layout.page_panels.attachComponent("music_navbar", Navbar, {
props: { props: {
setSearchResults: setSearchResults, setSearchResults: setSearchResults,
} },
}) })
return () => { return () => {
@ -36,41 +32,52 @@ const MusicExploreTab = (props) => {
} }
}, []) }, [])
return <div return (
className={classnames( <div className={classnames("music-explore")}>
"musicExplorer", {app.isMobile && (
)} <Searcher
>
{
app.isMobile && <Searcher
useUrlQuery useUrlQuery
renderResults={false} renderResults={false}
model={(keywords, params) => SearchModel.search("music", keywords, params)} model={(keywords, params) =>
SearchModel.search("music", keywords, params)
}
onSearchResult={setSearchResults} onSearchResult={setSearchResults}
onEmpty={() => setSearchResults(false)} onEmpty={() => setSearchResults(false)}
/> />
} )}
{ {searchResults && <SearchResults data={searchResults} />}
searchResults && <SearchResults
data={searchResults} {!searchResults && <RecentlyPlayedList />}
{!searchResults && (
<div className="music-explore-content">
<FeedItems
type="tracks"
headerTitle="All Tracks"
headerIcon={<Icons.MdMusicNote />}
fetchMethod={MusicModel.getAllTracks}
itemsPerPage={6}
/> />
}
{ <FeedItems
!searchResults && <div className="feed_main"> type="playlists"
<FeaturedPlaylist /> headerTitle="All Releases"
headerIcon={<Icons.MdNewspaper />}
fetchMethod={MusicModel.getAllReleases}
/>
<RecentlyPlayedList /> <FeedItems
type="radios"
<ReleasesList headerTitle="Trending Radios"
headerTitle="Explore" headerIcon={<Icons.FiRadio />}
headerIcon={<Icons.MdExplore />} fetchMethod={RadioModel.getTrendings}
fetchMethod={FeedModel.getGlobalMusicFeed} disablePagination
/> />
</div> </div>
} )}
</div> </div>
)
} }
export default MusicExploreTab export default MusicExploreTab

View File

@ -1,108 +1,14 @@
html { &.mobile {
&.mobile { .music-explore {
.musicExplorer { padding: 0 10px;
.playlistExplorer_section_list {
overflow: visible;
overflow-x: scroll;
width: unset; .recently_played-content {
display: flex; padding: 0;
flex-direction: row;
grid-gap: 10px;
}
}
}
}
.featured_playlist {
position: relative;
display: flex;
flex-direction: row;
overflow: hidden;
background-color: var(--background-color-accent);
width: 100%;
min-height: 200px;
height: fit-content;
border-radius: 12px;
cursor: pointer;
&:hover {
.featured_playlist_content {
h1,
p {
-webkit-text-stroke-width: 1.6px;
-webkit-text-stroke-color: var(--border-color);
color: var(--background-color-contrast);
}
} }
.lazy-load-image-background { .music-explore-content {
opacity: 1;
}
}
.lazy-load-image-background {
z-index: 50;
position: absolute;
opacity: 0.3;
transition: all 300ms ease-in-out !important;
img {
width: 100%;
height: 100%;
}
}
.featured_playlist_content {
z-index: 55;
padding: 20px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
h1 {
font-size: 2.5rem;
font-family: "Space Grotesk", sans-serif;
font-weight: 900;
transition: all 300ms ease-in-out !important;
}
p {
font-size: 1rem;
font-family: "Space Grotesk", sans-serif;
transition: all 300ms ease-in-out !important;
}
.featured_playlist_genre {
z-index: 55;
position: absolute;
left: 0;
bottom: 0;
margin: 10px;
background-color: var(--background-color-accent);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 10px 20px;
} }
} }
} }
@ -118,14 +24,14 @@ html {
border-radius: 12px; border-radius: 12px;
} }
.musicExplorer { .music-explore {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
height: 100%; height: 100%;
gap: 20px; gap: 30px;
&.search-focused { &.search-focused {
.feed_main { .feed_main {
@ -134,18 +40,19 @@ html {
} }
} }
.feed_main { .music-explore-content {
display: flex; display: grid;
flex-direction: column; grid-template-columns: repeat(2, auto);
grid-template-rows: auto;
width: 100%; width: 100%;
height: 100%; height: 100%;
gap: 50px; gap: 30px;
transition: all 0.2s ease-in-out; @media screen and (max-width: 1200px) {
grid-template-columns: 1fr;
overflow-x: visible; }
} }
} }

View File

@ -4,24 +4,30 @@ import * as antd from "antd"
import { Icons } from "@components/Icons" import { Icons } from "@components/Icons"
import TracksLibraryView from "./views/tracks" import TracksLibraryView from "./views/tracks"
import ReleasesLibraryView from "./views/releases"
import PlaylistLibraryView from "./views/playlists" import PlaylistLibraryView from "./views/playlists"
import "./index.less" import "./index.less"
const TabToView = { const Views = {
tracks: TracksLibraryView,
playlist: PlaylistLibraryView,
releases: PlaylistLibraryView,
}
const TabToHeader = {
tracks: { tracks: {
icon: <Icons.MdMusicNote />, value: "tracks",
label: "Tracks", label: "Tracks",
icon: <Icons.MdMusicNote />,
element: TracksLibraryView,
}, },
playlist: { releases: {
icon: <Icons.MdPlaylistPlay />, value: "releases",
label: "Releases",
icon: <Icons.MdAlbum />,
element: ReleasesLibraryView,
},
playlists: {
value: "playlists",
label: "Playlists", label: "Playlists",
icon: <Icons.MdPlaylistPlay />,
element: PlaylistLibraryView,
disabled: true,
}, },
} }
@ -34,29 +40,13 @@ const Library = (props) => {
<antd.Segmented <antd.Segmented
value={selectedTab} value={selectedTab}
onChange={setSelectedTab} onChange={setSelectedTab}
options={[ options={Object.values(Views)}
{
value: "tracks",
label: "Tracks",
icon: <Icons.MdMusicNote />,
},
{
value: "playlist",
label: "Playlists",
icon: <Icons.MdPlaylistPlay />,
},
{
value: "releases",
label: "Releases",
icon: <Icons.MdPlaylistPlay />,
},
]}
/> />
</div> </div>
{selectedTab && {selectedTab &&
TabToView[selectedTab] && Views[selectedTab] &&
React.createElement(TabToView[selectedTab])} React.createElement(Views[selectedTab].element)}
</div> </div>
) )
} }

View File

@ -1,181 +1,76 @@
import React from "react" import React from "react"
import * as antd from "antd" import * as antd from "antd"
import classnames from "classnames"
import Image from "@components/Image" import PlaylistView from "@components/Music/PlaylistView"
import { Icons } from "@components/Icons"
import OpenPlaylistCreator from "@components/Music/PlaylistCreator"
import MusicModel from "@models/music" import MusicModel from "@models/music"
import "./index.less" const loadLimit = 50
const ReleaseTypeDecorators = { const MyLibraryPlaylists = () => {
"user": () => <p > const [offset, setOffset] = React.useState(0)
<Icons.MdPlaylistAdd /> const [items, setItems] = React.useState([])
Playlist const [hasMore, setHasMore] = React.useState(true)
</p>, const [initialLoading, setInitialLoading] = React.useState(true)
"playlist": () => <p >
<Icons.MdPlaylistAdd />
Playlist
</p>,
"editorial": () => <p >
<Icons.MdPlaylistAdd />
Official Playlist
</p>,
"single": () => <p >
<Icons.MdMusicNote />
Single
</p>,
"album": () => <p >
<Icons.MdAlbum />
Album
</p>,
"ep": () => <p >
<Icons.MdAlbum />
EP
</p>,
"mix": () => <p >
<Icons.MdMusicNote />
Mix
</p>,
}
function isNotAPlaylist(type) { const [L_Library, R_Library, E_Library, M_Library] =
return type === "album" || type === "ep" || type === "mix" || type === "single" app.cores.api.useRequest(MusicModel.getMyLibrary, {
} offset: offset,
limit: loadLimit,
kind: "playlists",
})
const PlaylistItem = (props) => { async function onLoadMore() {
const data = props.data ?? {} const newOffset = offset + loadLimit
const handleOnClick = () => { setOffset(newOffset)
if (typeof props.onClick === "function") {
props.onClick(data)
}
if (props.type !== "action") { M_Library({
if (data.service) { offset: newOffset,
return app.navigation.goToPlaylist(`${data._id}?service=${data.service}`) limit: loadLimit,
}
return app.navigation.goToPlaylist(data._id)
}
}
return <div
className={classnames(
"playlist_item",
{
["action"]: props.type === "action",
["release"]: isNotAPlaylist(data.type),
}
)}
onClick={handleOnClick}
>
<div className="playlist_item_icon">
{
React.isValidElement(data.icon)
? <div className="playlist_item_icon_svg">
{data.icon}
</div>
: <Image
src={data.icon}
alt="playlist icon"
/>
}
</div>
<div className="playlist_item_info">
<div className="playlist_item_info_title">
<h1>
{
data.service === "tidal" && <Icons.SiTidal />
}
{
data.title ?? "Unnamed playlist"
}
</h1>
</div>
{
data.owner && <div className="playlist_item_info_owner">
<h4>
{
data.owner
}
</h4>
</div>
}
{
data.description && <div className="playlist_item_info_description">
<p>
{
data.description
}
</p>
{
ReleaseTypeDecorators[String(data.type).toLowerCase()] && ReleaseTypeDecorators[String(data.type).toLowerCase()](props)
}
{
data.public
? <p>
<Icons.MdVisibility />
Public
</p>
: <p>
<Icons.MdVisibilityOff />
Private
</p>
}
</div>
}
</div>
</div>
}
const PlaylistLibraryView = (props) => {
const [L_Playlists, R_Playlists, E_Playlists, M_Playlists] = app.cores.api.useRequest(MusicModel.getFavoritePlaylists)
if (E_Playlists) {
console.error(E_Playlists)
return <antd.Result
status="warning"
title="Failed to load"
subTitle="We are sorry, but we could not load your playlists. Please try again later."
/>
}
if (L_Playlists) {
return <antd.Skeleton />
}
return <div className="own_playlists">
<PlaylistItem
type="action"
data={{
icon: <Icons.MdPlaylistAdd />,
title: "Create new",
}}
onClick={OpenPlaylistCreator}
/>
{
R_Playlists.items.map((playlist) => {
playlist.icon = playlist.cover ?? playlist.thumbnail
playlist.description = `${playlist.numberOfTracks ?? playlist.list.length} tracks`
return <PlaylistItem
key={playlist.id}
data={playlist}
/>
}) })
} }
</div>
React.useEffect(() => {
if (R_Library && R_Library.items) {
if (initialLoading === true) {
setInitialLoading(false)
}
if (R_Library.items.length === 0) {
setHasMore(false)
} else {
setItems((prev) => {
prev = [...prev, ...R_Library.items]
return prev
})
}
}
}, [R_Library])
if (E_Library) {
return <antd.Result status="warning" title="Failed to load" />
}
if (initialLoading) {
return <antd.Skeleton active />
}
return (
<PlaylistView
noHeader
noSearch
loading={L_Library}
type="vertical"
playlist={{
items: items,
total_length: R_Library.total_items,
}}
onLoadMore={onLoadMore}
hasMore={hasMore}
/>
)
} }
export default PlaylistLibraryView export default MyLibraryPlaylists

View File

@ -0,0 +1,66 @@
import React from "react"
import * as antd from "antd"
import Playlist from "@components/Music/Playlist"
import MusicModel from "@models/music"
const loadLimit = 50
const MyLibraryReleases = () => {
const [offset, setOffset] = React.useState(0)
const [items, setItems] = React.useState([])
const [hasMore, setHasMore] = React.useState(true)
const [initialLoading, setInitialLoading] = React.useState(true)
const [L_Library, R_Library, E_Library, M_Library] =
app.cores.api.useRequest(MusicModel.getMyLibrary, {
offset: offset,
limit: loadLimit,
kind: "releases",
})
async function onLoadMore() {
const newOffset = offset + loadLimit
setOffset(newOffset)
M_Library({
offset: newOffset,
limit: loadLimit,
kind: "releases",
})
}
React.useEffect(() => {
if (R_Library && R_Library.items) {
if (initialLoading === true) {
setInitialLoading(false)
}
if (R_Library.items.length === 0) {
setHasMore(false)
} else {
setItems((prev) => {
prev = [...prev, ...R_Library.items]
return prev
})
}
}
}, [R_Library])
if (E_Library) {
return <antd.Result status="warning" title="Failed to load" />
}
if (initialLoading) {
return <antd.Skeleton active />
}
return items.map((item, index) => {
return <Playlist row key={index} playlist={item} />
})
}
export default MyLibraryReleases

View File

@ -0,0 +1,166 @@
@playlist_item_icon_size: 50px;
.playlist_item {
display: flex;
flex-direction: row;
width: 100%;
height: 70px;
gap: 10px;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 12px;
cursor: pointer;
overflow: hidden;
&.release {
.playlist_item_icon {
img {
border-radius: 50%;
}
}
}
&.action {
.playlist_item_icon {
color: var(--colorPrimary);
}
}
.playlist_item_icon {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: @playlist_item_icon_size;
height: @playlist_item_icon_size;
min-width: @playlist_item_icon_size;
min-height: @playlist_item_icon_size;
overflow: hidden;
border-radius: 12px;
img {
width: @playlist_item_icon_size;
height: @playlist_item_icon_size;
min-width: @playlist_item_icon_size;
min-height: @playlist_item_icon_size;
object-fit: cover;
}
.playlist_item_icon_svg {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background-color: var(--background-color-accent);
svg {
margin: 0;
font-size: 2rem;
}
}
}
.playlist_item_info {
display: flex;
flex-direction: column;
//align-items: center;
justify-content: center;
height: 100%;
width: 90%;
text-overflow: ellipsis;
.playlist_item_info_title {
display: inline;
font-size: 0.8rem;
text-overflow: ellipsis;
h1 {
font-weight: 700;
white-space: nowrap;
overflow: hidden;
}
}
.playlist_item_info_owner {
display: inline;
overflow: hidden;
font-size: 0.7rem;
h4 {
font-weight: 400;
}
}
.playlist_item_info_description {
display: inline-flex;
flex-direction: row;
gap: 10px;
font-size: 0.7rem;
p {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-transform: uppercase;
svg {
margin-right: 0.4rem;
}
}
}
h1,
h2,
h3,
h4,
h5,
h6,
p,
span {
margin: 0;
}
}
}
.own_playlists {
display: flex;
flex-direction: column;
width: 100%;
gap: 10px;
}

View File

@ -13,49 +13,47 @@ const TracksLibraryView = () => {
const [hasMore, setHasMore] = React.useState(true) const [hasMore, setHasMore] = React.useState(true)
const [initialLoading, setInitialLoading] = React.useState(true) const [initialLoading, setInitialLoading] = React.useState(true)
const [L_Favourites, R_Favourites, E_Favourites, M_Favourites] = const [L_Library, R_Library, E_Library, M_Library] =
app.cores.api.useRequest(MusicModel.getFavouriteFolder, { app.cores.api.useRequest(MusicModel.getMyLibrary, {
offset: offset, offset: offset,
limit: loadLimit, limit: loadLimit,
kind: "tracks",
}) })
async function onLoadMore() { async function onLoadMore() {
const newOffset = offset + loadLimit setOffset((prevOffset) => {
const newOffset = prevOffset + loadLimit
setOffset(newOffset) M_Library({
M_Favourites({
offset: newOffset, offset: newOffset,
limit: loadLimit, limit: loadLimit,
kind: "tracks",
})
if (newOffset >= R_Library.total_items) {
setHasMore(false)
}
return newOffset
}) })
} }
React.useEffect(() => { React.useEffect(() => {
if (R_Favourites && R_Favourites.tracks) { if (R_Library && R_Library.items) {
if (initialLoading === true) { if (initialLoading === true) {
setInitialLoading(false) setInitialLoading(false)
} }
if (R_Favourites.tracks.items.length === 0) {
setHasMore(false)
} else {
setItems((prev) => { setItems((prev) => {
prev = [...prev, ...R_Favourites.tracks.items] prev = [...prev, ...R_Library.items]
return prev return prev
}) })
} }
} }, [R_Library])
}, [R_Favourites])
if (E_Favourites) { if (E_Library) {
return ( return <antd.Result status="warning" title="Failed to load" />
<antd.Result
status="warning"
title="Failed to load"
subTitle={E_Favourites}
/>
)
} }
if (initialLoading) { if (initialLoading) {
@ -66,15 +64,14 @@ const TracksLibraryView = () => {
<PlaylistView <PlaylistView
noHeader noHeader
noSearch noSearch
loading={L_Favourites} loading={L_Library}
type="vertical" type="vertical"
playlist={{ playlist={{
items: items, items: items,
total_length: R_Favourites.tracks.total_items, total_length: R_Library.total_items,
}} }}
onLoadMore={onLoadMore} onLoadMore={onLoadMore}
hasMore={hasMore} hasMore={hasMore}
length={R_Favourites.tracks.total_length}
/> />
) )
} }

View File

@ -1,58 +1,12 @@
import React from "react" import React from "react"
import { Skeleton, Result } from "antd" import { Skeleton, Result } from "antd"
import RadioModel from "@models/radio"
import Image from "@components/Image"
import { MdPlayCircle, MdHeadphones } from "react-icons/md" import RadioModel from "@models/radio"
import Radio from "@components/Music/Radio"
import "./index.less" import "./index.less"
const RadioItem = ({ item, style }) => {
const onClickItem = () => {
app.cores.player.start(
{
title: item.name,
source: item.http_src,
cover: item.background,
},
{
radioId: item.radio_id,
},
)
}
if (!item) {
return (
<div className="radio-list-item empty" style={style}>
<div className="radio-list-item-content">
<Skeleton />
</div>
</div>
)
}
return (
<div className="radio-list-item" onClick={onClickItem} style={style}>
<Image className="radio-list-item-cover" src={item.background} />
<div className="radio-list-item-content">
<h1 id="title">{item.name}</h1>
<p>{item.description}</p>
<div className="radio-list-item-info">
<div className="radio-list-item-info-item" id="now_playing">
<MdPlayCircle />
<span>{item.now_playing.song.text}</span>
</div>
<div className="radio-list-item-info-item" id="now_playing">
<MdHeadphones />
<span>{item.listeners}</span>
</div>
</div>
</div>
</div>
)
}
const RadioTab = () => { const RadioTab = () => {
const [L_Radios, R_Radios, E_Radios, M_Radios] = app.cores.api.useRequest( const [L_Radios, R_Radios, E_Radios, M_Radios] = app.cores.api.useRequest(
RadioModel.getRadioList, RadioModel.getRadioList,
@ -69,12 +23,12 @@ const RadioTab = () => {
return ( return (
<div className="radio-list"> <div className="radio-list">
{R_Radios.map((item) => ( {R_Radios.map((item) => (
<RadioItem key={item.id} item={item} /> <Radio key={item.id} item={item} />
))} ))}
<RadioItem style={{ opacity: 0.5 }} /> <Radio style={{ opacity: 0.5 }} />
<RadioItem style={{ opacity: 0.4 }} /> <Radio style={{ opacity: 0.4 }} />
<RadioItem style={{ opacity: 0.3 }} /> <Radio style={{ opacity: 0.3 }} />
</div> </div>
) )
} }

View File

@ -7,87 +7,9 @@
gap: 10px; gap: 10px;
width: 100%; width: 100%;
}
.radio-list-item {
position: relative;
display: flex;
flex-direction: column;
.radio-item {
min-width: @min-item-width; min-width: @min-item-width;
min-height: @min-item-height; min-height: @min-item-height;
border-radius: 16px;
background-color: var(--background-color-accent);
overflow: hidden;
&:hover {
cursor: pointer;
.radio-list-item-content {
backdrop-filter: blur(2px);
}
}
&.empty {
cursor: default;
}
.lazy-load-image-background,
.radio-list-item-cover {
position: absolute;
z-index: 1;
top: 0;
left: 0;
width: 100%;
height: 100%;
img {
object-fit: cover;
}
}
.radio-list-item-content {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: space-between;
width: 100%;
height: 100%;
padding: 16px;
transition: all 150ms ease-in-out;
.radio-list-item-info {
display: flex;
align-items: center;
gap: 8px;
.radio-list-item-info-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
padding: 4px;
background-color: rgba(var(--bg_color_3), 0.7);
border-radius: 8px;
font-size: 0.7rem;
}
}
} }
} }

View File

@ -0,0 +1,67 @@
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: "50%",
position: "relative",
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: "10px",
padding: "5px 30px",
backgroundColor: "var(--background-color-primary)",
borderRadius: "8px",
fontFamily: "DM Mono, monospace",
fontSize: "0.8rem",
...props.style,
}}
>
<span>{visible ? props.value : "********"}</span>
<antd.Button
style={{
position: "absolute",
left: 0,
top: 0,
paddingTop: "0.5px",
}}
icon={visible ? <IoMdEye /> : <IoMdEyeOff />}
type="ghost"
size="small"
onClick={() => setVisible(!visible)}
/>
<antd.Button
style={{
position: "absolute",
right: 0,
top: 0,
paddingTop: "2.5px",
}}
icon={<IoMdClipboard />}
type="ghost"
size="small"
onClick={copyToClipboard}
/>
</div>
)
}
export default HiddenText

View File

@ -0,0 +1,278 @@
import React, { useEffect, useRef } from "react"
import * as d3 from "d3"
import { formatBitrate } from "../../liveTabUtils"
const CHART_HEIGHT = 220
const MIN_DATA_POINTS_FOR_CHART = 3
const ONE_MINUTE_IN_MS = 1 * 60 * 1000; // 1 minute in milliseconds
const Y_AXIS_MAX_TARGET_KBPS = 14000
const Y_AXIS_DISPLAY_MAX_KBPS = Y_AXIS_MAX_TARGET_KBPS * 1.1
const MAX_Y_DOMAIN_BPS_FROM_CONFIG = (Y_AXIS_DISPLAY_MAX_KBPS * 1000) / 8
const StreamRateChart = ({ streamData }) => {
const d3ContainerRef = useRef(null)
const tooltipRef = useRef(null)
useEffect(() => {
if (
streamData &&
streamData.length >= MIN_DATA_POINTS_FOR_CHART &&
d3ContainerRef.current
) {
const svgElement = d3ContainerRef.current
const tooltipDiv = d3.select(tooltipRef.current)
const availableWidth =
svgElement.clientWidth ||
(svgElement.parentNode && svgElement.parentNode.clientWidth) ||
600
const availableHeight = CHART_HEIGHT
const margin = { top: 20, right: 20, bottom: 30, left: 75 } // Adjusted right margin
const width = availableWidth - margin.left - margin.right
const height = availableHeight - margin.top - margin.bottom
const svg = d3.select(svgElement)
svg.selectAll("*").remove()
// Define a clip-path for the lines area
svg.append("defs").append("clipPath")
.attr("id", "chart-lines-clip") // Unique ID for clipPath
.append("rect")
.attr("width", width) // Clip to the plotting area width
.attr("height", height); // Clip to the plotting area height
// Main chart group for axes (not clipped)
const chartG = svg
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
// Group for lines, this group will be clipped
const linesG = chartG.append("g")
.attr("clip-path", "url(#chart-lines-clip)");
const xScale = d3
.scaleTime()
// Domain will now span the actual data present in streamData (up to 1 minute)
.domain(d3.extent(streamData, (d) => new Date(d.time)))
.range([0, width])
const currentMaxBps = d3.max(streamData, (d) => d.receivedRate) || 0
const yDomainMax = Math.max(
MAX_Y_DOMAIN_BPS_FROM_CONFIG,
currentMaxBps,
)
const yScale = d3
.scaleLinear()
.domain([0, yDomainMax > 0 ? yDomainMax : (1000 * 1000) / 8])
.range([height, 0])
.nice()
const xAxis = d3
.axisBottom(xScale)
.ticks(Math.min(5, Math.floor(width / 80)))
.tickFormat(d3.timeFormat("%H:%M:%S"))
const yAxis = d3.axisLeft(yScale).ticks(5).tickFormat(formatBitrate)
chartG
.append("g")
.attr("class", "x-axis")
.attr("transform", `translate(0,${height})`)
.call(xAxis)
.selectAll("text")
.style("fill", "#8c8c8c")
chartG.selectAll(".x-axis path").style("stroke", "#444")
chartG.selectAll(".x-axis .tick line").style("stroke", "#444")
chartG
.append("g")
.attr("class", "y-axis")
.call(yAxis)
.selectAll("text")
.style("fill", "#8c8c8c")
chartG.selectAll(".y-axis path").style("stroke", "#444")
chartG.selectAll(".y-axis .tick line").style("stroke", "#444")
const lineReceived = d3
.line()
.x((d) => xScale(new Date(d.time)))
.y((d) => yScale(d.receivedRate))
.curve(d3.curveMonotoneX)
const receivedColor = "#2ecc71"
// Filter data to ensure valid points for the line
const validStreamDataForLine = streamData.filter(
d => d && typeof d.receivedRate === 'number' && !isNaN(d.receivedRate) && d.time
);
// Append the line path to the clipped group 'linesG'
// Only draw if there's enough valid data to form a line
if (validStreamDataForLine.length > 1) {
linesG
.append("path")
.datum(validStreamDataForLine)
.attr("fill", "none")
.attr("stroke", receivedColor)
.attr("stroke-width", 2)
.attr("d", lineReceived);
// curveMonotoneX is applied in the lineReceived generator definition
}
// Tooltip focus elements are appended to chartG so they are not clipped by the lines' clip-path
const focus = chartG
.append("g")
.attr("class", "focus")
.style("display", "none")
focus
.append("line")
.attr("class", "focus-line")
.attr("y1", 0)
.attr("y2", height)
.attr("stroke", "#aaa")
.attr("stroke-width", 1)
.attr("stroke-dasharray", "3,3")
focus
.append("circle")
.attr("r", 4)
.attr("class", "focus-circle-received")
.style("fill", receivedColor)
.style("stroke", "white")
chartG
.append("rect")
.attr("class", "overlay")
.attr("width", width)
.attr("height", height)
.style("fill", "none")
.style("pointer-events", "all")
.on("mouseover", () => {
focus.style("display", null)
tooltipDiv.style("display", "block")
})
.on("mouseout", () => {
focus.style("display", "none")
tooltipDiv.style("display", "none")
})
.on("mousemove", mousemove)
const bisectDate = d3.bisector((d) => new Date(d.time)).left
function mousemove(event) {
const [mouseX] = d3.pointer(event, this)
const x0 = xScale.invert(mouseX)
const i = bisectDate(streamData, x0, 1)
const d0 = streamData[i - 1]
const d1 = streamData[i]
const t0 = d0 ? new Date(d0.time) : null
const t1 = d1 ? new Date(d1.time) : null
const d = t1 && x0 - t0 > t1 - x0 ? d1 : d0
if (d) {
const focusX = xScale(new Date(d.time))
focus.attr("transform", `translate(${focusX},0)`)
focus
.select(".focus-circle-received")
.attr("cy", yScale(d.receivedRate))
const tooltipX = margin.left + focusX + 15
const receivedY = yScale(d.receivedRate)
const tooltipY = margin.top + receivedY
tooltipDiv
.style("left", `${tooltipX}px`)
.style("top", `${tooltipY}px`)
.html(
`<strong>Time:</strong> ${d3.timeFormat("%H:%M:%S")(new Date(d.time))}<br/>` +
`<strong>Received:</strong> ${formatBitrate(d.receivedRate)}`,
)
}
}
} else if (d3ContainerRef.current) {
const svg = d3.select(d3ContainerRef.current)
svg.selectAll("*").remove()
if (streamData && streamData.length < MIN_DATA_POINTS_FOR_CHART) {
const currentSvgElement = d3ContainerRef.current
svg.append("text")
.attr(
"x",
(currentSvgElement?.clientWidth ||
(currentSvgElement?.parentNode &&
currentSvgElement?.parentNode.clientWidth) ||
600) / 2,
)
.attr("y", CHART_HEIGHT / 2)
.attr("text-anchor", "middle")
.text(
`Collecting data... (${streamData?.length || 0}/${MIN_DATA_POINTS_FOR_CHART})`,
)
.style("fill", "#8c8c8c")
.style("font-size", "12px")
}
}
}, [streamData])
return (
<div
style={{
width: "100%",
height: `${CHART_HEIGHT}px`,
position: "relative",
}}
>
<svg
ref={d3ContainerRef}
style={{ width: "100%", height: "100%", display: "block" }}
></svg>
<div
ref={tooltipRef}
style={{
position: "absolute",
display: "none",
padding: "8px",
background: "rgba(0,0,0,0.75)",
color: "white",
borderRadius: "4px",
pointerEvents: "none",
fontSize: "12px",
zIndex: 10,
}}
></div>
{(!streamData || streamData.length === 0) && (
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${CHART_HEIGHT}px`,
display: "flex",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
color: "#8c8c8c",
zIndex: 1,
}}
>
Waiting for stream data...
</div>
)}
</div>
)
}
export default StreamRateChart

View File

@ -0,0 +1,81 @@
import React from "react"
import { FiEye, FiRadio } from "react-icons/fi"
const ProfileHeader = ({ profile, streamHealth }) => {
const streamRef = React.useRef(streamHealth ?? {})
const [thumbnail, setThumbnail] = React.useState(
profile.info.offline_thumbnail,
)
async function setTimedThumbnail() {
setThumbnail(() => {
if (streamRef.current.online && profile.info.thumbnail) {
return `${profile.info.thumbnail}?t=${Date.now()}`
}
return profile.info.offline_thumbnail
})
}
React.useEffect(() => {
streamRef.current = streamHealth
}, [streamHealth])
React.useEffect(() => {
const timedThumbnailInterval = setInterval(setTimedThumbnail, 5000)
return () => {
clearInterval(timedThumbnailInterval)
}
}, [])
return (
<div className="profile-header">
<img className="profile-header__image" src={thumbnail} />
<div className="profile-header__content">
<div className="profile-header__card titles">
<h1
style={{
"--fontSize": "2rem",
"--fontWeight": "800",
}}
>
{profile.info.title}
</h1>
<h3
style={{
"--fontSize": "1rem",
}}
>
{profile.info.description}
</h3>
</div>
<div className="flex-row gap-10">
{streamHealth?.online ? (
<div className="profile-header__card on_live">
<span>
<FiRadio /> On Live
</span>
</div>
) : (
<div className="profile-header__card">
<span>Offline</span>
</div>
)}
<div className="profile-header__card viewers">
<span>
<FiEye />
{streamHealth?.viewers}
</span>
</div>
</div>
</div>
</div>
)
}
export default ProfileHeader

View File

@ -0,0 +1,217 @@
import React from "react"
import * as antd from "antd"
import Streaming from "@models/spectrum"
import useCenteredContainer from "@hooks/useCenteredContainer"
import ProfileHeader from "./header"
import LiveTab from "./tabs/Live"
import StreamConfiguration from "./tabs/StreamConfiguration"
import RestreamManager from "./tabs/RestreamManager"
import MediaUrls from "./tabs/MediaUrls"
import "./index.less"
const KeyToComponent = {
live: LiveTab,
configuration: StreamConfiguration,
restreams: RestreamManager,
media_urls: MediaUrls,
}
const useSpectrumWS = () => {
const client = React.useMemo(() => Streaming.createWebsocket(), [])
React.useEffect(() => {
client.connect()
return () => {
client.destroy()
}
}, [])
return client
}
const ProfileData = (props) => {
const { profile_id } = props.params
if (!profile_id) {
return null
}
useCenteredContainer(false)
const ws = useSpectrumWS()
const [loading, setLoading] = React.useState(false)
const [fetching, setFetching] = React.useState(true)
const [error, setError] = React.useState(null)
const [profile, setProfile] = React.useState(null)
const [selectedTab, setSelectedTab] = React.useState("live")
const [streamHealth, setStreamHealth] = React.useState(null)
const streamHealthIntervalRef = React.useRef(null)
async function fetchStreamHealth() {
if (!ws) {
return false
}
const health = await ws.call("stream:health", profile_id)
setStreamHealth(health)
}
async function fetchProfileData(idToFetch) {
setFetching(true)
setError(null)
try {
const result = await Streaming.getProfile(idToFetch)
if (result) {
setProfile(result)
} else {
setError({
message:
"Profile not found or an error occurred while fetching.",
})
}
} catch (err) {
console.error("Error fetching profile:", err)
setError(err)
} finally {
setFetching(false)
}
}
async function handleProfileUpdate(key, value) {
if (!profile || !profile._id) {
antd.message.error("Profile data is not available for update.")
return false
}
setLoading(true)
try {
const updatedProfile = await Streaming.updateProfile(profile._id, {
[key]: value,
})
antd.message.success("Change applyed")
setProfile(updatedProfile)
} catch (err) {
console.error(`Error updating profile (${key}):`, err)
const errorMessage =
err.response?.data?.message ||
err.message ||
`Failed to update ${key}.`
antd.message.error(errorMessage)
return false
} finally {
setLoading(false)
}
}
React.useEffect(() => {
if (profile_id) {
fetchProfileData(profile_id)
} else {
setProfile(null)
setError(null)
}
}, [profile_id])
React.useEffect(() => {
if (profile_id) {
streamHealthIntervalRef.current = setInterval(
fetchStreamHealth,
1000,
)
}
return () => {
clearInterval(streamHealthIntervalRef.current)
}
}, [profile_id])
if (fetching) {
return <antd.Skeleton active style={{ padding: "20px" }} />
}
if (error) {
return (
<antd.Result
status="warning"
title="Error Loading Profile"
subTitle={
error.message ||
"An unexpected error occurred. Please try again."
}
extra={[
<antd.Button
key="retry"
type="primary"
onClick={() => fetchProfileData(profile_id)}
>
Retry
</antd.Button>,
]}
/>
)
}
if (!profile) {
return (
<antd.Result
status="info"
title="No Profile Data"
subTitle="The profile data could not be loaded, is not available, or no profile is selected."
/>
)
}
return (
<div className="profile-view">
<ProfileHeader profile={profile} streamHealth={streamHealth} />
<antd.Segmented
options={[
{
label: "Live",
value: "live",
},
{
label: "Configuration",
value: "configuration",
},
{
label: "Restreams",
value: "restreams",
},
{
label: "Media URLs",
value: "media_urls",
},
]}
onChange={(value) => setSelectedTab(value)}
value={selectedTab}
/>
{KeyToComponent[selectedTab] &&
React.createElement(KeyToComponent[selectedTab], {
profile,
loading,
handleProfileUpdate,
streamHealth,
})}
</div>
)
}
export default ProfileData

View File

@ -0,0 +1,246 @@
.profile-view {
display: flex;
flex-direction: column;
width: 100%;
gap: 20px;
.profile-header {
position: relative;
max-height: 300px;
height: 300px;
background-repeat: no-repeat;
background-position: center;
background-size: cover;
border-radius: 12px;
overflow: hidden;
&__card {
display: flex;
flex-direction: row;
align-items: center;
width: fit-content;
padding: 5px 10px;
gap: 7px;
border-radius: 12px;
background-color: var(--background-color-primary);
&.titles {
flex-direction: column;
align-items: flex-start;
justify-content: center;
}
&.on_live {
background-color: var(--colorPrimary);
}
&.viewers {
font-family: "DM Mono", monospace;
}
}
.profile-header__image {
position: absolute;
height: 100%;
width: 100%;
left: 0;
top: 0;
z-index: 10;
}
.profile-header__content {
position: relative;
display: flex;
flex-direction: column;
z-index: 20;
height: 100%;
width: 100%;
padding: 30px;
gap: 10px;
background-color: rgba(0, 0, 0, 0.5);
}
}
.profile-section {
display: flex;
flex-direction: column;
gap: 10px;
&__header {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
span {
font-size: 1.2rem;
font-weight: 600;
}
svg {
font-size: 1.3rem;
opacity: 0.7;
}
}
}
.content-panel {
display: flex;
flex-direction: column;
width: 100%;
padding: 10px;
background-color: var(--background-color-accent);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09);
&__header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding-bottom: 10px;
margin-bottom: 10px;
border-bottom: 1px solid var(--border-color);
font-size: 1rem;
font-weight: 500;
}
&__content {
display: flex;
flex-direction: column;
gap: 10px;
flex-grow: 1;
}
}
.data-field {
display: flex;
flex-direction: column;
gap: 6px;
.profile-section:not(.content-panel) > & {
padding: 10px 0;
&:not(:last-child) {
border-bottom: 1px solid var(--border-color);
}
}
&__label {
display: flex;
flex-direction: column;
font-size: 0.9rem;
font-weight: 500;
svg {
font-size: 1.1em;
}
p {
font-size: 0.7rem;
opacity: 0.7;
}
}
&__value {
font-size: 0.9rem;
word-break: break-all;
code {
background-color: var(--background-color-primary);
padding: 5px 8px;
border-radius: 8px;
font-size: 0.8rem;
font-family: "DM Mono", monospace;
}
}
&__description {
font-size: 0.8rem;
p {
margin-bottom: 0;
line-height: 1.4;
}
}
&__content {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
}
}
.ant-segmented {
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;
}
}
}
.restream-server-item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
background-color: var(--background-color-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09);
padding: 10px;
}

View File

@ -0,0 +1,38 @@
export const formatBytes = (bytes, decimals = 2) => {
if (
bytes === undefined ||
bytes === null ||
isNaN(parseFloat(bytes)) ||
!isFinite(bytes)
)
return "0 Bytes"
if (bytes === 0) {
return "0 Bytes"
}
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]
}
export const formatBitrate = (bytesPerSecond) => {
if (typeof bytesPerSecond !== "number" || isNaN(bytesPerSecond)) {
return "0 Kbps"
}
const bitsPerSecond = bytesPerSecond * 8
if (bitsPerSecond >= 1000000) {
return `${(bitsPerSecond / 1000000).toFixed(1)} Mbps`
}
if (bitsPerSecond >= 1000 || bitsPerSecond === 0) {
return `${(bitsPerSecond / 1000).toFixed(0)} Kbps`
}
return `${bitsPerSecond.toFixed(0)} bps`
}

View File

@ -0,0 +1,231 @@
import { Button, Input, Statistic, Tag } from "antd"
import UploadButton from "@components/UploadButton"
import { FiImage, FiInfo } from "react-icons/fi"
import { MdTextFields, MdDescription } from "react-icons/md"
import StreamRateChart from "../../components/StreamRateChart"
import { formatBytes, formatBitrate } from "../../liveTabUtils"
import { useStreamSignalQuality } from "../../useStreamSignalQuality"
import "./index.less"
const MAX_DATA_POINTS = 30 // Approx 30 seconds of data (if 1 point per second)
const Y_AXIS_MAX_TARGET_KBPS = 14000
const Live = ({ profile, loading, handleProfileUpdate, streamHealth }) => {
const [newTitle, setNewTitle] = React.useState(profile.info.title)
const [newDescription, setNewDescription] = React.useState(
profile.info.description,
)
const [streamData, setStreamData] = React.useState([])
const targetMaxBitrateBpsForQuality = React.useMemo(
() => (Y_AXIS_MAX_TARGET_KBPS * 1000) / 8,
[],
)
const signalQualityInfo = useStreamSignalQuality(
streamHealth,
targetMaxBitrateBpsForQuality,
)
React.useEffect(() => {
if (
streamHealth &&
signalQualityInfo.currentReceivedRateBps !== undefined &&
signalQualityInfo.currentSentRateBps !== undefined
) {
const newPoint = {
time: new Date(),
sentRate: signalQualityInfo.currentSentRateBps,
receivedRate: signalQualityInfo.currentReceivedRateBps,
}
setStreamData((prevData) =>
[...prevData, newPoint].slice(-MAX_DATA_POINTS),
)
}
}, [
streamHealth,
signalQualityInfo.currentSentRateBps,
signalQualityInfo.currentReceivedRateBps,
])
async function saveProfileInfo() {
handleProfileUpdate("info", {
title: newTitle,
description: newDescription,
})
}
return (
<div className="profile-section live-tab-layout">
<div className="profile-section content-panel live-tab-info">
<div className="profile-section__header">
<span>
<FiInfo /> Information
</span>
</div>
<div className="content-panel__content">
<div className="data-field">
<div className="data-field__label">
<span>
<MdTextFields /> Title
</span>
</div>
<div className="data-field__value">
<Input
placeholder="Title this livestream"
defaultValue={profile.info.title}
onChange={(e) => setNewTitle(e.target.value)}
maxLength={50}
showCount
/>
</div>
</div>
<div className="data-field">
<div className="data-field__label">
<span>
<MdDescription /> Description
</span>
</div>
<div className="data-field__value">
<Input
placeholder="Describe this livestream in a few words"
defaultValue={profile.info.description}
onChange={(e) =>
setNewDescription(e.target.value)
}
maxLength={200}
showCount
/>
</div>
</div>
<div className="data-field">
<div className="data-field__label">
<span>
<FiImage /> Offline Thumbnail
</span>
<p>Displayed when the stream is offline</p>
</div>
<div className="data-field__content">
<UploadButton
accept="image/*"
onUploadDone={(response) => {
handleProfileUpdate("info", {
...profile.info,
offline_thumbnail: response.url,
})
}}
children={"Update"}
/>
</div>
</div>
<Button
type="primary"
onClick={saveProfileInfo}
loading={loading}
disabled={
profile.info.title === newTitle &&
profile.info.description === newDescription
}
>
Save
</Button>
</div>
</div>
<div className="live-tab-grid">
<div className="content-panel">
<div className="content-panel__header">
Live Preview & Status
</div>
<div className="content-panel__content">
<div className="status-indicator">
Stream Status:{" "}
{streamHealth?.online ? (
<Tag color="green">Online</Tag>
) : (
<Tag color="red">Offline</Tag>
)}
</div>
<div className="live-tab-preview">
{streamHealth?.online
? "Video Preview Area"
: "Stream is Offline"}
</div>
</div>
</div>
<div className="content-panel">
<div className="content-panel__header">
<div className="flex-row gap-10">
<p>Network Stats</p>
<Tag color={signalQualityInfo.color || "blue"}>
{signalQualityInfo.status}
</Tag>
</div>
<span className="status-indicator__message">
{signalQualityInfo.message}
</span>
</div>
<div className="content-panel__content">
<div className="live-tab-stats">
<div className="live-tab-stat">
<Statistic
title="Total Sent"
value={streamHealth?.bytesSent || 0}
formatter={formatBytes}
/>
</div>
<div className="live-tab-stat">
<Statistic
title="Total Received"
value={streamHealth?.bytesReceived || 0}
formatter={formatBytes}
/>
</div>
<div className="live-tab-stat">
<Statistic
title="Bitrate (Sent)"
value={
streamData.length > 0
? streamData[streamData.length - 1]
.sentRate
: 0
}
formatter={formatBitrate}
/>
</div>
<div className="live-tab-stat">
<Statistic
title="Bitrate (Received)"
value={
streamData.length > 0
? streamData[streamData.length - 1]
.receivedRate
: 0
}
formatter={formatBitrate}
/>
</div>
</div>
<div className="live-tab-chart">
<StreamRateChart streamData={streamData} />
</div>
</div>
</div>
</div>
</div>
)
}
export default Live

View File

@ -0,0 +1,72 @@
.live-tab-layout {
display: flex;
flex-direction: column;
width: 100%;
gap: 20px;
.live-tab-grid {
display: grid;
gap: 20px;
grid-template-columns: 1fr;
@media (min-width: 769px) {
grid-template-columns: 1fr 1fr;
}
}
.status-indicator__message {
font-size: 0.7rem;
}
.live-tab-preview {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 200px;
background-color: var(--background-color-primary);
border: 1px solid var(--border-color, #e8e8e8);
border-radius: 4px;
font-size: 1rem;
}
.live-tab-stats {
display: grid;
gap: 16px;
grid-template-columns: repeat(2, 1fr);
@media (min-width: 769px) {
grid-template-columns: repeat(4, 1fr);
}
.ant-statistic {
font-size: 1.2rem;
.ant-statistic-title {
font-size: 0.8rem;
margin: 0;
}
.ant-statistic-content {
height: fit-content;
}
.ant-statistic-content-value {
font-size: 1.2rem;
font-family: "DM Mono", monospace;
height: fit-content;
}
}
}
.live-tab-chart {
width: 100%;
}
}

View File

@ -0,0 +1,134 @@
import React from "react"
import * as antd from "antd"
import { FiLink } from "react-icons/fi"
const MediaUrls = ({ profile }) => {
const { sources } = profile
if (!sources || Object.keys(sources).length === 0) {
return null
}
const { hls, rtsp, html } = sources
const rtspt = rtsp ? rtsp.replace("rtsp://", "rtspt://") : null
return (
<div className="profile-section content-panel">
<div className="profile-section__header">
<span>
<FiLink /> Medias
</span>
</div>
{hls && (
<div className="data-field">
<div className="data-field__label">
<span>HLS</span>
</div>
<div className="data-field__description">
<p>
This protocol is highly compatible with a multitude
of devices and services. Recommended for general
use.
</p>
</div>
<div className="data-field__value">
<code>
<antd.Typography.Text
copyable={{
tooltips: ["Copy HLS URL", "Copied!"],
}}
>
{hls}
</antd.Typography.Text>
</code>
</div>
</div>
)}
{rtsp && (
<div className="data-field">
<div className="data-field__label">
<span>RTSP [tcp]</span>
</div>
<div className="data-field__description">
<p>
This protocol has the lowest possible latency and
the best quality. A compatible player is required.
</p>
</div>
<div className="data-field__value">
<code>
<antd.Typography.Text
copyable={{
tooltips: ["Copy RTSP URL", "Copied!"],
}}
>
{rtsp}
</antd.Typography.Text>
</code>
</div>
</div>
)}
{rtspt && (
<div className="data-field">
<div className="data-field__label">
<span>RTSPT [vrchat]</span>
</div>
<div className="data-field__description">
<p>
This protocol has the lowest possible latency and
the best quality available. Only works for VRChat
video players.
</p>
</div>
<div className="data-field__value">
<code>
<antd.Typography.Text
copyable={{
tooltips: ["Copy RTSPT URL", "Copied!"],
}}
>
{rtspt}
</antd.Typography.Text>
</code>
</div>
</div>
)}
{html && (
<div className="data-field">
<div className="data-field__label">
<span>HTML Viewer</span>
</div>
<div className="data-field__description">
<p>
Share a link to easily view your stream on any
device with a web browser.
</p>
</div>
<div className="data-field__value">
<code>
<antd.Typography.Text
copyable={{
tooltips: [
"Copy HTML Viewer URL",
"Copied!",
],
}}
>
{html}
</antd.Typography.Text>
</code>
</div>
</div>
)}
</div>
)
}
export default MediaUrls

View File

@ -0,0 +1,99 @@
import React from "react"
import * as antd from "antd"
import { FiPlusCircle } from "react-icons/fi"
import Streaming from "@models/spectrum"
const NewRestreamServerForm = ({ profile, loading, handleProfileUpdate }) => {
const [newRestreamHost, setNewRestreamHost] = React.useState("")
const [newRestreamKey, setNewRestreamKey] = React.useState("")
async function handleAddRestream() {
if (!newRestreamHost || !newRestreamKey) {
antd.message.error("Host URL and Key are required.")
return
}
if (
!newRestreamHost.startsWith("rtmp://") &&
!newRestreamHost.startsWith("rtsp://")
) {
antd.message.error(
"Invalid host URL. Must start with rtmp:// or rtsp://",
)
return
}
try {
const updatedProfile = await Streaming.addRestreamToProfile(
profile._id,
{ host: newRestreamHost, key: newRestreamKey },
)
if (updatedProfile && updatedProfile.restreams) {
handleProfileUpdate("restreams", updatedProfile.restreams)
setNewRestreamHost("")
setNewRestreamKey("")
antd.message.success("Restream server added successfully.")
} else {
antd.message.error(
"Failed to add restream server: No profile data returned from API.",
)
}
} catch (err) {
console.error("Failed to add restream server:", err)
const errorMessage =
err.response?.data?.message ||
err.message ||
"An unknown error occurred while adding the restream server."
antd.message.error(errorMessage)
}
}
return (
<div className="profile-section content-panel">
<div className="data-field__label">
<span>New server</span>
<p>Add a new restream server to the list.</p>
</div>
<div className="data-field__value">
<span>Host</span>
<antd.Input
name="stream_host"
placeholder="rtmp://example.server"
value={newRestreamHost}
onChange={(e) => setNewRestreamHost(e.target.value)}
disabled={loading}
/>
</div>
<div className="data-field__value">
<span>Key</span>
<antd.Input
name="stream_key"
placeholder="xxxx-xxxx-xxxx"
value={newRestreamKey}
onChange={(e) => setNewRestreamKey(e.target.value)}
disabled={loading}
/>
</div>
<antd.Button
type="primary"
onClick={handleAddRestream}
loading={loading}
disabled={loading || !newRestreamHost || !newRestreamKey}
icon={<FiPlusCircle />}
>
Add Restream Server
</antd.Button>
<p>
Please be aware! Pushing your stream to a malicious server could
be harmful, leading to data leaks and key stoling.
<br /> Only use servers you trust.
</p>
</div>
)
}
export default NewRestreamServerForm

View File

@ -0,0 +1,127 @@
import React from "react"
import * as antd from "antd"
import Streaming from "@models/spectrum"
import HiddenText from "../../components/HiddenText"
import { FiXCircle } from "react-icons/fi"
import NewRestreamServerForm from "./NewRestreamServerForm"
// Component to manage restream settings
const RestreamManager = ({ profile, loading, handleProfileUpdate }) => {
async function handleToggleRestreamEnabled(isEnabled) {
await handleProfileUpdate("options", {
...profile.options,
restream: isEnabled,
})
}
async function handleDeleteRestream(indexToDelete) {
if (!profile || !profile._id) {
antd.message.error("Profile not loaded. Cannot delete restream.")
return
}
try {
const updatedProfile = await Streaming.deleteRestreamFromProfile(
profile._id,
{ index: indexToDelete },
)
if (updatedProfile && updatedProfile.restreams) {
handleProfileUpdate("restreams", updatedProfile.restreams)
antd.message.success("Restream server deleted successfully.")
} else {
antd.message.error(
"Failed to delete restream server: No profile data returned from API.",
)
}
} catch (err) {
console.error("Failed to delete restream server:", err)
const errorMessage =
err.response?.data?.message ||
err.message ||
"An unknown error occurred while deleting the restream server."
antd.message.error(errorMessage)
}
}
return (
<>
<div className="profile-section content-panel">
<div className="content-panel__content">
<div className="data-field">
<div className="data-field__label">
<span>Enable Restreaming</span>
<p>
Allow this stream to be re-broadcasted to other
configured platforms.
</p>
<p style={{ fontWeight: "bold" }}>
Only works if the stream is not in private mode.
</p>
</div>
<div className="data-field__content">
<antd.Switch
checked={profile.options.restream}
loading={loading}
onChange={handleToggleRestreamEnabled}
/>
<p>Must restart the livestream to apply changes</p>
</div>
</div>
</div>
</div>
{profile.options.restream && (
<div className="profile-section content-panel">
<div className="data-field__label">
<span>Customs servers</span>
<p>View or modify the list of custom servers.</p>
</div>
{profile.restreams.map((item, index) => (
<div className="restream-server-item" key={index}>
<div className="data-field__label">
<span style={{ userSelect: "all" }}>
{item.host}
</span>
<p>
{item.key
? item.key.replace(/./g, "*")
: ""}
</p>
</div>
<div className="data-field__actions">
<antd.Button
icon={<FiXCircle />}
danger
onClick={() => handleDeleteRestream(index)}
loading={loading}
>
Delete
</antd.Button>
</div>
</div>
))}
{profile.restreams.length === 0 && (
<div className="custom-list-empty">
No restream servers configured.
</div>
)}
</div>
)}
{profile.options.restream && (
<NewRestreamServerForm
profile={profile}
loading={loading}
handleProfileUpdate={handleProfileUpdate}
/>
)}
</>
)
}
export default RestreamManager

View File

@ -0,0 +1,116 @@
import React from "react"
import * as antd from "antd"
import HiddenText from "../../components/HiddenText"
import { IoMdEyeOff } from "react-icons/io"
import { GrStorage, GrConfigure } from "react-icons/gr"
import { MdOutlineWifiTethering } from "react-icons/md"
const StreamConfiguration = ({ profile, loading, handleProfileUpdate }) => {
return (
<>
<div className="profile-section content-panel">
<div className="profile-section__header">
<MdOutlineWifiTethering />
<span>Server</span>
</div>
<div className="data-field">
<div className="data-field__label">
<span>Ingestion URL</span>
</div>
<div className="data-field__value">
<code>
<antd.Typography.Text
copyable={{
tooltips: ["Copied!"],
}}
>
{profile.ingestion_url}
</antd.Typography.Text>
</code>
</div>
</div>
<div className="data-field">
<div className="data-field__label">
<span>Stream Key</span>
</div>
<div className="data-field__value">
<HiddenText value={profile.stream_key} />
</div>
</div>
</div>
<div className="profile-section content-panel">
<div className="profile-section__header">
<GrConfigure />
<span>Options</span>
</div>
<div className="data-field">
<div className="data-field__label">
<span>
<IoMdEyeOff /> Private Mode
</span>
</div>
<div className="data-field__description">
<p>
When this is enabled, only users with the livestream
url can access the stream.
</p>
</div>
<div className="data-field__content">
<antd.Switch
checked={profile.options.private}
loading={loading}
onChange={(checked) =>
handleProfileUpdate("options", {
...profile.options,
private: checked,
})
}
/>
<p style={{ fontWeight: "bold" }}>
Must restart the livestream to apply changes
</p>
</div>
</div>
<div className="data-field">
<div className="data-field__label">
<span>
<GrStorage />
DVR [beta]
</span>
</div>
<div className="data-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="data-field__content">
<antd.Switch
checked={profile.options.dvr}
loading={loading}
onChange={(checked) =>
handleProfileUpdate("options", {
...profile.options,
dvr: checked,
})
}
disabled
/>
</div>
</div>
</div>
</>
)
}
export default StreamConfiguration

View File

@ -0,0 +1,124 @@
import { useState, useEffect, useRef } from "react"
const SMA_WINDOW_SIZE = 10
const FLUCTUATION_THRESHOLD_PERCENT = 50
export const useStreamSignalQuality = (streamHealth, targetMaxBitrateBps) => {
const [signalQuality, setSignalQuality] = useState({
status: "Calculating...",
message: "Waiting for stream data to assess stability.",
color: "orange",
currentReceivedRateBps: 0,
currentSentRateBps: 0,
})
const previousSampleRef = useRef(null)
const receivedBitrateHistoryRef = useRef([])
useEffect(() => {
if (
streamHealth &&
typeof streamHealth.bytesSent === "number" &&
typeof streamHealth.bytesReceived === "number"
) {
const currentTime = new Date()
let calculatedSentRateBps = 0
let calculatedReceivedRateBps = 0
if (previousSampleRef.current) {
const timeDiffSeconds =
(currentTime.getTime() -
previousSampleRef.current.time.getTime()) /
1000
if (timeDiffSeconds > 0.1) {
calculatedSentRateBps = Math.max(
0,
(streamHealth.bytesSent -
previousSampleRef.current.totalBytesSent) /
timeDiffSeconds,
)
calculatedReceivedRateBps = Math.max(
0,
(streamHealth.bytesReceived -
previousSampleRef.current.totalBytesReceived) /
timeDiffSeconds,
)
}
}
const newHistory = [
...receivedBitrateHistoryRef.current,
calculatedReceivedRateBps,
].slice(-SMA_WINDOW_SIZE)
receivedBitrateHistoryRef.current = newHistory
let newStatus = "Calculating..."
let newMessage = `Gathering incoming stream data (${newHistory.length}/${SMA_WINDOW_SIZE})...`
let newColor = "geekblue"
if (newHistory.length >= SMA_WINDOW_SIZE / 2) {
const sum = newHistory.reduce((acc, val) => acc + val, 0)
const sma = sum / newHistory.length
if (sma > 0) {
const fluctuationPercent =
(Math.abs(calculatedReceivedRateBps - sma) / sma) * 100
if (fluctuationPercent > FLUCTUATION_THRESHOLD_PERCENT) {
newStatus = "Unstable"
newMessage = `Incoming bitrate fluctuating significantly (±${fluctuationPercent.toFixed(0)}%).`
newColor = "red"
} else if (
calculatedReceivedRateBps <
targetMaxBitrateBps * 0.1
) {
newStatus = "Low Incoming Bitrate"
newMessage = "Incoming stream bitrate is very low."
newColor = "orange"
} else {
newStatus = "Good"
newMessage = "Incoming stream appears stable."
newColor = "green"
}
} else if (calculatedReceivedRateBps > 0) {
newStatus = "Good"
newMessage = "Incoming stream started."
newColor = "green"
} else {
newStatus = "No Incoming Data"
newMessage = "No incoming data transmission detected."
newColor = "red"
}
}
setSignalQuality({
status: newStatus,
message: newMessage,
color: newColor,
currentReceivedRateBps: calculatedReceivedRateBps,
currentSentRateBps: calculatedSentRateBps,
})
previousSampleRef.current = {
time: currentTime,
totalBytesSent: streamHealth.bytesSent,
totalBytesReceived: streamHealth.bytesReceived,
}
} else {
setSignalQuality({
status: "No Data",
message: "Stream health information is not available.",
color: "grey",
currentReceivedRateBps: 0,
currentSentRateBps: 0,
})
previousSampleRef.current = null
receivedBitrateHistoryRef.current = []
}
}, [streamHealth, targetMaxBitrateBps])
return signalQuality
}

View File

@ -1,80 +0,0 @@
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

@ -1,49 +0,0 @@
.editable-text {
border-radius: 12px;
font-size: 14px;
--fontSize: 14px;
--fontWeight: normal;
font-family: "DM Mono", sans-serif;
.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;
font-family: "DM Mono", sans-serif;
.ant-input {
background-color: transparent;
font-family: "DM Mono", sans-serif;
font-size: var(--fontSize);
font-weight: var(--fontWeight);
padding: 0 10px;
}
}
}

View File

@ -1,57 +0,0 @@
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

@ -1,39 +0,0 @@
import React from "react"
import * as antd from "antd"
import useRequest from "comty.js/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

@ -21,7 +21,7 @@ const ProfileCreator = (props) => {
await props.onEdit(name) await props.onEdit(name)
} }
} else { } else {
const result = await Streaming.createOrUpdateProfile({ const result = await Streaming.createProfile({
profile_name: name, profile_name: name,
}).catch((error) => { }).catch((error) => {
console.error(error) console.error(error)

View File

@ -1,360 +0,0 @@
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.createOrUpdateProfile({
[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() {
app.layout.modal.open("name_editor", ProfileCreator, {
props: {
editValue: profile.profile_name,
onEdit: async (value) => {
await handleChange("profile_name", value)
app.eventBus.emit("app:profiles_updated", profile._id)
},
},
})
}
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="tvstudio-profile-data">
<div className="tvstudio-profile-data-header">
<img
className="tvstudio-profile-data-header-image"
src={profile.info?.thumbnail}
/>
<div className="tvstudio-profile-data-header-content">
<EditableText
value={profile.info?.title ?? "Untitled"}
className="tvstudio-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="tvstudio-profile-data-header-description"
style={{
"--fontSize": "1rem",
}}
onSave={(newValue) => {
return handleChange("description", newValue)
}}
disabled={loading}
/>
</div>
</div>
<div className="tvstudio-profile-data-field">
<div className="tvstudio-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="tvstudio-profile-data-field">
<div className="tvstudio-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="tvstudio-profile-data-field">
<div className="tvstudio-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>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>RTSPT [vrchat]</span>
</div>
<div className="key-value-field-description">
<p>
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">
<span>
{profile.sources.rtsp.replace(
"rtsp://",
"rtspt://",
)}
</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="tvstudio-profile-data-field">
<div className="tvstudio-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

@ -1,66 +0,0 @@
.tvstudio-profile-data {
display: flex;
flex-direction: column;
gap: 20px;
.tvstudio-profile-data-header {
position: relative;
max-height: 200px;
background-repeat: no-repeat;
background-position: center;
background-size: cover;
border-radius: 12px;
overflow: hidden;
.tvstudio-profile-data-header-image {
position: absolute;
left: 0;
top: 0;
z-index: 10;
width: 100%;
}
.tvstudio-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);
}
}
.tvstudio-profile-data-field {
display: flex;
flex-direction: column;
gap: 10px;
.tvstudio-profile-data-field-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
span {
font-size: 1.5rem;
}
}
}
}

View File

@ -1,87 +0,0 @@
import React from "react"
import * as antd from "antd"
import Streaming from "@models/spectrum"
const ProfileSelector = (props) => {
const [loading, list, 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(list[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"
>
{
list.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

@ -1,57 +1,64 @@
import React from "react" import React from "react"
import * as antd from "antd" import * as antd from "antd"
import ProfileSelector from "./components/ProfileSelector"
import ProfileData from "./components/ProfileData"
import ProfileCreator from "./components/ProfileCreator" import ProfileCreator from "./components/ProfileCreator"
import Skeleton from "@components/Skeleton"
import Streaming from "@models/spectrum"
import useCenteredContainer from "@hooks/useCenteredContainer" 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(true) useCenteredContainer(false)
const [selectedProfileId, setSelectedProfileId] = React.useState(null) const [loading, list, error, repeat] = app.cores.api.useRequest(
Streaming.getOwnProfiles,
)
function newProfileModal() { function handleNewProfileClick() {
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) setSelectedProfileId(id)
}, },
} },
}) })
} }
return <div className="tvstudio-page"> function handleProfileClick(id) {
<div className="tvstudio-page-actions"> app.location.push(`/studio/tv/${id}`)
<ProfileSelector }
onChange={setSelectedProfileId}
/>
<antd.Button if (loading) {
type="primary" return <Skeleton />
onClick={newProfileModal} }
>
return (
<div className="tvstudio-page">
<div className="tvstudio-page-actions">
<antd.Button type="primary" onClick={handleNewProfileClick}>
Create new Create new
</antd.Button> </antd.Button>
</div> </div>
{ {list.length > 0 &&
selectedProfileId && <ProfileData list.map((profile, index) => {
profile_id={selectedProfileId} return (
<Profile
key={index}
profile={profile}
onClick={() => handleProfileClick(profile._id)}
/> />
} )
})}
{
!selectedProfileId && <div className="tvstudio-page-selector-hint">
<h1>
Select profile or create new
</h1>
</div>
}
</div> </div>
)
} }
export default TVStudioPage export default TVStudioPage

View File

@ -7,18 +7,18 @@ export default [
key: "feed", key: "feed",
label: "Feed", label: "Feed",
icon: "IoMdPaper", icon: "IoMdPaper",
component: FeedTab component: FeedTab,
}, },
{ {
key: "global", key: "global",
label: "Global", label: "Global",
icon: "FiGlobe", icon: "FiGlobe",
component: GlobalTab component: GlobalTab,
}, },
{ {
key: "savedPosts", key: "savedPosts",
label: "Saved posts", label: "Saved",
icon: "FiBookmark", icon: "FiBookmark",
component: SavedPostsTab component: SavedPostsTab,
} },
] ]

View File

@ -63,13 +63,11 @@ const SessionItem = (props) => {
return UAParser(session.client) return UAParser(session.client)
}) })
return <div return (
className={classnames( <div
"security_sessions_list_item_wrapper", className={classnames("security_sessions_list_item_wrapper", {
{ ["collapsed"]: collapsed,
["collapsed"]: collapsed })}
}
)}
> >
<div <div
id={session._id} id={session._id}
@ -78,15 +76,15 @@ const SessionItem = (props) => {
onClick={onClickCollapse} onClick={onClickCollapse}
> >
<div className="security_sessions_list_item_icon"> <div className="security_sessions_list_item_icon">
<DeviceIcon <DeviceIcon ua={ua} />
ua={ua}
/>
</div> </div>
<antd.Badge dot={isCurrentSession}> <antd.Badge dot={isCurrentSession}>
<div className="security_sessions_list_item_info"> <div className="security_sessions_list_item_info">
<div className="security_sessions_list_item_title"> <div className="security_sessions_list_item_title">
<h3><Icons.FiTag /> {session.session_uuid}</h3> <h3>
<Icons.FiTag /> {session._id}
</h3>
</div> </div>
<div className="security_sessions_list_item_info_details"> <div className="security_sessions_list_item_info_details">
@ -94,15 +92,15 @@ const SessionItem = (props) => {
<Icons.FiClock /> <Icons.FiClock />
<span> <span>
{moment(session.date).format("DD/MM/YYYY HH:mm")} {moment(session.date).format(
"DD/MM/YYYY HH:mm",
)}
</span> </span>
</div> </div>
<div className="security_sessions_list_item_info_details_item"> <div className="security_sessions_list_item_info_details_item">
<Icons.IoMdLocate /> <Icons.IoMdLocate />
<span> <span>{session.ip_address}</span>
{session.ip_address}
</span>
</div> </div>
</div> </div>
</div> </div>
@ -111,11 +109,7 @@ const SessionItem = (props) => {
<div className="security_sessions_list_item_extra-body"> <div className="security_sessions_list_item_extra-body">
<div className="security_sessions_list_item_actions"> <div className="security_sessions_list_item_actions">
<antd.Button <antd.Button onClick={onClickRevoke} danger size="small">
onClick={onClickRevoke}
danger
size="small"
>
Revoke Revoke
</antd.Button> </antd.Button>
</div> </div>
@ -123,22 +117,21 @@ const SessionItem = (props) => {
<div className="security_sessions_list_item_info_details_item"> <div className="security_sessions_list_item_info_details_item">
<Icons.MdDns /> <Icons.MdDns />
<span> <span>{session.location}</span>
{session.location}
</span>
</div> </div>
{ {ua.device.vendor && (
ua.device.vendor && <div className="security_sessions_list_item_info_details_item"> <div className="security_sessions_list_item_info_details_item">
<Icons.FiCpu /> <Icons.FiCpu />
<span> <span>
{ua.device.vendor} | {ua.device.model} {ua.device.vendor} | {ua.device.model}
</span> </span>
</div> </div>
} )}
</div> </div>
</div> </div>
)
} }
export default SessionItem export default SessionItem

View File

@ -35,8 +35,15 @@ export default () => {
app.message.warning("Not implemented yet") app.message.warning("Not implemented yet")
} }
const onClickRevokeAll = async () => { const onClickDestroyAll = async () => {
app.message.warning("Not implemented yet") app.layout.modal.confirm({
headerText: "Are you sure you want to delete this release?",
descriptionText: "This action cannot be undone.",
onConfirm: async () => {
await SessionModel.destroyAll()
await app.auth.logout(true)
},
})
} }
React.useEffect(() => { React.useEffect(() => {
@ -50,17 +57,18 @@ export default () => {
const offset = (sessionsPage - 1) * itemsPerPage const offset = (sessionsPage - 1) * itemsPerPage
const slicedItems = sessions.slice(offset, offset + itemsPerPage) const slicedItems = sessions.slice(offset, offset + itemsPerPage)
return <div className="security_sessions"> return (
<div className="security_sessions">
<div className="security_sessions_list"> <div className="security_sessions_list">
{ {slicedItems.map((session) => {
slicedItems.map((session) => { return (
return <SessionItem <SessionItem
key={session._id} key={session._id}
session={session} session={session}
onClickRevoke={onClickRevoke} onClickRevoke={onClickRevoke}
/> />
}) )
} })}
<antd.Pagination <antd.Pagination
onChange={(page) => { onChange={(page) => {
@ -73,5 +81,7 @@ export default () => {
simple simple
/> />
</div> </div>
<antd.Button onClick={onClickDestroyAll}>Destroy all</antd.Button>
</div> </div>
)
} }

View File

@ -0,0 +1,11 @@
export default (buffer) => {
const bytes = new Uint8Array(buffer)
let binary = ""
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return window.btoa(binary)
}

View File

@ -0,0 +1,11 @@
export default (base64) => {
const binaryString = window.atob(base64)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return bytes
}

View File

@ -117,7 +117,7 @@ function registerAliases() {
registerBaseAliases(global["__src"], global["aliases"]) registerBaseAliases(global["__src"], global["aliases"])
} }
async function injectEnvFromInfisical() { global.injectEnvFromInfisical = async function injectEnvFromInfisical() {
const envMode = (global.FORCE_ENV ?? global.isProduction) ? "prod" : "dev" const envMode = (global.FORCE_ENV ?? global.isProduction) ? "prod" : "dev"
console.log( console.log(

View File

@ -13,6 +13,7 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib {
audioBitrate: "320k", audioBitrate: "320k",
audioSampleRate: "48000", audioSampleRate: "48000",
segmentTime: 10, segmentTime: 10,
minBufferTime: 5,
includeMetadata: true, includeMetadata: true,
...params, ...params,
} }
@ -20,7 +21,6 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib {
buildSegmentationArgs = () => { buildSegmentationArgs = () => {
const args = [ const args = [
//`-threads 1`, // limits to one thread
`-v error -hide_banner -progress pipe:1`, `-v error -hide_banner -progress pipe:1`,
`-i ${this.params.input}`, `-i ${this.params.input}`,
`-c:a ${this.params.audioCodec}`, `-c:a ${this.params.audioCodec}`,
@ -56,6 +56,39 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib {
return args return args
} }
_updateMpdMinBufferTime = async (mpdPath, newMinBufferTimeSecs) => {
try {
const mpdTagRegex = /(<MPD[^>]*)/
let mpdContent = await fs.promises.readFile(mpdPath, "utf-8")
const minBufferTimeAttribute = `minBufferTime="PT${newMinBufferTimeSecs}.0S"`
const existingMinBufferTimeRegex =
/(<MPD[^>]*minBufferTime=")[^"]*(")/
if (existingMinBufferTimeRegex.test(mpdContent)) {
mpdContent = mpdContent.replace(
existingMinBufferTimeRegex,
`$1PT${newMinBufferTimeSecs}.0S$2`,
)
await fs.promises.writeFile(mpdPath, mpdContent, "utf-8")
} else {
if (mpdTagRegex.test(mpdContent)) {
mpdContent = mpdContent.replace(
mpdTagRegex,
`$1 ${minBufferTimeAttribute}`,
)
await fs.promises.writeFile(mpdPath, mpdContent, "utf-8")
}
}
} catch (error) {
console.error(
`[SegmentedAudioMPDJob] Error updating MPD minBufferTime for ${mpdPath}:`,
error,
)
}
}
run = async () => { run = async () => {
const segmentationCmd = this.buildSegmentationArgs() const segmentationCmd = this.buildSegmentationArgs()
const outputPath = const outputPath =
@ -75,7 +108,7 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib {
const inputProbe = await Utils.probe(this.params.input) const inputProbe = await Utils.probe(this.params.input)
try { try {
const result = await this.ffmpeg({ const ffmpegResult = await this.ffmpeg({
args: segmentationCmd, args: segmentationCmd,
onProcess: (process) => { onProcess: (process) => {
this.handleProgress( this.handleProgress(
@ -89,6 +122,17 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib {
cwd: outputPath, cwd: outputPath,
}) })
if (fs.existsSync(outputFile)) {
await this._updateMpdMinBufferTime(
outputFile,
this.params.minBufferTime,
)
} else {
console.warn(
`[SegmentedAudioMPDJob] MPD file ${outputFile} not found after ffmpeg run. Skipping minBufferTime update.`,
)
}
let outputProbe = await Utils.probe(outputFile) let outputProbe = await Utils.probe(outputFile)
this.emit("end", { this.emit("end", {
@ -100,9 +144,9 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib {
outputFile: outputFile, outputFile: outputFile,
}) })
return result return ffmpegResult
} catch (err) { } catch (err) {
return this.emit("error", err) this.emit("error", err)
} }
} }
} }

View File

@ -0,0 +1,40 @@
export default {
name: "ChatKey",
collection: "chat_keys",
schema: {
user_id_1: {
type: String,
required: true,
},
user_id_2: {
type: String,
required: true,
},
encrypted_key_1: {
type: String,
required: true,
},
encrypted_key_2: {
type: String,
default: null,
},
created_at: {
type: Number,
default: () => new Date().getTime(),
},
updated_at: {
type: Number,
default: () => new Date().getTime(),
},
},
extend: {
async findByUsers(user1, user2) {
return await this.findOne({
$or: [
{ user_id_1: user1, user_id_2: user2 },
{ user_id_1: user2, user_id_2: user1 },
],
})
},
},
}

View File

@ -7,5 +7,9 @@ export default {
to_user_id: { type: String, required: true }, to_user_id: { type: String, required: true },
content: { type: String, required: true }, content: { type: String, required: true },
created_at: { type: Date, required: true }, created_at: { type: Date, required: true },
} encrypted: {
type: Boolean,
default: false,
},
},
} }

View File

@ -5,16 +5,28 @@ import path from "path"
function generateModels() { function generateModels() {
let models = {} let models = {}
const dirs = fs.readdirSync(__dirname).filter(file => file !== "index.js") const dirs = fs.readdirSync(__dirname).filter((file) => file !== "index.js")
dirs.forEach((file) => { dirs.forEach((file) => {
const model = require(path.join(__dirname, file)).default const model = require(path.join(__dirname, file)).default
if (mongoose.models[model.name]) { if (mongoose.models[model.name]) {
return models[model.name] = mongoose.model(model.name) return (models[model.name] = mongoose.model(model.name))
} }
return models[model.name] = mongoose.model(model.name, new Schema(model.schema), model.collection) model.schema = new Schema(model.schema)
if (model.extend) {
Object.keys(model.extend).forEach((key) => {
model.schema.statics[key] = model.extend[key]
})
}
return (models[model.name] = mongoose.model(
model.name,
model.schema,
model.collection,
))
}) })
return models return models

View File

@ -0,0 +1,23 @@
export default {
name: "MusicLibraryItem",
collection: "music_library_items",
schema: {
user_id: {
type: String,
required: true,
},
item_id: {
type: String,
required: true,
},
kind: {
type: String,
required: true,
enum: ["tracks", "playlists", "releases"],
},
created_at: {
type: Date,
required: true,
},
},
}

View File

@ -4,31 +4,33 @@ export default {
schema: { schema: {
user_id: { user_id: {
type: String, type: String,
required: true required: true,
}, },
title: { title: {
type: String, type: String,
required: true required: true,
}, },
description: { description: {
type: String type: String,
}, },
list: { list: {
type: Object, type: Object,
default: [], default: [],
required: true required: true,
}, },
cover: { cover: {
type: String, type: String,
default: "https://storage.ragestudio.net/comty-static-assets/default_song.png" default:
"https://storage.ragestudio.net/comty-static-assets/default_song.png",
}, },
thumbnail: { thumbnail: {
type: String, type: String,
default: "https://storage.ragestudio.net/comty-static-assets/default_song.png" default:
"https://storage.ragestudio.net/comty-static-assets/default_song.png",
}, },
created_at: { created_at: {
type: Date, type: Date,
required: true required: true,
}, },
publisher: { publisher: {
type: Object, type: Object,
@ -37,5 +39,5 @@ export default {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
} },
} }

View File

@ -27,8 +27,9 @@ export default {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
publish_date: { created_at: {
type: Date, type: Date,
required: true,
}, },
cover: { cover: {
type: String, type: String,

View File

@ -0,0 +1,23 @@
export default {
name: "UserChat",
collection: "user_chats",
schema: {
user_1: {
type: Object,
required: true,
},
user_2: {
type: Object,
required: true,
},
started_at: {
type: Number,
default: () => new Date().getTime(),
},
updated_at: {
type: Number,
default: () => new Date().getTime(),
},
// ... set other things like themes, or more info
},
}

View File

@ -0,0 +1,15 @@
export default {
name: "UserDHKeyPair",
collection: "user_dh_key_pairs",
schema: {
user_id: {
type: String,
required: true,
unique: true,
},
str: {
type: String,
required: true,
},
},
}

Some files were not shown because too many files have changed in this diff Show More