progressive loading for favorite tracks

This commit is contained in:
SrGooglo 2023-08-15 19:39:26 +00:00
parent 8e686e1e4e
commit 7c448c854f
9 changed files with 236 additions and 62 deletions

View File

@ -92,6 +92,7 @@
"react-helmet": "6.1.0", "react-helmet": "6.1.0",
"react-i18next": "11.15.3", "react-i18next": "11.15.3",
"react-icons": "^4.8.0", "react-icons": "^4.8.0",
"react-infinite-scroll-component": "^6.1.0",
"react-inlinesvg": "^3.0.1", "react-inlinesvg": "^3.0.1",
"react-intersection-observer": "8.33.1", "react-intersection-observer": "8.33.1",
"react-json-view": "1.21.3", "react-json-view": "1.21.3",

View File

@ -9,6 +9,8 @@ import useWsEvents from "hooks/useWsEvents"
import { WithPlayerContext } from "contexts/WithPlayerContext" import { WithPlayerContext } from "contexts/WithPlayerContext"
import LoadMore from "components/LoadMore"
import { ImageViewer } from "components" import { ImageViewer } from "components"
import { Icons } from "components/Icons" import { Icons } from "components/Icons"
@ -67,17 +69,6 @@ export default (props) => {
})) }))
} }
const returnTracks = (list) => {
return list.map((item, index) => {
return <MusicTrack
order={index + 1}
track={item}
onClickPlayBtn={() => handleOnClickTrack(item)}
onLike={() => handleTrackLike(item)}
/>
})
}
const handleOnSearchChange = (value) => { const handleOnSearchChange = (value) => {
debounceSearch = setTimeout(() => { debounceSearch = setTimeout(() => {
makeSearch(value) makeSearch(value)
@ -120,6 +111,10 @@ export default (props) => {
socketName: "music", socketName: "music",
}) })
React.useEffect(() => {
setPlaylist(props.playlist)
}, [props.playlist])
if (!playlist) { if (!playlist) {
return <antd.Skeleton active /> return <antd.Skeleton active />
} }
@ -164,7 +159,7 @@ export default (props) => {
} }
<div className="play_info_statistics_item"> <div className="play_info_statistics_item">
<p> <p>
<Icons.MdLibraryMusic /> {playlist.list.length} Tracks <Icons.MdLibraryMusic /> {props.length ?? playlist.list.length} Tracks
</p> </p>
</div> </div>
@ -192,11 +187,36 @@ export default (props) => {
/> />
</div> </div>
<WithPlayerContext> {
{ playlist.list.length === 0 && <antd.Empty
returnTracks(searchResults ?? playlist.list) description={
} <>
</WithPlayerContext> <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>
</div> </div>
} }

View File

@ -239,5 +239,12 @@ html {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
.list_content {
display: flex;
flex-direction: column;
gap: 10px;
}
} }
} }

View File

@ -5,30 +5,124 @@ import PlaylistView from "components/Music/PlaylistView"
import MusicModel from "models/music" import MusicModel from "models/music"
export default () => { export default class FavoriteTracks extends React.Component {
const [L_Favorites, R_Favorites, E_Favorites] = app.cores.api.useRequest(MusicModel.getFavorites, { state = {
useTidal: app.cores.sync.getActiveLinkedServices().tidal error: null,
})
if (E_Favorites) { initialLoading: true,
return <antd.Result loading: false,
status="error"
title="Error" list: [],
subTitle={E_Favorites.message} total_length: 0,
empty: false,
hasMore: true,
offset: 0,
}
static loadLimit = 50
componentDidMount = async () => {
await this.loadItems()
}
onLoadMore = async () => {
console.log(`Loading more items...`, this.state.offset)
const newOffset = this.state.offset + FavoriteTracks.loadLimit
await this.setState({
offset: newOffset,
})
await this.loadItems({
offset: newOffset,
})
}
loadItems = async ({
replace = false,
offset = 0,
limit = FavoriteTracks.loadLimit,
} = {}) => {
this.setState({
loading: true,
})
const result = await MusicModel.getFavorites({
useTidal: app.cores.sync.getActiveLinkedServices().tidal,
offset: offset,
limit: limit,
}).catch((err) => {
this.setState({
error: err.message,
})
return false
})
console.log("Loaded favorites => ", result)
if (result) {
const { tracks, total_length } = result
this.setState({
total_length
})
if (tracks.length === 0) {
if (offset === 0) {
this.setState({
empty: true,
})
}
return this.setState({
hasMore: false,
})
}
if (replace) {
this.setState({
list: tracks,
})
} else {
this.setState({
list: [...this.state.list, ...tracks],
})
}
}
this.setState({
loading: false,
initialLoading: false,
})
}
render() {
if (this.state.error) {
return <antd.Result
status="error"
title="Error"
subTitle={this.state.error}
/>
}
if (this.state.initialLoading) {
return <antd.Skeleton active />
}
return <PlaylistView
type="vertical"
playlist={{
title: "Your favorites",
cover: "https://storage.ragestudio.net/comty-static-assets/favorite_song.png",
list: this.state.list
}}
centered={app.isMobile}
onLoadMore={this.onLoadMore}
hasMore={this.state.hasMore}
empty={this.state.empty}
length={this.state.total_length}
/> />
} }
if (L_Favorites) {
return <antd.Skeleton active />
}
return <PlaylistView
type="vertical"
playlist={{
title: "Your favorites",
cover: "https://storage.ragestudio.net/comty-static-assets/favorite_song.png",
list: R_Favorites
}}
centered={app.isMobile}
/>
} }

