diff --git a/app/components/CoverArt.tsx b/app/components/CoverArt.tsx index 37e2866..cfe42a8 100644 --- a/app/components/CoverArt.tsx +++ b/app/components/CoverArt.tsx @@ -1,5 +1,5 @@ -import { useArtistCoverArtFile, useCoverArtFile } from '@app/hooks/music' -import { CachedFile } from '@app/models/music' +import { useArtistArtFile, useCoverArtFile } from '@app/hooks/music' +import { CacheFile, CacheRequest } from '@app/models/music' import colors from '@app/styles/colors' import React, { useState } from 'react' import { ActivityIndicator, StyleSheet, View, ViewStyle } from 'react-native' @@ -22,39 +22,46 @@ type CoverArtProps = BaseProps & { coverArt?: string } -const Image = React.memo<{ file?: CachedFile } & BaseProps>(({ file, style, imageStyle, resizeMode }) => { - const [error, setError] = useState(false) +const Image = React.memo<{ cache?: { file?: CacheFile; request?: CacheRequest } } & BaseProps>( + ({ cache, style, imageStyle, resizeMode }) => { + const [error, setError] = useState(false) - let source - if (!error && file) { - source = { uri: `file://${file.path}` } - } else { - source = require('@res/fallback.png') - } + let source + if (!error && cache?.file && !cache?.request?.promise) { + source = { uri: `file://${cache.file.path}` } + } else { + source = require('@res/fallback.png') + } - return ( - <> - setError(true)} - /> - - - ) -}) + return ( + <> + setError(true)} + /> + + + ) + }, +) const ArtistImage = React.memo(props => { - const file = useArtistCoverArtFile(props.artistId) + const cache = useArtistArtFile(props.artistId) - return + return }) const CoverArtImage = React.memo(props => { - const file = useCoverArtFile(props.coverArt) + const cache = useCoverArtFile(props.coverArt) - return + return }) const CoverArt = React.memo(props => { diff --git a/app/hooks/music.ts b/app/hooks/music.ts index f5d2de4..77fb0c4 100644 --- a/app/hooks/music.ts +++ b/app/hooks/music.ts @@ -1,5 +1,7 @@ +import { CacheFileTypeKey } from '@app/models/music' import { selectCache } from '@app/state/cache' import { selectMusic } from '@app/state/music' +import { selectSettings } from '@app/state/settings' import { Store, useStore } from '@app/state/store' import { useCallback, useEffect } from 'react' @@ -62,45 +64,70 @@ export const useStarred = (id: string, type: string) => { ) } -export const useCoverArtFile = (coverArt: string = '-1') => { - const existing = useStore( +const useFileRequest = (key: CacheFileTypeKey, id: string) => { + const file = useStore( useCallback( - (state: Store) => { - const activeServerId = state.settings.activeServer + (store: Store) => { + const activeServerId = store.settings.activeServer if (!activeServerId) { return } - return state.cache[activeServerId].files.coverArt[coverArt] + + return store.cacheFiles[activeServerId][key][id] }, - [coverArt], + [key, id], ), ) - const progress = useStore(useCallback((state: Store) => state.cachedCoverArt[coverArt], [coverArt])) - const cacheCoverArt = useStore(selectCache.cacheCoverArt) + const request = useStore( + useCallback( + (store: Store) => { + const activeServerId = store.settings.activeServer + if (!activeServerId) { + return + } - useEffect(() => { - if (!existing) { - cacheCoverArt(coverArt) - } - }) + return store.cacheRequests[activeServerId][key][id] + }, + [key, id], + ), + ) - if (existing && progress && progress.promise !== undefined) { - return - } - - return existing + return { file, request } } -export const useArtistCoverArtFile = (artistId: string) => { +export const useCoverArtFile = (coverArt: string = '-1') => { + const { file, request } = useFileRequest('coverArt', coverArt) + const client = useStore(selectSettings.client) + const cacheItem = useStore(selectCache.cacheItem) + + useEffect(() => { + if (!file && client) { + cacheItem('coverArt', coverArt, () => client.getCoverArtUri({ id: coverArt })) + } + }, [cacheItem, client, coverArt, file]) + + // if (file && request && request.promise !== undefined) { + // return + // } + + return { file, request } +} + +export const useArtistArtFile = (artistId: string) => { const artistInfo = useArtistInfo(artistId) - const file = useStore(useCallback((state: Store) => state.cachedArtistArt[artistId], [artistId])) - const cacheArtistArt = useStore(selectCache.cacheArtistArt) + const { file, request } = useFileRequest('artistArt', artistId) + const cacheItem = useStore(selectCache.cacheItem) useEffect(() => { - if (!file && artistInfo) { - cacheArtistArt(artistId, artistInfo.largeImageUrl) + if (!file && artistInfo && artistInfo.largeImageUrl) { + console.log(artistInfo.largeImageUrl) + cacheItem('artistArt', artistId, artistInfo.largeImageUrl) } - }) + }, [artistId, artistInfo, artistInfo?.largeImageUrl, cacheItem, file]) - return file + // if (file && request && request.promise !== undefined) { + // return + // } + + return { file, request } } diff --git a/app/models/music.ts b/app/models/music.ts index b1b1113..48b470f 100644 --- a/app/models/music.ts +++ b/app/models/music.ts @@ -80,12 +80,25 @@ export type ListableItem = Song | AlbumListItem | Artist | PlaylistListItem export type HomeLists = { [key: string]: AlbumListItem[] } -export type CachedFile = { +export enum CacheFileType { + coverArt, + artistArt, + song, +} + +export type CacheFileTypeKey = keyof typeof CacheFileType + +export type CacheFile = { path: string date: number permanent: boolean } +export type CacheRequest = { + progress: number + promise?: Promise +} + export type DownloadedAlbum = Album & { songs: string[] } diff --git a/app/screens/SongListView.tsx b/app/screens/SongListView.tsx index 53fcfcf..5d4381a 100644 --- a/app/screens/SongListView.tsx +++ b/app/screens/SongListView.tsx @@ -80,7 +80,7 @@ const SongListDetails = React.memo<{ } return ( - + {songList.name} diff --git a/app/state/cache.ts b/app/state/cache.ts index e25880b..9784480 100644 --- a/app/state/cache.ts +++ b/app/state/cache.ts @@ -1,54 +1,38 @@ -import { - CachedFile, - DownloadedAlbum, - DownloadedArtist, - DownloadedPlaylist, - DownloadedSong, - Song, -} from '@app/models/music' +import { CacheFile, CacheFileTypeKey, CacheRequest, Song } from '@app/models/music' import PromiseQueue from '@app/util/PromiseQueue' -import { SetState, GetState } from 'zustand' -import { Store } from './store' import produce from 'immer' import RNFS from 'react-native-fs' +import { GetState, SetState } from 'zustand' +import { Store } from './store' const imageDownloadQueue = new PromiseQueue(10) -type DownloadProgress = { - progress: number - promise?: Promise -} +export type CacheDownload = CacheFile & CacheRequest -export type CacheDownload = CachedFile & DownloadProgress +export type CacheDirsByServer = Record> +export type CacheFilesByServer = Record>> +export type CacheRequestsByServer = Record>> + +// export type CacheItemsDb = Record< +// string, +// { +// songs: { [songId: string]: DownloadedSong } +// albums: { [albumId: string]: DownloadedAlbum } +// artists: { [songId: string]: DownloadedArtist } +// playlists: { [playlistId: string]: DownloadedPlaylist } +// } +// > export type CacheSlice = { - coverArtDir?: string - artistArtDir?: string - songsDir?: string + cacheItem: (key: CacheFileTypeKey, itemId: string, url: string | (() => string | Promise)) => Promise - cache: { - [serverId: string]: { - files: { - coverArt: { [coverArt: string]: CachedFile } - artistArt: { [artistId: string]: CachedFile } - songs: { [songId: string]: CachedFile } - } - songs: { [songId: string]: DownloadedSong } - albums: { [albumId: string]: DownloadedAlbum } - artists: { [songId: string]: DownloadedArtist } - playlists: { [playlistId: string]: DownloadedPlaylist } - } - } + // cache: CacheItemsDb + cacheDirs: CacheDirsByServer + cacheFiles: CacheFilesByServer + cacheRequests: CacheRequestsByServer - cachedCoverArt: { [coverArt: string]: DownloadProgress } - cacheCoverArt: (coverArt: string) => Promise getCoverArtPath: (coverArt: string) => Promise - cachedArtistArt: { [artistId: string]: CacheDownload } - cacheArtistArt: (artistId: string, url?: string) => Promise - - cachedSongs: { [id: string]: CacheDownload } - albumCoverArt: { [id: string]: string | undefined } albumCoverArtRequests: { [id: string]: Promise } fetchAlbumCoverArt: (id: string) => Promise @@ -57,19 +41,20 @@ export type CacheSlice = { } export const selectCache = { - cacheCoverArt: (store: CacheSlice) => store.cacheCoverArt, getCoverArtPath: (store: CacheSlice) => store.getCoverArtPath, - cacheArtistArt: (store: CacheSlice) => store.cacheArtistArt, + cacheItem: (store: CacheSlice) => store.cacheItem, fetchAlbumCoverArt: (store: CacheSlice) => store.fetchAlbumCoverArt, } export const createCacheSlice = (set: SetState, get: GetState): CacheSlice => ({ - cache: {}, + // cache: {}, - cachedCoverArt: {}, + cacheDirs: {}, + cacheFiles: {}, + cacheRequests: {}, - cacheCoverArt: async coverArt => { + cacheItem: async (key, itemId, url) => { const client = get().client if (!client) { return @@ -80,7 +65,7 @@ export const createCacheSlice = (set: SetState, get: GetState): Ca return } - const inProgress = get().cachedCoverArt[coverArt] + const inProgress = get().cacheRequests[activeServerId][key][itemId] if (inProgress) { if (inProgress.promise !== undefined) { return await inProgress.promise @@ -89,37 +74,39 @@ export const createCacheSlice = (set: SetState, get: GetState): Ca } } - const existing = get().cache[activeServerId].files.coverArt[coverArt] + const existing = get().cacheFiles[activeServerId][key][itemId] if (existing) { return } - const path = `${get().coverArtDir}/${coverArt}` + const path = `${get().cacheDirs[activeServerId][key]}/${itemId}` + const urlResult = typeof url === 'string' ? url : url() + const fromUrl = typeof urlResult === 'string' ? urlResult : await urlResult const promise = imageDownloadQueue .enqueue( () => RNFS.downloadFile({ - fromUrl: client.getCoverArtUri({ id: coverArt }), + fromUrl, toFile: path, }).promise, ) .then(() => { set( produce(state => { - state.cachedCoverArt[coverArt].progress = 1 - delete state.cachedCoverArt[coverArt].promise + state.cacheRequests[activeServerId][key][itemId].progress = 1 + delete state.cacheRequests[activeServerId][key][itemId].promise }), ) }) set( produce(state => { - state.cache[activeServerId].files.coverArt[coverArt] = { + state.cacheFiles[activeServerId][key][itemId] = { path, date: Date.now(), permanent: false, } - state.cachedCoverArt[coverArt] = { + state.cacheRequests[activeServerId][key][itemId] = { progress: 0, promise, } @@ -129,13 +116,18 @@ export const createCacheSlice = (set: SetState, get: GetState): Ca }, getCoverArtPath: async coverArt => { + const client = get().client + if (!client) { + return + } + const activeServerId = get().settings.activeServer if (!activeServerId) { return } - const existing = get().cache[activeServerId].files.coverArt[coverArt] - const inProgress = get().cachedCoverArt[coverArt] + const existing = get().cacheFiles[activeServerId].coverArt[coverArt] + const inProgress = get().cacheRequests[activeServerId].coverArt[coverArt] if (existing && inProgress) { if (inProgress.promise) { await inProgress.promise @@ -143,64 +135,10 @@ export const createCacheSlice = (set: SetState, get: GetState): Ca return existing.path } - await get().cacheCoverArt(coverArt) - return get().cache[activeServerId].files.coverArt[coverArt].path + await get().cacheItem('coverArt', coverArt, () => client.getCoverArtUri({ id: coverArt })) + return get().cacheFiles[activeServerId].coverArt[coverArt].path }, - cachedArtistArt: {}, - - cacheArtistArt: async (artistId, url) => { - if (!url) { - return - } - - const client = get().client - if (!client) { - return - } - - const path = `${get().artistArtDir}/${artistId}` - - const existing = get().cachedArtistArt[artistId] - if (existing) { - if (existing.promise !== undefined) { - return await existing.promise - } else { - return - } - } - - const promise = imageDownloadQueue - .enqueue( - () => - RNFS.downloadFile({ - fromUrl: url, - toFile: path, - }).promise, - ) - .then(() => { - set( - produce(state => { - state.cachedArtistArt[artistId].progress = 1 - delete state.cachedArtistArt[artistId].promise - }), - ) - }) - set( - produce(state => { - state.cachedArtistArt[artistId] = { - path, - date: Date.now(), - progress: 0, - permanent: false, - promise, - } - }), - ) - }, - - cachedSongs: {}, - albumCoverArt: {}, albumCoverArtRequests: {}, diff --git a/app/state/settings.ts b/app/state/settings.ts index c489834..d4a57b9 100644 --- a/app/state/settings.ts +++ b/app/state/settings.ts @@ -1,3 +1,4 @@ +import { CacheFileType } from '@app/models/music' import { AppSettings, Server } from '@app/models/settings' import { Store } from '@app/state/store' import { SubsonicApiClient } from '@app/subsonic/api' @@ -42,9 +43,6 @@ export const createSettingsSlice = (set: SetState, get: GetState): if (!newActiveServer) { set({ client: undefined, - coverArtDir: undefined, - artistArtDir: undefined, - songsDir: undefined, }) return } @@ -52,29 +50,35 @@ export const createSettingsSlice = (set: SetState, get: GetState): 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) + for (const type in CacheFileType) { + await mkdir(`${RNFS.DocumentDirectoryPath}/servers/${id}/${type}`) + } set( produce(state => { state.settings.activeServer = newActiveServer.id state.client = new SubsonicApiClient(newActiveServer) - state.coverArtDir = coverArtDir - state.artistArtDir = artistArtDir - state.songsDir = songsDir - state.cache[newActiveServer.id] = state.cache[newActiveServer.id] || { - files: { + + if (!state.cacheDirs[newActiveServer.id]) { + state.cacheDirs[newActiveServer.id] = { + song: `${RNFS.DocumentDirectoryPath}/servers/${id}/song`, + coverArt: `${RNFS.DocumentDirectoryPath}/servers/${id}/coverArt`, + artistArt: `${RNFS.DocumentDirectoryPath}/servers/${id}/artistArt`, + } + } + if (!state.cacheFiles[newActiveServer.id]) { + state.cacheFiles[newActiveServer.id] = { + song: {}, coverArt: {}, artistArt: {}, - songs: {}, - }, - songs: {}, - albums: {}, - artists: {}, + } + } + if (!state.cacheRequests[newActiveServer.id]) { + state.cacheRequests[newActiveServer.id] = { + song: {}, + coverArt: {}, + artistArt: {}, + } } }), ) @@ -92,6 +96,7 @@ export const createSettingsSlice = (set: SetState, get: GetState): }) export const selectSettings = { + client: (state: SettingsSlice) => state.client, activeServer: (state: SettingsSlice) => state.settings.servers.find(s => s.id === state.settings.activeServer), setActiveServer: (state: SettingsSlice) => state.setActiveServer, servers: (state: SettingsSlice) => state.settings.servers, diff --git a/app/state/store.ts b/app/state/store.ts index 11eed8e..a5cf63e 100644 --- a/app/state/store.ts +++ b/app/state/store.ts @@ -46,7 +46,7 @@ export const useStore = create( { name: '@appStore', getStorage: () => storage, - whitelist: ['settings', 'cache'], + whitelist: ['settings', 'cacheFiles'], onRehydrateStorage: _preState => { return async (postState, _error) => { await postState?.setActiveServer(postState.settings.activeServer, true)