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,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently -k \"yarn dev:client\" \"yarn dev:server\"",
|
"dev": "concurrently -k \"yarn dev:client\" \"yarn dev:server\"",
|
||||||
"dev:server": "cd packages/server && yarn dev",
|
"dev:server": "cd packages/server && npm run dev",
|
||||||
"dev:client": "cd packages/app && yarn dev",
|
"dev:client": "cd packages/app && npm run dev",
|
||||||
"postinstall": "node ./scripts/post-install.js"
|
"postinstall": "node ./scripts/post-install.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -34,6 +34,7 @@
|
|||||||
"bear-react-carousel": "^4.0.10-alpha.0",
|
"bear-react-carousel": "^4.0.10-alpha.0",
|
||||||
"classnames": "2.3.1",
|
"classnames": "2.3.1",
|
||||||
"comty.js": "^0.64.0",
|
"comty.js": "^0.64.0",
|
||||||
|
"d3": "^7.9.0",
|
||||||
"dashjs": "^5.0.0",
|
"dashjs": "^5.0.0",
|
||||||
"dompurify": "^3.0.0",
|
"dompurify": "^3.0.0",
|
||||||
"fast-average-color": "^9.2.0",
|
"fast-average-color": "^9.2.0",
|
||||||
|
@ -17,23 +17,28 @@ export default class AuthManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state = {
|
||||||
|
user: null,
|
||||||
|
}
|
||||||
|
|
||||||
public = {
|
public = {
|
||||||
login: () => {
|
login: () => {
|
||||||
app.layout.draggable.open("login", Login, {
|
app.layout.draggable.open("login", Login, {
|
||||||
props: {
|
componentProps: {
|
||||||
onDone: () => {
|
onDone: this.onLoginCallback,
|
||||||
app.layout.draggable.destroy("login")
|
|
||||||
this._emitBehavior("onLogin")
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
logout: () => {
|
logout: (bypass) => {
|
||||||
|
if (bypass === true) {
|
||||||
|
AuthModel.logout()
|
||||||
|
return this._emitBehavior("onLogout")
|
||||||
|
}
|
||||||
|
|
||||||
app.layout.modal.confirm({
|
app.layout.modal.confirm({
|
||||||
headerText: "Logout",
|
headerText: "Logout",
|
||||||
descriptionText: "Are you sure you want to logout?",
|
descriptionText: "Are you sure you want to logout?",
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
console.log("Logout confirmed")
|
|
||||||
AuthModel.logout()
|
AuthModel.logout()
|
||||||
this._emitBehavior("onLogout")
|
this._emitBehavior("onLogout")
|
||||||
},
|
},
|
||||||
@ -65,10 +70,6 @@ export default class AuthManager {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
state = {
|
|
||||||
user: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
initialize = async () => {
|
initialize = async () => {
|
||||||
const token = await SessionModel.token
|
const token = await SessionModel.token
|
||||||
|
|
||||||
@ -103,4 +104,6 @@ export default class AuthManager {
|
|||||||
await this.behaviors[behavior](...args)
|
await this.behaviors[behavior](...args)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//onLoginCallback = async (state, result) => {}
|
||||||
}
|
}
|
||||||
|
@ -3,23 +3,12 @@ import classnames from "classnames"
|
|||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
export default (props) => {
|
const LikeButton = (props) => {
|
||||||
const [liked, setLiked] = React.useState(
|
const [liked, setLiked] = React.useState(
|
||||||
typeof props.liked === "function" ? false : props.liked,
|
typeof props.liked === "function" ? false : props.liked,
|
||||||
)
|
)
|
||||||
const [clicked, setClicked] = React.useState(false)
|
const [clicked, setClicked] = React.useState(false)
|
||||||
|
|
||||||
// TODO: Support handle like change on websocket event
|
|
||||||
if (typeof props.watchWs === "object") {
|
|
||||||
// useWsEvents({
|
|
||||||
// [props.watchWs.event]: (data) => {
|
|
||||||
// handleUpdateTrackLike(data.track_id, data.action === "liked")
|
|
||||||
// }
|
|
||||||
// }, {
|
|
||||||
// socketName: props.watchWs.socket,
|
|
||||||
// })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function computeLikedState() {
|
async function computeLikedState() {
|
||||||
if (props.disabled) {
|
if (props.disabled) {
|
||||||
return false
|
return false
|
||||||
@ -48,7 +37,7 @@ export default (props) => {
|
|||||||
}, 500)
|
}, 500)
|
||||||
|
|
||||||
if (typeof props.onClick === "function") {
|
if (typeof props.onClick === "function") {
|
||||||
props.onClick()
|
props.onClick(!liked)
|
||||||
}
|
}
|
||||||
|
|
||||||
setLiked(!liked)
|
setLiked(!liked)
|
||||||
@ -74,3 +63,5 @@ export default (props) => {
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default LikeButton
|
||||||
|
@ -91,11 +91,11 @@ class Login extends React.Component {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onDone = async ({ mfa_required } = {}) => {
|
onDone = async (result = {}) => {
|
||||||
if (mfa_required) {
|
if (result.mfa_required) {
|
||||||
this.setState({
|
this.setState({
|
||||||
loading: false,
|
loading: false,
|
||||||
mfa_required: mfa_required,
|
mfa_required: result.mfa_required,
|
||||||
})
|
})
|
||||||
|
|
||||||
return false
|
return false
|
||||||
@ -108,7 +108,7 @@ class Login extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof this.props.onDone === "function") {
|
if (typeof this.props.onDone === "function") {
|
||||||
await this.props.onDone()
|
await this.props.onDone(this.state, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
@ -7,93 +7,98 @@ import { Icons } from "@components/Icons"
|
|||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
const typeToNavigationType = {
|
const typeToNavigationType = {
|
||||||
playlist: "playlist",
|
playlist: "playlist",
|
||||||
album: "album",
|
album: "album",
|
||||||
track: "track",
|
track: "track",
|
||||||
single: "track",
|
single: "track",
|
||||||
ep: "album"
|
ep: "album",
|
||||||
}
|
}
|
||||||
|
|
||||||
const Playlist = (props) => {
|
const Playlist = (props) => {
|
||||||
const [coverHover, setCoverHover] = React.useState(false)
|
const [coverHover, setCoverHover] = React.useState(false)
|
||||||
|
|
||||||
let { playlist } = props
|
let { playlist } = props
|
||||||
|
|
||||||
if (!playlist) {
|
if (!playlist) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
if (typeof props.onClick === "function") {
|
if (typeof props.onClick === "function") {
|
||||||
return props.onClick(playlist)
|
return props.onClick(playlist)
|
||||||
}
|
}
|
||||||
|
|
||||||
return app.location.push(`/music/${typeToNavigationType[playlist.type.toLowerCase()]}/${playlist._id}`)
|
return app.location.push(`/music/list/${playlist._id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClickPlay = (e) => {
|
const onClickPlay = (e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
app.cores.player.start(playlist.list)
|
app.cores.player.start(playlist.items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={playlist._id}
|
||||||
|
className={classnames("playlist", {
|
||||||
|
"cover-hovering": coverHover,
|
||||||
|
"row-mode": props.row === true,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="playlist_cover"
|
||||||
|
onMouseEnter={() => setCoverHover(true)}
|
||||||
|
onMouseLeave={() => setCoverHover(false)}
|
||||||
|
onClick={onClickPlay}
|
||||||
|
>
|
||||||
|
<div className="playlist_cover_mask">
|
||||||
|
<Icons.FiPlay />
|
||||||
|
</div>
|
||||||
|
|
||||||
const subtitle = playlist.type === "playlist" ? `By ${playlist.user_id}` : (playlist.description ?? (playlist.publisher && `Release from ${playlist.publisher?.fullName}`))
|
<ImageViewer
|
||||||
|
src={
|
||||||
|
playlist.cover ??
|
||||||
|
playlist.thumbnail ??
|
||||||
|
"/assets/no_song.png"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
return <div
|
<div className="playlist_info">
|
||||||
id={playlist._id}
|
<div className="playlist_info_title" onClick={onClick}>
|
||||||
key={props.key}
|
<h1>{playlist.title}</h1>
|
||||||
className={classnames(
|
</div>
|
||||||
"playlist",
|
{props.row && (
|
||||||
{
|
<div className="playlist_details">
|
||||||
"cover-hovering": coverHover
|
<p>
|
||||||
}
|
<Icons.MdAlbum />
|
||||||
)}
|
{playlist.type ?? "playlist"}
|
||||||
>
|
</p>
|
||||||
<div
|
</div>
|
||||||
className="playlist_cover"
|
)}
|
||||||
onMouseEnter={() => setCoverHover(true)}
|
</div>
|
||||||
onMouseLeave={() => setCoverHover(false)}
|
|
||||||
onClick={onClickPlay}
|
|
||||||
>
|
|
||||||
<div className="playlist_cover_mask">
|
|
||||||
<Icons.MdPlayArrow />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ImageViewer
|
{!props.row && (
|
||||||
src={playlist.cover ?? playlist.thumbnail ?? "/assets/no_song.png"}
|
<div className="playlist_details">
|
||||||
/>
|
{props.length && (
|
||||||
</div>
|
<p>
|
||||||
|
<Icons.MdLibraryMusic />{" "}
|
||||||
|
{props.length ??
|
||||||
|
playlist.total_length ??
|
||||||
|
playlist.list.length}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="playlist_info">
|
{playlist.type && (
|
||||||
<div className="playlist_info_title" onClick={onClick}>
|
<p>
|
||||||
<h1>{playlist.title}</h1>
|
<Icons.MdAlbum />
|
||||||
</div>
|
{playlist.type ?? "playlist"}
|
||||||
|
</p>
|
||||||
{
|
)}
|
||||||
subtitle && <div className="playlist_info_subtitle">
|
</div>
|
||||||
<p>
|
)}
|
||||||
{subtitle}
|
</div>
|
||||||
</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>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Playlist
|
export default Playlist
|
||||||
|
@ -14,6 +14,45 @@
|
|||||||
|
|
||||||
transition: all 150ms ease-in-out;
|
transition: all 150ms ease-in-out;
|
||||||
|
|
||||||
|
&.row-mode {
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
width: fit-content;
|
||||||
|
max-width: unset;
|
||||||
|
min-width: 100px;
|
||||||
|
|
||||||
|
height: fit-content;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.playlist_cover {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
|
||||||
|
min-width: 50px;
|
||||||
|
max-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist_cover {
|
||||||
|
.playlist_cover_mask {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist_info {
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.playlist_info_title {
|
||||||
|
h1 {
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.cover-hovering {
|
&.cover-hovering {
|
||||||
.playlist_cover {
|
.playlist_cover {
|
||||||
.playlist_cover_mask {
|
.playlist_cover_mask {
|
||||||
@ -29,18 +68,27 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
width: 100%;
|
width: @playlist_cover_maxSize;
|
||||||
//max-height: 150px;
|
height: @playlist_cover_maxSize;
|
||||||
|
|
||||||
transition: all 0.2s ease-in-out;
|
transition: all 0.2s ease-in-out;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
|
|
||||||
img {
|
border-radius: 12px;
|
||||||
width: @playlist_cover_maxSize;
|
overflow: hidden;
|
||||||
height: @playlist_cover_maxSize;
|
|
||||||
|
|
||||||
object-fit: cover;
|
background-color: var(--background-color-accent);
|
||||||
border-radius: 12px;
|
|
||||||
|
.image-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist_cover_mask {
|
.playlist_cover_mask {
|
||||||
@ -116,28 +164,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist_actions {
|
.playlist_details {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
align-self: flex-end;
|
|
||||||
justify-self: flex-end;
|
|
||||||
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
padding: 10px;
|
|
||||||
|
|
||||||
.ant-btn {
|
|
||||||
svg {
|
|
||||||
margin: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist_bottom {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|
||||||
|
@ -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 React from "react"
|
||||||
import * as antd from "antd"
|
import * as antd from "antd"
|
||||||
import classnames from "classnames"
|
import classnames from "classnames"
|
||||||
import ReactMarkdown from "react-markdown"
|
|
||||||
import remarkGfm from "remark-gfm"
|
|
||||||
import fuse from "fuse.js"
|
|
||||||
|
|
||||||
import { WithPlayerContext } from "@contexts/WithPlayerContext"
|
import { WithPlayerContext } from "@contexts/WithPlayerContext"
|
||||||
import { Context as PlaylistContext } from "@contexts/WithPlaylistContext"
|
import { Context as PlaylistContext } from "@contexts/WithPlaylistContext"
|
||||||
|
|
||||||
import useWsEvents from "@hooks/useWsEvents"
|
|
||||||
import checkUserIdIsSelf from "@utils/checkUserIdIsSelf"
|
import checkUserIdIsSelf from "@utils/checkUserIdIsSelf"
|
||||||
|
|
||||||
import LoadMore from "@components/LoadMore"
|
|
||||||
import { Icons } from "@components/Icons"
|
|
||||||
import MusicTrack from "@components/Music/Track"
|
|
||||||
import SearchButton from "@components/SearchButton"
|
|
||||||
import ImageViewer from "@components/ImageViewer"
|
|
||||||
|
|
||||||
import MusicModel from "@models/music"
|
import MusicModel from "@models/music"
|
||||||
|
|
||||||
|
import PlaylistHeader from "./header"
|
||||||
|
import TrackList from "./list"
|
||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
const PlaylistTypeDecorators = {
|
const PlaylistView = ({
|
||||||
single: () => (
|
playlist: initialPlaylist,
|
||||||
<span className="playlistType">
|
noHeader = false,
|
||||||
<Icons.MdMusicNote />
|
onLoadMore,
|
||||||
Single
|
hasMore,
|
||||||
</span>
|
}) => {
|
||||||
),
|
const [playlist, setPlaylist] = React.useState(initialPlaylist)
|
||||||
album: () => (
|
|
||||||
<span className="playlistType">
|
|
||||||
<Icons.MdAlbum />
|
|
||||||
Album
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
ep: () => (
|
|
||||||
<span className="playlistType">
|
|
||||||
<Icons.MdAlbum />
|
|
||||||
EP
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
mix: () => (
|
|
||||||
<span className="playlistType">
|
|
||||||
<Icons.MdMusicNote />
|
|
||||||
Mix
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
const PlaylistInfo = (props) => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ReactMarkdown
|
|
||||||
remarkPlugins={[remarkGfm]}
|
|
||||||
children={props.data.description}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const MoreMenuHandlers = {
|
|
||||||
edit: async (playlist) => {},
|
|
||||||
delete: async (playlist) => {
|
|
||||||
return antd.Modal.confirm({
|
|
||||||
title: "Are you sure you want to delete this playlist?",
|
|
||||||
onOk: async () => {
|
|
||||||
const result = await MusicModel.deletePlaylist(
|
|
||||||
playlist._id,
|
|
||||||
).catch((err) => {
|
|
||||||
console.log(err)
|
|
||||||
|
|
||||||
app.message.error("Failed to delete playlist")
|
|
||||||
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
app.navigation.goToMusic()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const PlaylistView = (props) => {
|
|
||||||
const [playlist, setPlaylist] = React.useState(props.playlist)
|
|
||||||
const [searchResults, setSearchResults] = React.useState(null)
|
const [searchResults, setSearchResults] = React.useState(null)
|
||||||
const [owningPlaylist, setOwningPlaylist] = React.useState(
|
const searchTimeoutRef = React.useRef(null) // Ref for debounce timeout
|
||||||
checkUserIdIsSelf(props.playlist?.user_id),
|
|
||||||
|
// Derive ownership directly instead of using state
|
||||||
|
const isOwner = React.useMemo(
|
||||||
|
() => checkUserIdIsSelf(playlist?.user_id),
|
||||||
|
[playlist],
|
||||||
)
|
)
|
||||||
|
|
||||||
const moreMenuItems = React.useMemo(() => {
|
const playlistContextValue = React.useMemo(
|
||||||
const items = [
|
() => ({
|
||||||
{
|
playlist_data: playlist,
|
||||||
key: "edit",
|
owning_playlist: isOwner,
|
||||||
label: "Edit",
|
add_track: (track) => {
|
||||||
|
/* TODO: Implement */
|
||||||
},
|
},
|
||||||
]
|
remove_track: (track) => {
|
||||||
|
/* TODO: Implement */
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[playlist, isOwner],
|
||||||
|
)
|
||||||
|
|
||||||
if (!playlist.type || playlist.type === "playlist") {
|
// Define handlers for playlist actions (Edit, Delete)
|
||||||
if (checkUserIdIsSelf(playlist.user_id)) {
|
const MoreMenuHandlers = React.useMemo(
|
||||||
items.push({
|
() => ({
|
||||||
key: "delete",
|
edit: async (pl) => {
|
||||||
label: "Delete",
|
// 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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
},
|
||||||
}
|
}),
|
||||||
|
[],
|
||||||
return items
|
)
|
||||||
})
|
|
||||||
|
|
||||||
const contextValues = {
|
|
||||||
playlist_data: playlist,
|
|
||||||
owning_playlist: owningPlaylist,
|
|
||||||
add_track: (track) => {},
|
|
||||||
remove_track: (track) => {},
|
|
||||||
}
|
|
||||||
|
|
||||||
let debounceSearch = null
|
|
||||||
|
|
||||||
const makeSearch = (value) => {
|
const makeSearch = (value) => {
|
||||||
//TODO: Implement me using API
|
// TODO: Implement API call for search
|
||||||
return app.message.info("Not implemented yet...")
|
console.log("Searching for:", value)
|
||||||
|
setSearchResults([]) // Placeholder: clear results or set loading state
|
||||||
|
return app.message.info("Search not implemented yet...")
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOnSearchChange = (value) => {
|
const handleSearchChange = (value) => {
|
||||||
debounceSearch = setTimeout(() => {
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current)
|
||||||
|
}
|
||||||
|
searchTimeoutRef.current = setTimeout(() => {
|
||||||
makeSearch(value)
|
makeSearch(value)
|
||||||
}, 500)
|
}, 500) // 500ms debounce
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOnSearchEmpty = () => {
|
const handleSearchEmpty = () => {
|
||||||
if (debounceSearch) {
|
if (searchTimeoutRef.current) {
|
||||||
clearTimeout(debounceSearch)
|
clearTimeout(searchTimeoutRef.current)
|
||||||
}
|
}
|
||||||
|
setSearchResults(null) // Clear search results when input is cleared
|
||||||
setSearchResults(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOnClickPlaylistPlay = () => {
|
const handlePlayAll = () => {
|
||||||
app.cores.player.start(playlist.items)
|
if (playlist?.items?.length > 0) {
|
||||||
|
app.cores.player.start(playlist.items)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOnClickViewDetails = () => {
|
const handleViewDetails = () => {
|
||||||
app.layout.modal.open("playlist_info", PlaylistInfo, {
|
if (playlist?.description) {
|
||||||
props: {
|
app.layout.modal.open(
|
||||||
data: playlist,
|
"playlist_info",
|
||||||
},
|
() => (
|
||||||
})
|
<PlaylistInfoModalContent
|
||||||
|
description={playlist.description}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
{ title: playlist.title || "Playlist Info" }, // Add title to modal
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOnClickTrack = (track) => {
|
const handleTrackClick = (track) => {
|
||||||
// search index of track
|
const index = playlist.items.findIndex((item) => item._id === track._id)
|
||||||
const index = playlist.items.findIndex((item) => {
|
|
||||||
return item._id === track._id
|
|
||||||
})
|
|
||||||
|
|
||||||
|
// Track not found in current playlist items
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if clicked track is currently playing
|
const playerCore = app.cores.player
|
||||||
if (app.cores.player.state.track_manifest?._id === track._id) {
|
// Toggle playback if the clicked track is already playing
|
||||||
app.cores.player.playback.toggle()
|
if (playerCore.state.track_manifest?._id === track._id) {
|
||||||
|
playerCore.playback.toggle()
|
||||||
} else {
|
} else {
|
||||||
app.cores.player.start(playlist.items, {
|
// Start playback from the clicked track
|
||||||
startIndex: index,
|
playerCore.start(playlist.items, { startIndex: index })
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateTrackLike = (track_id, liked) => {
|
const handleTrackStateChange = (track_id, update) => {
|
||||||
setPlaylist((prev) => {
|
setPlaylist((prev) => {
|
||||||
const index = prev.list.findIndex((item) => {
|
if (!prev) return prev
|
||||||
return item._id === track_id
|
const trackIndex = prev.items.findIndex(
|
||||||
})
|
(item) => item._id === track_id,
|
||||||
|
)
|
||||||
|
|
||||||
if (index !== -1) {
|
if (trackIndex !== -1) {
|
||||||
const newState = {
|
const updatedItems = [...prev.items]
|
||||||
...prev,
|
updatedItems[trackIndex] = {
|
||||||
}
|
...updatedItems[trackIndex],
|
||||||
|
|
||||||
newState.list[index].liked = liked
|
|
||||||
|
|
||||||
return newState
|
|
||||||
}
|
|
||||||
|
|
||||||
return prev
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTrackChangeState = (track_id, update) => {
|
|
||||||
setPlaylist((prev) => {
|
|
||||||
const index = prev.list.findIndex((item) => {
|
|
||||||
return item._id === track_id
|
|
||||||
})
|
|
||||||
|
|
||||||
if (index !== -1) {
|
|
||||||
const newState = {
|
|
||||||
...prev,
|
|
||||||
}
|
|
||||||
|
|
||||||
newState.list[index] = {
|
|
||||||
...newState.list[index],
|
|
||||||
...update,
|
...update,
|
||||||
}
|
}
|
||||||
|
return { ...prev, items: updatedItems }
|
||||||
return newState
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return prev
|
return prev
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMoreMenuClick = async (e) => {
|
const handleMoreMenuClick = async (e) => {
|
||||||
const handler = MoreMenuHandlers[e.key]
|
const handler = MoreMenuHandlers[e.key]
|
||||||
|
if (typeof handler === "function") {
|
||||||
if (typeof handler !== "function") {
|
await handler(playlist)
|
||||||
throw new Error(`Invalid menu handler [${e.key}]`)
|
} else {
|
||||||
|
console.error(`Invalid menu handler key: ${e.key}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return await handler(playlist)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useWsEvents(
|
React.useEffect(() => {
|
||||||
{
|
setPlaylist(initialPlaylist)
|
||||||
"music:track:toggle:like": (data) => {
|
setSearchResults(null)
|
||||||
handleUpdateTrackLike(data.track_id, data.action === "liked")
|
}, [initialPlaylist])
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
socketName: "music",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setPlaylist(props.playlist)
|
return () => {
|
||||||
setOwningPlaylist(checkUserIdIsSelf(props.playlist?.user_id))
|
if (searchTimeoutRef.current) {
|
||||||
}, [props.playlist])
|
clearTimeout(searchTimeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (!playlist) {
|
if (!playlist) {
|
||||||
return <antd.Skeleton active />
|
return <antd.Skeleton active />
|
||||||
}
|
}
|
||||||
|
|
||||||
const playlistType = playlist.type?.toLowerCase() ?? "playlist"
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlaylistContext.Provider value={contextValues}>
|
<PlaylistContext.Provider value={playlistContextValue}>
|
||||||
<WithPlayerContext>
|
<WithPlayerContext>
|
||||||
<div className={classnames("playlist_view")}>
|
<div className={classnames("playlist_view")}>
|
||||||
{!props.noHeader && (
|
{!noHeader && (
|
||||||
<div className="play_info_wrapper">
|
<PlaylistHeader
|
||||||
<div className="play_info">
|
playlist={playlist}
|
||||||
<div className="play_info_cover">
|
owningPlaylist={isOwner}
|
||||||
<ImageViewer
|
onPlayAll={handlePlayAll}
|
||||||
src={
|
onViewDetails={handleViewDetails}
|
||||||
playlist.cover ??
|
onMoreMenuClick={handleMoreMenuClick}
|
||||||
playlist?.thumbnail ??
|
/>
|
||||||
"/assets/no_song.png"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="play_info_details">
|
|
||||||
<div className="play_info_title">
|
|
||||||
{playlist.service === "tidal" && (
|
|
||||||
<Icons.SiTidal />
|
|
||||||
)}
|
|
||||||
{typeof playlist.title ===
|
|
||||||
"function" ? (
|
|
||||||
playlist.title
|
|
||||||
) : (
|
|
||||||
<h1>{playlist.title}</h1>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="play_info_statistics">
|
|
||||||
{playlistType &&
|
|
||||||
PlaylistTypeDecorators[
|
|
||||||
playlistType
|
|
||||||
] && (
|
|
||||||
<div className="play_info_statistics_item">
|
|
||||||
{PlaylistTypeDecorators[
|
|
||||||
playlistType
|
|
||||||
]()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="play_info_statistics_item">
|
|
||||||
<p>
|
|
||||||
<Icons.MdLibraryMusic />{" "}
|
|
||||||
{props.length ??
|
|
||||||
playlist.total_length ??
|
|
||||||
playlist.items.length}{" "}
|
|
||||||
Items
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{playlist.publisher && (
|
|
||||||
<div className="play_info_statistics_item">
|
|
||||||
<p
|
|
||||||
onClick={() => {
|
|
||||||
app.navigation.goToAccount(
|
|
||||||
playlist.publisher
|
|
||||||
.username,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icons.MdPerson />
|
|
||||||
Publised by{" "}
|
|
||||||
<a>
|
|
||||||
{
|
|
||||||
playlist.publisher
|
|
||||||
.username
|
|
||||||
}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="play_info_actions">
|
|
||||||
<antd.Button
|
|
||||||
type="primary"
|
|
||||||
shape="rounded"
|
|
||||||
size="large"
|
|
||||||
onClick={handleOnClickPlaylistPlay}
|
|
||||||
>
|
|
||||||
<Icons.MdPlayArrow />
|
|
||||||
Play
|
|
||||||
</antd.Button>
|
|
||||||
|
|
||||||
{playlist.description && (
|
|
||||||
<antd.Button
|
|
||||||
icon={<Icons.MdInfo />}
|
|
||||||
onClick={
|
|
||||||
handleOnClickViewDetails
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{owningPlaylist && (
|
|
||||||
<antd.Dropdown
|
|
||||||
trigger={["click"]}
|
|
||||||
placement="bottom"
|
|
||||||
menu={{
|
|
||||||
items: moreMenuItems,
|
|
||||||
onClick:
|
|
||||||
handleMoreMenuClick,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<antd.Button
|
|
||||||
icon={<Icons.MdMoreVert />}
|
|
||||||
/>
|
|
||||||
</antd.Dropdown>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="list">
|
<TrackList
|
||||||
{!props.noHeader && playlist.items.length > 0 && (
|
tracks={playlist.items || []}
|
||||||
<div className="list_header">
|
searchResults={searchResults}
|
||||||
<h1>
|
onTrackClick={handleTrackClick}
|
||||||
<Icons.MdPlaylistPlay /> Tracks
|
onTrackStateChange={handleTrackStateChange}
|
||||||
</h1>
|
onSearchChange={handleSearchChange}
|
||||||
|
onSearchEmpty={handleSearchEmpty}
|
||||||
<SearchButton
|
onLoadMore={onLoadMore}
|
||||||
onChange={handleOnSearchChange}
|
hasMore={hasMore}
|
||||||
onEmpty={handleOnSearchEmpty}
|
noHeader={noHeader}
|
||||||
disabled
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{playlist.items.length === 0 && (
|
|
||||||
<antd.Empty
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
<Icons.MdLibraryMusic /> This playlist
|
|
||||||
its empty!
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{searchResults &&
|
|
||||||
searchResults.map((item) => {
|
|
||||||
return (
|
|
||||||
<MusicTrack
|
|
||||||
key={item._id}
|
|
||||||
order={item._id}
|
|
||||||
track={item}
|
|
||||||
onPlay={() => handleOnClickTrack(item)}
|
|
||||||
changeState={(update) =>
|
|
||||||
handleTrackChangeState(
|
|
||||||
item._id,
|
|
||||||
update,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{!searchResults && playlist.items.length > 0 && (
|
|
||||||
<LoadMore
|
|
||||||
className="list_content"
|
|
||||||
loadingComponent={() => <antd.Skeleton />}
|
|
||||||
onBottom={props.onLoadMore}
|
|
||||||
hasMore={props.hasMore}
|
|
||||||
>
|
|
||||||
<WithPlayerContext>
|
|
||||||
{playlist.items.map((item, index) => {
|
|
||||||
return (
|
|
||||||
<MusicTrack
|
|
||||||
order={index + 1}
|
|
||||||
track={item}
|
|
||||||
onPlay={() =>
|
|
||||||
handleOnClickTrack(item)
|
|
||||||
}
|
|
||||||
changeState={(update) =>
|
|
||||||
handleTrackChangeState(
|
|
||||||
item._id,
|
|
||||||
update,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</WithPlayerContext>
|
|
||||||
</LoadMore>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</WithPlayerContext>
|
</WithPlayerContext>
|
||||||
</PlaylistContext.Provider>
|
</PlaylistContext.Provider>
|
||||||
|
@ -207,6 +207,13 @@ html {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
||||||
|
.likeButtonWrapper {
|
||||||
|
background-color: var(--background-color-primary);
|
||||||
|
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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 {
|
.music-track_title {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-family: "Space Grotesk", sans-serif;
|
//font-family: "Space Grotesk", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.music-track_artist {
|
.music-track_artist {
|
||||||
|
@ -286,7 +286,7 @@ const ReleaseEditor = (props) => {
|
|||||||
icon={<Icons.MdLink />}
|
icon={<Icons.MdLink />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
app.location.push(
|
app.location.push(
|
||||||
`/music/release/${globalState._id}`,
|
`/music/list/${globalState._id}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
@ -13,7 +13,6 @@ export class Tab extends React.Component {
|
|||||||
error: null,
|
error: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle on error
|
|
||||||
componentDidCatch(err) {
|
componentDidCatch(err) {
|
||||||
this.setState({ error: err })
|
this.setState({ error: err })
|
||||||
}
|
}
|
||||||
@ -28,7 +27,6 @@ export class Tab extends React.Component {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{this.props.children}</>
|
return <>{this.props.children}</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -49,7 +47,7 @@ export class PagePanelWithNavMenu extends React.Component {
|
|||||||
activeTab:
|
activeTab:
|
||||||
new URLSearchParams(window.location.search).get("type") ??
|
new URLSearchParams(window.location.search).get("type") ??
|
||||||
this.props.defaultTab ??
|
this.props.defaultTab ??
|
||||||
this.props.tabs[0].key,
|
this.props.tabs[0]?.key,
|
||||||
renders: [],
|
renders: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,41 +55,98 @@ export class PagePanelWithNavMenu extends React.Component {
|
|||||||
|
|
||||||
interface = {
|
interface = {
|
||||||
attachComponent: (id, component, options) => {
|
attachComponent: (id, component, options) => {
|
||||||
const renders = this.state.renders
|
this.setState((prevState) => ({
|
||||||
|
renders: [
|
||||||
renders.push({
|
...prevState.renders,
|
||||||
id: id,
|
{
|
||||||
component: component,
|
id: id,
|
||||||
options: options,
|
component: component,
|
||||||
ref: React.createRef(),
|
options: options,
|
||||||
})
|
ref: React.createRef(),
|
||||||
|
},
|
||||||
this.setState({
|
],
|
||||||
renders: renders,
|
}))
|
||||||
})
|
|
||||||
},
|
},
|
||||||
detachComponent: (id) => {
|
detachComponent: (id) => {
|
||||||
const renders = this.state.renders
|
this.setState((prevState) => ({
|
||||||
|
renders: prevState.renders.filter((render) => render.id !== id),
|
||||||
const index = renders.findIndex((render) => render.id === id)
|
}))
|
||||||
|
|
||||||
renders.splice(index, 1)
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
renders: renders,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateLayoutHeaderAndTopBar = () => {
|
||||||
|
const navMenuItems = this.getItems([
|
||||||
|
...(this.props.tabs ?? []),
|
||||||
|
...(this.props.extraItems ?? []),
|
||||||
|
])
|
||||||
|
|
||||||
|
const mobileNavMenuItems = this.getItems(this.props.tabs ?? [])
|
||||||
|
|
||||||
|
if (app.isMobile) {
|
||||||
|
if (mobileNavMenuItems.length > 0) {
|
||||||
|
app.layout.top_bar.render(
|
||||||
|
<NavMenu
|
||||||
|
activeKey={this.state.activeTab}
|
||||||
|
items={mobileNavMenuItems}
|
||||||
|
onClickItem={(key) => this.handleTabChange(key)}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
app.layout.top_bar.renderDefault()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
navMenuItems.length > 0 ||
|
||||||
|
this.state.renders.length > 0 ||
|
||||||
|
this.props.navMenuHeader
|
||||||
|
) {
|
||||||
|
app.layout.header.render(
|
||||||
|
<NavMenu
|
||||||
|
header={this.props.navMenuHeader}
|
||||||
|
activeKey={this.state.activeTab}
|
||||||
|
items={navMenuItems}
|
||||||
|
onClickItem={(key) => this.handleTabChange(key)}
|
||||||
|
renderNames
|
||||||
|
>
|
||||||
|
{this.state.renders.map((renderItem) =>
|
||||||
|
React.createElement(renderItem.component, {
|
||||||
|
...(renderItem.options.props ?? {}),
|
||||||
|
ref: renderItem.ref,
|
||||||
|
key: renderItem.id,
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
</NavMenu>,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
app.layout.header.render(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
app.layout.page_panels = this.interface
|
app.layout.page_panels = this.interface
|
||||||
|
|
||||||
if (app.isMobile) {
|
if (app.isMobile) {
|
||||||
app.layout.top_bar.shouldUseTopBarSpacer(true)
|
app.layout.top_bar.shouldUseTopBarSpacer(true)
|
||||||
app.layout.toggleCenteredContent(false)
|
app.layout.toggleCenteredContent(false)
|
||||||
|
} else {
|
||||||
|
app.layout.toggleCenteredContent(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.layout.toggleCenteredContent(true)
|
this.updateLayoutHeaderAndTopBar()
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps, prevState) {
|
||||||
|
if (
|
||||||
|
prevState.activeTab !== this.state.activeTab ||
|
||||||
|
prevProps.tabs !== this.props.tabs ||
|
||||||
|
prevProps.extraItems !== this.props.extraItems ||
|
||||||
|
prevState.renders !== this.state.renders ||
|
||||||
|
prevProps.navMenuHeader !== this.props.navMenuHeader ||
|
||||||
|
prevProps.defaultTab !== this.props.defaultTab
|
||||||
|
) {
|
||||||
|
this.updateLayoutHeaderAndTopBar()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
@ -102,7 +157,9 @@ export class PagePanelWithNavMenu extends React.Component {
|
|||||||
app.layout.header.render(null)
|
app.layout.header.render(null)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
app.layout.top_bar.renderDefault()
|
if (app.layout.top_bar) {
|
||||||
|
app.layout.top_bar.renderDefault()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,13 +169,23 @@ export class PagePanelWithNavMenu extends React.Component {
|
|||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.props.tabs.length === 0) {
|
if (this.props.tabs.length === 0 && !this.state.activeTab) {
|
||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
|
|
||||||
// slip the active tab by splitting on "."
|
|
||||||
if (!this.state.activeTab) {
|
if (!this.state.activeTab) {
|
||||||
console.error("PagePanelWithNavMenu: activeTab is not defined")
|
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 <></>
|
return <></>
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,14 +201,12 @@ export class PagePanelWithNavMenu extends React.Component {
|
|||||||
console.error(
|
console.error(
|
||||||
"PagePanelWithNavMenu: tab.children is not defined",
|
"PagePanelWithNavMenu: tab.children is not defined",
|
||||||
)
|
)
|
||||||
|
|
||||||
return (tab = null)
|
return (tab = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
tab = tab.children.find(
|
tab = tab.children.find(
|
||||||
(children) =>
|
(children) =>
|
||||||
children.key ===
|
children.key ===
|
||||||
`${activeTabDirectory[index - 1]}.${key}`,
|
`${activeTabDirectory.slice(0, index).join(".")}.${key}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -150,7 +215,6 @@ export class PagePanelWithNavMenu extends React.Component {
|
|||||||
if (this.props.onNotFound) {
|
if (this.props.onNotFound) {
|
||||||
return this.props.onNotFound()
|
return this.props.onNotFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<antd.Result
|
<antd.Result
|
||||||
status="404"
|
status="404"
|
||||||
@ -161,7 +225,6 @@ export class PagePanelWithNavMenu extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const componentProps = tab.props ?? this.props.tabProps
|
const componentProps = tab.props ?? this.props.tabProps
|
||||||
|
|
||||||
return React.createElement(tab.component, {
|
return React.createElement(tab.component, {
|
||||||
...componentProps,
|
...componentProps,
|
||||||
})
|
})
|
||||||
@ -176,7 +239,7 @@ export class PagePanelWithNavMenu extends React.Component {
|
|||||||
await this.props.beforeTabChange(key)
|
await this.props.beforeTabChange(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.setState({ activeTab: key })
|
this.setState({ activeTab: key })
|
||||||
|
|
||||||
if (this.props.useSetQueryType) {
|
if (this.props.useSetQueryType) {
|
||||||
this.replaceQueryTypeToCurrentTab(key)
|
this.replaceQueryTypeToCurrentTab(key)
|
||||||
@ -192,9 +255,11 @@ export class PagePanelWithNavMenu extends React.Component {
|
|||||||
|
|
||||||
if (this.props.transition) {
|
if (this.props.transition) {
|
||||||
if (document.startViewTransition) {
|
if (document.startViewTransition) {
|
||||||
return document.startViewTransition(() => {
|
document.startViewTransition(() => {
|
||||||
this.tabChange(key)
|
this.tabChange(key)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn(
|
console.warn(
|
||||||
@ -205,20 +270,17 @@ export class PagePanelWithNavMenu extends React.Component {
|
|||||||
this.primaryPanelRef.current &&
|
this.primaryPanelRef.current &&
|
||||||
this.primaryPanelRef.current?.classList
|
this.primaryPanelRef.current?.classList
|
||||||
) {
|
) {
|
||||||
// set to primary panel fade-opacity-leave class
|
|
||||||
this.primaryPanelRef.current.classList.add("fade-opacity-leave")
|
this.primaryPanelRef.current.classList.add("fade-opacity-leave")
|
||||||
|
|
||||||
// remove fade-opacity-leave class after animation
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.primaryPanelRef.current.classList.remove(
|
if (this.primaryPanelRef.current) {
|
||||||
"fade-opacity-leave",
|
this.primaryPanelRef.current.classList.remove(
|
||||||
)
|
"fade-opacity-leave",
|
||||||
|
)
|
||||||
|
}
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.tabChange(key)
|
return this.tabChange(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -229,59 +291,19 @@ export class PagePanelWithNavMenu extends React.Component {
|
|||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
return items.map((item) => ({
|
||||||
items = items.map((item) => {
|
key: item.key,
|
||||||
return {
|
icon: createIconRender(item.icon),
|
||||||
key: item.key,
|
label: item.label,
|
||||||
icon: createIconRender(item.icon),
|
children: item.children && this.getItems(item.children),
|
||||||
label: item.label,
|
disabled: item.disabled,
|
||||||
children: item.children && this.getItems(item.children),
|
props: item.props ?? {},
|
||||||
disabled: item.disabled,
|
}))
|
||||||
props: item.props ?? {},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{app.isMobile &&
|
|
||||||
app.layout.top_bar.render(
|
|
||||||
<NavMenu
|
|
||||||
activeKey={this.state.activeTab}
|
|
||||||
items={this.getItems(this.props.tabs)}
|
|
||||||
onClickItem={(key) => this.handleTabChange(key)}
|
|
||||||
/>,
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!app.isMobile &&
|
|
||||||
app.layout.header.render(
|
|
||||||
<NavMenu
|
|
||||||
header={this.props.navMenuHeader}
|
|
||||||
activeKey={this.state.activeTab}
|
|
||||||
items={this.getItems([
|
|
||||||
...(this.props.tabs ?? []),
|
|
||||||
...(this.props.extraItems ?? []),
|
|
||||||
])}
|
|
||||||
onClickItem={(key) => this.handleTabChange(key)}
|
|
||||||
renderNames
|
|
||||||
>
|
|
||||||
{Array.isArray(this.state.renders) && [
|
|
||||||
this.state.renders.map((render, index) => {
|
|
||||||
return React.createElement(
|
|
||||||
render.component,
|
|
||||||
{
|
|
||||||
...render.options.props,
|
|
||||||
ref: render.ref,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
]}
|
|
||||||
</NavMenu>,
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="pagePanels">
|
<div className="pagePanels">
|
||||||
<div className="panel" ref={this.primaryPanelRef}>
|
<div className="panel" ref={this.primaryPanelRef}>
|
||||||
{this.renderActiveTab()}
|
{this.renderActiveTab()}
|
||||||
|
@ -27,8 +27,8 @@ const ExtraActions = (props) => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
await trackInstance.manifest.serviceOperations.toggleItemFavourite(
|
await trackInstance.manifest.serviceOperations.toggleItemFavorite(
|
||||||
"track",
|
"tracks",
|
||||||
trackInstance.manifest._id,
|
trackInstance.manifest._id,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -47,7 +47,7 @@ const ExtraActions = (props) => {
|
|||||||
<LikeButton
|
<LikeButton
|
||||||
liked={
|
liked={
|
||||||
trackInstance?.manifest?.serviceOperations
|
trackInstance?.manifest?.serviceOperations
|
||||||
?.fetchLikeStatus
|
?.isItemFavorited
|
||||||
}
|
}
|
||||||
onClick={handleClickLike}
|
onClick={handleClickLike}
|
||||||
disabled={!trackInstance?.manifest?._id}
|
disabled={!trackInstance?.manifest?._id}
|
||||||
|
@ -4,20 +4,22 @@ import * as antd from "antd"
|
|||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
export default (props) => {
|
export default (props) => {
|
||||||
return <div className="player-volume_slider">
|
return (
|
||||||
<antd.Slider
|
<div className="player-volume_slider">
|
||||||
min={0}
|
<antd.Slider
|
||||||
max={1}
|
min={0}
|
||||||
step={0.01}
|
max={1}
|
||||||
value={props.volume}
|
step={0.01}
|
||||||
onAfterChange={props.onChange}
|
value={props.volume}
|
||||||
defaultValue={props.defaultValue}
|
onChangeComplete={props.onChange}
|
||||||
tooltip={{
|
defaultValue={props.defaultValue}
|
||||||
formatter: (value) => {
|
tooltip={{
|
||||||
return `${Math.round(value * 100)}%`
|
formatter: (value) => {
|
||||||
}
|
return `${Math.round(value * 100)}%`
|
||||||
}}
|
},
|
||||||
vertical
|
}}
|
||||||
/>
|
vertical
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -39,7 +39,7 @@ const EventsHandlers = {
|
|||||||
|
|
||||||
const track = app.cores.player.track()
|
const track = app.cores.player.track()
|
||||||
|
|
||||||
return await track.manifest.serviceOperations.toggleItemFavourite(
|
return await track.manifest.serviceOperations.toggleItemFavorite(
|
||||||
"track",
|
"track",
|
||||||
ctx.track_manifest._id,
|
ctx.track_manifest._id,
|
||||||
)
|
)
|
||||||
@ -133,7 +133,7 @@ const Controls = (props) => {
|
|||||||
<LikeButton
|
<LikeButton
|
||||||
liked={
|
liked={
|
||||||
trackInstance?.manifest?.serviceOperations
|
trackInstance?.manifest?.serviceOperations
|
||||||
?.fetchLikeStatus
|
?.isItemFavorited
|
||||||
}
|
}
|
||||||
onClick={() => handleAction("like")}
|
onClick={() => handleAction("like")}
|
||||||
disabled={!trackInstance?.manifest?._id}
|
disabled={!trackInstance?.manifest?._id}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import * as antd from "antd"
|
import Slider from "./slider"
|
||||||
import Slider from "@mui/material/Slider"
|
|
||||||
import classnames from "classnames"
|
import classnames from "classnames"
|
||||||
|
|
||||||
import seekToTimeLabel from "@utils/seekToTimeLabel"
|
import seekToTimeLabel from "@utils/seekToTimeLabel"
|
||||||
@ -8,6 +7,8 @@ import seekToTimeLabel from "@utils/seekToTimeLabel"
|
|||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
export default class SeekBar extends React.Component {
|
export default class SeekBar extends React.Component {
|
||||||
|
static updateInterval = 1000
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
playing: app.cores.player.state["playback_status"] === "playing",
|
playing: app.cores.player.state["playback_status"] === "playing",
|
||||||
timeText: "00:00",
|
timeText: "00:00",
|
||||||
@ -63,10 +64,16 @@ export default class SeekBar extends React.Component {
|
|||||||
const seek = app.cores.player.controls.seek()
|
const seek = app.cores.player.controls.seek()
|
||||||
const duration = app.cores.player.controls.duration()
|
const duration = app.cores.player.controls.duration()
|
||||||
|
|
||||||
const percent = (seek / duration) * 100
|
let percent = 0 // Default to 0
|
||||||
|
// Ensure duration is a positive number to prevent division by zero or NaN results
|
||||||
|
if (typeof duration === "number" && duration > 0) {
|
||||||
|
percent = (seek / duration) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure percent is a finite number; otherwise, default to 0.
|
||||||
|
// This handles cases like NaN (e.g., 0/0) or Infinity.
|
||||||
this.setState({
|
this.setState({
|
||||||
sliderTime: percent,
|
sliderTime: Number.isFinite(percent) ? percent : 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,7 +137,7 @@ export default class SeekBar extends React.Component {
|
|||||||
if (this.state.playing) {
|
if (this.state.playing) {
|
||||||
this.interval = setInterval(() => {
|
this.interval = setInterval(() => {
|
||||||
this.updateAll()
|
this.updateAll()
|
||||||
}, 1000)
|
}, SeekBar.updateInterval)
|
||||||
} else {
|
} else {
|
||||||
if (this.interval) {
|
if (this.interval) {
|
||||||
clearInterval(this.interval)
|
clearInterval(this.interval)
|
||||||
@ -173,7 +180,6 @@ export default class SeekBar extends React.Component {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Slider
|
<Slider
|
||||||
size="small"
|
|
||||||
value={this.state.sliderTime}
|
value={this.state.sliderTime}
|
||||||
disabled={
|
disabled={
|
||||||
this.props.stopped ||
|
this.props.stopped ||
|
||||||
@ -189,18 +195,17 @@ export default class SeekBar extends React.Component {
|
|||||||
sliderLock: true,
|
sliderLock: true,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
onChangeCommitted={() => {
|
onChangeCommitted={(_, value) => {
|
||||||
this.setState({
|
this.setState({
|
||||||
sliderLock: false,
|
sliderLock: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
this.handleSeek(this.state.sliderTime)
|
this.handleSeek(value)
|
||||||
|
|
||||||
if (!this.props.playing) {
|
if (!this.props.playing) {
|
||||||
app.cores.player.playback.play()
|
app.cores.player.playback.play()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
valueLabelDisplay="auto"
|
|
||||||
valueLabelFormat={(value) => {
|
valueLabelFormat={(value) => {
|
||||||
return seekToTimeLabel(
|
return seekToTimeLabel(
|
||||||
(value / 100) *
|
(value / 100) *
|
||||||
|
@ -1,75 +1,154 @@
|
|||||||
.player-seek_bar {
|
.player-seek_bar {
|
||||||
z-index: 330;
|
z-index: 330;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
align-self: center;
|
align-self: center;
|
||||||
|
|
||||||
width: 90%;
|
width: 90%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
margin: 0 0 10px 0;
|
gap: 6px;
|
||||||
|
|
||||||
border-radius: 8px;
|
//margin: 0 0 10px 0;
|
||||||
|
|
||||||
transition: all 150ms ease-in-out;
|
border-radius: 8px;
|
||||||
|
|
||||||
&.hidden {
|
transition: all 150ms ease-in-out;
|
||||||
height: 0px;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress {
|
&.hidden {
|
||||||
width: 100%;
|
height: 0px;
|
||||||
height: 100%;
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
transition: all 150ms ease-in-out;
|
.progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
&.hidden {
|
transition: all 150ms ease-in-out;
|
||||||
opacity: 0;
|
|
||||||
height: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.timers {
|
&.hidden {
|
||||||
display: inline-flex;
|
opacity: 0;
|
||||||
flex-direction: row;
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
width: 100%;
|
.timers {
|
||||||
height: fit-content;
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
justify-content: space-between;
|
width: 100%;
|
||||||
align-items: center;
|
height: fit-content;
|
||||||
}
|
|
||||||
|
|
||||||
.MuiSlider-rail {
|
justify-content: space-between;
|
||||||
height: 5px;
|
align-items: center;
|
||||||
}
|
|
||||||
|
|
||||||
.MuiSlider-track {
|
font-family: "DM Mono", monospace;
|
||||||
height: 5px;
|
font-size: 0.8rem;
|
||||||
background-color: var(--colorPrimary);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.MuiSlider-thumb {
|
.slider-container {
|
||||||
background-color: var(--colorPrimary);
|
position: relative;
|
||||||
|
|
||||||
}
|
z-index: 200;
|
||||||
|
|
||||||
h1,
|
width: 100%;
|
||||||
h2,
|
height: 8px;
|
||||||
h3,
|
|
||||||
h4,
|
transform: translateY(2px);
|
||||||
h5,
|
|
||||||
h6,
|
.slider-background-track {
|
||||||
p,
|
position: absolute;
|
||||||
span {
|
|
||||||
color: var(--text-color);
|
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) {
|
||||||
if (track.metadata.lossless) {
|
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);
|
color: var(--text-color-black);
|
||||||
}
|
}
|
||||||
|
|
||||||
.MuiSlider-root {
|
|
||||||
color: var(--text-color-black);
|
|
||||||
|
|
||||||
.MuiSlider-rail {
|
|
||||||
color: var(--text-color-black);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.loadCircle {
|
.loadCircle {
|
||||||
svg {
|
svg {
|
||||||
path {
|
path {
|
||||||
@ -150,11 +142,9 @@
|
|||||||
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
font-size: 1.5rem;
|
font-size: 1.4rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
||||||
font-family: "Space Grotesk", sans-serif;
|
|
||||||
|
|
||||||
transition: all 150ms ease-in-out;
|
transition: all 150ms ease-in-out;
|
||||||
|
|
||||||
color: currentColor;
|
color: currentColor;
|
||||||
@ -188,7 +178,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
gap: 5px;
|
gap: 10px;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
@ -1,15 +1,14 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import { Skeleton } from "antd"
|
import { Skeleton } from "antd"
|
||||||
import { LoadingOutlined } from "@ant-design/icons"
|
|
||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
export default () => {
|
const SkeletonComponent = () => {
|
||||||
return <div className="skeleton">
|
return (
|
||||||
<div className="indicator">
|
<div className="skeleton">
|
||||||
<LoadingOutlined spin />
|
<Skeleton active />
|
||||||
<h3>Loading...</h3>
|
</div>
|
||||||
</div>
|
)
|
||||||
<Skeleton active />
|
}
|
||||||
</div>
|
|
||||||
}
|
export default SkeletonComponent
|
||||||
|
@ -1,4 +1,10 @@
|
|||||||
.skeleton {
|
.skeleton {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
}
|
}
|
||||||
@ -9,14 +15,7 @@
|
|||||||
color: var(--background-color-contrast);
|
color: var(--background-color-contrast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.indicator {
|
|
||||||
color: var(--background-color-contrast);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
background-color: var(--background-color-accent);
|
background-color: var(--background-color-accent);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
@ -55,7 +55,7 @@ export class WithPlayerContext extends React.Component {
|
|||||||
state = app.cores.player.state
|
state = app.cores.player.state
|
||||||
|
|
||||||
events = {
|
events = {
|
||||||
"player.state.update": (state) => {
|
"player.state.update": async (state) => {
|
||||||
this.setState(state)
|
this.setState(state)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { MediaPlayer } from "dashjs"
|
import { MediaPlayer, Debug } from "dashjs"
|
||||||
import PlayerProcessors from "./PlayerProcessors"
|
import PlayerProcessors from "./PlayerProcessors"
|
||||||
import AudioPlayerStorage from "../player.storage"
|
import AudioPlayerStorage from "../player.storage"
|
||||||
|
|
||||||
@ -29,6 +29,7 @@ export default class AudioBase {
|
|||||||
// configure some settings for audio
|
// configure some settings for audio
|
||||||
this.audio.crossOrigin = "anonymous"
|
this.audio.crossOrigin = "anonymous"
|
||||||
this.audio.preload = "metadata"
|
this.audio.preload = "metadata"
|
||||||
|
this.audio.loop = this.player.state.playback_mode === "repeat"
|
||||||
|
|
||||||
// listen all events
|
// listen all events
|
||||||
for (const [key, value] of Object.entries(this.audioEvents)) {
|
for (const [key, value] of Object.entries(this.audioEvents)) {
|
||||||
@ -55,6 +56,9 @@ export default class AudioBase {
|
|||||||
resetSourceBuffersForTrackSwitch: true,
|
resetSourceBuffersForTrackSwitch: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// debug: {
|
||||||
|
// logLevel: Debug.LOG_LEVEL_DEBUG,
|
||||||
|
// },
|
||||||
})
|
})
|
||||||
|
|
||||||
this.demuxer.initialize(this.audio, null, false)
|
this.demuxer.initialize(this.audio, null, false)
|
||||||
@ -65,7 +69,10 @@ export default class AudioBase {
|
|||||||
this.audio.src = null
|
this.audio.src = null
|
||||||
this.audio.currentTime = 0
|
this.audio.currentTime = 0
|
||||||
|
|
||||||
this.demuxer.destroy()
|
if (this.demuxer) {
|
||||||
|
this.demuxer.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
this.createDemuxer()
|
this.createDemuxer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import ToolBarPlayer from "@components/Player/ToolBarPlayer"
|
import Player from "@components/Player"
|
||||||
|
|
||||||
export default class PlayerUI {
|
export default class PlayerUI {
|
||||||
constructor(player) {
|
constructor(player) {
|
||||||
@ -21,7 +21,7 @@ export default class PlayerUI {
|
|||||||
if (app.layout.tools_bar) {
|
if (app.layout.tools_bar) {
|
||||||
this.currentDomWindow = app.layout.tools_bar.attachRender(
|
this.currentDomWindow = app.layout.tools_bar.attachRender(
|
||||||
"mediaPlayer",
|
"mediaPlayer",
|
||||||
ToolBarPlayer,
|
Player,
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
position: "bottom",
|
position: "bottom",
|
||||||
|
@ -27,34 +27,63 @@ export default class TrackInstance {
|
|||||||
play = async (params = {}) => {
|
play = async (params = {}) => {
|
||||||
const startTime = performance.now()
|
const startTime = performance.now()
|
||||||
|
|
||||||
if (!this.manifest.source.endsWith(".mpd")) {
|
const isMpd = this.manifest.source.endsWith(".mpd")
|
||||||
this.player.base.demuxer.destroy()
|
const audioEl = this.player.base.audio
|
||||||
this.player.base.audio.src = this.manifest.source
|
|
||||||
|
if (!isMpd) {
|
||||||
|
// if a demuxer exists (from a previous MPD track), destroy it
|
||||||
|
if (this.player.base.demuxer) {
|
||||||
|
this.player.base.demuxer.destroy()
|
||||||
|
this.player.base.demuxer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// set the audio source directly
|
||||||
|
if (audioEl.src !== this.manifest.source) {
|
||||||
|
audioEl.src = this.manifest.source
|
||||||
|
audioEl.load() // important to apply the new src and stop previous playback
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// ensure the direct 'src' attribute is removed if it was set
|
||||||
|
const currentSrc = audioEl.getAttribute("src")
|
||||||
|
|
||||||
|
if (currentSrc && !currentSrc.startsWith("blob:")) {
|
||||||
|
// blob: indicates MSE is likely already in use
|
||||||
|
audioEl.removeAttribute("src")
|
||||||
|
audioEl.load() // tell the element to update its state after src removal
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure a demuxer instance exists
|
||||||
if (!this.player.base.demuxer) {
|
if (!this.player.base.demuxer) {
|
||||||
this.player.base.createDemuxer()
|
this.player.base.createDemuxer()
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.player.base.demuxer.attachSource(
|
// attach the mpd source to the demuxer
|
||||||
`${this.manifest.source}?t=${Date.now()}`,
|
await this.player.base.demuxer.attachSource(this.manifest.source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset audio properties
|
||||||
|
audioEl.currentTime = params.time ?? 0
|
||||||
|
audioEl.volume = 1
|
||||||
|
|
||||||
|
if (this.player.base.processors && this.player.base.processors.gain) {
|
||||||
|
this.player.base.processors.gain.set(this.player.state.volume)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioEl.paused) {
|
||||||
|
try {
|
||||||
|
await audioEl.play()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[INSTANCE] Error during audio.play():", error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
"[INSTANCE] Audio is already playing or will start shortly.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.player.base.audio.currentTime = params.time ?? 0
|
this._loadMs = performance.now() - startTime
|
||||||
|
|
||||||
if (this.player.base.audio.paused) {
|
console.log(`[INSTANCE] [tooks ${this._loadMs}ms] Playing >`, this)
|
||||||
await this.player.base.audio.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
// reset audio volume and gain
|
|
||||||
this.player.base.audio.volume = 1
|
|
||||||
this.player.base.processors.gain.set(this.player.state.volume)
|
|
||||||
|
|
||||||
const endTime = performance.now()
|
|
||||||
|
|
||||||
this._loadMs = endTime - startTime
|
|
||||||
|
|
||||||
console.log(`[INSTANCE] Playing >`, this)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pause = async () => {
|
pause = async () => {
|
||||||
@ -68,64 +97,4 @@ export default class TrackInstance {
|
|||||||
|
|
||||||
this.player.base.audio.play()
|
this.player.base.audio.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveManifest = async () => {
|
|
||||||
// if (typeof this.manifest === "string") {
|
|
||||||
// this.manifest = {
|
|
||||||
// src: this.manifest,
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// this.manifest = new TrackManifest(this.manifest, {
|
|
||||||
// serviceProviders: this.player.serviceProviders,
|
|
||||||
// })
|
|
||||||
|
|
||||||
// if (this.manifest.service) {
|
|
||||||
// if (!this.player.serviceProviders.has(this.manifest.service)) {
|
|
||||||
// throw new Error(
|
|
||||||
// `Service ${this.manifest.service} is not supported`,
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // try to resolve source file
|
|
||||||
// if (!this.manifest.source) {
|
|
||||||
// console.log("Resolving manifest cause no source defined")
|
|
||||||
|
|
||||||
// this.manifest = await this.player.serviceProviders.resolve(
|
|
||||||
// this.manifest.service,
|
|
||||||
// this.manifest,
|
|
||||||
// )
|
|
||||||
|
|
||||||
// console.log("Manifest resolved", this.manifest)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if (!this.manifest.source) {
|
|
||||||
// throw new Error("Manifest `source` is required")
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // set empty metadata if not provided
|
|
||||||
// if (!this.manifest.metadata) {
|
|
||||||
// this.manifest.metadata = {}
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // auto name if a title is not provided
|
|
||||||
// if (!this.manifest.metadata.title) {
|
|
||||||
// this.manifest.metadata.title = this.manifest.source.split("/").pop()
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // process overrides
|
|
||||||
// const override = await this.manifest.serviceOperations.fetchOverride()
|
|
||||||
|
|
||||||
// if (override) {
|
|
||||||
// console.log(
|
|
||||||
// `Override found for track ${this.manifest._id}`,
|
|
||||||
// override,
|
|
||||||
// )
|
|
||||||
|
|
||||||
// this.manifest.overrides = override
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return this.manifest
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
@ -97,18 +97,6 @@ export default class TrackManifest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serviceOperations = {
|
serviceOperations = {
|
||||||
fetchLikeStatus: async () => {
|
|
||||||
if (!this._id) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.ctx.serviceProviders.operation(
|
|
||||||
"isItemFavourited",
|
|
||||||
this.service,
|
|
||||||
this,
|
|
||||||
"track",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
fetchLyrics: async () => {
|
fetchLyrics: async () => {
|
||||||
if (!this._id) {
|
if (!this._id) {
|
||||||
return null
|
return null
|
||||||
@ -140,19 +128,31 @@ export default class TrackManifest {
|
|||||||
this,
|
this,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
toggleItemFavourite: async (to) => {
|
toggleItemFavorite: async (to) => {
|
||||||
if (!this._id) {
|
if (!this._id) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.ctx.serviceProviders.operation(
|
return await this.ctx.serviceProviders.operation(
|
||||||
"toggleItemFavourite",
|
"toggleItemFavorite",
|
||||||
this.service,
|
this.service,
|
||||||
this,
|
this,
|
||||||
"track",
|
"tracks",
|
||||||
to,
|
to,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
isItemFavorited: async () => {
|
||||||
|
if (!this._id) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.ctx.serviceProviders.operation(
|
||||||
|
"isItemFavorited",
|
||||||
|
this.service,
|
||||||
|
this,
|
||||||
|
"tracks",
|
||||||
|
)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
toSeriableObject = () => {
|
toSeriableObject = () => {
|
||||||
|
@ -119,6 +119,7 @@ export default class Player extends Core {
|
|||||||
return this.queue.currentItem
|
return this.queue.currentItem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Improve performance for large playlists
|
||||||
async start(manifest, { time, startIndex = 0, radioId } = {}) {
|
async start(manifest, { time, startIndex = 0, radioId } = {}) {
|
||||||
this.ui.attachPlayerComponent()
|
this.ui.attachPlayerComponent()
|
||||||
|
|
||||||
@ -150,6 +151,10 @@ export default class Player extends Core {
|
|||||||
playlist = await this.serviceProviders.resolveMany(playlist)
|
playlist = await this.serviceProviders.resolveMany(playlist)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (playlist.some((item) => !item.source)) {
|
||||||
|
playlist = await this.serviceProviders.resolveMany(playlist)
|
||||||
|
}
|
||||||
|
|
||||||
for await (let [index, _manifest] of playlist.entries()) {
|
for await (let [index, _manifest] of playlist.entries()) {
|
||||||
let instance = new TrackInstance(_manifest, this)
|
let instance = new TrackInstance(_manifest, this)
|
||||||
|
|
||||||
@ -176,12 +181,12 @@ export default class Player extends Core {
|
|||||||
|
|
||||||
// similar to player.start, but add to the queue
|
// similar to player.start, but add to the queue
|
||||||
// if next is true, it will add to the queue to the top of the queue
|
// if next is true, it will add to the queue to the top of the queue
|
||||||
async addToQueue(manifest, { next = false }) {
|
async addToQueue(manifest, { next = false } = {}) {
|
||||||
if (typeof manifest === "string") {
|
if (typeof manifest === "string") {
|
||||||
manifest = await this.serviceProviders.resolve(manifest)
|
manifest = await this.serviceProviders.resolve(manifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
let instance = await this.createInstance(manifest)
|
let instance = new TrackInstance(manifest, this)
|
||||||
|
|
||||||
this.queue.add(instance, next === true ? "start" : "end")
|
this.queue.add(instance, next === true ? "start" : "end")
|
||||||
|
|
||||||
|
@ -1,42 +1,42 @@
|
|||||||
import MusicModel from "comty.js/models/music"
|
import MusicModel from "comty.js/models/music"
|
||||||
|
|
||||||
export default class ComtyMusicServiceInterface {
|
export default class ComtyMusicServiceInterface {
|
||||||
static id = "default"
|
static id = "default"
|
||||||
|
|
||||||
resolve = async (manifest) => {
|
resolve = async (manifest) => {
|
||||||
if (typeof manifest === "string" && manifest.startsWith("https://")) {
|
if (typeof manifest === "string" && manifest.startsWith("https://")) {
|
||||||
return {
|
return {
|
||||||
source: manifest.source,
|
source: manifest.source,
|
||||||
service: "default",
|
service: "default",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof manifest === "string") {
|
if (typeof manifest === "string") {
|
||||||
manifest = {
|
manifest = {
|
||||||
_id: manifest,
|
_id: manifest,
|
||||||
service: ComtyMusicServiceInterface.id,
|
service: ComtyMusicServiceInterface.id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const track = await MusicModel.getTrackData(manifest._id)
|
const track = await MusicModel.getTrackData(manifest._id)
|
||||||
|
|
||||||
return track
|
return track
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveLyrics = async (manifest, options) => {
|
resolveLyrics = async (manifest, options) => {
|
||||||
return await MusicModel.getTrackLyrics(manifest._id, options)
|
return await MusicModel.getTrackLyrics(manifest._id, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveOverride = async (manifest) => {
|
resolveOverride = async (manifest) => {
|
||||||
// not supported yet for comty music service
|
// not supported yet for comty music service
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
isItemFavourited = async (manifest, itemType) => {
|
isItemFavorited = async (manifest, itemType) => {
|
||||||
return await MusicModel.isItemFavourited(itemType, manifest._id)
|
return await MusicModel.isItemFavorited(itemType, manifest._id)
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleItemFavourite = async (manifest, itemType, to) => {
|
toggleItemFavorite = async (manifest, itemType, to) => {
|
||||||
return await MusicModel.toggleItemFavourite(itemType, manifest._id, to)
|
return await MusicModel.toggleItemFavorite(itemType, manifest._id, to)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,11 @@ import { motion, AnimatePresence } from "motion/react"
|
|||||||
|
|
||||||
import { Icons, createIconRender } from "@components/Icons"
|
import { Icons, createIconRender } from "@components/Icons"
|
||||||
|
|
||||||
import { WithPlayerContext, Context } from "@contexts/WithPlayerContext"
|
import {
|
||||||
|
WithPlayerContext,
|
||||||
|
Context,
|
||||||
|
usePlayerStateContext,
|
||||||
|
} from "@contexts/WithPlayerContext"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
QuickNavMenuItems,
|
QuickNavMenuItems,
|
||||||
@ -36,33 +40,66 @@ const tourSteps = [
|
|||||||
const openPlayerView = () => {
|
const openPlayerView = () => {
|
||||||
app.layout.draggable.open("player", PlayerView)
|
app.layout.draggable.open("player", PlayerView)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openCreator = () => {
|
const openCreator = () => {
|
||||||
app.layout.draggable.open("creator", CreatorView)
|
app.layout.draggable.open("creator", CreatorView)
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlayerButton = (props) => {
|
const PlayerButton = (props) => {
|
||||||
|
const [currentManifest, setCurrentManifest] = React.useState(null)
|
||||||
|
const [coverAnalyzed, setCoverAnalyzed] = React.useState(null)
|
||||||
|
|
||||||
|
const [player] = usePlayerStateContext((state) => {
|
||||||
|
setCurrentManifest((prev) => {
|
||||||
|
if (!state.track_manifest) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prev?._id !== state.track_manifest?._id) {
|
||||||
|
return state.track_manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
openPlayerView()
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classnames("player_btn", {
|
className={classnames("player_btn", {
|
||||||
bounce: props.playback === "playing",
|
bounce: isPlaying,
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
"--average-color": props.colorAnalysis?.rgba,
|
"--average-color": coverAnalyzed?.rgba,
|
||||||
"--color": props.colorAnalysis?.isDark
|
"--color": coverAnalyzed?.isDark
|
||||||
? "var(--text-color-white)"
|
? "var(--text-color-white)"
|
||||||
: "var(--text-color-black)",
|
: "var(--text-color-black)",
|
||||||
}}
|
}}
|
||||||
onClick={openPlayerView}
|
onClick={openPlayerView}
|
||||||
>
|
>
|
||||||
{props.playback === "playing" ? (
|
{isPlaying ? <Icons.MdMusicNote /> : <Icons.MdPause />}
|
||||||
<Icons.MdMusicNote />
|
|
||||||
) : (
|
|
||||||
<Icons.MdPause />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -385,18 +422,7 @@ export class BottomBar extends React.Component {
|
|||||||
|
|
||||||
{this.context.track_manifest && (
|
{this.context.track_manifest && (
|
||||||
<div className="item">
|
<div className="item">
|
||||||
<PlayerButton
|
<PlayerButton />
|
||||||
manifest={
|
|
||||||
this.context.track_manifest
|
|
||||||
}
|
|
||||||
playback={
|
|
||||||
this.context.playback_status
|
|
||||||
}
|
|
||||||
colorAnalysis={
|
|
||||||
this.context.track_manifest
|
|
||||||
?.cover_analysis
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -6,172 +6,168 @@ import ActionsMenu from "../@mobile/actionsMenu"
|
|||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
export class DraggableDrawerController extends React.Component {
|
export class DraggableDrawerController extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props)
|
super(props)
|
||||||
|
|
||||||
this.interface = {
|
this.interface = {
|
||||||
open: this.open,
|
open: this.open,
|
||||||
destroy: this.destroy,
|
destroy: this.destroy,
|
||||||
actions: this.actions,
|
actions: this.actions,
|
||||||
}
|
exists: this.exists,
|
||||||
|
}
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
drawers: [],
|
drawers: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
app.layout.draggable = this.interface
|
app.layout.draggable = this.interface
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleDrawerOnClosed(drawer) {
|
async handleDrawerOnClosed(drawer) {
|
||||||
if (!drawer) {
|
if (!drawer) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof drawer.options.onClosed === "function") {
|
if (typeof drawer.options.onClosed === "function") {
|
||||||
await drawer.options.onClosed()
|
await drawer.options.onClosed()
|
||||||
}
|
}
|
||||||
|
|
||||||
this.destroy(drawer.id)
|
this.destroy(drawer.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
actions = (data) => {
|
actions = (data) => {
|
||||||
const win = this.open("actions-menu", ActionsMenu, {
|
const win = this.open("actions-menu", ActionsMenu, {
|
||||||
componentProps: {
|
componentProps: {
|
||||||
...data,
|
...data,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return win
|
return win
|
||||||
}
|
}
|
||||||
|
|
||||||
open = (id, render, options = {}) => {
|
open = (id, render, options = {}) => {
|
||||||
let drawerObj = {
|
let drawerObj = {
|
||||||
id: id,
|
id: id,
|
||||||
render: render,
|
render: render,
|
||||||
options: options
|
options: options,
|
||||||
}
|
}
|
||||||
|
|
||||||
const win = app.cores.window_mng.render(
|
const win = app.cores.window_mng.render(
|
||||||
id,
|
id,
|
||||||
<DraggableDrawer
|
<DraggableDrawer
|
||||||
options={options}
|
options={options}
|
||||||
onClosed={() => this.handleDrawerOnClosed(drawerObj)}
|
onClosed={() => this.handleDrawerOnClosed(drawerObj)}
|
||||||
>
|
>
|
||||||
{
|
{React.createElement(render, {
|
||||||
React.createElement(render, {
|
...options.componentProps,
|
||||||
...options.componentProps,
|
})}
|
||||||
})
|
</DraggableDrawer>,
|
||||||
}
|
)
|
||||||
</DraggableDrawer>
|
|
||||||
)
|
|
||||||
|
|
||||||
drawerObj.winId = win.id
|
drawerObj.winId = win.id
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
drawers: [...this.state.drawers, drawerObj],
|
drawers: [...this.state.drawers, drawerObj],
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...drawerObj,
|
...drawerObj,
|
||||||
close: () => this.destroy(id),
|
close: () => this.destroy(id),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy = (id) => {
|
destroy = (id) => {
|
||||||
const drawerIndex = this.state.drawers.findIndex((drawer) => drawer.id === id)
|
const drawerIndex = this.state.drawers.findIndex(
|
||||||
|
(drawer) => drawer.id === id,
|
||||||
|
)
|
||||||
|
|
||||||
if (drawerIndex === -1) {
|
if (drawerIndex === -1) {
|
||||||
console.error(`Drawer [${id}] not found`)
|
console.error(`Drawer [${id}] not found`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const drawer = this.state.drawers[drawerIndex]
|
const drawer = this.state.drawers[drawerIndex]
|
||||||
|
|
||||||
if (drawer.locked === true) {
|
if (drawer.locked === true) {
|
||||||
console.error(`Drawer [${drawer.id}] is locked`)
|
console.error(`Drawer [${drawer.id}] is locked`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const drawers = this.state.drawers
|
const drawers = this.state.drawers
|
||||||
|
|
||||||
drawers.splice(drawerIndex, 1)
|
drawers.splice(drawerIndex, 1)
|
||||||
|
|
||||||
this.setState({ drawers: drawers })
|
this.setState({ drawers: drawers })
|
||||||
|
|
||||||
app.cores.window_mng.close(drawer.id ?? id)
|
app.cores.window_mng.close(drawer.id ?? id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
exists = (id) => {
|
||||||
* This lifecycle method is called after the component has been updated.
|
return this.state.drawers.findIndex((drawer) => drawer.id === id) !== -1
|
||||||
* It will toggle the root scale effect based on the amount of drawers.
|
}
|
||||||
* If there are no drawers, the root scale effect is disabled.
|
|
||||||
* If there are one or more drawers, the root scale effect is enabled.
|
|
||||||
*/
|
|
||||||
componentDidUpdate() {
|
|
||||||
if (this.state.drawers.length === 0) {
|
|
||||||
app.layout.toggleRootScaleEffect(false)
|
|
||||||
} else {
|
|
||||||
app.layout.toggleRootScaleEffect(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
/**
|
||||||
return null
|
* This lifecycle method is called after the component has been updated.
|
||||||
}
|
* It will toggle the root scale effect based on the amount of drawers.
|
||||||
|
* If there are no drawers, the root scale effect is disabled.
|
||||||
|
* If there are one or more drawers, the root scale effect is enabled.
|
||||||
|
*/
|
||||||
|
componentDidUpdate() {
|
||||||
|
if (this.state.drawers.length === 0) {
|
||||||
|
app.layout.toggleRootScaleEffect(false)
|
||||||
|
} else {
|
||||||
|
app.layout.toggleRootScaleEffect(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DraggableDrawer = (props) => {
|
export const DraggableDrawer = (props) => {
|
||||||
const [isOpen, setIsOpen] = React.useState(true)
|
const [isOpen, setIsOpen] = React.useState(true)
|
||||||
|
|
||||||
async function handleOnOpenChanged(to) {
|
async function handleOnOpenChanged(to) {
|
||||||
if (to === true) {
|
if (to === true) {
|
||||||
return to
|
return to
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
|
|
||||||
if (typeof props.onClosed === "function") {
|
if (typeof props.onClosed === "function") {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
props.onClosed()
|
props.onClosed()
|
||||||
}, 350)
|
}, 350)
|
||||||
}
|
}
|
||||||
|
|
||||||
return to
|
return to
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Drawer.Root
|
return (
|
||||||
open={isOpen}
|
<Drawer.Root open={isOpen} onOpenChange={handleOnOpenChanged}>
|
||||||
onOpenChange={handleOnOpenChanged}
|
<Drawer.Portal>
|
||||||
>
|
<Drawer.Overlay className="app-drawer-overlay" />
|
||||||
<Drawer.Portal>
|
|
||||||
<Drawer.Overlay
|
|
||||||
className="app-drawer-overlay"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Drawer.Content
|
<Drawer.Content
|
||||||
className="app-drawer-content"
|
className="app-drawer-content"
|
||||||
onInteractOutside={() => {
|
onInteractOutside={() => {
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Drawer.Handle
|
<Drawer.Handle className="app-drawer-handle" />
|
||||||
className="app-drawer-handle"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Drawer.Title
|
<Drawer.Title className="app-drawer-title">
|
||||||
className="app-drawer-title"
|
{props.options?.title ?? "Drawer Title"}
|
||||||
>
|
</Drawer.Title>
|
||||||
{props.options?.title ?? "Drawer Title"}
|
|
||||||
</Drawer.Title>
|
|
||||||
|
|
||||||
{
|
{React.cloneElement(props.children, {
|
||||||
React.cloneElement(props.children, {
|
close: () => setIsOpen(false),
|
||||||
close: () => setIsOpen(false),
|
})}
|
||||||
})
|
</Drawer.Content>
|
||||||
}
|
</Drawer.Portal>
|
||||||
</Drawer.Content>
|
</Drawer.Root>
|
||||||
</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 user_id = props.state.user._id
|
||||||
|
|
||||||
const [L_Releases, R_Releases, E_Releases, M_Releases] =
|
const [L_Releases, R_Releases, E_Releases, M_Releases] =
|
||||||
app.cores.api.useRequest(MusicModel.getReleases, {
|
app.cores.api.useRequest(MusicModel.getAllReleases, {
|
||||||
user_id: user_id,
|
user_id: user_id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ import useHideOnMouseStop from "@hooks/useHideOnMouseStop"
|
|||||||
|
|
||||||
import { Icons } from "@components/Icons"
|
import { Icons } from "@components/Icons"
|
||||||
import Controls from "@components/Player/Controls"
|
import Controls from "@components/Player/Controls"
|
||||||
|
import SeekBar from "@components/Player/SeekBar"
|
||||||
import LiveInfo from "@components/Player/LiveInfo"
|
import LiveInfo from "@components/Player/LiveInfo"
|
||||||
|
|
||||||
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
|
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
|
||||||
@ -130,9 +131,11 @@ const PlayerController = React.forwardRef((props, ref) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lyrics-player-controller-info-details">
|
{playerState.track_manifest?.artist && (
|
||||||
<span>{playerState.track_manifest?.artistStr}</span>
|
<div className="lyrics-player-controller-info-details">
|
||||||
</div>
|
<span>{playerState.track_manifest?.artist}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{playerState.live && (
|
{playerState.live && (
|
||||||
<LiveInfo radioId={playerState.radioId} />
|
<LiveInfo radioId={playerState.radioId} />
|
||||||
@ -141,40 +144,7 @@ const PlayerController = React.forwardRef((props, ref) => {
|
|||||||
|
|
||||||
<Controls streamMode={playerState.live} />
|
<Controls streamMode={playerState.live} />
|
||||||
|
|
||||||
{!playerState.live && (
|
{!playerState.live && <SeekBar />}
|
||||||
<div className="lyrics-player-controller-progress-wrapper">
|
|
||||||
<div
|
|
||||||
className="lyrics-player-controller-progress"
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
setDraggingTime(true)
|
|
||||||
}}
|
|
||||||
onMouseUp={(e) => {
|
|
||||||
const rect =
|
|
||||||
e.currentTarget.getBoundingClientRect()
|
|
||||||
const seekTime =
|
|
||||||
(trackDuration * (e.clientX - rect.left)) /
|
|
||||||
rect.width
|
|
||||||
|
|
||||||
onDragEnd(seekTime)
|
|
||||||
}}
|
|
||||||
onMouseMove={(e) => {
|
|
||||||
const rect =
|
|
||||||
e.currentTarget.getBoundingClientRect()
|
|
||||||
const atWidth =
|
|
||||||
((e.clientX - rect.left) / rect.width) * 100
|
|
||||||
|
|
||||||
setCurrentDragWidth(atWidth)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="lyrics-player-controller-progress-bar"
|
|
||||||
style={{
|
|
||||||
width: `${draggingTime ? currentDragWidth : (currentTime / trackDuration) * 100}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="lyrics-player-controller-tags">
|
<div className="lyrics-player-controller-tags">
|
||||||
{playerState.track_manifest?.metadata?.lossless && (
|
{playerState.track_manifest?.metadata?.lossless && (
|
||||||
|
@ -1,179 +1,233 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import HLS from "hls.js"
|
import HLS from "hls.js"
|
||||||
|
|
||||||
import classnames from "classnames"
|
import classnames from "classnames"
|
||||||
|
|
||||||
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
|
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
|
||||||
|
|
||||||
const maxLatencyInMs = 55
|
const maxLatencyInMs = 55
|
||||||
|
|
||||||
const LyricsVideo = React.forwardRef((props, videoRef) => {
|
const LyricsVideo = React.forwardRef((props, videoRef) => {
|
||||||
const [playerState] = usePlayerStateContext()
|
const [playerState] = usePlayerStateContext()
|
||||||
|
|
||||||
const { lyrics } = props
|
const { lyrics } = props
|
||||||
|
|
||||||
const [initialLoading, setInitialLoading] = React.useState(true)
|
const [initialLoading, setInitialLoading] = React.useState(true)
|
||||||
const [syncInterval, setSyncInterval] = React.useState(null)
|
|
||||||
const [syncingVideo, setSyncingVideo] = React.useState(false)
|
const [syncingVideo, setSyncingVideo] = React.useState(false)
|
||||||
const [currentVideoLatency, setCurrentVideoLatency] = React.useState(0)
|
const [currentVideoLatency, setCurrentVideoLatency] = React.useState(0)
|
||||||
|
const isDebugEnabled = React.useMemo(
|
||||||
|
() => app.cores.settings.is("_debug", true),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
const hls = React.useRef(new HLS())
|
const hls = React.useRef(new HLS())
|
||||||
|
const syncIntervalRef = React.useRef(null)
|
||||||
|
|
||||||
async function seekVideoToSyncAudio() {
|
const stopSyncInterval = React.useCallback(() => {
|
||||||
if (!lyrics) {
|
setSyncingVideo(false)
|
||||||
return null
|
if (syncIntervalRef.current) {
|
||||||
|
clearInterval(syncIntervalRef.current)
|
||||||
|
syncIntervalRef.current = null
|
||||||
}
|
}
|
||||||
|
}, [setSyncingVideo])
|
||||||
|
|
||||||
|
const seekVideoToSyncAudio = React.useCallback(async () => {
|
||||||
if (
|
if (
|
||||||
|
!lyrics ||
|
||||||
!lyrics.video_source ||
|
!lyrics.video_source ||
|
||||||
typeof lyrics.sync_audio_at_ms === "undefined"
|
typeof lyrics.sync_audio_at_ms === "undefined" ||
|
||||||
|
!videoRef.current
|
||||||
) {
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTrackTime = app.cores.player.controls.seek()
|
const currentTrackTime = window.app.cores.player.controls.seek()
|
||||||
|
|
||||||
setSyncingVideo(true)
|
setSyncingVideo(true)
|
||||||
|
|
||||||
let newTime =
|
let newTime =
|
||||||
currentTrackTime + lyrics.sync_audio_at_ms / 1000 + 150 / 1000
|
currentTrackTime + lyrics.sync_audio_at_ms / 1000 + 150 / 1000
|
||||||
|
|
||||||
// dec some ms to ensure the video seeks correctly
|
|
||||||
newTime -= 5 / 1000
|
newTime -= 5 / 1000
|
||||||
|
|
||||||
videoRef.current.currentTime = newTime
|
videoRef.current.currentTime = newTime
|
||||||
}
|
}, [lyrics, videoRef, setSyncingVideo])
|
||||||
|
|
||||||
async function syncPlayback() {
|
const syncPlayback = React.useCallback(
|
||||||
// if something is wrong, stop syncing
|
async (override = false) => {
|
||||||
if (
|
if (
|
||||||
videoRef.current === null ||
|
!videoRef.current ||
|
||||||
!lyrics ||
|
!lyrics ||
|
||||||
!lyrics.video_source ||
|
!lyrics.video_source ||
|
||||||
typeof lyrics.sync_audio_at_ms === "undefined" ||
|
typeof lyrics.sync_audio_at_ms === "undefined"
|
||||||
playerState.playback_status !== "playing"
|
) {
|
||||||
) {
|
stopSyncInterval()
|
||||||
return stopSyncInterval()
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
const maxOffset = maxLatencyInMs / 1000
|
||||||
|
const currentVideoTimeDiff = Math.abs(
|
||||||
|
currentVideoTime - currentTrackTime,
|
||||||
|
)
|
||||||
|
|
||||||
|
setCurrentVideoLatency(currentVideoTimeDiff)
|
||||||
|
|
||||||
|
if (syncingVideo === true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentVideoTimeDiff > maxOffset) {
|
||||||
|
seekVideoToSyncAudio()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
videoRef,
|
||||||
|
lyrics,
|
||||||
|
playerState.playback_status,
|
||||||
|
setCurrentVideoLatency,
|
||||||
|
syncingVideo,
|
||||||
|
seekVideoToSyncAudio,
|
||||||
|
stopSyncInterval,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
const startSyncInterval = React.useCallback(() => {
|
||||||
|
if (syncIntervalRef.current) {
|
||||||
|
clearInterval(syncIntervalRef.current)
|
||||||
}
|
}
|
||||||
|
syncIntervalRef.current = setInterval(syncPlayback, 300)
|
||||||
|
}, [syncPlayback])
|
||||||
|
|
||||||
const currentTrackTime = 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
setCurrentVideoLatency(currentVideoTimeDiff)
|
|
||||||
|
|
||||||
if (syncingVideo === true) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
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(() => {
|
React.useEffect(() => {
|
||||||
|
setCurrentVideoLatency(0)
|
||||||
|
const videoElement = videoRef.current
|
||||||
|
if (!videoElement) return
|
||||||
|
|
||||||
|
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")) {
|
||||||
|
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") {
|
||||||
|
videoElement.loop = false
|
||||||
|
syncPlayback(true)
|
||||||
|
} else {
|
||||||
|
videoElement.loop = true
|
||||||
|
videoElement.currentTime = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
videoElement.src = ""
|
||||||
|
if (hls.current) {
|
||||||
|
hls.current.stopLoad()
|
||||||
|
if (hls.current.media) {
|
||||||
|
hls.current.detachMedia()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setInitialLoading(false)
|
||||||
|
}, [lyrics, videoRef, hls, setCurrentVideoLatency, setInitialLoading])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
stopSyncInterval()
|
||||||
|
|
||||||
|
if (initialLoading || !videoRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoElement = videoRef.current
|
||||||
|
const canPlayVideo = lyrics && lyrics.video_source
|
||||||
|
|
||||||
|
if (!canPlayVideo) {
|
||||||
|
videoElement.pause()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
lyrics?.video_source &&
|
|
||||||
playerState.loading === true &&
|
playerState.loading === true &&
|
||||||
playerState.playback_status === "playing"
|
playerState.playback_status === "playing"
|
||||||
) {
|
) {
|
||||||
videoRef.current.pause()
|
videoElement.pause()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const shouldSync = typeof lyrics.sync_audio_at_ms !== "undefined"
|
||||||
lyrics?.video_source &&
|
|
||||||
playerState.loading === false &&
|
|
||||||
playerState.playback_status === "playing"
|
|
||||||
) {
|
|
||||||
videoRef.current.play()
|
|
||||||
}
|
|
||||||
}, [playerState.loading])
|
|
||||||
|
|
||||||
//* Handle when playback status change
|
if (playerState.playback_status === "playing") {
|
||||||
React.useEffect(() => {
|
videoElement
|
||||||
if (initialLoading === false) {
|
.play()
|
||||||
console.log(
|
.catch((error) =>
|
||||||
`VIDEO:: Playback status changed to ${playerState.playback_status}`,
|
console.error("VIDEO:: Error playing video:", error),
|
||||||
)
|
)
|
||||||
|
if (shouldSync) {
|
||||||
if (lyrics && lyrics.video_source) {
|
startSyncInterval()
|
||||||
if (playerState.playback_status === "playing") {
|
|
||||||
videoRef.current.play()
|
|
||||||
startSyncInterval()
|
|
||||||
} else {
|
|
||||||
videoRef.current.pause()
|
|
||||||
stopSyncInterval()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
videoElement.pause()
|
||||||
}
|
}
|
||||||
}, [playerState.playback_status])
|
}, [
|
||||||
|
lyrics,
|
||||||
//* Handle when lyrics object change
|
playerState.playback_status,
|
||||||
React.useEffect(() => {
|
playerState.loading,
|
||||||
setCurrentVideoLatency(0)
|
initialLoading,
|
||||||
stopSyncInterval()
|
videoRef,
|
||||||
|
startSyncInterval,
|
||||||
if (lyrics) {
|
stopSyncInterval,
|
||||||
if (lyrics.video_source) {
|
])
|
||||||
console.log("Loading video source >", lyrics.video_source)
|
|
||||||
|
|
||||||
if (lyrics.video_source.endsWith(".mp4")) {
|
|
||||||
videoRef.current.src = lyrics.video_source
|
|
||||||
} else {
|
|
||||||
hls.current.loadSource(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()
|
|
||||||
} else {
|
|
||||||
videoRef.current.loop = true
|
|
||||||
videoRef.current.currentTime = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playerState.playback_status === "playing") {
|
|
||||||
videoRef.current.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setInitialLoading(false)
|
|
||||||
}, [lyrics])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
videoRef.current.addEventListener("seeked", (event) => {
|
const videoElement = videoRef.current
|
||||||
|
const hlsInstance = hls.current
|
||||||
|
|
||||||
|
const handleSeeked = () => {
|
||||||
setSyncingVideo(false)
|
setSyncingVideo(false)
|
||||||
})
|
}
|
||||||
|
|
||||||
hls.current.attachMedia(videoRef.current)
|
if (videoElement) {
|
||||||
|
videoElement.addEventListener("seeked", handleSeeked)
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
stopSyncInterval()
|
stopSyncInterval()
|
||||||
|
|
||||||
|
if (videoElement) {
|
||||||
|
videoElement.removeEventListener("seeked", handleSeeked)
|
||||||
|
}
|
||||||
|
if (hlsInstance) {
|
||||||
|
hlsInstance.destroy()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [videoRef, hls, stopSyncInterval, setSyncingVideo])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.lyrics?.sync_audio_at && (
|
{isDebugEnabled && (
|
||||||
<div className={classnames("videoDebugOverlay")}>
|
<div className={classnames("videoDebugOverlay")}>
|
||||||
<div>
|
<div>
|
||||||
<p>Maximun latency</p>
|
<p>Maximun latency</p>
|
||||||
@ -195,6 +249,7 @@ const LyricsVideo = React.forwardRef((props, videoRef) => {
|
|||||||
controls={false}
|
controls={false}
|
||||||
muted
|
muted
|
||||||
preload="auto"
|
preload="auto"
|
||||||
|
playsInline
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@ -1,329 +1,298 @@
|
|||||||
.lyrics {
|
.lyrics {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
&.stopped {
|
&.stopped {
|
||||||
.lyrics-video {
|
.lyrics-video {
|
||||||
filter: blur(6px);
|
filter: blur(6px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-background-color {
|
.lyrics-background-color {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
background:
|
background:
|
||||||
linear-gradient(0deg, rgba(var(--dominant-color), 1), rgba(0, 0, 0, 0)),
|
linear-gradient(0deg, rgb(var(--dominant-color)), rgba(0, 0, 0, 0)),
|
||||||
url("data:image/svg+xml,%3Csvg viewBox='0 0 284 284' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='7.59' numOctaves='5' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
url("data:image/svg+xml,%3Csvg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0' numOctaves='10' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-background-wrapper {
|
.lyrics-background-wrapper {
|
||||||
z-index: 110;
|
z-index: 110;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
|
|
||||||
.lyrics-background-cover {
|
.lyrics-background-cover {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
z-index: 110;
|
z-index: 110;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 40vw;
|
width: 40vw;
|
||||||
height: 40vw;
|
height: 40vw;
|
||||||
|
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-video {
|
.lyrics-video {
|
||||||
z-index: 120;
|
z-index: 120;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
|
||||||
transition: all 150ms ease-out;
|
transition: all 150ms ease-out;
|
||||||
|
|
||||||
&.hidden {
|
&.hidden {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-text-wrapper {
|
.lyrics-text-wrapper {
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
||||||
padding: 60px;
|
padding: 60px;
|
||||||
|
|
||||||
.lyrics-text {
|
.lyrics-text {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
width: 600px;
|
width: 600px;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
|
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
gap: 30px;
|
gap: 30px;
|
||||||
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
background-color: rgba(var(--background-color-accent-values), 0.6);
|
background-color: rgba(var(--background-color-accent-values), 0.6);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|
||||||
backdrop-filter: blur(5px);
|
backdrop-filter: blur(5px);
|
||||||
-webkit-backdrop-filter: blur(5px);
|
-webkit-backdrop-filter: blur(5px);
|
||||||
|
|
||||||
.line {
|
.line {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
|
|
||||||
opacity: 0.1;
|
opacity: 0.1;
|
||||||
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
&.current {
|
&.current {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-player-controller-wrapper {
|
.lyrics-player-controller-wrapper {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 210;
|
z-index: 210;
|
||||||
|
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|
||||||
padding: 60px;
|
padding: 60px;
|
||||||
|
|
||||||
transition: all 150ms ease-in-out;
|
transition: all 150ms ease-in-out;
|
||||||
|
|
||||||
&.hidden {
|
&.hidden {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-player-controller {
|
.lyrics-player-controller {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
||||||
width: 300px;
|
width: 300px;
|
||||||
|
|
||||||
padding: 30px;
|
padding: 20px;
|
||||||
|
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|
||||||
backdrop-filter: blur(5px);
|
backdrop-filter: blur(5px);
|
||||||
-webkit-backdrop-filter: blur(5px);
|
-webkit-backdrop-filter: blur(5px);
|
||||||
|
|
||||||
background-color: rgba(var(--background-color-accent-values), 0.8);
|
background-color: rgba(var(--background-color-accent-values), 0.8);
|
||||||
|
|
||||||
transition: all 150ms ease-in-out;
|
transition: all 150ms ease-in-out;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|
||||||
.player-controls {
|
.player-controls {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-player-controller-tags {
|
.lyrics-player-controller-tags {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-player-controller-info {
|
.lyrics-player-controller-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
gap: 10px;
|
gap: 5px;
|
||||||
|
|
||||||
transition: all 150ms ease-in-out;
|
transition: all 150ms ease-in-out;
|
||||||
|
|
||||||
.lyrics-player-controller-info-title {
|
.lyrics-player-controller-info-title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.4rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
color: var(--background-color-contrast);
|
color: var(--background-color-contrast);
|
||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-player-controller-title-text {
|
.lyrics-player-controller-title-text {
|
||||||
transition: all 150ms ease-in-out;
|
transition: all 150ms ease-in-out;
|
||||||
|
|
||||||
width: 90%;
|
width: 90%;
|
||||||
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
// do not wrap text
|
// do not wrap text
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
&.overflown {
|
&.overflown {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
height: 0px;
|
height: 0px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-player-controller-info-details {
|
.lyrics-player-controller-info-details {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
|
|
||||||
font-size: 0.6rem;
|
font-size: 0.7rem;
|
||||||
|
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
// do not wrap text
|
// do not wrap text
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-controls {
|
.player-controls {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
height: 0px;
|
height: 0px;
|
||||||
transition: all 150ms ease-in-out;
|
transition: all 150ms ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-player-controller-progress-wrapper {
|
.lyrics-player-controller-tags {
|
||||||
width: 100%;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
.lyrics-player-controller-progress {
|
align-items: center;
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
align-items: center;
|
justify-content: center;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 0px;
|
||||||
|
|
||||||
margin: auto;
|
gap: 10px;
|
||||||
|
|
||||||
transition: all 150ms ease-in-out;
|
opacity: 0;
|
||||||
|
|
||||||
border-radius: 12px;
|
transition: all 150ms ease-in-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
background-color: rgba(var(--background-color-accent-values), 0.8);
|
.videoDebugOverlay {
|
||||||
|
position: fixed;
|
||||||
|
|
||||||
&:hover {
|
top: 20px;
|
||||||
.lyrics-player-controller-progress-bar {
|
right: 20px;
|
||||||
height: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.lyrics-player-controller-progress-bar {
|
z-index: 300;
|
||||||
height: 5px;
|
|
||||||
|
|
||||||
background-color: white;
|
display: flex;
|
||||||
|
|
||||||
border-radius: 12px;
|
flex-direction: column;
|
||||||
|
|
||||||
transition: all 150ms ease-in-out;
|
padding: 10px;
|
||||||
}
|
border-radius: 12px;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.lyrics-player-controller-tags {
|
background-color: rgba(var(--background-color-accent-values), 0.8);
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
align-items: center;
|
width: 200px;
|
||||||
|
height: fit-content;
|
||||||
|
|
||||||
justify-content: center;
|
transition: all 150ms ease-in-out;
|
||||||
|
|
||||||
width: 100%;
|
&.hidden {
|
||||||
height: 0px;
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
gap: 10px;
|
.lyrics-text .line .word.current-word {
|
||||||
|
/* Styling for the currently active word */
|
||||||
opacity: 0;
|
font-weight: bold;
|
||||||
|
color: yellow; /* Example highlight */
|
||||||
transition: all 150ms ease-in-out;
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.videoDebugOverlay {
|
|
||||||
position: fixed;
|
|
||||||
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
|
|
||||||
z-index: 300;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
|
|
||||||
background-color: rgba(var(--background-color-accent-values), 0.8);
|
|
||||||
|
|
||||||
width: 200px;
|
|
||||||
height: fit-content;
|
|
||||||
|
|
||||||
transition: all 150ms ease-in-out;
|
|
||||||
|
|
||||||
&.hidden {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -13,173 +13,154 @@ import UserService from "@models/user"
|
|||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
const ChatPage = (props) => {
|
const ChatPage = (props) => {
|
||||||
const { to_user_id } = props.params
|
const { to_user_id } = props.params
|
||||||
|
|
||||||
const messagesRef = React.useRef()
|
const messagesRef = React.useRef()
|
||||||
|
|
||||||
const [isOnBottomView, setIsOnBottomView] = React.useState(true)
|
const [isOnBottomView, setIsOnBottomView] = React.useState(true)
|
||||||
const [currentText, setCurrentText] = React.useState("")
|
const [currentText, setCurrentText] = React.useState("")
|
||||||
|
|
||||||
const [L_User, R_User, E_User, M_User] = app.cores.api.useRequest(
|
const [L_User, R_User, E_User, M_User] = app.cores.api.useRequest(
|
||||||
UserService.data,
|
UserService.data,
|
||||||
{
|
{
|
||||||
user_id: to_user_id
|
user_id: to_user_id,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
const [L_History, R_History, E_History, M_History] = app.cores.api.useRequest(
|
const [L_History, R_History, E_History, M_History] =
|
||||||
ChatsService.getChatHistory,
|
app.cores.api.useRequest(ChatsService.getChatHistory, to_user_id)
|
||||||
to_user_id
|
|
||||||
)
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
sendMessage,
|
sendMessage,
|
||||||
messages,
|
messages,
|
||||||
setMessages,
|
setMessages,
|
||||||
setScroller,
|
setScroller,
|
||||||
emitTypingEvent,
|
emitTypingEvent,
|
||||||
isRemoteTyping,
|
isRemoteTyping,
|
||||||
} = useChat(to_user_id)
|
} = useChat(to_user_id)
|
||||||
|
|
||||||
console.log(R_User)
|
console.log(R_User)
|
||||||
|
|
||||||
async function submitMessage(e) {
|
async function submitMessage(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
if (!currentText) {
|
if (!currentText) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendMessage(currentText)
|
await sendMessage(currentText)
|
||||||
|
|
||||||
setCurrentText("")
|
setCurrentText("")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onInputChange(e) {
|
async function onInputChange(e) {
|
||||||
const value = e.target.value
|
const value = e.target.value
|
||||||
|
|
||||||
setCurrentText(value)
|
setCurrentText(value)
|
||||||
|
|
||||||
if (value === "") {
|
if (value === "") {
|
||||||
emitTypingEvent(false)
|
emitTypingEvent(false)
|
||||||
} {
|
}
|
||||||
emitTypingEvent(true)
|
{
|
||||||
}
|
emitTypingEvent(true)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
// React.useEffect(() => {
|
||||||
if (R_History) {
|
// if (R_History) {
|
||||||
setMessages(R_History.list)
|
// setMessages(R_History.list)
|
||||||
// scroll to bottom
|
// // scroll to bottom
|
||||||
messagesRef.current?.scrollTo({
|
// messagesRef.current?.scrollTo({
|
||||||
top: messagesRef.current.scrollHeight,
|
// top: messagesRef.current.scrollHeight,
|
||||||
behavior: "smooth",
|
// behavior: "smooth",
|
||||||
})
|
// })
|
||||||
}
|
// }
|
||||||
}, [R_History])
|
// }, [R_History])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isOnBottomView === true) {
|
if (isOnBottomView === true) {
|
||||||
setScroller(messagesRef)
|
setScroller(messagesRef)
|
||||||
} else {
|
} else {
|
||||||
setScroller(null)
|
setScroller(null)
|
||||||
}
|
}
|
||||||
}, [isOnBottomView])
|
}, [isOnBottomView])
|
||||||
|
|
||||||
if (E_History) {
|
if (E_History) {
|
||||||
return <antd.Result
|
return (
|
||||||
status="warning"
|
<antd.Result
|
||||||
title="Error"
|
status="warning"
|
||||||
subTitle={E_History.message}
|
title="Error"
|
||||||
/>
|
subTitle={E_History.message}
|
||||||
}
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (L_History) {
|
if (L_History) {
|
||||||
return <antd.Skeleton active />
|
return <antd.Skeleton active />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div
|
return (
|
||||||
className="chat-page"
|
<div className="chat-page">
|
||||||
>
|
<div className="chat-page-header">
|
||||||
<div className="chat-page-header">
|
<UserPreview user={R_User} />
|
||||||
<UserPreview
|
</div>
|
||||||
user={R_User}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={classnames(
|
className={classnames("chat-page-messages", {
|
||||||
"chat-page-messages",
|
["empty"]: messages.length === 0,
|
||||||
{
|
})}
|
||||||
["empty"]: messages.length === 0
|
ref={messagesRef}
|
||||||
}
|
>
|
||||||
)}
|
{messages.length === 0 && <antd.Empty />}
|
||||||
ref={messagesRef}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
messages.length === 0 && <antd.Empty />
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{messages.map((line, index) => {
|
||||||
messages.map((line, index) => {
|
return (
|
||||||
return <div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={classnames(
|
className={classnames("chat-page-line-wrapper", {
|
||||||
"chat-page-line-wrapper",
|
["self"]: line.user._id === app.userData._id,
|
||||||
{
|
})}
|
||||||
["self"]: line.user._id === app.userData._id
|
>
|
||||||
}
|
<div className="chat-page-line">
|
||||||
)}
|
<div className="chat-page-line-avatar">
|
||||||
>
|
<img src={line.user.avatar} />
|
||||||
<div className="chat-page-line">
|
<span>{line.user.username}</span>
|
||||||
<div
|
</div>
|
||||||
className="chat-page-line-avatar"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={line.user.avatar}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{line.user.username}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div className="chat-page-line-text">
|
||||||
className="chat-page-line-text"
|
<p>{line.content}</p>
|
||||||
>
|
</div>
|
||||||
<p>
|
</div>
|
||||||
{line.content}
|
</div>
|
||||||
</p>
|
)
|
||||||
</div>
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="chat-page-input-wrapper">
|
<div className="chat-page-input-wrapper">
|
||||||
<div className="chat-page-input">
|
<div className="chat-page-input">
|
||||||
<antd.Input.TextArea
|
<antd.Input.TextArea
|
||||||
placeholder="Enter message"
|
placeholder="Enter message"
|
||||||
value={currentText}
|
value={currentText}
|
||||||
onChange={onInputChange}
|
onChange={onInputChange}
|
||||||
onPressEnter={submitMessage}
|
onPressEnter={submitMessage}
|
||||||
autoSize
|
autoSize
|
||||||
maxLength={1024}
|
maxLength={1024}
|
||||||
maxRows={3}
|
maxRows={3}
|
||||||
/>
|
/>
|
||||||
<antd.Button
|
<antd.Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<Icons.FiSend />}
|
icon={<Icons.FiSend />}
|
||||||
onClick={submitMessage}
|
onClick={submitMessage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{isRemoteTyping && R_User && (
|
||||||
isRemoteTyping && R_User && <div className="chat-page-remote-typing">
|
<div className="chat-page-remote-typing">
|
||||||
<span>{R_User.username} is typing...</span>
|
<span>{R_User.username} is typing...</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ChatPage
|
export default ChatPage
|
||||||
|
@ -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 { Icons } from "@components/Icons"
|
||||||
import { PagePanelWithNavMenu } from "@components/PagePanels"
|
import { PagePanelWithNavMenu } from "@components/PagePanels"
|
||||||
|
|
||||||
|
import useCenteredContainer from "@hooks/useCenteredContainer"
|
||||||
|
|
||||||
import Tabs from "./tabs"
|
import Tabs from "./tabs"
|
||||||
|
|
||||||
const NavMenuHeader = (
|
const NavMenuHeader = (
|
||||||
@ -13,6 +15,8 @@ const NavMenuHeader = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
|
useCenteredContainer(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PagePanelWithNavMenu
|
<PagePanelWithNavMenu
|
||||||
tabs={Tabs}
|
tabs={Tabs}
|
||||||
|
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 Searcher from "@components/Searcher"
|
||||||
import SearchModel from "@models/search"
|
import SearchModel from "@models/search"
|
||||||
|
|
||||||
const MusicNavbar = (props) => {
|
const MusicNavbar = React.forwardRef((props, ref) => {
|
||||||
return (
|
return (
|
||||||
<div className="music_navbar">
|
<div className="music_navbar">
|
||||||
<Searcher
|
<Searcher
|
||||||
@ -17,6 +17,6 @@ const MusicNavbar = (props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
export default MusicNavbar
|
export default MusicNavbar
|
||||||
|
@ -8,61 +8,65 @@ import MusicModel from "@models/music"
|
|||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
const RecentlyPlayedItem = (props) => {
|
const RecentlyPlayedItem = (props) => {
|
||||||
const { track } = props
|
const { track } = props
|
||||||
|
|
||||||
return <div
|
return (
|
||||||
className="recently_played-item"
|
<div
|
||||||
onClick={() => app.cores.player.start(track._id)}
|
className="recently_played-item"
|
||||||
>
|
onClick={() => app.cores.player.start(track._id)}
|
||||||
<div className="recently_played-item-icon">
|
>
|
||||||
<Icons.FiPlay />
|
<div className="recently_played-item-icon">
|
||||||
</div>
|
<Icons.FiPlay />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="recently_played-item-cover">
|
<div className="recently_played-item-cover">
|
||||||
<Image
|
<Image src={track.cover} />
|
||||||
src={track.cover}
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="recently_played-item-content">
|
<div className="recently_played-item-content">
|
||||||
<h3>{track.title}</h3>
|
<h3>{track.title}</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const RecentlyPlayedList = (props) => {
|
const RecentlyPlayedList = (props) => {
|
||||||
const [L_Tracks, R_Tracks, E_Tracks, M_Tracks] = app.cores.api.useRequest(MusicModel.getRecentyPlayed, {
|
const [L_Tracks, R_Tracks, E_Tracks, M_Tracks] = app.cores.api.useRequest(
|
||||||
limit: 7
|
MusicModel.getRecentyPlayed,
|
||||||
})
|
{
|
||||||
|
limit: 6,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if (E_Tracks) {
|
if (E_Tracks) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className="recently_played">
|
return (
|
||||||
<div className="recently_played-header">
|
<div className="recently_played">
|
||||||
<h1><Icons.MdHistory /> Recently played</h1>
|
<div className="recently_played-header">
|
||||||
</div>
|
<h1>
|
||||||
|
<Icons.MdHistory /> Recently played
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="recently_played-content">
|
<div className="recently_played-content">
|
||||||
{
|
{L_Tracks && <antd.Skeleton active />}
|
||||||
L_Tracks && <antd.Skeleton active />
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{R_Tracks && R_Tracks.lenght === 0 && <antd.Skeleton active />}
|
||||||
!L_Tracks && <div className="recently_played-content-items">
|
|
||||||
{
|
{!L_Tracks && (
|
||||||
R_Tracks.map((track, index) => {
|
<div className="recently_played-content-items">
|
||||||
return <RecentlyPlayedItem
|
{R_Tracks.map((track, index) => {
|
||||||
key={index}
|
return (
|
||||||
track={track}
|
<RecentlyPlayedItem key={index} track={track} />
|
||||||
/>
|
)
|
||||||
})
|
})}
|
||||||
}
|
</div>
|
||||||
</div>
|
)}
|
||||||
}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default RecentlyPlayedList
|
export default RecentlyPlayedList
|
||||||
|
@ -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,105 +8,102 @@ import MusicTrack from "@components/Music/Track"
|
|||||||
import Playlist from "@components/Music/Playlist"
|
import Playlist from "@components/Music/Playlist"
|
||||||
|
|
||||||
const ResultGroupsDecorators = {
|
const ResultGroupsDecorators = {
|
||||||
"playlists": {
|
playlists: {
|
||||||
icon: "MdPlaylistPlay",
|
icon: "MdPlaylistPlay",
|
||||||
label: "Playlists",
|
label: "Playlists",
|
||||||
renderItem: (props) => {
|
renderItem: (props) => {
|
||||||
return <Playlist
|
return <Playlist key={props.key} playlist={props.item} />
|
||||||
key={props.key}
|
},
|
||||||
playlist={props.item}
|
},
|
||||||
/>
|
tracks: {
|
||||||
}
|
icon: "MdMusicNote",
|
||||||
},
|
label: "Tracks",
|
||||||
"tracks": {
|
renderItem: (props) => {
|
||||||
icon: "MdMusicNote",
|
return (
|
||||||
label: "Tracks",
|
<MusicTrack
|
||||||
renderItem: (props) => {
|
key={props.key}
|
||||||
return <MusicTrack
|
track={props.item}
|
||||||
key={props.key}
|
//onClickPlayBtn={() => app.cores.player.start(props.item)}
|
||||||
track={props.item}
|
onClick={() => app.location.push(`/play/${props.item._id}`)}
|
||||||
onClickPlayBtn={() => app.cores.player.start(props.item)}
|
/>
|
||||||
onClick={() => app.location.push(`/play/${props.item._id}`)}
|
)
|
||||||
/>
|
},
|
||||||
}
|
},
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SearchResults = ({
|
const SearchResults = ({ data }) => {
|
||||||
data
|
if (typeof data !== "object") {
|
||||||
}) => {
|
return null
|
||||||
if (typeof data !== "object") {
|
}
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
let groupsKeys = Object.keys(data)
|
let groupsKeys = Object.keys(data)
|
||||||
|
|
||||||
// filter out groups with no items array property
|
// filter out groups with no items array property
|
||||||
groupsKeys = groupsKeys.filter((key) => {
|
groupsKeys = groupsKeys.filter((key) => {
|
||||||
if (!Array.isArray(data[key].items)) {
|
if (!Array.isArray(data[key].items)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
// filter out groups with empty items array
|
// filter out groups with empty items array
|
||||||
groupsKeys = groupsKeys.filter((key) => {
|
groupsKeys = groupsKeys.filter((key) => {
|
||||||
return data[key].items.length > 0
|
return data[key].items.length > 0
|
||||||
})
|
})
|
||||||
|
|
||||||
if (groupsKeys.length === 0) {
|
if (groupsKeys.length === 0) {
|
||||||
return <div className="music-explorer_search_results no_results">
|
return (
|
||||||
<antd.Result
|
<div className="music-explorer_search_results no_results">
|
||||||
status="info"
|
<antd.Result
|
||||||
title="No results"
|
status="info"
|
||||||
subTitle="We are sorry, but we could not find any results for your search."
|
title="No results"
|
||||||
/>
|
subTitle="We are sorry, but we could not find any results for your search."
|
||||||
</div>
|
/>
|
||||||
}
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return <div
|
return (
|
||||||
className={classnames(
|
<div
|
||||||
"music-explorer_search_results",
|
className={classnames("music-explorer_search_results", {
|
||||||
{
|
["one_column"]: groupsKeys.length === 1,
|
||||||
["one_column"]: groupsKeys.length === 1,
|
})}
|
||||||
}
|
>
|
||||||
)}
|
{groupsKeys.map((key, index) => {
|
||||||
>
|
const decorator = ResultGroupsDecorators[key] ?? {
|
||||||
{
|
icon: null,
|
||||||
groupsKeys.map((key, index) => {
|
label: key,
|
||||||
const decorator = ResultGroupsDecorators[key] ?? {
|
renderItem: () => null,
|
||||||
icon: null,
|
}
|
||||||
label: key,
|
|
||||||
renderItem: () => null
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className="music-explorer_search_results_group" key={index}>
|
return (
|
||||||
<div className="music-explorer_search_results_group_header">
|
<div
|
||||||
<h1>
|
className="music-explorer_search_results_group"
|
||||||
{
|
key={index}
|
||||||
createIconRender(decorator.icon)
|
>
|
||||||
}
|
<div className="music-explorer_search_results_group_header">
|
||||||
<Translation>
|
<h1>
|
||||||
{(t) => t(decorator.label)}
|
{createIconRender(decorator.icon)}
|
||||||
</Translation>
|
<Translation>
|
||||||
</h1>
|
{(t) => t(decorator.label)}
|
||||||
</div>
|
</Translation>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="music-explorer_search_results_group_list">
|
<div className="music-explorer_search_results_group_list">
|
||||||
{
|
{data[key].items.map((item, index) => {
|
||||||
data[key].items.map((item, index) => {
|
return decorator.renderItem({
|
||||||
return decorator.renderItem({
|
key: index,
|
||||||
key: index,
|
item,
|
||||||
item
|
})
|
||||||
})
|
})}
|
||||||
})
|
</div>
|
||||||
}
|
</div>
|
||||||
</div>
|
)
|
||||||
</div>
|
})}
|
||||||
})
|
</div>
|
||||||
}
|
)
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SearchResults
|
export default SearchResults
|
||||||
|
@ -1,76 +1,83 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import classnames from "classnames"
|
import classnames from "classnames"
|
||||||
|
|
||||||
import useCenteredContainer from "@hooks/useCenteredContainer"
|
|
||||||
|
|
||||||
import Searcher from "@components/Searcher"
|
import Searcher from "@components/Searcher"
|
||||||
import { Icons } from "@components/Icons"
|
import { Icons } from "@components/Icons"
|
||||||
|
|
||||||
import FeedModel from "@models/feed"
|
|
||||||
import SearchModel from "@models/search"
|
import SearchModel from "@models/search"
|
||||||
|
import MusicModel from "@models/music"
|
||||||
|
import RadioModel from "@models/radio"
|
||||||
|
|
||||||
import Navbar from "./components/Navbar"
|
import Navbar from "./components/Navbar"
|
||||||
import RecentlyPlayedList from "./components/RecentlyPlayedList"
|
import RecentlyPlayedList from "./components/RecentlyPlayedList"
|
||||||
import SearchResults from "./components/SearchResults"
|
import SearchResults from "./components/SearchResults"
|
||||||
import ReleasesList from "./components/ReleasesList"
|
import FeedItems from "./components/FeedItems"
|
||||||
import FeaturedPlaylist from "./components/FeaturedPlaylist"
|
|
||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
const MusicExploreTab = (props) => {
|
const MusicExploreTab = (props) => {
|
||||||
const [searchResults, setSearchResults] = React.useState(false)
|
const [searchResults, setSearchResults] = React.useState(false)
|
||||||
|
|
||||||
useCenteredContainer(false)
|
React.useEffect(() => {
|
||||||
|
app.layout.page_panels.attachComponent("music_navbar", Navbar, {
|
||||||
|
props: {
|
||||||
|
setSearchResults: setSearchResults,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
React.useEffect(() => {
|
return () => {
|
||||||
app.layout.page_panels.attachComponent("music_navbar", Navbar, {
|
if (app.layout.page_panels) {
|
||||||
props: {
|
app.layout.page_panels.detachComponent("music_navbar")
|
||||||
setSearchResults: setSearchResults,
|
}
|
||||||
}
|
}
|
||||||
})
|
}, [])
|
||||||
|
|
||||||
return () => {
|
return (
|
||||||
if (app.layout.page_panels) {
|
<div className={classnames("music-explore")}>
|
||||||
app.layout.page_panels.detachComponent("music_navbar")
|
{app.isMobile && (
|
||||||
}
|
<Searcher
|
||||||
}
|
useUrlQuery
|
||||||
}, [])
|
renderResults={false}
|
||||||
|
model={(keywords, params) =>
|
||||||
|
SearchModel.search("music", keywords, params)
|
||||||
|
}
|
||||||
|
onSearchResult={setSearchResults}
|
||||||
|
onEmpty={() => setSearchResults(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
return <div
|
{searchResults && <SearchResults data={searchResults} />}
|
||||||
className={classnames(
|
|
||||||
"musicExplorer",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
app.isMobile && <Searcher
|
|
||||||
useUrlQuery
|
|
||||||
renderResults={false}
|
|
||||||
model={(keywords, params) => SearchModel.search("music", keywords, params)}
|
|
||||||
onSearchResult={setSearchResults}
|
|
||||||
onEmpty={() => setSearchResults(false)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{!searchResults && <RecentlyPlayedList />}
|
||||||
searchResults && <SearchResults
|
|
||||||
data={searchResults}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{!searchResults && (
|
||||||
!searchResults && <div className="feed_main">
|
<div className="music-explore-content">
|
||||||
<FeaturedPlaylist />
|
<FeedItems
|
||||||
|
type="tracks"
|
||||||
|
headerTitle="All Tracks"
|
||||||
|
headerIcon={<Icons.MdMusicNote />}
|
||||||
|
fetchMethod={MusicModel.getAllTracks}
|
||||||
|
itemsPerPage={6}
|
||||||
|
/>
|
||||||
|
|
||||||
<RecentlyPlayedList />
|
<FeedItems
|
||||||
|
type="playlists"
|
||||||
|
headerTitle="All Releases"
|
||||||
|
headerIcon={<Icons.MdNewspaper />}
|
||||||
|
fetchMethod={MusicModel.getAllReleases}
|
||||||
|
/>
|
||||||
|
|
||||||
<ReleasesList
|
<FeedItems
|
||||||
headerTitle="Explore"
|
type="radios"
|
||||||
headerIcon={<Icons.MdExplore />}
|
headerTitle="Trending Radios"
|
||||||
fetchMethod={FeedModel.getGlobalMusicFeed}
|
headerIcon={<Icons.FiRadio />}
|
||||||
/>
|
fetchMethod={RadioModel.getTrendings}
|
||||||
</div>
|
disablePagination
|
||||||
}
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MusicExploreTab
|
export default MusicExploreTab
|
||||||
|
@ -1,108 +1,14 @@
|
|||||||
html {
|
&.mobile {
|
||||||
&.mobile {
|
.music-explore {
|
||||||
.musicExplorer {
|
padding: 0 10px;
|
||||||
.playlistExplorer_section_list {
|
|
||||||
overflow: visible;
|
|
||||||
overflow-x: scroll;
|
|
||||||
|
|
||||||
width: unset;
|
.recently_played-content {
|
||||||
display: flex;
|
padding: 0;
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
grid-gap: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured_playlist {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
background-color: var(--background-color-accent);
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
min-height: 200px;
|
|
||||||
height: fit-content;
|
|
||||||
|
|
||||||
border-radius: 12px;
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.featured_playlist_content {
|
|
||||||
h1,
|
|
||||||
p {
|
|
||||||
-webkit-text-stroke-width: 1.6px;
|
|
||||||
-webkit-text-stroke-color: var(--border-color);
|
|
||||||
|
|
||||||
color: var(--background-color-contrast);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.lazy-load-image-background {
|
.music-explore-content {
|
||||||
opacity: 1;
|
display: flex;
|
||||||
}
|
flex-direction: column;
|
||||||
}
|
|
||||||
|
|
||||||
.lazy-load-image-background {
|
|
||||||
z-index: 50;
|
|
||||||
|
|
||||||
position: absolute;
|
|
||||||
|
|
||||||
opacity: 0.3;
|
|
||||||
|
|
||||||
transition: all 300ms ease-in-out !important;
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured_playlist_content {
|
|
||||||
z-index: 55;
|
|
||||||
|
|
||||||
padding: 20px;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-family: "Space Grotesk", sans-serif;
|
|
||||||
font-weight: 900;
|
|
||||||
|
|
||||||
transition: all 300ms ease-in-out !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-family: "Space Grotesk", sans-serif;
|
|
||||||
|
|
||||||
transition: all 300ms ease-in-out !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured_playlist_genre {
|
|
||||||
z-index: 55;
|
|
||||||
|
|
||||||
position: absolute;
|
|
||||||
|
|
||||||
left: 0;
|
|
||||||
bottom: 0;
|
|
||||||
|
|
||||||
margin: 10px;
|
|
||||||
|
|
||||||
background-color: var(--background-color-accent);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
|
|
||||||
border-radius: 12px;
|
|
||||||
|
|
||||||
padding: 10px 20px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -118,14 +24,14 @@ html {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.musicExplorer {
|
.music-explore {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
gap: 20px;
|
gap: 30px;
|
||||||
|
|
||||||
&.search-focused {
|
&.search-focused {
|
||||||
.feed_main {
|
.feed_main {
|
||||||
@ -134,18 +40,19 @@ html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed_main {
|
.music-explore-content {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: repeat(2, auto);
|
||||||
|
grid-template-rows: auto;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
gap: 50px;
|
gap: 30px;
|
||||||
|
|
||||||
transition: all 0.2s ease-in-out;
|
@media screen and (max-width: 1200px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
overflow-x: visible;
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,24 +4,30 @@ import * as antd from "antd"
|
|||||||
import { Icons } from "@components/Icons"
|
import { Icons } from "@components/Icons"
|
||||||
|
|
||||||
import TracksLibraryView from "./views/tracks"
|
import TracksLibraryView from "./views/tracks"
|
||||||
|
import ReleasesLibraryView from "./views/releases"
|
||||||
import PlaylistLibraryView from "./views/playlists"
|
import PlaylistLibraryView from "./views/playlists"
|
||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
const TabToView = {
|
const Views = {
|
||||||
tracks: TracksLibraryView,
|
|
||||||
playlist: PlaylistLibraryView,
|
|
||||||
releases: PlaylistLibraryView,
|
|
||||||
}
|
|
||||||
|
|
||||||
const TabToHeader = {
|
|
||||||
tracks: {
|
tracks: {
|
||||||
icon: <Icons.MdMusicNote />,
|
value: "tracks",
|
||||||
label: "Tracks",
|
label: "Tracks",
|
||||||
|
icon: <Icons.MdMusicNote />,
|
||||||
|
element: TracksLibraryView,
|
||||||
},
|
},
|
||||||
playlist: {
|
releases: {
|
||||||
icon: <Icons.MdPlaylistPlay />,
|
value: "releases",
|
||||||
|
label: "Releases",
|
||||||
|
icon: <Icons.MdAlbum />,
|
||||||
|
element: ReleasesLibraryView,
|
||||||
|
},
|
||||||
|
playlists: {
|
||||||
|
value: "playlists",
|
||||||
label: "Playlists",
|
label: "Playlists",
|
||||||
|
icon: <Icons.MdPlaylistPlay />,
|
||||||
|
element: PlaylistLibraryView,
|
||||||
|
disabled: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,29 +40,13 @@ const Library = (props) => {
|
|||||||
<antd.Segmented
|
<antd.Segmented
|
||||||
value={selectedTab}
|
value={selectedTab}
|
||||||
onChange={setSelectedTab}
|
onChange={setSelectedTab}
|
||||||
options={[
|
options={Object.values(Views)}
|
||||||
{
|
|
||||||
value: "tracks",
|
|
||||||
label: "Tracks",
|
|
||||||
icon: <Icons.MdMusicNote />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "playlist",
|
|
||||||
label: "Playlists",
|
|
||||||
icon: <Icons.MdPlaylistPlay />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "releases",
|
|
||||||
label: "Releases",
|
|
||||||
icon: <Icons.MdPlaylistPlay />,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedTab &&
|
{selectedTab &&
|
||||||
TabToView[selectedTab] &&
|
Views[selectedTab] &&
|
||||||
React.createElement(TabToView[selectedTab])}
|
React.createElement(Views[selectedTab].element)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,181 +1,76 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import * as antd from "antd"
|
import * as antd from "antd"
|
||||||
import classnames from "classnames"
|
|
||||||
|
|
||||||
import Image from "@components/Image"
|
import PlaylistView from "@components/Music/PlaylistView"
|
||||||
import { Icons } from "@components/Icons"
|
|
||||||
import OpenPlaylistCreator from "@components/Music/PlaylistCreator"
|
|
||||||
|
|
||||||
import MusicModel from "@models/music"
|
import MusicModel from "@models/music"
|
||||||
|
|
||||||
import "./index.less"
|
const loadLimit = 50
|
||||||
|
|
||||||
const ReleaseTypeDecorators = {
|
const MyLibraryPlaylists = () => {
|
||||||
"user": () => <p >
|
const [offset, setOffset] = React.useState(0)
|
||||||
<Icons.MdPlaylistAdd />
|
const [items, setItems] = React.useState([])
|
||||||
Playlist
|
const [hasMore, setHasMore] = React.useState(true)
|
||||||
</p>,
|
const [initialLoading, setInitialLoading] = React.useState(true)
|
||||||
"playlist": () => <p >
|
|
||||||
<Icons.MdPlaylistAdd />
|
const [L_Library, R_Library, E_Library, M_Library] =
|
||||||
Playlist
|
app.cores.api.useRequest(MusicModel.getMyLibrary, {
|
||||||
</p>,
|
offset: offset,
|
||||||
"editorial": () => <p >
|
limit: loadLimit,
|
||||||
<Icons.MdPlaylistAdd />
|
kind: "playlists",
|
||||||
Official Playlist
|
})
|
||||||
</p>,
|
|
||||||
"single": () => <p >
|
async function onLoadMore() {
|
||||||
<Icons.MdMusicNote />
|
const newOffset = offset + loadLimit
|
||||||
Single
|
|
||||||
</p>,
|
setOffset(newOffset)
|
||||||
"album": () => <p >
|
|
||||||
<Icons.MdAlbum />
|
M_Library({
|
||||||
Album
|
offset: newOffset,
|
||||||
</p>,
|
limit: loadLimit,
|
||||||
"ep": () => <p >
|
})
|
||||||
<Icons.MdAlbum />
|
}
|
||||||
EP
|
|
||||||
</p>,
|
React.useEffect(() => {
|
||||||
"mix": () => <p >
|
if (R_Library && R_Library.items) {
|
||||||
<Icons.MdMusicNote />
|
if (initialLoading === true) {
|
||||||
Mix
|
setInitialLoading(false)
|
||||||
</p>,
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNotAPlaylist(type) {
|
export default MyLibraryPlaylists
|
||||||
return type === "album" || type === "ep" || type === "mix" || type === "single"
|
|
||||||
}
|
|
||||||
|
|
||||||
const PlaylistItem = (props) => {
|
|
||||||
const data = props.data ?? {}
|
|
||||||
|
|
||||||
const handleOnClick = () => {
|
|
||||||
if (typeof props.onClick === "function") {
|
|
||||||
props.onClick(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PlaylistLibraryView
|
|
||||||
|
@ -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 [hasMore, setHasMore] = React.useState(true)
|
||||||
const [initialLoading, setInitialLoading] = React.useState(true)
|
const [initialLoading, setInitialLoading] = React.useState(true)
|
||||||
|
|
||||||
const [L_Favourites, R_Favourites, E_Favourites, M_Favourites] =
|
const [L_Library, R_Library, E_Library, M_Library] =
|
||||||
app.cores.api.useRequest(MusicModel.getFavouriteFolder, {
|
app.cores.api.useRequest(MusicModel.getMyLibrary, {
|
||||||
offset: offset,
|
offset: offset,
|
||||||
limit: loadLimit,
|
limit: loadLimit,
|
||||||
|
kind: "tracks",
|
||||||
})
|
})
|
||||||
|
|
||||||
async function onLoadMore() {
|
async function onLoadMore() {
|
||||||
const newOffset = offset + loadLimit
|
setOffset((prevOffset) => {
|
||||||
|
const newOffset = prevOffset + loadLimit
|
||||||
|
|
||||||
setOffset(newOffset)
|
M_Library({
|
||||||
|
offset: newOffset,
|
||||||
|
limit: loadLimit,
|
||||||
|
kind: "tracks",
|
||||||
|
})
|
||||||
|
|
||||||
M_Favourites({
|
if (newOffset >= R_Library.total_items) {
|
||||||
offset: newOffset,
|
setHasMore(false)
|
||||||
limit: loadLimit,
|
}
|
||||||
|
|
||||||
|
return newOffset
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (R_Favourites && R_Favourites.tracks) {
|
if (R_Library && R_Library.items) {
|
||||||
if (initialLoading === true) {
|
if (initialLoading === true) {
|
||||||
setInitialLoading(false)
|
setInitialLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (R_Favourites.tracks.items.length === 0) {
|
setItems((prev) => {
|
||||||
setHasMore(false)
|
prev = [...prev, ...R_Library.items]
|
||||||
} else {
|
|
||||||
setItems((prev) => {
|
|
||||||
prev = [...prev, ...R_Favourites.tracks.items]
|
|
||||||
|
|
||||||
return prev
|
return prev
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [R_Favourites])
|
}, [R_Library])
|
||||||
|
|
||||||
if (E_Favourites) {
|
if (E_Library) {
|
||||||
return (
|
return <antd.Result status="warning" title="Failed to load" />
|
||||||
<antd.Result
|
|
||||||
status="warning"
|
|
||||||
title="Failed to load"
|
|
||||||
subTitle={E_Favourites}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (initialLoading) {
|
if (initialLoading) {
|
||||||
@ -66,15 +64,14 @@ const TracksLibraryView = () => {
|
|||||||
<PlaylistView
|
<PlaylistView
|
||||||
noHeader
|
noHeader
|
||||||
noSearch
|
noSearch
|
||||||
loading={L_Favourites}
|
loading={L_Library}
|
||||||
type="vertical"
|
type="vertical"
|
||||||
playlist={{
|
playlist={{
|
||||||
items: items,
|
items: items,
|
||||||
total_length: R_Favourites.tracks.total_items,
|
total_length: R_Library.total_items,
|
||||||
}}
|
}}
|
||||||
onLoadMore={onLoadMore}
|
onLoadMore={onLoadMore}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
length={R_Favourites.tracks.total_length}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,58 +1,12 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import { Skeleton, Result } from "antd"
|
import { Skeleton, Result } from "antd"
|
||||||
import RadioModel from "@models/radio"
|
|
||||||
import Image from "@components/Image"
|
|
||||||
|
|
||||||
import { MdPlayCircle, MdHeadphones } from "react-icons/md"
|
import RadioModel from "@models/radio"
|
||||||
|
|
||||||
|
import Radio from "@components/Music/Radio"
|
||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
const RadioItem = ({ item, style }) => {
|
|
||||||
const onClickItem = () => {
|
|
||||||
app.cores.player.start(
|
|
||||||
{
|
|
||||||
title: item.name,
|
|
||||||
source: item.http_src,
|
|
||||||
cover: item.background,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
radioId: item.radio_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
return (
|
|
||||||
<div className="radio-list-item empty" style={style}>
|
|
||||||
<div className="radio-list-item-content">
|
|
||||||
<Skeleton />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="radio-list-item" onClick={onClickItem} style={style}>
|
|
||||||
<Image className="radio-list-item-cover" src={item.background} />
|
|
||||||
<div className="radio-list-item-content">
|
|
||||||
<h1 id="title">{item.name}</h1>
|
|
||||||
<p>{item.description}</p>
|
|
||||||
|
|
||||||
<div className="radio-list-item-info">
|
|
||||||
<div className="radio-list-item-info-item" id="now_playing">
|
|
||||||
<MdPlayCircle />
|
|
||||||
<span>{item.now_playing.song.text}</span>
|
|
||||||
</div>
|
|
||||||
<div className="radio-list-item-info-item" id="now_playing">
|
|
||||||
<MdHeadphones />
|
|
||||||
<span>{item.listeners}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const RadioTab = () => {
|
const RadioTab = () => {
|
||||||
const [L_Radios, R_Radios, E_Radios, M_Radios] = app.cores.api.useRequest(
|
const [L_Radios, R_Radios, E_Radios, M_Radios] = app.cores.api.useRequest(
|
||||||
RadioModel.getRadioList,
|
RadioModel.getRadioList,
|
||||||
@ -69,12 +23,12 @@ const RadioTab = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="radio-list">
|
<div className="radio-list">
|
||||||
{R_Radios.map((item) => (
|
{R_Radios.map((item) => (
|
||||||
<RadioItem key={item.id} item={item} />
|
<Radio key={item.id} item={item} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<RadioItem style={{ opacity: 0.5 }} />
|
<Radio style={{ opacity: 0.5 }} />
|
||||||
<RadioItem style={{ opacity: 0.4 }} />
|
<Radio style={{ opacity: 0.4 }} />
|
||||||
<RadioItem style={{ opacity: 0.3 }} />
|
<Radio style={{ opacity: 0.3 }} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -7,87 +7,9 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
|
||||||
|
|
||||||
.radio-list-item {
|
.radio-item {
|
||||||
position: relative;
|
min-width: @min-item-width;
|
||||||
|
min-height: @min-item-height;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
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)
|
await props.onEdit(name)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const result = await Streaming.createOrUpdateProfile({
|
const result = await Streaming.createProfile({
|
||||||
profile_name: name,
|
profile_name: name,
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
@ -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 React from "react"
|
||||||
import * as antd from "antd"
|
import * as antd from "antd"
|
||||||
|
|
||||||
import ProfileSelector from "./components/ProfileSelector"
|
|
||||||
import ProfileData from "./components/ProfileData"
|
|
||||||
import ProfileCreator from "./components/ProfileCreator"
|
import ProfileCreator from "./components/ProfileCreator"
|
||||||
|
import Skeleton from "@components/Skeleton"
|
||||||
|
|
||||||
|
import Streaming from "@models/spectrum"
|
||||||
|
|
||||||
import useCenteredContainer from "@hooks/useCenteredContainer"
|
import useCenteredContainer from "@hooks/useCenteredContainer"
|
||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
const TVStudioPage = (props) => {
|
const Profile = ({ profile, onClick }) => {
|
||||||
useCenteredContainer(true)
|
return <div onClick={onClick}>{profile.profile_name}</div>
|
||||||
|
|
||||||
const [selectedProfileId, setSelectedProfileId] = React.useState(null)
|
|
||||||
|
|
||||||
function newProfileModal() {
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<antd.Button
|
|
||||||
type="primary"
|
|
||||||
onClick={newProfileModal}
|
|
||||||
>
|
|
||||||
Create new
|
|
||||||
</antd.Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{
|
|
||||||
selectedProfileId && <ProfileData
|
|
||||||
profile_id={selectedProfileId}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
!selectedProfileId && <div className="tvstudio-page-selector-hint">
|
|
||||||
<h1>
|
|
||||||
Select profile or create new
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TVStudioPage
|
const TVStudioPage = (props) => {
|
||||||
|
useCenteredContainer(false)
|
||||||
|
|
||||||
|
const [loading, list, error, repeat] = app.cores.api.useRequest(
|
||||||
|
Streaming.getOwnProfiles,
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleNewProfileClick() {
|
||||||
|
app.layout.modal.open("tv_profile_creator", ProfileCreator, {
|
||||||
|
props: {
|
||||||
|
onCreate: (id, data) => {
|
||||||
|
setSelectedProfileId(id)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProfileClick(id) {
|
||||||
|
app.location.push(`/studio/tv/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{list.length > 0 &&
|
||||||
|
list.map((profile, index) => {
|
||||||
|
return (
|
||||||
|
<Profile
|
||||||
|
key={index}
|
||||||
|
profile={profile}
|
||||||
|
onClick={() => handleProfileClick(profile._id)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TVStudioPage
|
||||||
|
@ -3,22 +3,22 @@ import GlobalTab from "./components/global"
|
|||||||
import SavedPostsTab from "./components/savedPosts"
|
import SavedPostsTab from "./components/savedPosts"
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
key: "feed",
|
key: "feed",
|
||||||
label: "Feed",
|
label: "Feed",
|
||||||
icon: "IoMdPaper",
|
icon: "IoMdPaper",
|
||||||
component: FeedTab
|
component: FeedTab,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "global",
|
key: "global",
|
||||||
label: "Global",
|
label: "Global",
|
||||||
icon: "FiGlobe",
|
icon: "FiGlobe",
|
||||||
component: GlobalTab
|
component: GlobalTab,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "savedPosts",
|
key: "savedPosts",
|
||||||
label: "Saved posts",
|
label: "Saved",
|
||||||
icon: "FiBookmark",
|
icon: "FiBookmark",
|
||||||
component: SavedPostsTab
|
component: SavedPostsTab,
|
||||||
}
|
},
|
||||||
]
|
]
|
||||||
|
@ -16,129 +16,122 @@ import FirefoxIcon from "./icons/firefox"
|
|||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
const DeviceIcon = (props) => {
|
const DeviceIcon = (props) => {
|
||||||
if (!props.ua) {
|
if (!props.ua) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.ua.ua === "capacitor") {
|
if (props.ua.ua === "capacitor") {
|
||||||
return <MobileIcon />
|
return <MobileIcon />
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (props.ua.browser.name) {
|
switch (props.ua.browser.name) {
|
||||||
case "Chrome": {
|
case "Chrome": {
|
||||||
return <ChromeIcon />
|
return <ChromeIcon />
|
||||||
}
|
}
|
||||||
case "Firefox": {
|
case "Firefox": {
|
||||||
return <FirefoxIcon />
|
return <FirefoxIcon />
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
return <Icons.FiGlobe />
|
return <Icons.FiGlobe />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const SessionItem = (props) => {
|
const SessionItem = (props) => {
|
||||||
const { session } = props
|
const { session } = props
|
||||||
|
|
||||||
const [collapsed, setCollapsed] = React.useState(true)
|
const [collapsed, setCollapsed] = React.useState(true)
|
||||||
|
|
||||||
const onClickCollapse = () => {
|
const onClickCollapse = () => {
|
||||||
setCollapsed((prev) => {
|
setCollapsed((prev) => {
|
||||||
return !prev
|
return !prev
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClickRevoke = () => {
|
const onClickRevoke = () => {
|
||||||
// if (typeof props.onClickRevoke === "function") {
|
// if (typeof props.onClickRevoke === "function") {
|
||||||
// props.onClickRevoke(session)
|
// props.onClickRevoke(session)
|
||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCurrentSession = React.useMemo(() => {
|
const isCurrentSession = React.useMemo(() => {
|
||||||
const currentUUID = SessionModel.session_uuid
|
const currentUUID = SessionModel.session_uuid
|
||||||
return session.session_uuid === currentUUID
|
return session.session_uuid === currentUUID
|
||||||
})
|
})
|
||||||
|
|
||||||
const ua = React.useMemo(() => {
|
const ua = React.useMemo(() => {
|
||||||
return UAParser(session.client)
|
return UAParser(session.client)
|
||||||
})
|
})
|
||||||
|
|
||||||
return <div
|
return (
|
||||||
className={classnames(
|
<div
|
||||||
"security_sessions_list_item_wrapper",
|
className={classnames("security_sessions_list_item_wrapper", {
|
||||||
{
|
["collapsed"]: collapsed,
|
||||||
["collapsed"]: collapsed
|
})}
|
||||||
}
|
>
|
||||||
)}
|
<div
|
||||||
>
|
id={session._id}
|
||||||
<div
|
key={props.key}
|
||||||
id={session._id}
|
className="security_sessions_list_item"
|
||||||
key={props.key}
|
onClick={onClickCollapse}
|
||||||
className="security_sessions_list_item"
|
>
|
||||||
onClick={onClickCollapse}
|
<div className="security_sessions_list_item_icon">
|
||||||
>
|
<DeviceIcon ua={ua} />
|
||||||
<div className="security_sessions_list_item_icon">
|
</div>
|
||||||
<DeviceIcon
|
|
||||||
ua={ua}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<antd.Badge dot={isCurrentSession}>
|
<antd.Badge dot={isCurrentSession}>
|
||||||
<div className="security_sessions_list_item_info">
|
<div className="security_sessions_list_item_info">
|
||||||
<div className="security_sessions_list_item_title">
|
<div className="security_sessions_list_item_title">
|
||||||
<h3><Icons.FiTag /> {session.session_uuid}</h3>
|
<h3>
|
||||||
</div>
|
<Icons.FiTag /> {session._id}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="security_sessions_list_item_info_details">
|
<div className="security_sessions_list_item_info_details">
|
||||||
<div className="security_sessions_list_item_info_details_item">
|
<div className="security_sessions_list_item_info_details_item">
|
||||||
<Icons.FiClock />
|
<Icons.FiClock />
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
{moment(session.date).format("DD/MM/YYYY HH:mm")}
|
{moment(session.date).format(
|
||||||
</span>
|
"DD/MM/YYYY HH:mm",
|
||||||
</div>
|
)}
|
||||||
<div className="security_sessions_list_item_info_details_item">
|
</span>
|
||||||
<Icons.IoMdLocate />
|
</div>
|
||||||
|
<div className="security_sessions_list_item_info_details_item">
|
||||||
|
<Icons.IoMdLocate />
|
||||||
|
|
||||||
<span>
|
<span>{session.ip_address}</span>
|
||||||
{session.ip_address}
|
</div>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</antd.Badge>
|
||||||
</div>
|
</div>
|
||||||
</antd.Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="security_sessions_list_item_extra-body">
|
<div className="security_sessions_list_item_extra-body">
|
||||||
<div className="security_sessions_list_item_actions">
|
<div className="security_sessions_list_item_actions">
|
||||||
<antd.Button
|
<antd.Button onClick={onClickRevoke} danger size="small">
|
||||||
onClick={onClickRevoke}
|
Revoke
|
||||||
danger
|
</antd.Button>
|
||||||
size="small"
|
</div>
|
||||||
>
|
|
||||||
Revoke
|
|
||||||
</antd.Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="security_sessions_list_item_info_details_item">
|
<div className="security_sessions_list_item_info_details_item">
|
||||||
<Icons.MdDns />
|
<Icons.MdDns />
|
||||||
|
|
||||||
<span>
|
<span>{session.location}</span>
|
||||||
{session.location}
|
</div>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{
|
{ua.device.vendor && (
|
||||||
ua.device.vendor && <div className="security_sessions_list_item_info_details_item">
|
<div className="security_sessions_list_item_info_details_item">
|
||||||
<Icons.FiCpu />
|
<Icons.FiCpu />
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
{ua.device.vendor} | {ua.device.model}
|
{ua.device.vendor} | {ua.device.model}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SessionItem
|
export default SessionItem
|
||||||
|
@ -8,70 +8,80 @@ import SessionModel from "@models/session"
|
|||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const [loading, setLoading] = React.useState(true)
|
const [loading, setLoading] = React.useState(true)
|
||||||
const [sessions, setSessions] = React.useState([])
|
const [sessions, setSessions] = React.useState([])
|
||||||
const [sessionsPage, setSessionsPage] = React.useState(1)
|
const [sessionsPage, setSessionsPage] = React.useState(1)
|
||||||
const [itemsPerPage, setItemsPerPage] = React.useState(3)
|
const [itemsPerPage, setItemsPerPage] = React.useState(3)
|
||||||
|
|
||||||
const loadSessions = async () => {
|
const loadSessions = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
const response = await SessionModel.getAllSessions().catch((err) => {
|
const response = await SessionModel.getAllSessions().catch((err) => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
app.message.error("Failed to load sessions")
|
app.message.error("Failed to load sessions")
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response) {
|
if (response) {
|
||||||
setSessions(response)
|
setSessions(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClickRevoke = async (session) => {
|
const onClickRevoke = async (session) => {
|
||||||
console.log(session)
|
console.log(session)
|
||||||
|
|
||||||
app.message.warning("Not implemented yet")
|
app.message.warning("Not implemented yet")
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClickRevokeAll = async () => {
|
const onClickDestroyAll = async () => {
|
||||||
app.message.warning("Not implemented yet")
|
app.layout.modal.confirm({
|
||||||
}
|
headerText: "Are you sure you want to delete this release?",
|
||||||
|
descriptionText: "This action cannot be undone.",
|
||||||
|
onConfirm: async () => {
|
||||||
|
await SessionModel.destroyAll()
|
||||||
|
await app.auth.logout(true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
loadSessions()
|
loadSessions()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <antd.Skeleton active />
|
return <antd.Skeleton active />
|
||||||
}
|
}
|
||||||
|
|
||||||
const offset = (sessionsPage - 1) * itemsPerPage
|
const offset = (sessionsPage - 1) * itemsPerPage
|
||||||
const slicedItems = sessions.slice(offset, offset + itemsPerPage)
|
const slicedItems = sessions.slice(offset, offset + itemsPerPage)
|
||||||
|
|
||||||
return <div className="security_sessions">
|
return (
|
||||||
<div className="security_sessions_list">
|
<div className="security_sessions">
|
||||||
{
|
<div className="security_sessions_list">
|
||||||
slicedItems.map((session) => {
|
{slicedItems.map((session) => {
|
||||||
return <SessionItem
|
return (
|
||||||
key={session._id}
|
<SessionItem
|
||||||
session={session}
|
key={session._id}
|
||||||
onClickRevoke={onClickRevoke}
|
session={session}
|
||||||
/>
|
onClickRevoke={onClickRevoke}
|
||||||
})
|
/>
|
||||||
}
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
<antd.Pagination
|
<antd.Pagination
|
||||||
onChange={(page) => {
|
onChange={(page) => {
|
||||||
setSessionsPage(page)
|
setSessionsPage(page)
|
||||||
}}
|
}}
|
||||||
total={sessions.length}
|
total={sessions.length}
|
||||||
showTotal={(total) => {
|
showTotal={(total) => {
|
||||||
return `${total} Sessions`
|
return `${total} Sessions`
|
||||||
}}
|
}}
|
||||||
simple
|
simple
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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"])
|
registerBaseAliases(global["__src"], global["aliases"])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function injectEnvFromInfisical() {
|
global.injectEnvFromInfisical = async function injectEnvFromInfisical() {
|
||||||
const envMode = (global.FORCE_ENV ?? global.isProduction) ? "prod" : "dev"
|
const envMode = (global.FORCE_ENV ?? global.isProduction) ? "prod" : "dev"
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
@ -13,6 +13,7 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib {
|
|||||||
audioBitrate: "320k",
|
audioBitrate: "320k",
|
||||||
audioSampleRate: "48000",
|
audioSampleRate: "48000",
|
||||||
segmentTime: 10,
|
segmentTime: 10,
|
||||||
|
minBufferTime: 5,
|
||||||
includeMetadata: true,
|
includeMetadata: true,
|
||||||
...params,
|
...params,
|
||||||
}
|
}
|
||||||
@ -20,7 +21,6 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib {
|
|||||||
|
|
||||||
buildSegmentationArgs = () => {
|
buildSegmentationArgs = () => {
|
||||||
const args = [
|
const args = [
|
||||||
//`-threads 1`, // limits to one thread
|
|
||||||
`-v error -hide_banner -progress pipe:1`,
|
`-v error -hide_banner -progress pipe:1`,
|
||||||
`-i ${this.params.input}`,
|
`-i ${this.params.input}`,
|
||||||
`-c:a ${this.params.audioCodec}`,
|
`-c:a ${this.params.audioCodec}`,
|
||||||
@ -56,6 +56,39 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib {
|
|||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_updateMpdMinBufferTime = async (mpdPath, newMinBufferTimeSecs) => {
|
||||||
|
try {
|
||||||
|
const mpdTagRegex = /(<MPD[^>]*)/
|
||||||
|
let mpdContent = await fs.promises.readFile(mpdPath, "utf-8")
|
||||||
|
|
||||||
|
const minBufferTimeAttribute = `minBufferTime="PT${newMinBufferTimeSecs}.0S"`
|
||||||
|
const existingMinBufferTimeRegex =
|
||||||
|
/(<MPD[^>]*minBufferTime=")[^"]*(")/
|
||||||
|
|
||||||
|
if (existingMinBufferTimeRegex.test(mpdContent)) {
|
||||||
|
mpdContent = mpdContent.replace(
|
||||||
|
existingMinBufferTimeRegex,
|
||||||
|
`$1PT${newMinBufferTimeSecs}.0S$2`,
|
||||||
|
)
|
||||||
|
await fs.promises.writeFile(mpdPath, mpdContent, "utf-8")
|
||||||
|
} else {
|
||||||
|
if (mpdTagRegex.test(mpdContent)) {
|
||||||
|
mpdContent = mpdContent.replace(
|
||||||
|
mpdTagRegex,
|
||||||
|
`$1 ${minBufferTimeAttribute}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
await fs.promises.writeFile(mpdPath, mpdContent, "utf-8")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`[SegmentedAudioMPDJob] Error updating MPD minBufferTime for ${mpdPath}:`,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
run = async () => {
|
run = async () => {
|
||||||
const segmentationCmd = this.buildSegmentationArgs()
|
const segmentationCmd = this.buildSegmentationArgs()
|
||||||
const outputPath =
|
const outputPath =
|
||||||
@ -75,7 +108,7 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib {
|
|||||||
const inputProbe = await Utils.probe(this.params.input)
|
const inputProbe = await Utils.probe(this.params.input)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.ffmpeg({
|
const ffmpegResult = await this.ffmpeg({
|
||||||
args: segmentationCmd,
|
args: segmentationCmd,
|
||||||
onProcess: (process) => {
|
onProcess: (process) => {
|
||||||
this.handleProgress(
|
this.handleProgress(
|
||||||
@ -89,6 +122,17 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib {
|
|||||||
cwd: outputPath,
|
cwd: outputPath,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (fs.existsSync(outputFile)) {
|
||||||
|
await this._updateMpdMinBufferTime(
|
||||||
|
outputFile,
|
||||||
|
this.params.minBufferTime,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`[SegmentedAudioMPDJob] MPD file ${outputFile} not found after ffmpeg run. Skipping minBufferTime update.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let outputProbe = await Utils.probe(outputFile)
|
let outputProbe = await Utils.probe(outputFile)
|
||||||
|
|
||||||
this.emit("end", {
|
this.emit("end", {
|
||||||
@ -100,9 +144,9 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib {
|
|||||||
outputFile: outputFile,
|
outputFile: outputFile,
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return ffmpegResult
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return this.emit("error", err)
|
this.emit("error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
@ -1,11 +1,15 @@
|
|||||||
export default {
|
export default {
|
||||||
name: "ChatMessage",
|
name: "ChatMessage",
|
||||||
collection: "chats_messages",
|
collection: "chats_messages",
|
||||||
schema: {
|
schema: {
|
||||||
type: { type: String, required: true },
|
type: { type: String, required: true },
|
||||||
from_user_id: { type: String, required: true },
|
from_user_id: { type: String, required: true },
|
||||||
to_user_id: { type: String, required: true },
|
to_user_id: { type: String, required: true },
|
||||||
content: { type: String, required: true },
|
content: { type: String, required: true },
|
||||||
created_at: { type: Date, required: true },
|
created_at: { type: Date, required: true },
|
||||||
}
|
encrypted: {
|
||||||
}
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
@ -3,21 +3,33 @@ import fs from "fs"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
||||||
function generateModels() {
|
function generateModels() {
|
||||||
let models = {}
|
let models = {}
|
||||||
|
|
||||||
const dirs = fs.readdirSync(__dirname).filter(file => file !== "index.js")
|
const dirs = fs.readdirSync(__dirname).filter((file) => file !== "index.js")
|
||||||
|
|
||||||
dirs.forEach((file) => {
|
dirs.forEach((file) => {
|
||||||
const model = require(path.join(__dirname, file)).default
|
const model = require(path.join(__dirname, file)).default
|
||||||
|
|
||||||
if (mongoose.models[model.name]) {
|
if (mongoose.models[model.name]) {
|
||||||
return models[model.name] = mongoose.model(model.name)
|
return (models[model.name] = mongoose.model(model.name))
|
||||||
}
|
}
|
||||||
|
|
||||||
return models[model.name] = mongoose.model(model.name, new Schema(model.schema), model.collection)
|
model.schema = new Schema(model.schema)
|
||||||
})
|
|
||||||
|
|
||||||
return models
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = generateModels()
|
module.exports = generateModels()
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
@ -1,41 +1,43 @@
|
|||||||
export default {
|
export default {
|
||||||
name: "Playlist",
|
name: "Playlist",
|
||||||
collection: "playlists",
|
collection: "playlists",
|
||||||
schema: {
|
schema: {
|
||||||
user_id: {
|
user_id: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true,
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
type: String
|
type: String,
|
||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: [],
|
default: [],
|
||||||
required: true
|
required: true,
|
||||||
},
|
},
|
||||||
cover: {
|
cover: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "https://storage.ragestudio.net/comty-static-assets/default_song.png"
|
default:
|
||||||
},
|
"https://storage.ragestudio.net/comty-static-assets/default_song.png",
|
||||||
thumbnail: {
|
},
|
||||||
type: String,
|
thumbnail: {
|
||||||
default: "https://storage.ragestudio.net/comty-static-assets/default_song.png"
|
type: String,
|
||||||
},
|
default:
|
||||||
created_at: {
|
"https://storage.ragestudio.net/comty-static-assets/default_song.png",
|
||||||
type: Date,
|
},
|
||||||
required: true
|
created_at: {
|
||||||
},
|
type: Date,
|
||||||
publisher: {
|
required: true,
|
||||||
type: Object,
|
},
|
||||||
},
|
publisher: {
|
||||||
public: {
|
type: Object,
|
||||||
type: Boolean,
|
},
|
||||||
default: true,
|
public: {
|
||||||
},
|
type: Boolean,
|
||||||
}
|
default: true,
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
@ -27,8 +27,9 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
publish_date: {
|
created_at: {
|
||||||
type: Date,
|
type: Date,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
cover: {
|
cover: {
|
||||||
type: String,
|
type: String,
|
||||||
|
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