mirror of
https://github.com/ragestudio/comty.git
synced 2025-06-09 10:34:17 +00:00
rework player & music services
This commit is contained in:
parent
fd2a22344c
commit
af20663266
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) => {
|
||||
const [coverHover, setCoverHover] = React.useState(false)
|
||||
const { playlist } = props
|
||||
let { playlist } = props
|
||||
|
||||
if (!playlist) {
|
||||
return null
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
if (typeof props.onClick === "function") {
|
||||
@ -24,6 +28,8 @@ export default (props) => {
|
||||
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
|
||||
id={playlist._id}
|
||||
key={props.key}
|
||||
@ -53,6 +59,23 @@ export default (props) => {
|
||||
<div className="playlistItem_info_title" onClick={onClick}>
|
||||
<h1>{playlist.title}</h1>
|
||||
</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>
|
||||
}
|
@ -1,14 +1,25 @@
|
||||
@playlistItem_maxWidth: 175px;
|
||||
@playlistItem_padding: 10px;
|
||||
|
||||
@playlistItem_cover_maxSize: calc(@playlistItem_maxWidth - @playlistItem_padding * 2);
|
||||
|
||||
.playlistItem {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-direction: column;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
width: @playlistItem_maxWidth;
|
||||
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;
|
||||
|
||||
@ -16,7 +27,7 @@
|
||||
|
||||
&.cover-hovering {
|
||||
.playlistItem_cover {
|
||||
transform: scale(1.1);
|
||||
transform: scale(1.05);
|
||||
|
||||
.playlistItem_cover_mask {
|
||||
opacity: 1;
|
||||
@ -24,27 +35,35 @@
|
||||
}
|
||||
|
||||
.playlistItem_info {
|
||||
transform: translateX(10px);
|
||||
transform: translateY(5px);
|
||||
}
|
||||
}
|
||||
|
||||
.playlistItem_cover {
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
||||
height: 140px;
|
||||
width: 100%;
|
||||
height: @playlistItem_cover_maxSize;
|
||||
|
||||
max-width: @playlistItem_cover_maxSize;
|
||||
|
||||
transition: all 0.2s ease-in-out;
|
||||
z-index: 50;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
border-radius: 12px;
|
||||
|
||||
.image-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
|
||||
background-color: black;
|
||||
|
||||
z-index: 50
|
||||
}
|
||||
|
||||
.playlistItem_cover_mask {
|
||||
@ -79,23 +98,21 @@
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
|
||||
padding: 10px;
|
||||
|
||||
max-width: calc(100% - 10vh);
|
||||
height: 82px;
|
||||
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
.playlistItem_info_title {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.playlistItem_info_title {
|
||||
font-size: 1rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
|
||||
color: var(--background-color-contrast);
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h1,
|
||||
h4 {
|
||||
@ -108,24 +125,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
// set userPreview to the bottom of the playlistItem_info
|
||||
.userPreview {
|
||||
margin-top: auto;
|
||||
font-size: 0.8rem;
|
||||
.playlistItem_info_subtitle {
|
||||
color: var(--text-color);
|
||||
font-size: 0.7rem;
|
||||
|
||||
h1 {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
p {
|
||||
overflow: hidden;
|
||||
|
||||
.avatar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 ReactMarkdown from "react-markdown"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import moment from "moment"
|
||||
import fuse from "fuse.js"
|
||||
import useWsEvents from "hooks/useWsEvents"
|
||||
|
||||
import { WithPlayerContext } from "contexts/WithPlayerContext"
|
||||
import { Context as PlaylistContext } from "contexts/WithPlaylistContext"
|
||||
|
||||
import LoadMore from "components/LoadMore"
|
||||
|
||||
import { ImageViewer } from "components"
|
||||
import { Icons } from "components/Icons"
|
||||
|
||||
import PlaylistsModel from "models/playlists"
|
||||
import MusicModel from "models/music"
|
||||
|
||||
import MusicTrack from "components/Music/Track"
|
||||
|
||||
import SearchButton from "components/SearchButton"
|
||||
|
||||
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) => {
|
||||
const [playlist, setPlaylist] = React.useState(props.playlist)
|
||||
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
|
||||
|
||||
const handleOnClickPlaylistPlay = () => {
|
||||
app.cores.player.start(playlist.list, 0)
|
||||
}
|
||||
|
||||
const handleOnClickViewDetails = () => {
|
||||
app.layout.modal.open("playlist_info", PlaylistInfo, {
|
||||
props: {
|
||||
data: playlist
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleOnClickTrack = (track) => {
|
||||
// search index of track
|
||||
const index = playlist.list.findIndex((item) => {
|
||||
@ -48,10 +143,13 @@ export default (props) => {
|
||||
}
|
||||
|
||||
const handleTrackLike = async (track) => {
|
||||
return await PlaylistsModel.toggleTrackLike(track._id)
|
||||
return await MusicModel.toggleTrackLike(track._id)
|
||||
}
|
||||
|
||||
const makeSearch = (value) => {
|
||||
//TODO: Implement me using API
|
||||
return app.message.info("Not implemented yet...")
|
||||
|
||||
const options = {
|
||||
includeScore: true,
|
||||
keys: [
|
||||
@ -64,6 +162,8 @@ export default (props) => {
|
||||
const fuseInstance = new fuse(playlist.list, options)
|
||||
const results = fuseInstance.search(value)
|
||||
|
||||
console.log(results)
|
||||
|
||||
setSearchResults(results.map((result) => {
|
||||
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({
|
||||
"music:self:track:toggle:like": (data) => {
|
||||
updateTrackLike(data.track_id, data.action === "liked")
|
||||
@ -113,110 +223,170 @@ export default (props) => {
|
||||
|
||||
React.useEffect(() => {
|
||||
setPlaylist(props.playlist)
|
||||
setOwningPlaylist(app.cores.permissions.checkUserIdIsSelf(props.playlist?.user_id))
|
||||
}, [props.playlist])
|
||||
|
||||
if (!playlist) {
|
||||
return <antd.Skeleton active />
|
||||
}
|
||||
|
||||
return <div
|
||||
className={
|
||||
classnames("playlist_view", props.type ?? playlist.type)
|
||||
}
|
||||
>
|
||||
<div className="play_info_wrapper">
|
||||
<div className="play_info">
|
||||
<div className="play_info_cover">
|
||||
<ImageViewer src={playlist.cover ?? playlist?.thumbnail ?? "/assets/no_song.png"} />
|
||||
return <PlaylistContext.Provider value={contextValues}>
|
||||
<WithPlayerContext>
|
||||
<div
|
||||
className={classnames(
|
||||
"playlist_view",
|
||||
props.type ?? playlist.type,
|
||||
)}
|
||||
>
|
||||
|
||||
<div className="play_info_wrapper">
|
||||
<div className="play_info">
|
||||
<div className="play_info_cover">
|
||||
<ImageViewer src={playlist.cover ?? playlist?.thumbnail ?? "/assets/no_song.png"} />
|
||||
</div>
|
||||
|
||||
<div className="play_info_details">
|
||||
<div className="play_info_title">
|
||||
{
|
||||
playlist.service === "tidal" && <Icons.SiTidal />
|
||||
}
|
||||
{
|
||||
typeof playlist.title === "function" ?
|
||||
playlist.title :
|
||||
<h1>{playlist.title}</h1>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="play_info_statistics">
|
||||
{
|
||||
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">
|
||||
<p
|
||||
onClick={() => {
|
||||
app.navigation.goToAccount(playlist.publisher.username)
|
||||
}}
|
||||
>
|
||||
<Icons.MdPerson />
|
||||
|
||||
Publised by <a>{playlist.publisher.username}</a>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="play_info_actions">
|
||||
<antd.Button
|
||||
type="primary"
|
||||
shape="rounded"
|
||||
size="large"
|
||||
onClick={handleOnClickPlaylistPlay}
|
||||
>
|
||||
<Icons.MdPlayArrow />
|
||||
Play
|
||||
</antd.Button>
|
||||
|
||||
{
|
||||
!props.favorite && <antd.Button
|
||||
icon={<Icons.MdFavorite />}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
playlist.description && <antd.Button
|
||||
icon={<Icons.MdInfo />}
|
||||
onClick={handleOnClickViewDetails}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
owningPlaylist &&
|
||||
<antd.Dropdown
|
||||
trigger={["click"]}
|
||||
placement="bottom"
|
||||
menu={{
|
||||
items: moreMenuItems,
|
||||
onClick: handleMoreMenuClick
|
||||
}}
|
||||
>
|
||||
<antd.Button
|
||||
icon={<Icons.MdMoreVert />}
|
||||
/>
|
||||
</antd.Dropdown>
|
||||
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="play_info_details">
|
||||
<div className="play_info_title">
|
||||
{typeof playlist.title === "function" ? playlist.title : <h1>{playlist.title}</h1>}
|
||||
<div className="list">
|
||||
<div className="list_header">
|
||||
<h1>
|
||||
<Icons.MdPlaylistPlay /> Tracks
|
||||
</h1>
|
||||
|
||||
<SearchButton
|
||||
onChange={handleOnSearchChange}
|
||||
onEmpty={handleOnSearchEmpty}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{
|
||||
playlist.description && <div className="play_info_description">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{playlist.description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
searchResults && searchResults.map((item) => {
|
||||
return <MusicTrack
|
||||
key={item._id}
|
||||
order={item._id}
|
||||
track={item}
|
||||
onClickPlayBtn={() => handleOnClickTrack(item)}
|
||||
onLike={() => handleTrackLike(item)}
|
||||
/>
|
||||
})
|
||||
}
|
||||
|
||||
<div className="play_info_statistics">
|
||||
{
|
||||
playlist.publisher && <div className="play_info_statistics_item">
|
||||
<p
|
||||
onClick={() => {
|
||||
app.navigation.goToAccount(playlist.publisher.username)
|
||||
}}
|
||||
>
|
||||
<Icons.MdPerson />
|
||||
{
|
||||
!searchResults && playlist.list.length === 0 && <antd.Empty
|
||||
description={
|
||||
<>
|
||||
<Icons.MdLibraryMusic /> This playlist its empty!
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
|
||||
Publised by <a>{playlist.publisher.username}</a>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
<div className="play_info_statistics_item">
|
||||
<p>
|
||||
<Icons.MdLibraryMusic /> {props.length ?? playlist.list.length} Tracks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{
|
||||
playlist.created_at && <div className="play_info_statistics_item">
|
||||
<p>
|
||||
<Icons.MdAccessTime /> Released on {moment(playlist.created_at).format("DD/MM/YYYY")}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
{
|
||||
!searchResults && playlist.list.length > 0 && <LoadMore
|
||||
className="list_content"
|
||||
loadingComponent={() => <antd.Skeleton />}
|
||||
onBottom={props.onLoadMore}
|
||||
hasMore={props.hasMore}
|
||||
>
|
||||
<WithPlayerContext>
|
||||
{
|
||||
playlist.list.map((item, index) => {
|
||||
return <MusicTrack
|
||||
order={index + 1}
|
||||
track={item}
|
||||
onClickPlayBtn={() => handleOnClickTrack(item)}
|
||||
onLike={() => handleTrackLike(item)}
|
||||
/>
|
||||
})
|
||||
}
|
||||
</WithPlayerContext>
|
||||
</LoadMore>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="list">
|
||||
<div className="list_header">
|
||||
<h1>
|
||||
<Icons.MdPlaylistPlay /> Tracks
|
||||
</h1>
|
||||
|
||||
<SearchButton
|
||||
onChange={handleOnSearchChange}
|
||||
onEmpty={handleOnSearchEmpty}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{
|
||||
playlist.list.length === 0 && <antd.Empty
|
||||
description={
|
||||
<>
|
||||
<Icons.MdLibraryMusic /> This playlist its empty!
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
{
|
||||
playlist.list.length > 0 && <LoadMore
|
||||
className="list_content"
|
||||
loadingComponent={() => <antd.Skeleton />}
|
||||
onBottom={props.onLoadMore}
|
||||
hasMore={props.hasMore}
|
||||
>
|
||||
<WithPlayerContext>
|
||||
{
|
||||
playlist.list.map((item, index) => {
|
||||
return <MusicTrack
|
||||
order={index + 1}
|
||||
track={item}
|
||||
onClickPlayBtn={() => handleOnClickTrack(item)}
|
||||
onLike={() => handleTrackLike(item)}
|
||||
/>
|
||||
})
|
||||
}
|
||||
</WithPlayerContext>
|
||||
</LoadMore>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</WithPlayerContext>
|
||||
</PlaylistContext.Provider>
|
||||
}
|
@ -36,81 +36,46 @@ html {
|
||||
}
|
||||
|
||||
.playlist_view {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: flex-start;
|
||||
|
||||
position: sticky;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
|
||||
gap: 20px;
|
||||
|
||||
&.vertical {
|
||||
position: relative;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
.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;
|
||||
flex-direction: column;
|
||||
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
width: 100%;
|
||||
|
||||
z-index: 45;
|
||||
|
||||
color: var(--text-color);
|
||||
|
||||
.play_info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
|
||||
gap: 20px;
|
||||
|
||||
align-self: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
padding: 20px;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
background-color: var(--background-color-accent);
|
||||
border-radius: 12px;
|
||||
|
||||
@ -123,11 +88,11 @@ html {
|
||||
|
||||
align-self: center;
|
||||
|
||||
width: 20vw;
|
||||
height: 20vw;
|
||||
height: 15vh !important;
|
||||
width: 15vh !important;
|
||||
|
||||
min-height: 20vw;
|
||||
min-width: 20vw;
|
||||
min-height: 15vh;
|
||||
min-width: 15vh;
|
||||
|
||||
max-width: 400px;
|
||||
max-height: 400px;
|
||||
@ -146,34 +111,48 @@ html {
|
||||
}
|
||||
|
||||
.play_info_details {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 90%;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
.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;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
word-break: break-all;
|
||||
|
||||
font-weight: 600;
|
||||
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.play_info_description {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
|
||||
max-height: 10vh;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.play_info_statistics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
margin-top: 20px;
|
||||
|
||||
background-color: var(--background-color-primary);
|
||||
|
||||
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 * as antd from "antd"
|
||||
import classnames from "classnames"
|
||||
import LikeButton from "components/LikeButton"
|
||||
import seekToTimeLabel from "utils/seekToTimeLabel"
|
||||
|
||||
import { ImageViewer } from "components"
|
||||
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"
|
||||
|
||||
export default (props) => {
|
||||
const Track = (props) => {
|
||||
const {
|
||||
track_manifest,
|
||||
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 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
|
||||
id={props.track._id}
|
||||
className={classnames(
|
||||
@ -43,60 +109,78 @@ export default (props) => {
|
||||
["playing"]: isPlaying,
|
||||
}
|
||||
)}
|
||||
style={{
|
||||
"--cover_average-color": RGBStringToValues(track_manifest?.cover_analysis?.rgb),
|
||||
}}
|
||||
onClick={handleOnClickItem}
|
||||
>
|
||||
<div className={classnames(
|
||||
"music-track_actions",
|
||||
<div
|
||||
className="music-track_background"
|
||||
/>
|
||||
|
||||
<div className="music-track_content">
|
||||
{
|
||||
["withOrder"]: props.order !== undefined,
|
||||
!app.isMobile && <div className={classnames(
|
||||
"music-track_actions",
|
||||
{
|
||||
["withOrder"]: props.order !== undefined,
|
||||
}
|
||||
)}>
|
||||
<div className="music-track_action">
|
||||
<span className="music-track_orderIndex">
|
||||
{
|
||||
props.order
|
||||
}
|
||||
</span>
|
||||
<antd.Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={isPlaying ? <Icons.MdPause /> : <Icons.MdPlayArrow />}
|
||||
onClick={handleClickPlayBtn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
)}>
|
||||
<div className="music-track_action">
|
||||
<span className="music-track_orderIndex">
|
||||
{
|
||||
props.order
|
||||
}
|
||||
</span>
|
||||
<antd.Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={isPlaying ? <Icons.MdPause /> : <Icons.MdPlayArrow />}
|
||||
onClick={handleClickPlayBtn}
|
||||
/>
|
||||
|
||||
<div className="music-track_cover">
|
||||
<ImageViewer src={props.track.cover ?? props.track.thumbnail} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="music-track_cover">
|
||||
<ImageViewer src={props.track.cover ?? props.track.thumbnail} />
|
||||
</div>
|
||||
|
||||
<div className="music-track_details">
|
||||
<div className="music-track_title">
|
||||
{props.track.title}
|
||||
</div>
|
||||
<div className="music-track_artist">
|
||||
{props.track.artist}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="music-track_right_actions">
|
||||
<div className="music-track_info">
|
||||
{
|
||||
props.track.service === "tidal" && <Icons.SiTidal />
|
||||
}
|
||||
|
||||
<div className="music-track_info_duration">
|
||||
{
|
||||
props.track.metadata?.duration
|
||||
? seekToTimeLabel(props.track.metadata?.duration)
|
||||
: "00:00"
|
||||
}
|
||||
<div className="music-track_details">
|
||||
<div className="music-track_title">
|
||||
<span>
|
||||
{
|
||||
props.track.service === "tidal" && <Icons.SiTidal />
|
||||
}
|
||||
{props.track.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="music-track_artist">
|
||||
<span>
|
||||
{props.track.artist}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LikeButton
|
||||
liked={isLiked}
|
||||
onClick={props.onLike}
|
||||
/>
|
||||
<div className="music-track_right_actions">
|
||||
<antd.Dropdown
|
||||
menu={{
|
||||
items: moreMenuItems,
|
||||
onClick: handleMoreMenuItemClick
|
||||
}}
|
||||
onOpenChange={handleMoreMenuOpen}
|
||||
open={moreMenuOpened}
|
||||
trigger={["click"]}
|
||||
>
|
||||
<antd.Button
|
||||
type="ghost"
|
||||
size="large"
|
||||
icon={<Icons.IoMdMore />}
|
||||
/>
|
||||
</antd.Dropdown>
|
||||
</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 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
padding: 10px;
|
||||
position: relative;
|
||||
|
||||
border-radius: 8px;
|
||||
|
||||
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 {
|
||||
background-color: var(--background-color-accent);
|
||||
|
||||
.music-track_actions {
|
||||
.music-track_action {
|
||||
.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 {
|
||||
@ -61,16 +86,6 @@ html {
|
||||
|
||||
&.withOrder {
|
||||
.music-track_action {
|
||||
&:hover {
|
||||
.ant-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.music-track_orderIndex {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
opacity: 0;
|
||||
}
|
||||
@ -82,6 +97,8 @@ html {
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
.music-track_orderIndex {
|
||||
position: absolute;
|
||||
|
||||
|
@ -10,102 +10,116 @@ import LikeButton from "components/LikeButton"
|
||||
import AudioVolume from "components/Player/AudioVolume"
|
||||
import AudioPlayerChangeModeButton from "components/Player/ChangeModeButton"
|
||||
|
||||
import { Context } from "contexts/WithPlayerContext"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
export default ({
|
||||
className,
|
||||
controls,
|
||||
syncModeLocked = false,
|
||||
syncMode = false,
|
||||
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")
|
||||
const EventsHandlers = {
|
||||
"playback": () => {
|
||||
return app.cores.player.playback.toggle()
|
||||
},
|
||||
"like": () => {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof controls[event] !== "function") {
|
||||
console.warn(`[AudioPlayer] onClickActionsButton: ${event} is not a function`)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return controls[event]()
|
||||
},
|
||||
"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()
|
||||
}
|
||||
}
|
||||
|
||||
return <div
|
||||
className={
|
||||
className ?? "player-controls"
|
||||
}
|
||||
>
|
||||
<AudioPlayerChangeModeButton
|
||||
disabled={syncModeLocked}
|
||||
/>
|
||||
<antd.Button
|
||||
type="ghost"
|
||||
shape="round"
|
||||
icon={<Icons.ChevronLeft />}
|
||||
onClick={() => onClickActionsButton("previous")}
|
||||
disabled={syncModeLocked}
|
||||
/>
|
||||
<antd.Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={streamMode ? <Icons.MdStop /> : playbackStatus === "playing" ? <Icons.MdPause /> : <Icons.MdPlayArrow />}
|
||||
onClick={() => onClickActionsButton("toggle")}
|
||||
className="playButton"
|
||||
disabled={syncModeLocked}
|
||||
>
|
||||
{
|
||||
loading && <div className="loadCircle">
|
||||
<UseAnimations
|
||||
animation={LoadingAnimation}
|
||||
size="100%"
|
||||
/>
|
||||
</div>
|
||||
const Controls = (props) => {
|
||||
try {
|
||||
const ctx = React.useContext(Context)
|
||||
|
||||
const handleAction = (event, ...args) => {
|
||||
if (typeof EventsHandlers[event] !== "function") {
|
||||
throw new Error(`Unknown event "${event}"`)
|
||||
}
|
||||
</antd.Button>
|
||||
<antd.Button
|
||||
type="ghost"
|
||||
shape="round"
|
||||
icon={<Icons.ChevronRight />}
|
||||
onClick={() => onClickActionsButton("next")}
|
||||
disabled={syncModeLocked}
|
||||
/>
|
||||
{
|
||||
app.isMobile && <LikeButton
|
||||
onClick={controls.like}
|
||||
liked={liked}
|
||||
|
||||
return EventsHandlers[event](ctx, ...args)
|
||||
}
|
||||
|
||||
return <div
|
||||
className={
|
||||
props.className ?? "player-controls"
|
||||
}
|
||||
>
|
||||
<AudioPlayerChangeModeButton
|
||||
disabled={ctx.control_locked}
|
||||
/>
|
||||
}
|
||||
{
|
||||
!app.isMobile && <antd.Popover
|
||||
content={React.createElement(
|
||||
AudioVolume,
|
||||
{ onChange: onVolumeUpdate, defaultValue: audioVolume }
|
||||
)}
|
||||
trigger="hover"
|
||||
<antd.Button
|
||||
type="ghost"
|
||||
shape="round"
|
||||
icon={<Icons.ChevronLeft />}
|
||||
onClick={() => handleAction("previous")}
|
||||
disabled={ctx.control_locked}
|
||||
/>
|
||||
<antd.Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={ctx.livestream_mode ? <Icons.MdStop /> : ctx.playback_status === "playing" ? <Icons.MdPause /> : <Icons.MdPlayArrow />}
|
||||
onClick={() => handleAction("playback")}
|
||||
className="playButton"
|
||||
disabled={ctx.control_locked}
|
||||
>
|
||||
<div
|
||||
className="muteButton"
|
||||
onClick={onMuteUpdate}
|
||||
{
|
||||
ctx.loading && <div className="loadCircle">
|
||||
<UseAnimations
|
||||
animation={LoadingAnimation}
|
||||
size="100%"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</antd.Button>
|
||||
<antd.Button
|
||||
type="ghost"
|
||||
shape="round"
|
||||
icon={<Icons.ChevronRight />}
|
||||
onClick={() => handleAction("next")}
|
||||
disabled={ctx.control_locked}
|
||||
/>
|
||||
{
|
||||
app.isMobile && <LikeButton
|
||||
onClick={() => handleAction("like")}
|
||||
liked={ctx.track_manifest?.liked}
|
||||
/>
|
||||
}
|
||||
{
|
||||
!app.isMobile && <antd.Popover
|
||||
content={React.createElement(
|
||||
AudioVolume,
|
||||
{
|
||||
onChange: (value) => handleAction("volume", value),
|
||||
defaultValue: ctx.volume
|
||||
}
|
||||
)}
|
||||
trigger="hover"
|
||||
>
|
||||
{
|
||||
audioMuted
|
||||
? <Icons.VolumeX />
|
||||
: <Icons.Volume2 />
|
||||
}
|
||||
</div>
|
||||
</antd.Popover>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<button
|
||||
className="muteButton"
|
||||
onClick={() => handleAction("mute")}
|
||||
>
|
||||
{
|
||||
ctx.muted
|
||||
? <Icons.VolumeX />
|
||||
: <Icons.Volume2 />
|
||||
}
|
||||
</button>
|
||||
</antd.Popover>
|
||||
}
|
||||
</div>
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default Controls
|
@ -31,6 +31,35 @@ html {
|
||||
svg {
|
||||
color: var(--text-color);
|
||||
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 {
|
||||
@ -41,6 +70,10 @@ html {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.ant-btn-icon {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.loadCircle {
|
||||
position: absolute;
|
||||
|
||||
@ -66,15 +99,13 @@ html {
|
||||
|
||||
path {
|
||||
stroke: var(--text-color);
|
||||
stroke-width: 1;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.muteButton {
|
||||
padding: 10px;
|
||||
|
||||
svg {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
@ -33,8 +33,6 @@ html {
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
|
||||
//border-radius: 12px;
|
||||
|
||||
pointer-events: initial;
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
@ -45,6 +43,8 @@ html {
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
background-color: transparent;
|
||||
|
||||
.player {
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
|
@ -159,6 +159,9 @@ export default class SeekBar extends React.Component {
|
||||
return <div
|
||||
className={classnames(
|
||||
"player-seek_bar",
|
||||
{
|
||||
["stopped"]: this.props.stopped,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<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 { 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 AudioPlayerStorage from "./player.storage"
|
||||
@ -84,6 +84,9 @@ export default class Player extends Core {
|
||||
previous: this.previous.bind(this),
|
||||
seek: this.seek.bind(this),
|
||||
},
|
||||
_setLoading: function (to) {
|
||||
this.state.loading = !!to
|
||||
}.bind(this),
|
||||
duration: this.duration.bind(this),
|
||||
volume: this.volume.bind(this),
|
||||
mute: this.mute.bind(this),
|
||||
@ -213,12 +216,10 @@ export default class Player extends Core {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!app.layout.tools_bar) {
|
||||
this.console.error("Tools bar not found")
|
||||
return false
|
||||
if (app.layout.tools_bar) {
|
||||
this.currentDomWindow = app.layout.tools_bar.attachRender("mediaPlayer", ToolBarPlayer)
|
||||
}
|
||||
|
||||
this.currentDomWindow = app.layout.tools_bar.attachRender("mediaPlayer", EmbbededMediaPlayer)
|
||||
}
|
||||
|
||||
detachPlayerComponent() {
|
||||
@ -263,12 +264,7 @@ export default class Player extends Core {
|
||||
}
|
||||
|
||||
if (!instance.manifest.cover_analysis) {
|
||||
const img = new Image()
|
||||
|
||||
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)
|
||||
const cover_analysis = await this.fac.getColorAsync(`https://corsproxy.io/?${encodeURIComponent(instance.manifest.cover ?? instance.manifest.thumbnail)}`)
|
||||
.catch((err) => {
|
||||
this.console.error(err)
|
||||
|
||||
@ -762,6 +758,8 @@ export default class Player extends Core {
|
||||
|
||||
this.state.volume = volume
|
||||
|
||||
AudioPlayerStorage.set("volume", volume)
|
||||
|
||||
if (this.track_instance) {
|
||||
if (this.track_instance.gainNode) {
|
||||
this.track_instance.gainNode.gain.value = this.state.volume
|
||||
@ -904,7 +902,7 @@ export default class Player extends Core {
|
||||
return list
|
||||
}
|
||||
|
||||
const fetchedTracks = await PlaylistModel.getTracks(ids).catch((err) => {
|
||||
const fetchedTracks = await MusicModel.getTracksData(ids).catch((err) => {
|
||||
this.console.error(err)
|
||||
return false
|
||||
})
|
||||
|
@ -3,9 +3,12 @@ import GainProcessorNode from "./gainNode"
|
||||
import CompressorProcessorNode from "./compressorNode"
|
||||
//import BPMProcessorNode from "./bpmNode"
|
||||
|
||||
import SpatialNode from "./spatialNode"
|
||||
|
||||
export default [
|
||||
//BPMProcessorNode,
|
||||
EqProcessorNode,
|
||||
GainProcessorNode,
|
||||
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 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"
|
||||
|
||||
@ -81,13 +81,13 @@ const ReleaseItem = (props) => {
|
||||
</div>
|
||||
}
|
||||
|
||||
const openPlaylistCreator = ({
|
||||
playlist_id = null,
|
||||
const openReleaseCreator = ({
|
||||
release_id = null,
|
||||
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",
|
||||
props: {
|
||||
title: <h2
|
||||
@ -101,19 +101,19 @@ const openPlaylistCreator = ({
|
||||
width: "fit-content",
|
||||
},
|
||||
componentProps: {
|
||||
playlist_id: playlist_id,
|
||||
release_id: release_id,
|
||||
onModification: onModification,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const navigateToPlaylist = (playlist_id) => {
|
||||
return app.location.push(`/play/${playlist_id}`)
|
||||
const navigateToRelease = (release_id) => {
|
||||
return app.location.push(`/play/${release_id}`)
|
||||
}
|
||||
|
||||
export default (props) => {
|
||||
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) {
|
||||
console.error(E_Releases)
|
||||
@ -140,7 +140,7 @@ export default (props) => {
|
||||
|
||||
<div className="music_panel_releases_header_actions">
|
||||
<antd.Button
|
||||
onClick={() => openPlaylistCreator({
|
||||
onClick={() => openReleaseCreator({
|
||||
onModification: M_Releases,
|
||||
})}
|
||||
icon={<Icons.Plus />}
|
||||
@ -155,52 +155,52 @@ export default (props) => {
|
||||
<Searcher
|
||||
small
|
||||
renderResults={false}
|
||||
model={PlaylistsModel.getMyReleases}
|
||||
model={MusicModel.getMyReleases}
|
||||
onSearchResult={setSearchResults}
|
||||
onEmpty={() => setSearchResults(null)}
|
||||
/>
|
||||
|
||||
<div className="music_panel_releases_list">
|
||||
{
|
||||
searchResults && searchResults.length === 0 && <antd.Result
|
||||
searchResults?.items && searchResults.items.length === 0 && <antd.Result
|
||||
status="info"
|
||||
title="No results"
|
||||
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
|
||||
key={release._id}
|
||||
release={release}
|
||||
onClickEditTrack={() => openPlaylistCreator({
|
||||
playlist_id: release._id,
|
||||
onClickEditTrack={() => openReleaseCreator({
|
||||
release_id: release._id,
|
||||
onModification: M_Releases,
|
||||
})}
|
||||
onClickNavigate={() => navigateToPlaylist(release._id)}
|
||||
onClickNavigate={() => navigateToRelease(release._id)}
|
||||
/>
|
||||
})
|
||||
}
|
||||
{
|
||||
!searchResults && R_Releases.map((release) => {
|
||||
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
|
||||
!searchResults && R_Releases.items.length === 0 && <antd.Result
|
||||
status="info"
|
||||
title="No releases"
|
||||
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>
|
||||
}
|
@ -10,7 +10,6 @@ import { WithPlayerContext } from "contexts/WithPlayerContext"
|
||||
|
||||
import FeedModel from "models/feed"
|
||||
import MusicModel from "models/music"
|
||||
import SyncModel from "models/sync"
|
||||
|
||||
import MusicTrack from "components/Music/Track"
|
||||
import PlaylistItem from "components/Music/PlaylistItem"
|
||||
@ -207,7 +206,6 @@ const SearchResults = ({
|
||||
}
|
||||
)}
|
||||
>
|
||||
<WithPlayerContext>
|
||||
{
|
||||
groupsKeys.map((key, index) => {
|
||||
const decorator = ResultGroupsDecorators[key] ?? {
|
||||
@ -241,7 +239,6 @@ const SearchResults = ({
|
||||
</div>
|
||||
})
|
||||
}
|
||||
</WithPlayerContext>
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -293,7 +290,7 @@ export default (props) => {
|
||||
<PlaylistsList
|
||||
headerTitle="From your following artists"
|
||||
headerIcon={<Icons.MdPerson />}
|
||||
fetchMethod={FeedModel.getPlaylistsFeed}
|
||||
fetchMethod={FeedModel.getMusicFeed}
|
||||
/>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -71,10 +89,12 @@
|
||||
}
|
||||
|
||||
.playlistExplorer_section_list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
|
||||
gap: 10px;
|
||||
grid-gap: 20px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
min-width: 372px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -103,6 +123,35 @@
|
||||
.playlistItem {
|
||||
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;
|
||||
|
||||
@playlistItem_height: 80px;
|
||||
@playlistItem_padding: 10px;
|
||||
|
||||
@playlistItem_cover_size: calc(@playlistItem_height - @playlistItem_padding * 2);
|
||||
|
||||
.playlistItem {
|
||||
flex-direction: row;
|
||||
background-color: var(--background-color-primary);
|
||||
|
||||
max-width: 300px;
|
||||
height: 80px;
|
||||
width: 100%;
|
||||
|
||||
height: @playlistItem_height;
|
||||
|
||||
padding: @playlistItem_padding;
|
||||
|
||||
.playlistItem_cover {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
width: @playlistItem_cover_size;
|
||||
height: @playlistItem_cover_size;
|
||||
|
||||
img {
|
||||
height: 80px;
|
||||
width: 80px;
|
||||
min-height: @playlistItem_cover_size;
|
||||
min-width: @playlistItem_cover_size;
|
||||
}
|
||||
.playlistItem_bottom {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.playlistItem_info {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ export default class FavoriteTracks extends React.Component {
|
||||
loading: true,
|
||||
})
|
||||
|
||||
const result = await MusicModel.getFavorites({
|
||||
const result = await MusicModel.getFavoriteTracks({
|
||||
useTidal: app.cores.sync.getActiveLinkedServices().tidal,
|
||||
offset: offset,
|
||||
limit: limit,
|
||||
@ -112,6 +112,7 @@ export default class FavoriteTracks extends React.Component {
|
||||
}
|
||||
|
||||
return <PlaylistView
|
||||
favorite
|
||||
type="vertical"
|
||||
playlist={{
|
||||
title: "Your favorites",
|
||||
|
@ -1,7 +1,190 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
import classnames from "classnames"
|
||||
|
||||
import Image from "components/Image"
|
||||
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>
|
||||
|
||||
return <div className="music-library">
|
||||
<OwnPlaylists />
|
||||
</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"
|
||||
|
||||
export default (props) => {
|
||||
const [playlistName, setPlaylistName] = React.useState(props.playlist.title)
|
||||
const [playlistDescription, setPlaylistDescription] = React.useState(props.playlist.description)
|
||||
const [playlistThumbnail, setPlaylistThumbnail] = React.useState(props.playlist.cover ?? props.playlist.thumbnail)
|
||||
const [playlistVisibility, setPlaylistVisibility] = React.useState(props.playlist.visibility)
|
||||
const [releaseName, setReleaseName] = React.useState(props.release.title)
|
||||
const [releaseDescription, setReleaseDescription] = React.useState(props.release.description)
|
||||
const [releaseThumbnail, setReleaseThumbnail] = React.useState(props.release.cover ?? props.release.thumbnail)
|
||||
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) => {
|
||||
setPlaylistName(event.target.value)
|
||||
setReleaseName(event.target.value)
|
||||
|
||||
props.onTitleChange(event.target.value)
|
||||
props.onValueChange("title", event.target.value)
|
||||
}
|
||||
|
||||
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) => {
|
||||
setPlaylistThumbnail(file.url)
|
||||
setReleaseThumbnail(file.url)
|
||||
|
||||
props.onPlaylistCoverChange(file.url)
|
||||
props.onValueChange("cover", file.url)
|
||||
}
|
||||
|
||||
const handleRemoveCover = () => {
|
||||
setPlaylistThumbnail(null)
|
||||
setReleaseThumbnail(null)
|
||||
|
||||
props.onPlaylistCoverChange(null)
|
||||
props.onValueChange("cover", null)
|
||||
}
|
||||
|
||||
const handleVisibilityChange = (value) => {
|
||||
setPlaylistVisibility(value)
|
||||
setReleaseVisibility(value)
|
||||
|
||||
props.onVisibilityChange(value === "public")
|
||||
props.onValueChange("public", value === "public")
|
||||
}
|
||||
|
||||
return <div className="playlistCreator_layout_row">
|
||||
@ -51,10 +58,10 @@ export default (props) => {
|
||||
|
||||
<antd.Input
|
||||
className="inputText"
|
||||
placeholder="Playlist Title"
|
||||
placeholder="Publish Title"
|
||||
size="large"
|
||||
bordered={false}
|
||||
value={playlistName}
|
||||
value={releaseName}
|
||||
onChange={handleTitleOnChange}
|
||||
maxLength={120}
|
||||
/>
|
||||
@ -70,7 +77,7 @@ export default (props) => {
|
||||
className="inputText"
|
||||
placeholder="Description (Support Markdown)"
|
||||
bordered={false}
|
||||
value={playlistDescription}
|
||||
value={releaseDescription}
|
||||
onChange={handleDescriptionOnChange}
|
||||
maxLength={2500}
|
||||
rows={4}
|
||||
@ -79,6 +86,23 @@ export default (props) => {
|
||||
|
||||
<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_header">
|
||||
<Icons.Eye />
|
||||
@ -86,9 +110,9 @@ export default (props) => {
|
||||
</div>
|
||||
|
||||
<antd.Select
|
||||
value={playlistVisibility}
|
||||
value={releaseVisibility}
|
||||
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="private">Private</antd.Select.Option>
|
||||
@ -111,7 +135,7 @@ export default (props) => {
|
||||
|
||||
<div className="coverPreview">
|
||||
<div className="coverPreview_preview">
|
||||
<img src={playlistThumbnail ?? "/assets/no_song.png"} alt="Thumbnail" />
|
||||
<img src={releaseThumbnail ?? "/assets/no_song.png"} alt="Thumbnail" />
|
||||
</div>
|
||||
|
||||
<div className="coverPreview_actions">
|
||||
@ -125,7 +149,7 @@ export default (props) => {
|
||||
|
||||
<antd.Button
|
||||
onClick={handleRemoveCover}
|
||||
disabled={!playlistThumbnail}
|
||||
disabled={!releaseThumbnail}
|
||||
icon={<Icons.MdClose />}
|
||||
type="text"
|
||||
>
|
||||
@ -139,7 +163,7 @@ export default (props) => {
|
||||
|
||||
<div className="field">
|
||||
{
|
||||
props.playlist._id && <antd.Button
|
||||
props.release._id && <antd.Button
|
||||
onClick={props.onDeletePlaylist}
|
||||
icon={<Icons.MdDelete />}
|
||||
danger
|
||||
|
@ -4,7 +4,7 @@ import classnames from "classnames"
|
||||
import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd"
|
||||
import UploadButton from "components/UploadButton"
|
||||
|
||||
import PlaylistModel from "models/playlists"
|
||||
import MusicModel from "models/music"
|
||||
|
||||
import { Icons } from "components/Icons"
|
||||
|
||||
@ -127,7 +127,24 @@ const FileItemEditor = (props) => {
|
||||
/>
|
||||
</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_header">
|
||||
@ -141,6 +158,19 @@ const FileItemEditor = (props) => {
|
||||
/>
|
||||
</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_header">
|
||||
<Icons.Tag />
|
||||
@ -305,8 +335,8 @@ export default (props) => {
|
||||
app.DrawerController.open("track_editor", FileItemEditor, {
|
||||
type: "drawer",
|
||||
props: {
|
||||
width: "25vw",
|
||||
minWidth: "500px",
|
||||
width: "30vw",
|
||||
minWidth: "600px",
|
||||
},
|
||||
componentProps: {
|
||||
track,
|
||||
@ -318,7 +348,7 @@ export default (props) => {
|
||||
onRefreshCache: () => {
|
||||
console.log("Refreshing cache for track", track.uid)
|
||||
|
||||
PlaylistModel.refreshTrackCache(track._id)
|
||||
MusicModel.refreshTrackCache(track._id)
|
||||
.catch(() => {
|
||||
app.message.error("Failed to refresh cache for track")
|
||||
})
|
||||
|
@ -2,7 +2,7 @@ import React from "react"
|
||||
import * as antd from "antd"
|
||||
import jsmediatags from "jsmediatags/dist/jsmediatags.min.js"
|
||||
|
||||
import PlaylistModel from "models/playlists"
|
||||
import MusicModel from "models/music"
|
||||
|
||||
import BasicInformation from "./components/BasicInformation"
|
||||
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 = {
|
||||
playlistData: {},
|
||||
releaseData: {},
|
||||
|
||||
fileList: [],
|
||||
trackList: [],
|
||||
@ -145,10 +145,10 @@ export default class PlaylistCreatorSteps extends React.Component {
|
||||
},
|
||||
}
|
||||
|
||||
updatePlaylistData = (key, value) => {
|
||||
updateReleaseData = (key, value) => {
|
||||
this.setState({
|
||||
playlistData: {
|
||||
...this.state.playlistData,
|
||||
releaseData: {
|
||||
...this.state.releaseData,
|
||||
[key]: value
|
||||
}
|
||||
})
|
||||
@ -161,9 +161,9 @@ export default class PlaylistCreatorSteps extends React.Component {
|
||||
}
|
||||
|
||||
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 hasPendingUploads = pendingTracksUpload.length > 0
|
||||
const tracksHasValidData = trackList.every((track) => {
|
||||
@ -178,12 +178,12 @@ export default class PlaylistCreatorSteps extends React.Component {
|
||||
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({
|
||||
...playlistData,
|
||||
const result = await MusicModel.putRelease({
|
||||
...releaseData,
|
||||
list: trackList,
|
||||
})
|
||||
|
||||
@ -324,19 +324,19 @@ export default class PlaylistCreatorSteps extends React.Component {
|
||||
}
|
||||
|
||||
handleDeletePlaylist = async () => {
|
||||
if (!this.props.playlist_id) {
|
||||
console.error(`Cannot delete playlist without id`)
|
||||
if (!this.props.release_id) {
|
||||
console.error(`Cannot delete release without id`)
|
||||
return
|
||||
}
|
||||
|
||||
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",
|
||||
okText: "Delete",
|
||||
okType: "danger",
|
||||
cancelText: "Cancel",
|
||||
onOk: async () => {
|
||||
const result = await PlaylistModel.deletePlaylist(this.props.playlist_id, {
|
||||
const result = await MusicModel.deleteRelease(this.props.release_id, {
|
||||
remove_with_tracks: true
|
||||
})
|
||||
|
||||
@ -518,7 +518,7 @@ export default class PlaylistCreatorSteps extends React.Component {
|
||||
// check current step
|
||||
switch (this.state.currentStep) {
|
||||
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:
|
||||
return this.canSubmit()
|
||||
default:
|
||||
@ -529,8 +529,8 @@ export default class PlaylistCreatorSteps extends React.Component {
|
||||
componentDidMount() {
|
||||
window._hacks = this._hacks
|
||||
|
||||
if (this.props.playlist_id) {
|
||||
this.loadPlaylistData(this.props.playlist_id)
|
||||
if (this.props.release_id) {
|
||||
this.loadReleaseData(this.props.release_id)
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false
|
||||
@ -542,18 +542,20 @@ export default class PlaylistCreatorSteps extends React.Component {
|
||||
delete window._hacks
|
||||
}
|
||||
|
||||
loadPlaylistData = async (playlist_id) => {
|
||||
console.log(`Loading playlist data for playlist ${playlist_id}...`)
|
||||
loadReleaseData = async (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)
|
||||
antd.message.error(error)
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
if (playlistData) {
|
||||
const trackList = playlistData.list.map((track) => {
|
||||
console.log(releaseData)
|
||||
|
||||
if (releaseData) {
|
||||
const trackList = releaseData.list.map((track) => {
|
||||
return {
|
||||
...track,
|
||||
_id: track._id,
|
||||
@ -563,7 +565,7 @@ export default class PlaylistCreatorSteps extends React.Component {
|
||||
})
|
||||
|
||||
this.setState({
|
||||
playlistData: playlistData,
|
||||
releaseData: releaseData,
|
||||
trackList: trackList,
|
||||
fileList: trackList.map((track) => {
|
||||
return {
|
||||
@ -597,23 +599,15 @@ export default class PlaylistCreatorSteps extends React.Component {
|
||||
<div className="stepContent">
|
||||
{
|
||||
React.createElement(this.steps[this.state.currentStep].crender, {
|
||||
playlist: this.state.playlistData,
|
||||
release: this.state.releaseData,
|
||||
|
||||
trackList: this.state.trackList,
|
||||
fileList: this.state.fileList,
|
||||
|
||||
onTitleChange: (title) => {
|
||||
this.updatePlaylistData("title", title)
|
||||
},
|
||||
onDescriptionChange: (description) => {
|
||||
this.updatePlaylistData("description", description)
|
||||
},
|
||||
onPlaylistCoverChange: (url) => {
|
||||
this.updatePlaylistData("cover", url)
|
||||
},
|
||||
onVisibilityChange: (visibility) => {
|
||||
this.updatePlaylistData("public", visibility)
|
||||
onValueChange: (key, value) => {
|
||||
this.updateReleaseData(key, value)
|
||||
},
|
||||
|
||||
onDeletePlaylist: this.handleDeletePlaylist,
|
||||
|
||||
handleUploadTrack: this.handleUploadTrack,
|
||||
|
@ -15,7 +15,7 @@ export default () => {
|
||||
return <PagePanelWithNavMenu
|
||||
tabs={Tabs}
|
||||
navMenuHeader={NavMenuHeader}
|
||||
defaultTab="explore"
|
||||
defaultTab="library"
|
||||
primaryPanelClassName="full"
|
||||
useSetQueryType
|
||||
transition
|
||||
|
@ -16,7 +16,6 @@ export default [
|
||||
key: "library",
|
||||
label: "Library",
|
||||
icon: "MdLibraryMusic",
|
||||
disabled: true,
|
||||
component: LibraryTab,
|
||||
},
|
||||
{
|
||||
|
@ -1,31 +1,65 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
|
||||
import PlaylistsModel from "models/playlists"
|
||||
import MusicModel from "models/music"
|
||||
|
||||
import PlaylistView from "components/Music/PlaylistView"
|
||||
|
||||
export default (props) => {
|
||||
const PlayView = (props) => {
|
||||
const play_id = props.params.play_id
|
||||
const service = props.query.service
|
||||
|
||||
const [playlist, setPlaylist] = React.useState(null)
|
||||
const [offset, setOffset] = React.useState(0)
|
||||
|
||||
const loadData = async () => {
|
||||
const response = await PlaylistsModel.getPlaylist(play_id).catch((err) => {
|
||||
console.error(err)
|
||||
app.message.error("Failed to load playlist")
|
||||
return null
|
||||
})
|
||||
const loadData = async (_offset) => {
|
||||
if (_offset) {
|
||||
const response = await MusicModel.getPlaylistItems({
|
||||
playlist_id: play_id,
|
||||
service,
|
||||
|
||||
if (response) {
|
||||
setPlaylist(response)
|
||||
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)
|
||||
app.message.error("Failed to load playlist")
|
||||
return null
|
||||
})
|
||||
|
||||
if (response) {
|
||||
setPlaylist(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onLoadMore = async () => {
|
||||
setOffset((prev) => {
|
||||
const newValue = prev + 20
|
||||
|
||||
loadData(newValue)
|
||||
|
||||
return newValue
|
||||
})
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
loadData()
|
||||
|
||||
app.layout.toggleCenteredContent(false)
|
||||
}, [])
|
||||
|
||||
if (!playlist) {
|
||||
@ -35,5 +69,10 @@ export default (props) => {
|
||||
return <PlaylistView
|
||||
playlist={playlist}
|
||||
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
|
||||
}
|
||||
|
||||
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 FollowsModel from "./follows"
|
||||
import LivestreamModel from "./livestream"
|
||||
import PlaylistsModel from "./playlists"
|
||||
import PostModel from "./post"
|
||||
import SessionModel from "./session"
|
||||
import SyncModel from "./sync"
|
||||
@ -22,7 +21,6 @@ function createHandlers() {
|
||||
feed: getEndpointsFromModel(FeedModel),
|
||||
follows: getEndpointsFromModel(FollowsModel),
|
||||
livestream: getEndpointsFromModel(LivestreamModel),
|
||||
playlists: getEndpointsFromModel(PlaylistsModel),
|
||||
post: getEndpointsFromModel(PostModel),
|
||||
session: getEndpointsFromModel(SessionModel),
|
||||
sync: getEndpointsFromModel(SyncModel),
|
||||
@ -35,7 +33,6 @@ export {
|
||||
FeedModel,
|
||||
FollowsModel,
|
||||
LivestreamModel,
|
||||
PlaylistsModel,
|
||||
PostModel,
|
||||
SessionModel,
|
||||
SyncModel,
|
||||
|
@ -7,12 +7,51 @@ export default class MusicModel {
|
||||
return globalThis.__comty_shared_state.instances["music"]
|
||||
}
|
||||
|
||||
// TODO: Move external services fetching to API
|
||||
static getFavorites = async ({
|
||||
useTidal = false,
|
||||
limit,
|
||||
offset,
|
||||
}) => {
|
||||
/**
|
||||
* Retrieves track data for a given ID.
|
||||
*
|
||||
* @param {string} id - The ID of the track.
|
||||
* @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 limitPerRequesters = limit
|
||||
@ -54,19 +93,19 @@ export default class MusicModel {
|
||||
|
||||
result = await pmap(
|
||||
requesters,
|
||||
async (requester) => {
|
||||
async requester => {
|
||||
const data = await requester()
|
||||
|
||||
return data
|
||||
},
|
||||
{
|
||||
concurrency: 3
|
||||
}
|
||||
concurrency: 3,
|
||||
},
|
||||
)
|
||||
|
||||
let total_length = 0
|
||||
|
||||
result.forEach((result) => {
|
||||
result.forEach(result => {
|
||||
total_length += result.total_length
|
||||
})
|
||||
|
||||
@ -84,11 +123,182 @@ export default class MusicModel {
|
||||
}
|
||||
}
|
||||
|
||||
static search = async (keywords, {
|
||||
limit = 5,
|
||||
offset = 0,
|
||||
useTidal = false,
|
||||
}) => {
|
||||
/**
|
||||
* Retrieves favorite playlists based on the specified parameters.
|
||||
*
|
||||
* @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({
|
||||
instance: MusicModel.api_instance,
|
||||
method: "GET",
|
||||
@ -98,9 +308,219 @@ export default class MusicModel {
|
||||
limit,
|
||||
offset,
|
||||
useTidal,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 = []
|
||||
|
||||
const removeWithTracks = req.query.remove_with_tracks === "true"
|
||||
// const removeWithTracks = req.query.remove_with_tracks === "true"
|
||||
|
||||
let playlist = await Playlist.findOne({
|
||||
_id: req.params.playlist_id,
|
||||
@ -29,9 +29,9 @@ export default async (req, res) => {
|
||||
_id: req.params.playlist_id,
|
||||
})
|
||||
|
||||
if (removeWithTracks) {
|
||||
removedTracksIds = await RemoveTracks(playlist.list)
|
||||
}
|
||||
// if (removeWithTracks) {
|
||||
// removedTracksIds = await RemoveTracks(playlist.list)
|
||||
// }
|
||||
|
||||
return res.json({
|
||||
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"
|
||||
|
||||
export default async (req, res) => {
|
||||
const { playlist_id } = req.params
|
||||
const { limit, offset } = req.query
|
||||
|
||||
let playlist = await Playlist.findOne({
|
||||
_id: playlist_id,
|
||||
@ -10,6 +11,14 @@ export default async (req, res) => {
|
||||
return false
|
||||
})
|
||||
|
||||
if (!playlist) {
|
||||
playlist = await Release.findOne({
|
||||
_id: playlist_id,
|
||||
}).catch((err) => {
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
playlist = playlist.toObject()
|
||||
|
||||
if (playlist.public === false) {
|
||||
|
@ -1,47 +1,68 @@
|
||||
import { Playlist, Track } from "@shared-classes/DbModels"
|
||||
import { Playlist, 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)
|
||||
}
|
||||
if (!req.session) {
|
||||
return new AuthorizationError(req, res)
|
||||
}
|
||||
|
||||
const { keywords, limit = 10, offset = 0 } = req.query
|
||||
const user_id = req.session.user_id.toString()
|
||||
const { keywords, limit = 10, offset = 0 } = req.query
|
||||
|
||||
let searchQuery = {
|
||||
user_id,
|
||||
}
|
||||
const user_id = req.session.user_id.toString()
|
||||
|
||||
if (keywords) {
|
||||
searchQuery = {
|
||||
...searchQuery,
|
||||
title: {
|
||||
$regex: keywords,
|
||||
$options: "i",
|
||||
},
|
||||
}
|
||||
}
|
||||
let searchQuery = {
|
||||
user_id,
|
||||
}
|
||||
|
||||
let playlists = await Playlist.find(searchQuery)
|
||||
.sort({ created_at: -1 })
|
||||
.catch((err) => false)
|
||||
//.limit(limit)
|
||||
//.skip(offset)
|
||||
if (keywords) {
|
||||
searchQuery = {
|
||||
...searchQuery,
|
||||
title: {
|
||||
$regex: keywords,
|
||||
$options: "i",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (!playlists) {
|
||||
return new NotFoundError("Playlists not found")
|
||||
}
|
||||
const playlistsCount = await Playlist.count(searchQuery)
|
||||
const releasesCount = await Release.count(searchQuery)
|
||||
|
||||
playlists = await Promise.all(playlists.map(async (playlist) => {
|
||||
playlist.list = await Track.find({
|
||||
_id: [
|
||||
...playlist.list,
|
||||
]
|
||||
})
|
||||
let total_length = playlistsCount + releasesCount
|
||||
|
||||
return playlist
|
||||
}))
|
||||
let playlists = await Playlist.find(searchQuery)
|
||||
.sort({ created_at: -1 })
|
||||
.limit(limit)
|
||||
.skip(offset)
|
||||
|
||||
return res.json(playlists)
|
||||
}
|
||||
playlists = playlists.map((playlist) => {
|
||||
playlist = playlist.toObject()
|
||||
|
||||
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({
|
||||
_id: [...playlist.list],
|
||||
})
|
||||
|
||||
return playlist
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
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 axios from "axios"
|
||||
|
||||
const PlaylistAllowedUpdateFields = [
|
||||
const AllowedUpdateFields = [
|
||||
"title",
|
||||
"cover",
|
||||
"album",
|
||||
"artist",
|
||||
"description",
|
||||
"type",
|
||||
"public",
|
||||
]
|
||||
|
||||
@ -112,44 +112,44 @@ export default async (req, res) => {
|
||||
return new AuthorizationError(req, res)
|
||||
}
|
||||
|
||||
let playlist = null
|
||||
let release = null
|
||||
|
||||
if (!req.body._id) {
|
||||
playlist = new Playlist({
|
||||
release = new Release({
|
||||
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,
|
||||
type: req.body.type,
|
||||
public: req.body.public,
|
||||
list: req.body.list,
|
||||
public: req.body.public,
|
||||
})
|
||||
|
||||
await playlist.save()
|
||||
await release.save()
|
||||
} else {
|
||||
playlist = await Playlist.findById(req.body._id)
|
||||
release = await Release.findById(req.body._id)
|
||||
}
|
||||
|
||||
if (!playlist) {
|
||||
return new NotFoundError(req, res, "Playlist not found")
|
||||
if (!release) {
|
||||
return new NotFoundError(req, res, "Release not found")
|
||||
}
|
||||
|
||||
if (playlist.user_id !== req.session.user_id.toString()) {
|
||||
return new PermissionError(req, res, "You don't have permission to edit this playlist")
|
||||
if (release.user_id !== req.session.user_id.toString()) {
|
||||
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(),
|
||||
fullName: userData.fullName,
|
||||
username: userData.username,
|
||||
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") {
|
||||
return track
|
||||
}
|
||||
@ -168,19 +168,19 @@ export default async (req, res) => {
|
||||
}
|
||||
}))
|
||||
|
||||
PlaylistAllowedUpdateFields.forEach((field) => {
|
||||
AllowedUpdateFields.forEach((field) => {
|
||||
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) {
|
||||
return new NotFoundError(req, res, "Playlist not updated")
|
||||
if (!release) {
|
||||
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,61 +1,79 @@
|
||||
import { Playlist, Track } from "@shared-classes/DbModels"
|
||||
import { Release, Playlist, Track } from "@shared-classes/DbModels"
|
||||
import TidalAPI from "@shared-classes/TidalAPI"
|
||||
|
||||
async function searchRoute(req, res) {
|
||||
const {
|
||||
keywords,
|
||||
limit = 5,
|
||||
offset = 0,
|
||||
useTidal = false
|
||||
} = req.query
|
||||
try {
|
||||
const {
|
||||
keywords,
|
||||
limit = 5,
|
||||
offset = 0,
|
||||
useTidal = false
|
||||
} = req.query
|
||||
|
||||
let results = {
|
||||
playlists: [],
|
||||
artists: [],
|
||||
albums: [],
|
||||
tracks: [],
|
||||
}
|
||||
|
||||
let searchQuery = {
|
||||
public: true,
|
||||
}
|
||||
|
||||
if (keywords) {
|
||||
searchQuery = {
|
||||
...searchQuery,
|
||||
title: {
|
||||
$regex: keywords,
|
||||
$options: "i",
|
||||
},
|
||||
// TODO: Improve searching by album or artist
|
||||
let results = {
|
||||
playlists: [],
|
||||
artists: [],
|
||||
tracks: [],
|
||||
album: [],
|
||||
ep: [],
|
||||
single: [],
|
||||
}
|
||||
}
|
||||
|
||||
let playlists = await Playlist.find(searchQuery)
|
||||
.limit(limit)
|
||||
.skip(offset)
|
||||
let searchQuery = {
|
||||
public: true,
|
||||
}
|
||||
|
||||
if (playlists) {
|
||||
results.playlists = playlists
|
||||
}
|
||||
if (keywords) {
|
||||
searchQuery = {
|
||||
...searchQuery,
|
||||
title: {
|
||||
$regex: keywords,
|
||||
$options: "i",
|
||||
},
|
||||
// TODO: Improve searching by album or artist
|
||||
}
|
||||
}
|
||||
|
||||
let tracks = await Track.find(searchQuery)
|
||||
.limit(limit)
|
||||
.skip(offset)
|
||||
let releases = await Release.find(searchQuery)
|
||||
.limit(limit)
|
||||
.skip(offset)
|
||||
|
||||
if (tracks) {
|
||||
results.tracks = tracks
|
||||
}
|
||||
if (releases && releases.length > 0) {
|
||||
releases.forEach((release) => {
|
||||
results[release.type].push(release)
|
||||
})
|
||||
}
|
||||
|
||||
if (toBoolean(useTidal)) {
|
||||
const tidalResult = await TidalAPI.search({
|
||||
query: keywords
|
||||
let playlists = await Playlist.find(searchQuery)
|
||||
.limit(limit)
|
||||
.skip(offset)
|
||||
|
||||
if (playlists) {
|
||||
results.playlists = playlists
|
||||
}
|
||||
|
||||
let tracks = await Track.find(searchQuery)
|
||||
.limit(limit)
|
||||
.skip(offset)
|
||||
|
||||
if (tracks) {
|
||||
results.tracks = tracks
|
||||
}
|
||||
|
||||
if (toBoolean(useTidal)) {
|
||||
const tidalResult = await TidalAPI.search({
|
||||
query: keywords
|
||||
})
|
||||
|
||||
results.tracks = [...results.tracks, ...tidalResult]
|
||||
}
|
||||
|
||||
return res.json(results)
|
||||
} catch (error) {
|
||||
return res.status(500).json({
|
||||
error: error.message,
|
||||
})
|
||||
|
||||
results.tracks = [...results.tracks, ...tidalResult]
|
||||
}
|
||||
|
||||
return res.json(results)
|
||||
}
|
||||
|
||||
export default (router) => {
|
||||
|
@ -1,8 +1,12 @@
|
||||
import { Controller } from "linebridge/dist/server"
|
||||
|
||||
import pmap from "p-map"
|
||||
|
||||
import getPosts from "./services/getPosts"
|
||||
|
||||
import getGlobalReleases from "./services/getGlobalReleases"
|
||||
import getReleasesFromFollowing from "./services/getReleasesFromFollowing"
|
||||
import getPlaylistsFromFollowing from "./services/getPlaylistsFromFollowing"
|
||||
import getPlaylistsFromGlobal from "./services/getPlaylistsFromGlobal"
|
||||
|
||||
export default class FeedController extends Controller {
|
||||
static refName = "FeedController"
|
||||
@ -28,13 +32,6 @@ export default class FeedController extends Controller {
|
||||
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
|
||||
posts = posts.map((data) => {
|
||||
data.type = "post"
|
||||
@ -42,15 +39,8 @@ export default class FeedController extends Controller {
|
||||
return data
|
||||
})
|
||||
|
||||
playlists = playlists.map((data) => {
|
||||
data.type = "playlist"
|
||||
|
||||
return data
|
||||
})
|
||||
|
||||
let feed = [
|
||||
...posts,
|
||||
...playlists,
|
||||
]
|
||||
|
||||
// sort feed
|
||||
@ -73,7 +63,7 @@ export default class FeedController extends Controller {
|
||||
}
|
||||
|
||||
// fetch playlists from global
|
||||
const result = await getPlaylistsFromGlobal({
|
||||
const result = await getGlobalReleases({
|
||||
for_user_id,
|
||||
limit: req.query?.limit,
|
||||
skip: req.query?.trim,
|
||||
@ -93,30 +83,31 @@ export default class FeedController extends Controller {
|
||||
})
|
||||
}
|
||||
|
||||
let feed = {
|
||||
followingArtists: [],
|
||||
global: [],
|
||||
mayLike: [],
|
||||
}
|
||||
const searchers = [
|
||||
getGlobalReleases,
|
||||
//getReleasesFromFollowing,
|
||||
//getPlaylistsFromFollowing,
|
||||
]
|
||||
|
||||
// fetch playlists from following
|
||||
const followingArtistsPlaylists = await getPlaylistsFromFollowing({
|
||||
for_user_id,
|
||||
limit: req.query?.limit,
|
||||
skip: req.query?.trim,
|
||||
})
|
||||
let result = await pmap(
|
||||
searchers,
|
||||
async (fn, index) => {
|
||||
const data = await fn({
|
||||
for_user_id,
|
||||
limit: req.query?.limit,
|
||||
skip: req.query?.trim,
|
||||
})
|
||||
|
||||
// fetch playlists from global
|
||||
const globalPlaylists = await getPlaylistsFromGlobal({
|
||||
for_user_id,
|
||||
limit: req.query?.limit,
|
||||
skip: req.query?.trim,
|
||||
})
|
||||
return data
|
||||
}, {
|
||||
concurrency: 3,
|
||||
},)
|
||||
|
||||
feed.followingArtists = followingArtistsPlaylists
|
||||
feed.global = globalPlaylists
|
||||
result = result.reduce((acc, cur) => {
|
||||
return [...acc, ...cur]
|
||||
}, [])
|
||||
|
||||
return res.json(feed)
|
||||
return res.json(result)
|
||||
}
|
||||
},
|
||||
"/posts": {
|
||||
@ -144,31 +135,6 @@ export default class FeedController extends Controller {
|
||||
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) => {
|
||||
const {
|
||||
@ -6,7 +6,7 @@ export default async (payload) => {
|
||||
skip = 0,
|
||||
} = payload
|
||||
|
||||
let playlists = await Playlist.find({
|
||||
let releases = await Release.find({
|
||||
$or: [
|
||||
{ public: true },
|
||||
]
|
||||
@ -15,5 +15,11 @@ export default async (payload) => {
|
||||
.limit(limit)
|
||||
.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.user = await User.findOne({
|
||||
_id: playlist.user_id,
|
||||
}).catch((err) => {
|
||||
return {
|
||||
username: "Unknown user",
|
||||
}
|
||||
})
|
||||
|
||||
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