Library store refactor (#76)

* start of music store refactor

moving stuff into a state cache
better separate it from view logic

* added paginated list/album list

* reworked fetchAlbumList to remove ui state

refactored home screen to use new method
i broke playing songs somehow, JS thread goes into a loop

* don't reset parts manually, do it all at once

* fixed perf issue related to too many rerenders

rerenders were caused by strict equality check on object/array picks
switched artistInfo to new store
updated zustand and fixed deprecation warnings

* update typescript

and use workspace tsc version for vscode

* remove old artistInfo

* switched to new playlist w/songs

removed more unused stuff

* remove unused + (slightly) rework search

* refactor star

* use only original/large imges for covers/artist

fix view artist from context menu
add loading indicators to song list and artist views (show info we have right away)

* set starred/unstar assuming it works

and correct state on error

* reorg, remove old music slice files

* added back fix for song cover art

* sort artists by localCompare name

* update licenses

* fix now playing background grey bar

* update react-native-gesture-handler

for node-fetch security alert

* fix another gradient height grey bar issue

* update licenses again

* remove thumbnail cache

* rename to remove "Library" from methods

* Revert "remove thumbnail cache"

This reverts commit e0db4931f1.

* use ids for lists, pull state later

* Revert "use only original/large imges for covers/artist"

This reverts commit c9aea9065c.

* deep equal ListItem props for now

this needs a bigger refactor

* use immer as middleware

* refactor api client to use string method

hoping to use this for requestKey/deduping next

* use thumbnails in list items

* Revert "refactor api client to use string method"

This reverts commit 234326135b.

* rename/cleanup

* store servers by id

* get rid of settings selectors

* renames for clarity

remove unused estimateContentLength setting

* remove trackplayer selectors

* fix migration for library filter settings

* fixed shuffle order reporting wrong track/queue

* removed the other selectors

* don't actually need es6/react for our state

* fix slow artist sort on star

localeCompare is too slow for large lists
This commit is contained in:
austinried
2022-03-28 13:30:57 +09:00
committed by GitHub
parent 09ca4974c5
commit 081251061d
57 changed files with 2136 additions and 1843 deletions

View File

@@ -5,16 +5,15 @@ 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 { useArtistInfo } from '@app/hooks/music'
import { Album, Song } from '@app/models/music'
import { useStore } from '@app/state/store'
import { selectTrackPlayer } from '@app/state/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 from 'react'
import React, { useCallback, useEffect } from 'react'
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
import { useAnimatedScrollHandler, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
@@ -47,12 +46,12 @@ const TopSongs = React.memo<{
name: string
artistId: string
}>(({ songs, name, artistId }) => {
const setQueue = useStore(selectTrackPlayer.setQueue)
const setQueue = useStore(store => store.setQueue)
return (
<>
<Header>Top Songs</Header>
{songs.map((s, i) => (
{songs.slice(0, 5).map((s, i) => (
<ListItem
key={i}
item={s}
@@ -67,6 +66,29 @@ const TopSongs = React.memo<{
)
})
const ArtistAlbums = React.memo<{
albums: Album[]
}>(({ albums }) => {
const albumsLayout = useLayout()
const sortedAlbums = [...albums]
.sort((a, b) => a.name.localeCompare(b.name))
.sort((a, b) => (b.year || 0) - (a.year || 0))
const albumSize = albumsLayout.width / 2 - styles.contentContainer.paddingHorizontal / 2
return (
<>
<Header>Albums</Header>
<View style={styles.albums} onLayout={albumsLayout.onLayout}>
{sortedAlbums.map(a => (
<AlbumItem key={a.id} album={a} height={albumSize} width={albumSize} />
))}
</View>
</>
)
})
const ArtistViewFallback = React.memo(() => (
<GradientBackground style={styles.fallback}>
<ActivityIndicator size="large" color={colors.accent} />
@@ -74,8 +96,19 @@ const ArtistViewFallback = React.memo(() => (
))
const ArtistView = React.memo<{ id: string; title: string }>(({ id, title }) => {
const artist = useArtistInfo(id)
const albumsLayout = useLayout()
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 coverLayout = useLayout()
const headerOpacity = useSharedValue(0)
@@ -91,16 +124,22 @@ const ArtistView = React.memo<{ id: string; title: string }>(({ id, title }) =>
}
})
const albumSize = albumsLayout.width / 2 - styles.contentContainer.paddingHorizontal / 2
useEffect(() => {
if (!artist || !albumIds) {
fetchArtist(id)
}
}, [artist, albumIds, fetchArtist, id])
useEffect(() => {
if (artist && !topSongIds) {
fetchTopSongs(artist.name)
}
}, [artist, fetchTopSongs, topSongIds])
if (!artist) {
return <ArtistViewFallback />
}
const _albums = [...artist.albums]
.sort((a, b) => a.name.localeCompare(b.name))
.sort((a, b) => (b.year || 0) - (a.year || 0))
return (
<View style={styles.container}>
<HeaderBar title={title} headerStyle={[styles.header, animatedOpacity]} />
@@ -115,17 +154,18 @@ const ArtistView = React.memo<{ id: string; title: string }>(({ id, title }) =>
<Text style={styles.title}>{artist.name}</Text>
</View>
<View style={styles.contentContainer}>
{artist.topSongs.length > 0 ? (
<TopSongs songs={artist.topSongs} name={artist.name} artistId={artist.id} />
{topSongs && albums ? (
topSongs.length > 0 ? (
<>
<TopSongs songs={topSongs} name={artist.name} artistId={artist.id} />
<ArtistAlbums albums={albums} />
</>
) : (
<ArtistAlbums albums={albums} />
)
) : (
<></>
<ActivityIndicator size="large" color={colors.accent} style={styles.loading} />
)}
<Header>Albums</Header>
<View style={styles.albums} onLayout={albumsLayout.onLayout}>
{_albums.map(a => (
<AlbumItem key={a.id} album={a} height={albumSize} width={albumSize} />
))}
</View>
</View>
</GradientScrollView>
</View>
@@ -200,6 +240,9 @@ const styles = StyleSheet.create({
fontFamily: font.regular,
textAlign: 'center',
},
loading: {
marginTop: 30,
},
})
export default ArtistView

View File

@@ -3,17 +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/server'
import { AlbumListItem } from '@app/models/music'
import { selectMusic } from '@app/state/music'
import { selectSettings } from '@app/state/settings'
import { useStore } from '@app/state/store'
import { useActiveServerRefresh } from '@app/hooks/settings'
import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { GetAlbumListType } from '@app/subsonic/params'
import { GetAlbumList2TypeBase, GetAlbumListType } from '@app/subsonic/params'
import { useNavigation } from '@react-navigation/native'
import React, { useCallback } from 'react'
import equal from 'fast-deep-equal/es6/react'
import produce from 'immer'
import React, { useCallback, useState } from 'react'
import { RefreshControl, ScrollView, StatusBar, StyleSheet, Text, View } from 'react-native'
import create, { StateSelector } from 'zustand'
const titles: { [key in GetAlbumListType]?: string } = {
recent: 'Recently Played',
@@ -23,9 +23,14 @@ const titles: { [key in GetAlbumListType]?: string } = {
}
const AlbumItem = React.memo<{
album: AlbumListItem
}>(({ album }) => {
id: string
}>(({ id }) => {
const navigation = useNavigation()
const album = useStoreDeep(useCallback(store => store.library.albums[id], [id]))
if (!album) {
return <></>
}
return (
<AlbumContextPressable
@@ -49,9 +54,10 @@ const AlbumItem = React.memo<{
})
const Category = React.memo<{
name?: string
data: AlbumListItem[]
}>(({ name, data }) => {
type: string
}>(({ type }) => {
const list = useHomeStoreDeep(useCallback(store => store.lists[type] || [], [type]))
const Albums = () => (
<ScrollView
horizontal={true}
@@ -59,8 +65,8 @@ const Category = React.memo<{
overScrollMode={'never'}
style={styles.artScroll}
contentContainerStyle={styles.artScrollContent}>
{data.map(album => (
<AlbumItem key={album.id} album={album} />
{list.map(id => (
<AlbumItem key={id} id={id} />
))}
</ScrollView>
)
@@ -73,24 +79,57 @@ const Category = React.memo<{
return (
<View style={styles.category}>
<Header style={styles.header}>{name}</Header>
{data.length > 0 ? <Albums /> : <Nothing />}
<Header style={styles.header}>{titles[type as GetAlbumListType] || ''}</Header>
{list.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)
}
const Home = () => {
const types = useStore(selectSettings.homeLists)
const lists = useStore(selectMusic.homeLists)
const updating = useStore(selectMusic.homeListsUpdating)
const update = useStore(selectMusic.fetchHomeLists)
const clear = useStore(selectMusic.clearHomeLists)
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 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(() => {
clear()
update()
}, [clear, update]),
types.forEach(type => setList(type, []))
refresh()
}, [refresh, setList, types]),
)
return (
@@ -99,15 +138,15 @@ const Home = () => {
contentContainerStyle={styles.scrollContentContainer}
refreshControl={
<RefreshControl
refreshing={updating}
onRefresh={update}
refreshing={refreshing}
onRefresh={refresh}
colors={[colors.accent, colors.accentLow]}
progressViewOffset={StatusBar.currentHeight}
/>
}>
<View style={styles.content}>
{types.map(type => (
<Category key={type} name={titles[type as GetAlbumListType]} data={type in lists ? lists[type] : []} />
<Category key={type} type={type} />
))}
</View>
</GradientScrollView>

View File

@@ -3,24 +3,26 @@ 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 { Album, AlbumListItem } from '@app/models/music'
import { selectMusic } from '@app/state/music'
import { selectSettings } from '@app/state/settings'
import { useStore } from '@app/state/store'
import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { GetAlbumList2Type } from '@app/subsonic/params'
import { GetAlbumList2Params, GetAlbumList2Type } from '@app/subsonic/params'
import { useNavigation } from '@react-navigation/native'
import React, { useEffect } from 'react'
import React, { useCallback } from 'react'
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native'
const AlbumItem = React.memo<{
album: AlbumListItem
id: string
size: number
height: number
}>(({ album, size, height }) => {
}>(({ id, size, height }) => {
const album = useStoreDeep(useCallback(store => store.library.albums[id], [id]))
const navigation = useNavigation()
if (!album) {
return <></>
}
return (
<AlbumContextPressable
album={album}
@@ -41,8 +43,8 @@ const AlbumItem = React.memo<{
})
const AlbumListRenderItem: React.FC<{
item: { album: Album; size: number; height: number }
}> = ({ item }) => <AlbumItem album={item.album} size={item.size} height={item.height} />
item: { id: string; size: number; height: number }
}> = ({ item }) => <AlbumItem id={item.id} size={item.size} height={item.height} />
const filterOptions: OptionData[] = [
{ text: 'By Name', value: 'alphabeticalByName' },
@@ -57,24 +59,57 @@ const filterOptions: OptionData[] = [
]
const AlbumsList = () => {
const fetchAlbums = useStore(selectMusic.fetchAlbums)
const { list, refreshing, refresh, reset, fetchNextPage } = useFetchPaginatedList(fetchAlbums, 300)
const filter = useStore(selectSettings.libraryAlbumFilter)
const setFilter = useStore(selectSettings.setLibraryAlbumFilter)
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 layout = useWindowDimensions()
const size = layout.width / 3 - styles.itemWrapper.marginHorizontal * 2
const height = size + 36
useEffect(() => reset(), [reset, filter])
return (
<View style={styles.container}>
<GradientFlatList
data={list.map(album => ({ album, size, height }))}
data={list.map(id => ({ id, size, height }))}
renderItem={AlbumListRenderItem}
keyExtractor={item => item.album.id}
keyExtractor={item => item.id}
numColumns={3}
removeClippedSubviews={true}
refreshing={refreshing}

View File

@@ -1,12 +1,10 @@
import FilterButton, { OptionData } from '@app/components/FilterButton'
import GradientFlatList from '@app/components/GradientFlatList'
import ListItem from '@app/components/ListItem'
import { useFetchList } from '@app/hooks/list'
import { Artist } from '@app/models/music'
import { useFetchList2 } from '@app/hooks/list'
import { Artist } from '@app/models/library'
import { ArtistFilterType } from '@app/models/settings'
import { selectMusic } from '@app/state/music'
import { selectSettings } from '@app/state/settings'
import { useStore } from '@app/state/store'
import { useStore, useStoreDeep } from '@app/state/store'
import React, { useEffect, useState } from 'react'
import { StyleSheet, View } from 'react-native'
@@ -21,13 +19,17 @@ const filterOptions: OptionData[] = [
]
const ArtistsList = () => {
const fetchArtists = useStore(selectMusic.fetchArtists)
const { list, refreshing, refresh } = useFetchList(fetchArtists)
const filter = useStore(selectSettings.libraryArtistFilter)
const setFilter = useStore(selectSettings.setLibraryArtistFiler)
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 [sortedList, setSortedList] = useState<Artist[]>([])
useEffect(() => {
const list = Object.values(artists)
switch (filter.type) {
case 'random':
setSortedList([...list].sort(() => Math.random() - 0.5))
@@ -35,11 +37,14 @@ const ArtistsList = () => {
case 'starred':
setSortedList([...list].filter(a => a.starred))
break
case 'alphabeticalByName':
setSortedList(artistOrder.map(id => artists[id]))
break
default:
setSortedList([...list])
break
}
}, [list, filter])
}, [filter.type, artists, artistOrder])
return (
<View style={styles.container}>

View File

@@ -1,23 +1,23 @@
import GradientFlatList from '@app/components/GradientFlatList'
import ListItem from '@app/components/ListItem'
import { useFetchList } from '@app/hooks/list'
import { PlaylistListItem } from '@app/models/music'
import { selectMusic } from '@app/state/music'
import { useStore } from '@app/state/store'
import { useFetchList2 } from '@app/hooks/list'
import { Playlist } from '@app/models/library'
import { useStore, useStoreDeep } from '@app/state/store'
import React from 'react'
import { StyleSheet } from 'react-native'
const PlaylistRenderItem: React.FC<{ item: PlaylistListItem }> = ({ item }) => (
const PlaylistRenderItem: React.FC<{ item: Playlist }> = ({ item }) => (
<ListItem item={item} showArt={true} showStar={false} listStyle="big" style={styles.listItem} />
)
const PlaylistsList = () => {
const fetchPlaylists = useStore(selectMusic.fetchPlaylists)
const { list, refreshing, refresh } = useFetchList(fetchPlaylists)
const fetchPlaylists = useStore(store => store.fetchPlaylists)
const { refreshing, refresh } = useFetchList2(fetchPlaylists)
const playlists = useStoreDeep(store => store.library.playlists)
return (
<GradientFlatList
data={list}
data={Object.values(playlists)}
renderItem={PlaylistRenderItem}
keyExtractor={item => item.id}
onRefresh={refresh}

View File

@@ -2,10 +2,8 @@ import GradientFlatList from '@app/components/GradientFlatList'
import ListItem from '@app/components/ListItem'
import NowPlayingBar from '@app/components/NowPlayingBar'
import { useSkipTo } from '@app/hooks/trackplayer'
import { Song } from '@app/models/music'
import { useStore } from '@app/state/store'
import { selectTrackPlayer } from '@app/state/trackplayer'
import { selectTrackPlayerMap } from '@app/state/trackplayermap'
import { Song } from '@app/models/library'
import { useStore, useStoreDeep } from '@app/state/store'
import React from 'react'
import { StyleSheet, View } from 'react-native'
@@ -27,8 +25,8 @@ const SongRenderItem: React.FC<{
)
const NowPlayingQueue = React.memo<{}>(() => {
const queue = useStore(selectTrackPlayer.queue)
const mapTrackExtToSong = useStore(selectTrackPlayerMap.mapTrackExtToSong)
const queue = useStoreDeep(store => store.queue)
const mapTrackExtToSong = useStore(store => store.mapTrackExtToSong)
const skipTo = useSkipTo()
return (

View File

@@ -2,13 +2,10 @@ import CoverArt from '@app/components/CoverArt'
import HeaderBar from '@app/components/HeaderBar'
import ImageGradientBackground from '@app/components/ImageGradientBackground'
import PressableOpacity from '@app/components/PressableOpacity'
import Star from '@app/components/Star'
import { useStarred } from '@app/hooks/music'
import { PressableStar } from '@app/components/Star'
import { useNext, usePause, usePlay, usePrevious, useSeekTo } from '@app/hooks/trackplayer'
import { selectMusic } from '@app/state/music'
import { useStore } from '@app/state/store'
import { QueueContextType, selectTrackPlayer, TrackExt } from '@app/state/trackplayer'
import { selectTrackPlayerMap } from '@app/state/trackplayermap'
import { QueueContextType, TrackExt } from '@app/models/trackplayer'
import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import formatDuration from '@app/util/formatDuration'
@@ -41,9 +38,9 @@ function getContextName(type?: QueueContextType) {
const NowPlayingHeader = React.memo<{
track?: TrackExt
}>(({ track }) => {
const queueName = useStore(selectTrackPlayer.queueName)
const queueContextType = useStore(selectTrackPlayer.queueContextType)
const mapTrackExtToSong = useStore(selectTrackPlayerMap.mapTrackExtToSong)
const queueName = useStore(store => store.queueName)
const queueContextType = useStore(store => store.queueContextType)
const mapTrackExtToSong = useStore(store => store.mapTrackExtToSong)
if (!track) {
return <></>
@@ -94,11 +91,11 @@ const headerStyles = StyleSheet.create({
})
const SongCoverArt = () => {
const track = useStore(selectTrackPlayer.currentTrack)
const coverArt = useStore(store => store.currentTrack?.coverArt)
return (
<View style={coverArtStyles.container}>
<CoverArt type="cover" size="original" coverArt={track?.coverArt} style={coverArtStyles.image} />
<CoverArt type="cover" size="original" coverArt={coverArt} style={coverArtStyles.image} />
</View>
)
}
@@ -117,26 +114,22 @@ const coverArtStyles = StyleSheet.create({
})
const SongInfo = () => {
const track = useStore(selectTrackPlayer.currentTrack)
const id = track?.id || '-1'
const type = 'song'
const starred = useStarred(id, type)
const setStarred = useStore(selectMusic.starItem)
const id = useStore(store => store.currentTrack?.id)
const artist = useStore(store => store.currentTrack?.artist)
const title = useStore(store => store.currentTrack?.title)
return (
<View style={infoStyles.container}>
<View style={infoStyles.details}>
<Text numberOfLines={1} style={infoStyles.title}>
{track?.title}
{title}
</Text>
<Text numberOfLines={1} style={infoStyles.artist}>
{track?.artist}
{artist}
</Text>
</View>
<View style={infoStyles.controls}>
<PressableOpacity onPress={() => setStarred(id, type, starred)}>
<Star size={32} starred={starred} />
</PressableOpacity>
<PressableStar id={id || '-1'} type={'song'} size={32} />
</View>
</View>
)
@@ -170,7 +163,8 @@ const infoStyles = StyleSheet.create({
})
const SeekBar = () => {
const { position, duration } = useStore(selectTrackPlayer.progress)
const position = useStore(store => store.progress.position)
const duration = useStore(store => store.progress.duration)
const seekTo = useSeekTo()
const [value, setValue] = useState(0)
const [sliding, setSliding] = useState(false)
@@ -262,15 +256,15 @@ const seekStyles = StyleSheet.create({
})
const PlayerControls = () => {
const state = useStore(selectTrackPlayer.playerState)
const state = useStore(store => store.playerState)
const play = usePlay()
const pause = usePause()
const next = useNext()
const previous = usePrevious()
const shuffled = useStore(selectTrackPlayer.shuffled)
const toggleShuffle = useStore(selectTrackPlayer.toggleShuffle)
const repeatMode = useStore(selectTrackPlayer.repeatMode)
const toggleRepeat = useStore(selectTrackPlayer.toggleRepeatMode)
const shuffled = useStore(store => !!store.shuffleOrder)
const toggleShuffle = useStore(store => store.toggleShuffle)
const repeatMode = useStore(store => store.repeatMode)
const toggleRepeat = useStore(store => store.toggleRepeatMode)
const navigation = useNavigation()
let playPauseIcon: string
@@ -392,7 +386,7 @@ type RootStackParamList = {
type NowPlayingProps = NativeStackScreenProps<RootStackParamList, 'main'>
const NowPlayingView: React.FC<NowPlayingProps> = ({ navigation }) => {
const track = useStore(selectTrackPlayer.currentTrack)
const track = useStoreDeep(store => store.currentTrack)
useEffect(() => {
if (!track) {
@@ -404,7 +398,7 @@ const NowPlayingView: React.FC<NowPlayingProps> = ({ navigation }) => {
return (
<View style={styles.container}>
<ImageGradientBackground imagePath={imagePath} />
<ImageGradientBackground imagePath={imagePath} height={'100%'} />
<NowPlayingHeader track={track} />
<View style={styles.content}>
<SongCoverArt />

View File

@@ -4,13 +4,12 @@ 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/server'
import { ListableItem, SearchResults, Song } from '@app/models/music'
import { selectMusic } from '@app/state/music'
import { useStore } from '@app/state/store'
import { selectTrackPlayer } from '@app/state/trackplayer'
import { useActiveServerRefresh } from '@app/hooks/settings'
import { Song, Album, Artist, SearchResults } 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 React, { useCallback, useMemo, useRef, useState } from 'react'
@@ -25,7 +24,7 @@ import {
} from 'react-native'
const SongItem = React.memo<{ item: Song }>(({ item }) => {
const setQueue = useStore(selectTrackPlayer.setQueue)
const setQueue = useStore(store => store.setQueue)
return (
<ListItem
@@ -42,8 +41,27 @@ const SongItem = React.memo<{ item: Song }>(({ item }) => {
const ResultsCategory = React.memo<{
name: string
query: string
items: ListableItem[]
}>(({ name, query, items }) => {
ids: string[]
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],
),
)
const navigation = useNavigation()
if (items.length === 0) {
@@ -54,8 +72,8 @@ const ResultsCategory = React.memo<{
<>
<Header>{name}</Header>
{items.map(a =>
a.itemType === 'song' ? (
<SongItem key={a.id} item={a} />
type === 'song' ? (
<SongItem key={a.id} item={a as Song} />
) : (
<ListItem key={a.id} item={a} showArt={true} showStar={false} />
),
@@ -78,15 +96,15 @@ const Results = React.memo<{
}>(({ results, query }) => {
return (
<>
<ResultsCategory name="Artists" query={query} items={results.artists} />
<ResultsCategory name="Albums" query={query} items={results.albums} />
<ResultsCategory name="Songs" query={query} items={results.songs} />
<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} />
</>
)
})
const Search = () => {
const fetchSearchResults = useStore(selectMusic.fetchSearchResults)
const fetchSearchResults = useStore(store => store.fetchSearchResults)
const [results, setResults] = useState<SearchResults>({ artists: [], albums: [], songs: [] })
const [refreshing, setRefreshing] = useState(false)
const [text, setText] = useState('')
@@ -118,7 +136,7 @@ const Search = () => {
() =>
debounce(async (query: string) => {
setRefreshing(true)
setResults(await fetchSearchResults(query))
setResults(await fetchSearchResults({ query, albumCount: 5, artistCount: 5, songCount: 5 }))
setRefreshing(false)
}, 400),
[fetchSearchResults],

View File

@@ -1,18 +1,18 @@
import GradientFlatList from '@app/components/GradientFlatList'
import ListItem from '@app/components/ListItem'
import { useFetchPaginatedList } from '@app/hooks/list'
import { AlbumListItem, Artist, Song } from '@app/models/music'
import { selectMusic } from '@app/state/music'
import { useStore } from '@app/state/store'
import { selectTrackPlayer } from '@app/state/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 { StyleSheet } from 'react-native'
type SearchListItemType = AlbumListItem | Song | Artist
type SearchListItemType = Album | Song | Artist
const ResultsListItem: React.FC<{ item: SearchListItemType }> = ({ item }) => {
const setQueue = useStore(selectTrackPlayer.setQueue)
const setQueue = useStore(store => store.setQueue)
let onPress
if (item.itemType === 'song') {
@@ -40,27 +40,62 @@ const SearchResultsView: React.FC<{
type: 'album' | 'artist' | 'song'
}> = ({ query, type }) => {
const navigation = useNavigation()
const fetchSearchResults = useStore(selectMusic.fetchSearchResults)
const { list, refreshing, refresh, fetchNextPage } = useFetchPaginatedList<SearchListItemType>(
const fetchSearchResults = useStore(store => store.fetchSearchResults)
const { list, refreshing, refresh, fetchNextPage } = useFetchPaginatedList(
useCallback(
(size, offset) =>
fetchSearchResults(query, type, size, offset).then(results => {
switch (type) {
case 'album':
return results.albums
case 'artist':
return results.artists
case 'song':
return results.songs
default:
return []
}
}),
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)
switch (type) {
case 'album':
return results.albums
case 'artist':
return results.artists
case 'song':
return results.songs
default:
return []
}
},
[fetchSearchResults, query, type],
),
100,
)
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],
),
)
useEffect(() => {
navigation.setOptions({
title: `Search: "${query}"`,
@@ -70,7 +105,7 @@ const SearchResultsView: React.FC<{
return (
<GradientFlatList
data={list}
data={items}
renderItem={SearchResultsRenderItem}
keyExtractor={(item, i) => i.toString()}
onRefresh={refresh}

View File

@@ -1,8 +1,7 @@
import Button from '@app/components/Button'
import GradientScrollView from '@app/components/GradientScrollView'
import { Server } from '@app/models/settings'
import { selectSettings } from '@app/state/settings'
import { useStore } from '@app/state/store'
import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import toast from '@app/util/toast'
@@ -19,13 +18,13 @@ const ServerView: React.FC<{
id?: string
}> = ({ id }) => {
const navigation = useNavigation()
const activeServer = useStore(selectSettings.activeServer)
const servers = useStore(selectSettings.servers)
const addServer = useStore(selectSettings.addServer)
const updateServer = useStore(selectSettings.updateServer)
const removeServer = useStore(selectSettings.removeServer)
const server = id ? servers.find(s => s.id === id) : undefined
const pingServer = useStore(selectSettings.pingServer)
const activeServerId = useStore(store => store.settings.activeServerId)
const servers = useStoreDeep(store => store.settings.servers)
const addServer = useStore(store => store.addServer)
const updateServer = useStore(store => store.updateServer)
const removeServer = useStore(store => store.removeServer)
const server = id ? servers[id] : undefined
const pingServer = useStore(store => store.pingServer)
const [address, setAddress] = useState(server?.address || '')
const [username, setUsername] = useState(server?.username || '')
@@ -44,8 +43,8 @@ const ServerView: React.FC<{
}, [address, username, password])
const canRemove = useCallback(() => {
return id && servers.length > 1 && activeServer?.id !== id
}, [id, servers, activeServer])
return id && Object.keys(servers).length > 1 && activeServerId !== id
}, [id, servers, activeServerId])
const exit = useCallback(() => {
if (navigation.canGoBack()) {

View File

@@ -5,11 +5,9 @@ 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/server'
import { useSwitchActiveServer } from '@app/hooks/settings'
import { Server } from '@app/models/settings'
import { selectCache } from '@app/state/cache'
import { selectSettings } from '@app/state/settings'
import { useStore } from '@app/state/store'
import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { useNavigation } from '@react-navigation/core'
@@ -22,7 +20,7 @@ import { version } from '../../package.json'
const ServerItem = React.memo<{
server: Server
}>(({ server }) => {
const activeServer = useStore(selectSettings.activeServer)
const activeServerId = useStore(store => store.settings.activeServerId)
const switchActiveServer = useSwitchActiveServer()
const navigation = useNavigation()
@@ -36,7 +34,7 @@ const ServerItem = React.memo<{
subtitle={server.username}
onPress={() => navigation.navigate('server', { id: server.id })}>
<PressableOpacity style={styles.serverActive} onPress={setActive}>
{activeServer && activeServer.id === server.id ? (
{activeServerId === server.id ? (
<Icon name="checkbox-marked-circle" size={30} color={colors.accent} />
) : (
<Icon name="checkbox-blank-circle-outline" size={30} color={colors.text.secondary} />
@@ -193,27 +191,22 @@ function secondsUnit(seconds: string): string {
}
const SettingsContent = React.memo(() => {
const servers = useStore(selectSettings.servers)
const scrobble = useStore(selectSettings.scrobble)
const setScrobble = useStore(selectSettings.setScrobble)
const servers = useStoreDeep(store => store.settings.servers)
const scrobble = useStore(store => store.settings.scrobble)
const setScrobble = useStore(store => store.setScrobble)
// doesn't seem to ever be a case where we want this off
// will remove later if there isn't a use case for disabling
// const estimateContentLength = useStore(selectSettings.estimateContentLength)
// const setEstimateContentLength = useStore(selectSettings.setEstimateContentLength)
const maxBitrateWifi = useStore(store => store.settings.maxBitrateWifi)
const setMaxBitrateWifi = useStore(store => store.setMaxBitrateWifi)
const maxBitrateWifi = useStore(selectSettings.maxBitrateWifi)
const setMaxBitrateWifi = useStore(selectSettings.setMaxBitrateWifi)
const maxBitrateMobile = useStore(store => store.settings.maxBitrateMobile)
const setMaxBitrateMobile = useStore(store => store.setMaxBitrateMobile)
const maxBitrateMobile = useStore(selectSettings.maxBitrateMobile)
const setMaxBitrateMobile = useStore(selectSettings.setMaxBitrateMobile)
const minBuffer = useStore(store => store.settings.minBuffer)
const setMinBuffer = useStore(store => store.setMinBuffer)
const maxBuffer = useStore(store => store.settings.maxBuffer)
const setMaxBuffer = useStore(store => store.setMaxBuffer)
const minBuffer = useStore(selectSettings.minBuffer)
const setMinBuffer = useStore(selectSettings.setMinBuffer)
const maxBuffer = useStore(selectSettings.maxBuffer)
const setMaxBuffer = useStore(selectSettings.setMaxBuffer)
const clearImageCache = useStore(selectCache.clearImageCache)
const clearImageCache = useStore(store => store.clearImageCache)
const [clearing, setClearing] = useState(false)
const navigation = useNavigation()
@@ -239,7 +232,7 @@ const SettingsContent = React.memo(() => {
return (
<View style={styles.content}>
<Header>Servers</Header>
{servers.map(s => (
{Object.values(servers).map(s => (
<ServerItem key={s.id} server={s} />
))}
<Button
@@ -251,12 +244,6 @@ const SettingsContent = React.memo(() => {
<Header style={styles.header}>Network</Header>
<BitrateModal title="Maximum bitrate (Wi-Fi)" bitrate={maxBitrateWifi} setBitrate={setMaxBitrateWifi} />
<BitrateModal title="Maximum bitrate (mobile)" bitrate={maxBitrateMobile} setBitrate={setMaxBitrateMobile} />
{/* <SettingsSwitch
title="Estimate content length"
subtitle='Send the "estimateContentLength" flag when streaming. Helps fix issues with seeking when the server is transcoding songs.'
value={estimateContentLength}
setValue={setEstimateContentLength}
/> */}
<SettingsTextModal
title="Minimum buffer time"
value={minBuffer.toString()}

View File

@@ -4,14 +4,13 @@ import HeaderBar from '@app/components/HeaderBar'
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 { useAlbumWithSongs, usePlaylistWithSongs } from '@app/hooks/music'
import { AlbumWithSongs, PlaylistWithSongs, Song } from '@app/models/music'
import { useStore } from '@app/state/store'
import { selectTrackPlayer } from '@app/state/trackplayer'
import { Song, Album, Playlist } from '@app/models/library'
import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import React, { useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
type SongListType = 'album' | 'playlist'
@@ -46,18 +45,19 @@ const SongRenderItem: React.FC<{
const SongListDetails = React.memo<{
title: string
type: SongListType
songList?: AlbumWithSongs | PlaylistWithSongs
songList?: Album | Playlist
songs?: Song[]
subtitle?: string
}>(({ title, songList, subtitle, type }) => {
}>(({ title, songList, songs, subtitle, type }) => {
const coverArtFile = useCoverArtFile(songList?.coverArt, 'thumbnail')
const [headerColor, setHeaderColor] = useState<string | undefined>(undefined)
const setQueue = useStore(selectTrackPlayer.setQueue)
const setQueue = useStore(store => store.setQueue)
if (!songList) {
return <SongListDetailsFallback />
}
const _songs = [...songList.songs]
const _songs = [...(songs || [])]
let typeName = ''
if (type === 'album') {
@@ -101,21 +101,26 @@ const SongListDetails = React.memo<{
overScrollMode="never"
windowSize={7}
contentMarginTop={26}
ListEmptyComponent={
songs ? (
<NothingHere style={styles.nothing} />
) : (
<ActivityIndicator size="large" color={colors.accent} style={styles.listLoading} />
)
}
ListHeaderComponent={
<View style={styles.content}>
<CoverArt type="cover" size="original" coverArt={songList.coverArt} style={styles.cover} />
<Text style={styles.title}>{songList.name}</Text>
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : <></>}
{songList.songs.length > 0 && (
<ListPlayerControls
style={styles.controls}
songs={_songs}
typeName={typeName}
queueName={songList.name}
queueContextId={songList.id}
queueContextType={type}
/>
)}
<ListPlayerControls
style={styles.controls}
songs={_songs}
typeName={typeName}
queueName={songList.name}
queueContextId={songList.id}
queueContextType={type}
/>
</View>
}
/>
@@ -127,19 +132,58 @@ const PlaylistView = React.memo<{
id: string
title: string
}>(({ id, title }) => {
const playlist = usePlaylistWithSongs(id)
return <SongListDetails title={title} songList={playlist} subtitle={playlist?.comment} type="playlist" />
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 = useAlbumWithSongs(id)
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])
return (
<SongListDetails
title={title}
songList={album}
songs={songs}
subtitle={(album?.artist || '') + (album?.year ? ' • ' + album?.year : '')}
type="album"
/>
@@ -196,6 +240,12 @@ const styles = StyleSheet.create({
listItem: {
paddingHorizontal: 20,
},
nothing: {
width: '100%',
},
listLoading: {
marginTop: 10,
},
})
export default SongListView

View File

@@ -45,7 +45,7 @@ const SplashPage: React.FC<{}> = ({ children }) => {
const splash = (
<Animated.View style={[styles.splashContainer, animatedStyles]} pointerEvents="none">
<GradientBackground style={styles.background}>
<GradientBackground style={styles.background} height="100%">
<View style={styles.logoContainer}>
<Image style={styles.image} source={require('@res/casette.png')} fadeDuration={0} />
<Text style={styles.text}>subtracks</Text>