mirror of
https://github.com/ragestudio/comty.git
synced 2025-06-09 10:34:17 +00:00
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:
parent
2b8d47e18c
commit
8482f2e457
2
comty.js
2
comty.js
@ -1 +1 @@
|
||||
Subproject commit 0face5f004c2b1484751ea61228ec4ee226f49d4
|
||||
Subproject commit 511a81e313d0723a2d4f9887c1632ff5fc19658d
|
@ -4,8 +4,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"yarn dev:client\" \"yarn dev:server\"",
|
||||
"dev:server": "cd packages/server && yarn dev",
|
||||
"dev:client": "cd packages/app && yarn dev",
|
||||
"dev:server": "cd packages/server && npm run dev",
|
||||
"dev:client": "cd packages/app && npm run dev",
|
||||
"postinstall": "node ./scripts/post-install.js"
|
||||
},
|
||||
"dependencies": {
|
||||
|
@ -34,6 +34,7 @@
|
||||
"bear-react-carousel": "^4.0.10-alpha.0",
|
||||
"classnames": "2.3.1",
|
||||
"comty.js": "^0.64.0",
|
||||
"d3": "^7.9.0",
|
||||
"dashjs": "^5.0.0",
|
||||
"dompurify": "^3.0.0",
|
||||
"fast-average-color": "^9.2.0",
|
||||
|
@ -17,23 +17,28 @@ export default class AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
state = {
|
||||
user: null,
|
||||
}
|
||||
|
||||
public = {
|
||||
login: () => {
|
||||
app.layout.draggable.open("login", Login, {
|
||||
props: {
|
||||
onDone: () => {
|
||||
app.layout.draggable.destroy("login")
|
||||
this._emitBehavior("onLogin")
|
||||
},
|
||||
componentProps: {
|
||||
onDone: this.onLoginCallback,
|
||||
},
|
||||
})
|
||||
},
|
||||
logout: () => {
|
||||
logout: (bypass) => {
|
||||
if (bypass === true) {
|
||||
AuthModel.logout()
|
||||
return this._emitBehavior("onLogout")
|
||||
}
|
||||
|
||||
app.layout.modal.confirm({
|
||||
headerText: "Logout",
|
||||
descriptionText: "Are you sure you want to logout?",
|
||||
onConfirm: () => {
|
||||
console.log("Logout confirmed")
|
||||
AuthModel.logout()
|
||||
this._emitBehavior("onLogout")
|
||||
},
|
||||
@ -65,10 +70,6 @@ export default class AuthManager {
|
||||
},
|
||||
}
|
||||
|
||||
state = {
|
||||
user: null,
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
const token = await SessionModel.token
|
||||
|
||||
@ -103,4 +104,6 @@ export default class AuthManager {
|
||||
await this.behaviors[behavior](...args)
|
||||
}
|
||||
}
|
||||
|
||||
//onLoginCallback = async (state, result) => {}
|
||||
}
|
||||
|
@ -3,23 +3,12 @@ import classnames from "classnames"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
export default (props) => {
|
||||
const LikeButton = (props) => {
|
||||
const [liked, setLiked] = React.useState(
|
||||
typeof props.liked === "function" ? false : props.liked,
|
||||
)
|
||||
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() {
|
||||
if (props.disabled) {
|
||||
return false
|
||||
@ -48,7 +37,7 @@ export default (props) => {
|
||||
}, 500)
|
||||
|
||||
if (typeof props.onClick === "function") {
|
||||
props.onClick()
|
||||
props.onClick(!liked)
|
||||
}
|
||||
|
||||
setLiked(!liked)
|
||||
@ -74,3 +63,5 @@ export default (props) => {
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default LikeButton
|
||||
|
@ -91,11 +91,11 @@ class Login extends React.Component {
|
||||
})
|
||||
}
|
||||
|
||||
onDone = async ({ mfa_required } = {}) => {
|
||||
if (mfa_required) {
|
||||
onDone = async (result = {}) => {
|
||||
if (result.mfa_required) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
mfa_required: mfa_required,
|
||||
mfa_required: result.mfa_required,
|
||||
})
|
||||
|
||||
return false
|
||||
@ -108,7 +108,7 @@ class Login extends React.Component {
|
||||
}
|
||||
|
||||
if (typeof this.props.onDone === "function") {
|
||||
await this.props.onDone()
|
||||
await this.props.onDone(this.state, result)
|
||||
}
|
||||
|
||||
return true
|
||||
|
@ -11,7 +11,7 @@ const typeToNavigationType = {
|
||||
album: "album",
|
||||
track: "track",
|
||||
single: "track",
|
||||
ep: "album"
|
||||
ep: "album",
|
||||
}
|
||||
|
||||
const Playlist = (props) => {
|
||||
@ -28,27 +28,22 @@ const Playlist = (props) => {
|
||||
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) => {
|
||||
e.stopPropagation()
|
||||
|
||||
app.cores.player.start(playlist.list)
|
||||
app.cores.player.start(playlist.items)
|
||||
}
|
||||
|
||||
|
||||
const subtitle = playlist.type === "playlist" ? `By ${playlist.user_id}` : (playlist.description ?? (playlist.publisher && `Release from ${playlist.publisher?.fullName}`))
|
||||
|
||||
return <div
|
||||
return (
|
||||
<div
|
||||
id={playlist._id}
|
||||
key={props.key}
|
||||
className={classnames(
|
||||
"playlist",
|
||||
{
|
||||
"cover-hovering": coverHover
|
||||
}
|
||||
)}
|
||||
className={classnames("playlist", {
|
||||
"cover-hovering": coverHover,
|
||||
"row-mode": props.row === true,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="playlist_cover"
|
||||
@ -57,11 +52,15 @@ const Playlist = (props) => {
|
||||
onClick={onClickPlay}
|
||||
>
|
||||
<div className="playlist_cover_mask">
|
||||
<Icons.MdPlayArrow />
|
||||
<Icons.FiPlay />
|
||||
</div>
|
||||
|
||||
<ImageViewer
|
||||
src={playlist.cover ?? playlist.thumbnail ?? "/assets/no_song.png"}
|
||||
src={
|
||||
playlist.cover ??
|
||||
playlist.thumbnail ??
|
||||
"/assets/no_song.png"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -69,31 +68,37 @@ const Playlist = (props) => {
|
||||
<div className="playlist_info_title" onClick={onClick}>
|
||||
<h1>{playlist.title}</h1>
|
||||
</div>
|
||||
|
||||
{
|
||||
subtitle && <div className="playlist_info_subtitle">
|
||||
{props.row && (
|
||||
<div className="playlist_details">
|
||||
<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 />
|
||||
{playlist.type ?? "playlist"}
|
||||
</p>
|
||||
}
|
||||
</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
|
@ -14,6 +14,45 @@
|
||||
|
||||
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 {
|
||||
.playlist_cover {
|
||||
.playlist_cover_mask {
|
||||
@ -29,18 +68,27 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
//max-height: 150px;
|
||||
width: @playlist_cover_maxSize;
|
||||
height: @playlist_cover_maxSize;
|
||||
|
||||
transition: all 0.2s ease-in-out;
|
||||
z-index: 50;
|
||||
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
background-color: var(--background-color-accent);
|
||||
|
||||
.image-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
img {
|
||||
width: @playlist_cover_maxSize;
|
||||
height: @playlist_cover_maxSize;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist_cover_mask {
|
||||
@ -116,28 +164,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.playlist_actions {
|
||||
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 {
|
||||
.playlist_details {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
|
@ -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
|
158
packages/app/src/components/Music/PlaylistView/header.jsx
Normal file
158
packages/app/src/components/Music/PlaylistView/header.jsx
Normal 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
|
@ -1,437 +1,213 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
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 { Context as PlaylistContext } from "@contexts/WithPlaylistContext"
|
||||
|
||||
import useWsEvents from "@hooks/useWsEvents"
|
||||
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 PlaylistHeader from "./header"
|
||||
import TrackList from "./list"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
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>
|
||||
),
|
||||
}
|
||||
|
||||
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 PlaylistView = ({
|
||||
playlist: initialPlaylist,
|
||||
noHeader = false,
|
||||
onLoadMore,
|
||||
hasMore,
|
||||
}) => {
|
||||
const [playlist, setPlaylist] = React.useState(initialPlaylist)
|
||||
const [searchResults, setSearchResults] = React.useState(null)
|
||||
const [owningPlaylist, setOwningPlaylist] = React.useState(
|
||||
checkUserIdIsSelf(props.playlist?.user_id),
|
||||
const searchTimeoutRef = React.useRef(null) // Ref for debounce timeout
|
||||
|
||||
// Derive ownership directly instead of using state
|
||||
const isOwner = React.useMemo(
|
||||
() => checkUserIdIsSelf(playlist?.user_id),
|
||||
[playlist],
|
||||
)
|
||||
|
||||
const moreMenuItems = 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 = {
|
||||
const playlistContextValue = React.useMemo(
|
||||
() => ({
|
||||
playlist_data: playlist,
|
||||
owning_playlist: owningPlaylist,
|
||||
add_track: (track) => {},
|
||||
remove_track: (track) => {},
|
||||
}
|
||||
owning_playlist: isOwner,
|
||||
add_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) => {
|
||||
//TODO: Implement me using API
|
||||
return app.message.info("Not implemented yet...")
|
||||
// TODO: Implement API call for search
|
||||
console.log("Searching for:", value)
|
||||
setSearchResults([]) // Placeholder: clear results or set loading state
|
||||
return app.message.info("Search not implemented yet...")
|
||||
}
|
||||
|
||||
const handleOnSearchChange = (value) => {
|
||||
debounceSearch = setTimeout(() => {
|
||||
const handleSearchChange = (value) => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
makeSearch(value)
|
||||
}, 500)
|
||||
}, 500) // 500ms debounce
|
||||
}
|
||||
|
||||
const handleOnSearchEmpty = () => {
|
||||
if (debounceSearch) {
|
||||
clearTimeout(debounceSearch)
|
||||
const handleSearchEmpty = () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
setSearchResults(null) // Clear search results when input is cleared
|
||||
}
|
||||
|
||||
setSearchResults(null)
|
||||
}
|
||||
|
||||
const handleOnClickPlaylistPlay = () => {
|
||||
const handlePlayAll = () => {
|
||||
if (playlist?.items?.length > 0) {
|
||||
app.cores.player.start(playlist.items)
|
||||
}
|
||||
|
||||
const handleOnClickViewDetails = () => {
|
||||
app.layout.modal.open("playlist_info", PlaylistInfo, {
|
||||
props: {
|
||||
data: playlist,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleOnClickTrack = (track) => {
|
||||
// search index of track
|
||||
const index = playlist.items.findIndex((item) => {
|
||||
return item._id === track._id
|
||||
})
|
||||
const handleViewDetails = () => {
|
||||
if (playlist?.description) {
|
||||
app.layout.modal.open(
|
||||
"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) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// check if clicked track is currently playing
|
||||
if (app.cores.player.state.track_manifest?._id === track._id) {
|
||||
app.cores.player.playback.toggle()
|
||||
const playerCore = app.cores.player
|
||||
// Toggle playback if the clicked track is already playing
|
||||
if (playerCore.state.track_manifest?._id === track._id) {
|
||||
playerCore.playback.toggle()
|
||||
} else {
|
||||
app.cores.player.start(playlist.items, {
|
||||
startIndex: index,
|
||||
})
|
||||
// Start playback from the clicked track
|
||||
playerCore.start(playlist.items, { startIndex: index })
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateTrackLike = (track_id, liked) => {
|
||||
const handleTrackStateChange = (track_id, update) => {
|
||||
setPlaylist((prev) => {
|
||||
const index = prev.list.findIndex((item) => {
|
||||
return item._id === track_id
|
||||
})
|
||||
if (!prev) return prev
|
||||
const trackIndex = prev.items.findIndex(
|
||||
(item) => item._id === track_id,
|
||||
)
|
||||
|
||||
if (index !== -1) {
|
||||
const newState = {
|
||||
...prev,
|
||||
}
|
||||
|
||||
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],
|
||||
if (trackIndex !== -1) {
|
||||
const updatedItems = [...prev.items]
|
||||
updatedItems[trackIndex] = {
|
||||
...updatedItems[trackIndex],
|
||||
...update,
|
||||
}
|
||||
|
||||
return newState
|
||||
return { ...prev, items: updatedItems }
|
||||
}
|
||||
|
||||
return prev
|
||||
})
|
||||
}
|
||||
|
||||
const handleMoreMenuClick = async (e) => {
|
||||
const handler = MoreMenuHandlers[e.key]
|
||||
|
||||
if (typeof handler !== "function") {
|
||||
throw new Error(`Invalid menu handler [${e.key}]`)
|
||||
if (typeof handler === "function") {
|
||||
await handler(playlist)
|
||||
} 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(() => {
|
||||
setPlaylist(props.playlist)
|
||||
setOwningPlaylist(checkUserIdIsSelf(props.playlist?.user_id))
|
||||
}, [props.playlist])
|
||||
setPlaylist(initialPlaylist)
|
||||
setSearchResults(null)
|
||||
}, [initialPlaylist])
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!playlist) {
|
||||
return <antd.Skeleton active />
|
||||
}
|
||||
|
||||
const playlistType = playlist.type?.toLowerCase() ?? "playlist"
|
||||
|
||||
return (
|
||||
<PlaylistContext.Provider value={contextValues}>
|
||||
<PlaylistContext.Provider value={playlistContextValue}>
|
||||
<WithPlayerContext>
|
||||
<div className={classnames("playlist_view")}>
|
||||
{!props.noHeader && (
|
||||
<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">
|
||||
{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
|
||||
}
|
||||
{!noHeader && (
|
||||
<PlaylistHeader
|
||||
playlist={playlist}
|
||||
owningPlaylist={isOwner}
|
||||
onPlayAll={handlePlayAll}
|
||||
onViewDetails={handleViewDetails}
|
||||
onMoreMenuClick={handleMoreMenuClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{owningPlaylist && (
|
||||
<antd.Dropdown
|
||||
trigger={["click"]}
|
||||
placement="bottom"
|
||||
menu={{
|
||||
items: moreMenuItems,
|
||||
onClick:
|
||||
handleMoreMenuClick,
|
||||
}}
|
||||
>
|
||||
<antd.Button
|
||||
icon={<Icons.MdMoreVert />}
|
||||
<TrackList
|
||||
tracks={playlist.items || []}
|
||||
searchResults={searchResults}
|
||||
onTrackClick={handleTrackClick}
|
||||
onTrackStateChange={handleTrackStateChange}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSearchEmpty={handleSearchEmpty}
|
||||
onLoadMore={onLoadMore}
|
||||
hasMore={hasMore}
|
||||
noHeader={noHeader}
|
||||
/>
|
||||
</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>
|
||||
</WithPlayerContext>
|
||||
</PlaylistContext.Provider>
|
||||
|
@ -207,6 +207,13 @@ html {
|
||||
align-items: center;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
.likeButtonWrapper {
|
||||
background-color: var(--background-color-primary);
|
||||
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
98
packages/app/src/components/Music/PlaylistView/list.jsx
Normal file
98
packages/app/src/components/Music/PlaylistView/list.jsx
Normal 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
|
57
packages/app/src/components/Music/Radio/index.jsx
Normal file
57
packages/app/src/components/Music/Radio/index.jsx
Normal 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
|
82
packages/app/src/components/Music/Radio/index.less
Normal file
82
packages/app/src/components/Music/Radio/index.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -206,7 +206,7 @@ html {
|
||||
|
||||
.music-track_title {
|
||||
font-size: 1rem;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
//font-family: "Space Grotesk", sans-serif;
|
||||
}
|
||||
|
||||
.music-track_artist {
|
||||
|
@ -286,7 +286,7 @@ const ReleaseEditor = (props) => {
|
||||
icon={<Icons.MdLink />}
|
||||
onClick={() =>
|
||||
app.location.push(
|
||||
`/music/release/${globalState._id}`,
|
||||
`/music/list/${globalState._id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
|
@ -13,7 +13,6 @@ export class Tab extends React.Component {
|
||||
error: null,
|
||||
}
|
||||
|
||||
// handle on error
|
||||
componentDidCatch(err) {
|
||||
this.setState({ error: err })
|
||||
}
|
||||
@ -28,7 +27,6 @@ export class Tab extends React.Component {
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{this.props.children}</>
|
||||
}
|
||||
}
|
||||
@ -49,7 +47,7 @@ export class PagePanelWithNavMenu extends React.Component {
|
||||
activeTab:
|
||||
new URLSearchParams(window.location.search).get("type") ??
|
||||
this.props.defaultTab ??
|
||||
this.props.tabs[0].key,
|
||||
this.props.tabs[0]?.key,
|
||||
renders: [],
|
||||
}
|
||||
|
||||
@ -57,41 +55,98 @@ export class PagePanelWithNavMenu extends React.Component {
|
||||
|
||||
interface = {
|
||||
attachComponent: (id, component, options) => {
|
||||
const renders = this.state.renders
|
||||
|
||||
renders.push({
|
||||
this.setState((prevState) => ({
|
||||
renders: [
|
||||
...prevState.renders,
|
||||
{
|
||||
id: id,
|
||||
component: component,
|
||||
options: options,
|
||||
ref: React.createRef(),
|
||||
})
|
||||
|
||||
this.setState({
|
||||
renders: renders,
|
||||
})
|
||||
},
|
||||
],
|
||||
}))
|
||||
},
|
||||
detachComponent: (id) => {
|
||||
const renders = this.state.renders
|
||||
|
||||
const index = renders.findIndex((render) => render.id === id)
|
||||
|
||||
renders.splice(index, 1)
|
||||
|
||||
this.setState({
|
||||
renders: renders,
|
||||
})
|
||||
this.setState((prevState) => ({
|
||||
renders: prevState.renders.filter((render) => render.id !== id),
|
||||
}))
|
||||
},
|
||||
}
|
||||
|
||||
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() {
|
||||
app.layout.page_panels = this.interface
|
||||
|
||||
if (app.isMobile) {
|
||||
app.layout.top_bar.shouldUseTopBarSpacer(true)
|
||||
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() {
|
||||
@ -102,9 +157,11 @@ export class PagePanelWithNavMenu extends React.Component {
|
||||
app.layout.header.render(null)
|
||||
}
|
||||
} else {
|
||||
if (app.layout.top_bar) {
|
||||
app.layout.top_bar.renderDefault()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderActiveTab() {
|
||||
if (!Array.isArray(this.props.tabs)) {
|
||||
@ -112,13 +169,23 @@ export class PagePanelWithNavMenu extends React.Component {
|
||||
return <></>
|
||||
}
|
||||
|
||||
if (this.props.tabs.length === 0) {
|
||||
if (this.props.tabs.length === 0 && !this.state.activeTab) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
// slip the active tab by splitting on "."
|
||||
if (!this.state.activeTab) {
|
||||
const firstTabKey = this.props.tabs[0]?.key
|
||||
|
||||
if (firstTabKey) {
|
||||
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 <></>
|
||||
}
|
||||
|
||||
@ -134,14 +201,12 @@ export class PagePanelWithNavMenu extends React.Component {
|
||||
console.error(
|
||||
"PagePanelWithNavMenu: tab.children is not defined",
|
||||
)
|
||||
|
||||
return (tab = null)
|
||||
}
|
||||
|
||||
tab = tab.children.find(
|
||||
(children) =>
|
||||
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) {
|
||||
return this.props.onNotFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<antd.Result
|
||||
status="404"
|
||||
@ -161,7 +225,6 @@ export class PagePanelWithNavMenu extends React.Component {
|
||||
}
|
||||
|
||||
const componentProps = tab.props ?? this.props.tabProps
|
||||
|
||||
return React.createElement(tab.component, {
|
||||
...componentProps,
|
||||
})
|
||||
@ -176,7 +239,7 @@ export class PagePanelWithNavMenu extends React.Component {
|
||||
await this.props.beforeTabChange(key)
|
||||
}
|
||||
|
||||
await this.setState({ activeTab: key })
|
||||
this.setState({ activeTab: key })
|
||||
|
||||
if (this.props.useSetQueryType) {
|
||||
this.replaceQueryTypeToCurrentTab(key)
|
||||
@ -192,9 +255,11 @@ export class PagePanelWithNavMenu extends React.Component {
|
||||
|
||||
if (this.props.transition) {
|
||||
if (document.startViewTransition) {
|
||||
return document.startViewTransition(() => {
|
||||
document.startViewTransition(() => {
|
||||
this.tabChange(key)
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
console.warn(
|
||||
@ -205,20 +270,17 @@ export class PagePanelWithNavMenu extends React.Component {
|
||||
this.primaryPanelRef.current &&
|
||||
this.primaryPanelRef.current?.classList
|
||||
) {
|
||||
// set to primary panel fade-opacity-leave class
|
||||
this.primaryPanelRef.current.classList.add("fade-opacity-leave")
|
||||
|
||||
// remove fade-opacity-leave class after animation
|
||||
setTimeout(() => {
|
||||
if (this.primaryPanelRef.current) {
|
||||
this.primaryPanelRef.current.classList.remove(
|
||||
"fade-opacity-leave",
|
||||
)
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
}
|
||||
|
||||
return this.tabChange(key)
|
||||
}
|
||||
|
||||
@ -229,59 +291,19 @@ export class PagePanelWithNavMenu extends React.Component {
|
||||
)
|
||||
return []
|
||||
}
|
||||
|
||||
items = items.map((item) => {
|
||||
return {
|
||||
return items.map((item) => ({
|
||||
key: item.key,
|
||||
icon: createIconRender(item.icon),
|
||||
label: item.label,
|
||||
children: item.children && this.getItems(item.children),
|
||||
disabled: item.disabled,
|
||||
props: item.props ?? {},
|
||||
}
|
||||
})
|
||||
|
||||
return items
|
||||
}))
|
||||
}
|
||||
|
||||
render() {
|
||||
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="panel" ref={this.primaryPanelRef}>
|
||||
{this.renderActiveTab()}
|
||||
|
@ -27,8 +27,8 @@ const ExtraActions = (props) => {
|
||||
return false
|
||||
}
|
||||
|
||||
await trackInstance.manifest.serviceOperations.toggleItemFavourite(
|
||||
"track",
|
||||
await trackInstance.manifest.serviceOperations.toggleItemFavorite(
|
||||
"tracks",
|
||||
trackInstance.manifest._id,
|
||||
)
|
||||
}
|
||||
@ -47,7 +47,7 @@ const ExtraActions = (props) => {
|
||||
<LikeButton
|
||||
liked={
|
||||
trackInstance?.manifest?.serviceOperations
|
||||
?.fetchLikeStatus
|
||||
?.isItemFavorited
|
||||
}
|
||||
onClick={handleClickLike}
|
||||
disabled={!trackInstance?.manifest?._id}
|
||||
|
@ -4,20 +4,22 @@ import * as antd from "antd"
|
||||
import "./index.less"
|
||||
|
||||
export default (props) => {
|
||||
return <div className="player-volume_slider">
|
||||
return (
|
||||
<div className="player-volume_slider">
|
||||
<antd.Slider
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={props.volume}
|
||||
onAfterChange={props.onChange}
|
||||
onChangeComplete={props.onChange}
|
||||
defaultValue={props.defaultValue}
|
||||
tooltip={{
|
||||
formatter: (value) => {
|
||||
return `${Math.round(value * 100)}%`
|
||||
}
|
||||
},
|
||||
}}
|
||||
vertical
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -39,7 +39,7 @@ const EventsHandlers = {
|
||||
|
||||
const track = app.cores.player.track()
|
||||
|
||||
return await track.manifest.serviceOperations.toggleItemFavourite(
|
||||
return await track.manifest.serviceOperations.toggleItemFavorite(
|
||||
"track",
|
||||
ctx.track_manifest._id,
|
||||
)
|
||||
@ -133,7 +133,7 @@ const Controls = (props) => {
|
||||
<LikeButton
|
||||
liked={
|
||||
trackInstance?.manifest?.serviceOperations
|
||||
?.fetchLikeStatus
|
||||
?.isItemFavorited
|
||||
}
|
||||
onClick={() => handleAction("like")}
|
||||
disabled={!trackInstance?.manifest?._id}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
import Slider from "@mui/material/Slider"
|
||||
import Slider from "./slider"
|
||||
import classnames from "classnames"
|
||||
|
||||
import seekToTimeLabel from "@utils/seekToTimeLabel"
|
||||
@ -8,6 +7,8 @@ import seekToTimeLabel from "@utils/seekToTimeLabel"
|
||||
import "./index.less"
|
||||
|
||||
export default class SeekBar extends React.Component {
|
||||
static updateInterval = 1000
|
||||
|
||||
state = {
|
||||
playing: app.cores.player.state["playback_status"] === "playing",
|
||||
timeText: "00:00",
|
||||
@ -63,10 +64,16 @@ export default class SeekBar extends React.Component {
|
||||
const seek = app.cores.player.controls.seek()
|
||||
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({
|
||||
sliderTime: percent,
|
||||
sliderTime: Number.isFinite(percent) ? percent : 0,
|
||||
})
|
||||
}
|
||||
|
||||
@ -130,7 +137,7 @@ export default class SeekBar extends React.Component {
|
||||
if (this.state.playing) {
|
||||
this.interval = setInterval(() => {
|
||||
this.updateAll()
|
||||
}, 1000)
|
||||
}, SeekBar.updateInterval)
|
||||
} else {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval)
|
||||
@ -173,7 +180,6 @@ export default class SeekBar extends React.Component {
|
||||
})}
|
||||
>
|
||||
<Slider
|
||||
size="small"
|
||||
value={this.state.sliderTime}
|
||||
disabled={
|
||||
this.props.stopped ||
|
||||
@ -189,18 +195,17 @@ export default class SeekBar extends React.Component {
|
||||
sliderLock: true,
|
||||
})
|
||||
}}
|
||||
onChangeCommitted={() => {
|
||||
onChangeCommitted={(_, value) => {
|
||||
this.setState({
|
||||
sliderLock: false,
|
||||
})
|
||||
|
||||
this.handleSeek(this.state.sliderTime)
|
||||
this.handleSeek(value)
|
||||
|
||||
if (!this.props.playing) {
|
||||
app.cores.player.playback.play()
|
||||
}
|
||||
}}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value) => {
|
||||
return seekToTimeLabel(
|
||||
(value / 100) *
|
||||
|
@ -12,7 +12,9 @@
|
||||
width: 90%;
|
||||
height: 100%;
|
||||
|
||||
margin: 0 0 10px 0;
|
||||
gap: 6px;
|
||||
|
||||
//margin: 0 0 10px 0;
|
||||
|
||||
border-radius: 8px;
|
||||
|
||||
@ -46,30 +48,107 @@
|
||||
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.MuiSlider-rail {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.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);
|
||||
font-family: "DM Mono", monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
139
packages/app/src/components/Player/SeekBar/slider.jsx
Normal file
139
packages/app/src/components/Player/SeekBar/slider.jsx
Normal 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
|
@ -34,7 +34,11 @@ const Indicators = ({ track, playerState }) => {
|
||||
|
||||
if (track.metadata) {
|
||||
if (track.metadata.lossless) {
|
||||
indicators.push(<Icons.Lossless />)
|
||||
indicators.push(
|
||||
<antd.Tooltip title="Lossless Audio">
|
||||
<Icons.Lossless />
|
||||
</antd.Tooltip>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -35,14 +35,6 @@
|
||||
color: var(--text-color-black);
|
||||
}
|
||||
|
||||
.MuiSlider-root {
|
||||
color: var(--text-color-black);
|
||||
|
||||
.MuiSlider-rail {
|
||||
color: var(--text-color-black);
|
||||
}
|
||||
}
|
||||
|
||||
.loadCircle {
|
||||
svg {
|
||||
path {
|
||||
@ -150,11 +142,9 @@
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
|
||||
color: currentColor;
|
||||
@ -188,7 +178,7 @@
|
||||
width: 100%;
|
||||
|
||||
padding: 10px;
|
||||
gap: 5px;
|
||||
gap: 10px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
@ -1,15 +1,14 @@
|
||||
import React from "react"
|
||||
import { Skeleton } from "antd"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
export default () => {
|
||||
return <div className="skeleton">
|
||||
<div className="indicator">
|
||||
<LoadingOutlined spin />
|
||||
<h3>Loading...</h3>
|
||||
</div>
|
||||
const SkeletonComponent = () => {
|
||||
return (
|
||||
<div className="skeleton">
|
||||
<Skeleton active />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SkeletonComponent
|
||||
|
@ -1,4 +1,10 @@
|
||||
.skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
svg {
|
||||
margin: 0 !important;
|
||||
}
|
||||
@ -9,14 +15,7 @@
|
||||
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);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
@ -55,7 +55,7 @@ export class WithPlayerContext extends React.Component {
|
||||
state = app.cores.player.state
|
||||
|
||||
events = {
|
||||
"player.state.update": (state) => {
|
||||
"player.state.update": async (state) => {
|
||||
this.setState(state)
|
||||
},
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { MediaPlayer } from "dashjs"
|
||||
import { MediaPlayer, Debug } from "dashjs"
|
||||
import PlayerProcessors from "./PlayerProcessors"
|
||||
import AudioPlayerStorage from "../player.storage"
|
||||
|
||||
@ -29,6 +29,7 @@ export default class AudioBase {
|
||||
// configure some settings for audio
|
||||
this.audio.crossOrigin = "anonymous"
|
||||
this.audio.preload = "metadata"
|
||||
this.audio.loop = this.player.state.playback_mode === "repeat"
|
||||
|
||||
// listen all events
|
||||
for (const [key, value] of Object.entries(this.audioEvents)) {
|
||||
@ -55,6 +56,9 @@ export default class AudioBase {
|
||||
resetSourceBuffersForTrackSwitch: true,
|
||||
},
|
||||
},
|
||||
// debug: {
|
||||
// logLevel: Debug.LOG_LEVEL_DEBUG,
|
||||
// },
|
||||
})
|
||||
|
||||
this.demuxer.initialize(this.audio, null, false)
|
||||
@ -65,7 +69,10 @@ export default class AudioBase {
|
||||
this.audio.src = null
|
||||
this.audio.currentTime = 0
|
||||
|
||||
if (this.demuxer) {
|
||||
this.demuxer.destroy()
|
||||
}
|
||||
|
||||
this.createDemuxer()
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import ToolBarPlayer from "@components/Player/ToolBarPlayer"
|
||||
import Player from "@components/Player"
|
||||
|
||||
export default class PlayerUI {
|
||||
constructor(player) {
|
||||
@ -21,7 +21,7 @@ export default class PlayerUI {
|
||||
if (app.layout.tools_bar) {
|
||||
this.currentDomWindow = app.layout.tools_bar.attachRender(
|
||||
"mediaPlayer",
|
||||
ToolBarPlayer,
|
||||
Player,
|
||||
undefined,
|
||||
{
|
||||
position: "bottom",
|
||||
|
@ -27,34 +27,63 @@ export default class TrackInstance {
|
||||
play = async (params = {}) => {
|
||||
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.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 {
|
||||
// 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) {
|
||||
this.player.base.createDemuxer()
|
||||
}
|
||||
|
||||
await this.player.base.demuxer.attachSource(
|
||||
`${this.manifest.source}?t=${Date.now()}`,
|
||||
// attach the mpd source to the demuxer
|
||||
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) {
|
||||
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)
|
||||
console.log(`[INSTANCE] [tooks ${this._loadMs}ms] Playing >`, this)
|
||||
}
|
||||
|
||||
pause = async () => {
|
||||
@ -68,64 +97,4 @@ export default class TrackInstance {
|
||||
|
||||
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
|
||||
// }
|
||||
}
|
||||
|
@ -97,18 +97,6 @@ export default class TrackManifest {
|
||||
}
|
||||
|
||||
serviceOperations = {
|
||||
fetchLikeStatus: async () => {
|
||||
if (!this._id) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await this.ctx.serviceProviders.operation(
|
||||
"isItemFavourited",
|
||||
this.service,
|
||||
this,
|
||||
"track",
|
||||
)
|
||||
},
|
||||
fetchLyrics: async () => {
|
||||
if (!this._id) {
|
||||
return null
|
||||
@ -140,19 +128,31 @@ export default class TrackManifest {
|
||||
this,
|
||||
)
|
||||
},
|
||||
toggleItemFavourite: async (to) => {
|
||||
toggleItemFavorite: async (to) => {
|
||||
if (!this._id) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await this.ctx.serviceProviders.operation(
|
||||
"toggleItemFavourite",
|
||||
"toggleItemFavorite",
|
||||
this.service,
|
||||
this,
|
||||
"track",
|
||||
"tracks",
|
||||
to,
|
||||
)
|
||||
},
|
||||
isItemFavorited: async () => {
|
||||
if (!this._id) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await this.ctx.serviceProviders.operation(
|
||||
"isItemFavorited",
|
||||
this.service,
|
||||
this,
|
||||
"tracks",
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
toSeriableObject = () => {
|
||||
|
@ -119,6 +119,7 @@ export default class Player extends Core {
|
||||
return this.queue.currentItem
|
||||
}
|
||||
|
||||
// TODO: Improve performance for large playlists
|
||||
async start(manifest, { time, startIndex = 0, radioId } = {}) {
|
||||
this.ui.attachPlayerComponent()
|
||||
|
||||
@ -150,6 +151,10 @@ export default class Player extends Core {
|
||||
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()) {
|
||||
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
|
||||
// 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") {
|
||||
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")
|
||||
|
||||
|
@ -32,11 +32,11 @@ export default class ComtyMusicServiceInterface {
|
||||
return {}
|
||||
}
|
||||
|
||||
isItemFavourited = async (manifest, itemType) => {
|
||||
return await MusicModel.isItemFavourited(itemType, manifest._id)
|
||||
isItemFavorited = async (manifest, itemType) => {
|
||||
return await MusicModel.isItemFavorited(itemType, manifest._id)
|
||||
}
|
||||
|
||||
toggleItemFavourite = async (manifest, itemType, to) => {
|
||||
return await MusicModel.toggleItemFavourite(itemType, manifest._id, to)
|
||||
toggleItemFavorite = async (manifest, itemType, to) => {
|
||||
return await MusicModel.toggleItemFavorite(itemType, manifest._id, to)
|
||||
}
|
||||
}
|
@ -5,7 +5,11 @@ import { motion, AnimatePresence } from "motion/react"
|
||||
|
||||
import { Icons, createIconRender } from "@components/Icons"
|
||||
|
||||
import { WithPlayerContext, Context } from "@contexts/WithPlayerContext"
|
||||
import {
|
||||
WithPlayerContext,
|
||||
Context,
|
||||
usePlayerStateContext,
|
||||
} from "@contexts/WithPlayerContext"
|
||||
|
||||
import {
|
||||
QuickNavMenuItems,
|
||||
@ -36,33 +40,66 @@ const tourSteps = [
|
||||
const openPlayerView = () => {
|
||||
app.layout.draggable.open("player", PlayerView)
|
||||
}
|
||||
|
||||
const openCreator = () => {
|
||||
app.layout.draggable.open("creator", CreatorView)
|
||||
}
|
||||
|
||||
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(() => {
|
||||
if (currentManifest) {
|
||||
const track = app.cores.player.track()
|
||||
|
||||
if (!app.layout.draggable.exists("player")) {
|
||||
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 (
|
||||
<div
|
||||
className={classnames("player_btn", {
|
||||
bounce: props.playback === "playing",
|
||||
bounce: isPlaying,
|
||||
})}
|
||||
style={{
|
||||
"--average-color": props.colorAnalysis?.rgba,
|
||||
"--color": props.colorAnalysis?.isDark
|
||||
"--average-color": coverAnalyzed?.rgba,
|
||||
"--color": coverAnalyzed?.isDark
|
||||
? "var(--text-color-white)"
|
||||
: "var(--text-color-black)",
|
||||
}}
|
||||
onClick={openPlayerView}
|
||||
>
|
||||
{props.playback === "playing" ? (
|
||||
<Icons.MdMusicNote />
|
||||
) : (
|
||||
<Icons.MdPause />
|
||||
)}
|
||||
{isPlaying ? <Icons.MdMusicNote /> : <Icons.MdPause />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -385,18 +422,7 @@ export class BottomBar extends React.Component {
|
||||
|
||||
{this.context.track_manifest && (
|
||||
<div className="item">
|
||||
<PlayerButton
|
||||
manifest={
|
||||
this.context.track_manifest
|
||||
}
|
||||
playback={
|
||||
this.context.playback_status
|
||||
}
|
||||
colorAnalysis={
|
||||
this.context.track_manifest
|
||||
?.cover_analysis
|
||||
}
|
||||
/>
|
||||
<PlayerButton />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
@ -13,6 +13,7 @@ export class DraggableDrawerController extends React.Component {
|
||||
open: this.open,
|
||||
destroy: this.destroy,
|
||||
actions: this.actions,
|
||||
exists: this.exists,
|
||||
}
|
||||
|
||||
this.state = {
|
||||
@ -40,7 +41,7 @@ export class DraggableDrawerController extends React.Component {
|
||||
const win = this.open("actions-menu", ActionsMenu, {
|
||||
componentProps: {
|
||||
...data,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return win
|
||||
@ -50,7 +51,7 @@ export class DraggableDrawerController extends React.Component {
|
||||
let drawerObj = {
|
||||
id: id,
|
||||
render: render,
|
||||
options: options
|
||||
options: options,
|
||||
}
|
||||
|
||||
const win = app.cores.window_mng.render(
|
||||
@ -59,12 +60,10 @@ export class DraggableDrawerController extends React.Component {
|
||||
options={options}
|
||||
onClosed={() => this.handleDrawerOnClosed(drawerObj)}
|
||||
>
|
||||
{
|
||||
React.createElement(render, {
|
||||
{React.createElement(render, {
|
||||
...options.componentProps,
|
||||
})
|
||||
}
|
||||
</DraggableDrawer>
|
||||
})}
|
||||
</DraggableDrawer>,
|
||||
)
|
||||
|
||||
drawerObj.winId = win.id
|
||||
@ -80,7 +79,9 @@ export class DraggableDrawerController extends React.Component {
|
||||
}
|
||||
|
||||
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) {
|
||||
console.error(`Drawer [${id}] not found`)
|
||||
@ -103,6 +104,10 @@ export class DraggableDrawerController extends React.Component {
|
||||
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.
|
||||
* It will toggle the root scale effect based on the amount of drawers.
|
||||
@ -141,14 +146,10 @@ export const DraggableDrawer = (props) => {
|
||||
return to
|
||||
}
|
||||
|
||||
return <Drawer.Root
|
||||
open={isOpen}
|
||||
onOpenChange={handleOnOpenChanged}
|
||||
>
|
||||
return (
|
||||
<Drawer.Root open={isOpen} onOpenChange={handleOnOpenChanged}>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay
|
||||
className="app-drawer-overlay"
|
||||
/>
|
||||
<Drawer.Overlay className="app-drawer-overlay" />
|
||||
|
||||
<Drawer.Content
|
||||
className="app-drawer-content"
|
||||
@ -156,22 +157,17 @@ export const DraggableDrawer = (props) => {
|
||||
setIsOpen(false)
|
||||
}}
|
||||
>
|
||||
<Drawer.Handle
|
||||
className="app-drawer-handle"
|
||||
/>
|
||||
<Drawer.Handle className="app-drawer-handle" />
|
||||
|
||||
<Drawer.Title
|
||||
className="app-drawer-title"
|
||||
>
|
||||
<Drawer.Title className="app-drawer-title">
|
||||
{props.options?.title ?? "Drawer Title"}
|
||||
</Drawer.Title>
|
||||
|
||||
{
|
||||
React.cloneElement(props.children, {
|
||||
{React.cloneElement(props.children, {
|
||||
close: () => setIsOpen(false),
|
||||
})
|
||||
}
|
||||
})}
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
)
|
||||
}
|
49
packages/app/src/pages/_debug/loqui/index.jsx
Normal file
49
packages/app/src/pages/_debug/loqui/index.jsx
Normal 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
|
@ -12,7 +12,7 @@ export default (props) => {
|
||||
const user_id = props.state.user._id
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
|
@ -7,6 +7,7 @@ import useHideOnMouseStop from "@hooks/useHideOnMouseStop"
|
||||
|
||||
import { Icons } from "@components/Icons"
|
||||
import Controls from "@components/Player/Controls"
|
||||
import SeekBar from "@components/Player/SeekBar"
|
||||
import LiveInfo from "@components/Player/LiveInfo"
|
||||
|
||||
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
|
||||
@ -130,9 +131,11 @@ const PlayerController = React.forwardRef((props, ref) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{playerState.track_manifest?.artist && (
|
||||
<div className="lyrics-player-controller-info-details">
|
||||
<span>{playerState.track_manifest?.artistStr}</span>
|
||||
<span>{playerState.track_manifest?.artist}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{playerState.live && (
|
||||
<LiveInfo radioId={playerState.radioId} />
|
||||
@ -141,40 +144,7 @@ const PlayerController = React.forwardRef((props, ref) => {
|
||||
|
||||
<Controls streamMode={playerState.live} />
|
||||
|
||||
{!playerState.live && (
|
||||
<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>
|
||||
)}
|
||||
{!playerState.live && <SeekBar />}
|
||||
|
||||
<div className="lyrics-player-controller-tags">
|
||||
{playerState.track_manifest?.metadata?.lossless && (
|
||||
|
@ -1,65 +1,74 @@
|
||||
import React from "react"
|
||||
import HLS from "hls.js"
|
||||
|
||||
import classnames from "classnames"
|
||||
|
||||
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
|
||||
|
||||
const maxLatencyInMs = 55
|
||||
|
||||
const LyricsVideo = React.forwardRef((props, videoRef) => {
|
||||
const [playerState] = usePlayerStateContext()
|
||||
|
||||
const { lyrics } = props
|
||||
|
||||
const [initialLoading, setInitialLoading] = React.useState(true)
|
||||
const [syncInterval, setSyncInterval] = React.useState(null)
|
||||
const [syncingVideo, setSyncingVideo] = React.useState(false)
|
||||
const [currentVideoLatency, setCurrentVideoLatency] = React.useState(0)
|
||||
const isDebugEnabled = React.useMemo(
|
||||
() => app.cores.settings.is("_debug", true),
|
||||
[],
|
||||
)
|
||||
|
||||
const hls = React.useRef(new HLS())
|
||||
const syncIntervalRef = React.useRef(null)
|
||||
|
||||
async function seekVideoToSyncAudio() {
|
||||
if (!lyrics) {
|
||||
return null
|
||||
const stopSyncInterval = React.useCallback(() => {
|
||||
setSyncingVideo(false)
|
||||
if (syncIntervalRef.current) {
|
||||
clearInterval(syncIntervalRef.current)
|
||||
syncIntervalRef.current = null
|
||||
}
|
||||
}, [setSyncingVideo])
|
||||
|
||||
const seekVideoToSyncAudio = React.useCallback(async () => {
|
||||
if (
|
||||
!lyrics ||
|
||||
!lyrics.video_source ||
|
||||
typeof lyrics.sync_audio_at_ms === "undefined"
|
||||
typeof lyrics.sync_audio_at_ms === "undefined" ||
|
||||
!videoRef.current
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const currentTrackTime = app.cores.player.controls.seek()
|
||||
|
||||
const currentTrackTime = window.app.cores.player.controls.seek()
|
||||
setSyncingVideo(true)
|
||||
|
||||
let newTime =
|
||||
currentTrackTime + lyrics.sync_audio_at_ms / 1000 + 150 / 1000
|
||||
|
||||
// dec some ms to ensure the video seeks correctly
|
||||
newTime -= 5 / 1000
|
||||
|
||||
videoRef.current.currentTime = newTime
|
||||
}
|
||||
}, [lyrics, videoRef, setSyncingVideo])
|
||||
|
||||
async function syncPlayback() {
|
||||
// if something is wrong, stop syncing
|
||||
const syncPlayback = React.useCallback(
|
||||
async (override = false) => {
|
||||
if (
|
||||
videoRef.current === null ||
|
||||
!videoRef.current ||
|
||||
!lyrics ||
|
||||
!lyrics.video_source ||
|
||||
typeof lyrics.sync_audio_at_ms === "undefined" ||
|
||||
playerState.playback_status !== "playing"
|
||||
typeof lyrics.sync_audio_at_ms === "undefined"
|
||||
) {
|
||||
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 =
|
||||
videoRef.current.currentTime - lyrics.sync_audio_at_ms / 1000
|
||||
|
||||
//console.log(`Current track time: ${currentTrackTime}, current video time: ${currentVideoTime}`)
|
||||
|
||||
const maxOffset = maxLatencyInMs / 1000
|
||||
const currentVideoTimeDiff = Math.abs(
|
||||
currentVideoTime - currentTrackTime,
|
||||
@ -68,112 +77,157 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
|
||||
setCurrentVideoLatency(currentVideoTimeDiff)
|
||||
|
||||
if (syncingVideo === true) {
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
if (currentVideoTimeDiff > maxOffset) {
|
||||
seekVideoToSyncAudio()
|
||||
}
|
||||
}
|
||||
|
||||
function startSyncInterval() {
|
||||
setSyncInterval(setInterval(syncPlayback, 300))
|
||||
}
|
||||
|
||||
function stopSyncInterval() {
|
||||
setSyncingVideo(false)
|
||||
setSyncInterval(null)
|
||||
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}`,
|
||||
},
|
||||
[
|
||||
videoRef,
|
||||
lyrics,
|
||||
playerState.playback_status,
|
||||
setCurrentVideoLatency,
|
||||
syncingVideo,
|
||||
seekVideoToSyncAudio,
|
||||
stopSyncInterval,
|
||||
],
|
||||
)
|
||||
|
||||
if (lyrics && lyrics.video_source) {
|
||||
if (playerState.playback_status === "playing") {
|
||||
videoRef.current.play()
|
||||
startSyncInterval()
|
||||
} else {
|
||||
videoRef.current.pause()
|
||||
stopSyncInterval()
|
||||
const startSyncInterval = React.useCallback(() => {
|
||||
if (syncIntervalRef.current) {
|
||||
clearInterval(syncIntervalRef.current)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [playerState.playback_status])
|
||||
syncIntervalRef.current = setInterval(syncPlayback, 300)
|
||||
}, [syncPlayback])
|
||||
|
||||
//* Handle when lyrics object change
|
||||
React.useEffect(() => {
|
||||
setCurrentVideoLatency(0)
|
||||
stopSyncInterval()
|
||||
const videoElement = videoRef.current
|
||||
if (!videoElement) return
|
||||
|
||||
if (lyrics) {
|
||||
if (lyrics.video_source) {
|
||||
console.log("Loading video source >", lyrics.video_source)
|
||||
if (lyrics && lyrics.video_source) {
|
||||
console.log("VIDEO:: 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")) {
|
||||
videoRef.current.src = lyrics.video_source
|
||||
if (hls.current.media === videoElement) {
|
||||
hls.current.detachMedia()
|
||||
}
|
||||
videoElement.src = lyrics.video_source
|
||||
} else {
|
||||
if (HLS.isSupported()) {
|
||||
if (hls.current.media !== videoElement) {
|
||||
hls.current.attachMedia(videoElement)
|
||||
}
|
||||
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") {
|
||||
videoRef.current.loop = false
|
||||
videoRef.current.currentTime =
|
||||
lyrics.sync_audio_at_ms / 1000
|
||||
|
||||
startSyncInterval()
|
||||
videoElement.loop = false
|
||||
syncPlayback(true)
|
||||
} else {
|
||||
videoRef.current.loop = true
|
||||
videoRef.current.currentTime = 0
|
||||
videoElement.loop = true
|
||||
videoElement.currentTime = 0
|
||||
}
|
||||
|
||||
if (playerState.playback_status === "playing") {
|
||||
videoRef.current.play()
|
||||
} else {
|
||||
videoElement.src = ""
|
||||
if (hls.current) {
|
||||
hls.current.stopLoad()
|
||||
if (hls.current.media) {
|
||||
hls.current.detachMedia()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setInitialLoading(false)
|
||||
}, [lyrics])
|
||||
}, [lyrics, videoRef, hls, setCurrentVideoLatency, setInitialLoading])
|
||||
|
||||
React.useEffect(() => {
|
||||
videoRef.current.addEventListener("seeked", (event) => {
|
||||
setSyncingVideo(false)
|
||||
})
|
||||
stopSyncInterval()
|
||||
|
||||
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 () => {
|
||||
stopSyncInterval()
|
||||
|
||||
if (videoElement) {
|
||||
videoElement.removeEventListener("seeked", handleSeeked)
|
||||
}
|
||||
}, [])
|
||||
if (hlsInstance) {
|
||||
hlsInstance.destroy()
|
||||
}
|
||||
}
|
||||
}, [videoRef, hls, stopSyncInterval, setSyncingVideo])
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.lyrics?.sync_audio_at && (
|
||||
{isDebugEnabled && (
|
||||
<div className={classnames("videoDebugOverlay")}>
|
||||
<div>
|
||||
<p>Maximun latency</p>
|
||||
@ -195,6 +249,7 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
|
||||
controls={false}
|
||||
muted
|
||||
preload="auto"
|
||||
playsInline
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
@ -24,8 +24,8 @@
|
||||
height: 100%;
|
||||
|
||||
background:
|
||||
linear-gradient(0deg, rgba(var(--dominant-color), 1), 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");
|
||||
linear-gradient(0deg, rgb(var(--dominant-color)), rgba(0, 0, 0, 0)),
|
||||
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 {
|
||||
@ -152,7 +152,7 @@
|
||||
|
||||
width: 300px;
|
||||
|
||||
padding: 30px;
|
||||
padding: 20px;
|
||||
|
||||
border-radius: 12px;
|
||||
|
||||
@ -183,12 +183,12 @@
|
||||
|
||||
width: 100%;
|
||||
|
||||
gap: 10px;
|
||||
gap: 5px;
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
|
||||
.lyrics-player-controller-info-title {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
|
||||
width: 100%;
|
||||
@ -224,7 +224,7 @@
|
||||
|
||||
gap: 7px;
|
||||
|
||||
font-size: 0.6rem;
|
||||
font-size: 0.7rem;
|
||||
|
||||
font-weight: 400;
|
||||
|
||||
@ -243,43 +243,6 @@
|
||||
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 {
|
||||
display: flex;
|
||||
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 */
|
||||
}
|
||||
|
@ -23,13 +23,11 @@ const ChatPage = (props) => {
|
||||
const [L_User, R_User, E_User, M_User] = app.cores.api.useRequest(
|
||||
UserService.data,
|
||||
{
|
||||
user_id: to_user_id
|
||||
}
|
||||
)
|
||||
const [L_History, R_History, E_History, M_History] = app.cores.api.useRequest(
|
||||
ChatsService.getChatHistory,
|
||||
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 {
|
||||
sendMessage,
|
||||
@ -61,21 +59,22 @@ const ChatPage = (props) => {
|
||||
|
||||
if (value === "") {
|
||||
emitTypingEvent(false)
|
||||
} {
|
||||
}
|
||||
{
|
||||
emitTypingEvent(true)
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (R_History) {
|
||||
setMessages(R_History.list)
|
||||
// scroll to bottom
|
||||
messagesRef.current?.scrollTo({
|
||||
top: messagesRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
}, [R_History])
|
||||
// React.useEffect(() => {
|
||||
// if (R_History) {
|
||||
// setMessages(R_History.list)
|
||||
// // scroll to bottom
|
||||
// messagesRef.current?.scrollTo({
|
||||
// top: messagesRef.current.scrollHeight,
|
||||
// behavior: "smooth",
|
||||
// })
|
||||
// }
|
||||
// }, [R_History])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOnBottomView === true) {
|
||||
@ -86,73 +85,54 @@ const ChatPage = (props) => {
|
||||
}, [isOnBottomView])
|
||||
|
||||
if (E_History) {
|
||||
return <antd.Result
|
||||
return (
|
||||
<antd.Result
|
||||
status="warning"
|
||||
title="Error"
|
||||
subTitle={E_History.message}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (L_History) {
|
||||
return <antd.Skeleton active />
|
||||
}
|
||||
|
||||
return <div
|
||||
className="chat-page"
|
||||
>
|
||||
return (
|
||||
<div className="chat-page">
|
||||
<div className="chat-page-header">
|
||||
<UserPreview
|
||||
user={R_User}
|
||||
/>
|
||||
<UserPreview user={R_User} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classnames(
|
||||
"chat-page-messages",
|
||||
{
|
||||
["empty"]: messages.length === 0
|
||||
}
|
||||
)}
|
||||
className={classnames("chat-page-messages", {
|
||||
["empty"]: messages.length === 0,
|
||||
})}
|
||||
ref={messagesRef}
|
||||
>
|
||||
{
|
||||
messages.length === 0 && <antd.Empty />
|
||||
}
|
||||
{messages.length === 0 && <antd.Empty />}
|
||||
|
||||
{
|
||||
messages.map((line, index) => {
|
||||
return <div
|
||||
{messages.map((line, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={classnames(
|
||||
"chat-page-line-wrapper",
|
||||
{
|
||||
["self"]: line.user._id === app.userData._id
|
||||
}
|
||||
)}
|
||||
className={classnames("chat-page-line-wrapper", {
|
||||
["self"]: line.user._id === app.userData._id,
|
||||
})}
|
||||
>
|
||||
<div className="chat-page-line">
|
||||
<div
|
||||
className="chat-page-line-avatar"
|
||||
>
|
||||
<img
|
||||
src={line.user.avatar}
|
||||
/>
|
||||
<span>
|
||||
{line.user.username}
|
||||
</span>
|
||||
<div className="chat-page-line-avatar">
|
||||
<img src={line.user.avatar} />
|
||||
<span>{line.user.username}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="chat-page-line-text"
|
||||
>
|
||||
<p>
|
||||
{line.content}
|
||||
</p>
|
||||
<div className="chat-page-line-text">
|
||||
<p>{line.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
})
|
||||
}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="chat-page-input-wrapper">
|
||||
@ -173,13 +153,14 @@ const ChatPage = (props) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{
|
||||
isRemoteTyping && R_User && <div className="chat-page-remote-typing">
|
||||
{isRemoteTyping && R_User && (
|
||||
<div className="chat-page-remote-typing">
|
||||
<span>{R_User.username} is typing...</span>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatPage
|
@ -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
|
@ -3,6 +3,8 @@ import React from "react"
|
||||
import { Icons } from "@components/Icons"
|
||||
import { PagePanelWithNavMenu } from "@components/PagePanels"
|
||||
|
||||
import useCenteredContainer from "@hooks/useCenteredContainer"
|
||||
|
||||
import Tabs from "./tabs"
|
||||
|
||||
const NavMenuHeader = (
|
||||
@ -13,6 +15,8 @@ const NavMenuHeader = (
|
||||
)
|
||||
|
||||
export default () => {
|
||||
useCenteredContainer(false)
|
||||
|
||||
return (
|
||||
<PagePanelWithNavMenu
|
||||
tabs={Tabs}
|
||||
|
41
packages/app/src/pages/music/list/[id]/index.jsx
Normal file
41
packages/app/src/pages/music/list/[id]/index.jsx
Normal 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
|
@ -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
|
@ -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;
|
||||
}
|
||||
}
|
@ -3,7 +3,7 @@ import React from "react"
|
||||
import Searcher from "@components/Searcher"
|
||||
import SearchModel from "@models/search"
|
||||
|
||||
const MusicNavbar = (props) => {
|
||||
const MusicNavbar = React.forwardRef((props, ref) => {
|
||||
return (
|
||||
<div className="music_navbar">
|
||||
<Searcher
|
||||
@ -17,6 +17,6 @@ const MusicNavbar = (props) => {
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default MusicNavbar
|
||||
|
@ -10,7 +10,8 @@ import "./index.less"
|
||||
const RecentlyPlayedItem = (props) => {
|
||||
const { track } = props
|
||||
|
||||
return <div
|
||||
return (
|
||||
<div
|
||||
className="recently_played-item"
|
||||
onClick={() => app.cores.player.start(track._id)}
|
||||
>
|
||||
@ -19,50 +20,53 @@ const RecentlyPlayedItem = (props) => {
|
||||
</div>
|
||||
|
||||
<div className="recently_played-item-cover">
|
||||
<Image
|
||||
src={track.cover}
|
||||
/>
|
||||
<Image src={track.cover} />
|
||||
</div>
|
||||
|
||||
<div className="recently_played-item-content">
|
||||
<h3>{track.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RecentlyPlayedList = (props) => {
|
||||
const [L_Tracks, R_Tracks, E_Tracks, M_Tracks] = app.cores.api.useRequest(MusicModel.getRecentyPlayed, {
|
||||
limit: 7
|
||||
})
|
||||
const [L_Tracks, R_Tracks, E_Tracks, M_Tracks] = app.cores.api.useRequest(
|
||||
MusicModel.getRecentyPlayed,
|
||||
{
|
||||
limit: 6,
|
||||
},
|
||||
)
|
||||
|
||||
if (E_Tracks) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className="recently_played">
|
||||
return (
|
||||
<div className="recently_played">
|
||||
<div className="recently_played-header">
|
||||
<h1><Icons.MdHistory /> Recently played</h1>
|
||||
<h1>
|
||||
<Icons.MdHistory /> Recently played
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="recently_played-content">
|
||||
{
|
||||
L_Tracks && <antd.Skeleton active />
|
||||
}
|
||||
{L_Tracks && <antd.Skeleton active />}
|
||||
|
||||
{
|
||||
!L_Tracks && <div className="recently_played-content-items">
|
||||
{
|
||||
R_Tracks.map((track, index) => {
|
||||
return <RecentlyPlayedItem
|
||||
key={index}
|
||||
track={track}
|
||||
/>
|
||||
})
|
||||
}
|
||||
{R_Tracks && R_Tracks.lenght === 0 && <antd.Skeleton active />}
|
||||
|
||||
{!L_Tracks && (
|
||||
<div className="recently_played-content-items">
|
||||
{R_Tracks.map((track, index) => {
|
||||
return (
|
||||
<RecentlyPlayedItem key={index} track={track} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RecentlyPlayedList
|
@ -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
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -8,33 +8,30 @@ import MusicTrack from "@components/Music/Track"
|
||||
import Playlist from "@components/Music/Playlist"
|
||||
|
||||
const ResultGroupsDecorators = {
|
||||
"playlists": {
|
||||
playlists: {
|
||||
icon: "MdPlaylistPlay",
|
||||
label: "Playlists",
|
||||
renderItem: (props) => {
|
||||
return <Playlist
|
||||
key={props.key}
|
||||
playlist={props.item}
|
||||
/>
|
||||
}
|
||||
return <Playlist key={props.key} playlist={props.item} />
|
||||
},
|
||||
"tracks": {
|
||||
},
|
||||
tracks: {
|
||||
icon: "MdMusicNote",
|
||||
label: "Tracks",
|
||||
renderItem: (props) => {
|
||||
return <MusicTrack
|
||||
return (
|
||||
<MusicTrack
|
||||
key={props.key}
|
||||
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}`)}
|
||||
/>
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const SearchResults = ({
|
||||
data
|
||||
}) => {
|
||||
const SearchResults = ({ data }) => {
|
||||
if (typeof data !== "object") {
|
||||
return null
|
||||
}
|
||||
@ -56,37 +53,38 @@ const SearchResults = ({
|
||||
})
|
||||
|
||||
if (groupsKeys.length === 0) {
|
||||
return <div className="music-explorer_search_results no_results">
|
||||
return (
|
||||
<div className="music-explorer_search_results no_results">
|
||||
<antd.Result
|
||||
status="info"
|
||||
title="No results"
|
||||
subTitle="We are sorry, but we could not find any results for your search."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <div
|
||||
className={classnames(
|
||||
"music-explorer_search_results",
|
||||
{
|
||||
return (
|
||||
<div
|
||||
className={classnames("music-explorer_search_results", {
|
||||
["one_column"]: groupsKeys.length === 1,
|
||||
}
|
||||
)}
|
||||
})}
|
||||
>
|
||||
{
|
||||
groupsKeys.map((key, index) => {
|
||||
{groupsKeys.map((key, index) => {
|
||||
const decorator = ResultGroupsDecorators[key] ?? {
|
||||
icon: null,
|
||||
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">
|
||||
<h1>
|
||||
{
|
||||
createIconRender(decorator.icon)
|
||||
}
|
||||
{createIconRender(decorator.icon)}
|
||||
<Translation>
|
||||
{(t) => t(decorator.label)}
|
||||
</Translation>
|
||||
@ -94,19 +92,18 @@ const SearchResults = ({
|
||||
</div>
|
||||
|
||||
<div className="music-explorer_search_results_group_list">
|
||||
{
|
||||
data[key].items.map((item, index) => {
|
||||
{data[key].items.map((item, index) => {
|
||||
return decorator.renderItem({
|
||||
key: index,
|
||||
item
|
||||
item,
|
||||
})
|
||||
})
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
})
|
||||
}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchResults
|
@ -1,32 +1,28 @@
|
||||
import React from "react"
|
||||
import classnames from "classnames"
|
||||
|
||||
import useCenteredContainer from "@hooks/useCenteredContainer"
|
||||
|
||||
import Searcher from "@components/Searcher"
|
||||
import { Icons } from "@components/Icons"
|
||||
|
||||
import FeedModel from "@models/feed"
|
||||
import SearchModel from "@models/search"
|
||||
import MusicModel from "@models/music"
|
||||
import RadioModel from "@models/radio"
|
||||
|
||||
import Navbar from "./components/Navbar"
|
||||
import RecentlyPlayedList from "./components/RecentlyPlayedList"
|
||||
import SearchResults from "./components/SearchResults"
|
||||
import ReleasesList from "./components/ReleasesList"
|
||||
import FeaturedPlaylist from "./components/FeaturedPlaylist"
|
||||
import FeedItems from "./components/FeedItems"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const MusicExploreTab = (props) => {
|
||||
const [searchResults, setSearchResults] = React.useState(false)
|
||||
|
||||
useCenteredContainer(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
app.layout.page_panels.attachComponent("music_navbar", Navbar, {
|
||||
props: {
|
||||
setSearchResults: setSearchResults,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return () => {
|
||||
@ -36,41 +32,52 @@ const MusicExploreTab = (props) => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <div
|
||||
className={classnames(
|
||||
"musicExplorer",
|
||||
)}
|
||||
>
|
||||
{
|
||||
app.isMobile && <Searcher
|
||||
return (
|
||||
<div className={classnames("music-explore")}>
|
||||
{app.isMobile && (
|
||||
<Searcher
|
||||
useUrlQuery
|
||||
renderResults={false}
|
||||
model={(keywords, params) => SearchModel.search("music", keywords, params)}
|
||||
model={(keywords, params) =>
|
||||
SearchModel.search("music", keywords, params)
|
||||
}
|
||||
onSearchResult={setSearchResults}
|
||||
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}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
!searchResults && <div className="feed_main">
|
||||
<FeaturedPlaylist />
|
||||
<FeedItems
|
||||
type="playlists"
|
||||
headerTitle="All Releases"
|
||||
headerIcon={<Icons.MdNewspaper />}
|
||||
fetchMethod={MusicModel.getAllReleases}
|
||||
/>
|
||||
|
||||
<RecentlyPlayedList />
|
||||
|
||||
<ReleasesList
|
||||
headerTitle="Explore"
|
||||
headerIcon={<Icons.MdExplore />}
|
||||
fetchMethod={FeedModel.getGlobalMusicFeed}
|
||||
<FeedItems
|
||||
type="radios"
|
||||
headerTitle="Trending Radios"
|
||||
headerIcon={<Icons.FiRadio />}
|
||||
fetchMethod={RadioModel.getTrendings}
|
||||
disablePagination
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MusicExploreTab
|
@ -1,108 +1,14 @@
|
||||
html {
|
||||
&.mobile {
|
||||
.musicExplorer {
|
||||
.playlistExplorer_section_list {
|
||||
overflow: visible;
|
||||
overflow-x: scroll;
|
||||
&.mobile {
|
||||
.music-explore {
|
||||
padding: 0 10px;
|
||||
|
||||
width: unset;
|
||||
display: flex;
|
||||
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);
|
||||
}
|
||||
.recently_played-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.lazy-load-image-background {
|
||||
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;
|
||||
|
||||
.music-explore-content {
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
|
||||
.musicExplorer {
|
||||
.music-explore {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
gap: 20px;
|
||||
gap: 30px;
|
||||
|
||||
&.search-focused {
|
||||
.feed_main {
|
||||
@ -134,18 +40,19 @@ html {
|
||||
}
|
||||
}
|
||||
|
||||
.feed_main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.music-explore-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, auto);
|
||||
grid-template-rows: auto;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
gap: 50px;
|
||||
gap: 30px;
|
||||
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
||||
overflow-x: visible;
|
||||
@media screen and (max-width: 1200px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -4,24 +4,30 @@ import * as antd from "antd"
|
||||
import { Icons } from "@components/Icons"
|
||||
|
||||
import TracksLibraryView from "./views/tracks"
|
||||
import ReleasesLibraryView from "./views/releases"
|
||||
import PlaylistLibraryView from "./views/playlists"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const TabToView = {
|
||||
tracks: TracksLibraryView,
|
||||
playlist: PlaylistLibraryView,
|
||||
releases: PlaylistLibraryView,
|
||||
}
|
||||
|
||||
const TabToHeader = {
|
||||
const Views = {
|
||||
tracks: {
|
||||
icon: <Icons.MdMusicNote />,
|
||||
value: "tracks",
|
||||
label: "Tracks",
|
||||
icon: <Icons.MdMusicNote />,
|
||||
element: TracksLibraryView,
|
||||
},
|
||||
playlist: {
|
||||
icon: <Icons.MdPlaylistPlay />,
|
||||
releases: {
|
||||
value: "releases",
|
||||
label: "Releases",
|
||||
icon: <Icons.MdAlbum />,
|
||||
element: ReleasesLibraryView,
|
||||
},
|
||||
playlists: {
|
||||
value: "playlists",
|
||||
label: "Playlists",
|
||||
icon: <Icons.MdPlaylistPlay />,
|
||||
element: PlaylistLibraryView,
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
@ -34,29 +40,13 @@ const Library = (props) => {
|
||||
<antd.Segmented
|
||||
value={selectedTab}
|
||||
onChange={setSelectedTab}
|
||||
options={[
|
||||
{
|
||||
value: "tracks",
|
||||
label: "Tracks",
|
||||
icon: <Icons.MdMusicNote />,
|
||||
},
|
||||
{
|
||||
value: "playlist",
|
||||
label: "Playlists",
|
||||
icon: <Icons.MdPlaylistPlay />,
|
||||
},
|
||||
{
|
||||
value: "releases",
|
||||
label: "Releases",
|
||||
icon: <Icons.MdPlaylistPlay />,
|
||||
},
|
||||
]}
|
||||
options={Object.values(Views)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedTab &&
|
||||
TabToView[selectedTab] &&
|
||||
React.createElement(TabToView[selectedTab])}
|
||||
Views[selectedTab] &&
|
||||
React.createElement(Views[selectedTab].element)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,181 +1,76 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
import classnames from "classnames"
|
||||
|
||||
import Image from "@components/Image"
|
||||
import { Icons } from "@components/Icons"
|
||||
import OpenPlaylistCreator from "@components/Music/PlaylistCreator"
|
||||
import PlaylistView from "@components/Music/PlaylistView"
|
||||
|
||||
import MusicModel from "@models/music"
|
||||
|
||||
import "./index.less"
|
||||
const loadLimit = 50
|
||||
|
||||
const ReleaseTypeDecorators = {
|
||||
"user": () => <p >
|
||||
<Icons.MdPlaylistAdd />
|
||||
Playlist
|
||||
</p>,
|
||||
"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>,
|
||||
}
|
||||
const MyLibraryPlaylists = () => {
|
||||
const [offset, setOffset] = React.useState(0)
|
||||
const [items, setItems] = React.useState([])
|
||||
const [hasMore, setHasMore] = React.useState(true)
|
||||
const [initialLoading, setInitialLoading] = React.useState(true)
|
||||
|
||||
function isNotAPlaylist(type) {
|
||||
return type === "album" || type === "ep" || type === "mix" || type === "single"
|
||||
}
|
||||
const [L_Library, R_Library, E_Library, M_Library] =
|
||||
app.cores.api.useRequest(MusicModel.getMyLibrary, {
|
||||
offset: offset,
|
||||
limit: loadLimit,
|
||||
kind: "playlists",
|
||||
})
|
||||
|
||||
const PlaylistItem = (props) => {
|
||||
const data = props.data ?? {}
|
||||
async function onLoadMore() {
|
||||
const newOffset = offset + loadLimit
|
||||
|
||||
const handleOnClick = () => {
|
||||
if (typeof props.onClick === "function") {
|
||||
props.onClick(data)
|
||||
}
|
||||
setOffset(newOffset)
|
||||
|
||||
if (props.type !== "action") {
|
||||
if (data.service) {
|
||||
return app.navigation.goToPlaylist(`${data._id}?service=${data.service}`)
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
M_Library({
|
||||
offset: newOffset,
|
||||
limit: loadLimit,
|
||||
})
|
||||
}
|
||||
</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
|
||||
|
@ -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
|
@ -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;
|
||||
}
|
@ -13,49 +13,47 @@ const TracksLibraryView = () => {
|
||||
const [hasMore, setHasMore] = React.useState(true)
|
||||
const [initialLoading, setInitialLoading] = React.useState(true)
|
||||
|
||||
const [L_Favourites, R_Favourites, E_Favourites, M_Favourites] =
|
||||
app.cores.api.useRequest(MusicModel.getFavouriteFolder, {
|
||||
const [L_Library, R_Library, E_Library, M_Library] =
|
||||
app.cores.api.useRequest(MusicModel.getMyLibrary, {
|
||||
offset: offset,
|
||||
limit: loadLimit,
|
||||
kind: "tracks",
|
||||
})
|
||||
|
||||
async function onLoadMore() {
|
||||
const newOffset = offset + loadLimit
|
||||
setOffset((prevOffset) => {
|
||||
const newOffset = prevOffset + loadLimit
|
||||
|
||||
setOffset(newOffset)
|
||||
|
||||
M_Favourites({
|
||||
M_Library({
|
||||
offset: newOffset,
|
||||
limit: loadLimit,
|
||||
kind: "tracks",
|
||||
})
|
||||
|
||||
if (newOffset >= R_Library.total_items) {
|
||||
setHasMore(false)
|
||||
}
|
||||
|
||||
return newOffset
|
||||
})
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (R_Favourites && R_Favourites.tracks) {
|
||||
if (R_Library && R_Library.items) {
|
||||
if (initialLoading === true) {
|
||||
setInitialLoading(false)
|
||||
}
|
||||
|
||||
if (R_Favourites.tracks.items.length === 0) {
|
||||
setHasMore(false)
|
||||
} else {
|
||||
setItems((prev) => {
|
||||
prev = [...prev, ...R_Favourites.tracks.items]
|
||||
prev = [...prev, ...R_Library.items]
|
||||
|
||||
return prev
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [R_Favourites])
|
||||
}, [R_Library])
|
||||
|
||||
if (E_Favourites) {
|
||||
return (
|
||||
<antd.Result
|
||||
status="warning"
|
||||
title="Failed to load"
|
||||
subTitle={E_Favourites}
|
||||
/>
|
||||
)
|
||||
if (E_Library) {
|
||||
return <antd.Result status="warning" title="Failed to load" />
|
||||
}
|
||||
|
||||
if (initialLoading) {
|
||||
@ -66,15 +64,14 @@ const TracksLibraryView = () => {
|
||||
<PlaylistView
|
||||
noHeader
|
||||
noSearch
|
||||
loading={L_Favourites}
|
||||
loading={L_Library}
|
||||
type="vertical"
|
||||
playlist={{
|
||||
items: items,
|
||||
total_length: R_Favourites.tracks.total_items,
|
||||
total_length: R_Library.total_items,
|
||||
}}
|
||||
onLoadMore={onLoadMore}
|
||||
hasMore={hasMore}
|
||||
length={R_Favourites.tracks.total_length}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -1,58 +1,12 @@
|
||||
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 RadioModel from "@models/radio"
|
||||
|
||||
import Radio from "@components/Music/Radio"
|
||||
|
||||
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 [L_Radios, R_Radios, E_Radios, M_Radios] = app.cores.api.useRequest(
|
||||
RadioModel.getRadioList,
|
||||
@ -69,12 +23,12 @@ const RadioTab = () => {
|
||||
return (
|
||||
<div className="radio-list">
|
||||
{R_Radios.map((item) => (
|
||||
<RadioItem key={item.id} item={item} />
|
||||
<Radio key={item.id} item={item} />
|
||||
))}
|
||||
|
||||
<RadioItem style={{ opacity: 0.5 }} />
|
||||
<RadioItem style={{ opacity: 0.4 }} />
|
||||
<RadioItem style={{ opacity: 0.3 }} />
|
||||
<Radio style={{ opacity: 0.5 }} />
|
||||
<Radio style={{ opacity: 0.4 }} />
|
||||
<Radio style={{ opacity: 0.3 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -7,87 +7,9 @@
|
||||
gap: 10px;
|
||||
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.radio-list-item {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.radio-item {
|
||||
min-width: @min-item-width;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
@ -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
|
81
packages/app/src/pages/studio/tv/[profile_id]/header.jsx
Normal file
81
packages/app/src/pages/studio/tv/[profile_id]/header.jsx
Normal 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
|
217
packages/app/src/pages/studio/tv/[profile_id]/index.jsx
Normal file
217
packages/app/src/pages/studio/tv/[profile_id]/index.jsx
Normal 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
|
246
packages/app/src/pages/studio/tv/[profile_id]/index.less
Normal file
246
packages/app/src/pages/studio/tv/[profile_id]/index.less
Normal 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;
|
||||
}
|
@ -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`
|
||||
}
|
@ -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
|
@ -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%;
|
||||
}
|
||||
}
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
||||
}
|
@ -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
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
@ -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
|
@ -21,7 +21,7 @@ const ProfileCreator = (props) => {
|
||||
await props.onEdit(name)
|
||||
}
|
||||
} else {
|
||||
const result = await Streaming.createOrUpdateProfile({
|
||||
const result = await Streaming.createProfile({
|
||||
profile_name: name,
|
||||
}).catch((error) => {
|
||||
console.error(error)
|
||||
|
@ -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
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
@ -1,57 +1,64 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
|
||||
import ProfileSelector from "./components/ProfileSelector"
|
||||
import ProfileData from "./components/ProfileData"
|
||||
import ProfileCreator from "./components/ProfileCreator"
|
||||
import Skeleton from "@components/Skeleton"
|
||||
|
||||
import Streaming from "@models/spectrum"
|
||||
|
||||
import useCenteredContainer from "@hooks/useCenteredContainer"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const Profile = ({ profile, onClick }) => {
|
||||
return <div onClick={onClick}>{profile.profile_name}</div>
|
||||
}
|
||||
|
||||
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, {
|
||||
props: {
|
||||
onCreate: (id, data) => {
|
||||
setSelectedProfileId(id)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return <div className="tvstudio-page">
|
||||
<div className="tvstudio-page-actions">
|
||||
<ProfileSelector
|
||||
onChange={setSelectedProfileId}
|
||||
/>
|
||||
function handleProfileClick(id) {
|
||||
app.location.push(`/studio/tv/${id}`)
|
||||
}
|
||||
|
||||
<antd.Button
|
||||
type="primary"
|
||||
onClick={newProfileModal}
|
||||
>
|
||||
if (loading) {
|
||||
return <Skeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tvstudio-page">
|
||||
<div className="tvstudio-page-actions">
|
||||
<antd.Button type="primary" onClick={handleNewProfileClick}>
|
||||
Create new
|
||||
</antd.Button>
|
||||
</div>
|
||||
|
||||
{
|
||||
selectedProfileId && <ProfileData
|
||||
profile_id={selectedProfileId}
|
||||
{list.length > 0 &&
|
||||
list.map((profile, index) => {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export default TVStudioPage
|
@ -7,18 +7,18 @@ export default [
|
||||
key: "feed",
|
||||
label: "Feed",
|
||||
icon: "IoMdPaper",
|
||||
component: FeedTab
|
||||
component: FeedTab,
|
||||
},
|
||||
{
|
||||
key: "global",
|
||||
label: "Global",
|
||||
icon: "FiGlobe",
|
||||
component: GlobalTab
|
||||
component: GlobalTab,
|
||||
},
|
||||
{
|
||||
key: "savedPosts",
|
||||
label: "Saved posts",
|
||||
label: "Saved",
|
||||
icon: "FiBookmark",
|
||||
component: SavedPostsTab
|
||||
}
|
||||
component: SavedPostsTab,
|
||||
},
|
||||
]
|
@ -63,13 +63,11 @@ const SessionItem = (props) => {
|
||||
return UAParser(session.client)
|
||||
})
|
||||
|
||||
return <div
|
||||
className={classnames(
|
||||
"security_sessions_list_item_wrapper",
|
||||
{
|
||||
["collapsed"]: collapsed
|
||||
}
|
||||
)}
|
||||
return (
|
||||
<div
|
||||
className={classnames("security_sessions_list_item_wrapper", {
|
||||
["collapsed"]: collapsed,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
id={session._id}
|
||||
@ -78,15 +76,15 @@ const SessionItem = (props) => {
|
||||
onClick={onClickCollapse}
|
||||
>
|
||||
<div className="security_sessions_list_item_icon">
|
||||
<DeviceIcon
|
||||
ua={ua}
|
||||
/>
|
||||
<DeviceIcon ua={ua} />
|
||||
</div>
|
||||
|
||||
<antd.Badge dot={isCurrentSession}>
|
||||
<div className="security_sessions_list_item_info">
|
||||
<div className="security_sessions_list_item_title">
|
||||
<h3><Icons.FiTag /> {session.session_uuid}</h3>
|
||||
<h3>
|
||||
<Icons.FiTag /> {session._id}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="security_sessions_list_item_info_details">
|
||||
@ -94,15 +92,15 @@ const SessionItem = (props) => {
|
||||
<Icons.FiClock />
|
||||
|
||||
<span>
|
||||
{moment(session.date).format("DD/MM/YYYY HH:mm")}
|
||||
{moment(session.date).format(
|
||||
"DD/MM/YYYY HH:mm",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="security_sessions_list_item_info_details_item">
|
||||
<Icons.IoMdLocate />
|
||||
|
||||
<span>
|
||||
{session.ip_address}
|
||||
</span>
|
||||
<span>{session.ip_address}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -111,11 +109,7 @@ const SessionItem = (props) => {
|
||||
|
||||
<div className="security_sessions_list_item_extra-body">
|
||||
<div className="security_sessions_list_item_actions">
|
||||
<antd.Button
|
||||
onClick={onClickRevoke}
|
||||
danger
|
||||
size="small"
|
||||
>
|
||||
<antd.Button onClick={onClickRevoke} danger size="small">
|
||||
Revoke
|
||||
</antd.Button>
|
||||
</div>
|
||||
@ -123,22 +117,21 @@ const SessionItem = (props) => {
|
||||
<div className="security_sessions_list_item_info_details_item">
|
||||
<Icons.MdDns />
|
||||
|
||||
<span>
|
||||
{session.location}
|
||||
</span>
|
||||
<span>{session.location}</span>
|
||||
</div>
|
||||
|
||||
{
|
||||
ua.device.vendor && <div className="security_sessions_list_item_info_details_item">
|
||||
{ua.device.vendor && (
|
||||
<div className="security_sessions_list_item_info_details_item">
|
||||
<Icons.FiCpu />
|
||||
|
||||
<span>
|
||||
{ua.device.vendor} | {ua.device.model}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SessionItem
|
@ -35,8 +35,15 @@ export default () => {
|
||||
app.message.warning("Not implemented yet")
|
||||
}
|
||||
|
||||
const onClickRevokeAll = async () => {
|
||||
app.message.warning("Not implemented yet")
|
||||
const onClickDestroyAll = async () => {
|
||||
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(() => {
|
||||
@ -50,17 +57,18 @@ export default () => {
|
||||
const offset = (sessionsPage - 1) * itemsPerPage
|
||||
const slicedItems = sessions.slice(offset, offset + itemsPerPage)
|
||||
|
||||
return <div className="security_sessions">
|
||||
return (
|
||||
<div className="security_sessions">
|
||||
<div className="security_sessions_list">
|
||||
{
|
||||
slicedItems.map((session) => {
|
||||
return <SessionItem
|
||||
{slicedItems.map((session) => {
|
||||
return (
|
||||
<SessionItem
|
||||
key={session._id}
|
||||
session={session}
|
||||
onClickRevoke={onClickRevoke}
|
||||
/>
|
||||
})
|
||||
}
|
||||
)
|
||||
})}
|
||||
|
||||
<antd.Pagination
|
||||
onChange={(page) => {
|
||||
@ -73,5 +81,7 @@ export default () => {
|
||||
simple
|
||||
/>
|
||||
</div>
|
||||
<antd.Button onClick={onClickDestroyAll}>Destroy all</antd.Button>
|
||||
</div>
|
||||
)
|
||||
}
|
11
packages/app/src/utils/arrayBufferToBase64/index.js
Normal file
11
packages/app/src/utils/arrayBufferToBase64/index.js
Normal 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)
|
||||
}
|
11
packages/app/src/utils/base64ToArrayBuffer/index.js
Normal file
11
packages/app/src/utils/base64ToArrayBuffer/index.js
Normal 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
|
||||
}
|
@ -117,7 +117,7 @@ function registerAliases() {
|
||||
registerBaseAliases(global["__src"], global["aliases"])
|
||||
}
|
||||
|
||||
async function injectEnvFromInfisical() {
|
||||
global.injectEnvFromInfisical = async function injectEnvFromInfisical() {
|
||||
const envMode = (global.FORCE_ENV ?? global.isProduction) ? "prod" : "dev"
|
||||
|
||||
console.log(
|
||||
|
@ -13,6 +13,7 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib {
|
||||
audioBitrate: "320k",
|
||||
audioSampleRate: "48000",
|
||||
segmentTime: 10,
|
||||
minBufferTime: 5,
|
||||
includeMetadata: true,
|
||||
...params,
|
||||
}
|
||||
@ -20,7 +21,6 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib {
|
||||
|
||||
buildSegmentationArgs = () => {
|
||||
const args = [
|
||||
//`-threads 1`, // limits to one thread
|
||||
`-v error -hide_banner -progress pipe:1`,
|
||||
`-i ${this.params.input}`,
|
||||
`-c:a ${this.params.audioCodec}`,
|
||||
@ -56,6 +56,39 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib {
|
||||
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 () => {
|
||||
const segmentationCmd = this.buildSegmentationArgs()
|
||||
const outputPath =
|
||||
@ -75,7 +108,7 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib {
|
||||
const inputProbe = await Utils.probe(this.params.input)
|
||||
|
||||
try {
|
||||
const result = await this.ffmpeg({
|
||||
const ffmpegResult = await this.ffmpeg({
|
||||
args: segmentationCmd,
|
||||
onProcess: (process) => {
|
||||
this.handleProgress(
|
||||
@ -89,6 +122,17 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib {
|
||||
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)
|
||||
|
||||
this.emit("end", {
|
||||
@ -100,9 +144,9 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib {
|
||||
outputFile: outputFile,
|
||||
})
|
||||
|
||||
return result
|
||||
return ffmpegResult
|
||||
} catch (err) {
|
||||
return this.emit("error", err)
|
||||
this.emit("error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
40
packages/server/db_models/chatKey/index.js
Normal file
40
packages/server/db_models/chatKey/index.js
Normal 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 },
|
||||
],
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
@ -7,5 +7,9 @@ export default {
|
||||
to_user_id: { type: String, required: true },
|
||||
content: { type: String, required: true },
|
||||
created_at: { type: Date, required: true },
|
||||
}
|
||||
encrypted: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
}
|
@ -5,16 +5,28 @@ import path from "path"
|
||||
function generateModels() {
|
||||
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) => {
|
||||
const model = require(path.join(__dirname, file)).default
|
||||
|
||||
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
|
||||
|
23
packages/server/db_models/musicLibraryItem/index.js
Normal file
23
packages/server/db_models/musicLibraryItem/index.js
Normal 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,
|
||||
},
|
||||
},
|
||||
}
|
@ -4,31 +4,33 @@ export default {
|
||||
schema: {
|
||||
user_id: {
|
||||
type: String,
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String
|
||||
type: String,
|
||||
},
|
||||
list: {
|
||||
type: Object,
|
||||
default: [],
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
cover: {
|
||||
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: {
|
||||
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: {
|
||||
type: Date,
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
publisher: {
|
||||
type: Object,
|
||||
@ -37,5 +39,5 @@ export default {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
@ -27,8 +27,9 @@ export default {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
publish_date: {
|
||||
created_at: {
|
||||
type: Date,
|
||||
required: true,
|
||||
},
|
||||
cover: {
|
||||
type: String,
|
||||
|
23
packages/server/db_models/userChat/index.js
Normal file
23
packages/server/db_models/userChat/index.js
Normal 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
|
||||
},
|
||||
}
|
15
packages/server/db_models/userDHPair/index.js
Normal file
15
packages/server/db_models/userDHPair/index.js
Normal 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
Loading…
x
Reference in New Issue
Block a user