View File

@ -7,32 +7,47 @@ export default class MusicModel {
return globalThis.__comty_shared_state.instances["music"] return globalThis.__comty_shared_state.instances["music"]
} }
// TODO: Move external services fetching to API
static getFavorites = async ({ static getFavorites = async ({
useTidal = false useTidal = false,
limit,
offset,
}) => { }) => {
let result = [] let result = []
let limitPerRequesters = limit
if (useTidal) {
limitPerRequesters = limitPerRequesters / 2
}
const requesters = [ const requesters = [
async () => { async () => {
let { data } = await request({ let { data } = await request({
instance: MusicModel.api_instance, instance: MusicModel.api_instance,
method: "GET", method: "GET",
url: `/tracks/liked`, url: `/tracks/liked`,
params: {
limit: limitPerRequesters,
offset,
},
}) })
return data return data
}, },
] async () => {
if (!useTidal) {
if (useTidal) { return []
requesters.push(
async () => {
const tidalResult = await SyncModel.tidalCore.getMyFavoriteTracks()
return tidalResult
} }
)
} const tidalResult = await SyncModel.tidalCore.getMyFavoriteTracks({
limit: limitPerRequesters,
offset,
})
return tidalResult
},
]
result = await pmap( result = await pmap(
requesters, requesters,
@ -46,17 +61,24 @@ export default class MusicModel {
} }
) )
result = result.reduce((acc, cur) => { let total_length = 0
return [...acc, ...cur]
result.forEach((result) => {
total_length += result.total_length
})
let tracks = result.reduce((acc, cur) => {
return [...acc, ...cur.tracks]
}, []) }, [])
result = result.sort((a, b) => { tracks = tracks.sort((a, b) => {
return b.liked_at - a.liked_at return b.liked_at - a.liked_at
}) })
console.log(result) return {
total_length,
return result tracks,
}
} }
static search = async (keywords, { static search = async (keywords, {

View File

@ -81,11 +81,18 @@ export default class TidalService {
return data return data
} }
static async getMyFavoriteTracks() { static async getMyFavoriteTracks({
limit = 50,
offset = 0,
} = {}) {
const { data } = await request({ const { data } = await request({
instance: TidalService.api_instance, instance: TidalService.api_instance,
method: "GET", method: "GET",
url: `/services/tidal/favorites/tracks`, url: `/services/tidal/favorites/tracks`,
params: {
limit,
offset,
},
}) })
return data return data

View File

@ -1,14 +1,23 @@
import { Track, TrackLike } from "@shared-classes/DbModels" import { Track, TrackLike } from "@shared-classes/DbModels"
import { AuthorizationError } from "@shared-classes/Errors" import { AuthorizationError } from "@shared-classes/Errors"
// TODO: Fetch from external linked services (like tidal, spotify, ...)
export default async (req, res) => { export default async (req, res) => {
if (!req.session) { if (!req.session) {
return new AuthorizationError(req, res) return new AuthorizationError(req, res)
} }
const { limit = 100, offset = 0 } = req.query
let totalLikedTracks = await TrackLike.count({
user_id: req.session.user_id,
})
let likedTracks = await TrackLike.find({ let likedTracks = await TrackLike.find({
user_id: req.session.user_id, user_id: req.session.user_id,
}) })
.limit(Number(limit))
.skip(Number(offset))
.sort({ created_at: -1 }) .sort({ created_at: -1 })
const likedTracksIds = likedTracks.map((item) => { const likedTracksIds = likedTracks.map((item) => {
@ -44,5 +53,8 @@ export default async (req, res) => {
return indexA - indexB return indexA - indexB
}) })
return res.json(tracks) return res.json({
total_length: totalLikedTracks,
tracks,
})
} }

View File

@ -23,6 +23,8 @@ export default async (req, res) => {
user_id: user_data.id, user_id: user_data.id,
country: user_data.countryCode, country: user_data.countryCode,
access_token: access_token, access_token: access_token,
limit: Number(req.query.limit ?? 50),
offset: Number(req.query.offset ?? 0),
}) })
return res.json(response) return res.json(response)

View File

@ -196,6 +196,8 @@ export default class TidalAPI {
user_id, user_id,
country, country,
access_token, access_token,
limit = 100,
offset = 0,
}) { }) {
const url = `https://api.tidal.com/v1/users/${user_id}/favorites/tracks?countryCode=${country}` const url = `https://api.tidal.com/v1/users/${user_id}/favorites/tracks?countryCode=${country}`
@ -208,11 +210,13 @@ export default class TidalAPI {
}, },
params: { params: {
order: "DATE", order: "DATE",
orderDirection: "DESC" orderDirection: "DESC",
limit: limit,
offset: offset,
} }
}) })
return response.data.items.map((item) => { response.data.items = response.data.items.map((item) => {
// get js time // get js time
item.item.liked_at = new Date(item.created).getTime() item.item.liked_at = new Date(item.created).getTime()
item.item.service = "tidal" item.item.service = "tidal"
@ -238,5 +242,10 @@ export default class TidalAPI {
return item.item return item.item
}) })
return {
total_length: response.data.totalNumberOfItems,
tracks: response.data.items
}
} }
} }