diff --git a/app/hooks/list.ts b/app/hooks/list.ts index 7c95e0c..5a561c4 100644 --- a/app/hooks/list.ts +++ b/app/hooks/list.ts @@ -48,7 +48,7 @@ export const useFetchList2 = (fetchList: () => Promise, resetList: () => v } export const useFetchPaginatedList = ( - fetchList: (size?: number, offset?: number) => Promise, + fetchList: (size: number, offset: number) => Promise, pageSize: number, ) => { const [list, setList] = useState([]) @@ -94,32 +94,3 @@ export const useFetchPaginatedList = ( return { list, refreshing, refresh, reset, fetchNextPage } } - -export const useFetchPaginatedList2 = (fetchNextListPage: () => Promise, resetList: () => void) => { - const [refreshing, setRefreshing] = useState(false) - - const refresh = useCallback(async () => { - setRefreshing(true) - - resetList() - await fetchNextListPage() - - setRefreshing(false) - }, [fetchNextListPage, resetList]) - - useActiveServerRefresh( - useCallback(async () => { - await refresh() - }, [refresh]), - ) - - const fetchNextPage = useCallback(async () => { - setRefreshing(true) - - await fetchNextListPage() - - setRefreshing(false) - }, [fetchNextListPage]) - - return { refreshing, refresh, fetchNextPage } -} diff --git a/app/models/music.ts b/app/models/music.ts index d4e5fb8..124824a 100644 --- a/app/models/music.ts +++ b/app/models/music.ts @@ -63,7 +63,7 @@ export interface Song { duration?: number starred?: Date - streamUri: string + // streamUri: string coverArt?: string } diff --git a/app/screens/Home.tsx b/app/screens/Home.tsx index a21b91c..d96d7ed 100644 --- a/app/screens/Home.tsx +++ b/app/screens/Home.tsx @@ -3,17 +3,21 @@ import CoverArt from '@app/components/CoverArt' import GradientScrollView from '@app/components/GradientScrollView' import Header from '@app/components/Header' import NothingHere from '@app/components/NothingHere' +import { useFetchPaginatedList } from '@app/hooks/list' import { useActiveServerRefresh } from '@app/hooks/server' import { AlbumListItem } from '@app/models/music' +import { Album } from '@app/state/library' import { selectMusic } from '@app/state/music' import { selectSettings } from '@app/state/settings' -import { useStore } from '@app/state/store' +import { Store, useStore } from '@app/state/store' import colors from '@app/styles/colors' import font from '@app/styles/font' -import { GetAlbumListType } from '@app/subsonic/params' +import { GetAlbumList2Params, GetAlbumList2TypeBase, GetAlbumListType } from '@app/subsonic/params' import { useNavigation } from '@react-navigation/native' -import React, { useCallback } from 'react' +import produce from 'immer' +import React, { createContext, useCallback, useContext, useEffect, useState } from 'react' import { RefreshControl, ScrollView, StatusBar, StyleSheet, Text, View } from 'react-native' +import create from 'zustand' const titles: { [key in GetAlbumListType]?: string } = { recent: 'Recently Played', @@ -49,9 +53,11 @@ const AlbumItem = React.memo<{ }) const Category = React.memo<{ - name?: string - data: AlbumListItem[] -}>(({ name, data }) => { + type: string +}>(({ type }) => { + const list = useHomeStore(useCallback(store => store.lists[type] || [], [type])) + const albums = useStore(useCallback(store => list.map(id => store.entities.albums[id]), [list])) + const Albums = () => ( - {data.map(album => ( + {albums.map(album => ( ))} @@ -73,24 +79,55 @@ const Category = React.memo<{ return ( -
{name}
- {data.length > 0 ? : } +
{titles[type as GetAlbumListType] || ''}
+ {albums.length > 0 ? : }
) }) +interface HomeState { + lists: { [type: string]: string[] } + setList: (type: string, list: string[]) => void +} + +const useHomeStore = create((set, get) => ({ + lists: {}, + + setList: (type, list) => { + set( + produce(state => { + state.lists[type] = list + }), + ) + }, +})) + const Home = () => { + const [refreshing, setRefreshing] = useState(false) const types = useStore(selectSettings.homeLists) - const lists = useStore(selectMusic.homeLists) - const updating = useStore(selectMusic.homeListsUpdating) - const update = useStore(selectMusic.fetchHomeLists) - const clear = useStore(selectMusic.clearHomeLists) + const fetchAlbumList = useStore(store => store.fetchLibraryAlbumList) + const setList = useHomeStore(store => store.setList) + + const refresh = useCallback(async () => { + setRefreshing(true) + + await Promise.all( + types.map(async type => { + console.log('fetch', type) + const ids = await fetchAlbumList({ type: type as GetAlbumList2TypeBase, size: 20, offset: 0 }) + console.log('set', type) + setList(type, ids) + }), + ) + + setRefreshing(false) + }, [fetchAlbumList, setList, types]) useActiveServerRefresh( useCallback(() => { - clear() - update() - }, [clear, update]), + types.forEach(type => setList(type, [])) + refresh() + }, [refresh, setList, types]), ) return ( @@ -99,15 +136,15 @@ const Home = () => { contentContainerStyle={styles.scrollContentContainer} refreshControl={ }> {types.map(type => ( - + ))} diff --git a/app/screens/LibraryAlbums.tsx b/app/screens/LibraryAlbums.tsx index 842c00a..53ae5f6 100644 --- a/app/screens/LibraryAlbums.tsx +++ b/app/screens/LibraryAlbums.tsx @@ -2,15 +2,16 @@ import { AlbumContextPressable } from '@app/components/ContextMenu' import CoverArt from '@app/components/CoverArt' import FilterButton, { OptionData } from '@app/components/FilterButton' import GradientFlatList from '@app/components/GradientFlatList' -import { useFetchPaginatedList2 } from '@app/hooks/list' +import { useFetchPaginatedList } from '@app/hooks/list' import { Album, AlbumListItem } from '@app/models/music' import { selectSettings } from '@app/state/settings' import { Store, useStore } from '@app/state/store' import colors from '@app/styles/colors' import font from '@app/styles/font' -import { GetAlbumList2Type } from '@app/subsonic/params' +import { GetAlbumList2Params, GetAlbumList2Type } from '@app/subsonic/params' import { useNavigation } from '@react-navigation/native' -import React, { useEffect } from 'react' +import pick from 'lodash.pick' +import React, { useCallback, useEffect } from 'react' import { StyleSheet, Text, useWindowDimensions, View } from 'react-native' const AlbumItem = React.memo<{ @@ -55,35 +56,57 @@ const filterOptions: OptionData[] = [ // { text: 'By Genre...', value: 'byGenre' }, ] -const selectAlbumList = (store: Store) => { - return Object.values(store.entities.albumsList) - .flat() - .map(id => store.entities.albums[id]) -} - const AlbumsList = () => { - const list = useStore(selectAlbumList) - - const fetchAlbumsNextPage = useStore(store => store.fetchLibraryAlbumsNextPage) - const resetAlbumsList = useStore(store => store.resetLibraryAlbumsList) - const { refreshing, refresh, fetchNextPage } = useFetchPaginatedList2(fetchAlbumsNextPage, resetAlbumsList) - const filter = useStore(selectSettings.libraryAlbumFilter) const setFilter = useStore(selectSettings.setLibraryAlbumFilter) + const fetchAlbumList = useStore(store => store.fetchLibraryAlbumList) + const fetchPage = useCallback( + (size: number, offset: number) => { + let params: GetAlbumList2Params + switch (filter.type) { + case 'byYear': + params = { + size, + offset, + type: filter.type, + fromYear: filter.fromYear, + toYear: filter.toYear, + } + break + case 'byGenre': + params = { + size, + offset, + type: filter.type, + genre: filter.genre, + } + break + default: + params = { + size, + offset, + type: filter.type, + } + break + } + return fetchAlbumList(params) + }, + [fetchAlbumList, filter.fromYear, filter.genre, filter.toYear, filter.type], + ) + + const { list, refreshing, refresh, fetchNextPage } = useFetchPaginatedList(fetchPage, 300) + const albums = useStore(useCallback(store => list.map(id => store.entities.albums[id]), [list])) + const layout = useWindowDimensions() const size = layout.width / 3 - styles.itemWrapper.marginHorizontal * 2 const height = size + 36 - useEffect(() => { - refresh() - }, [refresh, filter]) - return ( ({ album, size, height }))} + data={albums.map(album => ({ album, size, height }))} renderItem={AlbumListRenderItem} keyExtractor={item => item.album.id} numColumns={3} diff --git a/app/screens/SongListView.tsx b/app/screens/SongListView.tsx index dd51547..b8f388b 100644 --- a/app/screens/SongListView.tsx +++ b/app/screens/SongListView.tsx @@ -6,12 +6,13 @@ import ListItem from '@app/components/ListItem' import ListPlayerControls from '@app/components/ListPlayerControls' import { useCoverArtFile } from '@app/hooks/cache' import { useAlbumWithSongs, usePlaylistWithSongs } from '@app/hooks/music' -import { AlbumWithSongs, PlaylistWithSongs, Song } from '@app/models/music' +import { Album, AlbumWithSongs, PlaylistListItem, PlaylistWithSongs, Song } from '@app/models/music' import { useStore } from '@app/state/store' import { selectTrackPlayer } from '@app/state/trackplayer' import colors from '@app/styles/colors' import font from '@app/styles/font' -import React, { useState } from 'react' +import pick from 'lodash.pick' +import React, { useCallback, useEffect, useState } from 'react' import { ActivityIndicator, StyleSheet, Text, View } from 'react-native' type SongListType = 'album' | 'playlist' @@ -46,18 +47,19 @@ const SongRenderItem: React.FC<{ const SongListDetails = React.memo<{ title: string type: SongListType - songList?: AlbumWithSongs | PlaylistWithSongs + songList?: Album | PlaylistListItem + songs?: Song[] subtitle?: string -}>(({ title, songList, subtitle, type }) => { +}>(({ title, songList, songs, subtitle, type }) => { const coverArtFile = useCoverArtFile(songList?.coverArt, 'thumbnail') const [headerColor, setHeaderColor] = useState(undefined) const setQueue = useStore(selectTrackPlayer.setQueue) - if (!songList) { + if (!songList || !songs) { return } - const _songs = [...songList.songs] + const _songs = [...songs] let typeName = '' if (type === 'album') { @@ -106,7 +108,7 @@ const SongListDetails = React.memo<{ {songList.name} {subtitle ? {subtitle} : <>} - {songList.songs.length > 0 && ( + {songs.length > 0 && ( (({ id, title }) => { - const album = useAlbumWithSongs(id) + // const album = useAlbumWithSongs(id) + + const album = useStore(useCallback(store => store.entities.albums[id], [id])) + const songs = useStore( + useCallback( + store => { + const ids = store.entities.albumSongs[id] + return ids ? ids.map(i => store.entities.songs[i]) : undefined + }, + [id], + ), + ) + + const fetchAlbum = useStore(store => store.fetchLibraryAlbum) + + useEffect(() => { + if (!album || !songs) { + fetchAlbum(id) + } + }, [album, fetchAlbum, id, songs]) + return ( diff --git a/app/state/library.ts b/app/state/library.ts index cd8a087..0cda9db 100644 --- a/app/state/library.ts +++ b/app/state/library.ts @@ -1,12 +1,22 @@ import { Store } from '@app/state/store' -import { AlbumID3Element, ArtistID3Element, ArtistInfo2Element, ChildElement } from '@app/subsonic/elements' -import { GetAlbumList2Params } from '@app/subsonic/params' +import { + AlbumID3Element, + ArtistID3Element, + ArtistInfo2Element, + ChildElement, + PlaylistElement, +} from '@app/subsonic/elements' +import { GetAlbumList2Params, Search3Params, StarParams } from '@app/subsonic/params' import { GetAlbumList2Response, + GetAlbumResponse, GetArtistInfo2Response, GetArtistResponse, GetArtistsResponse, + GetPlaylistResponse, + GetPlaylistsResponse, GetTopSongsResponse, + Search3Response, SubsonicResponse, } from '@app/subsonic/responses' import produce from 'immer' @@ -54,6 +64,14 @@ export interface Album { year?: number } +export interface Playlist { + itemType: 'playlist' + id: string + name: string + comment?: string + coverArt?: string +} + export interface Song { itemType: 'song' id: string @@ -66,11 +84,15 @@ export interface Song { discNumber?: number duration?: number starred?: Date - - // streamUri: string coverArt?: string } +export interface SearchResults { + artists: string[] + albums: string[] + songs: string[] +} + function mapArtist(artist: ArtistID3Element): Artist { return { itemType: 'artist', @@ -102,6 +124,16 @@ function mapAlbum(album: AlbumID3Element): Album { } } +function mapPlaylist(playlist: PlaylistElement): Playlist { + return { + itemType: 'playlist', + id: playlist.id, + name: playlist.name, + comment: playlist.comment, + coverArt: playlist.coverArt, + } +} + function mapSong(song: ChildElement): Song { return { itemType: 'song', @@ -119,6 +151,10 @@ function mapSong(song: ChildElement): Song { } } +function mapId(entities: { id: string }[]): string[] { + return entities.map(e => e.id) +} + export type LibrarySlice = { entities: { artists: ById @@ -127,9 +163,15 @@ export type LibrarySlice = { artistNameTopSongs: OneToMany albums: ById + albumSongs: OneToMany + + // todo: remove these and store in component state albumsList: PaginatedList albumsListSize: number + playlists: ById + playlistSongs: OneToMany + songs: ById } @@ -138,17 +180,29 @@ export type LibrarySlice = { fetchLibraryArtists: () => Promise fetchLibraryArtist: (id: string) => Promise fetchLibraryArtistInfo: (artistId: string) => Promise + fetchLibraryArtistTopSongs: (artistName: string) => Promise resetLibraryArtists: () => void - fetchLibraryTopSongs: (artistName: string) => Promise + fetchLibraryAlbum: (id: string) => Promise - fetchLibraryAlbumsNextPage: () => Promise - resetLibraryAlbumsList: () => void + fetchLibraryPlaylists: () => Promise + fetchLibraryPlaylist: (id: string) => Promise + + fetchLibraryAlbumList: (params: GetAlbumList2Params) => Promise + fetchLibrarySearchResults: (params: Search3Params) => Promise + star: (params: StarParams) => Promise + unstar: (params: StarParams) => Promise } -function nextOffest(list: PaginatedList): number { - const pages = Object.keys(list).map(k => parseInt(k, 10)) - return pages.length > 0 ? pages.sort((a, b) => a - b)[pages.length - 1] : 0 +function reduceById(collection: T[]): ById { + return collection.reduce((acc, value) => { + acc[value.id] = value + return acc + }, {} as ById) +} + +function mergeById(object: T, source: T): void { + merge(object, source) } const defaultEntities = () => ({ @@ -160,6 +214,10 @@ const defaultEntities = () => ({ albums: {}, albumsList: {}, albumsListSize: 300, + albumSongs: {}, + + playlists: {}, + playlistSongs: {}, songs: {}, }) @@ -186,15 +244,13 @@ export const createLibrarySlice = (set: SetState, get: GetState): return } - const artists = response.data.artists.reduce((acc, value) => { - acc[value.id] = mapArtist(value) - return acc - }, {} as ById) + const artists = response.data.artists.map(mapArtist) + const artistsById = reduceById(artists) set( produce(state => { - state.entities.artists = artists - state.entities.artistAlbums = pick(state.entities.artistAlbums, Object.keys(artists)) + state.entities.artists = artistsById + state.entities.artistAlbums = pick(state.entities.artistAlbums, mapId(artists)) }), ) }, @@ -212,18 +268,15 @@ export const createLibrarySlice = (set: SetState, get: GetState): return } - const albums = response.data.albums.reduce((acc, value) => { - acc[value.id] = mapAlbum(value) - return acc - }, {} as ById) - const artist = mapArtist(response.data.artist) + const albums = response.data.albums.map(mapAlbum) + const albumsById = reduceById(albums) set( produce(state => { state.entities.artists[id] = artist - state.entities.artistAlbums[id] = Object.keys(albums) - merge(state.entities.albums, albums) + state.entities.artistAlbums[id] = mapId(albums) + mergeById(state.entities.albums, albumsById) }), ) }, @@ -259,7 +312,7 @@ export const createLibrarySlice = (set: SetState, get: GetState): ) }, - fetchLibraryTopSongs: async artistName => { + fetchLibraryArtistTopSongs: async artistName => { const client = get().client if (!client) { return @@ -273,80 +326,200 @@ export const createLibrarySlice = (set: SetState, get: GetState): } const topSongs = response.data.songs.map(mapSong) + const topSongsById = reduceById(topSongs) set( produce(state => { - merge(state.entities.songs, topSongs) - state.entities.artistNameTopSongs[artistName] = topSongs.map(s => s.id) + mergeById(state.entities.songs, topSongsById) + state.entities.artistNameTopSongs[artistName] = mapId(topSongs) }), ) }, - fetchLibraryAlbumsNextPage: async () => { + fetchLibraryAlbum: async id => { const client = get().client if (!client) { return } - const filter = get().settings.screens.library.albums - const size = get().entities.albumsListSize - const offset = nextOffest(get().entities.albumsList) + let response: SubsonicResponse + try { + response = await client.getAlbum({ id }) + } catch { + return + } - let params: GetAlbumList2Params - switch (filter.type) { - case 'byYear': - params = { - size, - offset, - type: filter.type, - fromYear: filter.fromYear, - toYear: filter.toYear, - } - break - case 'byGenre': - params = { - size, - offset, - type: filter.type, - genre: filter.genre, - } - break - default: - params = { - size, - offset, - type: filter.type, - } - break + const album = mapAlbum(response.data.album) + const songs = response.data.songs.map(mapSong) + const songsById = reduceById(songs) + + set( + produce(state => { + state.entities.albums[id] = album + state.entities.albumSongs[id] = mapId(songs) + mergeById(state.entities.songs, songsById) + }), + ) + }, + + fetchLibraryPlaylists: async () => { + const client = get().client + if (!client) { + return + } + + let response: SubsonicResponse + try { + response = await client.getPlaylists() + } catch { + return + } + + const playlists = response.data.playlists.map(mapPlaylist) + const playlistsById = reduceById(playlists) + + set( + produce(state => { + state.entities.playlists = playlistsById + state.entities.playlistSongs = pick(state.entities.playlistSongs, mapId(playlists)) + }), + ) + }, + + fetchLibraryPlaylist: async id => { + const client = get().client + if (!client) { + return + } + + let response: SubsonicResponse + try { + response = await client.getPlaylist({ id }) + } catch { + return + } + + const playlist = mapPlaylist(response.data.playlist) + const songs = response.data.playlist.songs.map(mapSong) + const songsById = reduceById(songs) + + set( + produce(state => { + state.entities.playlists[id] = playlist + state.entities.playlistSongs[id] = mapId(songs) + mergeById(state.entities.songs, songsById) + }), + ) + }, + + fetchLibraryAlbumList: async params => { + const client = get().client + if (!client) { + return [] } let response: SubsonicResponse try { response = await client.getAlbumList2(params) } catch { - return + return [] } - const albums = response.data.albums.reduce((acc, value) => { - acc[value.id] = mapAlbum(value) - return acc - }, {} as ById) + const albums = response.data.albums.map(mapAlbum) + const albumsById = reduceById(albums) set( produce(state => { - if (response.data.albums.length <= 0) { - return + mergeById(state.entities.albums, albumsById) + }), + ) + + return mapId(albums) + }, + + fetchLibrarySearchResults: async params => { + const empty = { artists: [], albums: [], songs: [] } + + const client = get().client + if (!client) { + return empty + } + + let response: SubsonicResponse + try { + response = await client.search3(params) + } catch { + return empty + } + + const artists = response.data.artists.map(mapArtist) + const artistsById = reduceById(artists) + const albums = response.data.albums.map(mapAlbum) + const albumsById = reduceById(albums) + const songs = response.data.songs.map(mapSong) + const songsById = reduceById(songs) + + set( + produce(state => { + mergeById(state.entities.artists, artistsById) + mergeById(state.entities.albums, albumsById) + mergeById(state.entities.songs, songsById) + }), + ) + + return { + artists: mapId(artists), + albums: mapId(albums), + songs: mapId(songs), + } + }, + + star: async params => { + const client = get().client + if (!client) { + return + } + + try { + await client.star(params) + } catch { + return + } + + set( + produce(state => { + if (params.id) { + state.entities.songs[params.id].starred = new Date() + } else if (params.albumId) { + state.entities.albums[params.albumId].starred = new Date() + } else if (params.artistId) { + state.entities.artists[params.artistId].starred = new Date() } - merge(state.entities.albums, albums) - state.entities.albumsList[offset + size] = response.data.albums.map(a => a.id) }), ) }, - resetLibraryAlbumsList: () => { + unstar: async params => { + const client = get().client + if (!client) { + return + } + + try { + await client.unstar(params) + } catch { + return + } + set( produce(state => { - state.entities.albumsList = {} + if (params.id) { + state.entities.songs[params.id].starred = undefined + } else if (params.albumId) { + state.entities.albums[params.albumId].starred = undefined + } else if (params.artistId) { + state.entities.artists[params.artistId].starred = undefined + } }), ) }, diff --git a/app/state/trackplayer.ts b/app/state/trackplayer.ts index 02f599d..09a20c0 100644 --- a/app/state/trackplayer.ts +++ b/app/state/trackplayer.ts @@ -217,14 +217,6 @@ export const createTrackPlayerSlice = (set: SetState, get: GetState, get: GetState): TrackPlayerMapSlice => ({ mapSongtoTrackExt: async song => { let artwork = require('@res/fallback.png') - if (song.coverArt) { - const filePath = await get().fetchCoverArtFilePath(song.coverArt) - if (filePath) { - artwork = filePath - } - } + // if (song.coverArt) { + // const filePath = await get().fetchCoverArtFilePath(song.coverArt) + // if (filePath) { + // artwork = filePath + // } + // } + + console.log('mapping', song.title) return { id: song.id, title: song.title, artist: song.artist || 'Unknown Artist', album: song.album || 'Unknown Album', - url: song.streamUri, + url: get().buildStreamUri(song.id), userAgent, artwork, coverArt: song.coverArt,