rework player & music services

This commit is contained in:
SrGooglo 2023-10-10 13:35:58 +00:00
parent fd2a22344c
commit af20663266
54 changed files with 3205 additions and 863 deletions

View 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

View 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;
}
}
}
}

View File

@ -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>
}

View File

@ -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;
}
}
}

View File

@ -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>
}

View File

@ -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;
}
}
}
}

View File

@ -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

View File

@ -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;

View File

@ -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

View File

@ -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;
}

View File

@ -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;

View File

@ -159,6 +159,9 @@ export default class SeekBar extends React.Component {
return <div
className={classnames(
"player-seek_bar",
{
["stopped"]: this.props.stopped,
}
)}
>
<div

View 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

View 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;
}
}

View 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
}

View File

@ -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
})

View File

@ -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,
]

View File

@ -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;
}
}

View File

@ -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>
}

View File

@ -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

View File

@ -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;
}
}
}

View File

@ -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",

View File

@ -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>
}

View 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%;
}

View File

@ -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

View File

@ -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")
})

View File

@ -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,

View File

@ -15,7 +15,7 @@ export default () => {
return <PagePanelWithNavMenu
tabs={Tabs}
navMenuHeader={NavMenuHeader}
defaultTab="explore"
defaultTab="library"
primaryPanelClassName="full"
useSetQueryType
transition

View File

@ -16,7 +16,6 @@ export default [
key: "library",
label: "Library",
icon: "MdLibraryMusic",
disabled: true,
component: LibraryTab,
},
{

View File

@ -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

View File

@ -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
}
}

View File

@ -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,

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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,

View File

@ -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) {

View File

@ -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,
})
}

View File

@ -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)
}

View 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,
}
}

View File

@ -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,
})
}

View File

@ -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)
}

View File

@ -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,
})
}

View File

@ -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
})
}

View File

@ -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)
}

View File

@ -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) => {

View File

@ -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)
}
}
}
}
}

View File

@ -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
}

View File

@ -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
}))

View File

@ -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
}

View 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()

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}