mirror of
https://github.com/austinried/subtracks.git
synced 2026-02-10 15:02:42 +01:00
Library store refactor (#76)
* start of music store refactor moving stuff into a state cache better separate it from view logic * added paginated list/album list * reworked fetchAlbumList to remove ui state refactored home screen to use new method i broke playing songs somehow, JS thread goes into a loop * don't reset parts manually, do it all at once * fixed perf issue related to too many rerenders rerenders were caused by strict equality check on object/array picks switched artistInfo to new store updated zustand and fixed deprecation warnings * update typescript and use workspace tsc version for vscode * remove old artistInfo * switched to new playlist w/songs removed more unused stuff * remove unused + (slightly) rework search * refactor star * use only original/large imges for covers/artist fix view artist from context menu add loading indicators to song list and artist views (show info we have right away) * set starred/unstar assuming it works and correct state on error * reorg, remove old music slice files * added back fix for song cover art * sort artists by localCompare name * update licenses * fix now playing background grey bar * update react-native-gesture-handler for node-fetch security alert * fix another gradient height grey bar issue * update licenses again * remove thumbnail cache * rename to remove "Library" from methods * Revert "remove thumbnail cache" This reverts commite0db4931f1. * use ids for lists, pull state later * Revert "use only original/large imges for covers/artist" This reverts commitc9aea9065c. * deep equal ListItem props for now this needs a bigger refactor * use immer as middleware * refactor api client to use string method hoping to use this for requestKey/deduping next * use thumbnails in list items * Revert "refactor api client to use string method" This reverts commit234326135b. * rename/cleanup * store servers by id * get rid of settings selectors * renames for clarity remove unused estimateContentLength setting * remove trackplayer selectors * fix migration for library filter settings * fixed shuffle order reporting wrong track/queue * removed the other selectors * don't actually need es6/react for our state * fix slow artist sort on star localeCompare is too slow for large lists
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
import { CacheFile, CacheImageSize, CacheItemType, CacheItemTypeKey, CacheRequest } from '@app/models/cache'
|
||||
import { mkdir, rmdir } from '@app/util/fs'
|
||||
import PromiseQueue from '@app/util/PromiseQueue'
|
||||
import produce from 'immer'
|
||||
import RNFS from 'react-native-fs'
|
||||
import { GetState, SetState } from 'zustand'
|
||||
import { Store } from './store'
|
||||
import { GetStore, SetStore } from './store'
|
||||
|
||||
const queues: Record<CacheItemTypeKey, PromiseQueue> = {
|
||||
coverArt: new PromiseQueue(5),
|
||||
@@ -20,16 +18,6 @@ export type CacheDirsByServer = Record<string, Record<CacheItemTypeKey, string>>
|
||||
export type CacheFilesByServer = Record<string, Record<CacheItemTypeKey, Record<string, CacheFile>>>
|
||||
export type CacheRequestsByServer = Record<string, Record<CacheItemTypeKey, Record<string, CacheRequest>>>
|
||||
|
||||
// export type DownloadedItemsByServer = Record<
|
||||
// string,
|
||||
// {
|
||||
// songs: { [songId: string]: DownloadedSong }
|
||||
// albums: { [albumId: string]: DownloadedAlbum }
|
||||
// artists: { [songId: string]: DownloadedArtist }
|
||||
// playlists: { [playlistId: string]: DownloadedPlaylist }
|
||||
// }
|
||||
// >
|
||||
|
||||
export type CacheSlice = {
|
||||
cacheItem: (
|
||||
key: CacheItemTypeKey,
|
||||
@@ -51,13 +39,7 @@ export type CacheSlice = {
|
||||
clearImageCache: () => Promise<void>
|
||||
}
|
||||
|
||||
export const selectCache = {
|
||||
cacheItem: (store: CacheSlice) => store.cacheItem,
|
||||
fetchCoverArtFilePath: (store: CacheSlice) => store.fetchCoverArtFilePath,
|
||||
clearImageCache: (store: CacheSlice) => store.clearImageCache,
|
||||
}
|
||||
|
||||
export const createCacheSlice = (set: SetState<Store>, get: GetState<Store>): CacheSlice => ({
|
||||
export const createCacheSlice = (set: SetStore, get: GetStore): CacheSlice => ({
|
||||
// cache: {},
|
||||
cacheDirs: {},
|
||||
cacheFiles: {},
|
||||
@@ -69,7 +51,7 @@ export const createCacheSlice = (set: SetState<Store>, get: GetState<Store>): Ca
|
||||
return
|
||||
}
|
||||
|
||||
const activeServerId = get().settings.activeServer
|
||||
const activeServerId = get().settings.activeServerId
|
||||
if (!activeServerId) {
|
||||
return
|
||||
}
|
||||
@@ -115,34 +97,28 @@ export const createCacheSlice = (set: SetState<Store>, get: GetState<Store>): Ca
|
||||
// },
|
||||
}).promise
|
||||
|
||||
set(
|
||||
produce<CacheSlice>(state => {
|
||||
state.cacheRequests[activeServerId][key][itemId].progress = 1
|
||||
delete state.cacheRequests[activeServerId][key][itemId].promise
|
||||
}),
|
||||
)
|
||||
set(state => {
|
||||
state.cacheRequests[activeServerId][key][itemId].progress = 1
|
||||
delete state.cacheRequests[activeServerId][key][itemId].promise
|
||||
})
|
||||
} catch {
|
||||
set(
|
||||
produce<CacheSlice>(state => {
|
||||
delete state.cacheFiles[activeServerId][key][itemId]
|
||||
delete state.cacheRequests[activeServerId][key][itemId]
|
||||
}),
|
||||
)
|
||||
set(state => {
|
||||
delete state.cacheFiles[activeServerId][key][itemId]
|
||||
delete state.cacheRequests[activeServerId][key][itemId]
|
||||
})
|
||||
}
|
||||
})
|
||||
set(state => {
|
||||
state.cacheFiles[activeServerId][key][itemId] = {
|
||||
path,
|
||||
date: Date.now(),
|
||||
permanent: false,
|
||||
}
|
||||
state.cacheRequests[activeServerId][key][itemId] = {
|
||||
progress: 0,
|
||||
promise,
|
||||
}
|
||||
})
|
||||
set(
|
||||
produce<Store>(state => {
|
||||
state.cacheFiles[activeServerId][key][itemId] = {
|
||||
path,
|
||||
date: Date.now(),
|
||||
permanent: false,
|
||||
}
|
||||
state.cacheRequests[activeServerId][key][itemId] = {
|
||||
progress: 0,
|
||||
promise,
|
||||
}
|
||||
}),
|
||||
)
|
||||
return await promise
|
||||
},
|
||||
|
||||
@@ -152,7 +128,7 @@ export const createCacheSlice = (set: SetState<Store>, get: GetState<Store>): Ca
|
||||
return
|
||||
}
|
||||
|
||||
const activeServerId = get().settings.activeServer
|
||||
const activeServerId = get().settings.activeServerId
|
||||
if (!activeServerId) {
|
||||
return
|
||||
}
|
||||
@@ -183,54 +159,48 @@ export const createCacheSlice = (set: SetState<Store>, get: GetState<Store>): Ca
|
||||
await mkdir(`${RNFS.DocumentDirectoryPath}/servers/${serverId}/${type}`)
|
||||
}
|
||||
|
||||
set(
|
||||
produce<CacheSlice>(state => {
|
||||
state.cacheFiles[serverId] = {
|
||||
set(state => {
|
||||
state.cacheFiles[serverId] = {
|
||||
song: {},
|
||||
coverArt: {},
|
||||
coverArtThumb: {},
|
||||
artistArt: {},
|
||||
artistArtThumb: {},
|
||||
}
|
||||
})
|
||||
|
||||
get().prepareCache(serverId)
|
||||
},
|
||||
|
||||
prepareCache: serverId => {
|
||||
set(state => {
|
||||
if (!state.cacheDirs[serverId]) {
|
||||
state.cacheDirs[serverId] = {
|
||||
song: `${RNFS.DocumentDirectoryPath}/servers/${serverId}/song`,
|
||||
coverArt: `${RNFS.DocumentDirectoryPath}/servers/${serverId}/coverArt`,
|
||||
coverArtThumb: `${RNFS.DocumentDirectoryPath}/servers/${serverId}/coverArtThumb`,
|
||||
artistArt: `${RNFS.DocumentDirectoryPath}/servers/${serverId}/artistArt`,
|
||||
artistArtThumb: `${RNFS.DocumentDirectoryPath}/servers/${serverId}/artistArtThumb`,
|
||||
}
|
||||
}
|
||||
if (!state.cacheRequests[serverId]) {
|
||||
state.cacheRequests[serverId] = {
|
||||
song: {},
|
||||
coverArt: {},
|
||||
coverArtThumb: {},
|
||||
artistArt: {},
|
||||
artistArtThumb: {},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
get().prepareCache(serverId)
|
||||
},
|
||||
|
||||
prepareCache: serverId => {
|
||||
set(
|
||||
produce<CacheSlice>(state => {
|
||||
if (!state.cacheDirs[serverId]) {
|
||||
state.cacheDirs[serverId] = {
|
||||
song: `${RNFS.DocumentDirectoryPath}/servers/${serverId}/song`,
|
||||
coverArt: `${RNFS.DocumentDirectoryPath}/servers/${serverId}/coverArt`,
|
||||
coverArtThumb: `${RNFS.DocumentDirectoryPath}/servers/${serverId}/coverArtThumb`,
|
||||
artistArt: `${RNFS.DocumentDirectoryPath}/servers/${serverId}/artistArt`,
|
||||
artistArtThumb: `${RNFS.DocumentDirectoryPath}/servers/${serverId}/artistArtThumb`,
|
||||
}
|
||||
}
|
||||
if (!state.cacheRequests[serverId]) {
|
||||
state.cacheRequests[serverId] = {
|
||||
song: {},
|
||||
coverArt: {},
|
||||
coverArtThumb: {},
|
||||
artistArt: {},
|
||||
artistArtThumb: {},
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
pendingRemoval: {},
|
||||
|
||||
removeCache: async serverId => {
|
||||
set(
|
||||
produce<CacheSlice>(state => {
|
||||
state.pendingRemoval[serverId] = true
|
||||
}),
|
||||
)
|
||||
set(state => {
|
||||
state.pendingRemoval[serverId] = true
|
||||
})
|
||||
|
||||
const cacheRequests = get().cacheRequests[serverId]
|
||||
const pendingRequests: Promise<void>[] = []
|
||||
@@ -245,21 +215,19 @@ export const createCacheSlice = (set: SetState<Store>, get: GetState<Store>): Ca
|
||||
await Promise.all(pendingRequests)
|
||||
await rmdir(`${RNFS.DocumentDirectoryPath}/servers/${serverId}`)
|
||||
|
||||
set(
|
||||
produce<CacheSlice>(state => {
|
||||
delete state.pendingRemoval[serverId]
|
||||
set(state => {
|
||||
delete state.pendingRemoval[serverId]
|
||||
|
||||
if (state.cacheDirs[serverId]) {
|
||||
delete state.cacheDirs[serverId]
|
||||
}
|
||||
if (state.cacheFiles[serverId]) {
|
||||
delete state.cacheFiles[serverId]
|
||||
}
|
||||
if (state.cacheRequests[serverId]) {
|
||||
delete state.cacheRequests[serverId]
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (state.cacheDirs[serverId]) {
|
||||
delete state.cacheDirs[serverId]
|
||||
}
|
||||
if (state.cacheFiles[serverId]) {
|
||||
delete state.cacheFiles[serverId]
|
||||
}
|
||||
if (state.cacheRequests[serverId]) {
|
||||
delete state.cacheRequests[serverId]
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
clearImageCache: async () => {
|
||||
@@ -280,14 +248,12 @@ export const createCacheSlice = (set: SetState<Store>, get: GetState<Store>): Ca
|
||||
await rmdir(get().cacheDirs[serverId].artistArt)
|
||||
await mkdir(get().cacheDirs[serverId].artistArt)
|
||||
|
||||
set(
|
||||
produce<CacheSlice>(state => {
|
||||
state.cacheFiles[serverId].coverArt = {}
|
||||
state.cacheFiles[serverId].coverArtThumb = {}
|
||||
state.cacheFiles[serverId].artistArt = {}
|
||||
state.cacheFiles[serverId].artistArtThumb = {}
|
||||
}),
|
||||
)
|
||||
set(state => {
|
||||
state.cacheFiles[serverId].coverArt = {}
|
||||
state.cacheFiles[serverId].coverArtThumb = {}
|
||||
state.cacheFiles[serverId].artistArt = {}
|
||||
state.cacheFiles[serverId].artistArtThumb = {}
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
521
app/state/library.ts
Normal file
521
app/state/library.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
import { Album, Artist, ArtistInfo, Playlist, SearchResults, Song } from '@app/models/library'
|
||||
import { ById, OneToMany } from '@app/models/state'
|
||||
import { GetStore, SetStore, Store } from '@app/state/store'
|
||||
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,
|
||||
GetSongResponse,
|
||||
GetTopSongsResponse,
|
||||
Search3Response,
|
||||
} from '@app/subsonic/responses'
|
||||
import PromiseQueue from '@app/util/PromiseQueue'
|
||||
import { mapId, mergeById, reduceById } from '@app/util/state'
|
||||
import { WritableDraft } from 'immer/dist/types/types-external'
|
||||
import pick from 'lodash.pick'
|
||||
|
||||
const songCoverArtQueue = new PromiseQueue(2)
|
||||
|
||||
export type LibrarySlice = {
|
||||
library: {
|
||||
artists: ById<Artist>
|
||||
artistInfo: ById<ArtistInfo>
|
||||
artistAlbums: OneToMany
|
||||
artistNameTopSongs: OneToMany
|
||||
artistOrder: string[]
|
||||
|
||||
albums: ById<Album>
|
||||
albumSongs: OneToMany
|
||||
|
||||
playlists: ById<Playlist>
|
||||
playlistSongs: OneToMany
|
||||
|
||||
songs: ById<Song>
|
||||
}
|
||||
|
||||
resetLibrary: (state?: WritableDraft<Store>) => void
|
||||
|
||||
fetchArtists: () => Promise<void>
|
||||
fetchArtist: (id: string) => Promise<void>
|
||||
fetchArtistInfo: (artistId: string) => Promise<void>
|
||||
fetchArtistTopSongs: (artistName: string) => Promise<void>
|
||||
|
||||
fetchAlbum: (id: string) => Promise<void>
|
||||
|
||||
fetchPlaylists: () => Promise<void>
|
||||
fetchPlaylist: (id: string) => Promise<void>
|
||||
|
||||
fetchSong: (id: string) => Promise<void>
|
||||
|
||||
fetchAlbumList: (params: GetAlbumList2Params) => Promise<string[]>
|
||||
fetchSearchResults: (params: Search3Params) => Promise<SearchResults>
|
||||
star: (params: StarParams) => Promise<void>
|
||||
unstar: (params: StarParams) => Promise<void>
|
||||
|
||||
_fixSongCoverArt: (songs: Song[]) => Promise<void>
|
||||
}
|
||||
|
||||
const defaultLibrary = () => ({
|
||||
artists: {},
|
||||
artistAlbums: {},
|
||||
artistInfo: {},
|
||||
artistNameTopSongs: {},
|
||||
artistOrder: [],
|
||||
|
||||
albums: {},
|
||||
albumSongs: {},
|
||||
|
||||
playlists: {},
|
||||
playlistSongs: {},
|
||||
|
||||
songs: {},
|
||||
})
|
||||
|
||||
export const createLibrarySlice = (set: SetStore, get: GetStore): LibrarySlice => ({
|
||||
library: defaultLibrary(),
|
||||
|
||||
resetLibrary: state => {
|
||||
if (state) {
|
||||
state.library = defaultLibrary()
|
||||
return
|
||||
}
|
||||
set(store => {
|
||||
store.library = defaultLibrary()
|
||||
})
|
||||
},
|
||||
|
||||
fetchArtists: async () => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
let response: GetArtistsResponse
|
||||
try {
|
||||
response = await client.getArtists()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const artists = response.data.artists.map(mapArtist)
|
||||
const artistsById = reduceById(artists)
|
||||
const artistIds = mapId(artists)
|
||||
|
||||
set(state => {
|
||||
state.library.artists = artistsById
|
||||
state.library.artistAlbums = pick(state.library.artistAlbums, artistIds)
|
||||
state.library.artistOrder = artistIds
|
||||
})
|
||||
},
|
||||
|
||||
fetchArtist: async id => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
let response: GetArtistResponse
|
||||
try {
|
||||
response = await client.getArtist({ id })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const artist = mapArtist(response.data.artist)
|
||||
const albums = response.data.albums.map(mapAlbum)
|
||||
const albumsById = reduceById(albums)
|
||||
|
||||
set(state => {
|
||||
state.library.artists[id] = artist
|
||||
state.library.artistAlbums[id] = mapId(albums)
|
||||
mergeById(state.library.albums, albumsById)
|
||||
})
|
||||
},
|
||||
|
||||
fetchArtistInfo: async id => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
let response: GetArtistInfo2Response
|
||||
try {
|
||||
response = await client.getArtistInfo2({ id })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const info = mapArtistInfo(id, response.data.artistInfo)
|
||||
|
||||
set(state => {
|
||||
state.library.artistInfo[id] = info
|
||||
})
|
||||
},
|
||||
|
||||
fetchArtistTopSongs: async artistName => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
let response: GetTopSongsResponse
|
||||
try {
|
||||
response = await client.getTopSongs({ artist: artistName, count: 50 })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const topSongs = response.data.songs.map(mapSong)
|
||||
const topSongsById = reduceById(topSongs)
|
||||
|
||||
get()._fixSongCoverArt(topSongs)
|
||||
|
||||
set(state => {
|
||||
mergeById(state.library.songs, topSongsById)
|
||||
state.library.artistNameTopSongs[artistName] = mapId(topSongs)
|
||||
})
|
||||
},
|
||||
|
||||
fetchAlbum: async id => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
let response: GetAlbumResponse
|
||||
try {
|
||||
response = await client.getAlbum({ id })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const album = mapAlbum(response.data.album)
|
||||
const songs = response.data.songs.map(mapSong)
|
||||
const songsById = reduceById(songs)
|
||||
|
||||
get()._fixSongCoverArt(songs)
|
||||
|
||||
set(state => {
|
||||
state.library.albums[id] = album
|
||||
state.library.albumSongs[id] = mapId(songs)
|
||||
mergeById(state.library.songs, songsById)
|
||||
})
|
||||
},
|
||||
|
||||
fetchPlaylists: async () => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
let response: GetPlaylistsResponse
|
||||
try {
|
||||
response = await client.getPlaylists()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const playlists = response.data.playlists.map(mapPlaylist)
|
||||
const playlistsById = reduceById(playlists)
|
||||
|
||||
set(state => {
|
||||
state.library.playlists = playlistsById
|
||||
state.library.playlistSongs = pick(state.library.playlistSongs, mapId(playlists))
|
||||
})
|
||||
},
|
||||
|
||||
fetchPlaylist: async id => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
let response: GetPlaylistResponse
|
||||
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)
|
||||
|
||||
get()._fixSongCoverArt(songs)
|
||||
|
||||
set(state => {
|
||||
state.library.playlists[id] = playlist
|
||||
state.library.playlistSongs[id] = mapId(songs)
|
||||
mergeById(state.library.songs, songsById)
|
||||
})
|
||||
},
|
||||
|
||||
fetchSong: async id => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
let response: GetSongResponse
|
||||
try {
|
||||
response = await client.getSong({ id })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const song = mapSong(response.data.song)
|
||||
|
||||
get()._fixSongCoverArt([song])
|
||||
|
||||
set(state => {
|
||||
state.library.songs[id] = song
|
||||
})
|
||||
},
|
||||
|
||||
fetchAlbumList: async params => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return []
|
||||
}
|
||||
|
||||
let response: GetAlbumList2Response
|
||||
try {
|
||||
response = await client.getAlbumList2(params)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const albums = response.data.albums.map(mapAlbum)
|
||||
const albumsById = reduceById(albums)
|
||||
|
||||
set(state => {
|
||||
mergeById(state.library.albums, albumsById)
|
||||
})
|
||||
|
||||
return mapId(albums)
|
||||
},
|
||||
|
||||
fetchSearchResults: async params => {
|
||||
const empty = { artists: [], albums: [], songs: [] }
|
||||
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return empty
|
||||
}
|
||||
|
||||
let response: Search3Response
|
||||
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)
|
||||
|
||||
get()._fixSongCoverArt(songs)
|
||||
|
||||
set(state => {
|
||||
mergeById(state.library.artists, artistsById)
|
||||
mergeById(state.library.albums, albumsById)
|
||||
mergeById(state.library.songs, songsById)
|
||||
})
|
||||
|
||||
return {
|
||||
artists: mapId(artists),
|
||||
albums: mapId(albums),
|
||||
songs: mapId(songs),
|
||||
}
|
||||
},
|
||||
|
||||
star: async params => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
let id = '-1'
|
||||
let entity: 'songs' | 'artists' | 'albums' = 'songs'
|
||||
if (params.id) {
|
||||
id = params.id
|
||||
entity = 'songs'
|
||||
} else if (params.albumId) {
|
||||
id = params.albumId
|
||||
entity = 'albums'
|
||||
} else if (params.artistId) {
|
||||
id = params.artistId
|
||||
entity = 'artists'
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
const item = get().library[entity][id]
|
||||
const originalValue = item ? item.starred : null
|
||||
|
||||
set(state => {
|
||||
state.library[entity][id].starred = new Date()
|
||||
})
|
||||
|
||||
try {
|
||||
await client.star(params)
|
||||
} catch {
|
||||
set(state => {
|
||||
if (originalValue !== null) {
|
||||
state.library[entity][id].starred = originalValue
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
unstar: async params => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
let id = '-1'
|
||||
let entity: 'songs' | 'artists' | 'albums' = 'songs'
|
||||
if (params.id) {
|
||||
id = params.id
|
||||
entity = 'songs'
|
||||
} else if (params.albumId) {
|
||||
id = params.albumId
|
||||
entity = 'albums'
|
||||
} else if (params.artistId) {
|
||||
id = params.artistId
|
||||
entity = 'artists'
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
const item = get().library[entity][id]
|
||||
const originalValue = item ? item.starred : null
|
||||
|
||||
set(state => {
|
||||
state.library[entity][id].starred = undefined
|
||||
})
|
||||
|
||||
try {
|
||||
await client.unstar(params)
|
||||
} catch {
|
||||
set(state => {
|
||||
if (originalValue !== null) {
|
||||
state.library[entity][id].starred = originalValue
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// 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
|
||||
_fixSongCoverArt: async songs => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const albumsToGet: ById<Song[]> = {}
|
||||
for (const song of songs) {
|
||||
if (!song.albumId) {
|
||||
continue
|
||||
}
|
||||
|
||||
let album = get().library.albums[song.albumId]
|
||||
if (album) {
|
||||
song.coverArt = album.coverArt
|
||||
continue
|
||||
}
|
||||
|
||||
albumsToGet[song.albumId] = albumsToGet[song.albumId] || []
|
||||
albumsToGet[song.albumId].push(song)
|
||||
}
|
||||
|
||||
for (const id in albumsToGet) {
|
||||
songCoverArtQueue
|
||||
.enqueue(() => client.getAlbum({ id }))
|
||||
.then(res => {
|
||||
const album = mapAlbum(res.data.album)
|
||||
|
||||
set(state => {
|
||||
state.library.albums[album.id] = album
|
||||
for (const song of albumsToGet[album.id]) {
|
||||
state.library.songs[song.id].coverArt = album.coverArt
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function mapArtist(artist: ArtistID3Element): Artist {
|
||||
return {
|
||||
itemType: 'artist',
|
||||
id: artist.id,
|
||||
name: artist.name,
|
||||
starred: artist.starred,
|
||||
coverArt: artist.coverArt,
|
||||
}
|
||||
}
|
||||
|
||||
function mapArtistInfo(id: string, info: ArtistInfo2Element): ArtistInfo {
|
||||
return {
|
||||
id,
|
||||
smallImageUrl: info.smallImageUrl,
|
||||
largeImageUrl: info.largeImageUrl,
|
||||
}
|
||||
}
|
||||
|
||||
function mapAlbum(album: AlbumID3Element): Album {
|
||||
return {
|
||||
itemType: 'album',
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
artist: album.artist,
|
||||
artistId: album.artistId,
|
||||
starred: album.starred,
|
||||
coverArt: album.coverArt,
|
||||
year: album.year,
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
id: song.id,
|
||||
album: song.album,
|
||||
albumId: song.albumId,
|
||||
artist: song.artist,
|
||||
artistId: song.artistId,
|
||||
title: song.title,
|
||||
track: song.track,
|
||||
discNumber: song.discNumber,
|
||||
duration: song.duration,
|
||||
starred: song.starred,
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,34 @@
|
||||
import { Server } from '@app/models/settings'
|
||||
import { ById } from '@app/models/state'
|
||||
|
||||
const migrations: Array<(state: any) => any> = [
|
||||
state => {
|
||||
for (let server of state.settings.servers) {
|
||||
server.usePlainPassword = false
|
||||
}
|
||||
|
||||
return state
|
||||
},
|
||||
state => {
|
||||
state.settings.servers = state.settings.servers.reduce((acc: ById<Server>, server: Server) => {
|
||||
acc[server.id] = server
|
||||
return acc
|
||||
}, {} as ById<Server>)
|
||||
|
||||
state.settings.activeServerId = state.settings.activeServer
|
||||
delete state.settings.activeServer
|
||||
|
||||
state.settings.screens.home.listTypes = [...state.settings.screens.home.lists]
|
||||
delete state.settings.screens.home.lists
|
||||
|
||||
state.settings.screens.library.albumsFilter = { ...state.settings.screens.library.albums }
|
||||
delete state.settings.screens.library.albums
|
||||
|
||||
state.settings.screens.library.artistsFilter = { ...state.settings.screens.library.artists }
|
||||
delete state.settings.screens.library.artists
|
||||
|
||||
delete state.settings.estimateContentLength
|
||||
|
||||
return state
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,471 +0,0 @@
|
||||
import {
|
||||
AlbumListItem,
|
||||
AlbumWithSongs,
|
||||
Artist,
|
||||
ArtistInfo,
|
||||
HomeLists,
|
||||
PlaylistListItem,
|
||||
PlaylistWithSongs,
|
||||
SearchResults,
|
||||
StarrableItemType,
|
||||
} from '@app/models/music'
|
||||
import { Store } from '@app/state/store'
|
||||
import { GetAlbumList2Params, GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params'
|
||||
import produce from 'immer'
|
||||
import { GetState, SetState } from 'zustand'
|
||||
|
||||
export type MusicSlice = {
|
||||
//
|
||||
// family-style state
|
||||
//
|
||||
artistInfo: { [id: string]: ArtistInfo }
|
||||
fetchArtistInfo: (id: string) => Promise<ArtistInfo | undefined>
|
||||
|
||||
albumsWithSongs: { [id: string]: AlbumWithSongs }
|
||||
fetchAlbumWithSongs: (id: string) => Promise<AlbumWithSongs | undefined>
|
||||
|
||||
playlistsWithSongs: { [id: string]: PlaylistWithSongs }
|
||||
fetchPlaylistWithSongs: (id: string) => Promise<PlaylistWithSongs | undefined>
|
||||
|
||||
//
|
||||
// lists-style state
|
||||
//
|
||||
fetchArtists: (size?: number, offset?: number) => Promise<Artist[]>
|
||||
fetchPlaylists: () => Promise<PlaylistListItem[]>
|
||||
fetchAlbums: () => Promise<AlbumListItem[]>
|
||||
fetchSearchResults: (
|
||||
query: string,
|
||||
type?: 'album' | 'song' | 'artist',
|
||||
size?: number,
|
||||
offset?: number,
|
||||
) => Promise<SearchResults>
|
||||
|
||||
homeLists: HomeLists
|
||||
homeListsUpdating: boolean
|
||||
fetchHomeLists: () => Promise<void>
|
||||
clearHomeLists: () => void
|
||||
|
||||
//
|
||||
// actions, etc.
|
||||
//
|
||||
starredSongs: { [id: string]: boolean }
|
||||
starredAlbums: { [id: string]: boolean }
|
||||
starredArtists: { [id: string]: boolean }
|
||||
starItem: (id: string, type: StarrableItemType, unstar?: boolean) => Promise<void>
|
||||
|
||||
albumIdCoverArt: { [id: string]: string | undefined }
|
||||
albumIdCoverArtRequests: { [id: string]: Promise<void> }
|
||||
fetchAlbumCoverArt: (id: string) => Promise<void>
|
||||
getAlbumCoverArt: (id: string | undefined) => Promise<string | undefined>
|
||||
}
|
||||
|
||||
export const selectMusic = {
|
||||
fetchArtistInfo: (state: Store) => state.fetchArtistInfo,
|
||||
fetchAlbumWithSongs: (state: Store) => state.fetchAlbumWithSongs,
|
||||
fetchPlaylistWithSongs: (state: Store) => state.fetchPlaylistWithSongs,
|
||||
|
||||
fetchArtists: (store: MusicSlice) => store.fetchArtists,
|
||||
fetchPlaylists: (store: MusicSlice) => store.fetchPlaylists,
|
||||
fetchAlbums: (store: MusicSlice) => store.fetchAlbums,
|
||||
fetchSearchResults: (store: MusicSlice) => store.fetchSearchResults,
|
||||
|
||||
homeLists: (store: MusicSlice) => store.homeLists,
|
||||
homeListsUpdating: (store: MusicSlice) => store.homeListsUpdating,
|
||||
fetchHomeLists: (store: MusicSlice) => store.fetchHomeLists,
|
||||
clearHomeLists: (store: MusicSlice) => store.clearHomeLists,
|
||||
|
||||
starItem: (store: MusicSlice) => store.starItem,
|
||||
}
|
||||
|
||||
function reduceStarred(
|
||||
starredType: { [id: string]: boolean },
|
||||
items: { id: string; starred?: Date | boolean }[],
|
||||
): { [id: string]: boolean } {
|
||||
return {
|
||||
...starredType,
|
||||
...items.reduce((acc, val) => {
|
||||
acc[val.id] = !!val.starred
|
||||
return acc
|
||||
}, {} as { [id: string]: boolean }),
|
||||
}
|
||||
}
|
||||
|
||||
export const createMusicSlice = (set: SetState<Store>, get: GetState<Store>): MusicSlice => ({
|
||||
artistInfo: {},
|
||||
|
||||
fetchArtistInfo: async id => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const [artistResponse, artistInfoResponse] = await Promise.all([
|
||||
client.getArtist({ id }),
|
||||
client.getArtistInfo2({ id }),
|
||||
])
|
||||
const topSongsResponse = await client.getTopSongs({ artist: artistResponse.data.artist.name, count: 50 })
|
||||
const artistInfo = await get().mapArtistInfo(
|
||||
artistResponse.data,
|
||||
artistInfoResponse.data.artistInfo,
|
||||
topSongsResponse.data.songs,
|
||||
)
|
||||
|
||||
set(
|
||||
produce<MusicSlice>(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)
|
||||
}),
|
||||
)
|
||||
return artistInfo
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
|
||||
albumsWithSongs: {},
|
||||
|
||||
fetchAlbumWithSongs: async id => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.getAlbum({ id })
|
||||
const album = await get().mapAlbumID3WithSongstoAlbumWithSongs(response.data.album, response.data.songs)
|
||||
|
||||
set(
|
||||
produce<MusicSlice>(state => {
|
||||
state.albumsWithSongs[id] = album
|
||||
state.starredSongs = reduceStarred(state.starredSongs, album.songs)
|
||||
state.starredAlbums = reduceStarred(state.starredAlbums, [album])
|
||||
}),
|
||||
)
|
||||
return album
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
|
||||
playlistsWithSongs: {},
|
||||
|
||||
fetchPlaylistWithSongs: async id => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.getPlaylist({ id })
|
||||
const playlist = await get().mapPlaylistWithSongs(response.data.playlist)
|
||||
|
||||
set(
|
||||
produce<MusicSlice>(state => {
|
||||
state.playlistsWithSongs[id] = playlist
|
||||
state.starredSongs = reduceStarred(state.starredSongs, playlist.songs)
|
||||
}),
|
||||
)
|
||||
return playlist
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
|
||||
fetchArtists: async () => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.getArtists()
|
||||
const artists = response.data.artists.map(get().mapArtistID3toArtist)
|
||||
|
||||
set(
|
||||
produce<MusicSlice>(state => {
|
||||
state.starredArtists = reduceStarred(state.starredArtists, artists)
|
||||
}),
|
||||
)
|
||||
|
||||
return artists
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
fetchPlaylists: async () => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.getPlaylists()
|
||||
return response.data.playlists.map(get().mapPlaylistListItem)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
fetchAlbums: async (size = 500, offset = 0) => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const filter = get().settings.screens.library.albums
|
||||
|
||||
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 response = await client.getAlbumList2(params)
|
||||
const albums = response.data.albums.map(get().mapAlbumID3toAlbumListItem)
|
||||
|
||||
set(
|
||||
produce<MusicSlice>(state => {
|
||||
state.starredAlbums = reduceStarred(state.starredAlbums, albums)
|
||||
}),
|
||||
)
|
||||
|
||||
return albums
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
fetchSearchResults: async (query, type, size, offset) => {
|
||||
if (query.length < 2) {
|
||||
return { artists: [], albums: [], songs: [] }
|
||||
}
|
||||
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return { artists: [], albums: [], songs: [] }
|
||||
}
|
||||
|
||||
try {
|
||||
const params: Search3Params = { query }
|
||||
if (type === 'album') {
|
||||
params.albumCount = size
|
||||
params.albumOffset = offset
|
||||
} else if (type === 'artist') {
|
||||
params.artistCount = size
|
||||
params.artistOffset = offset
|
||||
} else if (type === 'song') {
|
||||
params.songCount = size
|
||||
params.songOffset = offset
|
||||
} else {
|
||||
params.albumCount = 5
|
||||
params.artistCount = 5
|
||||
params.songCount = 5
|
||||
}
|
||||
|
||||
const response = await client.search3(params)
|
||||
|
||||
const artists = response.data.artists.map(get().mapArtistID3toArtist)
|
||||
const albums = response.data.albums.map(get().mapAlbumID3toAlbumListItem)
|
||||
const songs = await get().mapChildrenToSongs(response.data.songs)
|
||||
|
||||
set(
|
||||
produce<MusicSlice>(state => {
|
||||
state.starredSongs = reduceStarred(state.starredSongs, songs)
|
||||
state.starredArtists = reduceStarred(state.starredArtists, artists)
|
||||
state.starredAlbums = reduceStarred(state.starredAlbums, albums)
|
||||
}),
|
||||
)
|
||||
|
||||
return { artists, albums, songs }
|
||||
} catch {
|
||||
return { artists: [], albums: [], songs: [] }
|
||||
}
|
||||
},
|
||||
|
||||
homeLists: {},
|
||||
homeListsUpdating: false,
|
||||
|
||||
fetchHomeLists: async () => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
if (get().homeListsUpdating) {
|
||||
return
|
||||
}
|
||||
set({ homeListsUpdating: true })
|
||||
|
||||
const types = get().settings.screens.home.lists
|
||||
|
||||
try {
|
||||
const promises: Promise<any>[] = []
|
||||
for (const type of types) {
|
||||
promises.push(
|
||||
client
|
||||
.getAlbumList2({ type: type as GetAlbumList2TypeBase, size: 20 })
|
||||
.then(response => {
|
||||
const list = response.data.albums.map(get().mapAlbumID3toAlbumListItem)
|
||||
set(
|
||||
produce<MusicSlice>(state => {
|
||||
state.homeLists[type] = list
|
||||
state.starredAlbums = reduceStarred(state.starredAlbums, state.homeLists[type])
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch(() => {}),
|
||||
)
|
||||
}
|
||||
await Promise.all(promises)
|
||||
} finally {
|
||||
set({ homeListsUpdating: false })
|
||||
}
|
||||
},
|
||||
|
||||
clearHomeLists: () => {
|
||||
set({ homeLists: {} })
|
||||
},
|
||||
|
||||
starredSongs: {},
|
||||
starredAlbums: {},
|
||||
starredArtists: {},
|
||||
|
||||
starItem: async (id, type, unstar = false) => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
let params: StarParams
|
||||
let setStarred: (starred: boolean) => void
|
||||
|
||||
switch (type) {
|
||||
case 'song':
|
||||
params = { id }
|
||||
setStarred = starred => {
|
||||
set(
|
||||
produce<MusicSlice>(state => {
|
||||
state.starredSongs = reduceStarred(state.starredSongs, [{ id, starred }])
|
||||
}),
|
||||
)
|
||||
}
|
||||
break
|
||||
case 'album':
|
||||
params = { albumId: id }
|
||||
setStarred = starred => {
|
||||
set(
|
||||
produce<MusicSlice>(state => {
|
||||
state.starredAlbums = reduceStarred(state.starredAlbums, [{ id, starred }])
|
||||
}),
|
||||
)
|
||||
}
|
||||
break
|
||||
case 'artist':
|
||||
params = { artistId: id }
|
||||
setStarred = starred => {
|
||||
set(
|
||||
produce<MusicSlice>(state => {
|
||||
state.starredArtists = reduceStarred(state.starredArtists, [{ id, starred }])
|
||||
}),
|
||||
)
|
||||
}
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setStarred(!unstar)
|
||||
if (unstar) {
|
||||
await client.unstar(params)
|
||||
} else {
|
||||
await client.star(params)
|
||||
}
|
||||
} catch {
|
||||
setStarred(unstar)
|
||||
}
|
||||
},
|
||||
|
||||
albumIdCoverArt: {},
|
||||
albumIdCoverArtRequests: {},
|
||||
|
||||
fetchAlbumCoverArt: async id => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const inProgress = get().albumIdCoverArtRequests[id]
|
||||
if (inProgress !== undefined) {
|
||||
return await inProgress
|
||||
}
|
||||
|
||||
const promise = new Promise<void>(async resolve => {
|
||||
try {
|
||||
const response = await client.getAlbum({ id })
|
||||
set(
|
||||
produce<MusicSlice>(state => {
|
||||
state.albumIdCoverArt[id] = response.data.album.coverArt
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
resolve()
|
||||
}
|
||||
}).then(() => {
|
||||
set(
|
||||
produce<MusicSlice>(state => {
|
||||
delete state.albumIdCoverArtRequests[id]
|
||||
}),
|
||||
)
|
||||
})
|
||||
set(
|
||||
produce<MusicSlice>(state => {
|
||||
state.albumIdCoverArtRequests[id] = promise
|
||||
}),
|
||||
)
|
||||
|
||||
return await promise
|
||||
},
|
||||
|
||||
getAlbumCoverArt: async id => {
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
const existing = get().albumIdCoverArt[id]
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
await get().fetchAlbumCoverArt(id)
|
||||
return get().albumIdCoverArt[id]
|
||||
},
|
||||
})
|
||||
@@ -1,142 +0,0 @@
|
||||
import {
|
||||
AlbumListItem,
|
||||
AlbumWithSongs,
|
||||
Artist,
|
||||
ArtistInfo,
|
||||
PlaylistListItem,
|
||||
PlaylistWithSongs,
|
||||
Song,
|
||||
} from '@app/models/music'
|
||||
import {
|
||||
AlbumID3Element,
|
||||
ArtistID3Element,
|
||||
ArtistInfo2Element,
|
||||
ChildElement,
|
||||
PlaylistElement,
|
||||
PlaylistWithSongsElement,
|
||||
} from '@app/subsonic/elements'
|
||||
import { GetArtistResponse } from '@app/subsonic/responses'
|
||||
import { GetState, SetState } from 'zustand'
|
||||
import { Store } from './store'
|
||||
|
||||
export type MusicMapSlice = {
|
||||
mapChildToSong: (child: ChildElement, coverArt?: string) => Promise<Song>
|
||||
mapChildrenToSongs: (children: ChildElement[], coverArt?: string) => Promise<Song[]>
|
||||
mapArtistID3toArtist: (artist: ArtistID3Element) => Artist
|
||||
mapArtistInfo: (
|
||||
artistResponse: GetArtistResponse,
|
||||
info: ArtistInfo2Element,
|
||||
topSongs: ChildElement[],
|
||||
) => Promise<ArtistInfo>
|
||||
mapAlbumID3toAlbumListItem: (album: AlbumID3Element) => AlbumListItem
|
||||
mapAlbumID3toAlbum: (album: AlbumID3Element) => AlbumListItem
|
||||
mapAlbumID3WithSongstoAlbumWithSongs: (album: AlbumID3Element, songs: ChildElement[]) => Promise<AlbumWithSongs>
|
||||
mapPlaylistListItem: (playlist: PlaylistElement) => PlaylistListItem
|
||||
mapPlaylistWithSongs: (playlist: PlaylistWithSongsElement) => Promise<PlaylistWithSongs>
|
||||
}
|
||||
|
||||
export const createMusicMapSlice = (set: SetState<Store>, get: GetState<Store>): MusicMapSlice => ({
|
||||
mapChildToSong: async (child, coverArt) => {
|
||||
return {
|
||||
itemType: 'song',
|
||||
id: child.id,
|
||||
album: child.album,
|
||||
albumId: child.albumId,
|
||||
artist: child.artist,
|
||||
artistId: child.artistId,
|
||||
title: child.title,
|
||||
track: child.track,
|
||||
discNumber: child.discNumber,
|
||||
duration: child.duration,
|
||||
starred: child.starred,
|
||||
coverArt: coverArt || (await get().getAlbumCoverArt(child.albumId)),
|
||||
streamUri: get().buildStreamUri(child.id),
|
||||
}
|
||||
},
|
||||
|
||||
mapChildrenToSongs: async (children, coverArt) => {
|
||||
const albumIds = children.reduce((acc, val) => {
|
||||
if (val.albumId && !(val.albumId in acc)) {
|
||||
acc[val.albumId] = get().getAlbumCoverArt(val.albumId)
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<string, Promise<string | undefined>>)
|
||||
|
||||
await Promise.all(Object.values(albumIds))
|
||||
|
||||
const songs: Song[] = []
|
||||
for (const child of children) {
|
||||
songs.push(await get().mapChildToSong(child, coverArt || (await get().getAlbumCoverArt(child.albumId))))
|
||||
}
|
||||
return songs
|
||||
},
|
||||
|
||||
mapArtistID3toArtist: artist => {
|
||||
return {
|
||||
itemType: 'artist',
|
||||
id: artist.id,
|
||||
name: artist.name,
|
||||
starred: artist.starred,
|
||||
coverArt: artist.coverArt,
|
||||
}
|
||||
},
|
||||
|
||||
mapArtistInfo: async (artistResponse, info, topSongs) => {
|
||||
const { artist, albums } = artistResponse
|
||||
|
||||
const mappedAlbums = albums.map(get().mapAlbumID3toAlbum)
|
||||
|
||||
return {
|
||||
...get().mapArtistID3toArtist(artist),
|
||||
albums: mappedAlbums,
|
||||
smallImageUrl: info.smallImageUrl,
|
||||
largeImageUrl: info.largeImageUrl,
|
||||
topSongs: (await get().mapChildrenToSongs(topSongs)).slice(0, 5),
|
||||
}
|
||||
},
|
||||
|
||||
mapAlbumID3toAlbumListItem: album => {
|
||||
return {
|
||||
itemType: 'album',
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
artist: album.artist,
|
||||
artistId: album.artistId,
|
||||
starred: album.starred,
|
||||
coverArt: album.coverArt,
|
||||
}
|
||||
},
|
||||
|
||||
mapAlbumID3toAlbum: album => {
|
||||
return {
|
||||
...get().mapAlbumID3toAlbumListItem(album),
|
||||
coverArt: album.coverArt,
|
||||
year: album.year,
|
||||
}
|
||||
},
|
||||
|
||||
mapAlbumID3WithSongstoAlbumWithSongs: async (album, songs) => {
|
||||
return {
|
||||
...get().mapAlbumID3toAlbum(album),
|
||||
songs: await get().mapChildrenToSongs(songs),
|
||||
}
|
||||
},
|
||||
|
||||
mapPlaylistListItem: playlist => {
|
||||
return {
|
||||
itemType: 'playlist',
|
||||
id: playlist.id,
|
||||
name: playlist.name,
|
||||
comment: playlist.comment,
|
||||
coverArt: playlist.coverArt,
|
||||
}
|
||||
},
|
||||
|
||||
mapPlaylistWithSongs: async playlist => {
|
||||
return {
|
||||
...get().mapPlaylistListItem(playlist),
|
||||
songs: await get().mapChildrenToSongs(playlist.songs),
|
||||
coverArt: playlist.coverArt,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,21 +1,36 @@
|
||||
import { AppSettings, ArtistFilterSettings, AlbumFilterSettings, Server } from '@app/models/settings'
|
||||
import { Store } from '@app/state/store'
|
||||
import { AlbumFilterSettings, ArtistFilterSettings, Server } from '@app/models/settings'
|
||||
import { ById } from '@app/models/state'
|
||||
import { GetStore, SetStore } from '@app/state/store'
|
||||
import { SubsonicApiClient } from '@app/subsonic/api'
|
||||
import produce from 'immer'
|
||||
import { GetState, SetState } from 'zustand'
|
||||
|
||||
export type SettingsSlice = {
|
||||
settings: AppSettings
|
||||
settings: {
|
||||
servers: ById<Server>
|
||||
activeServerId?: string
|
||||
screens: {
|
||||
home: {
|
||||
listTypes: string[]
|
||||
}
|
||||
library: {
|
||||
albumsFilter: AlbumFilterSettings
|
||||
artistsFilter: ArtistFilterSettings
|
||||
}
|
||||
}
|
||||
scrobble: boolean
|
||||
maxBitrateWifi: number
|
||||
maxBitrateMobile: number
|
||||
minBuffer: number
|
||||
maxBuffer: number
|
||||
}
|
||||
|
||||
client?: SubsonicApiClient
|
||||
|
||||
setActiveServer: (id: string | undefined, force?: boolean) => Promise<void>
|
||||
getActiveServer: () => Server | undefined
|
||||
addServer: (server: Server) => Promise<void>
|
||||
removeServer: (id: string) => Promise<void>
|
||||
updateServer: (server: Server) => void
|
||||
|
||||
setScrobble: (scrobble: boolean) => void
|
||||
setEstimateContentLength: (estimateContentLength: boolean) => void
|
||||
setMaxBitrateWifi: (maxBitrateWifi: number) => void
|
||||
setMaxBitrateMobile: (maxBitrateMobile: number) => void
|
||||
setMinBuffer: (minBuffer: number) => void
|
||||
@@ -27,66 +42,26 @@ export type SettingsSlice = {
|
||||
setLibraryArtistFiler: (filter: ArtistFilterSettings) => void
|
||||
}
|
||||
|
||||
export const selectSettings = {
|
||||
client: (state: SettingsSlice) => state.client,
|
||||
|
||||
firstRun: (state: SettingsSlice) => state.settings.servers.length === 0,
|
||||
|
||||
activeServer: (state: SettingsSlice) => state.settings.servers.find(s => s.id === state.settings.activeServer),
|
||||
setActiveServer: (state: SettingsSlice) => state.setActiveServer,
|
||||
|
||||
servers: (state: SettingsSlice) => state.settings.servers,
|
||||
addServer: (state: SettingsSlice) => state.addServer,
|
||||
removeServer: (state: SettingsSlice) => state.removeServer,
|
||||
updateServer: (state: SettingsSlice) => state.updateServer,
|
||||
|
||||
homeLists: (state: SettingsSlice) => state.settings.screens.home.lists,
|
||||
|
||||
scrobble: (state: SettingsSlice) => state.settings.scrobble,
|
||||
setScrobble: (state: SettingsSlice) => state.setScrobble,
|
||||
|
||||
estimateContentLength: (state: SettingsSlice) => state.settings.estimateContentLength,
|
||||
setEstimateContentLength: (state: SettingsSlice) => state.setEstimateContentLength,
|
||||
|
||||
maxBitrateWifi: (state: SettingsSlice) => state.settings.maxBitrateWifi,
|
||||
setMaxBitrateWifi: (state: SettingsSlice) => state.setMaxBitrateWifi,
|
||||
maxBitrateMobile: (state: SettingsSlice) => state.settings.maxBitrateMobile,
|
||||
setMaxBitrateMobile: (state: SettingsSlice) => state.setMaxBitrateMobile,
|
||||
|
||||
minBuffer: (state: SettingsSlice) => state.settings.minBuffer,
|
||||
setMinBuffer: (state: SettingsSlice) => state.setMinBuffer,
|
||||
maxBuffer: (state: SettingsSlice) => state.settings.maxBuffer,
|
||||
setMaxBuffer: (state: SettingsSlice) => state.setMaxBuffer,
|
||||
|
||||
pingServer: (state: SettingsSlice) => state.pingServer,
|
||||
|
||||
setLibraryAlbumFilter: (state: SettingsSlice) => state.setLibraryAlbumFilter,
|
||||
libraryAlbumFilter: (state: SettingsSlice) => state.settings.screens.library.albums,
|
||||
setLibraryArtistFiler: (state: SettingsSlice) => state.setLibraryArtistFiler,
|
||||
libraryArtistFilter: (state: SettingsSlice) => state.settings.screens.library.artists,
|
||||
}
|
||||
|
||||
export const createSettingsSlice = (set: SetState<Store>, get: GetState<Store>): SettingsSlice => ({
|
||||
export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice => ({
|
||||
settings: {
|
||||
servers: [],
|
||||
servers: {},
|
||||
screens: {
|
||||
home: {
|
||||
lists: ['frequent', 'recent', 'starred', 'random'],
|
||||
listTypes: ['frequent', 'recent', 'starred', 'random'],
|
||||
},
|
||||
library: {
|
||||
albums: {
|
||||
albumsFilter: {
|
||||
type: 'alphabeticalByArtist',
|
||||
fromYear: 1,
|
||||
toYear: 9999,
|
||||
genre: '',
|
||||
},
|
||||
artists: {
|
||||
artistsFilter: {
|
||||
type: 'alphabeticalByName',
|
||||
},
|
||||
},
|
||||
},
|
||||
scrobble: false,
|
||||
estimateContentLength: true,
|
||||
maxBitrateWifi: 0,
|
||||
maxBitrateMobile: 192,
|
||||
minBuffer: 6,
|
||||
@@ -95,12 +70,12 @@ export const createSettingsSlice = (set: SetState<Store>, get: GetState<Store>):
|
||||
|
||||
setActiveServer: async (id, force) => {
|
||||
const servers = get().settings.servers
|
||||
const currentActiveServerId = get().settings.activeServer
|
||||
const newActiveServer = servers.find(s => s.id === id)
|
||||
const currentActiveServerId = get().settings.activeServerId
|
||||
const newActiveServer = id ? servers[id] : undefined
|
||||
|
||||
if (!newActiveServer) {
|
||||
set({
|
||||
client: undefined,
|
||||
set(state => {
|
||||
state.client = undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -111,26 +86,21 @@ export const createSettingsSlice = (set: SetState<Store>, get: GetState<Store>):
|
||||
|
||||
get().prepareCache(newActiveServer.id)
|
||||
|
||||
set(
|
||||
produce<Store>(state => {
|
||||
state.settings.activeServer = newActiveServer.id
|
||||
state.client = new SubsonicApiClient(newActiveServer)
|
||||
}),
|
||||
)
|
||||
set(state => {
|
||||
state.settings.activeServerId = newActiveServer.id
|
||||
state.client = new SubsonicApiClient(newActiveServer)
|
||||
get().resetLibrary(state)
|
||||
})
|
||||
},
|
||||
|
||||
getActiveServer: () => get().settings.servers.find(s => s.id === get().settings.activeServer),
|
||||
|
||||
addServer: async server => {
|
||||
await get().createCache(server.id)
|
||||
|
||||
set(
|
||||
produce<SettingsSlice>(state => {
|
||||
state.settings.servers.push(server)
|
||||
}),
|
||||
)
|
||||
set(state => {
|
||||
state.settings.servers[server.id] = server
|
||||
})
|
||||
|
||||
if (get().settings.servers.length === 1) {
|
||||
if (Object.keys(get().settings.servers).length === 1) {
|
||||
get().setActiveServer(server.id)
|
||||
}
|
||||
},
|
||||
@@ -138,53 +108,31 @@ export const createSettingsSlice = (set: SetState<Store>, get: GetState<Store>):
|
||||
removeServer: async id => {
|
||||
await get().removeCache(id)
|
||||
|
||||
set(
|
||||
produce<SettingsSlice>(state => {
|
||||
state.settings.servers = state.settings.servers.filter(s => s.id !== id)
|
||||
}),
|
||||
)
|
||||
set(state => {
|
||||
delete state.settings.servers[id]
|
||||
})
|
||||
},
|
||||
|
||||
updateServer: server => {
|
||||
set(
|
||||
produce<SettingsSlice>(state => {
|
||||
state.settings.servers = replaceIndex(
|
||||
state.settings.servers,
|
||||
state.settings.servers.findIndex(s => s.id === server.id),
|
||||
server,
|
||||
)
|
||||
}),
|
||||
)
|
||||
set(state => {
|
||||
state.settings.servers[server.id] = server
|
||||
})
|
||||
|
||||
if (get().settings.activeServer === server.id) {
|
||||
if (get().settings.activeServerId === server.id) {
|
||||
get().setActiveServer(server.id, true)
|
||||
}
|
||||
},
|
||||
|
||||
setScrobble: scrobble => {
|
||||
set(
|
||||
produce<SettingsSlice>(state => {
|
||||
state.settings.scrobble = scrobble
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
setEstimateContentLength: estimateContentLength => {
|
||||
set(
|
||||
produce<SettingsSlice>(state => {
|
||||
state.settings.estimateContentLength = estimateContentLength
|
||||
}),
|
||||
)
|
||||
|
||||
get().rebuildQueue()
|
||||
set(state => {
|
||||
state.settings.scrobble = scrobble
|
||||
})
|
||||
},
|
||||
|
||||
setMaxBitrateWifi: maxBitrateWifi => {
|
||||
set(
|
||||
produce<SettingsSlice>(state => {
|
||||
state.settings.maxBitrateWifi = maxBitrateWifi
|
||||
}),
|
||||
)
|
||||
set(state => {
|
||||
state.settings.maxBitrateWifi = maxBitrateWifi
|
||||
})
|
||||
|
||||
if (get().netState === 'wifi') {
|
||||
get().rebuildQueue()
|
||||
@@ -192,11 +140,9 @@ export const createSettingsSlice = (set: SetState<Store>, get: GetState<Store>):
|
||||
},
|
||||
|
||||
setMaxBitrateMobile: maxBitrateMobile => {
|
||||
set(
|
||||
produce<SettingsSlice>(state => {
|
||||
state.settings.maxBitrateMobile = maxBitrateMobile
|
||||
}),
|
||||
)
|
||||
set(state => {
|
||||
state.settings.maxBitrateMobile = maxBitrateMobile
|
||||
})
|
||||
|
||||
if (get().netState === 'mobile') {
|
||||
get().rebuildQueue()
|
||||
@@ -208,11 +154,9 @@ export const createSettingsSlice = (set: SetState<Store>, get: GetState<Store>):
|
||||
return
|
||||
}
|
||||
|
||||
set(
|
||||
produce<SettingsSlice>(state => {
|
||||
state.settings.minBuffer = Math.max(1, Math.min(minBuffer, state.settings.maxBuffer / 2))
|
||||
}),
|
||||
)
|
||||
set(state => {
|
||||
state.settings.minBuffer = Math.max(1, Math.min(minBuffer, state.settings.maxBuffer / 2))
|
||||
})
|
||||
|
||||
get().rebuildQueue()
|
||||
},
|
||||
@@ -222,11 +166,9 @@ export const createSettingsSlice = (set: SetState<Store>, get: GetState<Store>):
|
||||
return
|
||||
}
|
||||
|
||||
set(
|
||||
produce<SettingsSlice>(state => {
|
||||
state.settings.maxBuffer = Math.min(5 * 60, Math.max(maxBuffer, state.settings.minBuffer * 2))
|
||||
}),
|
||||
)
|
||||
set(state => {
|
||||
state.settings.maxBuffer = Math.min(5 * 60, Math.max(maxBuffer, state.settings.minBuffer * 2))
|
||||
})
|
||||
|
||||
get().rebuildQueue()
|
||||
},
|
||||
@@ -252,24 +194,14 @@ export const createSettingsSlice = (set: SetState<Store>, get: GetState<Store>):
|
||||
},
|
||||
|
||||
setLibraryAlbumFilter: filter => {
|
||||
set(
|
||||
produce<SettingsSlice>(state => {
|
||||
state.settings.screens.library.albums = filter
|
||||
}),
|
||||
)
|
||||
set(state => {
|
||||
state.settings.screens.library.albumsFilter = filter
|
||||
})
|
||||
},
|
||||
|
||||
setLibraryArtistFiler: filter => {
|
||||
set(
|
||||
produce<SettingsSlice>(state => {
|
||||
state.settings.screens.library.artists = filter
|
||||
}),
|
||||
)
|
||||
set(state => {
|
||||
state.settings.screens.library.artistsFilter = filter
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function replaceIndex<T>(array: T[], index: number, replacement: T): T[] {
|
||||
const start = array.slice(0, index)
|
||||
const end = array.slice(index + 1)
|
||||
return [...start, replacement, ...end]
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { createMusicSlice, MusicSlice } from '@app/state/music'
|
||||
import { createSettingsSlice, SettingsSlice } from '@app/state/settings'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import create from 'zustand'
|
||||
import { persist, StateStorage } from 'zustand/middleware'
|
||||
import equal from 'fast-deep-equal'
|
||||
import create, { GetState, Mutate, SetState, State, StateCreator, StateSelector, StoreApi } from 'zustand'
|
||||
import { persist, subscribeWithSelector } from 'zustand/middleware'
|
||||
import { CacheSlice, createCacheSlice } from './cache'
|
||||
import { createLibrarySlice, LibrarySlice } from './library'
|
||||
import migrations from './migrations'
|
||||
import { createMusicMapSlice, MusicMapSlice } from './musicmap'
|
||||
import { createTrackPlayerSlice, TrackPlayerSlice } from './trackplayer'
|
||||
import { createTrackPlayerMapSlice, TrackPlayerMapSlice } from './trackplayermap'
|
||||
import produce, { Draft } from 'immer'
|
||||
import { WritableDraft } from 'immer/dist/internal'
|
||||
|
||||
const DB_VERSION = migrations.length
|
||||
|
||||
export type Store = SettingsSlice &
|
||||
MusicSlice &
|
||||
MusicMapSlice &
|
||||
LibrarySlice &
|
||||
TrackPlayerSlice &
|
||||
TrackPlayerMapSlice &
|
||||
CacheSlice & {
|
||||
@@ -21,59 +22,83 @@ export type Store = SettingsSlice &
|
||||
setHydrated: (hydrated: boolean) => void
|
||||
}
|
||||
|
||||
const storage: StateStorage = {
|
||||
getItem: async name => {
|
||||
try {
|
||||
return await AsyncStorage.getItem(name)
|
||||
} catch (err) {
|
||||
console.error(`getItem error (key: ${name})`, err)
|
||||
return null
|
||||
}
|
||||
},
|
||||
setItem: async (name, item) => {
|
||||
try {
|
||||
await AsyncStorage.setItem(name, item)
|
||||
} catch (err) {
|
||||
console.error(`setItem error (key: ${name})`, err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const useStore = create<Store>(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...createSettingsSlice(set, get),
|
||||
...createMusicSlice(set, get),
|
||||
...createMusicMapSlice(set, get),
|
||||
...createTrackPlayerSlice(set, get),
|
||||
...createTrackPlayerMapSlice(set, get),
|
||||
...createCacheSlice(set, get),
|
||||
|
||||
hydrated: false,
|
||||
setHydrated: hydrated => set({ hydrated }),
|
||||
}),
|
||||
{
|
||||
name: '@appStore',
|
||||
version: DB_VERSION,
|
||||
getStorage: () => storage,
|
||||
whitelist: ['settings', 'cacheFiles'],
|
||||
onRehydrateStorage: _preState => {
|
||||
return async (postState, _error) => {
|
||||
await postState?.setActiveServer(postState.settings.activeServer, true)
|
||||
postState?.setHydrated(true)
|
||||
}
|
||||
// taken from zustand test examples:
|
||||
// https://github.com/pmndrs/zustand/blob/v3.7.1/tests/middlewareTypes.test.tsx#L20
|
||||
const immer =
|
||||
<
|
||||
T extends State,
|
||||
CustomSetState extends SetState<T>,
|
||||
CustomGetState extends GetState<T>,
|
||||
CustomStoreApi extends StoreApi<T>,
|
||||
>(
|
||||
config: StateCreator<
|
||||
T,
|
||||
(partial: ((draft: Draft<T>) => void) | T, replace?: boolean) => void,
|
||||
CustomGetState,
|
||||
CustomStoreApi
|
||||
>,
|
||||
): StateCreator<T, CustomSetState, CustomGetState, CustomStoreApi> =>
|
||||
(set, get, api) =>
|
||||
config(
|
||||
(partial, replace) => {
|
||||
const nextState = typeof partial === 'function' ? produce(partial as (state: Draft<T>) => T) : (partial as T)
|
||||
return set(nextState, replace)
|
||||
},
|
||||
migrate: (persistedState, version) => {
|
||||
if (version > DB_VERSION) {
|
||||
throw new Error('cannot migrate db on a downgrade, delete all data first')
|
||||
}
|
||||
get,
|
||||
api,
|
||||
)
|
||||
|
||||
for (let i = version; i < DB_VERSION; i++) {
|
||||
persistedState = migrations[i](persistedState)
|
||||
}
|
||||
export type SetStore = (partial: Store | ((draft: WritableDraft<Store>) => void), replace?: boolean | undefined) => void
|
||||
export type GetStore = GetState<Store>
|
||||
|
||||
return persistedState
|
||||
// types taken from zustand test examples:
|
||||
// https://github.com/pmndrs/zustand/blob/v3.7.1/tests/middlewareTypes.test.tsx#L584
|
||||
export const useStore = create<
|
||||
Store,
|
||||
SetState<Store>,
|
||||
GetState<Store>,
|
||||
Mutate<StoreApi<Store>, [['zustand/subscribeWithSelector', never], ['zustand/persist', Partial<Store>]]>
|
||||
>(
|
||||
subscribeWithSelector(
|
||||
persist(
|
||||
immer((set, get) => ({
|
||||
...createSettingsSlice(set, get),
|
||||
...createLibrarySlice(set, get),
|
||||
...createTrackPlayerSlice(set, get),
|
||||
...createTrackPlayerMapSlice(set, get),
|
||||
...createCacheSlice(set, get),
|
||||
|
||||
hydrated: false,
|
||||
setHydrated: hydrated =>
|
||||
set(state => {
|
||||
state.hydrated = hydrated
|
||||
}),
|
||||
})),
|
||||
{
|
||||
name: '@appStore',
|
||||
version: DB_VERSION,
|
||||
getStorage: () => AsyncStorage,
|
||||
partialize: state => ({ settings: state.settings, cacheFiles: state.cacheFiles }),
|
||||
onRehydrateStorage: _preState => {
|
||||
return async (postState, _error) => {
|
||||
await postState?.setActiveServer(postState.settings.activeServerId, true)
|
||||
postState?.setHydrated(true)
|
||||
}
|
||||
},
|
||||
migrate: (persistedState, version) => {
|
||||
if (version > DB_VERSION) {
|
||||
throw new Error('cannot migrate db on a downgrade, delete all data first')
|
||||
}
|
||||
|
||||
for (let i = version; i < DB_VERSION; i++) {
|
||||
persistedState = migrations[i](persistedState)
|
||||
}
|
||||
|
||||
return persistedState
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
export const useStoreDeep = <U>(stateSelector: StateSelector<Store, U>) => useStore(stateSelector, equal)
|
||||
|
||||
@@ -1,27 +1,10 @@
|
||||
import { NoClientError } from '@app/models/error'
|
||||
import { Song } from '@app/models/music'
|
||||
import { Song } from '@app/models/library'
|
||||
import { Progress, QueueContextType, TrackExt } from '@app/models/trackplayer'
|
||||
import PromiseQueue from '@app/util/PromiseQueue'
|
||||
import produce from 'immer'
|
||||
import TrackPlayer, { PlayerOptions, RepeatMode, State, Track } from 'react-native-track-player'
|
||||
import { GetState, SetState } from 'zustand'
|
||||
import { Store } from './store'
|
||||
|
||||
export type TrackExt = Track & {
|
||||
id: string
|
||||
coverArt?: string
|
||||
artistId?: string
|
||||
albumId?: string
|
||||
track?: number
|
||||
discNumber?: number
|
||||
}
|
||||
|
||||
export type Progress = {
|
||||
position: number
|
||||
duration: number
|
||||
buffered: number
|
||||
}
|
||||
|
||||
export type QueueContextType = 'album' | 'playlist' | 'song' | 'artist'
|
||||
import TrackPlayer, { PlayerOptions, RepeatMode, State } from 'react-native-track-player'
|
||||
import { GetStore, SetStore } from './store'
|
||||
|
||||
export type TrackPlayerSlice = {
|
||||
queueName?: string
|
||||
@@ -74,55 +57,26 @@ export type TrackPlayerSlice = {
|
||||
getPlayerOptions: () => PlayerOptions
|
||||
}
|
||||
|
||||
export const selectTrackPlayer = {
|
||||
queueName: (store: TrackPlayerSlice) => store.queueName,
|
||||
setQueueName: (store: TrackPlayerSlice) => store.setQueueName,
|
||||
|
||||
queueContextType: (store: TrackPlayerSlice) => store.queueContextType,
|
||||
setQueueContextType: (store: TrackPlayerSlice) => store.setQueueContextType,
|
||||
|
||||
queueContextId: (store: TrackPlayerSlice) => store.queueContextId,
|
||||
setQueueContextId: (store: TrackPlayerSlice) => store.setQueueContextId,
|
||||
|
||||
shuffleOrder: (store: TrackPlayerSlice) => store.shuffleOrder,
|
||||
shuffled: (store: TrackPlayerSlice) => !!store.shuffleOrder,
|
||||
toggleShuffle: (store: TrackPlayerSlice) => store.toggleShuffle,
|
||||
|
||||
repeatMode: (store: TrackPlayerSlice) => store.repeatMode,
|
||||
toggleRepeatMode: (store: TrackPlayerSlice) => store.toggleRepeatMode,
|
||||
|
||||
playerState: (store: TrackPlayerSlice) => store.playerState,
|
||||
setPlayerState: (store: TrackPlayerSlice) => store.setPlayerState,
|
||||
|
||||
currentTrack: (store: TrackPlayerSlice) => store.currentTrack,
|
||||
currentTrackIdx: (store: TrackPlayerSlice) => store.currentTrackIdx,
|
||||
setCurrentTrackIdx: (store: TrackPlayerSlice) => store.setCurrentTrackIdx,
|
||||
|
||||
queue: (store: TrackPlayerSlice) => store.queue,
|
||||
setQueue: (store: TrackPlayerSlice) => store.setQueue,
|
||||
|
||||
progress: (store: TrackPlayerSlice) => store.progress,
|
||||
setProgress: (store: TrackPlayerSlice) => store.setProgress,
|
||||
|
||||
scrobbleTrack: (store: TrackPlayerSlice) => store.scrobbleTrack,
|
||||
|
||||
setNetState: (store: TrackPlayerSlice) => store.setNetState,
|
||||
buildStreamUri: (store: TrackPlayerSlice) => store.buildStreamUri,
|
||||
|
||||
resetTrackPlayerState: (store: TrackPlayerSlice) => store.resetTrackPlayerState,
|
||||
}
|
||||
|
||||
export const trackPlayerCommands = new PromiseQueue(1)
|
||||
|
||||
export const createTrackPlayerSlice = (set: SetState<Store>, get: GetState<Store>): TrackPlayerSlice => ({
|
||||
export const createTrackPlayerSlice = (set: SetStore, get: GetStore): TrackPlayerSlice => ({
|
||||
queueName: undefined,
|
||||
setQueueName: name => set({ queueName: name }),
|
||||
setQueueName: name =>
|
||||
set(state => {
|
||||
state.queueName = name
|
||||
}),
|
||||
|
||||
queueContextType: undefined,
|
||||
setQueueContextType: queueContextType => set({ queueContextType }),
|
||||
setQueueContextType: queueContextType =>
|
||||
set(state => {
|
||||
state.queueContextType = queueContextType
|
||||
}),
|
||||
|
||||
queueContextId: undefined,
|
||||
setQueueContextId: queueContextId => set({ queueContextId }),
|
||||
setQueueContextId: queueContextId =>
|
||||
set(state => {
|
||||
state.queueContextId = queueContextId
|
||||
}),
|
||||
|
||||
shuffleOrder: undefined,
|
||||
toggleShuffle: async () => {
|
||||
@@ -140,7 +94,9 @@ export const createTrackPlayerSlice = (set: SetState<Store>, get: GetState<Store
|
||||
}
|
||||
|
||||
await TrackPlayer.add(tracks)
|
||||
set({ shuffleOrder })
|
||||
set(state => {
|
||||
state.shuffleOrder = shuffleOrder
|
||||
})
|
||||
} else {
|
||||
const tracks = unshuffleTracks(queue, queueShuffleOrder)
|
||||
|
||||
@@ -155,11 +111,18 @@ export const createTrackPlayerSlice = (set: SetState<Store>, get: GetState<Store
|
||||
await TrackPlayer.add(tracks)
|
||||
}
|
||||
|
||||
set({ shuffleOrder: undefined })
|
||||
set(state => {
|
||||
state.shuffleOrder = undefined
|
||||
})
|
||||
}
|
||||
|
||||
set({ queue: await getQueue() })
|
||||
get().setCurrentTrackIdx(await getCurrentTrack())
|
||||
const newQueue = await getQueue()
|
||||
const newCurrentTrackIdx = await getCurrentTrack()
|
||||
|
||||
set(state => {
|
||||
state.queue = newQueue
|
||||
})
|
||||
get().setCurrentTrackIdx(newCurrentTrackIdx)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -182,12 +145,17 @@ export const createTrackPlayerSlice = (set: SetState<Store>, get: GetState<Store
|
||||
}
|
||||
|
||||
await TrackPlayer.setRepeatMode(nextMode)
|
||||
set({ repeatMode: nextMode })
|
||||
set(state => {
|
||||
state.repeatMode = nextMode
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
playerState: State.None,
|
||||
setPlayerState: playerState => set({ playerState }),
|
||||
setPlayerState: playerState =>
|
||||
set(state => {
|
||||
state.playerState = playerState
|
||||
}),
|
||||
|
||||
currentTrack: undefined,
|
||||
currentTrackIdx: undefined,
|
||||
@@ -201,7 +169,10 @@ export const createTrackPlayerSlice = (set: SetState<Store>, get: GetState<Store
|
||||
},
|
||||
|
||||
duckPaused: false,
|
||||
setDuckPaused: duckPaused => set({ duckPaused }),
|
||||
setDuckPaused: duckPaused =>
|
||||
set(state => {
|
||||
state.duckPaused = duckPaused
|
||||
}),
|
||||
|
||||
queue: [],
|
||||
setQueue: async (songs, name, contextType, contextId, playTrack, shuffle) => {
|
||||
@@ -217,31 +188,27 @@ export const createTrackPlayerSlice = (set: SetState<Store>, get: GetState<Store
|
||||
|
||||
let queue = await get().mapSongstoTrackExts(songs)
|
||||
|
||||
try {
|
||||
for (const t of queue) {
|
||||
t.url = get().buildStreamUri(t.id)
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (shuffled) {
|
||||
const { tracks, shuffleOrder } = shuffleTracks(queue, playTrack)
|
||||
set({ shuffleOrder })
|
||||
set(state => {
|
||||
state.shuffleOrder = shuffleOrder
|
||||
})
|
||||
queue = tracks
|
||||
playTrack = 0
|
||||
} else {
|
||||
set({ shuffleOrder: undefined })
|
||||
set(state => {
|
||||
state.shuffleOrder = undefined
|
||||
})
|
||||
}
|
||||
|
||||
playTrack = playTrack || 0
|
||||
|
||||
try {
|
||||
set({
|
||||
queue,
|
||||
queueName: name,
|
||||
queueContextType: contextType,
|
||||
queueContextId: contextId,
|
||||
set(state => {
|
||||
state.queue = queue
|
||||
state.queueName = name
|
||||
state.queueContextType = contextType
|
||||
state.queueContextId = contextId
|
||||
})
|
||||
get().setCurrentTrackIdx(playTrack)
|
||||
|
||||
@@ -264,7 +231,10 @@ export const createTrackPlayerSlice = (set: SetState<Store>, get: GetState<Store
|
||||
},
|
||||
|
||||
progress: { position: 0, duration: 0, buffered: 0 },
|
||||
setProgress: progress => set({ progress }),
|
||||
setProgress: progress =>
|
||||
set(state => {
|
||||
state.progress = progress
|
||||
}),
|
||||
|
||||
scrobbleTrack: async id => {
|
||||
const client = get().client
|
||||
@@ -286,7 +256,9 @@ export const createTrackPlayerSlice = (set: SetState<Store>, get: GetState<Store
|
||||
if (netState === get().netState) {
|
||||
return
|
||||
}
|
||||
set({ netState })
|
||||
set(state => {
|
||||
state.netState = netState
|
||||
})
|
||||
get().rebuildQueue()
|
||||
},
|
||||
|
||||
@@ -298,7 +270,7 @@ export const createTrackPlayerSlice = (set: SetState<Store>, get: GetState<Store
|
||||
}
|
||||
|
||||
const currentTrack = await getCurrentTrack()
|
||||
const state = await getPlayerState()
|
||||
const playerState = await getPlayerState()
|
||||
const position = (await TrackPlayer.getPosition()) || 0
|
||||
|
||||
const queueName = get().queueName
|
||||
@@ -316,11 +288,11 @@ export const createTrackPlayerSlice = (set: SetState<Store>, get: GetState<Store
|
||||
return
|
||||
}
|
||||
|
||||
set({
|
||||
queue,
|
||||
queueName,
|
||||
queueContextId,
|
||||
queueContextType,
|
||||
set(state => {
|
||||
state.queue = queue
|
||||
state.queueName = queueName
|
||||
state.queueContextType = queueContextType
|
||||
state.queueContextId = queueContextId
|
||||
})
|
||||
get().setCurrentTrackIdx(currentTrack)
|
||||
|
||||
@@ -332,7 +304,7 @@ export const createTrackPlayerSlice = (set: SetState<Store>, get: GetState<Store
|
||||
|
||||
await TrackPlayer.seekTo(position)
|
||||
|
||||
if (state === State.Playing || forcePlay) {
|
||||
if (playerState === State.Playing || forcePlay) {
|
||||
await TrackPlayer.play()
|
||||
}
|
||||
})
|
||||
@@ -346,23 +318,23 @@ export const createTrackPlayerSlice = (set: SetState<Store>, get: GetState<Store
|
||||
|
||||
return client.streamUri({
|
||||
id,
|
||||
estimateContentLength: get().settings.estimateContentLength,
|
||||
estimateContentLength: true,
|
||||
maxBitRate: get().netState === 'mobile' ? get().settings.maxBitrateMobile : get().settings.maxBitrateWifi,
|
||||
})
|
||||
},
|
||||
|
||||
resetTrackPlayerState: () => {
|
||||
set({
|
||||
queueName: undefined,
|
||||
queueContextType: undefined,
|
||||
queueContextId: undefined,
|
||||
shuffleOrder: undefined,
|
||||
repeatMode: RepeatMode.Off,
|
||||
playerState: State.None,
|
||||
currentTrack: undefined,
|
||||
currentTrackIdx: undefined,
|
||||
queue: [],
|
||||
progress: { position: 0, duration: 0, buffered: 0 },
|
||||
set(state => {
|
||||
state.queueName = undefined
|
||||
state.queueContextType = undefined
|
||||
state.queueContextId = undefined
|
||||
state.shuffleOrder = undefined
|
||||
state.repeatMode = RepeatMode.Off
|
||||
state.playerState = State.None
|
||||
state.currentTrack = undefined
|
||||
state.currentTrackIdx = undefined
|
||||
state.queue = []
|
||||
state.progress = { position: 0, duration: 0, buffered: 0 }
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Song } from '@app/models/music'
|
||||
import { Song } from '@app/models/library'
|
||||
import { TrackExt } from '@app/models/trackplayer'
|
||||
import userAgent from '@app/util/userAgent'
|
||||
import { GetState, SetState } from 'zustand'
|
||||
import { Store } from './store'
|
||||
import { TrackExt } from './trackplayer'
|
||||
import { GetStore, SetStore } from '@app/state/store'
|
||||
|
||||
export type TrackPlayerMapSlice = {
|
||||
mapSongtoTrackExt: (song: Song) => Promise<TrackExt>
|
||||
@@ -10,11 +9,7 @@ export type TrackPlayerMapSlice = {
|
||||
mapTrackExtToSong: (song: TrackExt) => Song
|
||||
}
|
||||
|
||||
export const selectTrackPlayerMap = {
|
||||
mapTrackExtToSong: (store: TrackPlayerMapSlice) => store.mapTrackExtToSong,
|
||||
}
|
||||
|
||||
export const createTrackPlayerMapSlice = (set: SetState<Store>, get: GetState<Store>): TrackPlayerMapSlice => ({
|
||||
export const createTrackPlayerMapSlice = (set: SetStore, get: GetStore): TrackPlayerMapSlice => ({
|
||||
mapSongtoTrackExt: async song => {
|
||||
let artwork = require('@res/fallback.png')
|
||||
if (song.coverArt) {
|
||||
@@ -29,7 +24,7 @@ export const createTrackPlayerMapSlice = (set: SetState<Store>, get: GetState<St
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user