diff --git a/app/components/CoverArt.tsx b/app/components/CoverArt.tsx index f2a3062..54167bc 100644 --- a/app/components/CoverArt.tsx +++ b/app/components/CoverArt.tsx @@ -1,146 +1,92 @@ -import { useArtistInfo, useCoverArtUri } from '@app/hooks/music' +import { useArtistCoverArtFile, useCoverArtFile } from '@app/hooks/music' +import { DownloadFile } from '@app/state/music' import colors from '@app/styles/colors' -import React, { useEffect, useState } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { ActivityIndicator, StyleSheet, View, ViewStyle } from 'react-native' import FastImage, { ImageStyle } from 'react-native-fast-image' type BaseProps = { - imageSize?: 'thumbnail' | 'original' style?: ViewStyle imageStyle?: ImageStyle resizeMode?: keyof typeof FastImage.resizeMode round?: boolean } -type BaseImageProps = BaseProps & { - enableLoading: () => void - disableLoading: () => void - fallbackError: () => void -} - -type ArtistIdProp = { +type ArtistCoverArtProps = BaseProps & { + type: 'artist' artistId: string } -type CoverArtProp = { +type CoverArtProps = BaseProps & { + type: 'cover' coverArt?: string } -type ArtistIdImageProps = BaseImageProps & ArtistIdProp -type CoverArtImageProps = BaseImageProps & CoverArtProp +const Image: React.FC<{ file?: DownloadFile } & BaseProps> = ({ file, style, imageStyle, resizeMode }) => { + const [source, setSource] = useState( + file && file.progress === 1 ? { uri: `file://${file.path}` } : require('@res/fallback.png'), + ) -type CoverArtProps = BaseProps & CoverArtProp & Partial - -const ArtistImageFallback: React.FC<{ - enableLoading: () => void -}> = ({ enableLoading }) => { useEffect(() => { - enableLoading() - }, [enableLoading]) - return <> + if (file && file.progress === 1) { + setSource({ uri: `file://${file.path}` }) + } + }, [file]) + + return ( + <> + { + setSource(require('@res/fallback.png')) + }} + /> + + + ) } -const ArtistImage = React.memo( - ({ artistId, imageSize, style, imageStyle, resizeMode, enableLoading, disableLoading, fallbackError }) => { - const artistInfo = useArtistInfo(artistId) +const ArtistImage = React.memo(props => { + const file = useArtistCoverArtFile(props.artistId) - if (!artistInfo) { - return - } + return +}) - const uri = imageSize === 'thumbnail' ? artistInfo?.smallImageUrl : artistInfo?.largeImageUrl +const CoverArtImage = React.memo(props => { + const file = useCoverArtFile(props.coverArt) - return ( - - ) - }, -) + return +}) -const CoverArtImage = React.memo( - ({ coverArt, imageSize, style, imageStyle, resizeMode, enableLoading, disableLoading, fallbackError }) => { - const coverArtUri = useCoverArtUri() - - return ( - - ) - }, -) - -const CoverArt: React.FC = ({ coverArt, artistId, resizeMode, imageSize, style, imageStyle, round }) => { - const [loading, setLoading] = useState(false) - const [fallback, setFallback] = useState(false) - - const enableLoading = React.useCallback(() => setLoading(true), []) - const disableLoading = React.useCallback(() => setLoading(false), []) - const fallbackError = React.useCallback(() => { - setFallback(true) - setLoading(false) - }, []) - - imageSize = imageSize === undefined ? 'thumbnail' : 'original' - round = round === undefined ? artistId !== undefined : round - - const viewStyles = [style] - if (round) { +const CoverArt: React.FC = props => { + const viewStyles = [props.style] + if (props.round) { viewStyles.push(styles.round) } - let ImageComponent - if (artistId) { - ImageComponent = ( - - ) - } else { - ImageComponent = ( - - ) - } + const coverArtImage = useCallback(() => , [props]) + const artistImage = useCallback(() => , [props]) - if (fallback) { - ImageComponent = ( - - ) + let ImageComponent + switch (props.type) { + case 'artist': + ImageComponent = artistImage + break + default: + ImageComponent = coverArtImage + break } return ( - {ImageComponent} - + ) } diff --git a/app/components/ImageGradientBackground.tsx b/app/components/ImageGradientBackground.tsx index ae694e0..64c0330 100644 --- a/app/components/ImageGradientBackground.tsx +++ b/app/components/ImageGradientBackground.tsx @@ -1,7 +1,6 @@ import { useNavigation } from '@react-navigation/native' import React, { useEffect, useState } from 'react' import { ViewStyle } from 'react-native' -import FastImage from 'react-native-fast-image' import ImageColors from 'react-native-image-colors' import { AndroidImageColors } from 'react-native-image-colors/lib/typescript/types' import colors from '@app/styles/colors' @@ -12,28 +11,27 @@ const ImageGradientBackground: React.FC<{ width?: number | string position?: 'relative' | 'absolute' style?: ViewStyle - imageUri?: string - imageKey?: string -}> = ({ height, width, position, style, imageUri, imageKey, children }) => { + imagePath?: string +}> = ({ height, width, position, style, imagePath, children }) => { const [highColor, setHighColor] = useState(colors.gradient.high) const navigation = useNavigation() useEffect(() => { async function getColors() { - if (imageUri === undefined) { + if (imagePath === undefined) { return } - const cachedResult = ImageColors.cache.getItem(imageKey ? imageKey : imageUri) + const cachedResult = ImageColors.cache.getItem(imagePath) let res: AndroidImageColors if (cachedResult) { res = cachedResult as AndroidImageColors } else { - const path = await FastImage.getCachePath({ uri: imageUri }) - res = (await ImageColors.getColors(path ? `file://${path}` : imageUri, { + const path = `file://${imagePath}` + res = (await ImageColors.getColors(path, { cache: true, - key: imageKey ? imageKey : imageUri, + key: imagePath, })) as AndroidImageColors } @@ -44,7 +42,7 @@ const ImageGradientBackground: React.FC<{ } } getColors() - }, [imageUri, imageKey]) + }, [imagePath]) useEffect(() => { navigation.setOptions({ diff --git a/app/components/ImageGradientScrollView.tsx b/app/components/ImageGradientScrollView.tsx index b7c7f64..505464b 100644 --- a/app/components/ImageGradientScrollView.tsx +++ b/app/components/ImageGradientScrollView.tsx @@ -4,7 +4,7 @@ import dimensions from '@app/styles/dimensions' import React from 'react' import { ScrollView, ScrollViewProps, useWindowDimensions } from 'react-native' -const ImageGradientScrollView: React.FC = props => { +const ImageGradientScrollView: React.FC = props => { const layout = useWindowDimensions() const minHeight = layout.height - (dimensions.top() + dimensions.bottom()) @@ -20,7 +20,7 @@ const ImageGradientScrollView: React.FC - + {props.children} ) diff --git a/app/components/ListItem.tsx b/app/components/ListItem.tsx index a0a3f1d..c5d66bf 100644 --- a/app/components/ListItem.tsx +++ b/app/components/ListItem.tsx @@ -65,7 +65,6 @@ const ListItem: React.FC<{ showStar = showStar === undefined ? true : showStar listStyle = listStyle || 'small' - const artSource = item.itemType === 'artist' ? { artistId: item.id } : { coverArt: item.coverArt } const sizeStyle = listStyle === 'big' ? bigStyles : smallStyles if (!onPress) { @@ -148,18 +147,19 @@ const ListItem: React.FC<{ title = } + const artStyle = { ...styles.art, ...sizeStyle.art } + const resizeMode = FastImage.resizeMode.cover + let coverArt = <> + if (item.itemType === 'artist') { + coverArt = + } else { + coverArt = + } + return ( - {showArt ? ( - - ) : ( - <> - )} + {showArt ? coverArt : <>} {title} {subtitle ? ( diff --git a/app/components/NowPlayingBar.tsx b/app/components/NowPlayingBar.tsx index 4f2fa60..8630f2d 100644 --- a/app/components/NowPlayingBar.tsx +++ b/app/components/NowPlayingBar.tsx @@ -11,7 +11,7 @@ import { Pressable, StyleSheet, Text, View } from 'react-native' import { State } from 'react-native-track-player' import IconFA5 from 'react-native-vector-icons/FontAwesome5' -const ProgressBar = () => { +const ProgressBar = React.memo(() => { const { position, duration } = useStore(selectTrackPlayer.progress) let progress = 0 @@ -25,7 +25,7 @@ const ProgressBar = () => { ) -} +}) const progressStyles = StyleSheet.create({ container: { @@ -40,9 +40,7 @@ const progressStyles = StyleSheet.create({ }, }) -const NowPlayingBar = () => { - const navigation = useNavigation() - const track = useStore(selectTrackPlayer.currentTrack) +const Controls = React.memo(() => { const playerState = useStore(selectTrackPlayer.playerState) const play = usePlay() const pause = usePause() @@ -61,6 +59,19 @@ const NowPlayingBar = () => { break } + return ( + + + + + + ) +}) + +const NowPlayingBar = React.memo(() => { + const navigation = useNavigation() + const track = useStore(selectTrackPlayer.currentTrack) + return ( navigation.navigate('now-playing')} @@ -68,8 +79,9 @@ const NowPlayingBar = () => { @@ -79,15 +91,11 @@ const NowPlayingBar = () => { {track?.artist} - - - - - + ) -} +}) const styles = StyleSheet.create({ container: { diff --git a/app/hooks/music.ts b/app/hooks/music.ts index 63d2693..3b2962b 100644 --- a/app/hooks/music.ts +++ b/app/hooks/music.ts @@ -1,8 +1,5 @@ import { selectMusic } from '@app/state/music' -import { selectSettings } from '@app/state/settings' import { Store, useStore } from '@app/state/store' -import { SubsonicApiClient } from '@app/subsonic/api' -import { GetCoverArtParams } from '@app/subsonic/params' import { useCallback } from 'react' export const useArtistInfo = (id: string) => { @@ -11,6 +8,7 @@ export const useArtistInfo = (id: string) => { if (!artistInfo) { fetchArtistInfo(id) + return undefined } return artistInfo @@ -58,21 +56,31 @@ export const useStarred = (id: string, type: string) => { ) } -export const useCoverArtUri = () => { - const server = useStore(selectSettings.activeServer) +export const useCoverArtFile = (coverArt: string = '-1') => { + const file = useStore(useCallback((state: Store) => state.cachedCoverArt[coverArt], [coverArt])) + const cacheCoverArt = useStore(selectMusic.cacheCoverArt) - if (!server) { - return () => undefined + if (!file) { + cacheCoverArt(coverArt) + return undefined } - const client = new SubsonicApiClient(server) - - return (coverArt?: string, size: 'thumbnail' | 'original' = 'thumbnail') => { - const params: GetCoverArtParams = { id: coverArt || '-1' } - if (size === 'thumbnail') { - params.size = '256' - } - - return client.getCoverArtUri(params) - } + return file +} + +export const useArtistCoverArtFile = (artistId: string) => { + const artistInfo = useArtistInfo(artistId) + const file = useStore(useCallback((state: Store) => state.cachedArtistArt[artistId], [artistId])) + const cacheArtistArt = useStore(selectMusic.cacheArtistArt) + + if (!artistInfo) { + return undefined + } + + if (!file) { + cacheArtistArt(artistId, artistInfo.largeImageUrl) + return undefined + } + + return file } diff --git a/app/hooks/trackplayer.ts b/app/hooks/trackplayer.ts index 6b2fdb2..1f72fe1 100644 --- a/app/hooks/trackplayer.ts +++ b/app/hooks/trackplayer.ts @@ -1,5 +1,5 @@ -import { useCoverArtUri } from '@app/hooks/music' import { Song } from '@app/models/music' +import { selectMusic } from '@app/state/music' import { useStore } from '@app/state/store' import { getCurrentTrack, @@ -191,7 +191,7 @@ export const useSetQueue = () => { const getQueueShuffled = useCallback(() => !!useStore.getState().shuffleOrder, []) const setQueueContextType = useStore(selectTrackPlayer.setQueueContextType) const setQueueContextId = useStore(selectTrackPlayer.setQueueContextId) - const coverArtUri = useCoverArtUri() + const getCoverArtPath = useStore(selectMusic.getCoverArtPath) return async ( songs: Song[], @@ -211,7 +211,16 @@ export const useSetQueue = () => { return } - let queue = songs.map(s => mapSongToTrack(s, coverArtUri)) + const coverArtPaths: { [coverArt: string]: string } = {} + for (const s of songs) { + if (!s.coverArt) { + continue + } + + coverArtPaths[s.coverArt] = await getCoverArtPath(s.coverArt) + } + + let queue = songs.map(s => mapSongToTrack(s, coverArtPaths)) if (shuffled) { const { tracks, shuffleOrder } = shuffleTracks(queue, playTrack) @@ -251,12 +260,10 @@ export const useIsPlaying = (contextId: string | undefined, track: number) => { const shuffleOrder = useStore(selectTrackPlayer.shuffleOrder) if (contextId === undefined) { - console.log(currentTrackIdx) return track === currentTrackIdx } if (shuffleOrder) { - console.log('asdf') const shuffledTrack = shuffleOrder.findIndex(i => i === track) track = shuffledTrack !== undefined ? shuffledTrack : -1 } @@ -264,14 +271,14 @@ export const useIsPlaying = (contextId: string | undefined, track: number) => { return contextId === queueContextId && track === currentTrackIdx } -function mapSongToTrack(song: Song, coverArtUri: (coverArt?: string) => string | undefined): TrackExt { +function mapSongToTrack(song: Song, coverArtPaths: { [coverArt: string]: string }): TrackExt { return { id: song.id, title: song.title, artist: song.artist || 'Unknown Artist', album: song.album || 'Unknown Album', url: song.streamUri, - artwork: coverArtUri(song.coverArt), + artwork: song.coverArt ? `file://${coverArtPaths[song.coverArt]}` : require('@res/fallback.png'), coverArt: song.coverArt, duration: song.duration, } diff --git a/app/models/music.ts b/app/models/music.ts index e16e43b..6a0f59f 100644 --- a/app/models/music.ts +++ b/app/models/music.ts @@ -182,7 +182,7 @@ export function mapChildToSong(child: ChildElement, client: SubsonicApiClient): } } -export function mapAlbumID3WithSongstoAlbunWithSongs( +export function mapAlbumID3WithSongstoAlbumWithSongs( album: AlbumID3Element, songs: ChildElement[], client: SubsonicApiClient, diff --git a/app/screens/ArtistView.tsx b/app/screens/ArtistView.tsx index 7dc93ce..c7be010 100644 --- a/app/screens/ArtistView.tsx +++ b/app/screens/ArtistView.tsx @@ -35,7 +35,12 @@ const AlbumItem = React.memo<{ onPress={() => navigation.navigate('album', { id: album.id, title: album.name })} menuStyle={[styles.albumItem, { width }]} triggerOuterWrapperStyle={{ width }}> - + {album.name} {album.year ? album.year : ''} @@ -111,11 +116,10 @@ const ArtistView = React.memo<{ id: string; title: string }>(({ id, title }) => contentContainerStyle={styles.scrollContent} onScroll={onScroll}> {artist.name} diff --git a/app/screens/Home.tsx b/app/screens/Home.tsx index c4f8889..6dc0c7a 100644 --- a/app/screens/Home.tsx +++ b/app/screens/Home.tsx @@ -34,6 +34,7 @@ const AlbumItem = React.memo<{ triggerWrapperStyle={styles.item} onPress={() => navigation.navigate('album', { id: album.id, title: album.name })}> navigation.navigate('album', { id: album.id, title: album.name })}> { return ( - + ) } @@ -390,9 +390,11 @@ const NowPlayingView: React.FC = ({ navigation }) => { } }) + const imagePath = typeof track?.artwork === 'string' ? track?.artwork.replace('file://', '') : undefined + return ( - + diff --git a/app/screens/SongListView.tsx b/app/screens/SongListView.tsx index 32e206c..53fcfcf 100644 --- a/app/screens/SongListView.tsx +++ b/app/screens/SongListView.tsx @@ -4,7 +4,7 @@ import ImageGradientScrollView from '@app/components/ImageGradientScrollView' import ListItem from '@app/components/ListItem' import ListPlayerControls from '@app/components/ListPlayerControls' import NothingHere from '@app/components/NothingHere' -import { useAlbumWithSongs, useCoverArtUri, usePlaylistWithSongs } from '@app/hooks/music' +import { useAlbumWithSongs, useCoverArtFile, usePlaylistWithSongs } from '@app/hooks/music' import { useSetQueue } from '@app/hooks/trackplayer' import { AlbumWithSongs, PlaylistWithSongs, Song } from '@app/models/music' import colors from '@app/styles/colors' @@ -73,16 +73,16 @@ const SongListDetails = React.memo<{ songList?: AlbumWithSongs | PlaylistWithSongs subtitle?: string }>(({ songList, subtitle, type }) => { - const coverArtUri = useCoverArtUri() + const coverArtFile = useCoverArtFile(songList?.coverArt) if (!songList) { return } return ( - + - + {songList.name} {subtitle ? {subtitle} : <>} {songList.songs.length > 0 ? ( diff --git a/app/state/music.ts b/app/state/music.ts index 0e3f85c..05586f9 100644 --- a/app/state/music.ts +++ b/app/state/music.ts @@ -5,7 +5,7 @@ import { ArtistInfo, HomeLists, mapAlbumID3toAlbumListItem, - mapAlbumID3WithSongstoAlbunWithSongs, + mapAlbumID3WithSongstoAlbumWithSongs, mapArtistID3toArtist, mapArtistInfo, mapChildToSong, @@ -14,12 +14,24 @@ import { PlaylistListItem, PlaylistWithSongs, SearchResults, + Song, } from '@app/models/music' import { Store } from '@app/state/store' import { GetAlbumList2Type, StarParams } from '@app/subsonic/params' +import PromiseQueue from '@app/util/PromiseQueue' import produce from 'immer' +import RNFS from 'react-native-fs' import { GetState, SetState } from 'zustand' +const imageDownloadQueue = new PromiseQueue(5) + +export type DownloadFile = { + path: string + date: number + progress: number + promise?: Promise +} + export type MusicSlice = { // // family-style state @@ -58,10 +70,42 @@ export type MusicSlice = { fetchHomeLists: () => Promise clearHomeLists: () => void + // + // downloads + // + coverArtDir?: string + artistArtDir?: string + songsDir?: string + + cachedCoverArt: { [coverArt: string]: DownloadFile } + downloadedCoverArt: { [coverArt: string]: DownloadFile } + + coverArtRequests: { [coverArt: string]: Promise } + + cacheCoverArt: (coverArt: string) => Promise + getCoverArtPath: (coverArt: string) => Promise + + cachedArtistArt: { [artistId: string]: DownloadFile } + downloadedArtistArt: { [artistId: string]: DownloadFile } + + cacheArtistArt: (artistId: string, url?: string) => Promise + + cachedSongs: { [id: string]: DownloadFile } + downloadedSongs: { [id: string]: DownloadFile } + + // + // actions, etc. + // starredSongs: { [id: string]: boolean } starredAlbums: { [id: string]: boolean } starredArtists: { [id: string]: boolean } starItem: (id: string, type: string, unstar?: boolean) => Promise + + albumCoverArt: { [id: string]: string | undefined } + albumCoverArtRequests: { [id: string]: Promise } + fetchAlbumCoverArt: (id: string) => Promise + getAlbumCoverArt: (id: string | undefined) => Promise + mapSongCoverArtFromAlbum: (songs: Song[]) => Promise } export const selectMusic = { @@ -91,7 +135,12 @@ export const selectMusic = { fetchHomeLists: (store: MusicSlice) => store.fetchHomeLists, clearHomeLists: (store: MusicSlice) => store.clearHomeLists, + cacheCoverArt: (store: MusicSlice) => store.cacheCoverArt, + getCoverArtPath: (store: MusicSlice) => store.getCoverArtPath, + cacheArtistArt: (store: MusicSlice) => store.cacheArtistArt, + starItem: (store: MusicSlice) => store.starItem, + fetchAlbumCoverArt: (store: MusicSlice) => store.fetchAlbumCoverArt, } function reduceStarred( @@ -129,9 +178,12 @@ export const createMusicSlice = (set: SetState, get: GetState): Mu client, ) + artistInfo.topSongs = await get().mapSongCoverArtFromAlbum(artistInfo.topSongs) + set( produce(state => { state.artistInfo[id] = artistInfo + state.starredSongs = reduceStarred(state.starredSongs, artistInfo.topSongs) state.starredArtists = reduceStarred(state.starredArtists, [artistInfo]) state.starredAlbums = reduceStarred(state.starredAlbums, artistInfo.albums) @@ -153,7 +205,9 @@ export const createMusicSlice = (set: SetState, get: GetState): Mu try { const response = await client.getAlbum({ id }) - const album = mapAlbumID3WithSongstoAlbunWithSongs(response.data.album, response.data.songs, client) + const album = mapAlbumID3WithSongstoAlbumWithSongs(response.data.album, response.data.songs, client) + + album.songs = await get().mapSongCoverArtFromAlbum(album.songs) set( produce(state => { @@ -180,6 +234,8 @@ export const createMusicSlice = (set: SetState, get: GetState): Mu const response = await client.getPlaylist({ id }) const playlist = mapPlaylistWithSongs(response.data.playlist, client) + playlist.songs = await get().mapSongCoverArtFromAlbum(playlist.songs) + set( produce(state => { state.playlistsWithSongs[id] = playlist @@ -293,12 +349,13 @@ export const createMusicSlice = (set: SetState, get: GetState): Mu try { const response = await client.search3({ query }) + const songs = await get().mapSongCoverArtFromAlbum(response.data.songs.map(a => mapChildToSong(a, client))) set( produce(state => { state.searchResults = { artists: response.data.artists.map(mapArtistID3toArtist), albums: response.data.albums.map(mapAlbumID3toAlbumListItem), - songs: response.data.songs.map(a => mapChildToSong(a, client)), + songs: songs, } state.starredSongs = reduceStarred(state.starredSongs, state.searchResults.songs) state.starredArtists = reduceStarred(state.starredArtists, state.searchResults.artists) @@ -359,6 +416,110 @@ export const createMusicSlice = (set: SetState, get: GetState): Mu set({ homeLists: {} }) }, + cachedCoverArt: {}, + downloadedCoverArt: {}, + + coverArtRequests: {}, + + cacheCoverArt: async coverArt => { + const client = get().client + if (!client) { + return + } + + const path = `${get().coverArtDir}/${coverArt}` + + const existing = get().cachedCoverArt[coverArt] + if (existing) { + if (existing.promise !== undefined) { + return await existing.promise + } else { + return + } + } + + const promise = imageDownloadQueue + .enqueue(() => + RNFS.downloadFile({ + fromUrl: client.getCoverArtUri({ id: coverArt }), + toFile: path, + }).promise.then(() => new Promise(resolve => setTimeout(resolve, 100))), + ) + .then(() => { + set( + produce(state => { + state.cachedCoverArt[coverArt].progress = 1 + delete state.cachedCoverArt[coverArt].promise + }), + ) + }) + set( + produce(state => { + state.cachedCoverArt[coverArt] = { + path, + date: Date.now(), + progress: 0, + promise, + } + }), + ) + return await promise + }, + + getCoverArtPath: async coverArt => { + const existing = get().cachedCoverArt[coverArt] + if (existing) { + if (existing.promise) { + await existing.promise + } + return existing.path + } + + await get().cacheCoverArt(coverArt) + return get().cachedCoverArt[coverArt].path + }, + + cachedArtistArt: {}, + downloadedArtistArt: {}, + + cacheArtistArt: async (artistId, url) => { + if (!url) { + return + } + + const client = get().client + if (!client) { + return + } + + const path = `${get().artistArtDir}/${artistId}` + + set( + produce(state => { + state.cachedArtistArt[artistId] = { + path, + date: Date.now(), + progress: 0, + } + }), + ) + await imageDownloadQueue.enqueue( + () => + RNFS.downloadFile({ + fromUrl: url, + toFile: path, + }).promise, + ) + set( + produce(state => { + state.cachedArtistArt[artistId].progress = 1 + }), + ) + }, + + cachedSongs: {}, + downloadedSongs: {}, + starredSongs: {}, starredAlbums: {}, starredArtists: {}, @@ -418,4 +579,70 @@ export const createMusicSlice = (set: SetState, get: GetState): Mu setStarred(unstar) } }, + + albumCoverArt: {}, + albumCoverArtRequests: {}, + + fetchAlbumCoverArt: async id => { + const client = get().client + if (!client) { + return + } + + const inProgress = get().albumCoverArtRequests[id] + if (inProgress !== undefined) { + return await inProgress + } + + const promise = new Promise(async resolve => { + try { + const response = await client.getAlbum({ id }) + set( + produce(state => { + state.albumCoverArt[id] = response.data.album.coverArt + }), + ) + } finally { + resolve() + } + }).then(() => { + set( + produce(state => { + delete state.albumCoverArtRequests[id] + }), + ) + }) + set( + produce(state => { + state.albumCoverArtRequests[id] = promise + }), + ) + + return await promise + }, + + getAlbumCoverArt: async id => { + if (!id) { + return + } + + const existing = get().albumCoverArt[id] + if (existing) { + return existing + } + + await get().fetchAlbumCoverArt(id) + return get().albumCoverArt[id] + }, + + mapSongCoverArtFromAlbum: async songs => { + const mapped: Song[] = [] + for (const s of songs) { + mapped.push({ + ...s, + coverArt: await get().getAlbumCoverArt(s.albumId), + }) + } + return mapped + }, }) diff --git a/app/state/settings.ts b/app/state/settings.ts index 1214205..0cdddf0 100644 --- a/app/state/settings.ts +++ b/app/state/settings.ts @@ -2,13 +2,27 @@ import { AppSettings, Server } from '@app/models/settings' import { Store } from '@app/state/store' import { SubsonicApiClient } from '@app/subsonic/api' import produce from 'immer' +import RNFS from 'react-native-fs' import { GetState, SetState } from 'zustand' +async function mkdir(path: string): Promise { + const exists = await RNFS.exists(path) + if (exists) { + const isDir = (await RNFS.stat(path)).isDirectory() + if (!isDir) { + throw new Error(`path exists and is not a directory: ${path}`) + } else { + return + } + } + + return await RNFS.mkdir(path) +} + export type SettingsSlice = { settings: AppSettings client?: SubsonicApiClient - createClient: (id?: string) => void - setActiveServer: (id?: string) => void + setActiveServer: (id: string | undefined, force?: boolean) => Promise getActiveServer: () => Server | undefined setServers: (servers: Server[]) => void } @@ -20,50 +34,51 @@ export const createSettingsSlice = (set: SetState, get: GetState): lists: ['recent', 'random', 'frequent', 'starred'], }, }, - createClient: (id?: string) => { - if (!id) { - set({ client: undefined }) - return - } - - const server = get().getActiveServer() - if (!server) { - set({ client: undefined }) - return - } - - set({ client: new SubsonicApiClient(server) }) - }, - setActiveServer: id => { + setActiveServer: async (id, force) => { const servers = get().settings.servers const currentActiveServerId = get().settings.activeServer const newActiveServer = servers.find(s => s.id === id) if (!newActiveServer) { + set({ + client: undefined, + coverArtDir: undefined, + artistArtDir: undefined, + songsDir: undefined, + }) return } - if (currentActiveServerId === id) { + if (currentActiveServerId === id && !force) { return } + const coverArtDir = `${RNFS.DocumentDirectoryPath}/cover-art/${id}` + const artistArtDir = `${RNFS.DocumentDirectoryPath}/artist-art/${id}` + const songsDir = `${RNFS.DocumentDirectoryPath}/songs/${id}` + await mkdir(coverArtDir) + await mkdir(artistArtDir) + await mkdir(songsDir) + set( - produce(state => { + produce(state => { state.settings.activeServer = id state.client = new SubsonicApiClient(newActiveServer) + state.coverArtDir = coverArtDir + state.artistArtDir = artistArtDir + state.songsDir = songsDir }), ) }, getActiveServer: () => get().settings.servers.find(s => s.id === get().settings.activeServer), - setServers: servers => + setServers: servers => { set( produce(state => { state.settings.servers = servers - const activeServer = servers.find(s => s.id === state.settings.activeServer) - if (activeServer) { - state.client = new SubsonicApiClient(activeServer) - } }), - ), + ) + const activeServer = servers.find(s => s.id === get().settings.activeServer) + get().setActiveServer(activeServer?.id) + }, }) export const selectSettings = { diff --git a/app/state/store.ts b/app/state/store.ts index d2a3cb9..05a8be5 100644 --- a/app/state/store.ts +++ b/app/state/store.ts @@ -45,8 +45,8 @@ export const useStore = create( getStorage: () => storage, whitelist: ['settings'], onRehydrateStorage: _preState => { - return (postState, _error) => { - postState?.createClient(postState.settings.activeServer) + return async (postState, _error) => { + await postState?.setActiveServer(postState.settings.activeServer, true) postState?.setHydrated(true) } }, diff --git a/app/subsonic/api.ts b/app/subsonic/api.ts index cfd6797..e8634b4 100644 --- a/app/subsonic/api.ts +++ b/app/subsonic/api.ts @@ -83,7 +83,7 @@ export class SubsonicApiClient { } const url = `${this.address}/rest/${method}?${query}` - // console.log(url) + console.log(`${method}: ${url}`) return url }