diff --git a/app/hooks/list.ts b/app/hooks/list.ts index 87e8443..48c8cef 100644 --- a/app/hooks/list.ts +++ b/app/hooks/list.ts @@ -28,6 +28,25 @@ export const useFetchList = (fetchList: () => Promise) => { return { list, refreshing, refresh, reset } } +export const useFetchList2 = (fetchList: () => Promise, resetList: () => Promise) => { + const [refreshing, setRefreshing] = useState(false) + + const refresh = useCallback(async () => { + setRefreshing(true) + await fetchList() + setRefreshing(false) + }, [fetchList]) + + useActiveServerRefresh( + useCallback(async () => { + await resetList() + await fetchList() + }, [fetchList, resetList]), + ) + + return { refreshing, refresh } +} + export const useFetchPaginatedList = ( fetchList: (size?: number, offset?: number) => Promise, pageSize: number, diff --git a/app/screens/ArtistView.tsx b/app/screens/ArtistView.tsx index 0caf18c..a0fa421 100644 --- a/app/screens/ArtistView.tsx +++ b/app/screens/ArtistView.tsx @@ -14,7 +14,8 @@ import dimensions from '@app/styles/dimensions' import font from '@app/styles/font' import { useLayout } from '@react-native-community/hooks' import { useNavigation } from '@react-navigation/native' -import React from 'react' +import pick from 'lodash.pick' +import React, { useEffect } from 'react' import { ActivityIndicator, StyleSheet, Text, View } from 'react-native' import { useAnimatedScrollHandler, useAnimatedStyle, useSharedValue } from 'react-native-reanimated' @@ -67,6 +68,40 @@ const TopSongs = React.memo<{ ) }) +const ArtistAlbums = React.memo<{ + id: string +}>(({ id }) => { + const albums = useStore(store => { + const ids = store.entities.artistAlbums[id] + return ids ? pick(store.entities.albums, ids) : undefined + }) + const fetchArtist = useStore(store => store.fetchLibraryArtist) + const albumsLayout = useLayout() + + useEffect(() => { + if (!albums) { + fetchArtist(id) + } + }, [albums, fetchArtist, id]) + + const sortedAlbums = (albums ? Object.values(albums) : []) + .sort((a, b) => a.name.localeCompare(b.name)) + .sort((a, b) => (b.year || 0) - (a.year || 0)) + + const albumSize = albumsLayout.width / 2 - styles.contentContainer.paddingHorizontal / 2 + + return ( + <> +
Albums
+ + {sortedAlbums.map(a => ( + + ))} + + + ) +}) + const ArtistViewFallback = React.memo(() => ( @@ -74,8 +109,14 @@ const ArtistViewFallback = React.memo(() => ( )) const ArtistView = React.memo<{ id: string; title: string }>(({ id, title }) => { - const artist = useArtistInfo(id) - const albumsLayout = useLayout() + // const artist = useArtistInfo(id) + + const artist = useStore(store => store.entities.artists[id]) + const artistInfo = useStore(store => store.entities.artistInfo[id]) + + const fetchArtist = useStore(store => store.fetchLibraryArtist) + const fetchArtistInfo = useStore(store => store.fetchLibraryArtistInfo) + const coverLayout = useLayout() const headerOpacity = useSharedValue(0) @@ -91,16 +132,22 @@ const ArtistView = React.memo<{ id: string; title: string }>(({ id, title }) => } }) - const albumSize = albumsLayout.width / 2 - styles.contentContainer.paddingHorizontal / 2 + useEffect(() => { + if (!artist) { + fetchArtist(id) + } + }, [artist, fetchArtist, id]) + + useEffect(() => { + if (!artistInfo) { + fetchArtistInfo(id) + } + }, [artistInfo, fetchArtistInfo, id]) if (!artist) { return } - const _albums = [...artist.albums] - .sort((a, b) => a.name.localeCompare(b.name)) - .sort((a, b) => (b.year || 0) - (a.year || 0)) - return ( @@ -115,17 +162,12 @@ const ArtistView = React.memo<{ id: string; title: string }>(({ id, title }) => {artist.name} - {artist.topSongs.length > 0 ? ( + {/* {artist.topSongs.length > 0 ? ( ) : ( <> - )} -
Albums
- - {_albums.map(a => ( - - ))} - + )} */} +
diff --git a/app/screens/LibraryArtists.tsx b/app/screens/LibraryArtists.tsx index 9f4f1bb..2f6ef5a 100644 --- a/app/screens/LibraryArtists.tsx +++ b/app/screens/LibraryArtists.tsx @@ -1,13 +1,13 @@ import FilterButton, { OptionData } from '@app/components/FilterButton' import GradientFlatList from '@app/components/GradientFlatList' import ListItem from '@app/components/ListItem' -import { useFetchList } from '@app/hooks/list' +import { useFetchList, useFetchList2 } from '@app/hooks/list' import { Artist } from '@app/models/music' import { ArtistFilterType } from '@app/models/settings' import { selectMusic } from '@app/state/music' import { selectSettings } from '@app/state/settings' import { useStore } from '@app/state/store' -import React, { useEffect, useState } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { StyleSheet, View } from 'react-native' const ArtistRenderItem: React.FC<{ item: Artist }> = ({ item }) => ( @@ -21,13 +21,18 @@ const filterOptions: OptionData[] = [ ] const ArtistsList = () => { - const fetchArtists = useStore(selectMusic.fetchArtists) - const { list, refreshing, refresh } = useFetchList(fetchArtists) + const fetchArtists = useStore(store => store.fetchLibraryArtists) + const resetArtists = useStore(store => store.resetLibraryArtists) + + const { refreshing, refresh } = useFetchList2(fetchArtists, resetArtists) + const artists = useStore(store => store.entities.artists) + const filter = useStore(selectSettings.libraryArtistFilter) const setFilter = useStore(selectSettings.setLibraryArtistFiler) const [sortedList, setSortedList] = useState([]) useEffect(() => { + const list = Object.values(artists) switch (filter.type) { case 'random': setSortedList([...list].sort(() => Math.random() - 0.5)) @@ -39,7 +44,7 @@ const ArtistsList = () => { setSortedList([...list]) break } - }, [list, filter]) + }, [filter.type, artists]) return ( diff --git a/app/state/library.ts b/app/state/library.ts new file mode 100644 index 0000000..dde8042 --- /dev/null +++ b/app/state/library.ts @@ -0,0 +1,253 @@ +import { Store } from '@app/state/store' +import { AlbumID3Element, ArtistID3Element, ArtistInfo2Element, ChildElement } from '@app/subsonic/elements' +import { + GetArtistInfo2Response, + GetArtistResponse, + GetArtistsResponse, + GetTopSongsResponse, + SubsonicResponse, +} from '@app/subsonic/responses' +import produce from 'immer' +import merge from 'lodash.merge' +import pick from 'lodash.pick' +import { GetState, SetState } from 'zustand' + +export interface ById { + [id: string]: T +} + +export type OneToMany = ById + +export interface Artist { + itemType: 'artist' + id: string + name: string + starred?: Date + coverArt?: string +} + +export interface ArtistInfo { + id: string + smallImageUrl?: string + largeImageUrl?: string +} + +export interface Album { + itemType: 'album' + id: string + name: string + artist?: string + artistId?: string + starred?: Date + coverArt?: string + year?: number +} + +export interface Song { + itemType: 'song' + id: string + album?: string + albumId?: string + artist?: string + artistId?: string + title: string + track?: number + discNumber?: number + duration?: number + starred?: Date + + // streamUri: string + coverArt?: string +} + +function mapArtist(artist: ArtistID3Element): Artist { + return { + itemType: 'artist', + id: artist.id, + name: artist.name, + starred: artist.starred, + coverArt: artist.coverArt, + } +} + +function mapArtistInfo(id: string, info: ArtistInfo2Element): ArtistInfo { + return { + id, + smallImageUrl: info.smallImageUrl, + largeImageUrl: info.largeImageUrl, + } +} + +function mapAlbum(album: AlbumID3Element): Album { + return { + itemType: 'album', + id: album.id, + name: album.name, + artist: album.artist, + artistId: album.artist, + starred: album.starred, + coverArt: album.coverArt, + year: album.year, + } +} + +function mapSong(song: ChildElement): Song { + return { + itemType: 'song', + id: song.id, + album: song.album, + albumId: song.albumId, + artist: song.artist, + artistId: song.artistId, + title: song.title, + track: song.track, + discNumber: song.discNumber, + duration: song.duration, + starred: song.starred, + coverArt: song.coverArt, + } +} + +export type LibrarySlice = { + entities: { + artists: ById + artistAlbums: OneToMany + + albums: ById + + artistInfo: ById + artistNameTopSongs: OneToMany + + songs: ById + } + + fetchLibraryArtists: () => Promise + fetchLibraryArtist: (id: string) => Promise + resetLibraryArtists: () => Promise + // fetchAlbums: (artistId: string) => Promise + fetchLibraryArtistInfo: (artistId: string) => Promise + fetchLibraryTopSongs: (artistName: string) => Promise +} + +export const createLibrarySlice = (set: SetState, get: GetState): LibrarySlice => ({ + entities: { + artists: {}, + artistAlbums: {}, + + albums: {}, + + artistInfo: {}, + artistNameTopSongs: {}, + + songs: {}, + }, + + fetchLibraryArtists: async () => { + const client = get().client + if (!client) { + return + } + + let response: SubsonicResponse + try { + response = await client.getArtists() + } catch { + return + } + + const artists = response.data.artists.reduce((acc, value) => { + acc[value.id] = mapArtist(value) + return acc + }, {} as ById) + + set( + produce(state => { + state.entities.artists = artists + state.entities.artistAlbums = pick(state.entities.artistAlbums, Object.keys(artists)) + }), + ) + }, + + fetchLibraryArtist: async id => { + const client = get().client + if (!client) { + return + } + + let response: SubsonicResponse + try { + response = await client.getArtist({ id }) + } catch { + return + } + + const albums = response.data.albums.reduce((acc, value) => { + acc[value.id] = mapAlbum(value) + return acc + }, {} as ById) + + const artist = mapArtist(response.data.artist) + + set( + produce(state => { + state.entities.artists[id] = artist + state.entities.artistAlbums[id] = Object.keys(albums) + state.entities.albums = merge(state.entities.albums, albums) + }), + ) + }, + + resetLibraryArtists: async () => { + set( + produce(state => { + state.entities.artists = {} + state.entities.artistAlbums = {} + }), + ) + }, + + fetchLibraryArtistInfo: async id => { + const client = get().client + if (!client) { + return + } + + let response: SubsonicResponse + try { + response = await client.getArtistInfo2({ id }) + } catch { + return + } + + const info = mapArtistInfo(id, response.data.artistInfo) + + set( + produce(state => { + state.entities.artistInfo[id] = info + }), + ) + }, + + fetchLibraryTopSongs: async artistName => { + const client = get().client + if (!client) { + return + } + + let response: SubsonicResponse + try { + response = await client.getTopSongs({ artist: artistName, count: 50 }) + } catch { + return + } + + const topSongs = response.data.songs.map(mapSong) + + set( + produce(state => { + state.entities.songs = merge(state.entities.songs, topSongs) + state.entities.artistNameTopSongs[artistName] = topSongs.map(s => s.id) + }), + ) + }, +}) diff --git a/app/state/store.ts b/app/state/store.ts index 0a01fdb..69b37bf 100644 --- a/app/state/store.ts +++ b/app/state/store.ts @@ -5,6 +5,7 @@ import create from 'zustand' import { persist, StateStorage } from 'zustand/middleware' import { CacheSlice, createCacheSlice } from './cache' import migrations from './migrations' +import { createLibrarySlice, LibrarySlice } from './library' import { createMusicMapSlice, MusicMapSlice } from './musicmap' import { createTrackPlayerSlice, TrackPlayerSlice } from './trackplayer' import { createTrackPlayerMapSlice, TrackPlayerMapSlice } from './trackplayermap' @@ -13,6 +14,7 @@ const DB_VERSION = migrations.length export type Store = SettingsSlice & MusicSlice & + LibrarySlice & MusicMapSlice & TrackPlayerSlice & TrackPlayerMapSlice & @@ -44,6 +46,7 @@ export const useStore = create( (set, get) => ({ ...createSettingsSlice(set, get), ...createMusicSlice(set, get), + ...createLibrarySlice(set, get), ...createMusicMapSlice(set, get), ...createTrackPlayerSlice(set, get), ...createTrackPlayerMapSlice(set, get), diff --git a/package.json b/package.json index b76a8ad..fe719f6 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "@xmldom/xmldom": "^0.7.0", "immer": "^9.0.6", "lodash.debounce": "^4.0.8", + "lodash.merge": "^4.6.2", + "lodash.pick": "^4.4.0", "md5": "^2.3.0", "react": "17.0.2", "react-native": "0.67.1", @@ -56,6 +58,8 @@ "@react-native-community/eslint-config": "^2.0.0", "@types/jest": "^26.0.23", "@types/lodash.debounce": "^4.0.6", + "@types/lodash.merge": "^4.6.6", + "@types/lodash.pick": "^4.4.6", "@types/md5": "^2.3.0", "@types/react-native-vector-icons": "^6.4.7", "@types/react-test-renderer": "^16.9.2", diff --git a/yarn.lock b/yarn.lock index f4332f9..9575132 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1492,6 +1492,20 @@ dependencies: "@types/lodash" "*" +"@types/lodash.merge@^4.6.6": + version "4.6.6" + resolved "https://registry.yarnpkg.com/@types/lodash.merge/-/lodash.merge-4.6.6.tgz#b84b403c1d31bc42d51772d1cd5557fa008cd3d6" + integrity sha512-IB90krzMf7YpfgP3u/EvZEdXVvm4e3gJbUvh5ieuI+o+XqiNEt6fCzqNRaiLlPVScLI59RxIGZMQ3+Ko/DJ8vQ== + dependencies: + "@types/lodash" "*" + +"@types/lodash.pick@^4.4.6": + version "4.4.6" + resolved "https://registry.yarnpkg.com/@types/lodash.pick/-/lodash.pick-4.4.6.tgz#ae4e8f109e982786313bb6aac4b1a73aefa6e9be" + integrity sha512-u8bzA16qQ+8dY280z3aK7PoWb3fzX5ATJ0rJB6F+uqchOX2VYF02Aqa+8aYiHiHgPzQiITqCgeimlyKFy4OA6g== + dependencies: + "@types/lodash" "*" + "@types/lodash@*", "@types/lodash@^4.14.53": version "4.14.178" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.178.tgz#341f6d2247db528d4a13ddbb374bcdc80406f4f8"