start of music store refactor

moving stuff into a state cache
better separate it from view logic
This commit is contained in:
austinried
2022-03-13 17:09:18 +09:00
parent 09ca4974c5
commit c45784bcbe
7 changed files with 361 additions and 21 deletions

View File

@@ -28,6 +28,25 @@ export const useFetchList = <T>(fetchList: () => Promise<T[]>) => {
return { list, refreshing, refresh, reset }
}
export const useFetchList2 = (fetchList: () => Promise<void>, resetList: () => Promise<void>) => {
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 = <T>(
fetchList: (size?: number, offset?: number) => Promise<T[]>,
pageSize: number,

View File

@@ -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 (
<>
<Header>Albums</Header>
<View style={styles.albums} onLayout={albumsLayout.onLayout}>
{sortedAlbums.map(a => (
<AlbumItem key={a.id} album={a} height={albumSize} width={albumSize} />
))}
</View>
</>
)
})
const ArtistViewFallback = React.memo(() => (
<GradientBackground style={styles.fallback}>
<ActivityIndicator size="large" color={colors.accent} />
@@ -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 <ArtistViewFallback />
}
const _albums = [...artist.albums]
.sort((a, b) => a.name.localeCompare(b.name))
.sort((a, b) => (b.year || 0) - (a.year || 0))
return (
<View style={styles.container}>
<HeaderBar title={title} headerStyle={[styles.header, animatedOpacity]} />
@@ -115,17 +162,12 @@ const ArtistView = React.memo<{ id: string; title: string }>(({ id, title }) =>
<Text style={styles.title}>{artist.name}</Text>
</View>
<View style={styles.contentContainer}>
{artist.topSongs.length > 0 ? (
{/* {artist.topSongs.length > 0 ? (
<TopSongs songs={artist.topSongs} name={artist.name} artistId={artist.id} />
) : (
<></>
)}
<Header>Albums</Header>
<View style={styles.albums} onLayout={albumsLayout.onLayout}>
{_albums.map(a => (
<AlbumItem key={a.id} album={a} height={albumSize} width={albumSize} />
))}
</View>
)} */}
<ArtistAlbums id={id} />
</View>
</GradientScrollView>
</View>

View File

@@ -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<Artist[]>([])
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 (
<View style={styles.container}>

253
app/state/library.ts Normal file
View File

@@ -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<T> {
[id: string]: T
}
export type OneToMany = ById<string[]>
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<Artist>
artistAlbums: OneToMany
albums: ById<Album>
artistInfo: ById<ArtistInfo>
artistNameTopSongs: OneToMany
songs: ById<Song>
}
fetchLibraryArtists: () => Promise<void>
fetchLibraryArtist: (id: string) => Promise<void>
resetLibraryArtists: () => Promise<void>
// fetchAlbums: (artistId: string) => Promise<void>
fetchLibraryArtistInfo: (artistId: string) => Promise<void>
fetchLibraryTopSongs: (artistName: string) => Promise<void>
}
export const createLibrarySlice = (set: SetState<Store>, get: GetState<Store>): LibrarySlice => ({
entities: {
artists: {},
artistAlbums: {},
albums: {},
artistInfo: {},
artistNameTopSongs: {},
songs: {},
},
fetchLibraryArtists: async () => {
const client = get().client
if (!client) {
return
}
let response: SubsonicResponse<GetArtistsResponse>
try {
response = await client.getArtists()
} catch {
return
}
const artists = response.data.artists.reduce((acc, value) => {
acc[value.id] = mapArtist(value)
return acc
}, {} as ById<Artist>)
set(
produce<LibrarySlice>(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<GetArtistResponse>
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<Album>)
const artist = mapArtist(response.data.artist)
set(
produce<LibrarySlice>(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<LibrarySlice>(state => {
state.entities.artists = {}
state.entities.artistAlbums = {}
}),
)
},
fetchLibraryArtistInfo: async id => {
const client = get().client
if (!client) {
return
}
let response: SubsonicResponse<GetArtistInfo2Response>
try {
response = await client.getArtistInfo2({ id })
} catch {
return
}
const info = mapArtistInfo(id, response.data.artistInfo)
set(
produce<LibrarySlice>(state => {
state.entities.artistInfo[id] = info
}),
)
},
fetchLibraryTopSongs: async artistName => {
const client = get().client
if (!client) {
return
}
let response: SubsonicResponse<GetTopSongsResponse>
try {
response = await client.getTopSongs({ artist: artistName, count: 50 })
} catch {
return
}
const topSongs = response.data.songs.map(mapSong)
set(
produce<LibrarySlice>(state => {
state.entities.songs = merge(state.entities.songs, topSongs)
state.entities.artistNameTopSongs[artistName] = topSongs.map(s => s.id)
}),
)
},
})

View File

@@ -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<Store>(
(set, get) => ({
...createSettingsSlice(set, get),
...createMusicSlice(set, get),
...createLibrarySlice(set, get),
...createMusicMapSlice(set, get),
...createTrackPlayerSlice(set, get),
...createTrackPlayerMapSlice(set, get),