mirror of
https://github.com/austinried/subtracks.git
synced 2026-02-10 15:02:42 +01:00
React Query refactor (#91)
* initial react-query experiments * use queries for item screens send the data we do have over routing to prepopulate (album/playlist) use number for starred because sending Date freaks out react-navigation * add in equiv. song cover art fix * reorg, switch artistview over start mapping song cover art when any are available * refactor useStar to queries fix caching for starred items and album cover art * add hook to reset queries on server change * refactor search to use query * fix song cover art setting * use query for artistInfo * remove last bits of library state * cleanup * use query key factory already fixed one wrong key... * require coverart size * let's try no promise queues on these for now * image cache uses query * perf fix for playlist parsing also use placeholder data so we don't have to deal with staleness * drill that disabled also list controls doesn't need its own songs hook/copy * switch to react-native-blob-util for downloads slightly slower but allows us to use DownloadManager, which backgrounds downloads so they are no longer corrupted when the app suspends * add a fake "top songs" based on artist search then sorted by play count/ratings artistview should load now even if topSongs fails * try not to swap between topSongs/search on refetch set queueContext by song list so the index isn't off if the list changes * add content type validation for file fetching also try to speed up existing file return by limiting fs ops * if the HEAD fails, don't queue the download * clean up params * reimpl clear image cache * precompute contextId prevents wrong "is playing" when any mismatch between queue and list * clear images from all servers use external files dir instead of cache * fix pressable disabled flicker don't retry topsongs on failure try to optimize setqueue and fixcoverart a bit * wait for queries during clear * break out fetchExistingFile from fetchFile allows to tell if file is coming from disk or not only show placeholder/loading spinner if actually fetching image * forgot these wouldn't do anything with objects * remove query cache when switching servers * add content-disposition extension gathering add support for progress hook (needs native support still) * added custom RNBU pkg with progress changes * fully unmount tabs when server changes prevents unwanted requests, gives fresh start on switch fix fixCoverArt not re-rendering in certain cases on search * use serverId from fetch deps * fix lint * update licenses * just use the whole lodash package * make using cache buster optional
This commit is contained in:
35
app/App.tsx
35
app/App.tsx
@@ -6,6 +6,8 @@ import { StatusBar, View, StyleSheet } from 'react-native'
|
||||
import ProgressHook from './components/ProgressHook'
|
||||
import { useStore } from './state/store'
|
||||
import { MenuProvider } from 'react-native-popup-menu'
|
||||
import { QueryClientProvider } from 'react-query'
|
||||
import queryClient from './queryClient'
|
||||
|
||||
const Debug = () => {
|
||||
const currentTrackTitle = useStore(store => store.currentTrack?.title)
|
||||
@@ -13,18 +15,27 @@ const Debug = () => {
|
||||
return <></>
|
||||
}
|
||||
|
||||
const App = () => (
|
||||
<MenuProvider backHandler={true}>
|
||||
<View style={styles.appContainer}>
|
||||
<StatusBar animated={true} backgroundColor={'rgba(0, 0, 0, 0.3)'} barStyle={'light-content'} translucent={true} />
|
||||
<SplashPage>
|
||||
<ProgressHook />
|
||||
<Debug />
|
||||
<RootNavigator />
|
||||
</SplashPage>
|
||||
</View>
|
||||
</MenuProvider>
|
||||
)
|
||||
const App = () => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MenuProvider backHandler={true}>
|
||||
<View style={styles.appContainer}>
|
||||
<StatusBar
|
||||
animated={true}
|
||||
backgroundColor={'rgba(0, 0, 0, 0.3)'}
|
||||
barStyle={'light-content'}
|
||||
translucent={true}
|
||||
/>
|
||||
<SplashPage>
|
||||
<ProgressHook />
|
||||
<Debug />
|
||||
<RootNavigator />
|
||||
</SplashPage>
|
||||
</View>
|
||||
</MenuProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
appContainer: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PressableOpacity from '@app/components/PressableOpacity'
|
||||
import { useStar } from '@app/hooks/library'
|
||||
import { useStar } from '@app/hooks/query'
|
||||
import { StarrableItemType, Song, Artist, Album } from '@app/models/library'
|
||||
import colors from '@app/styles/colors'
|
||||
import font from '@app/styles/font'
|
||||
@@ -22,6 +22,7 @@ type ContextMenuProps = {
|
||||
triggerTouchableStyle?: StyleProp<ViewStyle>
|
||||
onPress?: () => any
|
||||
triggerOnLongPress?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type InternalContextMenuProps = ContextMenuProps & {
|
||||
@@ -39,6 +40,7 @@ const ContextMenu: React.FC<InternalContextMenuProps> = ({
|
||||
menuOptions,
|
||||
children,
|
||||
triggerOnLongPress,
|
||||
disabled,
|
||||
}) => {
|
||||
menuStyle = menuStyle || { flex: 1 }
|
||||
triggerWrapperStyle = triggerWrapperStyle || { flex: 1 }
|
||||
@@ -47,11 +49,12 @@ const ContextMenu: React.FC<InternalContextMenuProps> = ({
|
||||
return (
|
||||
<Menu renderer={SlideInMenu} style={menuStyle}>
|
||||
<MenuTrigger
|
||||
disabled={disabled}
|
||||
triggerOnLongPress={triggerOnLongPress === undefined ? true : triggerOnLongPress}
|
||||
customStyles={{
|
||||
triggerOuterWrapper: triggerOuterWrapperStyle,
|
||||
triggerWrapper: triggerWrapperStyle,
|
||||
triggerTouchable: { style: triggerTouchableStyle },
|
||||
triggerTouchable: { style: triggerTouchableStyle, disabled },
|
||||
TriggerTouchableComponent: PressableOpacity,
|
||||
}}
|
||||
onAlternativeAction={onPress}>
|
||||
@@ -117,9 +120,24 @@ const MenuHeader = React.memo<{
|
||||
}>(({ coverArt, artistId, title, subtitle }) => (
|
||||
<View style={styles.menuHeader}>
|
||||
{artistId ? (
|
||||
<CoverArt type="artist" artistId={artistId} style={styles.coverArt} resizeMode={'cover'} round={true} />
|
||||
<CoverArt
|
||||
type="artist"
|
||||
artistId={artistId}
|
||||
style={styles.coverArt}
|
||||
resizeMode="cover"
|
||||
round={true}
|
||||
size="thumbnail"
|
||||
fadeDuration={0}
|
||||
/>
|
||||
) : (
|
||||
<CoverArt type="cover" coverArt={coverArt} style={styles.coverArt} resizeMode={'cover'} />
|
||||
<CoverArt
|
||||
type="cover"
|
||||
coverArt={coverArt}
|
||||
style={styles.coverArt}
|
||||
resizeMode="cover"
|
||||
size="thumbnail"
|
||||
fadeDuration={0}
|
||||
/>
|
||||
)}
|
||||
<View style={styles.menuHeaderText}>
|
||||
<Text numberOfLines={1} style={styles.menuTitle}>
|
||||
@@ -141,13 +159,13 @@ const OptionStar = React.memo<{
|
||||
type: StarrableItemType
|
||||
additionalText?: string
|
||||
}>(({ id, type, additionalText: text }) => {
|
||||
const { starred, toggleStar } = useStar(id, type)
|
||||
const { query, toggle } = useStar(id, type)
|
||||
|
||||
return (
|
||||
<ContextMenuIconTextOption
|
||||
IconComponentRaw={<Star starred={starred} size={26} />}
|
||||
text={(starred ? 'Unstar' : 'Star') + (text ? ` ${text}` : '')}
|
||||
onSelect={toggleStar}
|
||||
IconComponentRaw={<Star starred={!!query.data} size={26} />}
|
||||
text={(query.data ? 'Unstar' : 'Star') + (text ? ` ${text}` : '')}
|
||||
onSelect={() => toggle.mutate()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useArtistArtFile, useCoverArtFile } from '@app/hooks/cache'
|
||||
import { CacheFile, CacheImageSize, CacheRequest } from '@app/models/cache'
|
||||
import { useQueryArtistArtPath, useQueryCoverArtPath } from '@app/hooks/query'
|
||||
import { CacheImageSize } from '@app/models/cache'
|
||||
import colors from '@app/styles/colors'
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
@@ -18,7 +18,8 @@ type BaseProps = {
|
||||
imageStyle?: ImageStyle
|
||||
resizeMode?: ImageResizeMode
|
||||
round?: boolean
|
||||
size?: CacheImageSize
|
||||
size: CacheImageSize
|
||||
fadeDuration?: number
|
||||
}
|
||||
|
||||
type ArtistCoverArtProps = BaseProps & {
|
||||
@@ -31,47 +32,54 @@ type CoverArtProps = BaseProps & {
|
||||
coverArt?: string
|
||||
}
|
||||
|
||||
const ImageSource = React.memo<{ cache?: { file?: CacheFile; request?: CacheRequest } } & BaseProps>(
|
||||
({ cache, style, imageStyle, resizeMode }) => {
|
||||
type ImageSourceProps = BaseProps & {
|
||||
data?: string
|
||||
isFetching: boolean
|
||||
isExistingFetching: boolean
|
||||
}
|
||||
|
||||
const ImageSource = React.memo<ImageSourceProps>(
|
||||
({ style, imageStyle, resizeMode, data, isFetching, isExistingFetching, fadeDuration }) => {
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
let source: ImageSourcePropType
|
||||
if (!error && cache?.file && !cache?.request?.promise) {
|
||||
source = { uri: `file://${cache.file.path}`, cache: 'reload' }
|
||||
if (!error && data) {
|
||||
source = { uri: `file://${data}` }
|
||||
} else {
|
||||
source = require('@res/fallback.png')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Image
|
||||
source={source}
|
||||
fadeDuration={150}
|
||||
resizeMode={resizeMode || 'contain'}
|
||||
style={[{ height: style?.height, width: style?.width }, imageStyle]}
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
<ActivityIndicator
|
||||
animating={!!cache?.request?.promise}
|
||||
size="large"
|
||||
color={colors.accent}
|
||||
style={styles.indicator}
|
||||
/>
|
||||
{isExistingFetching ? (
|
||||
<View style={{ height: style?.height, width: style?.width }} />
|
||||
) : (
|
||||
<Image
|
||||
source={source}
|
||||
fadeDuration={fadeDuration === undefined ? 250 : fadeDuration}
|
||||
resizeMode={resizeMode || 'contain'}
|
||||
style={[{ height: style?.height, width: style?.width }, imageStyle]}
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
)}
|
||||
{isFetching && (
|
||||
<ActivityIndicator animating={true} size="large" color={colors.accent} style={styles.indicator} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const ArtistImage = React.memo<ArtistCoverArtProps>(props => {
|
||||
const cache = useArtistArtFile(props.artistId, props.size)
|
||||
const { data, isFetching, isExistingFetching } = useQueryArtistArtPath(props.artistId, props.size)
|
||||
|
||||
return <ImageSource cache={cache} {...props} imageStyle={{ ...styles.artistImage, ...props.imageStyle }} />
|
||||
return <ImageSource data={data} isFetching={isFetching} isExistingFetching={isExistingFetching} {...props} />
|
||||
})
|
||||
|
||||
const CoverArtImage = React.memo<CoverArtProps>(props => {
|
||||
const cache = useCoverArtFile(props.coverArt, props.size)
|
||||
const { data, isFetching, isExistingFetching } = useQueryCoverArtPath(props.coverArt, props.size)
|
||||
|
||||
return <ImageSource cache={cache} {...props} />
|
||||
return <ImageSource data={data} isFetching={isFetching} isExistingFetching={isExistingFetching} {...props} />
|
||||
})
|
||||
|
||||
const CoverArt = React.memo<CoverArtProps | ArtistCoverArtProps>(props => {
|
||||
|
||||
@@ -54,7 +54,8 @@ const ListItem: React.FC<{
|
||||
listStyle?: 'big' | 'small'
|
||||
subtitle?: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
}> = ({ item, contextId, queueId, onPress, showArt, showStar, subtitle, listStyle, style }) => {
|
||||
disabled?: boolean
|
||||
}> = ({ item, contextId, queueId, onPress, showArt, showStar, subtitle, listStyle, style, disabled }) => {
|
||||
const navigation = useNavigation()
|
||||
|
||||
showStar = showStar === undefined ? true : showStar
|
||||
@@ -65,13 +66,13 @@ const ListItem: React.FC<{
|
||||
if (!onPress) {
|
||||
switch (item.itemType) {
|
||||
case 'album':
|
||||
onPress = () => navigation.navigate('album', { id: item.id, title: item.name })
|
||||
onPress = () => navigation.navigate('album', { id: item.id, title: item.name, album: item })
|
||||
break
|
||||
case 'artist':
|
||||
onPress = () => navigation.navigate('artist', { id: item.id, title: item.name })
|
||||
break
|
||||
case 'playlist':
|
||||
onPress = () => navigation.navigate('playlist', { id: item.id, title: item.name })
|
||||
onPress = () => navigation.navigate('playlist', { id: item.id, title: item.name, playlist: item })
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -90,35 +91,43 @@ const ListItem: React.FC<{
|
||||
|
||||
const itemPressable = useCallback(
|
||||
({ children }) => (
|
||||
<PressableOpacity onPress={onPress} style={styles.item}>
|
||||
<PressableOpacity onPress={onPress} style={styles.item} disabled={disabled}>
|
||||
{children}
|
||||
</PressableOpacity>
|
||||
),
|
||||
[onPress],
|
||||
[disabled, onPress],
|
||||
)
|
||||
const albumPressable = useCallback(
|
||||
({ children }) => (
|
||||
<AlbumContextPressable album={item as Album} onPress={onPress} triggerWrapperStyle={styles.item}>
|
||||
<AlbumContextPressable
|
||||
album={item as Album}
|
||||
onPress={onPress}
|
||||
triggerWrapperStyle={styles.item}
|
||||
disabled={disabled}>
|
||||
{children}
|
||||
</AlbumContextPressable>
|
||||
),
|
||||
[item, onPress],
|
||||
[disabled, item, onPress],
|
||||
)
|
||||
const songPressable = useCallback(
|
||||
({ children }) => (
|
||||
<SongContextPressable song={item as Song} onPress={onPress} triggerWrapperStyle={styles.item}>
|
||||
<SongContextPressable song={item as Song} onPress={onPress} triggerWrapperStyle={styles.item} disabled={disabled}>
|
||||
{children}
|
||||
</SongContextPressable>
|
||||
),
|
||||
[item, onPress],
|
||||
[disabled, item, onPress],
|
||||
)
|
||||
const artistPressable = useCallback(
|
||||
({ children }) => (
|
||||
<ArtistContextPressable artist={item as Artist} onPress={onPress} triggerWrapperStyle={styles.item}>
|
||||
<ArtistContextPressable
|
||||
artist={item as Artist}
|
||||
onPress={onPress}
|
||||
triggerWrapperStyle={styles.item}
|
||||
disabled={disabled}>
|
||||
{children}
|
||||
</ArtistContextPressable>
|
||||
),
|
||||
[item, onPress],
|
||||
[disabled, item, onPress],
|
||||
)
|
||||
|
||||
let PressableComponent = itemPressable
|
||||
@@ -180,7 +189,7 @@ const ListItem: React.FC<{
|
||||
</PressableComponent>
|
||||
<View style={styles.controls}>
|
||||
{showStar && item.itemType !== 'playlist' && (
|
||||
<PressableStar id={item.id} type={item.itemType} size={26} style={styles.controlItem} />
|
||||
<PressableStar id={item.id} type={item.itemType} size={26} style={styles.controlItem} disabled={disabled} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import Button from '@app/components/Button'
|
||||
import { Song } from '@app/models/library'
|
||||
import { QueueContextType } from '@app/models/trackplayer'
|
||||
import { useStore } from '@app/state/store'
|
||||
import colors from '@app/styles/colors'
|
||||
import React, { useState } from 'react'
|
||||
import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'
|
||||
@@ -11,13 +9,12 @@ import IconMat from 'react-native-vector-icons/MaterialIcons'
|
||||
const ListPlayerControls = React.memo<{
|
||||
songs: Song[]
|
||||
typeName: string
|
||||
queueName: string
|
||||
queueContextType: QueueContextType
|
||||
queueContextId: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
}>(({ songs, typeName, queueName, queueContextType, queueContextId, style }) => {
|
||||
play: () => void
|
||||
shuffle: () => void
|
||||
disabled?: boolean
|
||||
}>(({ typeName, style, play, shuffle, disabled }) => {
|
||||
const [downloaded, setDownloaded] = useState(false)
|
||||
const setQueue = useStore(store => store.setQueue)
|
||||
|
||||
return (
|
||||
<View style={[styles.controls, style]}>
|
||||
@@ -34,16 +31,10 @@ const ListPlayerControls = React.memo<{
|
||||
</Button>
|
||||
</View>
|
||||
<View style={styles.controlsCenter}>
|
||||
<Button
|
||||
title={`Play ${typeName}`}
|
||||
disabled={songs.length === 0}
|
||||
onPress={() => setQueue(songs, queueName, queueContextType, queueContextId, undefined, false)}
|
||||
/>
|
||||
<Button title={`Play ${typeName}`} disabled={disabled} onPress={play} />
|
||||
</View>
|
||||
<View style={styles.controlsSide}>
|
||||
<Button
|
||||
disabled={songs.length === 0}
|
||||
onPress={() => setQueue(songs, queueName, queueContextType, queueContextId, undefined, true)}>
|
||||
<Button disabled={disabled} onPress={shuffle}>
|
||||
<Icon name="shuffle" size={26} color="white" />
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
@@ -93,6 +93,8 @@ const NowPlayingBar = React.memo(() => {
|
||||
type="cover"
|
||||
style={{ height: styles.subContainer.height, width: styles.subContainer.height }}
|
||||
coverArt={coverArt}
|
||||
size="thumbnail"
|
||||
fadeDuration={0}
|
||||
/>
|
||||
<View style={styles.detailsContainer}>
|
||||
<Text numberOfLines={1} style={styles.detailsTitle}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { GestureResponderEvent, LayoutRectangle, Pressable, PressableProps, ViewStyle, StyleSheet } from 'react-native'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { GestureResponderEvent, LayoutRectangle, Pressable, PressableProps, StyleSheet } from 'react-native'
|
||||
|
||||
type PressableOpacityProps = PressableProps & {
|
||||
ripple?: boolean
|
||||
@@ -9,13 +9,8 @@ type PressableOpacityProps = PressableProps & {
|
||||
|
||||
const PressableOpacity: React.FC<PressableOpacityProps> = props => {
|
||||
const [opacity, setOpacity] = useState(1)
|
||||
const [disabledStyle, setDisabledStyle] = useState<ViewStyle>({})
|
||||
const [dimensions, setDimensions] = useState<LayoutRectangle | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
props.disabled === true ? setDisabledStyle({ opacity: 0.3 }) : setDisabledStyle({})
|
||||
}, [props.disabled])
|
||||
|
||||
props = {
|
||||
...props,
|
||||
unstable_pressDelay: props.unstable_pressDelay === undefined ? 60 : props.unstable_pressDelay,
|
||||
@@ -55,7 +50,8 @@ const PressableOpacity: React.FC<PressableOpacityProps> = props => {
|
||||
return (
|
||||
<Pressable
|
||||
{...props}
|
||||
style={[styles.pressable, props.style as any, { opacity }, disabledStyle]}
|
||||
// eslint-disable-next-line react-native/no-inline-styles
|
||||
style={[styles.pressable, props.style as any, { opacity }, props.disabled ? { opacity: 0.3 } : {}]}
|
||||
android_ripple={
|
||||
props.ripple
|
||||
? {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useStar } from '@app/hooks/library'
|
||||
import { useStar } from '@app/hooks/query'
|
||||
import colors from '@app/styles/colors'
|
||||
import React from 'react'
|
||||
import { PressableStateCallbackType, StyleProp, ViewStyle } from 'react-native'
|
||||
@@ -19,12 +19,13 @@ export const PressableStar = React.memo<{
|
||||
type: 'album' | 'artist' | 'song'
|
||||
size: number
|
||||
style?: StyleProp<ViewStyle> | ((state: PressableStateCallbackType) => StyleProp<ViewStyle>) | undefined
|
||||
}>(({ id, type, size, style }) => {
|
||||
const { starred, toggleStar } = useStar(id, type)
|
||||
disabled?: boolean
|
||||
}>(({ id, type, size, style, disabled }) => {
|
||||
const { query, toggle } = useStar(id, type)
|
||||
|
||||
return (
|
||||
<PressableOpacity onPress={toggleStar} style={style}>
|
||||
<Star size={size} starred={starred} />
|
||||
<PressableOpacity onPress={() => toggle.mutate()} style={style} disabled={disabled}>
|
||||
<Star size={size} starred={!!query.data} />
|
||||
</PressableOpacity>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache'
|
||||
import { Store, useStore, useStoreDeep } from '@app/state/store'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
const useFileRequest = (key: CacheItemTypeKey, id: string) => {
|
||||
const file = useStore(
|
||||
useCallback(
|
||||
(store: Store) => {
|
||||
const activeServerId = store.settings.activeServerId
|
||||
if (!activeServerId) {
|
||||
return
|
||||
}
|
||||
|
||||
return store.cacheFiles[activeServerId][key][id]
|
||||
},
|
||||
[key, id],
|
||||
),
|
||||
)
|
||||
const request = useStore(
|
||||
useCallback(
|
||||
(store: Store) => {
|
||||
const activeServerId = store.settings.activeServerId
|
||||
if (!activeServerId) {
|
||||
return
|
||||
}
|
||||
|
||||
return store.cacheRequests[activeServerId][key][id]
|
||||
},
|
||||
[key, id],
|
||||
),
|
||||
)
|
||||
|
||||
return { file, request }
|
||||
}
|
||||
|
||||
export const useCoverArtFile = (coverArt = '-1', size: CacheImageSize = 'thumbnail') => {
|
||||
const type: CacheItemTypeKey = size === 'original' ? 'coverArt' : 'coverArtThumb'
|
||||
const { file, request } = useFileRequest(type, coverArt)
|
||||
const client = useStore(store => store.client)
|
||||
const cacheItem = useStore(store => store.cacheItem)
|
||||
|
||||
useEffect(() => {
|
||||
if (!file && client) {
|
||||
cacheItem(type, coverArt, () =>
|
||||
client.getCoverArtUri({
|
||||
id: coverArt,
|
||||
size: type === 'coverArtThumb' ? '256' : undefined,
|
||||
}),
|
||||
)
|
||||
}
|
||||
// intentionally leaving file out so it doesn't re-render if the request fails
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cacheItem, client, coverArt, type])
|
||||
|
||||
return { file, request }
|
||||
}
|
||||
|
||||
export const useArtistArtFile = (artistId: string, size: CacheImageSize = 'thumbnail') => {
|
||||
const type: CacheItemTypeKey = size === 'original' ? 'artistArt' : 'artistArtThumb'
|
||||
const fetchArtistInfo = useStore(store => store.fetchArtistInfo)
|
||||
const artistInfo = useStoreDeep(store => store.library.artistInfo[artistId])
|
||||
const { file, request } = useFileRequest(type, artistId)
|
||||
const cacheItem = useStore(store => store.cacheItem)
|
||||
|
||||
useEffect(() => {
|
||||
if (!artistInfo) {
|
||||
fetchArtistInfo(artistId)
|
||||
return
|
||||
}
|
||||
|
||||
if (!file && artistInfo) {
|
||||
cacheItem(type, artistId, async () => {
|
||||
return type === 'artistArtThumb' ? artistInfo?.smallImageUrl : artistInfo?.largeImageUrl
|
||||
})
|
||||
}
|
||||
// intentionally leaving file out so it doesn't re-render if the request fails
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [artistId, cacheItem, fetchArtistInfo, type, artistInfo])
|
||||
|
||||
return { file, request }
|
||||
}
|
||||
305
app/hooks/fetch.ts
Normal file
305
app/hooks/fetch.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { CacheItemTypeKey } from '@app/models/cache'
|
||||
import { Album, AlbumCoverArt, 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 { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params'
|
||||
import { cacheDir } from '@app/util/fs'
|
||||
import { mapCollectionById } from '@app/util/state'
|
||||
import userAgent from '@app/util/userAgent'
|
||||
import cd from 'content-disposition'
|
||||
import mime from 'mime-types'
|
||||
import path from 'path'
|
||||
import ReactNativeBlobUtil, { FetchBlobResponse } from 'react-native-blob-util'
|
||||
import RNFS from 'react-native-fs'
|
||||
import qk from './queryKeys'
|
||||
|
||||
export const useClient = () => {
|
||||
const client = useStore(store => store.client)
|
||||
|
||||
return () => {
|
||||
if (!client) {
|
||||
throw new Error('no client!')
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
}
|
||||
|
||||
function cacheStarredData<T extends { id: string; starred?: undefined | any }>(item: T) {
|
||||
queryClient.setQueryData<boolean>(qk.starredItems(item.id), !!item.starred)
|
||||
}
|
||||
|
||||
function cacheAlbumCoverArtData<T extends { id: string; coverArt?: string }>(item: T) {
|
||||
queryClient.setQueryData<AlbumCoverArt>(qk.albumCoverArt(item.id), { albumId: item.id, coverArt: item.coverArt })
|
||||
}
|
||||
|
||||
export const useFetchArtists = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async () => {
|
||||
const res = await client().getArtists()
|
||||
|
||||
res.data.artists.forEach(cacheStarredData)
|
||||
|
||||
return mapCollectionById(res.data.artists, mapArtist)
|
||||
}
|
||||
}
|
||||
|
||||
export const useFetchArtist = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async (id: string) => {
|
||||
const res = await client().getArtist({ id })
|
||||
|
||||
cacheStarredData(res.data.artist)
|
||||
res.data.albums.forEach(cacheStarredData)
|
||||
|
||||
res.data.albums.forEach(cacheAlbumCoverArtData)
|
||||
|
||||
return {
|
||||
artist: mapArtist(res.data.artist),
|
||||
albums: res.data.albums.map(mapAlbum),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useFetchArtistInfo = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async (id: string) => {
|
||||
const res = await client().getArtistInfo2({ id })
|
||||
return mapArtistInfo(id, res.data.artistInfo)
|
||||
}
|
||||
}
|
||||
|
||||
export const useFetchArtistTopSongs = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async (artistName: string) => {
|
||||
const res = await client().getTopSongs({ artist: artistName })
|
||||
|
||||
res.data.songs.forEach(cacheStarredData)
|
||||
|
||||
return res.data.songs.map(mapSong)
|
||||
}
|
||||
}
|
||||
|
||||
export const useFetchPlaylists = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async () => {
|
||||
const res = await client().getPlaylists()
|
||||
return mapCollectionById(res.data.playlists, mapPlaylist)
|
||||
}
|
||||
}
|
||||
|
||||
export const useFetchPlaylist = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async (id: string): Promise<{ playlist: Playlist; songs?: Song[] }> => {
|
||||
const res = await client().getPlaylist({ id })
|
||||
|
||||
res.data.playlist.songs.forEach(cacheStarredData)
|
||||
|
||||
return {
|
||||
playlist: mapPlaylist(res.data.playlist),
|
||||
songs: res.data.playlist.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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useFetchAlbumList = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async (size: number, offset: number, type: GetAlbumList2TypeBase) => {
|
||||
const res = await client().getAlbumList2({ size, offset, type })
|
||||
|
||||
res.data.albums.forEach(cacheStarredData)
|
||||
|
||||
res.data.albums.forEach(cacheAlbumCoverArtData)
|
||||
|
||||
return res.data.albums.map(mapAlbum)
|
||||
}
|
||||
}
|
||||
|
||||
export const useFetchSong = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async (id: string) => {
|
||||
const res = await client().getSong({ id })
|
||||
|
||||
cacheStarredData(res.data.song)
|
||||
|
||||
return mapSong(res.data.song)
|
||||
}
|
||||
}
|
||||
|
||||
export const useFetchSearchResults = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async (params: Search3Params) => {
|
||||
const res = await client().search3(params)
|
||||
|
||||
res.data.artists.forEach(cacheStarredData)
|
||||
res.data.albums.forEach(cacheStarredData)
|
||||
res.data.songs.forEach(cacheStarredData)
|
||||
|
||||
res.data.albums.forEach(cacheAlbumCoverArtData)
|
||||
|
||||
return {
|
||||
artists: res.data.artists.map(mapArtist),
|
||||
albums: res.data.albums.map(mapAlbum),
|
||||
songs: res.data.songs.map(mapSong),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useFetchStar = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async (params: StarParams) => {
|
||||
await client().star(params)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export const useFetchUnstar = () => {
|
||||
const client = useClient()
|
||||
|
||||
return async (params: StarParams) => {
|
||||
await client().unstar(params)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export type FetchExisingFileOptions = {
|
||||
itemType: CacheItemTypeKey
|
||||
itemId: string
|
||||
}
|
||||
|
||||
export const useFetchExistingFile: () => (options: FetchExisingFileOptions) => Promise<string | undefined> = () => {
|
||||
const serverId = useStore(store => store.settings.activeServerId)
|
||||
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
|
||||
function assertMimeType(expected?: string, actual?: string) {
|
||||
expected = expected?.toLowerCase()
|
||||
actual = actual?.toLowerCase()
|
||||
|
||||
if (!expected || expected === actual) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!expected.includes(';')) {
|
||||
actual = actual?.split(';')[0]
|
||||
}
|
||||
|
||||
if (!expected.includes('/')) {
|
||||
actual = actual?.split('/')[0]
|
||||
}
|
||||
|
||||
if (expected !== actual) {
|
||||
throw new Error(`Request does not satisfy expected content type. Expected: ${expected} Actual: ${actual}`)
|
||||
}
|
||||
}
|
||||
|
||||
export type FetchFileOptions = FetchExisingFileOptions & {
|
||||
fromUrl: string
|
||||
useCacheBuster?: boolean
|
||||
expectedContentType?: string
|
||||
progress?: (received: number, total: number) => void
|
||||
}
|
||||
|
||||
export const useFetchFile: () => (options: FetchFileOptions) => Promise<string> = () => {
|
||||
const serverId = useStore(store => store.settings.activeServerId)
|
||||
|
||||
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)
|
||||
|
||||
try {
|
||||
await RNFS.unlink(fileDir)
|
||||
} catch {}
|
||||
|
||||
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 })
|
||||
|
||||
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<typeof config['fetch']> = ['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<string>(qk.existingFiles(itemType, itemId), downloadPath)
|
||||
|
||||
console.log('downloaded file:', downloadPath)
|
||||
return downloadPath
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useStore } from '@app/state/store'
|
||||
import { StarParams } from '@app/subsonic/params'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
type StarrableItem = 'album' | 'artist' | 'song'
|
||||
|
||||
function starParams(id: string, type: StarrableItem): StarParams {
|
||||
const params: StarParams = {}
|
||||
if (type === 'album') {
|
||||
params.albumId = id
|
||||
} else if (type === 'artist') {
|
||||
params.artistId = id
|
||||
} else {
|
||||
params.id = id
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
export const useStar = (id: string, type: StarrableItem) => {
|
||||
const fetchAlbum = useStore(store => store.fetchAlbum)
|
||||
const fetchArtist = useStore(store => store.fetchArtist)
|
||||
const fetchSong = useStore(store => store.fetchSong)
|
||||
|
||||
const _starred = useStore(
|
||||
useCallback(
|
||||
store => {
|
||||
if (type === 'album') {
|
||||
return store.library.albums[id] ? !!store.library.albums[id].starred : null
|
||||
} else if (type === 'artist') {
|
||||
return store.library.artists[id] ? !!store.library.artists[id].starred : null
|
||||
} else {
|
||||
return store.library.songs[id] ? !!store.library.songs[id].starred : null
|
||||
}
|
||||
},
|
||||
[id, type],
|
||||
),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (_starred === null) {
|
||||
if (type === 'album') {
|
||||
fetchAlbum(id)
|
||||
} else if (type === 'artist') {
|
||||
fetchArtist(id)
|
||||
} else {
|
||||
fetchSong(id)
|
||||
}
|
||||
}
|
||||
}, [fetchAlbum, fetchArtist, fetchSong, id, _starred, type])
|
||||
|
||||
const starred = !!_starred
|
||||
|
||||
const _star = useStore(store => store.star)
|
||||
const _unstar = useStore(store => store.unstar)
|
||||
|
||||
const star = useCallback(() => _star(starParams(id, type)), [_star, id, type])
|
||||
const unstar = useCallback(() => _unstar(starParams(id, type)), [_unstar, id, type])
|
||||
|
||||
const toggleStar = useCallback(() => (starred ? unstar() : star()), [star, starred, unstar])
|
||||
|
||||
return { star, unstar, toggleStar, starred }
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useActiveServerRefresh } from './settings'
|
||||
|
||||
export const useFetchList = <T>(fetchList: () => Promise<T[]>) => {
|
||||
const [list, setList] = useState<T[]>([])
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setRefreshing(true)
|
||||
|
||||
fetchList().then(items => {
|
||||
setList(items)
|
||||
setRefreshing(false)
|
||||
})
|
||||
}, [fetchList])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setList([])
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
useActiveServerRefresh(
|
||||
useCallback(() => {
|
||||
reset()
|
||||
}, [reset]),
|
||||
)
|
||||
|
||||
return { list, refreshing, refresh, reset }
|
||||
}
|
||||
|
||||
export const useFetchList2 = (fetchList: () => Promise<void>) => {
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
await fetchList()
|
||||
setRefreshing(false)
|
||||
}, [fetchList])
|
||||
|
||||
useActiveServerRefresh(
|
||||
useCallback(async () => {
|
||||
await refresh()
|
||||
}, [refresh]),
|
||||
)
|
||||
|
||||
return { refreshing, refresh }
|
||||
}
|
||||
|
||||
export const useFetchPaginatedList = <T>(
|
||||
fetchList: (size: number, offset: number) => Promise<T[]>,
|
||||
pageSize: number,
|
||||
) => {
|
||||
const [list, setList] = useState<T[]>([])
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [offset, setOffset] = useState(0)
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setOffset(0)
|
||||
setRefreshing(true)
|
||||
|
||||
fetchList(pageSize, 0).then(firstPage => {
|
||||
setList(firstPage)
|
||||
setRefreshing(false)
|
||||
})
|
||||
}, [fetchList, pageSize])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setList([])
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
useActiveServerRefresh(
|
||||
useCallback(() => {
|
||||
refresh()
|
||||
}, [refresh]),
|
||||
)
|
||||
|
||||
const fetchNextPage = useCallback(() => {
|
||||
const newOffset = offset + pageSize
|
||||
setRefreshing(true)
|
||||
|
||||
fetchList(pageSize, newOffset).then(nextPage => {
|
||||
setRefreshing(false)
|
||||
|
||||
if (nextPage.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setList([...list, ...nextPage])
|
||||
setOffset(newOffset)
|
||||
})
|
||||
}, [offset, pageSize, fetchList, list])
|
||||
|
||||
return { list, refreshing, refresh, reset, fetchNextPage }
|
||||
}
|
||||
397
app/hooks/query.ts
Normal file
397
app/hooks/query.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache'
|
||||
import { Album, AlbumCoverArt, 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 {
|
||||
useFetchAlbum,
|
||||
useFetchAlbumList,
|
||||
useFetchArtist,
|
||||
useFetchArtistInfo,
|
||||
useFetchArtists,
|
||||
useFetchArtistTopSongs,
|
||||
useFetchExistingFile,
|
||||
useFetchFile,
|
||||
useFetchPlaylist,
|
||||
useFetchPlaylists,
|
||||
useFetchSearchResults,
|
||||
useFetchSong,
|
||||
useFetchStar,
|
||||
useFetchUnstar,
|
||||
} from './fetch'
|
||||
import qk from './queryKeys'
|
||||
|
||||
export const useQueryArtists = () => useQuery(qk.artists, useFetchArtists())
|
||||
|
||||
export const useQueryArtist = (id: string) => {
|
||||
const fetchArtist = useFetchArtist()
|
||||
|
||||
return useQuery(qk.artist(id), () => fetchArtist(id), {
|
||||
placeholderData: () => {
|
||||
const artist = queryClient.getQueryData<CollectionById<Artist>>(qk.artists)?.byId[id]
|
||||
if (artist) {
|
||||
return { artist, albums: [] }
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useQueryArtistInfo = (id: string) => {
|
||||
const fetchArtistInfo = useFetchArtistInfo()
|
||||
return useQuery(qk.artistInfo(id), () => fetchArtistInfo(id))
|
||||
}
|
||||
|
||||
export const useQueryArtistTopSongs = (artistName?: string) => {
|
||||
const fetchArtistTopSongs = useFetchArtistTopSongs()
|
||||
const query = useQuery(qk.artistTopSongs(artistName || ''), () => fetchArtistTopSongs(artistName as string), {
|
||||
enabled: !!artistName,
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
cacheTime: Infinity,
|
||||
notifyOnChangeProps: ['data', 'isError', 'isFetched', 'isSuccess', 'isFetching'],
|
||||
})
|
||||
|
||||
const querySuccess = query.isFetched && query.isSuccess && query.data && query.data.length > 0
|
||||
|
||||
const fetchSearchResults = useFetchSearchResults()
|
||||
const backupQuery = useQuery(
|
||||
qk.search(artistName || '', 0, 0, 50),
|
||||
() => fetchSearchResults({ query: artistName as string, songCount: 50 }),
|
||||
{
|
||||
enabled: !!artistName && !query.isFetching && !querySuccess,
|
||||
select: data =>
|
||||
// sortBy is a stable sort, so that this doesn't change order arbitrarily and re-render
|
||||
_.sortBy(data.songs, [s => -(s.playCount || 0), s => -(s.averageRating || 0), s => -(s.userRating || 0)]),
|
||||
staleTime: Infinity,
|
||||
cacheTime: Infinity,
|
||||
notifyOnChangeProps: ['data', 'isError'],
|
||||
},
|
||||
)
|
||||
|
||||
return useFixCoverArt(querySuccess ? query : backupQuery)
|
||||
}
|
||||
|
||||
export const useQueryPlaylists = () => useQuery(qk.playlists, useFetchPlaylists())
|
||||
|
||||
export const useQueryPlaylist = (id: string, placeholderPlaylist?: Playlist) => {
|
||||
const fetchPlaylist = useFetchPlaylist()
|
||||
|
||||
const query = useQuery(qk.playlist(id), () => fetchPlaylist(id), {
|
||||
placeholderData: () => {
|
||||
if (placeholderPlaylist) {
|
||||
return { playlist: placeholderPlaylist }
|
||||
}
|
||||
|
||||
const playlist = queryClient.getQueryData<CollectionById<Playlist>>(qk.playlists)?.byId[id]
|
||||
if (playlist) {
|
||||
return { playlist, songs: [] }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return useFixCoverArt(query)
|
||||
}
|
||||
|
||||
export const useQueryAlbum = (id: string, placeholderAlbum?: Album) => {
|
||||
const fetchAlbum = useFetchAlbum()
|
||||
|
||||
const query = useQuery(qk.album(id), () => fetchAlbum(id), {
|
||||
placeholderData: (): { album: Album; songs?: Song[] } | undefined =>
|
||||
placeholderAlbum ? { album: placeholderAlbum } : undefined,
|
||||
})
|
||||
|
||||
return useFixCoverArt(query)
|
||||
}
|
||||
|
||||
export const useQueryAlbumList = (type: GetAlbumList2TypeBase, size: number) => {
|
||||
const fetchAlbumList = useFetchAlbumList()
|
||||
|
||||
return useInfiniteQuery(
|
||||
qk.albumList(type, size),
|
||||
async context => {
|
||||
return await fetchAlbumList(size, context.pageParam || 0, type)
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
if (lastPage.length === 0) {
|
||||
return
|
||||
}
|
||||
return allPages.length * size
|
||||
},
|
||||
cacheTime: 0,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export const useQuerySearchResults = (params: Search3Params) => {
|
||||
const fetchSearchResults = useFetchSearchResults()
|
||||
|
||||
const query = useInfiniteQuery(
|
||||
qk.search(params.query, params.artistCount, params.albumCount, params.songCount),
|
||||
async context => {
|
||||
return await fetchSearchResults({
|
||||
...params,
|
||||
artistOffset: context.pageParam?.artistOffset || 0,
|
||||
albumOffset: context.pageParam?.albumOffset || 0,
|
||||
songOffset: context.pageParam?.songOffset || 0,
|
||||
})
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
if (lastPage.albums.length + lastPage.artists.length + lastPage.songs.length === 0) {
|
||||
return
|
||||
}
|
||||
return {
|
||||
artistOffset: allPages.reduce((acc, val) => (acc += val.artists.length), 0),
|
||||
albumOffset: allPages.reduce((acc, val) => (acc += val.albums.length), 0),
|
||||
songOffset: allPages.reduce((acc, val) => (acc += val.songs.length), 0),
|
||||
}
|
||||
},
|
||||
cacheTime: 1000 * 60,
|
||||
enabled: !!params.query && params.query.length > 1,
|
||||
},
|
||||
)
|
||||
|
||||
return useFixCoverArt(query)
|
||||
}
|
||||
|
||||
export const useQueryHomeLists = (types: GetAlbumList2TypeBase[], size: number) => {
|
||||
const fetchAlbumList = useFetchAlbumList()
|
||||
|
||||
const listQueries = useQueries(
|
||||
types.map(type => {
|
||||
return {
|
||||
queryKey: qk.albumList(type, size),
|
||||
queryFn: async () => {
|
||||
const albums = await fetchAlbumList(size, 0, type as GetAlbumList2TypeBase)
|
||||
return { type, albums }
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return listQueries
|
||||
}
|
||||
|
||||
export const useStar = (id: string, type: StarrableItemType) => {
|
||||
const fetchStar = useFetchStar()
|
||||
const fetchUnstar = useFetchUnstar()
|
||||
const fetchSong = useFetchSong()
|
||||
const fetchAlbum = useFetchAlbum()
|
||||
const fetchArtist = useFetchArtist()
|
||||
|
||||
const query = useQuery(
|
||||
qk.starredItems(id),
|
||||
async () => {
|
||||
switch (type) {
|
||||
case 'album':
|
||||
console.log('fetch album starred', id)
|
||||
return !!(await fetchAlbum(id)).album.starred
|
||||
case 'artist':
|
||||
console.log('fetch artist starred', id)
|
||||
return !!(await fetchArtist(id)).artist.starred
|
||||
default:
|
||||
console.log('fetch song starred', id)
|
||||
return !!(await fetchSong(id)).starred
|
||||
}
|
||||
},
|
||||
{
|
||||
cacheTime: Infinity,
|
||||
staleTime: Infinity,
|
||||
},
|
||||
)
|
||||
|
||||
const toggle = useMutation(
|
||||
() => {
|
||||
const params: StarParams = {
|
||||
id: type === 'song' ? id : undefined,
|
||||
albumId: type === 'album' ? id : undefined,
|
||||
artistId: type === 'artist' ? id : undefined,
|
||||
}
|
||||
return !query.data ? fetchStar(params) : fetchUnstar(params)
|
||||
},
|
||||
{
|
||||
onMutate: () => {
|
||||
queryClient.setQueryData<boolean>(qk.starredItems(id), !query.data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (type === 'album') {
|
||||
queryClient.invalidateQueries(qk.albumList('starred'))
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return { query, toggle }
|
||||
}
|
||||
|
||||
export const useQueryExistingFile = (itemType: CacheItemTypeKey, itemId: string) => {
|
||||
const fetchExistingFile = useFetchExistingFile()
|
||||
|
||||
return useQuery(qk.existingFiles(itemType, itemId), () => fetchExistingFile({ itemType, itemId }), {
|
||||
staleTime: Infinity,
|
||||
cacheTime: Infinity,
|
||||
notifyOnChangeProps: ['data', 'isFetched'],
|
||||
})
|
||||
}
|
||||
|
||||
export const useQueryCoverArtPath = (coverArt = '-1', size: CacheImageSize = 'thumbnail') => {
|
||||
const fetchFile = useFetchFile()
|
||||
const client = useStore(store => store.client)
|
||||
|
||||
const itemType: CacheItemTypeKey = size === 'original' ? 'coverArt' : 'coverArtThumb'
|
||||
const existing = useQueryExistingFile(itemType, coverArt)
|
||||
|
||||
const query = useQuery(
|
||||
qk.coverArt(coverArt, size),
|
||||
async () => {
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const fromUrl = client.getCoverArtUri({ id: coverArt, size: itemType === 'coverArtThumb' ? '256' : undefined })
|
||||
return await fetchFile({ itemType, itemId: coverArt, fromUrl, expectedContentType: 'image' })
|
||||
},
|
||||
{
|
||||
enabled: existing.isFetched && !existing.data && !!client,
|
||||
staleTime: Infinity,
|
||||
cacheTime: Infinity,
|
||||
},
|
||||
)
|
||||
|
||||
return { ...query, data: existing.data || query.data, isExistingFetching: existing.isFetching }
|
||||
}
|
||||
|
||||
export const useQueryArtistArtPath = (artistId: string, size: CacheImageSize = 'thumbnail') => {
|
||||
const fetchFile = useFetchFile()
|
||||
const client = useStore(store => store.client)
|
||||
const { data: artistInfo } = useQueryArtistInfo(artistId)
|
||||
|
||||
const itemType: CacheItemTypeKey = size === 'original' ? 'artistArt' : 'artistArtThumb'
|
||||
const existing = useQueryExistingFile(itemType, artistId)
|
||||
|
||||
const query = useQuery(
|
||||
qk.artistArt(artistId, size),
|
||||
async () => {
|
||||
if (!client || !artistInfo?.smallImageUrl || !artistInfo?.largeImageUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
const fromUrl = itemType === 'artistArtThumb' ? artistInfo.smallImageUrl : artistInfo.largeImageUrl
|
||||
return await fetchFile({ itemType, itemId: artistId, fromUrl, expectedContentType: 'image' })
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
existing.isFetched &&
|
||||
!existing.data &&
|
||||
!!client &&
|
||||
(!!artistInfo?.smallImageUrl || !!artistInfo?.largeImageUrl),
|
||||
staleTime: Infinity,
|
||||
cacheTime: Infinity,
|
||||
},
|
||||
)
|
||||
|
||||
return { ...query, data: existing.data || query.data, isExistingFetching: existing.isFetching }
|
||||
}
|
||||
|
||||
type WithSongs = Song[] | { songs?: Song[] }
|
||||
type InfiniteWithSongs = { songs: Song[] }
|
||||
type AnyDataWithSongs = WithSongs | InfiniteData<InfiniteWithSongs>
|
||||
type AnyQueryWithSongs = UseQueryResult<WithSongs> | UseInfiniteQueryResult<{ songs: Song[] }>
|
||||
|
||||
function getSongs<T extends AnyDataWithSongs>(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<T extends AnyQueryWithSongs>(query: T, coverArts: UseQueryResult<AlbumCoverArt>[]): 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 = <T extends AnyQueryWithSongs>(query: T) => {
|
||||
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<AlbumCoverArt> => {
|
||||
const res = await fetchAlbum(id)
|
||||
return { albumId: res.album.id, coverArt: res.album.coverArt }
|
||||
},
|
||||
staleTime: Infinity,
|
||||
cacheTime: Infinity,
|
||||
notifyOnChangeProps: ['data', 'isFetched'] as any,
|
||||
})),
|
||||
)
|
||||
|
||||
if (coverArts.every(c => c.isFetched)) {
|
||||
return setSongCoverArt(query, coverArts)
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
52
app/hooks/queryKeys.ts
Normal file
52
app/hooks/queryKeys.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache'
|
||||
import { GetAlbumList2TypeBase } from '@app/subsonic/params'
|
||||
|
||||
const qk = {
|
||||
starredItems: (id: string) => ['starredItems', id],
|
||||
albumCoverArt: (id: string) => ['albumCoverArt', id],
|
||||
|
||||
artists: 'artists',
|
||||
artist: (id: string) => ['artist', id],
|
||||
artistInfo: (id: string) => ['artistInfo', id],
|
||||
artistTopSongs: (artistName: string) => ['artistTopSongs', artistName],
|
||||
|
||||
playlists: 'playlists',
|
||||
playlist: (id: string) => ['playlist', id],
|
||||
|
||||
album: (id: string) => ['album', id],
|
||||
albumList: (type: GetAlbumList2TypeBase, size?: number) => {
|
||||
const key: (string | number)[] = ['albumList', type]
|
||||
size !== undefined && key.push(size)
|
||||
return key
|
||||
},
|
||||
|
||||
search: (query: string, artistCount?: number, albumCount?: number, songCount?: number) => [
|
||||
'search',
|
||||
query,
|
||||
artistCount,
|
||||
albumCount,
|
||||
songCount,
|
||||
],
|
||||
|
||||
coverArt: (coverArt?: string, size?: CacheImageSize) => {
|
||||
const key: string[] = ['coverArt']
|
||||
coverArt !== undefined && key.push(coverArt)
|
||||
size !== undefined && key.push(size)
|
||||
return key
|
||||
},
|
||||
artistArt: (artistId?: string, size?: CacheImageSize) => {
|
||||
const key: string[] = ['artistArt']
|
||||
artistId !== undefined && key.push(artistId)
|
||||
size !== undefined && key.push(size)
|
||||
return key
|
||||
},
|
||||
|
||||
existingFiles: (type?: CacheItemTypeKey, itemId?: string) => {
|
||||
const key: string[] = ['existingFiles']
|
||||
type !== undefined && key.push(type)
|
||||
itemId !== undefined && key.push(itemId)
|
||||
return key
|
||||
},
|
||||
}
|
||||
|
||||
export default qk
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useReset } from '@app/hooks/trackplayer'
|
||||
import { useStore } from '@app/state/store'
|
||||
import { useEffect } from 'react'
|
||||
import { CacheItemTypeKey } from '@app/models/cache'
|
||||
import queryClient from '@app/queryClient'
|
||||
import { useStore, useStoreDeep } from '@app/state/store'
|
||||
import { cacheDir } from '@app/util/fs'
|
||||
import RNFS from 'react-native-fs'
|
||||
import qk from './queryKeys'
|
||||
|
||||
export const useSwitchActiveServer = () => {
|
||||
const activeServerId = useStore(store => store.settings.activeServerId)
|
||||
@@ -12,21 +16,53 @@ export const useSwitchActiveServer = () => {
|
||||
return
|
||||
}
|
||||
|
||||
await queryClient.cancelQueries(undefined, { active: true })
|
||||
await resetPlayer()
|
||||
queryClient.removeQueries()
|
||||
setActiveServer(id)
|
||||
}
|
||||
}
|
||||
|
||||
export const useActiveServerRefresh = (refresh: () => void) => {
|
||||
const activeServerId = useStore(store => store.settings.activeServerId)
|
||||
|
||||
useEffect(() => {
|
||||
if (activeServerId) {
|
||||
refresh()
|
||||
}
|
||||
}, [activeServerId, refresh])
|
||||
}
|
||||
|
||||
export const useFirstRun = () => {
|
||||
return useStore(store => Object.keys(store.settings.servers).length === 0)
|
||||
}
|
||||
|
||||
export const useResetImageCache = () => {
|
||||
const serverIds = useStoreDeep(store => Object.keys(store.settings.servers))
|
||||
const changeCacheBuster = useStore(store => store.changeCacheBuster)
|
||||
|
||||
return async () => {
|
||||
// disable/invalidate queries
|
||||
await Promise.all([
|
||||
queryClient.cancelQueries(qk.artistArt(), { active: true }),
|
||||
queryClient.cancelQueries(qk.coverArt(), { active: true }),
|
||||
queryClient.cancelQueries(qk.existingFiles(), { active: true }),
|
||||
queryClient.invalidateQueries(qk.artistArt(), { refetchActive: false }),
|
||||
queryClient.invalidateQueries(qk.coverArt(), { refetchActive: false }),
|
||||
queryClient.invalidateQueries(qk.existingFiles(), { refetchActive: false }),
|
||||
])
|
||||
|
||||
// delete all images
|
||||
const itemTypes: CacheItemTypeKey[] = ['artistArt', 'artistArtThumb', 'coverArt', 'coverArtThumb']
|
||||
await Promise.all(
|
||||
serverIds.flatMap(id =>
|
||||
itemTypes.map(async type => {
|
||||
const dir = cacheDir(id, type)
|
||||
try {
|
||||
await RNFS.unlink(dir)
|
||||
} catch {}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// change cacheBuster
|
||||
changeCacheBuster()
|
||||
|
||||
// enable queries
|
||||
await Promise.all([
|
||||
queryClient.refetchQueries(qk.existingFiles(), { active: true }),
|
||||
queryClient.refetchQueries(qk.artistArt(), { active: true }),
|
||||
queryClient.refetchQueries(qk.coverArt(), { active: true }),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { Song } from '@app/models/library'
|
||||
import { QueueContextType, TrackExt } from '@app/models/trackplayer'
|
||||
import queryClient from '@app/queryClient'
|
||||
import { useStore, useStoreDeep } from '@app/state/store'
|
||||
import { getQueue, trackPlayerCommands } from '@app/state/trackplayer'
|
||||
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 = () => {
|
||||
return () => trackPlayerCommands.enqueue(() => TrackPlayer.play())
|
||||
@@ -83,3 +91,88 @@ export const useIsPlaying = (contextId: string | undefined, track: number) => {
|
||||
|
||||
return contextId === queueContextId && track === currentTrackIdx
|
||||
}
|
||||
|
||||
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<string | undefined>(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 })
|
||||
}
|
||||
|
||||
return { setQueue, contextId, isReady: coverArtPaths.every(c => c.isFetched) }
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ export enum CacheItemType {
|
||||
coverArtThumb = 'coverArtThumb',
|
||||
artistArt = 'artistArt',
|
||||
artistArtThumb = 'artistArtThumb',
|
||||
song = 'song',
|
||||
}
|
||||
|
||||
export type CacheItemTypeKey = keyof typeof CacheItemType
|
||||
|
||||
@@ -2,7 +2,7 @@ export interface Artist {
|
||||
itemType: 'artist'
|
||||
id: string
|
||||
name: string
|
||||
starred?: Date
|
||||
starred?: number
|
||||
coverArt?: string
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface Album {
|
||||
name: string
|
||||
artist?: string
|
||||
artistId?: string
|
||||
starred?: Date
|
||||
starred?: number
|
||||
coverArt?: string
|
||||
year?: number
|
||||
}
|
||||
@@ -42,16 +42,24 @@ export interface Song {
|
||||
track?: number
|
||||
discNumber?: number
|
||||
duration?: number
|
||||
starred?: Date
|
||||
starred?: number
|
||||
coverArt?: string
|
||||
playCount?: number
|
||||
userRating?: number
|
||||
averageRating?: number
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
artists: string[]
|
||||
albums: string[]
|
||||
songs: string[]
|
||||
artists: Artist[]
|
||||
albums: Album[]
|
||||
songs: Song[]
|
||||
}
|
||||
|
||||
export type StarrableItemType = 'album' | 'song' | 'artist'
|
||||
|
||||
export type ListableItem = Album | Song | Artist | Playlist
|
||||
|
||||
export interface AlbumCoverArt {
|
||||
albumId: string
|
||||
coverArt?: string
|
||||
}
|
||||
|
||||
85
app/models/map.ts
Normal file
85
app/models/map.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
AlbumID3Element,
|
||||
ArtistID3Element,
|
||||
ArtistInfo2Element,
|
||||
ChildElement,
|
||||
PlaylistElement,
|
||||
} from '@app/subsonic/elements'
|
||||
import { Album, Artist, ArtistInfo, Playlist, Song } from './library'
|
||||
import { TrackExt } from './trackplayer'
|
||||
|
||||
export function mapArtist(artist: ArtistID3Element): Artist {
|
||||
return {
|
||||
itemType: 'artist',
|
||||
id: artist.id,
|
||||
name: artist.name,
|
||||
starred: artist.starred?.getTime(),
|
||||
coverArt: artist.coverArt,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapArtistInfo(id: string, info: ArtistInfo2Element): ArtistInfo {
|
||||
return {
|
||||
id,
|
||||
smallImageUrl: info.smallImageUrl,
|
||||
largeImageUrl: info.largeImageUrl,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapAlbum(album: AlbumID3Element): Album {
|
||||
return {
|
||||
itemType: 'album',
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
artist: album.artist,
|
||||
artistId: album.artistId,
|
||||
starred: album.starred?.getTime(),
|
||||
coverArt: album.coverArt,
|
||||
year: album.year,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapPlaylist(playlist: PlaylistElement): Playlist {
|
||||
return {
|
||||
itemType: 'playlist',
|
||||
id: playlist.id,
|
||||
name: playlist.name,
|
||||
comment: playlist.comment,
|
||||
coverArt: playlist.coverArt,
|
||||
}
|
||||
}
|
||||
|
||||
export 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?.getTime(),
|
||||
playCount: song.playCount,
|
||||
averageRating: song.averageRating,
|
||||
userRating: song.userRating,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapTrackExtToSong(track: TrackExt): Song {
|
||||
return {
|
||||
itemType: 'song',
|
||||
id: track.id,
|
||||
title: track.title as string,
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
coverArt: track.coverArt,
|
||||
duration: track.duration,
|
||||
artistId: track.artistId,
|
||||
albumId: track.albumId,
|
||||
track: track.track,
|
||||
discNumber: track.discNumber,
|
||||
}
|
||||
}
|
||||
@@ -12,3 +12,8 @@ export interface OrderedById<T> {
|
||||
export interface PaginatedList {
|
||||
[offset: number]: string[]
|
||||
}
|
||||
|
||||
export interface CollectionById<T extends { id: string }> {
|
||||
byId: ById<T>
|
||||
allIds: string[]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useFirstRun } from '@app/hooks/settings'
|
||||
import { Album, Playlist } from '@app/models/library'
|
||||
import BottomTabBar from '@app/navigation/BottomTabBar'
|
||||
import LibraryTopTabNavigator from '@app/navigation/LibraryTopTabNavigator'
|
||||
import ArtistView from '@app/screens/ArtistView'
|
||||
@@ -9,6 +10,7 @@ import ServerView from '@app/screens/ServerView'
|
||||
import SettingsView from '@app/screens/Settings'
|
||||
import SongListView from '@app/screens/SongListView'
|
||||
import WebViewScreen from '@app/screens/WebViewScreen'
|
||||
import { useStore } from '@app/state/store'
|
||||
import colors from '@app/styles/colors'
|
||||
import font from '@app/styles/font'
|
||||
import { BottomTabNavigationProp, createBottomTabNavigator } from '@react-navigation/bottom-tabs'
|
||||
@@ -19,9 +21,9 @@ import { createNativeStackNavigator, NativeStackNavigationProp } from 'react-nat
|
||||
|
||||
type TabStackParamList = {
|
||||
main: undefined
|
||||
album: { id: string; title: string }
|
||||
album: { id: string; title: string; album?: Album }
|
||||
artist: { id: string; title: string }
|
||||
playlist: { id: string; title: string }
|
||||
playlist: { id: string; title: string; playlist?: Playlist }
|
||||
results: { query: string; type: 'album' | 'song' | 'artist' }
|
||||
}
|
||||
|
||||
@@ -32,9 +34,7 @@ type AlbumScreenProps = {
|
||||
navigation: AlbumScreenNavigationProp
|
||||
}
|
||||
|
||||
const AlbumScreen: React.FC<AlbumScreenProps> = ({ route }) => (
|
||||
<SongListView id={route.params.id} title={route.params.title} type="album" />
|
||||
)
|
||||
const AlbumScreen: React.FC<AlbumScreenProps> = ({ route }) => <SongListView {...route.params} type="album" />
|
||||
|
||||
type ArtistScreenNavigationProp = NativeStackNavigationProp<TabStackParamList, 'artist'>
|
||||
type ArtistScreenRouteProp = RouteProp<TabStackParamList, 'artist'>
|
||||
@@ -43,9 +43,7 @@ type ArtistScreenProps = {
|
||||
navigation: ArtistScreenNavigationProp
|
||||
}
|
||||
|
||||
const ArtistScreen: React.FC<ArtistScreenProps> = ({ route }) => (
|
||||
<ArtistView id={route.params.id} title={route.params.title} />
|
||||
)
|
||||
const ArtistScreen: React.FC<ArtistScreenProps> = ({ route }) => <ArtistView {...route.params} />
|
||||
|
||||
type PlaylistScreenNavigationProp = NativeStackNavigationProp<TabStackParamList, 'playlist'>
|
||||
type PlaylistScreenRouteProp = RouteProp<TabStackParamList, 'playlist'>
|
||||
@@ -54,9 +52,7 @@ type PlaylistScreenProps = {
|
||||
navigation: PlaylistScreenNavigationProp
|
||||
}
|
||||
|
||||
const PlaylistScreen: React.FC<PlaylistScreenProps> = ({ route }) => (
|
||||
<SongListView id={route.params.id} title={route.params.title} type="playlist" />
|
||||
)
|
||||
const PlaylistScreen: React.FC<PlaylistScreenProps> = ({ route }) => <SongListView {...route.params} type="playlist" />
|
||||
|
||||
type ResultsScreenNavigationProp = NativeStackNavigationProp<TabStackParamList, 'results'>
|
||||
type ResultsScreenRouteProp = RouteProp<TabStackParamList, 'results'>
|
||||
@@ -65,9 +61,7 @@ type ResultsScreenProps = {
|
||||
navigation: ResultsScreenNavigationProp
|
||||
}
|
||||
|
||||
const ResultsScreen: React.FC<ResultsScreenProps> = ({ route }) => (
|
||||
<SearchResultsView query={route.params.query} type={route.params.type} />
|
||||
)
|
||||
const ResultsScreen: React.FC<ResultsScreenProps> = ({ route }) => <SearchResultsView {...route.params} />
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
stackheaderStyle: {
|
||||
@@ -177,12 +171,19 @@ const Tab = createBottomTabNavigator()
|
||||
|
||||
const BottomTabNavigator = () => {
|
||||
const firstRun = useFirstRun()
|
||||
const resetServer = useStore(store => store.resetServer)
|
||||
|
||||
return (
|
||||
<Tab.Navigator tabBar={BottomTabBar} initialRouteName={firstRun ? 'settings' : 'home'}>
|
||||
<Tab.Screen name="home" component={HomeTab} options={{ tabBarLabel: 'Home' }} />
|
||||
<Tab.Screen name="library" component={LibraryTab} options={{ tabBarLabel: 'Library' }} />
|
||||
<Tab.Screen name="search" component={SearchTab} options={{ tabBarLabel: 'Search' }} />
|
||||
{resetServer ? (
|
||||
<></>
|
||||
) : (
|
||||
<>
|
||||
<Tab.Screen name="home" component={HomeTab} options={{ tabBarLabel: 'Home' }} />
|
||||
<Tab.Screen name="library" component={LibraryTab} options={{ tabBarLabel: 'Library' }} />
|
||||
<Tab.Screen name="search" component={SearchTab} options={{ tabBarLabel: 'Search' }} />
|
||||
</>
|
||||
)}
|
||||
<Tab.Screen name="settings" component={SettingsTab} options={{ tabBarLabel: 'Settings' }} />
|
||||
</Tab.Navigator>
|
||||
)
|
||||
|
||||
5
app/queryClient.ts
Normal file
5
app/queryClient.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { QueryClient } from 'react-query'
|
||||
|
||||
const client = new QueryClient()
|
||||
|
||||
export default client
|
||||
@@ -5,15 +5,16 @@ import GradientScrollView from '@app/components/GradientScrollView'
|
||||
import Header from '@app/components/Header'
|
||||
import HeaderBar from '@app/components/HeaderBar'
|
||||
import ListItem from '@app/components/ListItem'
|
||||
import { useQueryArtist, useQueryArtistTopSongs } from '@app/hooks/query'
|
||||
import { useSetQueue } from '@app/hooks/trackplayer'
|
||||
import { Album, Song } from '@app/models/library'
|
||||
import { useStore, useStoreDeep } from '@app/state/store'
|
||||
import colors from '@app/styles/colors'
|
||||
import dimensions from '@app/styles/dimensions'
|
||||
import font from '@app/styles/font'
|
||||
import { mapById } from '@app/util/state'
|
||||
import { useLayout } from '@react-native-community/hooks'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import equal from 'fast-deep-equal/es6/react'
|
||||
import React from 'react'
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
|
||||
import { useAnimatedScrollHandler, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
|
||||
|
||||
@@ -31,22 +32,21 @@ const AlbumItem = React.memo<{
|
||||
return (
|
||||
<AlbumContextPressable
|
||||
album={album}
|
||||
onPress={() => navigation.navigate('album', { id: album.id, title: album.name })}
|
||||
onPress={() => navigation.navigate('album', { id: album.id, title: album.name, album })}
|
||||
menuStyle={[styles.albumItem, { width }]}
|
||||
triggerOuterWrapperStyle={{ width }}>
|
||||
<CoverArt type="cover" coverArt={album.coverArt} style={{ height, width }} resizeMode={'cover'} />
|
||||
<CoverArt type="cover" coverArt={album.coverArt} style={{ height, width }} resizeMode="cover" size="thumbnail" />
|
||||
<Text style={styles.albumTitle}>{album.name}</Text>
|
||||
<Text style={styles.albumYear}> {album.year ? album.year : ''}</Text>
|
||||
</AlbumContextPressable>
|
||||
)
|
||||
})
|
||||
}, equal)
|
||||
|
||||
const TopSongs = React.memo<{
|
||||
songs: Song[]
|
||||
name: string
|
||||
artistId: string
|
||||
}>(({ songs, name, artistId }) => {
|
||||
const setQueue = useStore(store => store.setQueue)
|
||||
}>(({ songs, name }) => {
|
||||
const { setQueue, isReady, contextId } = useSetQueue('artist', songs)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -55,16 +55,17 @@ const TopSongs = React.memo<{
|
||||
<ListItem
|
||||
key={i}
|
||||
item={s}
|
||||
contextId={artistId}
|
||||
contextId={contextId}
|
||||
queueId={i}
|
||||
showArt={true}
|
||||
subtitle={s.album}
|
||||
onPress={() => setQueue(songs, name, 'artist', artistId, i)}
|
||||
onPress={() => setQueue({ title: name, playTrack: i })}
|
||||
disabled={!isReady}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
})
|
||||
}, equal)
|
||||
|
||||
const ArtistAlbums = React.memo<{
|
||||
albums: Album[]
|
||||
@@ -87,7 +88,7 @@ const ArtistAlbums = React.memo<{
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
})
|
||||
}, equal)
|
||||
|
||||
const ArtistViewFallback = React.memo(() => (
|
||||
<GradientBackground style={styles.fallback}>
|
||||
@@ -96,18 +97,8 @@ const ArtistViewFallback = React.memo(() => (
|
||||
))
|
||||
|
||||
const ArtistView = React.memo<{ id: string; title: string }>(({ id, title }) => {
|
||||
const artist = useStoreDeep(useCallback(store => store.library.artists[id], [id]))
|
||||
const topSongIds = useStoreDeep(useCallback(store => store.library.artistNameTopSongs[artist?.name], [artist?.name]))
|
||||
const topSongs = useStoreDeep(
|
||||
useCallback(store => (topSongIds ? mapById(store.library.songs, topSongIds) : undefined), [topSongIds]),
|
||||
)
|
||||
const albumIds = useStoreDeep(useCallback(store => store.library.artistAlbums[id], [id]))
|
||||
const albums = useStoreDeep(
|
||||
useCallback(store => (albumIds ? mapById(store.library.albums, albumIds) : undefined), [albumIds]),
|
||||
)
|
||||
|
||||
const fetchArtist = useStore(store => store.fetchArtist)
|
||||
const fetchTopSongs = useStore(store => store.fetchArtistTopSongs)
|
||||
const { data: artistData } = useQueryArtist(id)
|
||||
const { data: topSongs, isError } = useQueryArtistTopSongs(artistData?.artist?.name)
|
||||
|
||||
const coverLayout = useLayout()
|
||||
const headerOpacity = useSharedValue(0)
|
||||
@@ -124,22 +115,12 @@ const ArtistView = React.memo<{ id: string; title: string }>(({ id, title }) =>
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!artist || !albumIds) {
|
||||
fetchArtist(id)
|
||||
}
|
||||
}, [artist, albumIds, fetchArtist, id])
|
||||
|
||||
useEffect(() => {
|
||||
if (artist && !topSongIds) {
|
||||
fetchTopSongs(artist.name)
|
||||
}
|
||||
}, [artist, fetchTopSongs, topSongIds])
|
||||
|
||||
if (!artist) {
|
||||
if (!artistData) {
|
||||
return <ArtistViewFallback />
|
||||
}
|
||||
|
||||
const { artist, albums } = artistData
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<HeaderBar title={title} headerStyle={[styles.header, animatedOpacity]} />
|
||||
@@ -149,15 +130,15 @@ const ArtistView = React.memo<{ id: string; title: string }>(({ id, title }) =>
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
onScroll={onScroll}>
|
||||
<CoverArt type="artist" size="original" artistId={artist.id} style={styles.artistCover} resizeMode={'cover'} />
|
||||
<CoverArt type="artist" size="original" artistId={artist.id} style={styles.artistCover} resizeMode="cover" />
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.title}>{artist.name}</Text>
|
||||
</View>
|
||||
<View style={styles.contentContainer}>
|
||||
{topSongs && albums ? (
|
||||
topSongs.length > 0 ? (
|
||||
{(topSongs || isError) && artist ? (
|
||||
topSongs && topSongs.length > 0 ? (
|
||||
<>
|
||||
<TopSongs songs={topSongs} name={artist.name} artistId={artist.id} />
|
||||
<TopSongs songs={topSongs} name={artist.name} />
|
||||
<ArtistAlbums albums={albums} />
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -3,18 +3,17 @@ import CoverArt from '@app/components/CoverArt'
|
||||
import GradientScrollView from '@app/components/GradientScrollView'
|
||||
import Header from '@app/components/Header'
|
||||
import NothingHere from '@app/components/NothingHere'
|
||||
import { useActiveServerRefresh } from '@app/hooks/settings'
|
||||
import { useStore, useStoreDeep } from '@app/state/store'
|
||||
import { useQueryHomeLists } from '@app/hooks/query'
|
||||
import { Album } from '@app/models/library'
|
||||
import { useStoreDeep } from '@app/state/store'
|
||||
import colors from '@app/styles/colors'
|
||||
import font from '@app/styles/font'
|
||||
import { GetAlbumList2TypeBase, GetAlbumListType } from '@app/subsonic/params'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import equal from 'fast-deep-equal/es6/react'
|
||||
import produce from 'immer'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import React from 'react'
|
||||
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import create, { StateSelector } from 'zustand'
|
||||
|
||||
const titles: { [key in GetAlbumListType]?: string } = {
|
||||
recent: 'Recently Played',
|
||||
@@ -24,25 +23,21 @@ const titles: { [key in GetAlbumListType]?: string } = {
|
||||
}
|
||||
|
||||
const AlbumItem = React.memo<{
|
||||
id: string
|
||||
}>(({ id }) => {
|
||||
album: Album
|
||||
}>(({ album }) => {
|
||||
const navigation = useNavigation()
|
||||
const album = useStoreDeep(useCallback(store => store.library.albums[id], [id]))
|
||||
|
||||
if (!album) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<AlbumContextPressable
|
||||
album={album}
|
||||
triggerWrapperStyle={styles.item}
|
||||
onPress={() => navigation.navigate('album', { id: album.id, title: album.name })}>
|
||||
onPress={() => navigation.navigate('album', { id: album.id, title: album.name, album })}>
|
||||
<CoverArt
|
||||
type="cover"
|
||||
coverArt={album.coverArt}
|
||||
style={{ height: styles.item.width, width: styles.item.width }}
|
||||
resizeMode={'cover'}
|
||||
resizeMode="cover"
|
||||
size="thumbnail"
|
||||
/>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{album.name}
|
||||
@@ -52,13 +47,12 @@ const AlbumItem = React.memo<{
|
||||
</Text>
|
||||
</AlbumContextPressable>
|
||||
)
|
||||
})
|
||||
}, equal)
|
||||
|
||||
const Category = React.memo<{
|
||||
type: string
|
||||
}>(({ type }) => {
|
||||
const list = useHomeStoreDeep(useCallback(store => store.lists[type] || [], [type]))
|
||||
|
||||
albums: Album[]
|
||||
}>(({ type, albums }) => {
|
||||
const Albums = () => (
|
||||
<ScrollView
|
||||
horizontal={true}
|
||||
@@ -66,8 +60,8 @@ const Category = React.memo<{
|
||||
overScrollMode={'never'}
|
||||
style={styles.artScroll}
|
||||
contentContainerStyle={styles.artScrollContent}>
|
||||
{list.map(id => (
|
||||
<AlbumItem key={id} id={id} />
|
||||
{albums.map(a => (
|
||||
<AlbumItem key={a.id} album={a} />
|
||||
))}
|
||||
</ScrollView>
|
||||
)
|
||||
@@ -81,75 +75,33 @@ const Category = React.memo<{
|
||||
return (
|
||||
<View style={styles.category}>
|
||||
<Header style={styles.header}>{titles[type as GetAlbumListType] || ''}</Header>
|
||||
{list.length > 0 ? <Albums /> : <Nothing />}
|
||||
{albums.length > 0 ? <Albums /> : <Nothing />}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
interface HomeState {
|
||||
lists: { [type: string]: string[] }
|
||||
setList: (type: string, list: string[]) => void
|
||||
}
|
||||
|
||||
const useHomeStore = create<HomeState>(set => ({
|
||||
lists: {},
|
||||
|
||||
setList: (type, list) => {
|
||||
set(
|
||||
produce<HomeState>(state => {
|
||||
state.lists[type] = list
|
||||
}),
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
function useHomeStoreDeep<U>(stateSelector: StateSelector<HomeState, U>) {
|
||||
return useHomeStore(stateSelector, equal)
|
||||
}
|
||||
}, equal)
|
||||
|
||||
const Home = () => {
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const types = useStoreDeep(store => store.settings.screens.home.listTypes)
|
||||
const fetchAlbumList = useStore(store => store.fetchAlbumList)
|
||||
const setList = useHomeStore(store => store.setList)
|
||||
const listQueries = useQueryHomeLists(types as GetAlbumList2TypeBase[], 20)
|
||||
const paddingTop = useSafeAreaInsets().top
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
await Promise.all(
|
||||
types.map(async type => {
|
||||
const ids = await fetchAlbumList({ type: type as GetAlbumList2TypeBase, size: 20, offset: 0 })
|
||||
setList(type, ids)
|
||||
}),
|
||||
)
|
||||
|
||||
setRefreshing(false)
|
||||
}, [fetchAlbumList, setList, types])
|
||||
|
||||
useActiveServerRefresh(
|
||||
useCallback(() => {
|
||||
types.forEach(type => setList(type, []))
|
||||
refresh()
|
||||
}, [refresh, setList, types]),
|
||||
)
|
||||
|
||||
return (
|
||||
<GradientScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={{ paddingTop }}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={refresh}
|
||||
refreshing={listQueries.some(q => q.isLoading)}
|
||||
onRefresh={() => listQueries.forEach(q => q.refetch())}
|
||||
colors={[colors.accent, colors.accentLow]}
|
||||
progressViewOffset={paddingTop}
|
||||
/>
|
||||
}>
|
||||
<View style={styles.content}>
|
||||
{types.map(type => (
|
||||
<Category key={type} type={type} />
|
||||
))}
|
||||
{types.map(type => {
|
||||
const query = listQueries.find(list => list.data?.type === type)
|
||||
return <Category key={type} type={type} albums={query?.data?.albums || []} />
|
||||
})}
|
||||
</View>
|
||||
</GradientScrollView>
|
||||
)
|
||||
|
||||
@@ -2,21 +2,22 @@ import { AlbumContextPressable } from '@app/components/ContextMenu'
|
||||
import CoverArt from '@app/components/CoverArt'
|
||||
import FilterButton, { OptionData } from '@app/components/FilterButton'
|
||||
import GradientFlatList from '@app/components/GradientFlatList'
|
||||
import { useFetchPaginatedList } from '@app/hooks/list'
|
||||
import { useQueryAlbumList } from '@app/hooks/query'
|
||||
import { Album } from '@app/models/library'
|
||||
import { useStore, useStoreDeep } from '@app/state/store'
|
||||
import colors from '@app/styles/colors'
|
||||
import font from '@app/styles/font'
|
||||
import { GetAlbumList2Params, GetAlbumList2Type } from '@app/subsonic/params'
|
||||
import { GetAlbumList2Type, GetAlbumList2TypeBase } from '@app/subsonic/params'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import React, { useCallback } from 'react'
|
||||
import equal from 'fast-deep-equal/es6/react'
|
||||
import React from 'react'
|
||||
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native'
|
||||
|
||||
const AlbumItem = React.memo<{
|
||||
id: string
|
||||
album: Album
|
||||
size: number
|
||||
height: number
|
||||
}>(({ id, size, height }) => {
|
||||
const album = useStoreDeep(useCallback(store => store.library.albums[id], [id]))
|
||||
}>(({ album, size, height }) => {
|
||||
const navigation = useNavigation()
|
||||
|
||||
if (!album) {
|
||||
@@ -28,8 +29,14 @@ const AlbumItem = React.memo<{
|
||||
album={album}
|
||||
menuStyle={[styles.itemMenu, { width: size }]}
|
||||
triggerWrapperStyle={[styles.itemWrapper, { height }]}
|
||||
onPress={() => navigation.navigate('album', { id: album.id, title: album.name })}>
|
||||
<CoverArt type="cover" coverArt={album.coverArt} style={{ height: size, width: size }} resizeMode={'cover'} />
|
||||
onPress={() => navigation.navigate('album', { id: album.id, title: album.name, album })}>
|
||||
<CoverArt
|
||||
type="cover"
|
||||
coverArt={album.coverArt}
|
||||
style={{ height: size, width: size }}
|
||||
resizeMode="cover"
|
||||
size="thumbnail"
|
||||
/>
|
||||
<View style={styles.itemDetails}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{album.name}
|
||||
@@ -40,11 +47,11 @@ const AlbumItem = React.memo<{
|
||||
</View>
|
||||
</AlbumContextPressable>
|
||||
)
|
||||
})
|
||||
}, equal)
|
||||
|
||||
const AlbumListRenderItem: React.FC<{
|
||||
item: { id: string; size: number; height: number }
|
||||
}> = ({ item }) => <AlbumItem id={item.id} size={item.size} height={item.height} />
|
||||
item: { album: Album; size: number; height: number }
|
||||
}> = ({ item }) => <AlbumItem album={item.album} size={item.size} height={item.height} />
|
||||
|
||||
const filterOptions: OptionData[] = [
|
||||
{ text: 'By Name', value: 'alphabeticalByName' },
|
||||
@@ -62,42 +69,7 @@ const AlbumsList = () => {
|
||||
const filter = useStoreDeep(store => store.settings.screens.library.albumsFilter)
|
||||
const setFilter = useStore(store => store.setLibraryAlbumFilter)
|
||||
|
||||
const fetchAlbumList = useStore(store => store.fetchAlbumList)
|
||||
const fetchPage = useCallback(
|
||||
(size: number, offset: number) => {
|
||||
let params: GetAlbumList2Params
|
||||
switch (filter.type) {
|
||||
case 'byYear':
|
||||
params = {
|
||||
size,
|
||||
offset,
|
||||
type: filter.type,
|
||||
fromYear: filter.fromYear,
|
||||
toYear: filter.toYear,
|
||||
}
|
||||
break
|
||||
case 'byGenre':
|
||||
params = {
|
||||
size,
|
||||
offset,
|
||||
type: filter.type,
|
||||
genre: filter.genre,
|
||||
}
|
||||
break
|
||||
default:
|
||||
params = {
|
||||
size,
|
||||
offset,
|
||||
type: filter.type,
|
||||
}
|
||||
break
|
||||
}
|
||||
return fetchAlbumList(params)
|
||||
},
|
||||
[fetchAlbumList, filter.fromYear, filter.genre, filter.toYear, filter.type],
|
||||
)
|
||||
|
||||
const { list, refreshing, refresh, fetchNextPage } = useFetchPaginatedList(fetchPage, 300)
|
||||
const { isLoading, data, fetchNextPage, refetch } = useQueryAlbumList(filter.type as GetAlbumList2TypeBase, 300)
|
||||
|
||||
const layout = useWindowDimensions()
|
||||
|
||||
@@ -107,15 +79,15 @@ const AlbumsList = () => {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<GradientFlatList
|
||||
data={list.map(id => ({ id, size, height }))}
|
||||
data={data ? data.pages.flatMap(albums => albums.map(album => ({ album, size, height }))) : []}
|
||||
renderItem={AlbumListRenderItem}
|
||||
keyExtractor={item => item.id}
|
||||
keyExtractor={item => item.album.id}
|
||||
numColumns={3}
|
||||
removeClippedSubviews={true}
|
||||
refreshing={refreshing}
|
||||
onRefresh={refresh}
|
||||
refreshing={isLoading}
|
||||
onRefresh={refetch}
|
||||
overScrollMode="never"
|
||||
onEndReached={fetchNextPage}
|
||||
onEndReached={() => fetchNextPage()}
|
||||
onEndReachedThreshold={6}
|
||||
windowSize={5}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import FilterButton, { OptionData } from '@app/components/FilterButton'
|
||||
import GradientFlatList from '@app/components/GradientFlatList'
|
||||
import ListItem from '@app/components/ListItem'
|
||||
import { useFetchList2 } from '@app/hooks/list'
|
||||
import { useQueryArtists } from '@app/hooks/query'
|
||||
import { Artist } from '@app/models/library'
|
||||
import { ArtistFilterType } from '@app/models/settings'
|
||||
import { useStore, useStoreDeep } from '@app/state/store'
|
||||
@@ -19,17 +19,19 @@ const filterOptions: OptionData[] = [
|
||||
]
|
||||
|
||||
const ArtistsList = () => {
|
||||
const fetchArtists = useStore(store => store.fetchArtists)
|
||||
const { refreshing, refresh } = useFetchList2(fetchArtists)
|
||||
const artists = useStoreDeep(store => store.library.artists)
|
||||
const artistOrder = useStoreDeep(store => store.library.artistOrder)
|
||||
|
||||
const filter = useStoreDeep(store => store.settings.screens.library.artistsFilter)
|
||||
const setFilter = useStore(store => store.setLibraryArtistFiler)
|
||||
|
||||
const { isLoading, data, refetch } = useQueryArtists()
|
||||
const [sortedList, setSortedList] = useState<Artist[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const list = Object.values(artists)
|
||||
if (!data) {
|
||||
setSortedList([])
|
||||
return
|
||||
}
|
||||
|
||||
const list = Object.values(data.byId)
|
||||
switch (filter.type) {
|
||||
case 'random':
|
||||
setSortedList([...list].sort(() => Math.random() - 0.5))
|
||||
@@ -38,13 +40,13 @@ const ArtistsList = () => {
|
||||
setSortedList([...list].filter(a => a.starred))
|
||||
break
|
||||
case 'alphabeticalByName':
|
||||
setSortedList(artistOrder.map(id => artists[id]))
|
||||
setSortedList(data.allIds.map(id => data.byId[id]))
|
||||
break
|
||||
default:
|
||||
setSortedList([...list])
|
||||
break
|
||||
}
|
||||
}, [filter.type, artists, artistOrder])
|
||||
}, [filter.type, data])
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -52,8 +54,8 @@ const ArtistsList = () => {
|
||||
data={sortedList}
|
||||
renderItem={ArtistRenderItem}
|
||||
keyExtractor={item => item.id}
|
||||
onRefresh={refresh}
|
||||
refreshing={refreshing}
|
||||
onRefresh={refetch}
|
||||
refreshing={isLoading}
|
||||
overScrollMode="never"
|
||||
windowSize={3}
|
||||
contentMarginTop={6}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import GradientFlatList from '@app/components/GradientFlatList'
|
||||
import ListItem from '@app/components/ListItem'
|
||||
import { useFetchList2 } from '@app/hooks/list'
|
||||
import { useQueryPlaylists } from '@app/hooks/query'
|
||||
import { Playlist } from '@app/models/library'
|
||||
import { useStore, useStoreDeep } from '@app/state/store'
|
||||
import { mapById } from '@app/util/state'
|
||||
import React from 'react'
|
||||
import { StyleSheet } from 'react-native'
|
||||
|
||||
@@ -11,17 +11,15 @@ const PlaylistRenderItem: React.FC<{ item: Playlist }> = ({ item }) => (
|
||||
)
|
||||
|
||||
const PlaylistsList = () => {
|
||||
const fetchPlaylists = useStore(store => store.fetchPlaylists)
|
||||
const { refreshing, refresh } = useFetchList2(fetchPlaylists)
|
||||
const playlists = useStoreDeep(store => store.library.playlists)
|
||||
const { isLoading, data, refetch } = useQueryPlaylists()
|
||||
|
||||
return (
|
||||
<GradientFlatList
|
||||
data={Object.values(playlists)}
|
||||
data={data ? mapById(data?.byId, data?.allIds) : []}
|
||||
renderItem={PlaylistRenderItem}
|
||||
keyExtractor={item => item.id}
|
||||
onRefresh={refresh}
|
||||
refreshing={refreshing}
|
||||
onRefresh={refetch}
|
||||
refreshing={isLoading}
|
||||
overScrollMode="never"
|
||||
windowSize={5}
|
||||
contentMarginTop={6}
|
||||
|
||||
@@ -3,7 +3,8 @@ import ListItem from '@app/components/ListItem'
|
||||
import NowPlayingBar from '@app/components/NowPlayingBar'
|
||||
import { useSkipTo } from '@app/hooks/trackplayer'
|
||||
import { Song } from '@app/models/library'
|
||||
import { useStore, useStoreDeep } from '@app/state/store'
|
||||
import { mapTrackExtToSong } from '@app/models/map'
|
||||
import { useStoreDeep } from '@app/state/store'
|
||||
import React from 'react'
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
|
||||
@@ -26,7 +27,6 @@ const SongRenderItem: React.FC<{
|
||||
|
||||
const NowPlayingQueue = React.memo<{}>(() => {
|
||||
const queue = useStoreDeep(store => store.queue)
|
||||
const mapTrackExtToSong = useStore(store => store.mapTrackExtToSong)
|
||||
const skipTo = useSkipTo()
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ import ImageGradientBackground from '@app/components/ImageGradientBackground'
|
||||
import PressableOpacity from '@app/components/PressableOpacity'
|
||||
import { PressableStar } from '@app/components/Star'
|
||||
import { useNext, usePause, usePlay, usePrevious, useSeekTo } from '@app/hooks/trackplayer'
|
||||
import { mapTrackExtToSong } from '@app/models/map'
|
||||
import { QueueContextType, TrackExt } from '@app/models/trackplayer'
|
||||
import { useStore, useStoreDeep } from '@app/state/store'
|
||||
import colors from '@app/styles/colors'
|
||||
@@ -40,7 +41,6 @@ const NowPlayingHeader = React.memo<{
|
||||
}>(({ track }) => {
|
||||
const queueName = useStore(store => store.queueName)
|
||||
const queueContextType = useStore(store => store.queueContextType)
|
||||
const mapTrackExtToSong = useStore(store => store.mapTrackExtToSong)
|
||||
|
||||
if (!track) {
|
||||
return <></>
|
||||
|
||||
@@ -4,14 +4,14 @@ import Header from '@app/components/Header'
|
||||
import ListItem from '@app/components/ListItem'
|
||||
import NothingHere from '@app/components/NothingHere'
|
||||
import TextInput from '@app/components/TextInput'
|
||||
import { useActiveServerRefresh } from '@app/hooks/settings'
|
||||
import { useQuerySearchResults } from '@app/hooks/query'
|
||||
import { useSetQueue } from '@app/hooks/trackplayer'
|
||||
import { Album, Artist, SearchResults, Song } from '@app/models/library'
|
||||
import { useStore, useStoreDeep } from '@app/state/store'
|
||||
import colors from '@app/styles/colors'
|
||||
import font from '@app/styles/font'
|
||||
import { mapById } from '@app/util/state'
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native'
|
||||
import debounce from 'lodash.debounce'
|
||||
import equal from 'fast-deep-equal/es6/react'
|
||||
import _ from 'lodash'
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -24,44 +24,27 @@ import {
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
|
||||
const SongItem = React.memo<{ item: Song }>(({ item }) => {
|
||||
const setQueue = useStore(store => store.setQueue)
|
||||
const { setQueue, isReady, contextId } = useSetQueue('song', [item])
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
item={item}
|
||||
contextId={item.id}
|
||||
contextId={contextId}
|
||||
queueId={0}
|
||||
showArt={true}
|
||||
showStar={false}
|
||||
onPress={() => setQueue([item], item.title, 'song', item.id, 0)}
|
||||
onPress={() => setQueue({ title: item.title, playTrack: 0 })}
|
||||
disabled={!isReady}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}, equal)
|
||||
|
||||
const ResultsCategory = React.memo<{
|
||||
name: string
|
||||
query: string
|
||||
ids: string[]
|
||||
items: (Artist | Album | Song)[]
|
||||
type: 'artist' | 'album' | 'song'
|
||||
}>(({ name, query, type, ids }) => {
|
||||
const items: (Album | Artist | Song)[] = useStoreDeep(
|
||||
useCallback(
|
||||
store => {
|
||||
switch (type) {
|
||||
case 'album':
|
||||
return mapById(store.library.albums, ids)
|
||||
case 'artist':
|
||||
return mapById(store.library.artists, ids)
|
||||
case 'song':
|
||||
return mapById(store.library.songs, ids)
|
||||
default:
|
||||
return []
|
||||
}
|
||||
},
|
||||
[ids, type],
|
||||
),
|
||||
)
|
||||
|
||||
}>(({ name, query, type, items }) => {
|
||||
const navigation = useNavigation()
|
||||
|
||||
if (items.length === 0) {
|
||||
@@ -88,7 +71,7 @@ const ResultsCategory = React.memo<{
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
}, equal)
|
||||
|
||||
const Results = React.memo<{
|
||||
results: SearchResults
|
||||
@@ -96,17 +79,17 @@ const Results = React.memo<{
|
||||
}>(({ results, query }) => {
|
||||
return (
|
||||
<>
|
||||
<ResultsCategory name="Artists" query={query} type={'artist'} ids={results.artists} />
|
||||
<ResultsCategory name="Albums" query={query} type={'album'} ids={results.albums} />
|
||||
<ResultsCategory name="Songs" query={query} type={'song'} ids={results.songs} />
|
||||
<ResultsCategory name="Artists" query={query} type={'artist'} items={results.artists} />
|
||||
<ResultsCategory name="Albums" query={query} type={'album'} items={results.albums} />
|
||||
<ResultsCategory name="Songs" query={query} type={'song'} items={results.songs} />
|
||||
</>
|
||||
)
|
||||
})
|
||||
}, equal)
|
||||
|
||||
const Search = () => {
|
||||
const fetchSearchResults = useStore(store => store.fetchSearchResults)
|
||||
const [results, setResults] = useState<SearchResults>({ artists: [], albums: [], songs: [] })
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const { data, isLoading } = useQuerySearchResults({ query, albumCount: 5, artistCount: 5, songCount: 5 })
|
||||
|
||||
const [text, setText] = useState('')
|
||||
const searchBarRef = useRef<ReactTextInput>(null)
|
||||
const scrollRef = useRef<ScrollView>(null)
|
||||
@@ -116,42 +99,39 @@ const Search = () => {
|
||||
useCallback(() => {
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
setTimeout(() => {
|
||||
if (text) {
|
||||
return
|
||||
}
|
||||
setText('')
|
||||
setResults({ artists: [], albums: [], songs: [] })
|
||||
setQuery('')
|
||||
searchBarRef.current?.focus()
|
||||
scrollRef.current?.scrollTo({ y: 0, animated: true })
|
||||
}, 50)
|
||||
})
|
||||
return () => task.cancel()
|
||||
}, [searchBarRef, scrollRef]),
|
||||
}, [text]),
|
||||
)
|
||||
|
||||
useActiveServerRefresh(
|
||||
useCallback(() => {
|
||||
setText('')
|
||||
setResults({ artists: [], albums: [], songs: [] })
|
||||
}, []),
|
||||
)
|
||||
|
||||
const debouncedonUpdateSearch = useMemo(
|
||||
const debouncedSetQuery = useMemo(
|
||||
() =>
|
||||
debounce(async (query: string) => {
|
||||
setRefreshing(true)
|
||||
setResults(await fetchSearchResults({ query, albumCount: 5, artistCount: 5, songCount: 5 }))
|
||||
setRefreshing(false)
|
||||
_.debounce((value: string) => {
|
||||
setQuery(value)
|
||||
}, 400),
|
||||
[fetchSearchResults],
|
||||
[],
|
||||
)
|
||||
|
||||
const onChangeText = useCallback(
|
||||
(value: string) => {
|
||||
setText(value)
|
||||
debouncedonUpdateSearch(value)
|
||||
debouncedSetQuery(value)
|
||||
},
|
||||
[setText, debouncedonUpdateSearch],
|
||||
[setText, debouncedSetQuery],
|
||||
)
|
||||
|
||||
const resultsCount = results.albums.length + results.artists.length + results.songs.length
|
||||
const resultsCount =
|
||||
(data ? data.pages.reduce((acc, val) => (acc += val.artists.length), 0) : 0) +
|
||||
(data ? data.pages.reduce((acc, val) => (acc += val.albums.length), 0) : 0) +
|
||||
(data ? data.pages.reduce((acc, val) => (acc += val.songs.length), 0) : 0)
|
||||
|
||||
return (
|
||||
<GradientScrollView ref={scrollRef} style={styles.scroll} contentContainerStyle={{ paddingTop }}>
|
||||
@@ -164,14 +144,13 @@ const Search = () => {
|
||||
value={text}
|
||||
onChangeText={onChangeText}
|
||||
/>
|
||||
<ActivityIndicator
|
||||
animating={refreshing}
|
||||
size="small"
|
||||
color={colors.text.secondary}
|
||||
style={styles.activity}
|
||||
/>
|
||||
<ActivityIndicator animating={isLoading} size="small" color={colors.text.secondary} style={styles.activity} />
|
||||
</View>
|
||||
{resultsCount > 0 ? <Results results={results} query={text} /> : <NothingHere style={styles.noResults} />}
|
||||
{data !== undefined && resultsCount > 0 ? (
|
||||
<Results results={data.pages[0]} query={text} />
|
||||
) : (
|
||||
<NothingHere style={styles.noResults} />
|
||||
)}
|
||||
</View>
|
||||
</GradientScrollView>
|
||||
)
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import GradientFlatList from '@app/components/GradientFlatList'
|
||||
import ListItem from '@app/components/ListItem'
|
||||
import { useFetchPaginatedList } from '@app/hooks/list'
|
||||
import { useQuerySearchResults } from '@app/hooks/query'
|
||||
import { useSetQueue } from '@app/hooks/trackplayer'
|
||||
import { Album, Artist, Song } from '@app/models/library'
|
||||
import { useStore, useStoreDeep } from '@app/state/store'
|
||||
import { Search3Params } from '@app/subsonic/params'
|
||||
import { mapById } from '@app/util/state'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import { StyleSheet } from 'react-native'
|
||||
|
||||
type SearchListItemType = Album | Song | Artist
|
||||
|
||||
const ResultsListItem: React.FC<{ item: SearchListItemType }> = ({ item }) => {
|
||||
const setQueue = useStore(store => store.setQueue)
|
||||
const SongResultsListItem: React.FC<{ item: Song }> = ({ item }) => {
|
||||
const { setQueue, isReady, contextId } = useSetQueue('song', [item])
|
||||
|
||||
let onPress
|
||||
if (item.itemType === 'song') {
|
||||
onPress = () => setQueue([item], item.title, 'song', item.id, 0)
|
||||
}
|
||||
return (
|
||||
<ListItem
|
||||
item={item}
|
||||
contextId={contextId}
|
||||
queueId={0}
|
||||
showArt={true}
|
||||
showStar={false}
|
||||
listStyle="small"
|
||||
onPress={() => setQueue({ title: item.title, playTrack: 0 })}
|
||||
style={styles.listItem}
|
||||
disabled={!isReady}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const OtherResultsListItem: React.FC<{ item: SearchListItemType }> = ({ item }) => {
|
||||
return (
|
||||
<ListItem
|
||||
item={item}
|
||||
@@ -27,12 +37,19 @@ const ResultsListItem: React.FC<{ item: SearchListItemType }> = ({ item }) => {
|
||||
showArt={true}
|
||||
showStar={false}
|
||||
listStyle="small"
|
||||
onPress={onPress}
|
||||
style={styles.listItem}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ResultsListItem: React.FC<{ item: SearchListItemType }> = ({ item }) => {
|
||||
if (item.itemType === 'song') {
|
||||
return <SongResultsListItem item={item} />
|
||||
} else {
|
||||
return <OtherResultsListItem item={item} />
|
||||
}
|
||||
}
|
||||
|
||||
const SearchResultsRenderItem: React.FC<{ item: SearchListItemType }> = ({ item }) => <ResultsListItem item={item} />
|
||||
|
||||
const SearchResultsView: React.FC<{
|
||||
@@ -40,61 +57,28 @@ const SearchResultsView: React.FC<{
|
||||
type: 'album' | 'artist' | 'song'
|
||||
}> = ({ query, type }) => {
|
||||
const navigation = useNavigation()
|
||||
const fetchSearchResults = useStore(store => store.fetchSearchResults)
|
||||
const { list, refreshing, refresh, fetchNextPage } = useFetchPaginatedList(
|
||||
useCallback(
|
||||
async (size, offset) => {
|
||||
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 results = await fetchSearchResults(params)
|
||||
const size = 100
|
||||
const params: Search3Params = { query }
|
||||
|
||||
switch (type) {
|
||||
case 'album':
|
||||
return results.albums
|
||||
case 'artist':
|
||||
return results.artists
|
||||
case 'song':
|
||||
return results.songs
|
||||
default:
|
||||
return []
|
||||
}
|
||||
},
|
||||
[fetchSearchResults, query, type],
|
||||
),
|
||||
100,
|
||||
)
|
||||
if (type === 'album') {
|
||||
params.albumCount = size
|
||||
} else if (type === 'artist') {
|
||||
params.artistCount = size
|
||||
} else {
|
||||
params.songCount = size
|
||||
}
|
||||
|
||||
const items: SearchListItemType[] = useStoreDeep(
|
||||
useCallback(
|
||||
store => {
|
||||
switch (type) {
|
||||
case 'album':
|
||||
return mapById(store.library.albums, list)
|
||||
case 'artist':
|
||||
return mapById(store.library.artists, list)
|
||||
case 'song':
|
||||
return mapById(store.library.songs, list)
|
||||
default:
|
||||
return []
|
||||
}
|
||||
},
|
||||
[list, type],
|
||||
),
|
||||
)
|
||||
const { data, isLoading, refetch, fetchNextPage } = useQuerySearchResults(params)
|
||||
|
||||
const items: (Artist | Album | Song)[] = []
|
||||
if (type === 'album') {
|
||||
data && items.push(...data.pages.flatMap(p => p.albums))
|
||||
} else if (type === 'artist') {
|
||||
data && items.push(...data.pages.flatMap(p => p.artists))
|
||||
} else {
|
||||
data && items.push(...data.pages.flatMap(p => p.songs))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
@@ -108,10 +92,10 @@ const SearchResultsView: React.FC<{
|
||||
data={items}
|
||||
renderItem={SearchResultsRenderItem}
|
||||
keyExtractor={(item, i) => i.toString()}
|
||||
onRefresh={refresh}
|
||||
refreshing={refreshing}
|
||||
onRefresh={refetch}
|
||||
refreshing={isLoading}
|
||||
overScrollMode="never"
|
||||
onEndReached={fetchNextPage}
|
||||
onEndReached={() => fetchNextPage}
|
||||
removeClippedSubviews={true}
|
||||
onEndReachedThreshold={2}
|
||||
contentMarginTop={6}
|
||||
|
||||
@@ -35,8 +35,6 @@ const ServerView: React.FC<{
|
||||
)
|
||||
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [removing, setRemoving] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const validate = useCallback(() => {
|
||||
return !!address && !!username && !!password
|
||||
@@ -57,7 +55,7 @@ const ServerView: React.FC<{
|
||||
const createServer = useCallback<() => Server>(() => {
|
||||
if (usePlainPassword) {
|
||||
return {
|
||||
id: server?.id || (uuid.v4() as string),
|
||||
id: server?.id || '',
|
||||
usePlainPassword,
|
||||
plainPassword: password,
|
||||
address,
|
||||
@@ -77,7 +75,7 @@ const ServerView: React.FC<{
|
||||
}
|
||||
|
||||
return {
|
||||
id: server?.id || (uuid.v4() as string),
|
||||
id: server?.id || '',
|
||||
address,
|
||||
username,
|
||||
usePlainPassword,
|
||||
@@ -91,22 +89,15 @@ const ServerView: React.FC<{
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
const update = createServer()
|
||||
|
||||
const waitForSave = async () => {
|
||||
try {
|
||||
if (id) {
|
||||
updateServer(update)
|
||||
} else {
|
||||
await addServer(update)
|
||||
}
|
||||
exit()
|
||||
} catch (err) {
|
||||
setSaving(false)
|
||||
}
|
||||
if (id) {
|
||||
updateServer(update)
|
||||
} else {
|
||||
addServer(update)
|
||||
}
|
||||
waitForSave()
|
||||
|
||||
exit()
|
||||
}, [addServer, createServer, exit, id, updateServer, validate])
|
||||
|
||||
const remove = useCallback(() => {
|
||||
@@ -114,16 +105,8 @@ const ServerView: React.FC<{
|
||||
return
|
||||
}
|
||||
|
||||
setRemoving(true)
|
||||
const waitForRemove = async () => {
|
||||
try {
|
||||
await removeServer(id as string)
|
||||
exit()
|
||||
} catch (err) {
|
||||
setRemoving(false)
|
||||
}
|
||||
}
|
||||
waitForRemove()
|
||||
removeServer(id as string)
|
||||
exit()
|
||||
}, [canRemove, exit, id, removeServer])
|
||||
|
||||
const togglePlainPassword = useCallback(
|
||||
@@ -162,8 +145,8 @@ const ServerView: React.FC<{
|
||||
}, [createServer, pingServer])
|
||||
|
||||
const disableControls = useCallback(() => {
|
||||
return !validate() || testing || removing || saving
|
||||
}, [validate, testing, removing, saving])
|
||||
return !validate() || testing
|
||||
}, [validate, testing])
|
||||
|
||||
const formatAddress = useCallback(() => {
|
||||
let addressFormatted = address.trim()
|
||||
|
||||
@@ -5,7 +5,7 @@ import PressableOpacity from '@app/components/PressableOpacity'
|
||||
import SettingsItem from '@app/components/SettingsItem'
|
||||
import SettingsSwitch from '@app/components/SettingsSwitch'
|
||||
import TextInput from '@app/components/TextInput'
|
||||
import { useSwitchActiveServer } from '@app/hooks/settings'
|
||||
import { useSwitchActiveServer, useResetImageCache } from '@app/hooks/settings'
|
||||
import { Server } from '@app/models/settings'
|
||||
import { useStore, useStoreDeep } from '@app/state/store'
|
||||
import colors from '@app/styles/colors'
|
||||
@@ -207,25 +207,16 @@ const SettingsContent = React.memo(() => {
|
||||
const maxBuffer = useStore(store => store.settings.maxBuffer)
|
||||
const setMaxBuffer = useStore(store => store.setMaxBuffer)
|
||||
|
||||
const clearImageCache = useStore(store => store.clearImageCache)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const resetImageCache = useResetImageCache()
|
||||
|
||||
const navigation = useNavigation()
|
||||
|
||||
const clear = useCallback(() => {
|
||||
const clear = useCallback(async () => {
|
||||
setClearing(true)
|
||||
|
||||
const waitForClear = async () => {
|
||||
try {
|
||||
await clearImageCache()
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
} finally {
|
||||
setClearing(false)
|
||||
}
|
||||
}
|
||||
waitForClear()
|
||||
}, [clearImageCache])
|
||||
await resetImageCache()
|
||||
setClearing(false)
|
||||
}, [resetImageCache])
|
||||
|
||||
const setMinBufferText = useCallback((text: string) => setMinBuffer(parseFloat(text)), [setMinBuffer])
|
||||
const setMaxBufferText = useCallback((text: string) => setMaxBuffer(parseFloat(text)), [setMaxBuffer])
|
||||
|
||||
@@ -5,12 +5,13 @@ import ImageGradientFlatList from '@app/components/ImageGradientFlatList'
|
||||
import ListItem from '@app/components/ListItem'
|
||||
import ListPlayerControls from '@app/components/ListPlayerControls'
|
||||
import NothingHere from '@app/components/NothingHere'
|
||||
import { useCoverArtFile } from '@app/hooks/cache'
|
||||
import { Song, Album, Playlist } from '@app/models/library'
|
||||
import { useStore, useStoreDeep } from '@app/state/store'
|
||||
import { useQueryAlbum, useQueryCoverArtPath, useQueryPlaylist } from '@app/hooks/query'
|
||||
import { useSetQueue } from '@app/hooks/trackplayer'
|
||||
import { Album, Playlist, Song } from '@app/models/library'
|
||||
import colors from '@app/styles/colors'
|
||||
import font from '@app/styles/font'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import equal from 'fast-deep-equal/es6/react'
|
||||
import React, { useState } from 'react'
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
|
||||
|
||||
type SongListType = 'album' | 'playlist'
|
||||
@@ -29,6 +30,7 @@ const SongRenderItem: React.FC<{
|
||||
subtitle?: string
|
||||
onPress?: () => void
|
||||
showArt?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
}> = ({ item }) => (
|
||||
<ListItem
|
||||
@@ -39,6 +41,7 @@ const SongRenderItem: React.FC<{
|
||||
onPress={item.onPress}
|
||||
showArt={item.showArt}
|
||||
style={styles.listItem}
|
||||
disabled={item.disabled}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -49,13 +52,8 @@ const SongListDetails = React.memo<{
|
||||
songs?: Song[]
|
||||
subtitle?: string
|
||||
}>(({ title, songList, songs, subtitle, type }) => {
|
||||
const coverArtFile = useCoverArtFile(songList?.coverArt, 'thumbnail')
|
||||
const { data: coverArtPath } = useQueryCoverArtPath(songList?.coverArt, 'thumbnail')
|
||||
const [headerColor, setHeaderColor] = useState<string | undefined>(undefined)
|
||||
const setQueue = useStore(store => store.setQueue)
|
||||
|
||||
if (!songList) {
|
||||
return <SongListDetailsFallback />
|
||||
}
|
||||
|
||||
const _songs = [...(songs || [])]
|
||||
let typeName = ''
|
||||
@@ -75,6 +73,16 @@ const SongListDetails = React.memo<{
|
||||
typeName = 'Playlist'
|
||||
}
|
||||
|
||||
const { setQueue, isReady, contextId } = useSetQueue(type, _songs)
|
||||
|
||||
if (!songList) {
|
||||
return <SongListDetailsFallback />
|
||||
}
|
||||
|
||||
const disabled = !isReady || _songs.length === 0
|
||||
const play = (track?: number, shuffle?: boolean) => () =>
|
||||
setQueue({ title: songList.name, playTrack: track, shuffle })
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<HeaderBar
|
||||
@@ -85,16 +93,17 @@ const SongListDetails = React.memo<{
|
||||
<ImageGradientFlatList
|
||||
data={_songs.map((s, i) => ({
|
||||
song: s,
|
||||
contextId: songList.id,
|
||||
contextId,
|
||||
queueId: i,
|
||||
subtitle: s.artist,
|
||||
onPress: () => setQueue(_songs, songList.name, type, songList.id, i),
|
||||
onPress: play(i),
|
||||
showArt: songList.itemType === 'playlist',
|
||||
disabled: disabled,
|
||||
}))}
|
||||
renderItem={SongRenderItem}
|
||||
keyExtractor={(item, i) => i.toString()}
|
||||
backgroundProps={{
|
||||
imagePath: coverArtFile?.file?.path,
|
||||
imagePath: coverArtPath,
|
||||
style: styles.container,
|
||||
onGetColor: setHeaderColor,
|
||||
}}
|
||||
@@ -117,86 +126,66 @@ const SongListDetails = React.memo<{
|
||||
style={styles.controls}
|
||||
songs={_songs}
|
||||
typeName={typeName}
|
||||
queueName={songList.name}
|
||||
queueContextId={songList.id}
|
||||
queueContextType={type}
|
||||
play={play(undefined, false)}
|
||||
shuffle={play(undefined, true)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
}, equal)
|
||||
|
||||
const PlaylistView = React.memo<{
|
||||
id: string
|
||||
title: string
|
||||
}>(({ id, title }) => {
|
||||
const playlist = useStoreDeep(useCallback(store => store.library.playlists[id], [id]))
|
||||
const songs = useStoreDeep(
|
||||
useCallback(
|
||||
store => {
|
||||
const ids = store.library.playlistSongs[id]
|
||||
return ids ? ids.map(i => store.library.songs[i]) : undefined
|
||||
},
|
||||
[id],
|
||||
),
|
||||
)
|
||||
|
||||
const fetchPlaylist = useStore(store => store.fetchPlaylist)
|
||||
|
||||
useEffect(() => {
|
||||
if (!playlist || !songs) {
|
||||
fetchPlaylist(id)
|
||||
}
|
||||
}, [playlist, fetchPlaylist, id, songs])
|
||||
|
||||
return (
|
||||
<SongListDetails title={title} songList={playlist} songs={songs} subtitle={playlist?.comment} type="playlist" />
|
||||
)
|
||||
})
|
||||
|
||||
const AlbumView = React.memo<{
|
||||
id: string
|
||||
title: string
|
||||
}>(({ id, title }) => {
|
||||
const album = useStoreDeep(useCallback(store => store.library.albums[id], [id]))
|
||||
const songs = useStoreDeep(
|
||||
useCallback(
|
||||
store => {
|
||||
const ids = store.library.albumSongs[id]
|
||||
return ids ? ids.map(i => store.library.songs[i]) : undefined
|
||||
},
|
||||
[id],
|
||||
),
|
||||
)
|
||||
|
||||
const fetchAlbum = useStore(store => store.fetchAlbum)
|
||||
|
||||
useEffect(() => {
|
||||
if (!album || !songs) {
|
||||
fetchAlbum(id)
|
||||
}
|
||||
}, [album, fetchAlbum, id, songs])
|
||||
playlist?: Playlist
|
||||
}>(({ id, title, playlist }) => {
|
||||
const query = useQueryPlaylist(id, playlist)
|
||||
|
||||
return (
|
||||
<SongListDetails
|
||||
title={title}
|
||||
songList={album}
|
||||
songs={songs}
|
||||
subtitle={(album?.artist || '') + (album?.year ? ' • ' + album?.year : '')}
|
||||
songList={query.data?.playlist}
|
||||
songs={query.data?.songs}
|
||||
subtitle={query.data?.playlist?.comment}
|
||||
type="playlist"
|
||||
/>
|
||||
)
|
||||
}, equal)
|
||||
|
||||
const AlbumView = React.memo<{
|
||||
id: string
|
||||
title: string
|
||||
album?: Album
|
||||
}>(({ id, title, album }) => {
|
||||
const query = useQueryAlbum(id, album)
|
||||
|
||||
return (
|
||||
<SongListDetails
|
||||
title={title}
|
||||
songList={query.data?.album}
|
||||
songs={query.data?.songs}
|
||||
subtitle={(query.data?.album?.artist || '') + (query.data?.album?.year ? ' • ' + query.data?.album?.year : '')}
|
||||
type="album"
|
||||
/>
|
||||
)
|
||||
})
|
||||
}, equal)
|
||||
|
||||
const SongListView = React.memo<{
|
||||
id: string
|
||||
title: string
|
||||
type: SongListType
|
||||
}>(({ id, title, type }) => {
|
||||
return type === 'album' ? <AlbumView id={id} title={title} /> : <PlaylistView id={id} title={title} />
|
||||
})
|
||||
album?: Album
|
||||
playlist?: Playlist
|
||||
}>(({ id, title, type, album, playlist }) => {
|
||||
return type === 'album' ? (
|
||||
<AlbumView id={id} title={title} album={album} />
|
||||
) : (
|
||||
<PlaylistView id={id} title={title} playlist={playlist} />
|
||||
)
|
||||
}, equal)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
import { CacheFile, CacheImageSize, CacheItemType, CacheItemTypeKey, CacheRequest } from '@app/models/cache'
|
||||
import { mkdir, rmdir } from '@app/util/fs'
|
||||
import PromiseQueue from '@app/util/PromiseQueue'
|
||||
import RNFS from 'react-native-fs'
|
||||
import { GetStore, SetStore } from './store'
|
||||
|
||||
const queues: Record<CacheItemTypeKey, PromiseQueue> = {
|
||||
coverArt: new PromiseQueue(5),
|
||||
coverArtThumb: new PromiseQueue(50),
|
||||
artistArt: new PromiseQueue(5),
|
||||
artistArtThumb: new PromiseQueue(50),
|
||||
song: new PromiseQueue(1),
|
||||
}
|
||||
|
||||
export type CacheDownload = CacheFile & CacheRequest
|
||||
|
||||
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 CacheSlice = {
|
||||
cacheItem: (
|
||||
key: CacheItemTypeKey,
|
||||
itemId: string,
|
||||
url: string | (() => string | Promise<string | undefined>),
|
||||
) => Promise<void>
|
||||
|
||||
// cache: DownloadedItemsByServer
|
||||
cacheDirs: CacheDirsByServer
|
||||
cacheFiles: CacheFilesByServer
|
||||
cacheRequests: CacheRequestsByServer
|
||||
|
||||
fetchCoverArtFilePath: (coverArt: string, size?: CacheImageSize) => Promise<string | undefined>
|
||||
|
||||
createCache: (serverId: string) => Promise<void>
|
||||
prepareCache: (serverId: string) => void
|
||||
pendingRemoval: Record<string, boolean>
|
||||
removeCache: (serverId: string) => Promise<void>
|
||||
clearImageCache: () => Promise<void>
|
||||
}
|
||||
|
||||
export const createCacheSlice = (set: SetStore, get: GetStore): CacheSlice => ({
|
||||
// cache: {},
|
||||
cacheDirs: {},
|
||||
cacheFiles: {},
|
||||
cacheRequests: {},
|
||||
|
||||
cacheItem: async (key, itemId, url) => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const activeServerId = get().settings.activeServerId
|
||||
if (!activeServerId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (get().pendingRemoval[activeServerId]) {
|
||||
return
|
||||
}
|
||||
|
||||
const inProgress = get().cacheRequests[activeServerId][key][itemId]
|
||||
if (inProgress && inProgress.promise !== undefined) {
|
||||
return await inProgress.promise
|
||||
}
|
||||
|
||||
const existing = get().cacheFiles[activeServerId][key][itemId]
|
||||
if (existing) {
|
||||
return
|
||||
}
|
||||
|
||||
const path = `${get().cacheDirs[activeServerId][key]}/${itemId}`
|
||||
|
||||
const promise = queues[key].enqueue(async () => {
|
||||
const urlResult = typeof url === 'string' ? url : url()
|
||||
const fromUrl = typeof urlResult === 'string' ? urlResult : await urlResult
|
||||
|
||||
try {
|
||||
if (!fromUrl) {
|
||||
throw new Error('cannot resolve url for cache request')
|
||||
}
|
||||
|
||||
await RNFS.downloadFile({
|
||||
fromUrl,
|
||||
toFile: path,
|
||||
// progressInterval: 100,
|
||||
// progress: res => {
|
||||
// set(
|
||||
// produce<CacheSlice>(state => {
|
||||
// state.cacheRequests[activeServerId][key][itemId].progress = Math.max(
|
||||
// 1,
|
||||
// res.bytesWritten / (res.contentLength || 1),
|
||||
// )
|
||||
// }),
|
||||
// )
|
||||
// },
|
||||
}).promise
|
||||
|
||||
set(state => {
|
||||
state.cacheRequests[activeServerId][key][itemId].progress = 1
|
||||
delete state.cacheRequests[activeServerId][key][itemId].promise
|
||||
})
|
||||
} catch {
|
||||
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,
|
||||
}
|
||||
})
|
||||
return await promise
|
||||
},
|
||||
|
||||
fetchCoverArtFilePath: async (coverArt, size = 'thumbnail') => {
|
||||
const client = get().client
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const activeServerId = get().settings.activeServerId
|
||||
if (!activeServerId) {
|
||||
return
|
||||
}
|
||||
|
||||
const key: CacheItemTypeKey = size === 'thumbnail' ? 'coverArtThumb' : 'coverArt'
|
||||
|
||||
const existing = get().cacheFiles[activeServerId][key][coverArt]
|
||||
const inProgress = get().cacheRequests[activeServerId][key][coverArt]
|
||||
if (existing && inProgress) {
|
||||
if (inProgress.promise) {
|
||||
await inProgress.promise
|
||||
}
|
||||
return `file://${existing.path}`
|
||||
}
|
||||
|
||||
await get().cacheItem(key, coverArt, () =>
|
||||
client.getCoverArtUri({
|
||||
id: coverArt,
|
||||
size: size === 'thumbnail' ? '256' : undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
return `file://${get().cacheFiles[activeServerId][key][coverArt].path}`
|
||||
},
|
||||
|
||||
createCache: async serverId => {
|
||||
for (const type in CacheItemType) {
|
||||
await mkdir(`${RNFS.DocumentDirectoryPath}/servers/${serverId}/${type}`)
|
||||
}
|
||||
|
||||
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: {},
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
pendingRemoval: {},
|
||||
|
||||
removeCache: async serverId => {
|
||||
set(state => {
|
||||
state.pendingRemoval[serverId] = true
|
||||
})
|
||||
|
||||
const cacheRequests = get().cacheRequests[serverId]
|
||||
const pendingRequests: Promise<void>[] = []
|
||||
|
||||
for (const type in CacheItemType) {
|
||||
const requests = Object.values(cacheRequests[type as CacheItemTypeKey])
|
||||
.filter(r => r.promise !== undefined)
|
||||
.map(r => r.promise) as Promise<void>[]
|
||||
pendingRequests.push(...requests)
|
||||
}
|
||||
|
||||
await Promise.all(pendingRequests)
|
||||
await rmdir(`${RNFS.DocumentDirectoryPath}/servers/${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]
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
clearImageCache: async () => {
|
||||
const cacheRequests = get().cacheRequests
|
||||
for (const serverId in cacheRequests) {
|
||||
const coverArtRequests = cacheRequests[serverId].coverArt
|
||||
const artstArtRequests = cacheRequests[serverId].artistArt
|
||||
const requests = [...Object.values(coverArtRequests), ...Object.values(artstArtRequests)]
|
||||
const pendingRequests = [
|
||||
...(requests.filter(r => r.promise !== undefined).map(r => r.promise) as Promise<void>[]),
|
||||
]
|
||||
|
||||
await Promise.all(pendingRequests)
|
||||
|
||||
await rmdir(get().cacheDirs[serverId].coverArt)
|
||||
await mkdir(get().cacheDirs[serverId].coverArt)
|
||||
|
||||
await rmdir(get().cacheDirs[serverId].artistArt)
|
||||
await mkdir(get().cacheDirs[serverId].artistArt)
|
||||
|
||||
set(state => {
|
||||
state.cacheFiles[serverId].coverArt = {}
|
||||
state.cacheFiles[serverId].coverArtThumb = {}
|
||||
state.cacheFiles[serverId].artistArt = {}
|
||||
state.cacheFiles[serverId].artistArtThumb = {}
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,521 +0,0 @@
|
||||
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,15 +1,20 @@
|
||||
import { Server } from '@app/models/settings'
|
||||
import { ById } from '@app/models/state'
|
||||
import { newCacheBuster } from './settings'
|
||||
import RNFS from 'react-native-fs'
|
||||
|
||||
const migrations: Array<(state: any) => any> = [
|
||||
state => {
|
||||
const migrations: Array<(state: any) => Promise<any>> = [
|
||||
// 1
|
||||
async state => {
|
||||
for (let server of state.settings.servers) {
|
||||
server.usePlainPassword = false
|
||||
}
|
||||
|
||||
return state
|
||||
},
|
||||
state => {
|
||||
|
||||
// 2
|
||||
async state => {
|
||||
state.settings.servers = state.settings.servers.reduce((acc: ById<Server>, server: Server) => {
|
||||
acc[server.id] = server
|
||||
return acc
|
||||
@@ -31,6 +36,34 @@ const migrations: Array<(state: any) => any> = [
|
||||
|
||||
return state
|
||||
},
|
||||
|
||||
// 3
|
||||
async state => {
|
||||
state.settings.cacheBuster = newCacheBuster()
|
||||
|
||||
state.settings.servers = Object.values(state.settings.servers as Record<string, Server>).reduce(
|
||||
(acc, server, i) => {
|
||||
const newId = i.toString()
|
||||
|
||||
if (server.id === state.settings.activeServerId) {
|
||||
state.settings.activeServerId = newId
|
||||
}
|
||||
|
||||
server.id = newId
|
||||
acc[newId] = server
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, Server>,
|
||||
)
|
||||
|
||||
try {
|
||||
await RNFS.unlink(`${RNFS.DocumentDirectoryPath}/servers`)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
|
||||
return state
|
||||
},
|
||||
]
|
||||
|
||||
export default migrations
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AlbumFilterSettings, ArtistFilterSettings, Server } from '@app/models/s
|
||||
import { ById } from '@app/models/state'
|
||||
import { GetStore, SetStore } from '@app/state/store'
|
||||
import { SubsonicApiClient } from '@app/subsonic/api'
|
||||
import uuid from 'react-native-uuid'
|
||||
|
||||
export type SettingsSlice = {
|
||||
settings: {
|
||||
@@ -21,13 +22,17 @@ export type SettingsSlice = {
|
||||
maxBitrateMobile: number
|
||||
minBuffer: number
|
||||
maxBuffer: number
|
||||
cacheBuster: string
|
||||
}
|
||||
|
||||
client?: SubsonicApiClient
|
||||
resetServer: boolean
|
||||
|
||||
changeCacheBuster: () => void
|
||||
|
||||
setActiveServer: (id: string | undefined, force?: boolean) => Promise<void>
|
||||
addServer: (server: Server) => Promise<void>
|
||||
removeServer: (id: string) => Promise<void>
|
||||
addServer: (server: Server) => void
|
||||
removeServer: (id: string) => void
|
||||
updateServer: (server: Server) => void
|
||||
|
||||
setScrobble: (scrobble: boolean) => void
|
||||
@@ -42,6 +47,10 @@ export type SettingsSlice = {
|
||||
setLibraryArtistFiler: (filter: ArtistFilterSettings) => void
|
||||
}
|
||||
|
||||
export function newCacheBuster(): string {
|
||||
return (uuid.v4() as string).split('-')[0]
|
||||
}
|
||||
|
||||
export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice => ({
|
||||
settings: {
|
||||
servers: {},
|
||||
@@ -66,6 +75,15 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
|
||||
maxBitrateMobile: 192,
|
||||
minBuffer: 6,
|
||||
maxBuffer: 60,
|
||||
cacheBuster: newCacheBuster(),
|
||||
},
|
||||
|
||||
resetServer: false,
|
||||
|
||||
changeCacheBuster: () => {
|
||||
set(store => {
|
||||
store.settings.cacheBuster = newCacheBuster()
|
||||
})
|
||||
},
|
||||
|
||||
setActiveServer: async (id, force) => {
|
||||
@@ -84,17 +102,24 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
|
||||
return
|
||||
}
|
||||
|
||||
get().prepareCache(newActiveServer.id)
|
||||
set(state => {
|
||||
state.resetServer = true
|
||||
})
|
||||
|
||||
set(state => {
|
||||
state.settings.activeServerId = newActiveServer.id
|
||||
state.client = new SubsonicApiClient(newActiveServer)
|
||||
get().resetLibrary(state)
|
||||
})
|
||||
|
||||
set(state => {
|
||||
state.resetServer = false
|
||||
})
|
||||
},
|
||||
|
||||
addServer: async server => {
|
||||
await get().createCache(server.id)
|
||||
addServer: server => {
|
||||
const serverIds = Object.keys(get().settings.servers)
|
||||
server.id =
|
||||
serverIds.length === 0 ? '0' : (serverIds.map(i => parseInt(i, 10)).sort((a, b) => b - a)[0] + 1).toString()
|
||||
|
||||
set(state => {
|
||||
state.settings.servers[server.id] = server
|
||||
@@ -105,9 +130,7 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
|
||||
}
|
||||
},
|
||||
|
||||
removeServer: async id => {
|
||||
await get().removeCache(id)
|
||||
|
||||
removeServer: id => {
|
||||
set(state => {
|
||||
delete state.settings.servers[id]
|
||||
})
|
||||
|
||||
@@ -3,21 +3,15 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
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 { 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 &
|
||||
LibrarySlice &
|
||||
TrackPlayerSlice &
|
||||
TrackPlayerMapSlice &
|
||||
CacheSlice & {
|
||||
TrackPlayerSlice & {
|
||||
hydrated: boolean
|
||||
setHydrated: (hydrated: boolean) => void
|
||||
}
|
||||
@@ -63,10 +57,7 @@ export const useStore = create<
|
||||
persist(
|
||||
immer((set, get) => ({
|
||||
...createSettingsSlice(set, get),
|
||||
...createLibrarySlice(set, get),
|
||||
...createTrackPlayerSlice(set, get),
|
||||
...createTrackPlayerMapSlice(set, get),
|
||||
...createCacheSlice(set, get),
|
||||
|
||||
hydrated: false,
|
||||
setHydrated: hydrated =>
|
||||
@@ -78,20 +69,20 @@ export const useStore = create<
|
||||
name: '@appStore',
|
||||
version: DB_VERSION,
|
||||
getStorage: () => AsyncStorage,
|
||||
partialize: state => ({ settings: state.settings, cacheFiles: state.cacheFiles }),
|
||||
partialize: state => ({ settings: state.settings }),
|
||||
onRehydrateStorage: _preState => {
|
||||
return async (postState, _error) => {
|
||||
await postState?.setActiveServer(postState.settings.activeServerId, true)
|
||||
postState?.setHydrated(true)
|
||||
}
|
||||
},
|
||||
migrate: (persistedState, version) => {
|
||||
migrate: async (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)
|
||||
persistedState = await migrations[i](persistedState)
|
||||
}
|
||||
|
||||
return persistedState
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { NoClientError } from '@app/models/error'
|
||||
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 } from 'react-native-track-player'
|
||||
import { GetStore, SetStore } from './store'
|
||||
|
||||
export type SetQueueOptions = {
|
||||
title: string
|
||||
playTrack?: number
|
||||
shuffle?: boolean
|
||||
}
|
||||
|
||||
export type SetQueueOptionsInternal = SetQueueOptions & {
|
||||
queue: TrackExt[]
|
||||
contextId: string
|
||||
type: QueueContextType
|
||||
}
|
||||
|
||||
export type TrackPlayerSlice = {
|
||||
queueName?: string
|
||||
setQueueName: (name?: string) => void
|
||||
@@ -33,14 +44,7 @@ export type TrackPlayerSlice = {
|
||||
setCurrentTrackIdx: (idx?: number) => void
|
||||
|
||||
queue: TrackExt[]
|
||||
setQueue: (
|
||||
songs: Song[],
|
||||
name: string,
|
||||
contextType: QueueContextType,
|
||||
contextId: string,
|
||||
playTrack?: number,
|
||||
shuffle?: boolean,
|
||||
) => Promise<void>
|
||||
setQueue: (options: SetQueueOptionsInternal) => Promise<void>
|
||||
|
||||
progress: Progress
|
||||
setProgress: (progress: Progress) => void
|
||||
@@ -175,19 +179,17 @@ export const createTrackPlayerSlice = (set: SetStore, get: GetStore): TrackPlaye
|
||||
}),
|
||||
|
||||
queue: [],
|
||||
setQueue: async (songs, name, contextType, contextId, playTrack, shuffle) => {
|
||||
setQueue: async ({ queue, title, type, contextId, playTrack, shuffle }) => {
|
||||
return trackPlayerCommands.enqueue(async () => {
|
||||
const shuffled = shuffle !== undefined ? shuffle : !!get().shuffleOrder
|
||||
|
||||
await TrackPlayer.setupPlayer(get().getPlayerOptions())
|
||||
await TrackPlayer.reset()
|
||||
|
||||
if (songs.length === 0) {
|
||||
if (queue.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let queue = await get().mapSongstoTrackExts(songs)
|
||||
|
||||
if (shuffled) {
|
||||
const { tracks, shuffleOrder } = shuffleTracks(queue, playTrack)
|
||||
set(state => {
|
||||
@@ -206,8 +208,8 @@ export const createTrackPlayerSlice = (set: SetStore, get: GetStore): TrackPlaye
|
||||
try {
|
||||
set(state => {
|
||||
state.queue = queue
|
||||
state.queueName = name
|
||||
state.queueContextType = contextType
|
||||
state.queueName = title
|
||||
state.queueContextType = type
|
||||
state.queueContextId = contextId
|
||||
})
|
||||
get().setCurrentTrackIdx(playTrack)
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Song } from '@app/models/library'
|
||||
import { TrackExt } from '@app/models/trackplayer'
|
||||
import userAgent from '@app/util/userAgent'
|
||||
import { GetStore, SetStore } from '@app/state/store'
|
||||
|
||||
export type TrackPlayerMapSlice = {
|
||||
mapSongtoTrackExt: (song: Song) => Promise<TrackExt>
|
||||
mapSongstoTrackExts: (songs: Song[]) => Promise<TrackExt[]>
|
||||
mapTrackExtToSong: (song: TrackExt) => Song
|
||||
}
|
||||
|
||||
export const createTrackPlayerMapSlice = (set: SetStore, get: GetStore): TrackPlayerMapSlice => ({
|
||||
mapSongtoTrackExt: async song => {
|
||||
let artwork = require('@res/fallback.png')
|
||||
if (song.coverArt) {
|
||||
const filePath = await get().fetchCoverArtFilePath(song.coverArt)
|
||||
if (filePath) {
|
||||
artwork = filePath
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist || 'Unknown Artist',
|
||||
album: song.album || 'Unknown Album',
|
||||
url: get().buildStreamUri(song.id),
|
||||
userAgent,
|
||||
artwork,
|
||||
coverArt: song.coverArt,
|
||||
duration: song.duration,
|
||||
artistId: song.artistId,
|
||||
albumId: song.albumId,
|
||||
track: song.track,
|
||||
discNumber: song.discNumber,
|
||||
}
|
||||
},
|
||||
|
||||
mapSongstoTrackExts: async songs => {
|
||||
return await Promise.all(songs.map(get().mapSongtoTrackExt))
|
||||
},
|
||||
|
||||
mapTrackExtToSong: track => {
|
||||
return {
|
||||
itemType: 'song',
|
||||
id: track.id,
|
||||
title: track.title as string,
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
streamUri: track.url as string,
|
||||
coverArt: track.coverArt,
|
||||
duration: track.duration,
|
||||
artistId: track.artistId,
|
||||
albumId: track.albumId,
|
||||
track: track.track,
|
||||
discNumber: track.discNumber,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -101,10 +101,7 @@ export class BaseArtistInfoElement<T> {
|
||||
this.largeImageUrl = e.getElementsByTagName('largeImageUrl')[0].textContent as string
|
||||
}
|
||||
|
||||
const similarArtistElements = e.getElementsByTagName('similarArtist')
|
||||
for (let i = 0; i < similarArtistElements.length; i++) {
|
||||
this.similarArtists.push(new artistType(similarArtistElements[i]))
|
||||
}
|
||||
this.similarArtists = Array.from(e.getElementsByTagName('similarArtist')).map(i => new artistType(i))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,9 +247,7 @@ export class PlaylistElement {
|
||||
coverArt?: string
|
||||
|
||||
constructor(e: Element) {
|
||||
for (let i = 0; i < e.getElementsByTagName('allowedUser').length; i++) {
|
||||
this.allowedUser.push(e.getElementsByTagName('allowedUser')[i].textContent as string)
|
||||
}
|
||||
this.allowedUser = Array.from(e.getElementsByTagName('allowedUser')).map(i => i.textContent as string)
|
||||
|
||||
this.id = requiredString(e, 'id')
|
||||
this.name = requiredString(e, 'name')
|
||||
@@ -273,8 +268,6 @@ export class PlaylistWithSongsElement extends PlaylistElement {
|
||||
constructor(e: Element) {
|
||||
super(e)
|
||||
|
||||
for (let i = 0; i < e.getElementsByTagName('entry').length; i++) {
|
||||
this.songs.push(new ChildElement(e.getElementsByTagName('entry')[i]))
|
||||
}
|
||||
this.songs = Array.from(e.getElementsByTagName('entry')).map(i => new ChildElement(i))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import RNFS from 'react-native-fs'
|
||||
import path from 'path'
|
||||
import { CacheItemTypeKey } from '@app/models/cache'
|
||||
|
||||
export async function mkdir(path: string): Promise<void> {
|
||||
const exists = await RNFS.exists(path)
|
||||
if (exists) {
|
||||
const isDir = (await RNFS.stat(path)).isDirectory()
|
||||
if (!isDir) {
|
||||
throw new Error(`path exists and is not a directory: ${path}`)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
const serversCacheDir = path.join(RNFS.ExternalDirectoryPath, 's')
|
||||
|
||||
return await RNFS.mkdir(path)
|
||||
}
|
||||
|
||||
export async function rmdir(path: string): Promise<void> {
|
||||
return RNFS.unlink(path)
|
||||
export function cacheDir(serverId?: string, itemType?: CacheItemTypeKey, itemId?: string): string {
|
||||
const segments: string[] = []
|
||||
|
||||
serverId && segments.push(serverId)
|
||||
serverId && itemType && segments.push(itemType)
|
||||
serverId && itemType && itemId && segments.push(itemId)
|
||||
|
||||
return path.join(serversCacheDir, ...segments)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ById } from '@app/models/state'
|
||||
import merge from 'lodash.merge'
|
||||
import { ById, CollectionById } from '@app/models/state'
|
||||
import _ from 'lodash'
|
||||
|
||||
export function reduceById<T extends { id: string }>(collection: T[]): ById<T> {
|
||||
return collection.reduce((acc, value) => {
|
||||
@@ -9,7 +9,7 @@ export function reduceById<T extends { id: string }>(collection: T[]): ById<T> {
|
||||
}
|
||||
|
||||
export function mergeById<T extends { [id: string]: unknown }>(object: T, source: T): void {
|
||||
merge(object, source)
|
||||
_.merge(object, source)
|
||||
}
|
||||
|
||||
export function mapById<T>(object: ById<T>, ids: string[]): T[] {
|
||||
@@ -19,3 +19,14 @@ export function mapById<T>(object: ById<T>, ids: string[]): T[] {
|
||||
export function mapId(entities: { id: string }[]): string[] {
|
||||
return entities.map(e => e.id)
|
||||
}
|
||||
|
||||
export function mapCollectionById<T, U extends { id: string }>(
|
||||
collection: T[],
|
||||
map: (item: T) => U,
|
||||
): CollectionById<U> {
|
||||
const mapped = collection.map(map)
|
||||
return {
|
||||
byId: reduceById(mapped),
|
||||
allIds: mapId(mapped),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user