mirror of
https://github.com/ragestudio/comty.git
synced 2025-06-09 18:44:16 +00:00
rework player & music services
This commit is contained in:
parent
136424dacd
commit
19fa3dc683
88
packages/app/src/components/Music/PlaylistCreator/index.jsx
Normal file
88
packages/app/src/components/Music/PlaylistCreator/index.jsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import { Icons } from "components/Icons"
|
||||||
|
|
||||||
|
import MusicModel from "models/music"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
export const PlaylistCreator = (props) => {
|
||||||
|
const [submitting, setSubmitting] = React.useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (values) => {
|
||||||
|
setSubmitting(true)
|
||||||
|
|
||||||
|
const result = await MusicModel.newPlaylist(values).catch((err) => {
|
||||||
|
console.error(err)
|
||||||
|
app.message.error("Failed to create playlist")
|
||||||
|
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
setSubmitting(false)
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
app.navigation.goToPlaylist(result._id)
|
||||||
|
|
||||||
|
if (typeof props.close === "function") {
|
||||||
|
props.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="playlist_creator">
|
||||||
|
<antd.Form
|
||||||
|
name="playlist"
|
||||||
|
className="playlist_creator_form"
|
||||||
|
onFinish={handleSubmit}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
public: false
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<antd.Form.Item
|
||||||
|
name="title"
|
||||||
|
label="Title"
|
||||||
|
>
|
||||||
|
<antd.Input
|
||||||
|
placeholder="Playlist name"
|
||||||
|
/>
|
||||||
|
</antd.Form.Item>
|
||||||
|
|
||||||
|
<antd.Form.Item
|
||||||
|
name="description"
|
||||||
|
label="Description"
|
||||||
|
>
|
||||||
|
<antd.Input.TextArea />
|
||||||
|
</antd.Form.Item>
|
||||||
|
|
||||||
|
<antd.Form.Item
|
||||||
|
name="public"
|
||||||
|
label={<span>
|
||||||
|
<Icons.MdOutlinePublic />
|
||||||
|
Public
|
||||||
|
</span>}
|
||||||
|
>
|
||||||
|
<antd.Switch />
|
||||||
|
</antd.Form.Item>
|
||||||
|
|
||||||
|
<div className="playlist_creator_actions">
|
||||||
|
<antd.Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
htmlType="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
loading={submitting}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</antd.Button>
|
||||||
|
</div>
|
||||||
|
</antd.Form>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const openModal = () => {
|
||||||
|
app.layout.modal.open("playlist_creator", PlaylistCreator)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default openModal
|
32
packages/app/src/components/Music/PlaylistCreator/index.less
Normal file
32
packages/app/src/components/Music/PlaylistCreator/index.less
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
.playlist_creator {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.playlist_creator_form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.playlist_creator_actions {
|
||||||
|
display: inline-flex;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-form-item-control-input-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
|
||||||
|
.ant-switch {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -8,7 +8,11 @@ import "./index.less"
|
|||||||
|
|
||||||
export default (props) => {
|
export default (props) => {
|
||||||
const [coverHover, setCoverHover] = React.useState(false)
|
const [coverHover, setCoverHover] = React.useState(false)
|
||||||
const { playlist } = props
|
let { playlist } = props
|
||||||
|
|
||||||
|
if (!playlist) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
if (typeof props.onClick === "function") {
|
if (typeof props.onClick === "function") {
|
||||||
@ -24,6 +28,8 @@ export default (props) => {
|
|||||||
app.cores.player.start(playlist.list)
|
app.cores.player.start(playlist.list)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const subtitle = playlist.type === "playlist" ? `By ${playlist.user_id}` : (playlist.description ?? (playlist.publisher && `Release from ${playlist.publisher?.fullName}`))
|
||||||
|
|
||||||
return <div
|
return <div
|
||||||
id={playlist._id}
|
id={playlist._id}
|
||||||
key={props.key}
|
key={props.key}
|
||||||
@ -53,6 +59,23 @@ export default (props) => {
|
|||||||
<div className="playlistItem_info_title" onClick={onClick}>
|
<div className="playlistItem_info_title" onClick={onClick}>
|
||||||
<h1>{playlist.title}</h1>
|
<h1>{playlist.title}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="playlistItem_info_subtitle">
|
||||||
|
<p>
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="playlistItem_bottom">
|
||||||
|
<p>
|
||||||
|
<Icons.MdLibraryMusic /> {props.length ?? playlist.total_length ?? playlist.list.length}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<Icons.MdAlbum />
|
||||||
|
{playlist.type ?? "playlist"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
@ -1,14 +1,25 @@
|
|||||||
|
@playlistItem_maxWidth: 175px;
|
||||||
|
@playlistItem_padding: 10px;
|
||||||
|
|
||||||
|
@playlistItem_cover_maxSize: calc(@playlistItem_maxWidth - @playlistItem_padding * 2);
|
||||||
|
|
||||||
.playlistItem {
|
.playlistItem {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: column;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
width: 100%;
|
width: @playlistItem_maxWidth;
|
||||||
height: 140px;
|
max-width: @playlistItem_maxWidth;
|
||||||
|
min-width: @playlistItem_maxWidth;
|
||||||
|
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
transition: all 0.2s ease-in-out;
|
transition: all 0.2s ease-in-out;
|
||||||
|
|
||||||
@ -16,7 +27,7 @@
|
|||||||
|
|
||||||
&.cover-hovering {
|
&.cover-hovering {
|
||||||
.playlistItem_cover {
|
.playlistItem_cover {
|
||||||
transform: scale(1.1);
|
transform: scale(1.05);
|
||||||
|
|
||||||
.playlistItem_cover_mask {
|
.playlistItem_cover_mask {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@ -24,27 +35,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.playlistItem_info {
|
.playlistItem_info {
|
||||||
transform: translateX(10px);
|
transform: translateY(5px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlistItem_cover {
|
.playlistItem_cover {
|
||||||
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
height: 140px;
|
width: 100%;
|
||||||
|
height: @playlistItem_cover_maxSize;
|
||||||
|
|
||||||
|
max-width: @playlistItem_cover_maxSize;
|
||||||
|
|
||||||
transition: all 0.2s ease-in-out;
|
transition: all 0.2s ease-in-out;
|
||||||
|
z-index: 50;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
.image-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 140px;
|
width: 100%;
|
||||||
height: 140px;
|
height: 100%;
|
||||||
|
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
background-color: black;
|
|
||||||
|
|
||||||
z-index: 50
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlistItem_cover_mask {
|
.playlistItem_cover_mask {
|
||||||
@ -79,23 +98,21 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 82px;
|
||||||
padding: 10px;
|
|
||||||
|
|
||||||
max-width: calc(100% - 10vh);
|
|
||||||
|
|
||||||
transition: all 0.2s ease-in-out;
|
transition: all 0.2s ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
.playlistItem_info_title {
|
.playlistItem_info_title {
|
||||||
font-size: 1rem;
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlistItem_info_title {
|
||||||
|
font-size: 0.7rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
||||||
color: var(--background-color-contrast);
|
color: var(--background-color-contrast);
|
||||||
font-family: "Space Grotesk", sans-serif;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h4 {
|
h4 {
|
||||||
@ -108,24 +125,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// set userPreview to the bottom of the playlistItem_info
|
.playlistItem_info_subtitle {
|
||||||
.userPreview {
|
color: var(--text-color);
|
||||||
margin-top: auto;
|
font-size: 0.7rem;
|
||||||
font-size: 0.8rem;
|
|
||||||
|
|
||||||
h1 {
|
p {
|
||||||
font-size: 0.8rem;
|
overflow: hidden;
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
text-overflow: ellipsis;
|
||||||
display: flex;
|
white-space: nowrap;
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
align-items: center;
|
margin: 0;
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -150,4 +160,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlistItem_bottom {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
svg, p{
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -3,30 +3,125 @@ import * as antd from "antd"
|
|||||||
import classnames from "classnames"
|
import classnames from "classnames"
|
||||||
import ReactMarkdown from "react-markdown"
|
import ReactMarkdown from "react-markdown"
|
||||||
import remarkGfm from "remark-gfm"
|
import remarkGfm from "remark-gfm"
|
||||||
import moment from "moment"
|
|
||||||
import fuse from "fuse.js"
|
import fuse from "fuse.js"
|
||||||
import useWsEvents from "hooks/useWsEvents"
|
import useWsEvents from "hooks/useWsEvents"
|
||||||
|
|
||||||
import { WithPlayerContext } from "contexts/WithPlayerContext"
|
import { WithPlayerContext } from "contexts/WithPlayerContext"
|
||||||
|
import { Context as PlaylistContext } from "contexts/WithPlaylistContext"
|
||||||
|
|
||||||
import LoadMore from "components/LoadMore"
|
import LoadMore from "components/LoadMore"
|
||||||
|
|
||||||
import { ImageViewer } from "components"
|
import { ImageViewer } from "components"
|
||||||
import { Icons } from "components/Icons"
|
import { Icons } from "components/Icons"
|
||||||
|
|
||||||
import PlaylistsModel from "models/playlists"
|
import MusicModel from "models/music"
|
||||||
|
|
||||||
import MusicTrack from "components/Music/Track"
|
import MusicTrack from "components/Music/Track"
|
||||||
|
|
||||||
import SearchButton from "components/SearchButton"
|
import SearchButton from "components/SearchButton"
|
||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
|
const PlaylistTypeDecorators = {
|
||||||
|
"single": (props) => <span className="playlistType">
|
||||||
|
<Icons.MdMusicNote />
|
||||||
|
Single
|
||||||
|
</span>,
|
||||||
|
"album": (props) => <span className="playlistType">
|
||||||
|
<Icons.MdAlbum />
|
||||||
|
Album
|
||||||
|
</span>,
|
||||||
|
"ep": (props) => <span className="playlistType">
|
||||||
|
<Icons.MdAlbum />
|
||||||
|
EP
|
||||||
|
</span>,
|
||||||
|
"mix": (props) => <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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default (props) => {
|
export default (props) => {
|
||||||
const [playlist, setPlaylist] = React.useState(props.playlist)
|
const [playlist, setPlaylist] = React.useState(props.playlist)
|
||||||
const [searchResults, setSearchResults] = React.useState(null)
|
const [searchResults, setSearchResults] = React.useState(null)
|
||||||
|
const [owningPlaylist, setOwningPlaylist] = React.useState(app.cores.permissions.checkUserIdIsSelf(props.playlist?.user_id))
|
||||||
|
|
||||||
|
const moreMenuItems = React.useMemo(() => {
|
||||||
|
const items = [{
|
||||||
|
key: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
}]
|
||||||
|
|
||||||
|
if (!playlist.type || playlist.type === "playlist") {
|
||||||
|
if (app.cores.permissions.checkUserIdIsSelf(playlist.user_id)) {
|
||||||
|
items.push({
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
const contextValues = {
|
||||||
|
playlist_data: playlist,
|
||||||
|
owning_playlist: owningPlaylist,
|
||||||
|
add_track: (track) => {
|
||||||
|
|
||||||
|
},
|
||||||
|
remove_track: (track) => {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let debounceSearch = null
|
let debounceSearch = null
|
||||||
|
|
||||||
|
const handleOnClickPlaylistPlay = () => {
|
||||||
|
app.cores.player.start(playlist.list, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOnClickViewDetails = () => {
|
||||||
|
app.layout.modal.open("playlist_info", PlaylistInfo, {
|
||||||
|
props: {
|
||||||
|
data: playlist
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleOnClickTrack = (track) => {
|
const handleOnClickTrack = (track) => {
|
||||||
// search index of track
|
// search index of track
|
||||||
const index = playlist.list.findIndex((item) => {
|
const index = playlist.list.findIndex((item) => {
|
||||||
@ -48,10 +143,13 @@ export default (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleTrackLike = async (track) => {
|
const handleTrackLike = async (track) => {
|
||||||
return await PlaylistsModel.toggleTrackLike(track._id)
|
return await MusicModel.toggleTrackLike(track._id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const makeSearch = (value) => {
|
const makeSearch = (value) => {
|
||||||
|
//TODO: Implement me using API
|
||||||
|
return app.message.info("Not implemented yet...")
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
includeScore: true,
|
includeScore: true,
|
||||||
keys: [
|
keys: [
|
||||||
@ -64,6 +162,8 @@ export default (props) => {
|
|||||||
const fuseInstance = new fuse(playlist.list, options)
|
const fuseInstance = new fuse(playlist.list, options)
|
||||||
const results = fuseInstance.search(value)
|
const results = fuseInstance.search(value)
|
||||||
|
|
||||||
|
console.log(results)
|
||||||
|
|
||||||
setSearchResults(results.map((result) => {
|
setSearchResults(results.map((result) => {
|
||||||
return result.item
|
return result.item
|
||||||
}))
|
}))
|
||||||
@ -103,6 +203,16 @@ export default (props) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleMoreMenuClick = async (e) => {
|
||||||
|
const handler = MoreMenuHandlers[e.key]
|
||||||
|
|
||||||
|
if (typeof handler !== "function") {
|
||||||
|
throw new Error(`Invalid menu handler [${e.key}]`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await handler(playlist)
|
||||||
|
}
|
||||||
|
|
||||||
useWsEvents({
|
useWsEvents({
|
||||||
"music:self:track:toggle:like": (data) => {
|
"music:self:track:toggle:like": (data) => {
|
||||||
updateTrackLike(data.track_id, data.action === "liked")
|
updateTrackLike(data.track_id, data.action === "liked")
|
||||||
@ -113,17 +223,22 @@ export default (props) => {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setPlaylist(props.playlist)
|
setPlaylist(props.playlist)
|
||||||
|
setOwningPlaylist(app.cores.permissions.checkUserIdIsSelf(props.playlist?.user_id))
|
||||||
}, [props.playlist])
|
}, [props.playlist])
|
||||||
|
|
||||||
if (!playlist) {
|
if (!playlist) {
|
||||||
return <antd.Skeleton active />
|
return <antd.Skeleton active />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div
|
return <PlaylistContext.Provider value={contextValues}>
|
||||||
className={
|
<WithPlayerContext>
|
||||||
classnames("playlist_view", props.type ?? playlist.type)
|
<div
|
||||||
}
|
className={classnames(
|
||||||
|
"playlist_view",
|
||||||
|
props.type ?? playlist.type,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
|
|
||||||
<div className="play_info_wrapper">
|
<div className="play_info_wrapper">
|
||||||
<div className="play_info">
|
<div className="play_info">
|
||||||
<div className="play_info_cover">
|
<div className="play_info_cover">
|
||||||
@ -132,18 +247,29 @@ export default (props) => {
|
|||||||
|
|
||||||
<div className="play_info_details">
|
<div className="play_info_details">
|
||||||
<div className="play_info_title">
|
<div className="play_info_title">
|
||||||
{typeof playlist.title === "function" ? playlist.title : <h1>{playlist.title}</h1>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{
|
{
|
||||||
playlist.description && <div className="play_info_description">
|
playlist.service === "tidal" && <Icons.SiTidal />
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
|
||||||
{playlist.description}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
typeof playlist.title === "function" ?
|
||||||
|
playlist.title :
|
||||||
|
<h1>{playlist.title}</h1>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="play_info_statistics">
|
<div className="play_info_statistics">
|
||||||
|
{
|
||||||
|
playlist.type && PlaylistTypeDecorators[playlist.type] && <div className="play_info_statistics_item">
|
||||||
|
{
|
||||||
|
PlaylistTypeDecorators[playlist.type]()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div className="play_info_statistics_item">
|
||||||
|
<p>
|
||||||
|
<Icons.MdLibraryMusic /> {props.length ?? playlist.total_length ?? playlist.list.length} Tracks
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{
|
{
|
||||||
playlist.publisher && <div className="play_info_statistics_item">
|
playlist.publisher && <div className="play_info_statistics_item">
|
||||||
<p
|
<p
|
||||||
@ -157,18 +283,47 @@ export default (props) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div className="play_info_statistics_item">
|
|
||||||
<p>
|
|
||||||
<Icons.MdLibraryMusic /> {props.length ?? playlist.list.length} Tracks
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="play_info_actions">
|
||||||
|
<antd.Button
|
||||||
|
type="primary"
|
||||||
|
shape="rounded"
|
||||||
|
size="large"
|
||||||
|
onClick={handleOnClickPlaylistPlay}
|
||||||
|
>
|
||||||
|
<Icons.MdPlayArrow />
|
||||||
|
Play
|
||||||
|
</antd.Button>
|
||||||
|
|
||||||
{
|
{
|
||||||
playlist.created_at && <div className="play_info_statistics_item">
|
!props.favorite && <antd.Button
|
||||||
<p>
|
icon={<Icons.MdFavorite />}
|
||||||
<Icons.MdAccessTime /> Released on {moment(playlist.created_at).format("DD/MM/YYYY")}
|
/>
|
||||||
</p>
|
}
|
||||||
</div>
|
|
||||||
|
{
|
||||||
|
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>
|
||||||
@ -188,7 +343,19 @@ export default (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
playlist.list.length === 0 && <antd.Empty
|
searchResults && searchResults.map((item) => {
|
||||||
|
return <MusicTrack
|
||||||
|
key={item._id}
|
||||||
|
order={item._id}
|
||||||
|
track={item}
|
||||||
|
onClickPlayBtn={() => handleOnClickTrack(item)}
|
||||||
|
onLike={() => handleTrackLike(item)}
|
||||||
|
/>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!searchResults && playlist.list.length === 0 && <antd.Empty
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
<Icons.MdLibraryMusic /> This playlist its empty!
|
<Icons.MdLibraryMusic /> This playlist its empty!
|
||||||
@ -196,8 +363,9 @@ export default (props) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
playlist.list.length > 0 && <LoadMore
|
!searchResults && playlist.list.length > 0 && <LoadMore
|
||||||
className="list_content"
|
className="list_content"
|
||||||
loadingComponent={() => <antd.Skeleton />}
|
loadingComponent={() => <antd.Skeleton />}
|
||||||
onBottom={props.onLoadMore}
|
onBottom={props.onLoadMore}
|
||||||
@ -219,4 +387,6 @@ export default (props) => {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</WithPlayerContext>
|
||||||
|
</PlaylistContext.Provider>
|
||||||
}
|
}
|
@ -36,81 +36,46 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.playlist_view {
|
.playlist_view {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: column;
|
||||||
|
|
||||||
align-items: flex-start;
|
|
||||||
|
|
||||||
position: sticky;
|
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|
||||||
&.vertical {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.play_info_wrapper {
|
.play_info_wrapper {
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
z-index: 45;
|
|
||||||
|
|
||||||
.play_info {
|
|
||||||
flex-direction: row;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
//box-shadow: @card-shadow;
|
|
||||||
|
|
||||||
.play_info_cover {
|
|
||||||
height: 15vh !important;
|
|
||||||
width: 15vh !important;
|
|
||||||
|
|
||||||
min-height: 15vh;
|
|
||||||
min-width: 15vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play_info_details {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.list {
|
|
||||||
z-index: 40;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.play_info_wrapper {
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
width: fit-content;
|
width: 100%;
|
||||||
height: fit-content;
|
|
||||||
|
z-index: 45;
|
||||||
|
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
|
|
||||||
.play_info {
|
.play_info {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
|
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
align-self: center;
|
align-self: center;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
background-color: var(--background-color-accent);
|
background-color: var(--background-color-accent);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|
||||||
@ -123,11 +88,11 @@ html {
|
|||||||
|
|
||||||
align-self: center;
|
align-self: center;
|
||||||
|
|
||||||
width: 20vw;
|
height: 15vh !important;
|
||||||
height: 20vw;
|
width: 15vh !important;
|
||||||
|
|
||||||
min-height: 20vw;
|
min-height: 15vh;
|
||||||
min-width: 20vw;
|
min-width: 15vh;
|
||||||
|
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
@ -146,34 +111,48 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.play_info_details {
|
.play_info_details {
|
||||||
padding: 20px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
width: 90%;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
.play_info_title {
|
.play_info_title {
|
||||||
font-size: 1.5rem;
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
font-size: 1.2rem;
|
||||||
font-family: "Space Grotesk", sans-serif;
|
font-family: "Space Grotesk", sans-serif;
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
|
||||||
|
|
||||||
word-break: break-all;
|
|
||||||
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
word-break: break-all;
|
||||||
margin-bottom: 10px;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.play_info_description {
|
.play_info_description {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
|
max-height: 10vh;
|
||||||
|
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.play_info_statistics {
|
.play_info_statistics {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
margin-top: 20px;
|
|
||||||
|
|
||||||
background-color: var(--background-color-primary);
|
background-color: var(--background-color-primary);
|
||||||
|
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@ -214,6 +193,15 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.play_info_actions {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,23 +1,28 @@
|
|||||||
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 LikeButton from "components/LikeButton"
|
|
||||||
import seekToTimeLabel from "utils/seekToTimeLabel"
|
import seekToTimeLabel from "utils/seekToTimeLabel"
|
||||||
|
|
||||||
import { ImageViewer } from "components"
|
import { ImageViewer } from "components"
|
||||||
import { Icons } from "components/Icons"
|
import { Icons } from "components/Icons"
|
||||||
|
|
||||||
import { Context } from "contexts/WithPlayerContext"
|
import RGBStringToValues from "utils/rgbToValues"
|
||||||
|
|
||||||
|
import { Context as PlayerContext } from "contexts/WithPlayerContext"
|
||||||
|
import { Context as PlaylistContext } from "contexts/WithPlaylistContext"
|
||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
export default (props) => {
|
const Track = (props) => {
|
||||||
const {
|
const {
|
||||||
track_manifest,
|
track_manifest,
|
||||||
playback_status,
|
playback_status,
|
||||||
} = React.useContext(Context)
|
} = React.useContext(PlayerContext)
|
||||||
|
|
||||||
|
const playlist_ctx = React.useContext(PlaylistContext)
|
||||||
|
|
||||||
|
const [moreMenuOpened, setMoreMenuOpened] = React.useState(false)
|
||||||
|
|
||||||
const isLiked = props.track?.liked
|
|
||||||
const isCurrent = track_manifest?._id === props.track._id
|
const isCurrent = track_manifest?._id === props.track._id
|
||||||
const isPlaying = isCurrent && playback_status === "playing"
|
const isPlaying = isCurrent && playback_status === "playing"
|
||||||
|
|
||||||
@ -34,6 +39,67 @@ export default (props) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleOnClickItem = () => {
|
||||||
|
if (app.isMobile) {
|
||||||
|
handleClickPlayBtn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMoreMenuOpen = () => {
|
||||||
|
if (app.isMobile) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return setMoreMenuOpened((prev) => {
|
||||||
|
return !prev
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMoreMenuItemClick = () => {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const moreMenuItems = React.useMemo(() => {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
key: "like",
|
||||||
|
icon: <Icons.MdFavorite />,
|
||||||
|
label: "Like",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "share",
|
||||||
|
icon: <Icons.MdShare />,
|
||||||
|
label: "Share",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "add_to_playlist",
|
||||||
|
icon: <Icons.MdPlaylistAdd />,
|
||||||
|
label: "Add to playlist",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "add_to_queue",
|
||||||
|
icon: <Icons.MdQueueMusic />,
|
||||||
|
label: "Add to queue",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
if (playlist_ctx) {
|
||||||
|
if (playlist_ctx.owning_playlist) {
|
||||||
|
items.push({
|
||||||
|
type: "divider",
|
||||||
|
})
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
key: "remove_from_playlist",
|
||||||
|
icon: <Icons.MdPlaylistRemove />,
|
||||||
|
label: "Remove from playlist",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
return <div
|
return <div
|
||||||
id={props.track._id}
|
id={props.track._id}
|
||||||
className={classnames(
|
className={classnames(
|
||||||
@ -43,8 +109,18 @@ export default (props) => {
|
|||||||
["playing"]: isPlaying,
|
["playing"]: isPlaying,
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
|
style={{
|
||||||
|
"--cover_average-color": RGBStringToValues(track_manifest?.cover_analysis?.rgb),
|
||||||
|
}}
|
||||||
|
onClick={handleOnClickItem}
|
||||||
>
|
>
|
||||||
<div className={classnames(
|
<div
|
||||||
|
className="music-track_background"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="music-track_content">
|
||||||
|
{
|
||||||
|
!app.isMobile && <div className={classnames(
|
||||||
"music-track_actions",
|
"music-track_actions",
|
||||||
{
|
{
|
||||||
["withOrder"]: props.order !== undefined,
|
["withOrder"]: props.order !== undefined,
|
||||||
@ -64,6 +140,7 @@ export default (props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div className="music-track_cover">
|
<div className="music-track_cover">
|
||||||
<ImageViewer src={props.track.cover ?? props.track.thumbnail} />
|
<ImageViewer src={props.track.cover ?? props.track.thumbnail} />
|
||||||
@ -71,32 +148,39 @@ export default (props) => {
|
|||||||
|
|
||||||
<div className="music-track_details">
|
<div className="music-track_details">
|
||||||
<div className="music-track_title">
|
<div className="music-track_title">
|
||||||
|
<span>
|
||||||
|
{
|
||||||
|
props.track.service === "tidal" && <Icons.SiTidal />
|
||||||
|
}
|
||||||
{props.track.title}
|
{props.track.title}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="music-track_artist">
|
<div className="music-track_artist">
|
||||||
|
<span>
|
||||||
{props.track.artist}
|
{props.track.artist}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="music-track_right_actions">
|
<div className="music-track_right_actions">
|
||||||
<div className="music-track_info">
|
<antd.Dropdown
|
||||||
{
|
menu={{
|
||||||
props.track.service === "tidal" && <Icons.SiTidal />
|
items: moreMenuItems,
|
||||||
}
|
onClick: handleMoreMenuItemClick
|
||||||
|
}}
|
||||||
<div className="music-track_info_duration">
|
onOpenChange={handleMoreMenuOpen}
|
||||||
{
|
open={moreMenuOpened}
|
||||||
props.track.metadata?.duration
|
trigger={["click"]}
|
||||||
? seekToTimeLabel(props.track.metadata?.duration)
|
>
|
||||||
: "00:00"
|
<antd.Button
|
||||||
}
|
type="ghost"
|
||||||
</div>
|
size="large"
|
||||||
</div>
|
icon={<Icons.IoMdMore />}
|
||||||
|
|
||||||
<LikeButton
|
|
||||||
liked={isLiked}
|
|
||||||
onClick={props.onLike}
|
|
||||||
/>
|
/>
|
||||||
|
</antd.Dropdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Track
|
@ -1,42 +1,31 @@
|
|||||||
html {
|
|
||||||
&.mobile {
|
|
||||||
.music-track {
|
|
||||||
.music-track_actions {
|
|
||||||
.music-track_action {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.music-track_orderIndex {
|
|
||||||
transform: translate(-45%, -30%);
|
|
||||||
padding: 0;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-track {
|
.music-track {
|
||||||
display: flex;
|
position: relative;
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
padding: 10px;
|
|
||||||
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
||||||
background-color: var(--background-color-accent);
|
background-color: var(--background-color-accent);
|
||||||
|
|
||||||
cursor: pointer;
|
overflow: hidden;
|
||||||
|
|
||||||
|
isolation: isolate;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.music-track_actions {
|
||||||
|
&.withOrder {
|
||||||
|
.music-track_action {
|
||||||
|
.ant-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-track_orderIndex {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.current {
|
&.current {
|
||||||
background-color: var(--background-color-accent);
|
|
||||||
|
|
||||||
.music-track_actions {
|
.music-track_actions {
|
||||||
.music-track_action {
|
.music-track_action {
|
||||||
.ant-btn {
|
.ant-btn {
|
||||||
@ -50,6 +39,42 @@ html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.music-track_background {
|
||||||
|
background:
|
||||||
|
linear-gradient(to right, rgba(var(--cover_average-color)), transparent),
|
||||||
|
url(https://grainy-gradients.vercel.app/noise.svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-track_background {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
z-index: 50;
|
||||||
|
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
transition: all 150ms ease-in-out;
|
||||||
|
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-track_content {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
z-index: 55;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.music-track_actions {
|
.music-track_actions {
|
||||||
@ -61,16 +86,6 @@ html {
|
|||||||
|
|
||||||
&.withOrder {
|
&.withOrder {
|
||||||
.music-track_action {
|
.music-track_action {
|
||||||
&:hover {
|
|
||||||
.ant-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-track_orderIndex {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-btn {
|
.ant-btn {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
@ -82,6 +97,8 @@ html {
|
|||||||
|
|
||||||
transition: all 150ms ease-in-out;
|
transition: all 150ms ease-in-out;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
.music-track_orderIndex {
|
.music-track_orderIndex {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
||||||
|
@ -10,63 +10,68 @@ import LikeButton from "components/LikeButton"
|
|||||||
import AudioVolume from "components/Player/AudioVolume"
|
import AudioVolume from "components/Player/AudioVolume"
|
||||||
import AudioPlayerChangeModeButton from "components/Player/ChangeModeButton"
|
import AudioPlayerChangeModeButton from "components/Player/ChangeModeButton"
|
||||||
|
|
||||||
|
import { Context } from "contexts/WithPlayerContext"
|
||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
export default ({
|
const EventsHandlers = {
|
||||||
className,
|
"playback": () => {
|
||||||
controls,
|
return app.cores.player.playback.toggle()
|
||||||
syncModeLocked = false,
|
},
|
||||||
syncMode = false,
|
"like": () => {
|
||||||
streamMode,
|
|
||||||
playbackStatus,
|
|
||||||
onVolumeUpdate,
|
|
||||||
onMuteUpdate,
|
|
||||||
audioVolume = 0.3,
|
|
||||||
audioMuted = false,
|
|
||||||
loading = false,
|
|
||||||
liked = false,
|
|
||||||
} = {}) => {
|
|
||||||
const onClickActionsButton = (event) => {
|
|
||||||
if (typeof controls !== "object") {
|
|
||||||
console.warn("[AudioPlayer] onClickActionsButton: props.controls is not an object")
|
|
||||||
|
|
||||||
return false
|
},
|
||||||
|
"previous": () => {
|
||||||
|
return app.cores.player.playback.previous()
|
||||||
|
},
|
||||||
|
"next": () => {
|
||||||
|
return app.cores.player.playback.next()
|
||||||
|
},
|
||||||
|
"volume": (ctx, value) => {
|
||||||
|
return app.cores.player.volume(value)
|
||||||
|
},
|
||||||
|
"mute": () => {
|
||||||
|
return app.cores.player.toggleMute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Controls = (props) => {
|
||||||
|
try {
|
||||||
|
const ctx = React.useContext(Context)
|
||||||
|
|
||||||
|
const handleAction = (event, ...args) => {
|
||||||
|
if (typeof EventsHandlers[event] !== "function") {
|
||||||
|
throw new Error(`Unknown event "${event}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof controls[event] !== "function") {
|
return EventsHandlers[event](ctx, ...args)
|
||||||
console.warn(`[AudioPlayer] onClickActionsButton: ${event} is not a function`)
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return controls[event]()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div
|
return <div
|
||||||
className={
|
className={
|
||||||
className ?? "player-controls"
|
props.className ?? "player-controls"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AudioPlayerChangeModeButton
|
<AudioPlayerChangeModeButton
|
||||||
disabled={syncModeLocked}
|
disabled={ctx.control_locked}
|
||||||
/>
|
/>
|
||||||
<antd.Button
|
<antd.Button
|
||||||
type="ghost"
|
type="ghost"
|
||||||
shape="round"
|
shape="round"
|
||||||
icon={<Icons.ChevronLeft />}
|
icon={<Icons.ChevronLeft />}
|
||||||
onClick={() => onClickActionsButton("previous")}
|
onClick={() => handleAction("previous")}
|
||||||
disabled={syncModeLocked}
|
disabled={ctx.control_locked}
|
||||||
/>
|
/>
|
||||||
<antd.Button
|
<antd.Button
|
||||||
type="primary"
|
type="primary"
|
||||||
shape="circle"
|
shape="circle"
|
||||||
icon={streamMode ? <Icons.MdStop /> : playbackStatus === "playing" ? <Icons.MdPause /> : <Icons.MdPlayArrow />}
|
icon={ctx.livestream_mode ? <Icons.MdStop /> : ctx.playback_status === "playing" ? <Icons.MdPause /> : <Icons.MdPlayArrow />}
|
||||||
onClick={() => onClickActionsButton("toggle")}
|
onClick={() => handleAction("playback")}
|
||||||
className="playButton"
|
className="playButton"
|
||||||
disabled={syncModeLocked}
|
disabled={ctx.control_locked}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
loading && <div className="loadCircle">
|
ctx.loading && <div className="loadCircle">
|
||||||
<UseAnimations
|
<UseAnimations
|
||||||
animation={LoadingAnimation}
|
animation={LoadingAnimation}
|
||||||
size="100%"
|
size="100%"
|
||||||
@ -78,34 +83,43 @@ export default ({
|
|||||||
type="ghost"
|
type="ghost"
|
||||||
shape="round"
|
shape="round"
|
||||||
icon={<Icons.ChevronRight />}
|
icon={<Icons.ChevronRight />}
|
||||||
onClick={() => onClickActionsButton("next")}
|
onClick={() => handleAction("next")}
|
||||||
disabled={syncModeLocked}
|
disabled={ctx.control_locked}
|
||||||
/>
|
/>
|
||||||
{
|
{
|
||||||
app.isMobile && <LikeButton
|
app.isMobile && <LikeButton
|
||||||
onClick={controls.like}
|
onClick={() => handleAction("like")}
|
||||||
liked={liked}
|
liked={ctx.track_manifest?.liked}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
!app.isMobile && <antd.Popover
|
!app.isMobile && <antd.Popover
|
||||||
content={React.createElement(
|
content={React.createElement(
|
||||||
AudioVolume,
|
AudioVolume,
|
||||||
{ onChange: onVolumeUpdate, defaultValue: audioVolume }
|
{
|
||||||
|
onChange: (value) => handleAction("volume", value),
|
||||||
|
defaultValue: ctx.volume
|
||||||
|
}
|
||||||
)}
|
)}
|
||||||
trigger="hover"
|
trigger="hover"
|
||||||
>
|
>
|
||||||
<div
|
<button
|
||||||
className="muteButton"
|
className="muteButton"
|
||||||
onClick={onMuteUpdate}
|
onClick={() => handleAction("mute")}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
audioMuted
|
ctx.muted
|
||||||
? <Icons.VolumeX />
|
? <Icons.VolumeX />
|
||||||
: <Icons.Volume2 />
|
: <Icons.Volume2 />
|
||||||
}
|
}
|
||||||
</div>
|
</button>
|
||||||
</antd.Popover>
|
</antd.Popover>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Controls
|
@ -31,6 +31,35 @@ html {
|
|||||||
svg {
|
svg {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
|
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-btn {
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-btn-icon-only {
|
||||||
|
height: 32px;
|
||||||
|
width: 32px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
height: 32px;
|
||||||
|
width: 32px !important;
|
||||||
|
|
||||||
|
background-color: transparent;
|
||||||
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playButton {
|
.playButton {
|
||||||
@ -41,6 +70,10 @@ html {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
.ant-btn-icon {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.loadCircle {
|
.loadCircle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
||||||
@ -66,15 +99,13 @@ html {
|
|||||||
|
|
||||||
path {
|
path {
|
||||||
stroke: var(--text-color);
|
stroke: var(--text-color);
|
||||||
stroke-width: 1;
|
stroke-width: 1.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.muteButton {
|
.muteButton {
|
||||||
padding: 10px;
|
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
@ -33,8 +33,6 @@ html {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: fit-content;
|
height: fit-content;
|
||||||
|
|
||||||
//border-radius: 12px;
|
|
||||||
|
|
||||||
pointer-events: initial;
|
pointer-events: initial;
|
||||||
|
|
||||||
transition: all 150ms ease-in-out;
|
transition: all 150ms ease-in-out;
|
||||||
@ -45,6 +43,8 @@ html {
|
|||||||
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
.player {
|
.player {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
@ -159,6 +159,9 @@ export default class SeekBar extends React.Component {
|
|||||||
return <div
|
return <div
|
||||||
className={classnames(
|
className={classnames(
|
||||||
"player-seek_bar",
|
"player-seek_bar",
|
||||||
|
{
|
||||||
|
["stopped"]: this.props.stopped,
|
||||||
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
229
packages/app/src/components/Player/ToolBarPlayer/index.jsx
Normal file
229
packages/app/src/components/Player/ToolBarPlayer/index.jsx
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { WithPlayerContext, Context } from "contexts/WithPlayerContext"
|
||||||
|
|
||||||
|
import { Icons } from "components/Icons"
|
||||||
|
|
||||||
|
import Marquee from "react-fast-marquee"
|
||||||
|
|
||||||
|
import * as antd from "antd"
|
||||||
|
import classnames from "classnames"
|
||||||
|
|
||||||
|
import SeekBar from "components/Player/SeekBar"
|
||||||
|
import Controls from "components/Player/Controls"
|
||||||
|
|
||||||
|
import RGBStringToValues from "utils/rgbToValues"
|
||||||
|
|
||||||
|
import LikeButton from "components/LikeButton"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
function isOverflown(parent, element) {
|
||||||
|
if (!parent || !element) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentRect = parent.getBoundingClientRect()
|
||||||
|
const elementRect = element.getBoundingClientRect()
|
||||||
|
|
||||||
|
return elementRect.width > parentRect.width
|
||||||
|
}
|
||||||
|
|
||||||
|
const ServiceIndicator = (props) => {
|
||||||
|
if (!props.service) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (props.service) {
|
||||||
|
case "tidal": {
|
||||||
|
return <div className="service_indicator">
|
||||||
|
<Icons.SiTidal />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExtraActions = (props) => {
|
||||||
|
return <div className="extra_actions">
|
||||||
|
<LikeButton />
|
||||||
|
|
||||||
|
<antd.Button
|
||||||
|
type="ghost"
|
||||||
|
icon={<Icons.MdQueueMusic />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const Player = (props) => {
|
||||||
|
const ctx = React.useContext(Context)
|
||||||
|
|
||||||
|
const contentRef = React.useRef()
|
||||||
|
const titleRef = React.useRef()
|
||||||
|
const subtitleRef = React.useRef()
|
||||||
|
|
||||||
|
const [topActionsVisible, setTopActionsVisible] = React.useState(false)
|
||||||
|
const [titleOverflown, setTitleOverflown] = React.useState(false)
|
||||||
|
const [subtitleOverflown, setSubtitleOverflown] = React.useState(false)
|
||||||
|
|
||||||
|
const handleOnMouseInteraction = (e) => {
|
||||||
|
if (e.type === "mouseenter") {
|
||||||
|
setTopActionsVisible(true)
|
||||||
|
} else {
|
||||||
|
setTopActionsVisible(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
album,
|
||||||
|
artist,
|
||||||
|
liked,
|
||||||
|
service,
|
||||||
|
lyricsEnabled,
|
||||||
|
cover_analysis,
|
||||||
|
cover,
|
||||||
|
} = ctx.track_manifest ?? {}
|
||||||
|
|
||||||
|
const playing = ctx.playback_status === "playing"
|
||||||
|
const stopped = ctx.playback_status === "stopped"
|
||||||
|
|
||||||
|
const titleText = (!playing && stopped) ? "Stopped" : (title ?? "Untitled")
|
||||||
|
const subtitleText = ""
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const titleIsOverflown = isOverflown(contentRef.current, titleRef.current)
|
||||||
|
|
||||||
|
setTitleOverflown(titleIsOverflown)
|
||||||
|
}, [title])
|
||||||
|
|
||||||
|
return <div
|
||||||
|
className={classnames(
|
||||||
|
"toolbar_player_wrapper",
|
||||||
|
{
|
||||||
|
"hover": topActionsVisible,
|
||||||
|
"minimized": ctx.minimized,
|
||||||
|
"cover_light": cover_analysis?.isLight,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
"--cover_averageValues": RGBStringToValues(cover_analysis?.rgb),
|
||||||
|
"--cover_isLight": cover_analysis?.isLight,
|
||||||
|
}}
|
||||||
|
onMouseEnter={handleOnMouseInteraction}
|
||||||
|
onMouseLeave={handleOnMouseInteraction}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={classnames(
|
||||||
|
"toolbar_player_top_actions",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
!ctx.control_locked && <antd.Button
|
||||||
|
icon={<Icons.MdCast />}
|
||||||
|
shape="circle"
|
||||||
|
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
lyricsEnabled && <antd.Button
|
||||||
|
icon={<Icons.MdLyrics />}
|
||||||
|
shape="circle"
|
||||||
|
onClick={() => app.location.push("/lyrics")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* <antd.Button
|
||||||
|
icon={<Icons.MdOfflineBolt />}
|
||||||
|
>
|
||||||
|
HyperDrive
|
||||||
|
</antd.Button> */}
|
||||||
|
|
||||||
|
<antd.Button
|
||||||
|
icon={<Icons.X />}
|
||||||
|
shape="circle"
|
||||||
|
onClick={() => app.cores.player.close()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={classnames(
|
||||||
|
"toolbar_player"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="toolbar_cover_background"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${cover})`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="toolbar_player_content"
|
||||||
|
ref={contentRef}
|
||||||
|
>
|
||||||
|
<div className="toolbar_player_info">
|
||||||
|
<h1
|
||||||
|
ref={titleRef}
|
||||||
|
className={classnames(
|
||||||
|
"toolbar_player_info_title",
|
||||||
|
{
|
||||||
|
["overflown"]: titleOverflown
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ServiceIndicator
|
||||||
|
service={service}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{titleText}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{
|
||||||
|
titleOverflown && <Marquee
|
||||||
|
gradientColor={RGBStringToValues(cover_analysis?.rgb)}
|
||||||
|
gradientWidth={20}
|
||||||
|
play={ctx.playback_status !== "stopped"}
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
className="toolbar_player_info_title"
|
||||||
|
>
|
||||||
|
<ServiceIndicator
|
||||||
|
service={service}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{titleText}
|
||||||
|
</h1>
|
||||||
|
</Marquee>
|
||||||
|
}
|
||||||
|
|
||||||
|
<p className="toolbar_player_info_subtitle">
|
||||||
|
{artist ?? ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar_player_actions">
|
||||||
|
<Controls />
|
||||||
|
|
||||||
|
<SeekBar
|
||||||
|
stopped={ctx.playback_status === "stopped"}
|
||||||
|
playing={ctx.playback_status === "playing"}
|
||||||
|
streamMode={ctx.livestream_mode}
|
||||||
|
disabled={ctx.control_locked}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExtraActions />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlayerContextHandler = () => {
|
||||||
|
return <WithPlayerContext>
|
||||||
|
<Player />
|
||||||
|
</WithPlayerContext>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PlayerContextHandler
|
269
packages/app/src/components/Player/ToolBarPlayer/index.less
Normal file
269
packages/app/src/components/Player/ToolBarPlayer/index.less
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
@import "theme/vars.less";
|
||||||
|
|
||||||
|
@toolbar_player_borderRadius: 12px;
|
||||||
|
|
||||||
|
@toolbar_player_top_actions_height: 50px;
|
||||||
|
@toolbar_player_top_actions_padding_vertical: 5px;
|
||||||
|
@toolbar_player_top_actions_padding_horizontal: 15px;
|
||||||
|
|
||||||
|
.toolbar_player_wrapper {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
transition: all 150ms ease-in-out;
|
||||||
|
|
||||||
|
&.hover {
|
||||||
|
filter: drop-shadow(@card-drop-shadow);
|
||||||
|
|
||||||
|
.toolbar_player_top_actions {
|
||||||
|
height: @toolbar_player_top_actions_height;
|
||||||
|
padding: @toolbar_player_top_actions_padding_vertical @toolbar_player_top_actions_padding_horizontal;
|
||||||
|
padding-bottom: calc(calc(@toolbar_player_borderRadius / 2) + @toolbar_player_top_actions_padding_vertical);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cover_light {
|
||||||
|
.toolbar_player_content {
|
||||||
|
color: var(--text-color-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.MuiSlider-root {
|
||||||
|
color: var(--text-color-black);
|
||||||
|
|
||||||
|
.MuiSlider-rail {
|
||||||
|
color: var(--text-color-black);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadCircle {
|
||||||
|
svg {
|
||||||
|
path {
|
||||||
|
stroke: var(--text-color-black);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar_player_top_actions {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
z-index: 60;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 0px;
|
||||||
|
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
transition: all 150ms ease-in-out;
|
||||||
|
transform: translateY(calc(@toolbar_player_borderRadius / 2));
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
background-color: var(--background-color-primary);
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar_player {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
z-index: 70;
|
||||||
|
|
||||||
|
border-radius: @toolbar_player_borderRadius;
|
||||||
|
|
||||||
|
.toolbar_cover_background {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
|
||||||
|
opacity: 0.6;
|
||||||
|
|
||||||
|
z-index: 65;
|
||||||
|
|
||||||
|
// create a mask to the bottom
|
||||||
|
//-webkit-mask-image: linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar_player_content {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
z-index: 80;
|
||||||
|
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
background-color: rgba(var(--cover_averageValues), 0.7);
|
||||||
|
|
||||||
|
color: var(--text-color-white);
|
||||||
|
|
||||||
|
.toolbar_player_info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
color: currentColor;
|
||||||
|
|
||||||
|
.toolbar_player_info_title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
margin: 0;
|
||||||
|
margin-right: 30px;
|
||||||
|
|
||||||
|
width: fit-content;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
font-family: "Space Grotesk", sans-serif;
|
||||||
|
|
||||||
|
transition: all 150ms ease-in-out;
|
||||||
|
|
||||||
|
color: currentColor;
|
||||||
|
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&.overflown {
|
||||||
|
opacity: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar_player_info_subtitle {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar_player_actions {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
color: currentColor;
|
||||||
|
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
height: fit-content;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
padding: 10px;
|
||||||
|
gap: 5px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.player-controls {
|
||||||
|
color: currentColor;
|
||||||
|
|
||||||
|
transition: all 150ms ease-in-out;
|
||||||
|
|
||||||
|
// padding: 3px 0;
|
||||||
|
|
||||||
|
// background-color: rgba(var(--layoutBackgroundColor), 0.7);
|
||||||
|
|
||||||
|
// -webkit-backdrop-filter: blur(5px);
|
||||||
|
// backdrop-filter: blur(5px);
|
||||||
|
|
||||||
|
// border-radius: 12px;
|
||||||
|
|
||||||
|
.ant-btn-icon,
|
||||||
|
button {
|
||||||
|
color: currentColor;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-seek_bar {
|
||||||
|
height: fit-content;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
color: currentColor;
|
||||||
|
|
||||||
|
.timers {
|
||||||
|
color: currentColor;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.extra_actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
width: 70%;
|
||||||
|
|
||||||
|
margin: auto;
|
||||||
|
|
||||||
|
padding: 7px 25px;
|
||||||
|
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
background-color: rgba(var(--layoutBackgroundColor), 0.7);
|
||||||
|
|
||||||
|
-webkit-backdrop-filter: blur(5px);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
button,
|
||||||
|
.likeButton {
|
||||||
|
width: 32px;
|
||||||
|
height: 16px;
|
||||||
|
|
||||||
|
.ant-btn-icon {
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-btn {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
14
packages/app/src/contexts/WithPlaylistContext/index.js
Normal file
14
packages/app/src/contexts/WithPlaylistContext/index.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
const Context = React.createContext({
|
||||||
|
playlist_data: null,
|
||||||
|
owning_playlist: null,
|
||||||
|
add_track: () => { },
|
||||||
|
remove_track: () => { },
|
||||||
|
})
|
||||||
|
|
||||||
|
export default Context
|
||||||
|
|
||||||
|
export {
|
||||||
|
Context
|
||||||
|
}
|
@ -3,9 +3,9 @@ import EventEmitter from "evite/src/internals/EventEmitter"
|
|||||||
import { Observable } from "object-observer"
|
import { Observable } from "object-observer"
|
||||||
import { FastAverageColor } from "fast-average-color"
|
import { FastAverageColor } from "fast-average-color"
|
||||||
|
|
||||||
import PlaylistModel from "comty.js/models/playlists"
|
import MusicModel from "comty.js/models/music"
|
||||||
|
|
||||||
import EmbbededMediaPlayer from "components/Player/MediaPlayer"
|
import ToolBarPlayer from "components/Player/ToolBarPlayer"
|
||||||
import BackgroundMediaPlayer from "components/Player/BackgroundMediaPlayer"
|
import BackgroundMediaPlayer from "components/Player/BackgroundMediaPlayer"
|
||||||
|
|
||||||
import AudioPlayerStorage from "./player.storage"
|
import AudioPlayerStorage from "./player.storage"
|
||||||
@ -84,6 +84,9 @@ export default class Player extends Core {
|
|||||||
previous: this.previous.bind(this),
|
previous: this.previous.bind(this),
|
||||||
seek: this.seek.bind(this),
|
seek: this.seek.bind(this),
|
||||||
},
|
},
|
||||||
|
_setLoading: function (to) {
|
||||||
|
this.state.loading = !!to
|
||||||
|
}.bind(this),
|
||||||
duration: this.duration.bind(this),
|
duration: this.duration.bind(this),
|
||||||
volume: this.volume.bind(this),
|
volume: this.volume.bind(this),
|
||||||
mute: this.mute.bind(this),
|
mute: this.mute.bind(this),
|
||||||
@ -213,12 +216,10 @@ export default class Player extends Core {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!app.layout.tools_bar) {
|
if (app.layout.tools_bar) {
|
||||||
this.console.error("Tools bar not found")
|
this.currentDomWindow = app.layout.tools_bar.attachRender("mediaPlayer", ToolBarPlayer)
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentDomWindow = app.layout.tools_bar.attachRender("mediaPlayer", EmbbededMediaPlayer)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
detachPlayerComponent() {
|
detachPlayerComponent() {
|
||||||
@ -263,12 +264,7 @@ export default class Player extends Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!instance.manifest.cover_analysis) {
|
if (!instance.manifest.cover_analysis) {
|
||||||
const img = new Image()
|
const cover_analysis = await this.fac.getColorAsync(`https://corsproxy.io/?${encodeURIComponent(instance.manifest.cover ?? instance.manifest.thumbnail)}`)
|
||||||
|
|
||||||
img.crossOrigin = "anonymous"
|
|
||||||
img.src = instance.manifest.cover ?? instance.manifest.thumbnail //`https://cors-anywhere.herokuapp.com/${instance.manifest.cover ?? instance.manifest.thumbnail}`
|
|
||||||
|
|
||||||
const cover_analysis = await this.fac.getColorAsync(img)
|
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
this.console.error(err)
|
this.console.error(err)
|
||||||
|
|
||||||
@ -762,6 +758,8 @@ export default class Player extends Core {
|
|||||||
|
|
||||||
this.state.volume = volume
|
this.state.volume = volume
|
||||||
|
|
||||||
|
AudioPlayerStorage.set("volume", volume)
|
||||||
|
|
||||||
if (this.track_instance) {
|
if (this.track_instance) {
|
||||||
if (this.track_instance.gainNode) {
|
if (this.track_instance.gainNode) {
|
||||||
this.track_instance.gainNode.gain.value = this.state.volume
|
this.track_instance.gainNode.gain.value = this.state.volume
|
||||||
@ -904,7 +902,7 @@ export default class Player extends Core {
|
|||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchedTracks = await PlaylistModel.getTracks(ids).catch((err) => {
|
const fetchedTracks = await MusicModel.getTracksData(ids).catch((err) => {
|
||||||
this.console.error(err)
|
this.console.error(err)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
@ -3,9 +3,12 @@ import GainProcessorNode from "./gainNode"
|
|||||||
import CompressorProcessorNode from "./compressorNode"
|
import CompressorProcessorNode from "./compressorNode"
|
||||||
//import BPMProcessorNode from "./bpmNode"
|
//import BPMProcessorNode from "./bpmNode"
|
||||||
|
|
||||||
|
import SpatialNode from "./spatialNode"
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
//BPMProcessorNode,
|
//BPMProcessorNode,
|
||||||
EqProcessorNode,
|
EqProcessorNode,
|
||||||
GainProcessorNode,
|
GainProcessorNode,
|
||||||
CompressorProcessorNode,
|
CompressorProcessorNode,
|
||||||
|
SpatialNode,
|
||||||
]
|
]
|
@ -0,0 +1,62 @@
|
|||||||
|
import AudioPlayerStorage from "../../player.storage"
|
||||||
|
import ProcessorNode from "../node"
|
||||||
|
|
||||||
|
export default class SpatialNode extends ProcessorNode {
|
||||||
|
static refName = "spatial"
|
||||||
|
|
||||||
|
static dependsOnSettings = ["player.spatial"]
|
||||||
|
|
||||||
|
panner = this.audioContext.createPanner()
|
||||||
|
|
||||||
|
state = {
|
||||||
|
position: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
z: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exposeToPublic = {
|
||||||
|
panner: new Proxy(this.panner, {
|
||||||
|
get: (target, property) => {
|
||||||
|
if (!property) {
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
return target[property]
|
||||||
|
},
|
||||||
|
|
||||||
|
}),
|
||||||
|
updateLocation: this.updateLocation.bind(this),
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLocation(x, y, z) {
|
||||||
|
this.state.position.x = x
|
||||||
|
this.state.position.y = y
|
||||||
|
this.state.position.z = z
|
||||||
|
|
||||||
|
this.applyValues()
|
||||||
|
}
|
||||||
|
|
||||||
|
applyValues() {
|
||||||
|
// apply to current instance
|
||||||
|
this.panner.setPosition(this.state.position.x, this.state.position.y, this.state.position.z)
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (!this.audioContext) {
|
||||||
|
throw new Error("audioContext is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processor = this.panner
|
||||||
|
|
||||||
|
this.processor.panningModel = "HRTF"
|
||||||
|
this.processor.distanceModel = "inverse"
|
||||||
|
this.processor.refDistance = 1
|
||||||
|
this.processor.maxDistance = 15
|
||||||
|
this.processor.rolloffFactor = 1;
|
||||||
|
this.processor.coneInnerAngle = 360;
|
||||||
|
this.processor.coneOuterAngle = 360;
|
||||||
|
this.processor.coneOuterGain = 0;
|
||||||
|
}
|
||||||
|
}
|
@ -5,9 +5,9 @@ import { Icons } from "components/Icons"
|
|||||||
import { ImageViewer } from "components"
|
import { ImageViewer } from "components"
|
||||||
import Searcher from "components/Searcher"
|
import Searcher from "components/Searcher"
|
||||||
|
|
||||||
import PlaylistCreator from "../../../creator"
|
import ReleaseCreator from "../../../creator"
|
||||||
|
|
||||||
import PlaylistsModel from "models/playlists"
|
import MusicModel from "models/music"
|
||||||
|
|
||||||
import "./index.less"
|
import "./index.less"
|
||||||
|
|
||||||
@ -81,13 +81,13 @@ const ReleaseItem = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
const openPlaylistCreator = ({
|
const openReleaseCreator = ({
|
||||||
playlist_id = null,
|
release_id = null,
|
||||||
onModification = () => { }
|
onModification = () => { }
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
console.log("Opening playlist creator", playlist_id)
|
console.log("Opening release creator", release_id)
|
||||||
|
|
||||||
app.DrawerController.open("playlist_creator", PlaylistCreator, {
|
app.DrawerController.open("release_creator", ReleaseCreator, {
|
||||||
type: "drawer",
|
type: "drawer",
|
||||||
props: {
|
props: {
|
||||||
title: <h2
|
title: <h2
|
||||||
@ -101,19 +101,19 @@ const openPlaylistCreator = ({
|
|||||||
width: "fit-content",
|
width: "fit-content",
|
||||||
},
|
},
|
||||||
componentProps: {
|
componentProps: {
|
||||||
playlist_id: playlist_id,
|
release_id: release_id,
|
||||||
onModification: onModification,
|
onModification: onModification,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigateToPlaylist = (playlist_id) => {
|
const navigateToRelease = (release_id) => {
|
||||||
return app.location.push(`/play/${playlist_id}`)
|
return app.location.push(`/play/${release_id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (props) => {
|
export default (props) => {
|
||||||
const [searchResults, setSearchResults] = React.useState(null)
|
const [searchResults, setSearchResults] = React.useState(null)
|
||||||
const [L_Releases, R_Releases, E_Releases, M_Releases] = app.cores.api.useRequest(PlaylistsModel.getMyReleases)
|
const [L_Releases, R_Releases, E_Releases, M_Releases] = app.cores.api.useRequest(MusicModel.getMyReleases)
|
||||||
|
|
||||||
if (E_Releases) {
|
if (E_Releases) {
|
||||||
console.error(E_Releases)
|
console.error(E_Releases)
|
||||||
@ -140,7 +140,7 @@ export default (props) => {
|
|||||||
|
|
||||||
<div className="music_panel_releases_header_actions">
|
<div className="music_panel_releases_header_actions">
|
||||||
<antd.Button
|
<antd.Button
|
||||||
onClick={() => openPlaylistCreator({
|
onClick={() => openReleaseCreator({
|
||||||
onModification: M_Releases,
|
onModification: M_Releases,
|
||||||
})}
|
})}
|
||||||
icon={<Icons.Plus />}
|
icon={<Icons.Plus />}
|
||||||
@ -155,52 +155,52 @@ export default (props) => {
|
|||||||
<Searcher
|
<Searcher
|
||||||
small
|
small
|
||||||
renderResults={false}
|
renderResults={false}
|
||||||
model={PlaylistsModel.getMyReleases}
|
model={MusicModel.getMyReleases}
|
||||||
onSearchResult={setSearchResults}
|
onSearchResult={setSearchResults}
|
||||||
onEmpty={() => setSearchResults(null)}
|
onEmpty={() => setSearchResults(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="music_panel_releases_list">
|
<div className="music_panel_releases_list">
|
||||||
{
|
{
|
||||||
searchResults && searchResults.length === 0 && <antd.Result
|
searchResults?.items && searchResults.items.length === 0 && <antd.Result
|
||||||
status="info"
|
status="info"
|
||||||
title="No results"
|
title="No results"
|
||||||
subTitle="We are sorry, but we could not find any results for your search."
|
subTitle="We are sorry, but we could not find any results for your search."
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
searchResults && searchResults.length > 0 && searchResults.map((release) => {
|
searchResults?.items && searchResults.items.length > 0 && searchResults.items.map((release) => {
|
||||||
return <ReleaseItem
|
return <ReleaseItem
|
||||||
key={release._id}
|
key={release._id}
|
||||||
release={release}
|
release={release}
|
||||||
onClickEditTrack={() => openPlaylistCreator({
|
onClickEditTrack={() => openReleaseCreator({
|
||||||
playlist_id: release._id,
|
release_id: release._id,
|
||||||
onModification: M_Releases,
|
onModification: M_Releases,
|
||||||
})}
|
})}
|
||||||
onClickNavigate={() => navigateToPlaylist(release._id)}
|
onClickNavigate={() => navigateToRelease(release._id)}
|
||||||
/>
|
/>
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
!searchResults && R_Releases.map((release) => {
|
!searchResults && R_Releases.items.length === 0 && <antd.Result
|
||||||
return <ReleaseItem
|
|
||||||
key={release._id}
|
|
||||||
release={release}
|
|
||||||
onClickEditTrack={() => openPlaylistCreator({
|
|
||||||
playlist_id: release._id,
|
|
||||||
onModification: M_Releases,
|
|
||||||
})}
|
|
||||||
onClickNavigate={() => navigateToPlaylist(release._id)}
|
|
||||||
/>
|
|
||||||
})
|
|
||||||
}
|
|
||||||
{
|
|
||||||
!searchResults && R_Releases.length === 0 && <antd.Result
|
|
||||||
status="info"
|
status="info"
|
||||||
title="No releases"
|
title="No releases"
|
||||||
subTitle="You don't have any releases yet."
|
subTitle="You don't have any releases yet."
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
!searchResults && R_Releases.items.map((release) => {
|
||||||
|
return <ReleaseItem
|
||||||
|
key={release._id}
|
||||||
|
release={release}
|
||||||
|
onClickEditTrack={() => openReleaseCreator({
|
||||||
|
release_id: release._id,
|
||||||
|
onModification: M_Releases,
|
||||||
|
})}
|
||||||
|
onClickNavigate={() => navigateToRelease(release._id)}
|
||||||
|
/>
|
||||||
|
})
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
@ -10,7 +10,6 @@ import { WithPlayerContext } from "contexts/WithPlayerContext"
|
|||||||
|
|
||||||
import FeedModel from "models/feed"
|
import FeedModel from "models/feed"
|
||||||
import MusicModel from "models/music"
|
import MusicModel from "models/music"
|
||||||
import SyncModel from "models/sync"
|
|
||||||
|
|
||||||
import MusicTrack from "components/Music/Track"
|
import MusicTrack from "components/Music/Track"
|
||||||
import PlaylistItem from "components/Music/PlaylistItem"
|
import PlaylistItem from "components/Music/PlaylistItem"
|
||||||
@ -207,7 +206,6 @@ const SearchResults = ({
|
|||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<WithPlayerContext>
|
|
||||||
{
|
{
|
||||||
groupsKeys.map((key, index) => {
|
groupsKeys.map((key, index) => {
|
||||||
const decorator = ResultGroupsDecorators[key] ?? {
|
const decorator = ResultGroupsDecorators[key] ?? {
|
||||||
@ -241,7 +239,6 @@ const SearchResults = ({
|
|||||||
</div>
|
</div>
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</WithPlayerContext>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -293,7 +290,7 @@ export default (props) => {
|
|||||||
<PlaylistsList
|
<PlaylistsList
|
||||||
headerTitle="From your following artists"
|
headerTitle="From your following artists"
|
||||||
headerIcon={<Icons.MdPerson />}
|
headerIcon={<Icons.MdPerson />}
|
||||||
fetchMethod={FeedModel.getPlaylistsFeed}
|
fetchMethod={FeedModel.getMusicFeed}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PlaylistsList
|
<PlaylistsList
|
||||||
|
@ -1,3 +1,21 @@
|
|||||||
|
html {
|
||||||
|
&.mobile {
|
||||||
|
.musicExplorer {
|
||||||
|
|
||||||
|
.playlistExplorer_section_list {
|
||||||
|
overflow: visible;
|
||||||
|
overflow-x: scroll;
|
||||||
|
|
||||||
|
width: unset;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
grid-gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.music_navbar {
|
.music_navbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -71,10 +89,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.playlistExplorer_section_list {
|
.playlistExplorer_section_list {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
gap: 10px;
|
grid-gap: 20px;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
|
||||||
|
min-width: 372px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -103,6 +123,35 @@
|
|||||||
.playlistItem {
|
.playlistItem {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
|
|
||||||
|
.playlistItem_info_subtitle {
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlistItem_bottom {
|
||||||
|
display: flex!important;
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
top:0;
|
||||||
|
right: 0;
|
||||||
|
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
//-webkit-backdrop-filter: blur(5px);
|
||||||
|
//backdrop-filter: blur(5px);
|
||||||
|
|
||||||
|
background-color: var(--background-color-primary);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
p {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -143,19 +192,36 @@
|
|||||||
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
||||||
|
@playlistItem_height: 80px;
|
||||||
|
@playlistItem_padding: 10px;
|
||||||
|
|
||||||
|
@playlistItem_cover_size: calc(@playlistItem_height - @playlistItem_padding * 2);
|
||||||
|
|
||||||
.playlistItem {
|
.playlistItem {
|
||||||
|
flex-direction: row;
|
||||||
background-color: var(--background-color-primary);
|
background-color: var(--background-color-primary);
|
||||||
|
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
height: 80px;
|
width: 100%;
|
||||||
|
|
||||||
|
height: @playlistItem_height;
|
||||||
|
|
||||||
|
padding: @playlistItem_padding;
|
||||||
|
|
||||||
.playlistItem_cover {
|
.playlistItem_cover {
|
||||||
width: 80px;
|
width: @playlistItem_cover_size;
|
||||||
height: 80px;
|
height: @playlistItem_cover_size;
|
||||||
|
|
||||||
img {
|
min-height: @playlistItem_cover_size;
|
||||||
height: 80px;
|
min-width: @playlistItem_cover_size;
|
||||||
width: 80px;
|
}
|
||||||
|
.playlistItem_bottom {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.playlistItem_info {
|
||||||
|
transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -49,7 +49,7 @@ export default class FavoriteTracks extends React.Component {
|
|||||||
loading: true,
|
loading: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await MusicModel.getFavorites({
|
const result = await MusicModel.getFavoriteTracks({
|
||||||
useTidal: app.cores.sync.getActiveLinkedServices().tidal,
|
useTidal: app.cores.sync.getActiveLinkedServices().tidal,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
limit: limit,
|
limit: limit,
|
||||||
@ -112,6 +112,7 @@ export default class FavoriteTracks extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return <PlaylistView
|
return <PlaylistView
|
||||||
|
favorite
|
||||||
type="vertical"
|
type="vertical"
|
||||||
playlist={{
|
playlist={{
|
||||||
title: "Your favorites",
|
title: "Your favorites",
|
||||||
|
@ -1,7 +1,190 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import classnames from "classnames"
|
||||||
|
|
||||||
export default () => {
|
import Image from "components/Image"
|
||||||
return <div>
|
import { Icons } from "components/Icons"
|
||||||
|
|
||||||
|
import MusicModel from "models/music"
|
||||||
|
|
||||||
|
import OpenPlaylistCreator from "components/Music/PlaylistCreator"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
const ReleaseTypeDecorators = {
|
||||||
|
"user": () => <p >
|
||||||
|
<Icons.MdPlaylistAdd />
|
||||||
|
Playlist
|
||||||
|
</p>,
|
||||||
|
"playlist": () => <p >
|
||||||
|
<Icons.MdPlaylistAdd />
|
||||||
|
Playlist
|
||||||
|
</p>,
|
||||||
|
"editorial": () => <p >
|
||||||
|
<Icons.MdPlaylistAdd />
|
||||||
|
Official Playlist
|
||||||
|
</p>,
|
||||||
|
"single": () => <p >
|
||||||
|
<Icons.MdMusicNote />
|
||||||
|
Single
|
||||||
|
</p>,
|
||||||
|
"album": () => <p >
|
||||||
|
<Icons.MdAlbum />
|
||||||
|
Album
|
||||||
|
</p>,
|
||||||
|
"ep": () => <p >
|
||||||
|
<Icons.MdAlbum />
|
||||||
|
EP
|
||||||
|
</p>,
|
||||||
|
"mix": () => <p >
|
||||||
|
<Icons.MdMusicNote />
|
||||||
|
Mix
|
||||||
|
</p>,
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNotAPlaylist(type) {
|
||||||
|
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 OwnPlaylists = (props) => {
|
||||||
|
const [L_Playlists, R_Playlists, E_Playlists, M_Playlists] = app.cores.api.useRequest(MusicModel.getFavoritePlaylists, {
|
||||||
|
services: {
|
||||||
|
tidal: app.cores.sync.getActiveLinkedServices().tidal
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
return <div className="music-library">
|
||||||
|
<OwnPlaylists />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
173
packages/app/src/pages/music/components/library/index.less
Normal file
173
packages/app/src/pages/music/components/library/index.less
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-library {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
}
|
@ -4,39 +4,46 @@ import { Icons } from "components/Icons"
|
|||||||
import UploadButton from "components/UploadButton"
|
import UploadButton from "components/UploadButton"
|
||||||
|
|
||||||
export default (props) => {
|
export default (props) => {
|
||||||
const [playlistName, setPlaylistName] = React.useState(props.playlist.title)
|
const [releaseName, setReleaseName] = React.useState(props.release.title)
|
||||||
const [playlistDescription, setPlaylistDescription] = React.useState(props.playlist.description)
|
const [releaseDescription, setReleaseDescription] = React.useState(props.release.description)
|
||||||
const [playlistThumbnail, setPlaylistThumbnail] = React.useState(props.playlist.cover ?? props.playlist.thumbnail)
|
const [releaseThumbnail, setReleaseThumbnail] = React.useState(props.release.cover ?? props.release.thumbnail)
|
||||||
const [playlistVisibility, setPlaylistVisibility] = React.useState(props.playlist.visibility)
|
const [releaseVisibility, setReleaseVisibility] = React.useState(props.release.visibility)
|
||||||
|
const [releaseType, setReleaseType] = React.useState(props.release.type)
|
||||||
|
|
||||||
|
const handleReleaseTypeChange = (value) => {
|
||||||
|
setReleaseType(value)
|
||||||
|
|
||||||
|
props.onValueChange("type", value)
|
||||||
|
}
|
||||||
|
|
||||||
const handleTitleOnChange = (event) => {
|
const handleTitleOnChange = (event) => {
|
||||||
setPlaylistName(event.target.value)
|
setReleaseName(event.target.value)
|
||||||
|
|
||||||
props.onTitleChange(event.target.value)
|
props.onValueChange("title", event.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDescriptionOnChange = (event) => {
|
const handleDescriptionOnChange = (event) => {
|
||||||
setPlaylistDescription(event.target.value)
|
setReleaseDescription(event.target.value)
|
||||||
|
|
||||||
props.onDescriptionChange(event.target.value)
|
props.onValueChange("description", event.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCoverChange = (file) => {
|
const handleCoverChange = (file) => {
|
||||||
setPlaylistThumbnail(file.url)
|
setReleaseThumbnail(file.url)
|
||||||
|
|
||||||
props.onPlaylistCoverChange(file.url)
|
props.onValueChange("cover", file.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRemoveCover = () => {
|
const handleRemoveCover = () => {
|
||||||
setPlaylistThumbnail(null)
|
setReleaseThumbnail(null)
|
||||||
|
|
||||||
props.onPlaylistCoverChange(null)
|
props.onValueChange("cover", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleVisibilityChange = (value) => {
|
const handleVisibilityChange = (value) => {
|
||||||
setPlaylistVisibility(value)
|
setReleaseVisibility(value)
|
||||||
|
|
||||||
props.onVisibilityChange(value === "public")
|
props.onValueChange("public", value === "public")
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className="playlistCreator_layout_row">
|
return <div className="playlistCreator_layout_row">
|
||||||
@ -51,10 +58,10 @@ export default (props) => {
|
|||||||
|
|
||||||
<antd.Input
|
<antd.Input
|
||||||
className="inputText"
|
className="inputText"
|
||||||
placeholder="Playlist Title"
|
placeholder="Publish Title"
|
||||||
size="large"
|
size="large"
|
||||||
bordered={false}
|
bordered={false}
|
||||||
value={playlistName}
|
value={releaseName}
|
||||||
onChange={handleTitleOnChange}
|
onChange={handleTitleOnChange}
|
||||||
maxLength={120}
|
maxLength={120}
|
||||||
/>
|
/>
|
||||||
@ -70,7 +77,7 @@ export default (props) => {
|
|||||||
className="inputText"
|
className="inputText"
|
||||||
placeholder="Description (Support Markdown)"
|
placeholder="Description (Support Markdown)"
|
||||||
bordered={false}
|
bordered={false}
|
||||||
value={playlistDescription}
|
value={releaseDescription}
|
||||||
onChange={handleDescriptionOnChange}
|
onChange={handleDescriptionOnChange}
|
||||||
maxLength={2500}
|
maxLength={2500}
|
||||||
rows={4}
|
rows={4}
|
||||||
@ -79,6 +86,23 @@ export default (props) => {
|
|||||||
|
|
||||||
<antd.Divider />
|
<antd.Divider />
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<div className="field_header">
|
||||||
|
<Icons.IoMdRecording />
|
||||||
|
<span>Type</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<antd.Select
|
||||||
|
value={releaseType}
|
||||||
|
onChange={handleReleaseTypeChange}
|
||||||
|
defaultValue="album"
|
||||||
|
>
|
||||||
|
<antd.Select.Option value="album">Album</antd.Select.Option>
|
||||||
|
<antd.Select.Option value="ep">EP</antd.Select.Option>
|
||||||
|
<antd.Select.Option value="single">Single</antd.Select.Option>
|
||||||
|
</antd.Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<div className="field_header">
|
<div className="field_header">
|
||||||
<Icons.Eye />
|
<Icons.Eye />
|
||||||
@ -86,9 +110,9 @@ export default (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<antd.Select
|
<antd.Select
|
||||||
value={playlistVisibility}
|
value={releaseVisibility}
|
||||||
onChange={handleVisibilityChange}
|
onChange={handleVisibilityChange}
|
||||||
defaultValue={props.playlist.public ? "public" : "private"}
|
defaultValue={props.release.public ? "public" : "private"}
|
||||||
>
|
>
|
||||||
<antd.Select.Option value="public">Public</antd.Select.Option>
|
<antd.Select.Option value="public">Public</antd.Select.Option>
|
||||||
<antd.Select.Option value="private">Private</antd.Select.Option>
|
<antd.Select.Option value="private">Private</antd.Select.Option>
|
||||||
@ -111,7 +135,7 @@ export default (props) => {
|
|||||||
|
|
||||||
<div className="coverPreview">
|
<div className="coverPreview">
|
||||||
<div className="coverPreview_preview">
|
<div className="coverPreview_preview">
|
||||||
<img src={playlistThumbnail ?? "/assets/no_song.png"} alt="Thumbnail" />
|
<img src={releaseThumbnail ?? "/assets/no_song.png"} alt="Thumbnail" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="coverPreview_actions">
|
<div className="coverPreview_actions">
|
||||||
@ -125,7 +149,7 @@ export default (props) => {
|
|||||||
|
|
||||||
<antd.Button
|
<antd.Button
|
||||||
onClick={handleRemoveCover}
|
onClick={handleRemoveCover}
|
||||||
disabled={!playlistThumbnail}
|
disabled={!releaseThumbnail}
|
||||||
icon={<Icons.MdClose />}
|
icon={<Icons.MdClose />}
|
||||||
type="text"
|
type="text"
|
||||||
>
|
>
|
||||||
@ -139,7 +163,7 @@ export default (props) => {
|
|||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
{
|
{
|
||||||
props.playlist._id && <antd.Button
|
props.release._id && <antd.Button
|
||||||
onClick={props.onDeletePlaylist}
|
onClick={props.onDeletePlaylist}
|
||||||
icon={<Icons.MdDelete />}
|
icon={<Icons.MdDelete />}
|
||||||
danger
|
danger
|
||||||
|
@ -4,7 +4,7 @@ import classnames from "classnames"
|
|||||||
import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd"
|
import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd"
|
||||||
import UploadButton from "components/UploadButton"
|
import UploadButton from "components/UploadButton"
|
||||||
|
|
||||||
import PlaylistModel from "models/playlists"
|
import MusicModel from "models/music"
|
||||||
|
|
||||||
import { Icons } from "components/Icons"
|
import { Icons } from "components/Icons"
|
||||||
|
|
||||||
@ -127,7 +127,24 @@ const FileItemEditor = (props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<antd.Divider />
|
<div className="fileItemEditor_field">
|
||||||
|
<div className="fileItemEditor_field_header">
|
||||||
|
<Icons.MdTimeline />
|
||||||
|
<span>Timestamps</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<antd.Button
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</antd.Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<antd.Divider
|
||||||
|
style={{
|
||||||
|
margin: "5px 0",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="fileItemEditor_field">
|
<div className="fileItemEditor_field">
|
||||||
<div className="fileItemEditor_field_header">
|
<div className="fileItemEditor_field_header">
|
||||||
@ -141,6 +158,19 @@ const FileItemEditor = (props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="fileItemEditor_field">
|
||||||
|
<div className="fileItemEditor_field_header">
|
||||||
|
<Icons.MdTextFormat />
|
||||||
|
<span>Upload LRC</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<antd.Button
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
</antd.Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="fileItemEditor_field">
|
<div className="fileItemEditor_field">
|
||||||
<div className="fileItemEditor_field_header">
|
<div className="fileItemEditor_field_header">
|
||||||
<Icons.Tag />
|
<Icons.Tag />
|
||||||
@ -305,8 +335,8 @@ export default (props) => {
|
|||||||
app.DrawerController.open("track_editor", FileItemEditor, {
|
app.DrawerController.open("track_editor", FileItemEditor, {
|
||||||
type: "drawer",
|
type: "drawer",
|
||||||
props: {
|
props: {
|
||||||
width: "25vw",
|
width: "30vw",
|
||||||
minWidth: "500px",
|
minWidth: "600px",
|
||||||
},
|
},
|
||||||
componentProps: {
|
componentProps: {
|
||||||
track,
|
track,
|
||||||
@ -318,7 +348,7 @@ export default (props) => {
|
|||||||
onRefreshCache: () => {
|
onRefreshCache: () => {
|
||||||
console.log("Refreshing cache for track", track.uid)
|
console.log("Refreshing cache for track", track.uid)
|
||||||
|
|
||||||
PlaylistModel.refreshTrackCache(track._id)
|
MusicModel.refreshTrackCache(track._id)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
app.message.error("Failed to refresh cache for track")
|
app.message.error("Failed to refresh cache for track")
|
||||||
})
|
})
|
||||||
|
@ -2,7 +2,7 @@ import React from "react"
|
|||||||
import * as antd from "antd"
|
import * as antd from "antd"
|
||||||
import jsmediatags from "jsmediatags/dist/jsmediatags.min.js"
|
import jsmediatags from "jsmediatags/dist/jsmediatags.min.js"
|
||||||
|
|
||||||
import PlaylistModel from "models/playlists"
|
import MusicModel from "models/music"
|
||||||
|
|
||||||
import BasicInformation from "./components/BasicInformation"
|
import BasicInformation from "./components/BasicInformation"
|
||||||
import TracksUploads from "./components/TracksUploads"
|
import TracksUploads from "./components/TracksUploads"
|
||||||
@ -50,9 +50,9 @@ function createDefaultTrackData({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class PlaylistCreatorSteps extends React.Component {
|
export default class PlaylistPublisherSteps extends React.Component {
|
||||||
state = {
|
state = {
|
||||||
playlistData: {},
|
releaseData: {},
|
||||||
|
|
||||||
fileList: [],
|
fileList: [],
|
||||||
trackList: [],
|
trackList: [],
|
||||||
@ -145,10 +145,10 @@ export default class PlaylistCreatorSteps extends React.Component {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePlaylistData = (key, value) => {
|
updateReleaseData = (key, value) => {
|
||||||
this.setState({
|
this.setState({
|
||||||
playlistData: {
|
releaseData: {
|
||||||
...this.state.playlistData,
|
...this.state.releaseData,
|
||||||
[key]: value
|
[key]: value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -161,9 +161,9 @@ export default class PlaylistCreatorSteps extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
canSubmit = () => {
|
canSubmit = () => {
|
||||||
const { playlistData, trackList, pendingTracksUpload } = this.state
|
const { releaseData, trackList, pendingTracksUpload } = this.state
|
||||||
|
|
||||||
const hasValidTitle = playlistData.title && playlistData.title.length > 0
|
const hasValidTitle = releaseData.title && releaseData.title.length > 0
|
||||||
const hasTracks = trackList.length > 0
|
const hasTracks = trackList.length > 0
|
||||||
const hasPendingUploads = pendingTracksUpload.length > 0
|
const hasPendingUploads = pendingTracksUpload.length > 0
|
||||||
const tracksHasValidData = trackList.every((track) => {
|
const tracksHasValidData = trackList.every((track) => {
|
||||||
@ -178,12 +178,12 @@ export default class PlaylistCreatorSteps extends React.Component {
|
|||||||
submitting: true
|
submitting: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const { playlistData, trackList } = this.state
|
const { releaseData: releaseData, trackList } = this.state
|
||||||
|
|
||||||
console.log(`Submitting playlist ${playlistData.title} with ${trackList.length} tracks`, playlistData, trackList)
|
console.log(`Submitting playlist ${releaseData.title} with ${trackList.length} tracks`, releaseData, trackList)
|
||||||
|
|
||||||
const result = await PlaylistModel.putPlaylist({
|
const result = await MusicModel.putRelease({
|
||||||
...playlistData,
|
...releaseData,
|
||||||
list: trackList,
|
list: trackList,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -324,19 +324,19 @@ export default class PlaylistCreatorSteps extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleDeletePlaylist = async () => {
|
handleDeletePlaylist = async () => {
|
||||||
if (!this.props.playlist_id) {
|
if (!this.props.release_id) {
|
||||||
console.error(`Cannot delete playlist without id`)
|
console.error(`Cannot delete release without id`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
antd.Modal.confirm({
|
antd.Modal.confirm({
|
||||||
title: "Are you sure you want to delete this playlist?",
|
title: "Are you sure you want to delete this release?",
|
||||||
content: "This action cannot be undone",
|
content: "This action cannot be undone",
|
||||||
okText: "Delete",
|
okText: "Delete",
|
||||||
okType: "danger",
|
okType: "danger",
|
||||||
cancelText: "Cancel",
|
cancelText: "Cancel",
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
const result = await PlaylistModel.deletePlaylist(this.props.playlist_id, {
|
const result = await MusicModel.deleteRelease(this.props.release_id, {
|
||||||
remove_with_tracks: true
|
remove_with_tracks: true
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -518,7 +518,7 @@ export default class PlaylistCreatorSteps extends React.Component {
|
|||||||
// check current step
|
// check current step
|
||||||
switch (this.state.currentStep) {
|
switch (this.state.currentStep) {
|
||||||
case 0:
|
case 0:
|
||||||
return typeof this.state.playlistData.title === "string" && this.state.playlistData.title.length > 0
|
return typeof this.state.releaseData.title === "string" && this.state.releaseData.title.length > 0
|
||||||
case 1:
|
case 1:
|
||||||
return this.canSubmit()
|
return this.canSubmit()
|
||||||
default:
|
default:
|
||||||
@ -529,8 +529,8 @@ export default class PlaylistCreatorSteps extends React.Component {
|
|||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
window._hacks = this._hacks
|
window._hacks = this._hacks
|
||||||
|
|
||||||
if (this.props.playlist_id) {
|
if (this.props.release_id) {
|
||||||
this.loadPlaylistData(this.props.playlist_id)
|
this.loadReleaseData(this.props.release_id)
|
||||||
} else {
|
} else {
|
||||||
this.setState({
|
this.setState({
|
||||||
loading: false
|
loading: false
|
||||||
@ -542,18 +542,20 @@ export default class PlaylistCreatorSteps extends React.Component {
|
|||||||
delete window._hacks
|
delete window._hacks
|
||||||
}
|
}
|
||||||
|
|
||||||
loadPlaylistData = async (playlist_id) => {
|
loadReleaseData = async (id) => {
|
||||||
console.log(`Loading playlist data for playlist ${playlist_id}...`)
|
console.log(`Loading release data for ${id}...`)
|
||||||
|
|
||||||
const playlistData = await PlaylistModel.getPlaylist(playlist_id).catch((error) => {
|
const releaseData = await MusicModel.getReleaseData(id).catch((error) => {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
antd.message.error(error)
|
antd.message.error(error)
|
||||||
|
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
if (playlistData) {
|
console.log(releaseData)
|
||||||
const trackList = playlistData.list.map((track) => {
|
|
||||||
|
if (releaseData) {
|
||||||
|
const trackList = releaseData.list.map((track) => {
|
||||||
return {
|
return {
|
||||||
...track,
|
...track,
|
||||||
_id: track._id,
|
_id: track._id,
|
||||||
@ -563,7 +565,7 @@ export default class PlaylistCreatorSteps extends React.Component {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
playlistData: playlistData,
|
releaseData: releaseData,
|
||||||
trackList: trackList,
|
trackList: trackList,
|
||||||
fileList: trackList.map((track) => {
|
fileList: trackList.map((track) => {
|
||||||
return {
|
return {
|
||||||
@ -597,23 +599,15 @@ export default class PlaylistCreatorSteps extends React.Component {
|
|||||||
<div className="stepContent">
|
<div className="stepContent">
|
||||||
{
|
{
|
||||||
React.createElement(this.steps[this.state.currentStep].crender, {
|
React.createElement(this.steps[this.state.currentStep].crender, {
|
||||||
playlist: this.state.playlistData,
|
release: this.state.releaseData,
|
||||||
|
|
||||||
trackList: this.state.trackList,
|
trackList: this.state.trackList,
|
||||||
fileList: this.state.fileList,
|
fileList: this.state.fileList,
|
||||||
|
|
||||||
onTitleChange: (title) => {
|
onValueChange: (key, value) => {
|
||||||
this.updatePlaylistData("title", title)
|
this.updateReleaseData(key, value)
|
||||||
},
|
|
||||||
onDescriptionChange: (description) => {
|
|
||||||
this.updatePlaylistData("description", description)
|
|
||||||
},
|
|
||||||
onPlaylistCoverChange: (url) => {
|
|
||||||
this.updatePlaylistData("cover", url)
|
|
||||||
},
|
|
||||||
onVisibilityChange: (visibility) => {
|
|
||||||
this.updatePlaylistData("public", visibility)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onDeletePlaylist: this.handleDeletePlaylist,
|
onDeletePlaylist: this.handleDeletePlaylist,
|
||||||
|
|
||||||
handleUploadTrack: this.handleUploadTrack,
|
handleUploadTrack: this.handleUploadTrack,
|
||||||
|
@ -15,7 +15,7 @@ export default () => {
|
|||||||
return <PagePanelWithNavMenu
|
return <PagePanelWithNavMenu
|
||||||
tabs={Tabs}
|
tabs={Tabs}
|
||||||
navMenuHeader={NavMenuHeader}
|
navMenuHeader={NavMenuHeader}
|
||||||
defaultTab="explore"
|
defaultTab="library"
|
||||||
primaryPanelClassName="full"
|
primaryPanelClassName="full"
|
||||||
useSetQueryType
|
useSetQueryType
|
||||||
transition
|
transition
|
||||||
|
@ -16,7 +16,6 @@ export default [
|
|||||||
key: "library",
|
key: "library",
|
||||||
label: "Library",
|
label: "Library",
|
||||||
icon: "MdLibraryMusic",
|
icon: "MdLibraryMusic",
|
||||||
disabled: true,
|
|
||||||
component: LibraryTab,
|
component: LibraryTab,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -1,17 +1,42 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import * as antd from "antd"
|
import * as antd from "antd"
|
||||||
|
|
||||||
import PlaylistsModel from "models/playlists"
|
import MusicModel from "models/music"
|
||||||
|
|
||||||
import PlaylistView from "components/Music/PlaylistView"
|
import PlaylistView from "components/Music/PlaylistView"
|
||||||
|
|
||||||
export default (props) => {
|
const PlayView = (props) => {
|
||||||
const play_id = props.params.play_id
|
const play_id = props.params.play_id
|
||||||
|
const service = props.query.service
|
||||||
|
|
||||||
const [playlist, setPlaylist] = React.useState(null)
|
const [playlist, setPlaylist] = React.useState(null)
|
||||||
|
const [offset, setOffset] = React.useState(0)
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async (_offset) => {
|
||||||
const response = await PlaylistsModel.getPlaylist(play_id).catch((err) => {
|
if (_offset) {
|
||||||
|
const response = await MusicModel.getPlaylistItems({
|
||||||
|
playlist_id: play_id,
|
||||||
|
service,
|
||||||
|
|
||||||
|
limit: 20,
|
||||||
|
offset: _offset,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
return setPlaylist((prev) => {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
list: [...prev.list, ...response.list],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const response = await MusicModel.getPlaylistData({
|
||||||
|
playlist_id: play_id,
|
||||||
|
service,
|
||||||
|
|
||||||
|
limit: 20,
|
||||||
|
}).catch((err) => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
app.message.error("Failed to load playlist")
|
app.message.error("Failed to load playlist")
|
||||||
return null
|
return null
|
||||||
@ -21,11 +46,20 @@ export default (props) => {
|
|||||||
setPlaylist(response)
|
setPlaylist(response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLoadMore = async () => {
|
||||||
|
setOffset((prev) => {
|
||||||
|
const newValue = prev + 20
|
||||||
|
|
||||||
|
loadData(newValue)
|
||||||
|
|
||||||
|
return newValue
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
|
|
||||||
app.layout.toggleCenteredContent(false)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (!playlist) {
|
if (!playlist) {
|
||||||
@ -35,5 +69,10 @@ export default (props) => {
|
|||||||
return <PlaylistView
|
return <PlaylistView
|
||||||
playlist={playlist}
|
playlist={playlist}
|
||||||
centered={app.isMobile}
|
centered={app.isMobile}
|
||||||
|
|
||||||
|
onLoadMore={onLoadMore}
|
||||||
|
hasMore={playlist.total_length > playlist.list.length}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default PlayView
|
@ -53,17 +53,4 @@ export default class FeedModel {
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
static getPlaylistsFeed = async ({ trim, limit } = {}) => {
|
|
||||||
const { data } = await request({
|
|
||||||
method: "GET",
|
|
||||||
url: `/feed/playlists`,
|
|
||||||
params: {
|
|
||||||
trim: trim ?? 0,
|
|
||||||
limit: limit ?? Settings.get("feed_max_fetch"),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -2,7 +2,6 @@ import AuthModel from "./auth"
|
|||||||
import FeedModel from "./feed"
|
import FeedModel from "./feed"
|
||||||
import FollowsModel from "./follows"
|
import FollowsModel from "./follows"
|
||||||
import LivestreamModel from "./livestream"
|
import LivestreamModel from "./livestream"
|
||||||
import PlaylistsModel from "./playlists"
|
|
||||||
import PostModel from "./post"
|
import PostModel from "./post"
|
||||||
import SessionModel from "./session"
|
import SessionModel from "./session"
|
||||||
import SyncModel from "./sync"
|
import SyncModel from "./sync"
|
||||||
@ -22,7 +21,6 @@ function createHandlers() {
|
|||||||
feed: getEndpointsFromModel(FeedModel),
|
feed: getEndpointsFromModel(FeedModel),
|
||||||
follows: getEndpointsFromModel(FollowsModel),
|
follows: getEndpointsFromModel(FollowsModel),
|
||||||
livestream: getEndpointsFromModel(LivestreamModel),
|
livestream: getEndpointsFromModel(LivestreamModel),
|
||||||
playlists: getEndpointsFromModel(PlaylistsModel),
|
|
||||||
post: getEndpointsFromModel(PostModel),
|
post: getEndpointsFromModel(PostModel),
|
||||||
session: getEndpointsFromModel(SessionModel),
|
session: getEndpointsFromModel(SessionModel),
|
||||||
sync: getEndpointsFromModel(SyncModel),
|
sync: getEndpointsFromModel(SyncModel),
|
||||||
@ -35,7 +33,6 @@ export {
|
|||||||
FeedModel,
|
FeedModel,
|
||||||
FollowsModel,
|
FollowsModel,
|
||||||
LivestreamModel,
|
LivestreamModel,
|
||||||
PlaylistsModel,
|
|
||||||
PostModel,
|
PostModel,
|
||||||
SessionModel,
|
SessionModel,
|
||||||
SyncModel,
|
SyncModel,
|
||||||
|
@ -7,12 +7,51 @@ export default class MusicModel {
|
|||||||
return globalThis.__comty_shared_state.instances["music"]
|
return globalThis.__comty_shared_state.instances["music"]
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Move external services fetching to API
|
/**
|
||||||
static getFavorites = async ({
|
* Retrieves track data for a given ID.
|
||||||
useTidal = false,
|
*
|
||||||
limit,
|
* @param {string} id - The ID of the track.
|
||||||
offset,
|
* @return {Promise<Object>} The track data.
|
||||||
}) => {
|
*/
|
||||||
|
static async getTrackData(id) {
|
||||||
|
const response = await request({
|
||||||
|
instance: MusicModel.api_instance,
|
||||||
|
method: "GET",
|
||||||
|
url: `/tracks/${id}/data`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves tracks data for the given track IDs.
|
||||||
|
*
|
||||||
|
* @param {Array} ids - An array of track IDs.
|
||||||
|
* @return {Promise<Object>} A promise that resolves to the tracks data.
|
||||||
|
*/
|
||||||
|
static async getTracksData(ids) {
|
||||||
|
const response = await request({
|
||||||
|
instance: MusicModel.api_instance,
|
||||||
|
method: "GET",
|
||||||
|
url: `/tracks/many`,
|
||||||
|
params: {
|
||||||
|
ids,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves favorite tracks based on specified parameters.
|
||||||
|
*
|
||||||
|
* @param {Object} options - The options for retrieving favorite tracks.
|
||||||
|
* @param {boolean} options.useTidal - Whether to use Tidal for retrieving tracks. Defaults to false.
|
||||||
|
* @param {number} options.limit - The maximum number of tracks to retrieve.
|
||||||
|
* @param {number} options.offset - The offset from which to start retrieving tracks.
|
||||||
|
* @return {Promise<Object>} - An object containing the total length of the tracks and the retrieved tracks.
|
||||||
|
*/
|
||||||
|
static async getFavoriteTracks({ useTidal = false, limit, offset }) {
|
||||||
let result = []
|
let result = []
|
||||||
|
|
||||||
let limitPerRequesters = limit
|
let limitPerRequesters = limit
|
||||||
@ -54,19 +93,19 @@ export default class MusicModel {
|
|||||||
|
|
||||||
result = await pmap(
|
result = await pmap(
|
||||||
requesters,
|
requesters,
|
||||||
async (requester) => {
|
async requester => {
|
||||||
const data = await requester()
|
const data = await requester()
|
||||||
|
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
concurrency: 3
|
concurrency: 3,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
let total_length = 0
|
let total_length = 0
|
||||||
|
|
||||||
result.forEach((result) => {
|
result.forEach(result => {
|
||||||
total_length += result.total_length
|
total_length += result.total_length
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -84,11 +123,182 @@ export default class MusicModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static search = async (keywords, {
|
/**
|
||||||
limit = 5,
|
* Retrieves favorite playlists based on the specified parameters.
|
||||||
offset = 0,
|
*
|
||||||
useTidal = false,
|
* @param {Object} options - The options for retrieving favorite playlists.
|
||||||
}) => {
|
* @param {number} options.limit - The maximum number of playlists to retrieve. Default is 50.
|
||||||
|
* @param {number} options.offset - The offset of playlists to retrieve. Default is 0.
|
||||||
|
* @param {Object} options.services - The services to include for retrieving playlists. Default is an empty object.
|
||||||
|
* @param {string} options.keywords - The keywords to filter playlists by.
|
||||||
|
* @return {Promise<Object>} - An object containing the total length of the playlists and the playlist items.
|
||||||
|
*/
|
||||||
|
static async getFavoritePlaylists({ limit = 50, offset = 0, services = {}, keywords } = {}) {
|
||||||
|
let result = []
|
||||||
|
|
||||||
|
let limitPerRequesters = limit
|
||||||
|
|
||||||
|
const requesters = [
|
||||||
|
async () => {
|
||||||
|
const { data } = await request({
|
||||||
|
instance: MusicModel.api_instance,
|
||||||
|
method: "GET",
|
||||||
|
url: `/playlists/self`,
|
||||||
|
params: {
|
||||||
|
keywords,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (services["tidal"] === true) {
|
||||||
|
limitPerRequesters = limitPerRequesters / (requesters.length + 1)
|
||||||
|
|
||||||
|
requesters.push(async () => {
|
||||||
|
const _result = await SyncModel.tidalCore.getMyFavoritePlaylists({
|
||||||
|
limit: limitPerRequesters,
|
||||||
|
offset,
|
||||||
|
})
|
||||||
|
|
||||||
|
return _result
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await pmap(
|
||||||
|
requesters,
|
||||||
|
async requester => {
|
||||||
|
const data = await requester()
|
||||||
|
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
{
|
||||||
|
concurrency: 3,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// calculate total length
|
||||||
|
let total_length = 0
|
||||||
|
|
||||||
|
result.forEach(result => {
|
||||||
|
total_length += result.total_length
|
||||||
|
})
|
||||||
|
|
||||||
|
// reduce items
|
||||||
|
let items = result.reduce((acc, cur) => {
|
||||||
|
return [...acc, ...cur.items]
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
|
// sort by created_at
|
||||||
|
items = items.sort((a, b) => {
|
||||||
|
return new Date(b.created_at) - new Date(a.created_at)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_length: total_length,
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves playlist items based on the provided parameters.
|
||||||
|
*
|
||||||
|
* @param {Object} options - The options object.
|
||||||
|
* @param {string} options.playlist_id - The ID of the playlist.
|
||||||
|
* @param {string} options.service - The service from which to retrieve the playlist items.
|
||||||
|
* @param {number} options.limit - The maximum number of items to retrieve.
|
||||||
|
* @param {number} options.offset - The number of items to skip before retrieving.
|
||||||
|
* @return {Promise<Object>} Playlist items data.
|
||||||
|
*/
|
||||||
|
static async getPlaylistItems({
|
||||||
|
playlist_id,
|
||||||
|
service,
|
||||||
|
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
}) {
|
||||||
|
if (service === "tidal") {
|
||||||
|
const result = await SyncModel.tidalCore.getPlaylistItems({
|
||||||
|
playlist_id,
|
||||||
|
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
|
||||||
|
resolve_items: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await request({
|
||||||
|
instance: MusicModel.api_instance,
|
||||||
|
method: "GET",
|
||||||
|
url: `/playlists/${playlist_id}/items`,
|
||||||
|
params: {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves playlist data based on the provided parameters.
|
||||||
|
*
|
||||||
|
* @param {Object} options - The options object.
|
||||||
|
* @param {string} options.playlist_id - The ID of the playlist.
|
||||||
|
* @param {string} options.service - The service to use.
|
||||||
|
* @param {number} options.limit - The maximum number of items to retrieve.
|
||||||
|
* @param {number} options.offset - The offset for pagination.
|
||||||
|
* @return {Promise<Object>} Playlist data.
|
||||||
|
*/
|
||||||
|
static async getPlaylistData({
|
||||||
|
playlist_id,
|
||||||
|
service,
|
||||||
|
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
}) {
|
||||||
|
if (service === "tidal") {
|
||||||
|
const result = await SyncModel.tidalCore.getPlaylistData({
|
||||||
|
playlist_id,
|
||||||
|
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
|
||||||
|
resolve_items: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await request({
|
||||||
|
instance: MusicModel.api_instance,
|
||||||
|
method: "GET",
|
||||||
|
url: `/playlists/${playlist_id}/data`,
|
||||||
|
params: {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs a search based on the provided keywords, with optional parameters for limiting the number of results and pagination.
|
||||||
|
*
|
||||||
|
* @param {string} keywords - The keywords to search for.
|
||||||
|
* @param {object} options - An optional object containing additional parameters.
|
||||||
|
* @param {number} options.limit - The maximum number of results to return. Defaults to 5.
|
||||||
|
* @param {number} options.offset - The offset to start returning results from. Defaults to 0.
|
||||||
|
* @param {boolean} options.useTidal - Whether to use Tidal for the search. Defaults to false.
|
||||||
|
* @return {Promise<Object>} The search results.
|
||||||
|
*/
|
||||||
|
static async search(keywords, { limit = 5, offset = 0, useTidal = false }) {
|
||||||
const { data } = await request({
|
const { data } = await request({
|
||||||
instance: MusicModel.api_instance,
|
instance: MusicModel.api_instance,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@ -98,9 +308,219 @@ export default class MusicModel {
|
|||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
useTidal,
|
useTidal,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new playlist.
|
||||||
|
*
|
||||||
|
* @param {object} payload - The payload containing the data for the new playlist.
|
||||||
|
* @return {Promise<Object>} The new playlist data.
|
||||||
|
*/
|
||||||
|
static async newPlaylist(payload) {
|
||||||
|
const { data } = await request({
|
||||||
|
instance: MusicModel.api_instance,
|
||||||
|
method: "POST",
|
||||||
|
url: `/playlists/new`,
|
||||||
|
data: payload,
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a playlist item in the specified playlist.
|
||||||
|
*
|
||||||
|
* @param {string} playlist_id - The ID of the playlist to update.
|
||||||
|
* @param {object} item - The updated playlist item to be added.
|
||||||
|
* @return {Promise<Object>} - The updated playlist item.
|
||||||
|
*/
|
||||||
|
static async putPlaylistItem(playlist_id, item) {
|
||||||
|
const response = await request({
|
||||||
|
instance: MusicModel.api_instance,
|
||||||
|
method: "PUT",
|
||||||
|
url: `/playlists/${playlist_id}/items`,
|
||||||
|
data: item,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a playlist item.
|
||||||
|
*
|
||||||
|
* @param {string} playlist_id - The ID of the playlist.
|
||||||
|
* @param {string} item_id - The ID of the item to delete.
|
||||||
|
* @return {Promise<Object>} The data returned by the server after the item is deleted.
|
||||||
|
*/
|
||||||
|
static async deletePlaylistItem(playlist_id, item_id) {
|
||||||
|
const response = await request({
|
||||||
|
instance: MusicModel.api_instance,
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/playlists/${playlist_id}/items/${item_id}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a playlist.
|
||||||
|
*
|
||||||
|
* @param {number} playlist_id - The ID of the playlist to be deleted.
|
||||||
|
* @return {Promise<Object>} The response data from the server.
|
||||||
|
*/
|
||||||
|
static async deletePlaylist(playlist_id) {
|
||||||
|
const response = await request({
|
||||||
|
instance: MusicModel.api_instance,
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/playlists/${playlist_id}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a PUT request to update or create a release.
|
||||||
|
*
|
||||||
|
* @param {object} payload - The payload data.
|
||||||
|
* @return {Promise<Object>} The response data from the server.
|
||||||
|
*/
|
||||||
|
static async putRelease(payload) {
|
||||||
|
const response = await request({
|
||||||
|
instance: MusicModel.api_instance,
|
||||||
|
method: "PUT",
|
||||||
|
url: `/releases/release`,
|
||||||
|
data: payload
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the releases associated with the authenticated user.
|
||||||
|
*
|
||||||
|
* @param {string} keywords - The keywords to filter the releases by.
|
||||||
|
* @return {Promise<Object>} A promise that resolves to the data of the releases.
|
||||||
|
*/
|
||||||
|
static async getMyReleases(keywords) {
|
||||||
|
const response = await request({
|
||||||
|
instance: MusicModel.api_instance,
|
||||||
|
method: "GET",
|
||||||
|
url: `/releases/self`,
|
||||||
|
params: {
|
||||||
|
keywords,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves releases based on the provided parameters.
|
||||||
|
*
|
||||||
|
* @param {object} options - The options for retrieving releases.
|
||||||
|
* @param {string} options.user_id - The ID of the user.
|
||||||
|
* @param {string[]} options.keywords - The keywords to filter releases by.
|
||||||
|
* @param {number} options.limit - The maximum number of releases to retrieve.
|
||||||
|
* @param {number} options.offset - The offset for paginated results.
|
||||||
|
* @return {Promise<Object>} - A promise that resolves to the retrieved releases.
|
||||||
|
*/
|
||||||
|
static async getReleases({
|
||||||
|
user_id,
|
||||||
|
keywords,
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
}) {
|
||||||
|
const response = await request({
|
||||||
|
instance: MusicModel.api_instance,
|
||||||
|
method: "GET",
|
||||||
|
url: `/releases/user/${user_id}`,
|
||||||
|
params: {
|
||||||
|
keywords,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves release data by ID.
|
||||||
|
*
|
||||||
|
* @param {number} id - The ID of the release.
|
||||||
|
* @return {Promise<Object>} The release data.
|
||||||
|
*/
|
||||||
|
static async getReleaseData(id) {
|
||||||
|
const response = await request({
|
||||||
|
instance: MusicModel.api_instance,
|
||||||
|
method: "GET",
|
||||||
|
url: `/releases/${id}/data`
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a release by its ID.
|
||||||
|
*
|
||||||
|
* @param {string} id - The ID of the release to delete.
|
||||||
|
* @return {Promise<Object>} - A Promise that resolves to the data returned by the API.
|
||||||
|
*/
|
||||||
|
static async deleteRelease(id) {
|
||||||
|
const response = await request({
|
||||||
|
instance: MusicModel.api_instance,
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/releases/${id}`
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes the track cache for a given track ID.
|
||||||
|
*
|
||||||
|
* @param {string} track_id - The ID of the track to refresh the cache for.
|
||||||
|
* @throws {Error} If track_id is not provided.
|
||||||
|
* @return {Promise<Object>} The response data from the API call.
|
||||||
|
*/
|
||||||
|
static async refreshTrackCache(track_id) {
|
||||||
|
if (!track_id) {
|
||||||
|
throw new Error("Track ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request({
|
||||||
|
instance: MusicModel.api_instance,
|
||||||
|
method: "POST",
|
||||||
|
url: `/tracks/${track_id}/refresh-cache`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles the like status of a track.
|
||||||
|
*
|
||||||
|
* @param {number} track_id - The ID of the track.
|
||||||
|
* @throws {Error} If track_id is not provided.
|
||||||
|
* @return {Promise<Object>} The response data.
|
||||||
|
*/
|
||||||
|
static async toggleTrackLike(track_id) {
|
||||||
|
if (!track_id) {
|
||||||
|
throw new Error("Track ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request({
|
||||||
|
instance: MusicModel.api_instance,
|
||||||
|
method: "POST",
|
||||||
|
url: `/tracks/${track_id}/toggle-like`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,124 +0,0 @@
|
|||||||
import request from "../../handlers/request"
|
|
||||||
|
|
||||||
export default class PlaylistsModel {
|
|
||||||
static get api_instance() {
|
|
||||||
return globalThis.__comty_shared_state.instances["music"]
|
|
||||||
}
|
|
||||||
|
|
||||||
static refreshTrackCache = async (track_id) => {
|
|
||||||
if (!track_id) {
|
|
||||||
throw new Error("Track ID is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data } = await request({
|
|
||||||
instance: PlaylistsModel.api_instance,
|
|
||||||
method: "POST",
|
|
||||||
url: `/tracks/${track_id}/refresh-cache`,
|
|
||||||
})
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
static putPlaylist = async (payload) => {
|
|
||||||
if (!payload) {
|
|
||||||
throw new Error("Payload is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data } = await request({
|
|
||||||
instance: PlaylistsModel.api_instance,
|
|
||||||
method: "PUT",
|
|
||||||
url: `/playlists/playlist`,
|
|
||||||
data: payload,
|
|
||||||
})
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
static deletePlaylist = async (id, options = {}) => {
|
|
||||||
if (!id) {
|
|
||||||
throw new Error("ID is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data } = await request({
|
|
||||||
instance: PlaylistsModel.api_instance,
|
|
||||||
method: "DELETE",
|
|
||||||
url: `/playlists/${id}`,
|
|
||||||
params: options,
|
|
||||||
})
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
static getTrack = async (id) => {
|
|
||||||
const { data } = await request({
|
|
||||||
instance: PlaylistsModel.api_instance,
|
|
||||||
method: "GET",
|
|
||||||
url: `/tracks/${id}/data`,
|
|
||||||
})
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
static getTracks = async (ids) => {
|
|
||||||
const { data } = await request({
|
|
||||||
instance: PlaylistsModel.api_instance,
|
|
||||||
method: "GET",
|
|
||||||
url: `/tracks/many`,
|
|
||||||
params: {
|
|
||||||
ids,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
static getPlaylist = async (id) => {
|
|
||||||
const { data } = await request({
|
|
||||||
instance: PlaylistsModel.api_instance,
|
|
||||||
method: "GET",
|
|
||||||
url: `/playlists/${id}/data`,
|
|
||||||
})
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
static search = async (keywords) => {
|
|
||||||
const { data } = await request({
|
|
||||||
instance: PlaylistsModel.api_instance,
|
|
||||||
method: "GET",
|
|
||||||
url: `/playlists/search`,
|
|
||||||
params: {
|
|
||||||
keywords,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
static getMyReleases = async (keywords) => {
|
|
||||||
const { data } = await request({
|
|
||||||
instance: PlaylistsModel.api_instance,
|
|
||||||
method: "GET",
|
|
||||||
url: `/playlists/self`,
|
|
||||||
params: {
|
|
||||||
keywords,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
static toggleTrackLike = async (track_id) => {
|
|
||||||
if (!track_id) {
|
|
||||||
throw new Error("Track ID is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data } = await request({
|
|
||||||
instance: PlaylistsModel.api_instance,
|
|
||||||
method: "POST",
|
|
||||||
url: `/tracks/${track_id}/toggle-like`,
|
|
||||||
})
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
}
|
|
@ -97,4 +97,63 @@ export default class TidalService {
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async getMyFavoritePlaylists({
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
} = {}) {
|
||||||
|
const { data } = await request({
|
||||||
|
instance: TidalService.api_instance,
|
||||||
|
method: "GET",
|
||||||
|
url: `/services/tidal/favorites/playlists`,
|
||||||
|
params: {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getPlaylistData({
|
||||||
|
playlist_id,
|
||||||
|
|
||||||
|
resolve_items = false,
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
}) {
|
||||||
|
const { data } = await request({
|
||||||
|
instance: TidalService.api_instance,
|
||||||
|
method: "GET",
|
||||||
|
url: `/services/tidal/playlist/${playlist_id}/data`,
|
||||||
|
params: {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
resolve_items,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getPlaylistItems({
|
||||||
|
playlist_id,
|
||||||
|
|
||||||
|
resolve_items = false,
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
}) {
|
||||||
|
const { data } = await request({
|
||||||
|
instance: TidalService.api_instance,
|
||||||
|
method: "GET",
|
||||||
|
url: `/services/tidal/playlist/${playlist_id}/items`,
|
||||||
|
params: {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
resolve_items,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
}
|
}
|
@ -9,7 +9,7 @@ export default async (req, res) => {
|
|||||||
|
|
||||||
let removedTracksIds = []
|
let removedTracksIds = []
|
||||||
|
|
||||||
const removeWithTracks = req.query.remove_with_tracks === "true"
|
// const removeWithTracks = req.query.remove_with_tracks === "true"
|
||||||
|
|
||||||
let playlist = await Playlist.findOne({
|
let playlist = await Playlist.findOne({
|
||||||
_id: req.params.playlist_id,
|
_id: req.params.playlist_id,
|
||||||
@ -29,9 +29,9 @@ export default async (req, res) => {
|
|||||||
_id: req.params.playlist_id,
|
_id: req.params.playlist_id,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (removeWithTracks) {
|
// if (removeWithTracks) {
|
||||||
removedTracksIds = await RemoveTracks(playlist.list)
|
// removedTracksIds = await RemoveTracks(playlist.list)
|
||||||
}
|
// }
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { Playlist, TrackLike, Track } from "@shared-classes/DbModels"
|
import { Playlist, Release, TrackLike, Track } from "@shared-classes/DbModels"
|
||||||
import { NotFoundError } from "@shared-classes/Errors"
|
import { NotFoundError } from "@shared-classes/Errors"
|
||||||
|
|
||||||
export default async (req, res) => {
|
export default async (req, res) => {
|
||||||
const { playlist_id } = req.params
|
const { playlist_id } = req.params
|
||||||
|
const { limit, offset } = req.query
|
||||||
|
|
||||||
let playlist = await Playlist.findOne({
|
let playlist = await Playlist.findOne({
|
||||||
_id: playlist_id,
|
_id: playlist_id,
|
||||||
@ -10,6 +11,14 @@ export default async (req, res) => {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!playlist) {
|
||||||
|
playlist = await Release.findOne({
|
||||||
|
_id: playlist_id,
|
||||||
|
}).catch((err) => {
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
playlist = playlist.toObject()
|
playlist = playlist.toObject()
|
||||||
|
|
||||||
if (playlist.public === false) {
|
if (playlist.public === false) {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Playlist, Track } from "@shared-classes/DbModels"
|
import { Playlist, Release, Track } from "@shared-classes/DbModels"
|
||||||
import { AuthorizationError, NotFoundError } from "@shared-classes/Errors"
|
import { AuthorizationError, NotFoundError } from "@shared-classes/Errors"
|
||||||
|
|
||||||
export default async (req, res) => {
|
export default async (req, res) => {
|
||||||
@ -7,6 +7,7 @@ export default async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { keywords, limit = 10, offset = 0 } = req.query
|
const { keywords, limit = 10, offset = 0 } = req.query
|
||||||
|
|
||||||
const user_id = req.session.user_id.toString()
|
const user_id = req.session.user_id.toString()
|
||||||
|
|
||||||
let searchQuery = {
|
let searchQuery = {
|
||||||
@ -23,25 +24,45 @@ export default async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const playlistsCount = await Playlist.count(searchQuery)
|
||||||
|
const releasesCount = await Release.count(searchQuery)
|
||||||
|
|
||||||
|
let total_length = playlistsCount + releasesCount
|
||||||
|
|
||||||
let playlists = await Playlist.find(searchQuery)
|
let playlists = await Playlist.find(searchQuery)
|
||||||
.sort({ created_at: -1 })
|
.sort({ created_at: -1 })
|
||||||
.catch((err) => false)
|
.limit(limit)
|
||||||
//.limit(limit)
|
.skip(offset)
|
||||||
//.skip(offset)
|
|
||||||
|
|
||||||
if (!playlists) {
|
playlists = playlists.map((playlist) => {
|
||||||
return new NotFoundError("Playlists not found")
|
playlist = playlist.toObject()
|
||||||
}
|
|
||||||
|
|
||||||
playlists = await Promise.all(playlists.map(async (playlist) => {
|
playlist.type = "playlist"
|
||||||
|
|
||||||
|
return playlist
|
||||||
|
})
|
||||||
|
|
||||||
|
let releases = await Release.find(searchQuery)
|
||||||
|
.sort({ created_at: -1 })
|
||||||
|
.limit(limit)
|
||||||
|
.skip(offset)
|
||||||
|
|
||||||
|
let result = [...playlists, ...releases]
|
||||||
|
|
||||||
|
if (req.query.resolveItemsData === "true") {
|
||||||
|
result = await Promise.all(
|
||||||
|
playlists.map(async playlist => {
|
||||||
playlist.list = await Track.find({
|
playlist.list = await Track.find({
|
||||||
_id: [
|
_id: [...playlist.list],
|
||||||
...playlist.list,
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return playlist
|
return playlist
|
||||||
}))
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return res.json(playlists)
|
return res.json({
|
||||||
|
total_length: total_length,
|
||||||
|
items: result,
|
||||||
|
})
|
||||||
}
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
import { Playlist } from "@shared-classes/DbModels"
|
||||||
|
import { AuthorizationError } from "@shared-classes/Errors"
|
||||||
|
|
||||||
|
export default async (req, res) => {
|
||||||
|
if (!req.session) {
|
||||||
|
return new AuthorizationError(req, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = await global.comty.rest.user.data({
|
||||||
|
user_id: req.session.user_id.toString(),
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log("err", err)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!userData) {
|
||||||
|
return new AuthorizationError(req, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
let playlist = await Playlist.findOne({
|
||||||
|
title: req.body.title,
|
||||||
|
user_id: req.session.user_id.toString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (playlist) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: "Playlist already exists",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
playlist = new Playlist({
|
||||||
|
user_id: req.session.user_id.toString(),
|
||||||
|
created_at: Date.now(),
|
||||||
|
title: req.body.title ?? "Untitled",
|
||||||
|
description: req.body.description,
|
||||||
|
cover: req.body.cover,
|
||||||
|
explicit: req.body.explicit,
|
||||||
|
public: req.body.public,
|
||||||
|
list: req.body.list ?? [],
|
||||||
|
public: req.body.public,
|
||||||
|
})
|
||||||
|
|
||||||
|
await playlist.save()
|
||||||
|
|
||||||
|
return res.json(playlist)
|
||||||
|
}
|
21
packages/music_server/src/controllers/releases/index.js
Normal file
21
packages/music_server/src/controllers/releases/index.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import path from "path"
|
||||||
|
import createRoutesFromDirectory from "@utils/createRoutesFromDirectory"
|
||||||
|
import getMiddlewares from "@utils/getMiddlewares"
|
||||||
|
|
||||||
|
export default async (router) => {
|
||||||
|
// create a file based router
|
||||||
|
const routesPath = path.resolve(__dirname, "routes")
|
||||||
|
|
||||||
|
const middlewares = await getMiddlewares(["withOptionalAuth"])
|
||||||
|
|
||||||
|
for (const middleware of middlewares) {
|
||||||
|
router.use(middleware)
|
||||||
|
}
|
||||||
|
|
||||||
|
router = createRoutesFromDirectory("routes", routesPath, router)
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: "/releases",
|
||||||
|
router,
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
import { Release } from "@shared-classes/DbModels"
|
||||||
|
import { AuthorizationError, PermissionError, NotFoundError } from "@shared-classes/Errors"
|
||||||
|
import RemoveTracks from "@services/removeTracks"
|
||||||
|
|
||||||
|
export default async (req, res) => {
|
||||||
|
if (!req.session) {
|
||||||
|
return new AuthorizationError(req, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
let removedTracksIds = []
|
||||||
|
|
||||||
|
const removeWithTracks = req.query.remove_with_tracks === "true"
|
||||||
|
|
||||||
|
let release = await Release.findOne({
|
||||||
|
_id: req.params.release_id,
|
||||||
|
}).catch((err) => {
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!release) {
|
||||||
|
return new NotFoundError(req, res, "Release not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (release.user_id !== req.session.user_id.toString()) {
|
||||||
|
return new PermissionError(req, res, "You don't have permission to edit this release")
|
||||||
|
}
|
||||||
|
|
||||||
|
await Release.deleteOne({
|
||||||
|
_id: req.params.release_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (removeWithTracks) {
|
||||||
|
removedTracksIds = await RemoveTracks(release.list)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
removedTracksIds,
|
||||||
|
})
|
||||||
|
}
|
@ -0,0 +1,57 @@
|
|||||||
|
import { Release, TrackLike, Track } from "@shared-classes/DbModels"
|
||||||
|
import { NotFoundError } from "@shared-classes/Errors"
|
||||||
|
|
||||||
|
export default async (req, res) => {
|
||||||
|
const { release_id } = req.params
|
||||||
|
const { limit, offset } = req.query
|
||||||
|
|
||||||
|
let release = await Release.findOne({
|
||||||
|
_id: release_id,
|
||||||
|
}).catch((err) => {
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
release = release.toObject()
|
||||||
|
|
||||||
|
if (release.public === false) {
|
||||||
|
if (req.session) {
|
||||||
|
if (req.session.user_id !== release.user_id) {
|
||||||
|
release = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
release = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!release) {
|
||||||
|
return new NotFoundError(req, res, "Release not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedIds = release.list
|
||||||
|
|
||||||
|
release.list = await Track.find({
|
||||||
|
_id: [...release.list],
|
||||||
|
public: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
release.list = release.list.sort((a, b) => {
|
||||||
|
return orderedIds.findIndex((id) => id === a._id.toString()) - orderedIds.findIndex((id) => id === b._id.toString())
|
||||||
|
})
|
||||||
|
|
||||||
|
if (req.session) {
|
||||||
|
const likes = await TrackLike.find({
|
||||||
|
user_id: req.session.user_id,
|
||||||
|
track_id: [...release.list.map((track) => track._id.toString())],
|
||||||
|
})
|
||||||
|
|
||||||
|
release.list = release.list.map((track) => {
|
||||||
|
track = track.toObject()
|
||||||
|
|
||||||
|
track.liked = likes.findIndex((like) => like.track_id === track._id.toString()) !== -1
|
||||||
|
|
||||||
|
return track
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(release)
|
||||||
|
}
|
@ -0,0 +1,54 @@
|
|||||||
|
import { Release, Track } from "@shared-classes/DbModels"
|
||||||
|
import { AuthorizationError, NotFoundError } from "@shared-classes/Errors"
|
||||||
|
|
||||||
|
export default async (req, res) => {
|
||||||
|
if (!req.session) {
|
||||||
|
return new AuthorizationError(req, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { keywords, limit = 10, offset = 0 } = req.query
|
||||||
|
|
||||||
|
const user_id = req.session.user_id.toString()
|
||||||
|
|
||||||
|
let searchQuery = {
|
||||||
|
user_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keywords) {
|
||||||
|
searchQuery = {
|
||||||
|
...searchQuery,
|
||||||
|
title: {
|
||||||
|
$regex: keywords,
|
||||||
|
$options: "i",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const total_length = await Release.count(searchQuery)
|
||||||
|
|
||||||
|
let releases = await Release.find(searchQuery)
|
||||||
|
.sort({ created_at: -1 })
|
||||||
|
.limit(limit)
|
||||||
|
.skip(offset)
|
||||||
|
|
||||||
|
if (!releases) {
|
||||||
|
return new NotFoundError("Releases not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.query.resolveItemsData === "true") {
|
||||||
|
releases = await Promise.all(
|
||||||
|
releases.map(async (release) => {
|
||||||
|
release.list = await Track.find({
|
||||||
|
_id: [...release.list],
|
||||||
|
})
|
||||||
|
|
||||||
|
return release
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
total_length: total_length,
|
||||||
|
items: releases,
|
||||||
|
})
|
||||||
|
}
|
@ -0,0 +1,27 @@
|
|||||||
|
import { Release } from "@shared-classes/DbModels"
|
||||||
|
|
||||||
|
export default async (req, res) => {
|
||||||
|
if (!req.session) {
|
||||||
|
return new AuthorizationError(req, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user_id } = req.params
|
||||||
|
const { keywords, limit = 10, offset = 0 } = req.query
|
||||||
|
|
||||||
|
const total_length = await Release.count({
|
||||||
|
user_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
let releases = await Release.find({
|
||||||
|
user_id,
|
||||||
|
public: true,
|
||||||
|
})
|
||||||
|
.limit(limit)
|
||||||
|
.skip(offset)
|
||||||
|
.sort({ created_at: -1 })
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
total_length,
|
||||||
|
items: releases
|
||||||
|
})
|
||||||
|
}
|
@ -1,13 +1,13 @@
|
|||||||
import { Playlist, Track } from "@shared-classes/DbModels"
|
import { Release, Track } from "@shared-classes/DbModels"
|
||||||
import { AuthorizationError, NotFoundError, PermissionError, BadRequestError } from "@shared-classes/Errors"
|
import { AuthorizationError, NotFoundError, PermissionError, BadRequestError } from "@shared-classes/Errors"
|
||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
|
|
||||||
const PlaylistAllowedUpdateFields = [
|
const AllowedUpdateFields = [
|
||||||
"title",
|
"title",
|
||||||
"cover",
|
"cover",
|
||||||
"album",
|
"album",
|
||||||
"artist",
|
"artist",
|
||||||
"description",
|
"type",
|
||||||
"public",
|
"public",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -112,44 +112,44 @@ export default async (req, res) => {
|
|||||||
return new AuthorizationError(req, res)
|
return new AuthorizationError(req, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
let playlist = null
|
let release = null
|
||||||
|
|
||||||
if (!req.body._id) {
|
if (!req.body._id) {
|
||||||
playlist = new Playlist({
|
release = new Release({
|
||||||
user_id: req.session.user_id.toString(),
|
user_id: req.session.user_id.toString(),
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
title: req.body.title ?? "Untitled",
|
title: req.body.title ?? "Untitled",
|
||||||
description: req.body.description,
|
|
||||||
cover: req.body.cover,
|
cover: req.body.cover,
|
||||||
explicit: req.body.explicit,
|
explicit: req.body.explicit,
|
||||||
|
type: req.body.type,
|
||||||
public: req.body.public,
|
public: req.body.public,
|
||||||
list: req.body.list,
|
list: req.body.list,
|
||||||
public: req.body.public,
|
public: req.body.public,
|
||||||
})
|
})
|
||||||
|
|
||||||
await playlist.save()
|
await release.save()
|
||||||
} else {
|
} else {
|
||||||
playlist = await Playlist.findById(req.body._id)
|
release = await Release.findById(req.body._id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!playlist) {
|
if (!release) {
|
||||||
return new NotFoundError(req, res, "Playlist not found")
|
return new NotFoundError(req, res, "Release not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playlist.user_id !== req.session.user_id.toString()) {
|
if (release.user_id !== req.session.user_id.toString()) {
|
||||||
return new PermissionError(req, res, "You don't have permission to edit this playlist")
|
return new PermissionError(req, res, "You don't have permission to edit this release")
|
||||||
}
|
}
|
||||||
|
|
||||||
playlist = playlist.toObject()
|
release = release.toObject()
|
||||||
|
|
||||||
playlist.publisher = {
|
release.publisher = {
|
||||||
user_id: req.session.user_id.toString(),
|
user_id: req.session.user_id.toString(),
|
||||||
fullName: userData.fullName,
|
fullName: userData.fullName,
|
||||||
username: userData.username,
|
username: userData.username,
|
||||||
avatar: userData.avatar,
|
avatar: userData.avatar,
|
||||||
}
|
}
|
||||||
|
|
||||||
playlist.list = await Promise.all(req.body.list.map(async (track, index) => {
|
release.list = await Promise.all(req.body.list.map(async (track, index) => {
|
||||||
if (typeof track !== "object") {
|
if (typeof track !== "object") {
|
||||||
return track
|
return track
|
||||||
}
|
}
|
||||||
@ -168,19 +168,19 @@ export default async (req, res) => {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
PlaylistAllowedUpdateFields.forEach((field) => {
|
AllowedUpdateFields.forEach((field) => {
|
||||||
if (typeof req.body[field] !== "undefined") {
|
if (typeof req.body[field] !== "undefined") {
|
||||||
playlist[field] = req.body[field]
|
release[field] = req.body[field]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
playlist = await Playlist.findByIdAndUpdate(playlist._id.toString(), playlist)
|
release = await Release.findByIdAndUpdate(release._id.toString(), release)
|
||||||
|
|
||||||
if (!playlist) {
|
if (!release) {
|
||||||
return new NotFoundError(req, res, "Playlist not updated")
|
return new NotFoundError(req, res, "Release not updated")
|
||||||
}
|
}
|
||||||
|
|
||||||
global.eventBus.emit(`playlist.${playlist._id}.updated`, playlist)
|
global.eventBus.emit(`release.${release._id}.updated`, release)
|
||||||
|
|
||||||
return res.json(playlist)
|
return res.json(release)
|
||||||
}
|
}
|
@ -1,7 +1,8 @@
|
|||||||
import { Playlist, Track } from "@shared-classes/DbModels"
|
import { Release, Playlist, Track } from "@shared-classes/DbModels"
|
||||||
import TidalAPI from "@shared-classes/TidalAPI"
|
import TidalAPI from "@shared-classes/TidalAPI"
|
||||||
|
|
||||||
async function searchRoute(req, res) {
|
async function searchRoute(req, res) {
|
||||||
|
try {
|
||||||
const {
|
const {
|
||||||
keywords,
|
keywords,
|
||||||
limit = 5,
|
limit = 5,
|
||||||
@ -12,8 +13,10 @@ async function searchRoute(req, res) {
|
|||||||
let results = {
|
let results = {
|
||||||
playlists: [],
|
playlists: [],
|
||||||
artists: [],
|
artists: [],
|
||||||
albums: [],
|
|
||||||
tracks: [],
|
tracks: [],
|
||||||
|
album: [],
|
||||||
|
ep: [],
|
||||||
|
single: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
let searchQuery = {
|
let searchQuery = {
|
||||||
@ -31,6 +34,16 @@ async function searchRoute(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let releases = await Release.find(searchQuery)
|
||||||
|
.limit(limit)
|
||||||
|
.skip(offset)
|
||||||
|
|
||||||
|
if (releases && releases.length > 0) {
|
||||||
|
releases.forEach((release) => {
|
||||||
|
results[release.type].push(release)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let playlists = await Playlist.find(searchQuery)
|
let playlists = await Playlist.find(searchQuery)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.skip(offset)
|
.skip(offset)
|
||||||
@ -56,6 +69,11 @@ async function searchRoute(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return res.json(results)
|
return res.json(results)
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: error.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (router) => {
|
export default (router) => {
|
||||||
|
@ -1,8 +1,12 @@
|
|||||||
import { Controller } from "linebridge/dist/server"
|
import { Controller } from "linebridge/dist/server"
|
||||||
|
|
||||||
|
import pmap from "p-map"
|
||||||
|
|
||||||
import getPosts from "./services/getPosts"
|
import getPosts from "./services/getPosts"
|
||||||
|
|
||||||
|
import getGlobalReleases from "./services/getGlobalReleases"
|
||||||
|
import getReleasesFromFollowing from "./services/getReleasesFromFollowing"
|
||||||
import getPlaylistsFromFollowing from "./services/getPlaylistsFromFollowing"
|
import getPlaylistsFromFollowing from "./services/getPlaylistsFromFollowing"
|
||||||
import getPlaylistsFromGlobal from "./services/getPlaylistsFromGlobal"
|
|
||||||
|
|
||||||
export default class FeedController extends Controller {
|
export default class FeedController extends Controller {
|
||||||
static refName = "FeedController"
|
static refName = "FeedController"
|
||||||
@ -28,13 +32,6 @@ export default class FeedController extends Controller {
|
|||||||
skip: req.query?.trim,
|
skip: req.query?.trim,
|
||||||
})
|
})
|
||||||
|
|
||||||
// fetch playlists
|
|
||||||
let playlists = await getPlaylistsFromFollowing({
|
|
||||||
for_user_id,
|
|
||||||
limit: req.query?.limit,
|
|
||||||
skip: req.query?.trim,
|
|
||||||
})
|
|
||||||
|
|
||||||
// add type to posts and playlists
|
// add type to posts and playlists
|
||||||
posts = posts.map((data) => {
|
posts = posts.map((data) => {
|
||||||
data.type = "post"
|
data.type = "post"
|
||||||
@ -42,15 +39,8 @@ export default class FeedController extends Controller {
|
|||||||
return data
|
return data
|
||||||
})
|
})
|
||||||
|
|
||||||
playlists = playlists.map((data) => {
|
|
||||||
data.type = "playlist"
|
|
||||||
|
|
||||||
return data
|
|
||||||
})
|
|
||||||
|
|
||||||
let feed = [
|
let feed = [
|
||||||
...posts,
|
...posts,
|
||||||
...playlists,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// sort feed
|
// sort feed
|
||||||
@ -73,7 +63,7 @@ export default class FeedController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fetch playlists from global
|
// fetch playlists from global
|
||||||
const result = await getPlaylistsFromGlobal({
|
const result = await getGlobalReleases({
|
||||||
for_user_id,
|
for_user_id,
|
||||||
limit: req.query?.limit,
|
limit: req.query?.limit,
|
||||||
skip: req.query?.trim,
|
skip: req.query?.trim,
|
||||||
@ -93,30 +83,31 @@ export default class FeedController extends Controller {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let feed = {
|
const searchers = [
|
||||||
followingArtists: [],
|
getGlobalReleases,
|
||||||
global: [],
|
//getReleasesFromFollowing,
|
||||||
mayLike: [],
|
//getPlaylistsFromFollowing,
|
||||||
}
|
]
|
||||||
|
|
||||||
// fetch playlists from following
|
let result = await pmap(
|
||||||
const followingArtistsPlaylists = await getPlaylistsFromFollowing({
|
searchers,
|
||||||
|
async (fn, index) => {
|
||||||
|
const data = await fn({
|
||||||
for_user_id,
|
for_user_id,
|
||||||
limit: req.query?.limit,
|
limit: req.query?.limit,
|
||||||
skip: req.query?.trim,
|
skip: req.query?.trim,
|
||||||
})
|
})
|
||||||
|
|
||||||
// fetch playlists from global
|
return data
|
||||||
const globalPlaylists = await getPlaylistsFromGlobal({
|
}, {
|
||||||
for_user_id,
|
concurrency: 3,
|
||||||
limit: req.query?.limit,
|
},)
|
||||||
skip: req.query?.trim,
|
|
||||||
})
|
|
||||||
|
|
||||||
feed.followingArtists = followingArtistsPlaylists
|
result = result.reduce((acc, cur) => {
|
||||||
feed.global = globalPlaylists
|
return [...acc, ...cur]
|
||||||
|
}, [])
|
||||||
|
|
||||||
return res.json(feed)
|
return res.json(result)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/posts": {
|
"/posts": {
|
||||||
@ -144,31 +135,6 @@ export default class FeedController extends Controller {
|
|||||||
return res.json(feed)
|
return res.json(feed)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/playlists": {
|
|
||||||
middlewares: ["withAuthentication"],
|
|
||||||
fn: async (req, res) => {
|
|
||||||
const for_user_id = req.user?._id.toString()
|
|
||||||
|
|
||||||
if (!for_user_id) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: "Invalid user id"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
let feed = []
|
|
||||||
|
|
||||||
// fetch playlists
|
|
||||||
const playlists = await getPlaylistsFromFollowing({
|
|
||||||
for_user_id,
|
|
||||||
limit: req.query?.limit,
|
|
||||||
skip: req.query?.trim,
|
|
||||||
})
|
|
||||||
|
|
||||||
feed = feed.concat(playlists)
|
|
||||||
|
|
||||||
return res.json(feed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { Playlist } from "@shared-classes/DbModels"
|
import { Release } from "@shared-classes/DbModels"
|
||||||
|
|
||||||
export default async (payload) => {
|
export default async (payload) => {
|
||||||
const {
|
const {
|
||||||
@ -6,7 +6,7 @@ export default async (payload) => {
|
|||||||
skip = 0,
|
skip = 0,
|
||||||
} = payload
|
} = payload
|
||||||
|
|
||||||
let playlists = await Playlist.find({
|
let releases = await Release.find({
|
||||||
$or: [
|
$or: [
|
||||||
{ public: true },
|
{ public: true },
|
||||||
]
|
]
|
||||||
@ -15,5 +15,11 @@ export default async (payload) => {
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
|
|
||||||
return playlists
|
releases = Promise.all(releases.map(async (release) => {
|
||||||
|
release = release.toObject()
|
||||||
|
|
||||||
|
return release
|
||||||
|
}))
|
||||||
|
|
||||||
|
return releases
|
||||||
}
|
}
|
@ -35,14 +35,6 @@ export default async (payload) => {
|
|||||||
|
|
||||||
playlist.type = "playlist"
|
playlist.type = "playlist"
|
||||||
|
|
||||||
playlist.user = await User.findOne({
|
|
||||||
_id: playlist.user_id,
|
|
||||||
}).catch((err) => {
|
|
||||||
return {
|
|
||||||
username: "Unknown user",
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return playlist
|
return playlist
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
@ -0,0 +1,40 @@
|
|||||||
|
import { Release, UserFollow } from "@shared-classes/DbModels"
|
||||||
|
|
||||||
|
export default async (payload) => {
|
||||||
|
const {
|
||||||
|
for_user_id,
|
||||||
|
limit = 20,
|
||||||
|
skip = 0,
|
||||||
|
} = payload
|
||||||
|
|
||||||
|
// get post from users that the user follows
|
||||||
|
const followingUsers = await UserFollow.find({
|
||||||
|
user_id: for_user_id
|
||||||
|
})
|
||||||
|
|
||||||
|
const followingUserIds = followingUsers.map((followingUser) => followingUser.to)
|
||||||
|
|
||||||
|
const fetchFromUserIds = [
|
||||||
|
for_user_id,
|
||||||
|
...followingUserIds,
|
||||||
|
]
|
||||||
|
|
||||||
|
// firter out the releases that are not public
|
||||||
|
let releases = await Release.find({
|
||||||
|
user_id: { $in: fetchFromUserIds },
|
||||||
|
$or: [
|
||||||
|
{ public: true },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.sort({ created_at: -1 })
|
||||||
|
.limit(limit)
|
||||||
|
.skip(skip)
|
||||||
|
|
||||||
|
releases = Promise.all(releases.map(async (release) => {
|
||||||
|
release = release.toObject()
|
||||||
|
|
||||||
|
return release
|
||||||
|
}))
|
||||||
|
|
||||||
|
return releases
|
||||||
|
}
|
47
packages/server/src/fixments/move_playlist_to_release.js
Normal file
47
packages/server/src/fixments/move_playlist_to_release.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Release, Playlist } from "@shared-classes/DbModels"
|
||||||
|
import DBManager from "@shared-classes/DbManager"
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`Running fixment move_playlist_to_release...`)
|
||||||
|
|
||||||
|
const dbManager = new DBManager()
|
||||||
|
await dbManager.initialize()
|
||||||
|
|
||||||
|
const playlists = await Playlist.find({}).catch(() => false)
|
||||||
|
|
||||||
|
console.log(`Found ${playlists.length} playlists`)
|
||||||
|
|
||||||
|
for await (let playlist of playlists) {
|
||||||
|
console.log(`Moving playlist ${playlist._id} to release...`)
|
||||||
|
|
||||||
|
let data = playlist.toObject()
|
||||||
|
|
||||||
|
let release = await Release.findOne(data).catch((err) => {
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (release) {
|
||||||
|
console.log(`Release for playlist ${playlist._id} already exists, skipping...`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
release = new Release({
|
||||||
|
user_id: data.user_id,
|
||||||
|
title: data.title,
|
||||||
|
type: "album",
|
||||||
|
list: data.list,
|
||||||
|
cover: data.cover,
|
||||||
|
created_at: data.created_at ?? new Date(),
|
||||||
|
publisher: data.publisher,
|
||||||
|
public: data.public
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Playlist ${playlist._id} done`)
|
||||||
|
|
||||||
|
await release.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Done!")
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
@ -0,0 +1,34 @@
|
|||||||
|
import SecureSyncEntry from "@shared-classes/SecureSyncEntry"
|
||||||
|
import { AuthorizationError, InternalServerError, NotFoundError } from "@shared-classes/Errors"
|
||||||
|
|
||||||
|
import TidalAPI from "@shared-classes/TidalAPI"
|
||||||
|
|
||||||
|
export default async (req, res) => {
|
||||||
|
if (!req.session) {
|
||||||
|
return new AuthorizationError(req, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const access_token = await SecureSyncEntry.get(req.session.user_id.toString(), "tidal_access_token")
|
||||||
|
|
||||||
|
if (!access_token) {
|
||||||
|
return new AuthorizationError(req, res, "Its needed to link your TIDAL account to perform this action.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_data = await SecureSyncEntry.get(req.session.user_id.toString(), "tidal_user")
|
||||||
|
|
||||||
|
user_data = JSON.parse(user_data)
|
||||||
|
|
||||||
|
let response = await TidalAPI.getFavoritePlaylists({
|
||||||
|
user_id: user_data.id,
|
||||||
|
country: user_data.countryCode,
|
||||||
|
access_token: access_token,
|
||||||
|
limit: Number(req.query.limit ?? 50),
|
||||||
|
offset: Number(req.query.offset ?? 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json(response)
|
||||||
|
} catch (error) {
|
||||||
|
return new InternalServerError(req, res, error)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
import SecureSyncEntry from "@shared-classes/SecureSyncEntry"
|
||||||
|
import { AuthorizationError, InternalServerError, NotFoundError } from "@shared-classes/Errors"
|
||||||
|
|
||||||
|
import TidalAPI from "@shared-classes/TidalAPI"
|
||||||
|
|
||||||
|
export default async (req, res) => {
|
||||||
|
if (!req.session) {
|
||||||
|
return new AuthorizationError(req, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const access_token = await SecureSyncEntry.get(req.session.user_id.toString(), "tidal_access_token")
|
||||||
|
|
||||||
|
if (!access_token) {
|
||||||
|
return new AuthorizationError(req, res, "Its needed to link your TIDAL account to perform this action.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_data = await SecureSyncEntry.get(req.session.user_id.toString(), "tidal_user")
|
||||||
|
|
||||||
|
user_data = JSON.parse(user_data)
|
||||||
|
|
||||||
|
let response = await TidalAPI.getPlaylistData({
|
||||||
|
uuid: req.params.uuid,
|
||||||
|
country: user_data.countryCode,
|
||||||
|
access_token: access_token,
|
||||||
|
limit: Number(req.query.limit ?? 50),
|
||||||
|
offset: Number(req.query.offset ?? 0),
|
||||||
|
resolve_items: req.query.resolve_items === "true",
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json(response)
|
||||||
|
} catch (error) {
|
||||||
|
return new InternalServerError(req, res, error)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
import SecureSyncEntry from "@shared-classes/SecureSyncEntry"
|
||||||
|
import { AuthorizationError, InternalServerError, NotFoundError } from "@shared-classes/Errors"
|
||||||
|
|
||||||
|
import TidalAPI from "@shared-classes/TidalAPI"
|
||||||
|
|
||||||
|
export default async (req, res) => {
|
||||||
|
if (!req.session) {
|
||||||
|
return new AuthorizationError(req, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const access_token = await SecureSyncEntry.get(req.session.user_id.toString(), "tidal_access_token")
|
||||||
|
|
||||||
|
if (!access_token) {
|
||||||
|
return new AuthorizationError(req, res, "Its needed to link your TIDAL account to perform this action.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_data = await SecureSyncEntry.get(req.session.user_id.toString(), "tidal_user")
|
||||||
|
|
||||||
|
user_data = JSON.parse(user_data)
|
||||||
|
|
||||||
|
let response = await TidalAPI.getPlaylistItems({
|
||||||
|
uuid: req.params.uuid,
|
||||||
|
country: user_data.countryCode,
|
||||||
|
access_token: access_token,
|
||||||
|
limit: Number(req.query.limit ?? 50),
|
||||||
|
offset: Number(req.query.offset ?? 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json(response)
|
||||||
|
} catch (error) {
|
||||||
|
return new InternalServerError(req, res, error)
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user