diff --git a/app/components/ContextMenu.tsx b/app/components/ContextMenu.tsx index 17dc2d8..f69a42b 100644 --- a/app/components/ContextMenu.tsx +++ b/app/components/ContextMenu.tsx @@ -119,11 +119,13 @@ const ContextMenuIconTextOption = React.memo( const MenuHeader = React.memo<{ coverArt?: string artistId?: string + albumId?: string title: string subtitle?: string -}>(({ coverArt, artistId, title, subtitle }) => ( - - {artistId ? ( +}>(({ coverArt, artistId, albumId, title, subtitle }) => { + let CoverArtComponent = <> + if (artistId) { + CoverArtComponent = ( - ) : ( + ) + } else if (albumId) { + CoverArtComponent = ( + + ) + } else { + CoverArtComponent = ( - )} - - - {title} - - {subtitle ? ( - - {subtitle} + ) + } + + return ( + + {CoverArtComponent} + + + {title} - ) : ( - <> - )} + {subtitle ? ( + + {subtitle} + + ) : ( + <> + )} + - -)) + ) +}) const OptionStar = withSuspenseMemo<{ id: string @@ -260,7 +281,7 @@ export const SongContextPressable: React.FC = props = return ( } + menuHeader={} menuOptions={ <> @@ -307,7 +328,7 @@ export const NowPlayingContextPressable: React.FC} + menuHeader={} menuOptions={ <> diff --git a/app/components/CoverArt.tsx b/app/components/CoverArt.tsx index d2a2190..6c39acb 100644 --- a/app/components/CoverArt.tsx +++ b/app/components/CoverArt.tsx @@ -1,4 +1,4 @@ -import { useQueryArtistArtPath, useQueryCoverArtPath } from '@app/hooks/query' +import { useQueryAlbumCoverArtPath, useQueryArtistArtPath, useQueryCoverArtPath } from '@app/hooks/query' import { CacheImageSize } from '@app/models/cache' import colors from '@app/styles/colors' import React, { useState } from 'react' @@ -32,6 +32,11 @@ type CoverArtProps = BaseProps & { coverArt?: string } +type AlbumIdProps = BaseProps & { + type: 'album' + albumId?: string +} + type ImageSourceProps = BaseProps & { data?: string isFetching: boolean @@ -82,7 +87,13 @@ const CoverArtImage = React.memo(props => { return }) -const CoverArt = React.memo(props => { +const AlbumIdIamge = React.memo(props => { + const { data, isFetching, isExistingFetching } = useQueryAlbumCoverArtPath(props.albumId, props.size) + + return +}) + +const CoverArt = React.memo(props => { const viewStyles = [props.style] if (props.round) { viewStyles.push(styles.round) @@ -93,6 +104,9 @@ const CoverArt = React.memo(props => { case 'artist': imageComponent = break + case 'album': + imageComponent = + break default: imageComponent = break diff --git a/app/components/ListItem.tsx b/app/components/ListItem.tsx index 57f76db..9f33c39 100644 --- a/app/components/ListItem.tsx +++ b/app/components/ListItem.tsx @@ -160,6 +160,10 @@ const ListItem: React.FC<{ size="thumbnail" /> ) + } else if (item.itemType === 'song') { + coverArt = ( + + ) } else { coverArt = ( diff --git a/app/components/NowPlayingBar.tsx b/app/components/NowPlayingBar.tsx index 587ec5b..c900c1d 100644 --- a/app/components/NowPlayingBar.tsx +++ b/app/components/NowPlayingBar.tsx @@ -79,7 +79,7 @@ const Controls = React.memo(() => { const NowPlayingBar = React.memo(() => { const navigation = useNavigation() const currentTrackExists = useStore(store => !!store.currentTrack) - const coverArt = useStore(store => store.currentTrack?.coverArt) + const albumId = useStore(store => store.currentTrack?.albumId) const title = useStore(store => store.currentTrack?.title) const artist = useStore(store => store.currentTrack?.artist) @@ -90,9 +90,9 @@ const NowPlayingBar = React.memo(() => { diff --git a/app/hooks/fetch.ts b/app/hooks/fetch.ts index 86ee453..a62a777 100644 --- a/app/hooks/fetch.ts +++ b/app/hooks/fetch.ts @@ -1,8 +1,9 @@ import { CacheItemTypeKey } from '@app/models/cache' -import { Album, AlbumCoverArt, Playlist, Song } from '@app/models/library' +import { Album, Playlist, Song } from '@app/models/library' import { mapAlbum, mapArtist, mapArtistInfo, mapPlaylist, mapSong } from '@app/models/map' import queryClient from '@app/queryClient' import { useStore } from '@app/state/store' +import { SubsonicApiClient } from '@app/subsonic/api' import { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params' import { cacheDir } from '@app/util/fs' import { mapCollectionById } from '@app/util/state' @@ -31,7 +32,7 @@ function cacheStarredData(i } function cacheAlbumCoverArtData(item: T) { - queryClient.setQueryData(qk.albumCoverArt(item.id), { albumId: item.id, coverArt: item.coverArt }) + queryClient.setQueryData(qk.albumCoverArt(item.id), item.coverArt) } export const useFetchArtists = () => { @@ -109,22 +110,23 @@ export const useFetchPlaylist = () => { } } +export async function fetchAlbum(id: string, client: SubsonicApiClient): Promise<{ album: Album; songs?: Song[] }> { + const res = await client.getAlbum({ id }) + + cacheStarredData(res.data.album) + res.data.songs.forEach(cacheStarredData) + + cacheAlbumCoverArtData(res.data.album) + + return { + album: mapAlbum(res.data.album), + songs: res.data.songs.map(mapSong), + } +} + export const useFetchAlbum = () => { const client = useClient() - - return async (id: string): Promise<{ album: Album; songs?: Song[] }> => { - const res = await client().getAlbum({ id }) - - cacheStarredData(res.data.album) - res.data.songs.forEach(cacheStarredData) - - cacheAlbumCoverArtData(res.data.album) - - return { - album: mapAlbum(res.data.album), - songs: res.data.songs.map(mapSong), - } - } + return async (id: string) => fetchAlbum(id, client()) } export const useFetchAlbumList = () => { @@ -196,17 +198,23 @@ export type FetchExisingFileOptions = { itemId: string } -export const useFetchExistingFile: () => (options: FetchExisingFileOptions) => Promise = () => { - const serverId = useStore(store => store.settings.activeServerId) +export async function fetchExistingFile( + options: FetchExisingFileOptions, + serverId: string | undefined, +): Promise { + const { itemType, itemId } = options + const fileDir = cacheDir(serverId, itemType, itemId) - return async ({ itemType, itemId }) => { - const fileDir = cacheDir(serverId, itemType, itemId) - try { - const dir = await RNFS.readDir(fileDir) - console.log('existing file:', dir[0].path) - return dir[0].path - } catch {} - } + try { + const dir = await RNFS.readDir(fileDir) + console.log('existing file:', dir[0].path) + return dir[0].path + } catch {} +} + +export const useFetchExistingFile = () => { + const serverId = useStore(store => store.settings.activeServerId) + return async (options: FetchExisingFileOptions) => fetchExistingFile(options, serverId) } function assertMimeType(expected?: string, actual?: string) { @@ -237,69 +245,71 @@ export type FetchFileOptions = FetchExisingFileOptions & { progress?: (received: number, total: number) => void } -export const useFetchFile: () => (options: FetchFileOptions) => Promise = () => { - const serverId = useStore(store => store.settings.activeServerId) +export async function fetchFile(options: FetchFileOptions, serverId: string | undefined): Promise { + let { itemType, itemId, fromUrl, useCacheBuster, expectedContentType, progress } = options + useCacheBuster = useCacheBuster === undefined ? true : useCacheBuster - return async ({ itemType, itemId, fromUrl, useCacheBuster, expectedContentType, progress }) => { - useCacheBuster = useCacheBuster === undefined ? true : useCacheBuster + const fileDir = cacheDir(serverId, itemType, itemId) + const filePathNoExt = path.join(fileDir, useCacheBuster ? useStore.getState().settings.cacheBuster : itemType) - const fileDir = cacheDir(serverId, itemType, itemId) - const filePathNoExt = path.join(fileDir, useCacheBuster ? useStore.getState().settings.cacheBuster : itemType) + try { + await RNFS.unlink(fileDir) + } catch {} - try { - await RNFS.unlink(fileDir) - } catch {} + const headers = { 'User-Agent': userAgent } - const headers = { 'User-Agent': userAgent } + // we send a HEAD first for two reasons: + // 1. to follow any redirects and get the actual URL (DownloadManager does not support redirects) + // 2. to obtain the mime-type up front so we can use it for the file extension/validation + const headRes = await fetch(fromUrl, { method: 'HEAD', headers }) - // we send a HEAD first for two reasons: - // 1. to follow any redirects and get the actual URL (DownloadManager does not support redirects) - // 2. to obtain the mime-type up front so we can use it for the file extension/validation - const headRes = await fetch(fromUrl, { method: 'HEAD', headers }) - - if (headRes.status > 399) { - throw new Error(`HTTP status error ${headRes.status}. File: ${itemType} ID: ${itemId}`) - } - - const contentType = headRes.headers.get('content-type') || undefined - assertMimeType(expectedContentType, contentType) - - const contentDisposition = headRes.headers.get('content-disposition') || undefined - const filename = contentDisposition ? cd.parse(contentDisposition).parameters.filename : undefined - - let extension: string | undefined - if (filename) { - extension = path.extname(filename) || undefined - if (extension) { - extension = extension.substring(1) - } - } else if (contentType) { - extension = mime.extension(contentType) || undefined - } - - const config = ReactNativeBlobUtil.config({ - addAndroidDownloads: { - useDownloadManager: true, - notification: false, - mime: contentType, - description: 'subtracks', - path: extension ? `${filePathNoExt}.${extension}` : filePathNoExt, - }, - }) - - const fetchParams: Parameters = ['GET', headRes.url, headers] - - let res: FetchBlobResponse - if (progress) { - res = await config.fetch(...fetchParams).progress(progress) - } else { - res = await config.fetch(...fetchParams) - } - - const downloadPath = res.path() - queryClient.setQueryData(qk.existingFiles(itemType, itemId), downloadPath) - - console.log('downloaded file:', downloadPath) - return downloadPath + if (headRes.status > 399) { + throw new Error(`HTTP status error ${headRes.status}. File: ${itemType} ID: ${itemId}`) } + + const contentType = headRes.headers.get('content-type') || undefined + assertMimeType(expectedContentType, contentType) + + const contentDisposition = headRes.headers.get('content-disposition') || undefined + const filename = contentDisposition ? cd.parse(contentDisposition).parameters.filename : undefined + + let extension: string | undefined + if (filename) { + extension = path.extname(filename) || undefined + if (extension) { + extension = extension.substring(1) + } + } else if (contentType) { + extension = mime.extension(contentType) || undefined + } + + const config = ReactNativeBlobUtil.config({ + addAndroidDownloads: { + useDownloadManager: true, + notification: false, + mime: contentType, + description: 'subtracks', + path: extension ? `${filePathNoExt}.${extension}` : filePathNoExt, + }, + }) + + const fetchParams: Parameters = ['GET', headRes.url, headers] + + let res: FetchBlobResponse + if (progress) { + res = await config.fetch(...fetchParams).progress(progress) + } else { + res = await config.fetch(...fetchParams) + } + + const downloadPath = res.path() + queryClient.setQueryData(qk.existingFiles(itemType, itemId), downloadPath) + + console.log('downloaded file:', downloadPath) + return downloadPath +} + +export const useFetchFile = () => { + const serverId = useStore(store => store.settings.activeServerId) + return async (options: FetchFileOptions) => fetchFile(options, serverId) } diff --git a/app/hooks/query.ts b/app/hooks/query.ts index 61318e3..9942ba9 100644 --- a/app/hooks/query.ts +++ b/app/hooks/query.ts @@ -1,19 +1,11 @@ import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache' -import { Album, AlbumCoverArt, Artist, Playlist, Song, StarrableItemType } from '@app/models/library' +import { Album, Artist, Playlist, Song, StarrableItemType } from '@app/models/library' import { CollectionById } from '@app/models/state' import queryClient from '@app/queryClient' import { useStore } from '@app/state/store' import { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params' import _ from 'lodash' -import { - InfiniteData, - useInfiniteQuery, - UseInfiniteQueryResult, - useMutation, - useQueries, - useQuery, - UseQueryResult, -} from 'react-query' +import { useInfiniteQuery, useMutation, useQueries, useQuery } from 'react-query' import { useFetchAlbum, useFetchAlbumList, @@ -88,7 +80,7 @@ export const useQueryArtistTopSongs = (artistName?: string) => { }, ) - return useFixCoverArt(querySuccess ? query : backupQuery) + return querySuccess ? query : backupQuery } export const useQueryPlaylists = () => useQuery(qk.playlists, useFetchPlaylists()) @@ -109,7 +101,7 @@ export const useQueryPlaylist = (id: string, placeholderPlaylist?: Playlist) => }, }) - return useFixCoverArt(query) + return query } export const useQueryAlbum = (id: string, placeholderAlbum?: Album) => { @@ -120,7 +112,7 @@ export const useQueryAlbum = (id: string, placeholderAlbum?: Album) => { placeholderAlbum ? { album: placeholderAlbum } : undefined, }) - return useFixCoverArt(query) + return query } export const useQueryAlbumList = (type: GetAlbumList2TypeBase, size: number) => { @@ -172,7 +164,7 @@ export const useQuerySearchResults = (params: Search3Params) => { }, ) - return useFixCoverArt(query) + return query } export const useQueryHomeLists = (types: GetAlbumList2TypeBase[], size: number) => { @@ -314,93 +306,18 @@ export const useQueryArtistArtPath = (artistId: string, size: CacheImageSize = ' return { ...query, data: existing.data || query.data, isExistingFetching: existing.isFetching } } -type WithSongs = Song[] | { songs?: Song[] } -type InfiniteWithSongs = { songs: Song[] } -type AnyDataWithSongs = WithSongs | InfiniteData -type AnyQueryWithSongs = UseQueryResult | UseInfiniteQueryResult<{ songs: Song[] }> - -function getSongs(data: T | undefined): Song[] { - if (!data) { - return [] - } - - if (Array.isArray(data)) { - return data - } - - if ('pages' in data) { - return data.pages.flatMap(p => p.songs) - } - - return data.songs || [] -} - -function setSongCoverArt(query: T, coverArts: UseQueryResult[]): T { - if (!query.data) { - return query - } - - const mapSongCoverArt = (song: Song) => ({ - ...song, - coverArt: coverArts.find(c => c.data?.albumId === song.albumId)?.data?.coverArt, - }) - - if (Array.isArray(query.data)) { - return { - ...query, - data: query.data.map(mapSongCoverArt), - } - } - - if ('pages' in query.data) { - return { - ...query, - data: { - pages: query.data.pages.map(p => ({ - ...p, - songs: p.songs.map(mapSongCoverArt), - })), - }, - } - } - - if (query.data.songs) { - return { - ...query, - data: { - ...query.data, - songs: query.data.songs.map(mapSongCoverArt), - }, - } - } - - return query -} - -// song cover art comes back from the api as a unique id per song even if it all points to the same -// album art, which prevents us from caching it once, so we need to use the album's cover art -const useFixCoverArt = (query: T) => { +export const useQueryAlbumCoverArtPath = (albumId?: string, size: CacheImageSize = 'thumbnail') => { const fetchAlbum = useFetchAlbum() - const songs = getSongs(query.data) - const albumIds = _.uniq((songs || []).map(s => s.albumId).filter((id): id is string => id !== undefined)) - - const coverArts = useQueries( - albumIds.map(id => ({ - queryKey: qk.albumCoverArt(id), - queryFn: async (): Promise => { - const res = await fetchAlbum(id) - return { albumId: res.album.id, coverArt: res.album.coverArt } - }, + const query = useQuery( + qk.albumCoverArt(albumId || '-1'), + async () => (await fetchAlbum(albumId || '-1')).album.coverArt, + { + enabled: !!albumId, staleTime: Infinity, cacheTime: Infinity, - notifyOnChangeProps: ['data', 'isFetched'] as any, - })), + }, ) - if (coverArts.every(c => c.isFetched)) { - return setSongCoverArt(query, coverArts) - } - - return query + return useQueryCoverArtPath(query.data, size) } diff --git a/app/hooks/trackplayer.ts b/app/hooks/trackplayer.ts index 2a2d46e..4bd6f8d 100644 --- a/app/hooks/trackplayer.ts +++ b/app/hooks/trackplayer.ts @@ -1,13 +1,11 @@ import { Song } from '@app/models/library' import { QueueContextType, TrackExt } from '@app/models/trackplayer' import queryClient from '@app/queryClient' +import queueService from '@app/queueservice' import { useStore, useStoreDeep } from '@app/state/store' import { getQueue, SetQueueOptions, trackPlayerCommands } from '@app/state/trackplayer' import userAgent from '@app/util/userAgent' -import _ from 'lodash' import TrackPlayer from 'react-native-track-player' -import { useQueries } from 'react-query' -import { useFetchExistingFile, useFetchFile } from './fetch' import qk from './queryKeys' export const usePlay = () => { @@ -92,87 +90,50 @@ export const useIsPlaying = (contextId: string | undefined, track: number) => { return contextId === queueContextId && track === currentTrackIdx } +export function mapSongToTrackExt(song: Song): TrackExt { + return { + id: song.id, + title: song.title, + artist: song.artist || 'Unknown Artist', + album: song.album || 'Unknown Album', + url: useStore.getState().buildStreamUri(song.id), + artwork: require('@res/fallback.png'), + userAgent, + duration: song.duration, + artistId: song.artistId, + albumId: song.albumId, + track: song.track, + discNumber: song.discNumber, + } +} + export const useSetQueue = (type: QueueContextType, songs?: Song[]) => { const _setQueue = useStore(store => store.setQueue) - const client = useStore(store => store.client) - const buildStreamUri = useStore(store => store.buildStreamUri) - const fetchFile = useFetchFile() - const fetchExistingFile = useFetchExistingFile() - - const songCoverArt = _.uniq((songs || []).map(s => s.coverArt)).filter((c): c is string => c !== undefined) - - const coverArtPaths = useQueries( - songCoverArt.map(coverArt => ({ - queryKey: qk.coverArt(coverArt, 'thumbnail'), - queryFn: async () => { - if (!client) { - return - } - - const itemType = 'coverArtThumb' - - const existingCache = queryClient.getQueryData(qk.existingFiles(itemType, coverArt)) - if (existingCache) { - return existingCache - } - - const existingDisk = await fetchExistingFile({ itemId: coverArt, itemType }) - if (existingDisk) { - return existingDisk - } - - const fromUrl = client.getCoverArtUri({ id: coverArt, size: '256' }) - return await fetchFile({ - itemType, - itemId: coverArt, - fromUrl, - expectedContentType: 'image', - }) - }, - enabled: !!client && !!songs, - staleTime: Infinity, - cacheTime: Infinity, - notifyOnChangeProps: ['data', 'isFetched'] as any, - })), - ) - - const songCoverArtToPath = _.zipObject( - songCoverArt, - coverArtPaths.map(c => c.data), - ) - - const mapSongToTrackExt = (s: Song): TrackExt => { - let artwork = require('@res/fallback.png') - if (s.coverArt) { - const filePath = songCoverArtToPath[s.coverArt] - if (filePath) { - artwork = `file://${filePath}` - } - } - - return { - id: s.id, - title: s.title, - artist: s.artist || 'Unknown Artist', - album: s.album || 'Unknown Album', - url: buildStreamUri(s.id), - userAgent, - artwork, - coverArt: s.coverArt, - duration: s.duration, - artistId: s.artistId, - albumId: s.albumId, - track: s.track, - discNumber: s.discNumber, - } - } const contextId = `${type}-${songs?.map(s => s.id).join('-')}` const setQueue = async (options: SetQueueOptions) => { - const queue = (songs || []).map(mapSongToTrackExt) - return await _setQueue({ queue, type, contextId, ...options }) + if (!songs || songs.length === 0) { + return + } + + const queue = songs.map(mapSongToTrackExt) + const first = queue[options.playTrack || 0] + + if (!first.albumId) { + first.artwork = require('@res/fallback.png') + } else { + const albumCoverArt = queryClient.getQueryData(qk.albumCoverArt(first.albumId)) + const existingFile = queryClient.getQueryData(qk.existingFiles('coverArtThumb', albumCoverArt)) + const downloadFile = queryClient.getQueryData(qk.coverArt(albumCoverArt, 'thumbnail')) + if (existingFile || downloadFile) { + first.artwork = `file://${existingFile || downloadFile}` + } + } + + await _setQueue({ queue, type, contextId, ...options }) + queueService.emit('set', { queue }) } - return { setQueue, contextId, isReady: coverArtPaths.every(c => c.isFetched) } + return { setQueue, contextId } } diff --git a/app/models/library.ts b/app/models/library.ts index f556df9..bace502 100644 --- a/app/models/library.ts +++ b/app/models/library.ts @@ -43,7 +43,6 @@ export interface Song { discNumber?: number duration?: number starred?: number - coverArt?: string playCount?: number userRating?: number averageRating?: number diff --git a/app/models/map.ts b/app/models/map.ts index 00f41f5..3a81051 100644 --- a/app/models/map.ts +++ b/app/models/map.ts @@ -75,7 +75,6 @@ export function mapTrackExtToSong(track: TrackExt): Song { title: track.title as string, artist: track.artist, album: track.album, - coverArt: track.coverArt, duration: track.duration, artistId: track.artistId, albumId: track.albumId, diff --git a/app/playbackservice.ts b/app/playbackservice.ts index 45c5b46..27daa57 100644 --- a/app/playbackservice.ts +++ b/app/playbackservice.ts @@ -1,8 +1,14 @@ import { getCurrentTrack, getPlayerState, trackPlayerCommands } from '@app/state/trackplayer' -import TrackPlayer, { Event, State } from 'react-native-track-player' -import { useStore } from './state/store' -import { unstable_batchedUpdates } from 'react-native' import NetInfo, { NetInfoStateType } from '@react-native-community/netinfo' +import _ from 'lodash' +import { unstable_batchedUpdates } from 'react-native' +import TrackPlayer, { Event, State } from 'react-native-track-player' +import { fetchAlbum, FetchExisingFileOptions, fetchExistingFile, fetchFile, FetchFileOptions } from './hooks/fetch' +import qk from './hooks/queryKeys' +import queryClient from './queryClient' +import queueService from './queueservice' +import { useStore } from './state/store' +import { ReturnedPromiseResolvedType } from './util/types' const reset = () => { unstable_batchedUpdates(() => { @@ -34,12 +40,81 @@ const rebuildQueue = (forcePlay?: boolean) => { }) } +const updateQueue = () => { + unstable_batchedUpdates(() => { + useStore.getState().updateQueue() + }) +} + const setDuckPaused = (duckPaused: boolean) => { unstable_batchedUpdates(() => { useStore.getState().setDuckPaused(duckPaused) }) } +const setQueryDataAlbum = (queryKey: any, data: ReturnedPromiseResolvedType) => { + unstable_batchedUpdates(() => { + queryClient.setQueryData(queryKey, data) + }) +} + +const setQueryDataExistingFiles = (queryKey: any, data: ReturnedPromiseResolvedType) => { + unstable_batchedUpdates(() => { + queryClient.setQueryData(queryKey, data) + }) +} + +const setQueryDataCoverArt = (queryKey: any, data: ReturnedPromiseResolvedType) => { + unstable_batchedUpdates(() => { + queryClient.setQueryData(queryKey, data) + }) +} + +function getClient() { + const client = useStore.getState().client + if (!client) { + throw new Error('no client!') + } + + return client +} + +async function getAlbum(id: string) { + try { + const res = await fetchAlbum(id, getClient()) + setQueryDataAlbum(qk.album(id), res) + return res + } catch {} +} + +async function getCoverArtThumbExisting(coverArt: string) { + const serverId = useStore.getState().settings.activeServerId + const options: FetchExisingFileOptions = { itemType: 'coverArtThumb', itemId: coverArt } + + try { + const res = await fetchExistingFile(options, serverId) + setQueryDataExistingFiles(qk.existingFiles(options.itemType, options.itemId), res) + return res + } catch {} +} + +async function getCoverArtThumb(coverArt: string) { + const serverId = useStore.getState().settings.activeServerId + const fromUrl = getClient().getCoverArtUri({ id: coverArt, size: '256' }) + const options: FetchFileOptions = { + itemType: 'coverArtThumb', + itemId: coverArt, + fromUrl, + expectedContentType: 'image', + } + + try { + const res = await fetchFile(options, serverId) + setQueryDataCoverArt(qk.coverArt(coverArt, 'thumbnail'), res) + return res + } catch {} +} + let serviceCreated = false const createService = async () => { @@ -142,6 +217,78 @@ const createService = async () => { rebuildQueue(true) } }) + + queueService.addListener('set', async ({ queue }) => { + const contextId = useStore.getState().queueContextId + const throwIfQueueChanged = () => { + if (contextId !== useStore.getState().queueContextId) { + throw 'queue-changed' + } + } + + const albumIds = _.uniq(queue.map(s => s.albumId)).filter((id): id is string => id !== undefined) + + const albumIdImagePath: { [albumId: string]: string | undefined } = {} + for (const albumId of albumIds) { + let coverArt = queryClient.getQueryData(qk.albumCoverArt(albumId)) + if (!coverArt) { + throwIfQueueChanged() + console.log('no cached coverArt for album', albumId, 'getting album...') + coverArt = (await getAlbum(albumId))?.album.coverArt + if (!coverArt) { + continue + } + } + + let imagePath = + queryClient.getQueryData(qk.existingFiles('coverArtThumb', coverArt)) || + queryClient.getQueryData(qk.coverArt(coverArt, 'thumbnail')) + if (!imagePath) { + throwIfQueueChanged() + console.log('no cached image for', coverArt, 'getting file...') + imagePath = (await getCoverArtThumbExisting(coverArt)) || (await getCoverArtThumb(coverArt)) + if (!imagePath) { + continue + } + } + + albumIdImagePath[albumId] = imagePath + } + + for (let i = 0; i < queue.length; i++) { + const track = queue[i] + if (typeof track.artwork === 'string') { + continue + } + + if (!track.albumId) { + continue + } + + let imagePath = albumIdImagePath[track.albumId] + if (!imagePath) { + continue + } + + try { + throwIfQueueChanged() + + let trackIdx = i + const shuffleOrder = useStore.getState().shuffleOrder + if (shuffleOrder) { + trackIdx = shuffleOrder.indexOf(i) + } + + await TrackPlayer.updateMetadataForTrack(trackIdx, { ...track, artwork: `file://${imagePath}` }) + } catch { + break + } + } + + await trackPlayerCommands.enqueue(async () => { + updateQueue() + }) + }) } module.exports = async function () { diff --git a/app/queueservice.ts b/app/queueservice.ts new file mode 100644 index 0000000..2b5411d --- /dev/null +++ b/app/queueservice.ts @@ -0,0 +1,18 @@ +/* eslint-disable no-dupe-class-members */ +import { EmitterSubscription, NativeEventEmitter } from 'react-native' +import { TrackExt } from './models/trackplayer' + +class QueueService extends NativeEventEmitter { + addListener(eventType: 'set', listener: (event: { queue: TrackExt[] }) => void): EmitterSubscription + addListener(eventType: string, listener: (event: any) => void, context?: Object): EmitterSubscription { + return super.addListener(eventType, listener, context) + } + + emit(eventType: 'set', event: { queue: TrackExt[] }): void + emit(eventType: string, ...params: any[]): void { + super.emit(eventType, ...params) + } +} + +const queueService = new QueueService() +export default queueService diff --git a/app/screens/ArtistView.tsx b/app/screens/ArtistView.tsx index 78a8093..275887f 100644 --- a/app/screens/ArtistView.tsx +++ b/app/screens/ArtistView.tsx @@ -49,7 +49,7 @@ const TopSongs = withSuspenseMemo<{ name: string }>( ({ songs, name }) => { - const { setQueue, isReady, contextId } = useSetQueue('artist', songs) + const { setQueue, contextId } = useSetQueue('artist', songs) const { t } = useTranslation() return ( @@ -64,7 +64,6 @@ const TopSongs = withSuspenseMemo<{ showArt={true} subtitle={s.album} onPress={() => setQueue({ title: name, playTrack: i })} - disabled={!isReady} /> ))} diff --git a/app/screens/NowPlayingView.tsx b/app/screens/NowPlayingView.tsx index 00bdc4d..73df61a 100644 --- a/app/screens/NowPlayingView.tsx +++ b/app/screens/NowPlayingView.tsx @@ -90,11 +90,11 @@ const headerStyles = StyleSheet.create({ }) const SongCoverArt = () => { - const coverArt = useStore(store => store.currentTrack?.coverArt) + const albumId = useStore(store => store.currentTrack?.albumId) return ( - + ) } diff --git a/app/screens/Search.tsx b/app/screens/Search.tsx index 0967970..8ef8222 100644 --- a/app/screens/Search.tsx +++ b/app/screens/Search.tsx @@ -26,7 +26,7 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context' const SongItem = React.memo<{ item: Song }>(({ item }) => { - const { setQueue, isReady, contextId } = useSetQueue('song', [item]) + const { setQueue, contextId } = useSetQueue('song', [item]) return ( (({ item }) => { showArt={true} showStar={false} onPress={() => setQueue({ title: item.title, playTrack: 0 })} - disabled={!isReady} /> ) }, equal) diff --git a/app/screens/SearchResultsView.tsx b/app/screens/SearchResultsView.tsx index 079a46f..ce7efbb 100644 --- a/app/screens/SearchResultsView.tsx +++ b/app/screens/SearchResultsView.tsx @@ -13,7 +13,7 @@ import { StyleSheet } from 'react-native' type SearchListItemType = Album | Song | Artist const SongResultsListItem: React.FC<{ item: Song }> = ({ item }) => { - const { setQueue, isReady, contextId } = useSetQueue('song', [item]) + const { setQueue, contextId } = useSetQueue('song', [item]) return ( = ({ item }) => { listStyle="small" onPress={() => setQueue({ title: item.title, playTrack: 0 })} style={styles.listItem} - disabled={!isReady} /> ) } diff --git a/app/screens/SongListView.tsx b/app/screens/SongListView.tsx index 31bec03..2886902 100644 --- a/app/screens/SongListView.tsx +++ b/app/screens/SongListView.tsx @@ -69,13 +69,13 @@ const SongListDetails = React.memo<{ } } - const { setQueue, isReady, contextId } = useSetQueue(type, _songs) + const { setQueue, contextId } = useSetQueue(type, _songs) if (!songList) { return } - const disabled = !isReady || _songs.length === 0 + const disabled = _songs.length === 0 const play = (track?: number, shuffle?: boolean) => () => setQueue({ title: songList.name, playTrack: track, shuffle }) diff --git a/app/state/trackplayer.ts b/app/state/trackplayer.ts index eb1c5c8..693cc2b 100644 --- a/app/state/trackplayer.ts +++ b/app/state/trackplayer.ts @@ -55,6 +55,7 @@ export type TrackPlayerSlice = { setNetState: (netState: 'mobile' | 'wifi') => Promise rebuildQueue: (forcePlay?: boolean) => Promise + updateQueue: () => Promise buildStreamUri: (id: string) => string resetTrackPlayerState: () => void @@ -314,6 +315,17 @@ export const createTrackPlayerSlice = (set: SetStore, get: GetStore): TrackPlaye }) }, + updateQueue: async () => { + const newQueue = await getQueue() + const currentTrack = await getCurrentTrack() + set(state => { + state.queue = newQueue + if (currentTrack !== undefined) { + state.currentTrack = newQueue[currentTrack] + } + }) + }, + buildStreamUri: id => { const client = get().client if (!client) { diff --git a/app/util/types.ts b/app/util/types.ts new file mode 100644 index 0000000..45d5cab --- /dev/null +++ b/app/util/types.ts @@ -0,0 +1,2 @@ +export type PromiseResolvedType = T extends Promise ? R : never +export type ReturnedPromiseResolvedType any> = PromiseResolvedType>