A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz

feat: add ArtistListeners component and integrate with Artist page

+349 -252
+13 -5
apps/web/src/api/library.ts
··· 29 29 30 30 export const getArtistTracks = async ( 31 31 uri: string, 32 - limit = 10, 32 + limit = 10 33 33 ): Promise< 34 34 { 35 35 id: string; ··· 45 45 > => { 46 46 const response = await client.get( 47 47 "/xrpc/app.rocksky.artist.getArtistTracks", 48 - { params: { uri, limit } }, 48 + { params: { uri, limit } } 49 49 ); 50 50 return response.data.tracks; 51 51 }; 52 52 53 53 export const getArtistAlbums = async ( 54 54 uri: string, 55 - limit = 10, 55 + limit = 10 56 56 ): Promise< 57 57 { 58 58 id: string; ··· 65 65 > => { 66 66 const response = await client.get( 67 67 "/xrpc/app.rocksky.artist.getArtistAlbums", 68 - { params: { uri, limit } }, 68 + { params: { uri, limit } } 69 69 ); 70 70 return response.data.albums; 71 71 }; ··· 96 96 "/xrpc/app.rocksky.actor.getActorLovedSongs", 97 97 { 98 98 params: { did, limit, offset }, 99 - }, 99 + } 100 100 ); 101 101 return response.data.tracks; 102 102 }; ··· 114 114 }); 115 115 return response.data; 116 116 }; 117 + 118 + export const getArtistListeners = async (uri: string, limit: number) => { 119 + const response = await client.get( 120 + "/xrpc/app.rocksky.artist.getArtistListeners", 121 + { params: { uri, limit } } 122 + ); 123 + return response.data; 124 + };
+81 -72
apps/web/src/hooks/useLibrary.tsx
··· 1 1 import { useQuery } from "@tanstack/react-query"; 2 2 import { 3 - getAlbum, 4 - getAlbums, 5 - getArtist, 6 - getArtistAlbums, 7 - getArtists, 8 - getArtistTracks, 9 - getLovedTracks, 10 - getSongByUri, 11 - getTracks, 3 + getAlbum, 4 + getAlbums, 5 + getArtist, 6 + getArtistAlbums, 7 + getArtistListeners, 8 + getArtists, 9 + getArtistTracks, 10 + getLovedTracks, 11 + getSongByUri, 12 + getTracks, 12 13 } from "../api/library"; 13 14 14 15 export const useSongByUriQuery = (uri: string) => 15 - useQuery({ 16 - queryKey: ["songByUri", uri], 17 - queryFn: () => getSongByUri(uri), 18 - enabled: !!uri, 19 - }); 16 + useQuery({ 17 + queryKey: ["songByUri", uri], 18 + queryFn: () => getSongByUri(uri), 19 + enabled: !!uri, 20 + }); 20 21 21 22 export const useArtistTracksQuery = (uri: string, limit = 10) => 22 - useQuery({ 23 - queryKey: ["artistTracks", uri, limit], 24 - queryFn: () => getArtistTracks(uri, limit), 25 - enabled: !!uri, 26 - }); 23 + useQuery({ 24 + queryKey: ["artistTracks", uri, limit], 25 + queryFn: () => getArtistTracks(uri, limit), 26 + enabled: !!uri, 27 + }); 27 28 28 29 export const useArtistAlbumsQuery = (uri: string, limit = 10) => 29 - useQuery({ 30 - queryKey: ["artistAlbums", uri, limit], 31 - queryFn: () => getArtistAlbums(uri, limit), 32 - enabled: !!uri, 33 - }); 30 + useQuery({ 31 + queryKey: ["artistAlbums", uri, limit], 32 + queryFn: () => getArtistAlbums(uri, limit), 33 + enabled: !!uri, 34 + }); 34 35 35 36 export const useArtistsQuery = (did: string, offset = 0, limit = 30) => 36 - useQuery({ 37 - queryKey: ["artists", did, offset, limit], 38 - queryFn: () => getArtists(did, offset, limit), 39 - enabled: !!did, 40 - select: (data) => 41 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 42 - data?.artists.map((x: any) => ({ 43 - ...x, 44 - scrobbles: x.playCount, 45 - })), 46 - }); 37 + useQuery({ 38 + queryKey: ["artists", did, offset, limit], 39 + queryFn: () => getArtists(did, offset, limit), 40 + enabled: !!did, 41 + select: (data) => 42 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 43 + data?.artists.map((x: any) => ({ 44 + ...x, 45 + scrobbles: x.playCount, 46 + })), 47 + }); 47 48 48 49 export const useAlbumsQuery = (did: string, offset = 0, limit = 12) => 49 - useQuery({ 50 - queryKey: ["albums", did, offset, limit], 51 - queryFn: () => getAlbums(did, offset, limit), 52 - enabled: !!did, 53 - select: (data) => 54 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 55 - data?.albums.map((x: any) => ({ 56 - ...x, 57 - scrobbles: x.playCount, 58 - })), 59 - }); 50 + useQuery({ 51 + queryKey: ["albums", did, offset, limit], 52 + queryFn: () => getAlbums(did, offset, limit), 53 + enabled: !!did, 54 + select: (data) => 55 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 56 + data?.albums.map((x: any) => ({ 57 + ...x, 58 + scrobbles: x.playCount, 59 + })), 60 + }); 60 61 61 62 export const useTracksQuery = (did: string, offset = 0, limit = 20) => 62 - useQuery({ 63 - queryKey: ["tracks", did, offset, limit], 64 - queryFn: () => getTracks(did, offset, limit), 65 - enabled: !!did, 66 - select: (data) => 67 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 68 - data?.tracks.map((x: any) => ({ 69 - ...x, 70 - scrobbles: x.playCount, 71 - })), 72 - }); 63 + useQuery({ 64 + queryKey: ["tracks", did, offset, limit], 65 + queryFn: () => getTracks(did, offset, limit), 66 + enabled: !!did, 67 + select: (data) => 68 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 69 + data?.tracks.map((x: any) => ({ 70 + ...x, 71 + scrobbles: x.playCount, 72 + })), 73 + }); 73 74 74 75 export const useLovedTracksQuery = (did: string, offset = 0, limit = 20) => 75 - useQuery({ 76 - queryKey: ["lovedTracks", did, offset, limit], 77 - queryFn: () => getLovedTracks(did, offset, limit), 78 - enabled: !!did, 79 - }); 76 + useQuery({ 77 + queryKey: ["lovedTracks", did, offset, limit], 78 + queryFn: () => getLovedTracks(did, offset, limit), 79 + enabled: !!did, 80 + }); 80 81 81 82 export const useAlbumQuery = (did: string, rkey: string) => 82 - useQuery({ 83 - queryKey: ["album", did, rkey], 84 - queryFn: () => getAlbum(did, rkey), 85 - enabled: !!did && !!rkey, 86 - }); 83 + useQuery({ 84 + queryKey: ["album", did, rkey], 85 + queryFn: () => getAlbum(did, rkey), 86 + enabled: !!did && !!rkey, 87 + }); 87 88 88 89 export const useArtistQuery = (did: string, rkey: string) => 89 - useQuery({ 90 - queryKey: ["artist", did, rkey], 91 - queryFn: () => getArtist(did, rkey), 92 - enabled: !!did && !!rkey, 93 - }); 90 + useQuery({ 91 + queryKey: ["artist", did, rkey], 92 + queryFn: () => getArtist(did, rkey), 93 + enabled: !!did && !!rkey, 94 + }); 95 + 96 + export const useArtistListenersQuery = (uri: string, limit = 10) => 97 + useQuery({ 98 + queryKey: ["artistListeners", uri, limit], 99 + queryFn: () => getArtistListeners(uri, limit), 100 + enabled: !!uri, 101 + select: (data) => data.listeners, 102 + });
+178 -175
apps/web/src/pages/artist/Artist.tsx
··· 10 10 import ArtistIcon from "../../components/Icons/Artist"; 11 11 import Shout from "../../components/Shout/Shout"; 12 12 import { 13 - useArtistAlbumsQuery, 14 - useArtistQuery, 15 - useArtistTracksQuery, 13 + useArtistAlbumsQuery, 14 + useArtistListenersQuery, 15 + useArtistQuery, 16 + useArtistTracksQuery, 16 17 } from "../../hooks/useLibrary"; 17 18 import Main from "../../layouts/Main"; 18 19 import Albums from "./Albums"; 20 + import ArtistListeners from "./ArtistListeners"; 19 21 import PopularSongs from "./PopularSongs"; 20 22 21 23 const Group = styled.div` ··· 26 28 `; 27 29 28 30 const Artist = () => { 29 - const { did, rkey } = useParams({ strict: false }); 31 + const { did, rkey } = useParams({ strict: false }); 30 32 31 - const uri = `at://${did}/app.rocksky.artist/${rkey}`; 32 - const artistResult = useArtistQuery(did!, rkey!); 33 - const artistTracksResult = useArtistTracksQuery(uri); 34 - const artistAlbumsResult = useArtistAlbumsQuery(uri); 33 + const uri = `at://${did}/app.rocksky.artist/${rkey}`; 34 + const artistResult = useArtistQuery(did!, rkey!); 35 + const artistTracksResult = useArtistTracksQuery(uri); 36 + const artistAlbumsResult = useArtistAlbumsQuery(uri); 37 + const artistListenersResult = useArtistListenersQuery(uri); 35 38 36 - const artist = useAtomValue(artistAtom); 37 - const setArtist = useSetAtom(artistAtom); 38 - const [topTracks, setTopTracks] = useState< 39 - { 40 - id: string; 41 - title: string; 42 - artist: string; 43 - albumArtist: string; 44 - albumArt: string; 45 - uri: string; 46 - scrobbles: number; 47 - albumUri?: string; 48 - artistUri?: string; 49 - }[] 50 - >([]); 51 - const [topAlbums, setTopAlbums] = useState< 52 - { 53 - id: string; 54 - title: string; 55 - artist: string; 56 - albumArt: string; 57 - artistUri: string; 58 - uri: string; 59 - }[] 60 - >([]); 39 + const artist = useAtomValue(artistAtom); 40 + const setArtist = useSetAtom(artistAtom); 41 + const [topTracks, setTopTracks] = useState< 42 + { 43 + id: string; 44 + title: string; 45 + artist: string; 46 + albumArtist: string; 47 + albumArt: string; 48 + uri: string; 49 + scrobbles: number; 50 + albumUri?: string; 51 + artistUri?: string; 52 + }[] 53 + >([]); 54 + const [topAlbums, setTopAlbums] = useState< 55 + { 56 + id: string; 57 + title: string; 58 + artist: string; 59 + albumArt: string; 60 + artistUri: string; 61 + uri: string; 62 + }[] 63 + >([]); 61 64 62 - useEffect(() => { 63 - if (artistResult.isLoading || artistResult.isError) { 64 - return; 65 - } 65 + useEffect(() => { 66 + if (artistResult.isLoading || artistResult.isError) { 67 + return; 68 + } 66 69 67 - if (!artistResult.data || !did) { 68 - return; 69 - } 70 + if (!artistResult.data || !did) { 71 + return; 72 + } 70 73 71 - setArtist({ 72 - id: artistResult.data.id, 73 - name: artistResult.data.name, 74 - born: artistResult.data.born, 75 - bornIn: artistResult.data.bornIn, 76 - died: artistResult.data.died, 77 - listeners: artistResult.data.uniqueListeners, 78 - scrobbles: artistResult.data.playCount, 79 - picture: artistResult.data.picture, 80 - tags: artistResult.data.tags, 81 - uri: artistResult.data.uri, 82 - spotifyLink: artistResult.data.spotifyLink, 83 - }); 84 - // eslint-disable-next-line react-hooks/exhaustive-deps 85 - }, [artistResult.data, artistResult.isLoading, artistResult.isError, did]); 74 + setArtist({ 75 + id: artistResult.data.id, 76 + name: artistResult.data.name, 77 + born: artistResult.data.born, 78 + bornIn: artistResult.data.bornIn, 79 + died: artistResult.data.died, 80 + listeners: artistResult.data.uniqueListeners, 81 + scrobbles: artistResult.data.playCount, 82 + picture: artistResult.data.picture, 83 + tags: artistResult.data.tags, 84 + uri: artistResult.data.uri, 85 + spotifyLink: artistResult.data.spotifyLink, 86 + }); 87 + // eslint-disable-next-line react-hooks/exhaustive-deps 88 + }, [artistResult.data, artistResult.isLoading, artistResult.isError, did]); 86 89 87 - useEffect(() => { 88 - if (artistTracksResult.isLoading || artistTracksResult.isError) { 89 - return; 90 - } 90 + useEffect(() => { 91 + if (artistTracksResult.isLoading || artistTracksResult.isError) { 92 + return; 93 + } 91 94 92 - if (!artistTracksResult.data || !did) { 93 - return; 94 - } 95 + if (!artistTracksResult.data || !did) { 96 + return; 97 + } 95 98 96 - setTopTracks( 97 - artistTracksResult.data.map((track) => ({ 98 - ...track, 99 - scrobbles: track.playCount || 1, 100 - })), 101 - ); 102 - }, [ 103 - artistTracksResult.data, 104 - artistTracksResult.isLoading, 105 - artistTracksResult.isError, 106 - did, 107 - ]); 99 + setTopTracks( 100 + artistTracksResult.data.map((track) => ({ 101 + ...track, 102 + scrobbles: track.playCount || 1, 103 + })), 104 + ); 105 + }, [ 106 + artistTracksResult.data, 107 + artistTracksResult.isLoading, 108 + artistTracksResult.isError, 109 + did, 110 + ]); 108 111 109 - useEffect(() => { 110 - if (artistAlbumsResult.isLoading || artistAlbumsResult.isError) { 111 - return; 112 - } 112 + useEffect(() => { 113 + if (artistAlbumsResult.isLoading || artistAlbumsResult.isError) { 114 + return; 115 + } 113 116 114 - if (!artistAlbumsResult.data || !did) { 115 - return; 116 - } 117 + if (!artistAlbumsResult.data || !did) { 118 + return; 119 + } 117 120 118 - setTopAlbums(artistAlbumsResult.data); 119 - }, [ 120 - artistAlbumsResult.data, 121 - artistAlbumsResult.isLoading, 122 - artistAlbumsResult.isError, 123 - did, 124 - ]); 121 + setTopAlbums(artistAlbumsResult.data); 122 + }, [ 123 + artistAlbumsResult.data, 124 + artistAlbumsResult.isLoading, 125 + artistAlbumsResult.isError, 126 + did, 127 + ]); 125 128 126 - const loading = 127 - artistResult.isLoading || 128 - artistTracksResult.isLoading || 129 - artistAlbumsResult.isLoading; 130 - return ( 131 - <Main> 132 - <div className="pb-[100px] pt-[50px]"> 133 - <Group> 134 - <div className="mr-[20px]"> 135 - {artist?.picture && !loading && ( 136 - <Avatar name={artist?.name} src={artist?.picture} size="150px" /> 137 - )} 138 - {!artist?.picture && !loading && ( 139 - <div className="w-[150px] h-[150px] rounded-[80px] bg-[rgba(243, 243, 243, 0.725)] flex items-center justify-center"> 140 - <div 141 - style={{ 142 - height: 60, 143 - width: 60, 144 - }} 145 - > 146 - <ArtistIcon color="rgba(66, 87, 108, 0.65)" /> 147 - </div> 148 - </div> 149 - )} 150 - </div> 151 - {artist && !loading && ( 152 - <div style={{ flex: 1 }}> 153 - <HeadingMedium 154 - marginTop={"20px"} 155 - marginBottom={0} 156 - className="!text-[var(--color-text)]" 157 - > 158 - {artist?.name} 159 - </HeadingMedium> 160 - <div className="mt-[20px] flex flex-row"> 161 - <div className="mr-[20px]"> 162 - <LabelMedium 163 - margin={0} 164 - className="!text-[var(--color-text-muted)]" 165 - > 166 - Listeners 167 - </LabelMedium> 168 - <HeadingXSmall 169 - margin={0} 170 - className="!text-[var(--color-text)]" 171 - > 172 - {numeral(artist?.listeners).format("0,0")} 173 - </HeadingXSmall> 174 - </div> 175 - <div> 176 - <LabelMedium 177 - margin={0} 178 - className="!text-[var(--color-text-muted)]" 179 - > 180 - Scrobbles 181 - </LabelMedium> 182 - <HeadingXSmall 183 - margin={0} 184 - className="!text-[var(--color-text)]" 185 - > 186 - {numeral(artist?.scrobbles).format("0,0")} 187 - </HeadingXSmall> 188 - </div> 189 - <div className="flex items-center justify-end flex-1 mr-[10px]"> 190 - <a 191 - href={`https://pdsls.dev/at/${uri.replace("at://", "")}`} 192 - target="_blank" 193 - className="text-[var(--color-text)] no-underline bg-[var(--color-default-button)] rounded-[10px] p-[16px] pl-[25px] pr-[25px]" 194 - > 195 - <ExternalLink 196 - size={24} 197 - className="mr-[10px] text-[var(--color-text)]" 198 - /> 199 - View on PDSls 200 - </a> 201 - </div> 202 - </div> 203 - </div> 204 - )} 205 - </Group> 206 - 207 - <PopularSongs topTracks={topTracks} /> 208 - <Albums topAlbums={topAlbums} /> 129 + const loading = 130 + artistResult.isLoading || 131 + artistTracksResult.isLoading || 132 + artistAlbumsResult.isLoading; 133 + return ( 134 + <Main> 135 + <div className="pb-[100px] pt-[50px]"> 136 + <Group> 137 + <div className="mr-[20px]"> 138 + {artist?.picture && !loading && ( 139 + <Avatar name={artist?.name} src={artist?.picture} size="150px" /> 140 + )} 141 + {!artist?.picture && !loading && ( 142 + <div className="w-[150px] h-[150px] rounded-[80px] bg-[rgba(243, 243, 243, 0.725)] flex items-center justify-center"> 143 + <div 144 + style={{ 145 + height: 60, 146 + width: 60, 147 + }} 148 + > 149 + <ArtistIcon color="rgba(66, 87, 108, 0.65)" /> 150 + </div> 151 + </div> 152 + )} 153 + </div> 154 + {artist && !loading && ( 155 + <div style={{ flex: 1 }}> 156 + <HeadingMedium 157 + marginTop={"20px"} 158 + marginBottom={0} 159 + className="!text-[var(--color-text)]" 160 + > 161 + {artist?.name} 162 + </HeadingMedium> 163 + <div className="mt-[20px] flex flex-row"> 164 + <div className="mr-[20px]"> 165 + <LabelMedium 166 + margin={0} 167 + className="!text-[var(--color-text-muted)]" 168 + > 169 + Listeners 170 + </LabelMedium> 171 + <HeadingXSmall 172 + margin={0} 173 + className="!text-[var(--color-text)]" 174 + > 175 + {numeral(artist?.listeners).format("0,0")} 176 + </HeadingXSmall> 177 + </div> 178 + <div> 179 + <LabelMedium 180 + margin={0} 181 + className="!text-[var(--color-text-muted)]" 182 + > 183 + Scrobbles 184 + </LabelMedium> 185 + <HeadingXSmall 186 + margin={0} 187 + className="!text-[var(--color-text)]" 188 + > 189 + {numeral(artist?.scrobbles).format("0,0")} 190 + </HeadingXSmall> 191 + </div> 192 + <div className="flex items-center justify-end flex-1 mr-[10px]"> 193 + <a 194 + href={`https://pdsls.dev/at/${uri.replace("at://", "")}`} 195 + target="_blank" 196 + className="text-[var(--color-text)] no-underline bg-[var(--color-default-button)] rounded-[10px] p-[16px] pl-[25px] pr-[25px]" 197 + > 198 + <ExternalLink 199 + size={24} 200 + className="mr-[10px] text-[var(--color-text)]" 201 + /> 202 + View on PDSls 203 + </a> 204 + </div> 205 + </div> 206 + </div> 207 + )} 208 + </Group> 209 209 210 - <Shout type="artist" /> 211 - </div> 212 - </Main> 213 - ); 210 + <PopularSongs topTracks={topTracks} /> 211 + <Albums topAlbums={topAlbums} /> 212 + <ArtistListeners listeners={artistListenersResult.data} /> 213 + <Shout type="artist" /> 214 + </div> 215 + </Main> 216 + ); 214 217 }; 215 218 216 219 export default Artist;
+74
apps/web/src/pages/artist/ArtistListeners/ArtistListeners.tsx
··· 1 + import { Link } from "@tanstack/react-router"; 2 + import { Avatar } from "baseui/avatar"; 3 + import { HeadingSmall } from "baseui/typography"; 4 + 5 + interface ArtistListenersProps { 6 + listeners: { 7 + id: string; 8 + did: string; 9 + handle: string; 10 + displayName: string; 11 + avatar: string; 12 + mostListenedSong: { 13 + title: string; 14 + uri: string; 15 + playCount: number; 16 + }; 17 + totalPlays: number; 18 + rank: number; 19 + }[]; 20 + } 21 + 22 + function ArtistListeners(props: ArtistListenersProps) { 23 + return ( 24 + <> 25 + <HeadingSmall 26 + marginBottom={"15px"} 27 + className="!text-[var(--color-text)] !mb-[30px]" 28 + > 29 + Listeners 30 + </HeadingSmall> 31 + {props.listeners?.map((item) => ( 32 + <div 33 + key={item.id} 34 + className="mb-[30px] flex flex-row items-center gap-[20px]" 35 + > 36 + <Link 37 + to={`/profile/${item.handle}` as string} 38 + className="no-underline" 39 + > 40 + <Avatar src={item.avatar} name={item.displayName} size={"60px"} /> 41 + </Link> 42 + <div> 43 + <Link 44 + to={`/profile/${item.handle}` as string} 45 + className="text-[var(--color-text)] hover:underline no-underline" 46 + style={{ fontWeight: 600 }} 47 + > 48 + @{item.handle} 49 + </Link> 50 + <div className="!text-[14px] mt-[5px]"> 51 + Listens to{" "} 52 + {item.mostListenedSong.uri && ( 53 + <Link 54 + to={`${item.mostListenedSong.uri?.split("at:/")[1].replace("app.rocksky.", "")}`} 55 + className="text-[var(--color-primary)] hover:underline no-underline" 56 + > 57 + {item.mostListenedSong.title} 58 + </Link> 59 + )} 60 + {!item.mostListenedSong.uri && ( 61 + <div style={{ fontWeight: 600 }}> 62 + {item.mostListenedSong.title} 63 + </div> 64 + )}{" "} 65 + a lot 66 + </div> 67 + </div> 68 + </div> 69 + ))} 70 + </> 71 + ); 72 + } 73 + 74 + export default ArtistListeners;
+3
apps/web/src/pages/artist/ArtistListeners/index.tsx
··· 1 + import ArtistListeners from "./ArtistListeners"; 2 + 3 + export default ArtistListeners;