redid cover art (again...) and impl a ListItem

This commit is contained in:
austinried 2021-07-24 17:17:55 +09:00
parent 6dd17f2797
commit fbf6060db4
24 changed files with 602 additions and 597 deletions

View File

@ -16,7 +16,7 @@
android:label="@string/app_name" android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode"
android:launchMode="singleTask" android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustPan">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />

View File

@ -1,207 +0,0 @@
import CoverArt from '@app/components/CoverArt'
import { artistArtAtomFamily } from '@app/state/music'
import colors from '@app/styles/colors'
import { useLayout } from '@react-native-community/hooks'
import { useAtomValue } from 'jotai/utils'
import React from 'react'
import { ActivityIndicator, StyleSheet, View } from 'react-native'
import FastImage from 'react-native-fast-image'
import LinearGradient from 'react-native-linear-gradient'
import IconFA5 from 'react-native-vector-icons/FontAwesome5'
interface ArtistArtSizeProps {
height: number
width: number
}
interface ArtistArtXUpProps extends ArtistArtSizeProps {
albumCoverUris: string[]
}
interface ArtistArtProps extends ArtistArtSizeProps {
id: string
round?: boolean
}
const PlaceholderContainer: React.FC<ArtistArtSizeProps> = ({ height, width, children }) => {
const layout = useLayout()
return (
<LinearGradient
onLayout={layout.onLayout}
colors={[colors.accent, colors.accentLow]}
style={[styles.placeholderContainer, { height, width }]}>
<IconFA5 name="microphone" color="black" size={layout.width / 1.8} style={styles.placeholderIcon} />
{children}
</LinearGradient>
)
}
const FourUp = React.memo<ArtistArtXUpProps>(({ height, width, albumCoverUris }) => {
const halfHeight = height / 2
const halfWidth = width / 2
return (
<PlaceholderContainer height={height} width={width}>
<View style={[styles.artRow, { width, height: halfHeight }]}>
<FastImage
source={{ uri: albumCoverUris[0] }}
style={{ height: halfHeight, width: halfWidth }}
resizeMode={FastImage.resizeMode.cover}
/>
<FastImage
source={{ uri: albumCoverUris[1] }}
style={{ height: halfHeight, width: halfWidth }}
resizeMode={FastImage.resizeMode.cover}
/>
</View>
<View style={[styles.artRow, { width, height: halfHeight }]}>
<FastImage
source={{ uri: albumCoverUris[2] }}
style={{ height: halfHeight, width: halfWidth }}
resizeMode={FastImage.resizeMode.cover}
/>
<FastImage
source={{ uri: albumCoverUris[3] }}
style={{ height: halfHeight, width: halfWidth }}
resizeMode={FastImage.resizeMode.cover}
/>
</View>
</PlaceholderContainer>
)
})
const ThreeUp = React.memo<ArtistArtXUpProps>(({ height, width, albumCoverUris }) => {
const halfHeight = height / 2
const halfWidth = width / 2
return (
<PlaceholderContainer height={height} width={width}>
<View style={[styles.artRow, { width, height: halfHeight }]}>
<FastImage
source={{ uri: albumCoverUris[0] }}
style={{ height: halfHeight, width }}
resizeMode={FastImage.resizeMode.cover}
/>
</View>
<View style={[styles.artRow, { width, height: halfHeight }]}>
<FastImage
source={{ uri: albumCoverUris[1] }}
style={{ height: halfHeight, width: halfWidth }}
resizeMode={FastImage.resizeMode.cover}
/>
<FastImage
source={{ uri: albumCoverUris[2] }}
style={{ height: halfHeight, width: halfWidth }}
resizeMode={FastImage.resizeMode.cover}
/>
</View>
</PlaceholderContainer>
)
})
const TwoUp = React.memo<ArtistArtXUpProps>(({ height, width, albumCoverUris }) => {
const halfHeight = height / 2
return (
<PlaceholderContainer height={height} width={width}>
<View style={[styles.artRow, { width, height: halfHeight }]}>
<FastImage
source={{ uri: albumCoverUris[0] }}
style={{ height: halfHeight, width }}
resizeMode={FastImage.resizeMode.cover}
/>
</View>
<View style={[styles.artRow, { width, height: halfHeight }]}>
<FastImage
source={{ uri: albumCoverUris[1] }}
style={{ height: halfHeight, width }}
resizeMode={FastImage.resizeMode.cover}
/>
</View>
</PlaceholderContainer>
)
})
const OneUp = React.memo<ArtistArtXUpProps>(({ height, width, albumCoverUris }) => (
<PlaceholderContainer height={height} width={width}>
<FastImage source={{ uri: albumCoverUris[0] }} style={{ height, width }} resizeMode={FastImage.resizeMode.cover} />
</PlaceholderContainer>
))
const NoneUp = React.memo<ArtistArtSizeProps>(({ height, width }) => (
<PlaceholderContainer height={height} width={width} />
))
const ArtistArt = React.memo<ArtistArtProps>(({ id, height, width, round }) => {
const artistArt = useAtomValue(artistArtAtomFamily(id))
round = round === undefined ? true : round
const Placeholder = () => {
if (!artistArt) {
return <NoneUp height={height} width={width} />
}
const { albumCoverUris } = artistArt
if (albumCoverUris.length >= 4) {
return <FourUp height={height} width={width} albumCoverUris={albumCoverUris} />
}
if (albumCoverUris.length === 3) {
return <ThreeUp height={height} width={width} albumCoverUris={albumCoverUris} />
}
if (albumCoverUris.length === 2) {
return <TwoUp height={height} width={width} albumCoverUris={albumCoverUris} />
}
if (albumCoverUris.length === 1) {
return <OneUp height={height} width={width} albumCoverUris={albumCoverUris} />
}
return <NoneUp height={height} width={width} />
}
return (
<View style={[styles.container, round ? { borderRadius: height / 2 } : {}]}>
<CoverArt
FallbackComponent={Placeholder}
style={{ height, width }}
coverArtUri={artistArt?.uri}
resizeMode={FastImage.resizeMode.cover}
/>
</View>
)
})
const ArtistArtFallback = React.memo<ArtistArtProps>(({ height, width }) => (
<View style={[styles.fallback, { height, width }]}>
<ActivityIndicator size="large" color={colors.accent} />
</View>
))
const ArtistArtLoader: React.FC<ArtistArtProps> = props => (
<React.Suspense fallback={<ArtistArtFallback {...props} />}>
<ArtistArt {...props} />
</React.Suspense>
)
const styles = StyleSheet.create({
placeholderContainer: {
alignItems: 'center',
justifyContent: 'center',
},
placeholderIcon: {
position: 'absolute',
},
artRow: {
flexDirection: 'row',
},
container: {
overflow: 'hidden',
},
fallback: {
alignItems: 'center',
justifyContent: 'center',
},
})
export default React.memo(ArtistArtLoader)

View File

@ -1,69 +1,124 @@
import { artistInfoAtomFamily, useCoverArtUri } from '@app/state/music'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import React, { useState } from 'react' import { useAtomValue } from 'jotai/utils'
import { ActivityIndicator, StyleSheet, View } from 'react-native' import React, { useEffect, useState } from 'react'
import { ActivityIndicator, StyleSheet, View, ViewStyle } from 'react-native'
import FastImage, { ImageStyle } from 'react-native-fast-image' import FastImage, { ImageStyle } from 'react-native-fast-image'
import LinearGradient from 'react-native-linear-gradient'
type CoverImageProps = { type BaseProps = {
uri?: string imageSize?: 'thumbnail' | 'original'
style?: ImageStyle style?: ViewStyle
imageStyle?: ImageStyle
resizeMode?: keyof typeof FastImage.resizeMode resizeMode?: keyof typeof FastImage.resizeMode
onProgress?: () => void round?: boolean
onLoadEnd?: () => void
onError?: () => void
} }
const CoverImage = React.memo<CoverImageProps>(({ uri, style, resizeMode, onProgress, onLoadEnd, onError }) => ( type BaseImageProps = BaseProps & {
enableLoading: () => void
disableLoading: () => void
}
type ArtistIdProp = {
artistId: string
}
type CoverArtProp = {
coverArt?: string
}
type ArtistIdImageProps = BaseImageProps & ArtistIdProp
type CoverArtImageProps = BaseImageProps & CoverArtProp
type CoverArtProps = BaseProps & CoverArtProp & Partial<ArtistIdProp>
const ArtistIdImageLoaded = React.memo<ArtistIdImageProps>(
({ artistId, imageSize, style, imageStyle, resizeMode, enableLoading, disableLoading }) => {
const artistInfo = useAtomValue(artistInfoAtomFamily(artistId))
const uri = imageSize === 'thumbnail' ? artistInfo?.smallImageUrl : artistInfo?.largeImageUrl
return (
<FastImage <FastImage
source={{ uri }} source={{ uri }}
style={style} style={[{ height: style?.height, width: style?.width }, imageStyle]}
resizeMode={resizeMode || FastImage.resizeMode.contain} resizeMode={resizeMode || FastImage.resizeMode.contain}
onProgress={onProgress} onProgress={enableLoading}
onLoadEnd={onLoadEnd} onLoadEnd={disableLoading}
onError={onError}
/> />
)) )
},
)
const Fallback = React.memo<{}>(({}) => { const ArtistIdImageFallback: React.FC<{
return <LinearGradient colors={[colors.accent, colors.accentLow]} style={styles.fallback} /> enableLoading: () => void
}> = ({ enableLoading }) => {
useEffect(() => {
enableLoading()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return <></>
}
const ArtistIdImage = React.memo<ArtistIdImageProps>(props => {
return (
<React.Suspense fallback={<ArtistIdImageFallback enableLoading={props.enableLoading} />}>
<ArtistIdImageLoaded {...props} />
</React.Suspense>
)
}) })
const CoverArt: React.FC<{ const CoverArtImage = React.memo<CoverArtImageProps>(
FallbackComponent?: () => JSX.Element ({ coverArt, imageSize, style, imageStyle, resizeMode, enableLoading, disableLoading }) => {
placeholderIcon?: string const coverArtUri = useCoverArtUri()
height?: string | number
width?: string | number return (
coverArtUri?: string <FastImage
resizeMode?: keyof typeof FastImage.resizeMode source={{ uri: coverArtUri(coverArt, imageSize) }}
style?: ImageStyle style={[{ height: style?.height, width: style?.width }, imageStyle]}
}> = ({ FallbackComponent, coverArtUri, resizeMode, style }) => { resizeMode={resizeMode || FastImage.resizeMode.contain}
onProgress={enableLoading}
onLoadEnd={disableLoading}
/>
)
},
)
const CoverArt: React.FC<CoverArtProps> = ({ coverArt, artistId, resizeMode, imageSize, style, imageStyle, round }) => {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [fallbackVisible, setFallbackVisible] = useState(false)
const enableLoading = React.useCallback(() => setLoading(true), []) const enableLoading = React.useCallback(() => setLoading(true), [])
const disableLoading = React.useCallback(() => setLoading(false), []) const disableLoading = React.useCallback(() => setLoading(false), [])
const enableFallback = React.useCallback(() => setFallbackVisible(true), [])
imageSize = imageSize === undefined ? 'thumbnail' : 'original'
round = round === undefined ? artistId !== undefined : round
const viewStyles = [style]
if (round) {
viewStyles.push(styles.round)
}
return ( return (
<View style={style}> <View style={viewStyles}>
<CoverImage {artistId ? (
uri={coverArtUri} <ArtistIdImage
artistId={artistId}
imageSize={imageSize}
style={style} style={style}
imageStyle={imageStyle}
resizeMode={resizeMode} resizeMode={resizeMode}
onProgress={enableLoading} enableLoading={enableLoading}
onLoadEnd={disableLoading} disableLoading={disableLoading}
onError={enableFallback}
/> />
{fallbackVisible ? (
FallbackComponent ? (
<View style={styles.fallback}>
<FallbackComponent />
</View>
) : ( ) : (
<Fallback /> <CoverArtImage
) coverArt={coverArt}
) : ( imageSize={imageSize}
<></> style={style}
imageStyle={imageStyle}
resizeMode={resizeMode}
enableLoading={enableLoading}
disableLoading={disableLoading}
/>
)} )}
<ActivityIndicator animating={loading} size="large" color={colors.accent} style={styles.indicator} /> <ActivityIndicator animating={loading} size="large" color={colors.accent} style={styles.indicator} />
</View> </View>
@ -71,16 +126,9 @@ const CoverArt: React.FC<{
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
image: { round: {
height: '100%', overflow: 'hidden',
width: '100%', borderRadius: 1000,
},
fallback: {
height: '100%',
width: '100%',
position: 'absolute',
justifyContent: 'center',
alignItems: 'center',
}, },
indicator: { indicator: {
height: '100%', height: '100%',

View File

@ -1,11 +1,13 @@
import React from 'react' import React from 'react'
import { FlatList, FlatListProps, useWindowDimensions } from 'react-native' import { FlatList, FlatListProps, StyleSheet, useWindowDimensions } from 'react-native'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import GradientBackground from '@app/components/GradientBackground' import GradientBackground from '@app/components/GradientBackground'
function GradientFlatList<ItemT>(props: FlatListProps<ItemT>) { function GradientFlatList<ItemT>(props: FlatListProps<ItemT>) {
const layout = useWindowDimensions() const layout = useWindowDimensions()
const contentContainerStyle = StyleSheet.flatten(props.contentContainerStyle)
return ( return (
<FlatList <FlatList
{...props} {...props}
@ -16,6 +18,8 @@ function GradientFlatList<ItemT>(props: FlatListProps<ItemT>) {
ListHeaderComponent={() => <GradientBackground position="relative" />} ListHeaderComponent={() => <GradientBackground position="relative" />}
ListHeaderComponentStyle={{ ListHeaderComponentStyle={{
marginBottom: -layout.height, marginBottom: -layout.height,
marginHorizontal: -(contentContainerStyle.paddingHorizontal || 0),
top: -(contentContainerStyle.paddingTop || 0),
}} }}
/> />
) )

22
app/components/Header.tsx Normal file
View File

@ -0,0 +1,22 @@
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import React from 'react'
import { StyleSheet, Text, TextStyle } from 'react-native'
const Header: React.FC<{
style?: TextStyle
}> = ({ children, style }) => {
return <Text style={[styles.text, style]}>{children}</Text>
}
const styles = StyleSheet.create({
text: {
fontFamily: font.bold,
fontSize: 24,
color: colors.text.primary,
marginTop: 18,
marginBottom: 12,
},
})
export default Header

171
app/components/ListItem.tsx Normal file
View File

@ -0,0 +1,171 @@
import { AlbumListItem, Artist, PlaylistListItem, Song } from '@app/models/music'
import { currentTrackAtom } from '@app/state/trackplayer'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { useAtomValue } from 'jotai/utils'
import React, { useState } from 'react'
import { GestureResponderEvent, StyleSheet, Text, View } from 'react-native'
import IconFA from 'react-native-vector-icons/FontAwesome'
import IconFA5 from 'react-native-vector-icons/FontAwesome5'
import IconMat from 'react-native-vector-icons/MaterialIcons'
import CoverArt from './CoverArt'
import PressableOpacity from './PressableOpacity'
const TitleTextSong = React.memo<{
id: string
title?: string
}>(({ id, title }) => {
const currentTrack = useAtomValue(currentTrackAtom)
const playing = currentTrack?.id === id
return (
<View style={styles.textLine}>
{playing ? <IconFA5 name="play" size={10} color={colors.accent} style={styles.playingIcon} /> : <></>}
<Text style={[styles.title, { color: playing ? colors.accent : colors.text.primary }]}>{title}</Text>
</View>
)
})
const TitleText = React.memo<{
title?: string
}>(({ title }) => {
return (
<View style={styles.textLine}>
<Text style={styles.title}>{title}</Text>
</View>
)
})
const ListItem: React.FC<{
item: Song | AlbumListItem | Artist | PlaylistListItem
onPress?: (event: GestureResponderEvent) => void
showArt?: boolean
showStar?: boolean
listStyle?: 'big' | 'small'
subtitle?: string
}> = ({ item, onPress, showArt, showStar, subtitle, listStyle }) => {
const [starred, setStarred] = useState(false)
showStar = showStar === undefined ? true : showStar
listStyle = listStyle || 'small'
const artSource = item.itemType === 'artist' ? { artistId: item.id } : { coverArt: item.coverArt }
const sizeStyle = listStyle === 'big' ? bigStyles : smallStyles
return (
<View style={[styles.container, sizeStyle.container]}>
<PressableOpacity onPress={onPress} style={styles.item}>
{showArt ? <CoverArt {...artSource} style={{ ...styles.art, ...sizeStyle.art }} resizeMode="cover" /> : <></>}
<View style={styles.text}>
{item.itemType === 'song' ? (
<TitleTextSong id={item.id} title={item.title} />
) : (
<TitleText title={item.name} />
)}
{subtitle ? (
<View style={styles.textLine}>
{starred ? (
<IconMat
name="file-download-done"
size={17}
color={colors.text.secondary}
style={styles.downloadedIcon}
/>
) : (
<></>
)}
<Text style={styles.subtitle}>{subtitle}</Text>
</View>
) : (
<></>
)}
</View>
</PressableOpacity>
<View style={styles.controls}>
{showStar ? (
<PressableOpacity onPress={() => setStarred(!starred)} style={styles.controlItem}>
{starred ? (
<IconFA name="star" size={26} color={colors.accent} />
) : (
<IconFA name="star-o" size={26} color={colors.text.secondary} />
)}
</PressableOpacity>
) : (
<></>
)}
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
marginBottom: 14,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
item: {
flex: 1,
flexDirection: 'row',
justifyContent: 'flex-start',
},
art: {
marginRight: 10,
},
text: {
flex: 1,
},
textLine: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
},
title: {
fontSize: 16,
fontFamily: font.semiBold,
color: colors.text.primary,
},
playingIcon: {
marginRight: 5,
marginLeft: 1,
},
downloadedIcon: {
marginRight: 2,
marginLeft: -3,
},
subtitle: {
fontSize: 14,
fontFamily: font.regular,
color: colors.text.secondary,
},
controls: {
flexDirection: 'row',
alignItems: 'center',
},
controlItem: {
marginLeft: 16,
},
})
const smallStyles = StyleSheet.create({
container: {
minHeight: 50,
},
art: {
height: 50,
width: 50,
},
})
const bigStyles = StyleSheet.create({
container: {
minHeight: 70,
},
art: {
height: 70,
width: 70,
},
})
export default React.memo(ListItem)

View File

@ -70,7 +70,7 @@ const NowPlayingBar = () => {
<View style={styles.subContainer}> <View style={styles.subContainer}>
<CoverArt <CoverArt
style={{ height: styles.subContainer.height, width: styles.subContainer.height }} style={{ height: styles.subContainer.height, width: styles.subContainer.height }}
coverArtUri={track?.artworkThumb} coverArt={track?.coverArt || '-1'}
/> />
<View style={styles.detailsContainer}> <View style={styles.detailsContainer}>
<Text numberOfLines={1} style={styles.detailsTitle}> <Text numberOfLines={1} style={styles.detailsTitle}>

View File

@ -1,116 +0,0 @@
import { Song } from '@app/models/music'
import { currentTrackAtom } from '@app/state/trackplayer'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { useAtomValue } from 'jotai/utils'
import React, { useState } from 'react'
import { GestureResponderEvent, StyleSheet, Text, View } from 'react-native'
import IconFA from 'react-native-vector-icons/FontAwesome'
import IconFA5 from 'react-native-vector-icons/FontAwesome5'
import IconMat from 'react-native-vector-icons/MaterialIcons'
import CoverArt from './CoverArt'
import PressableOpacity from './PressableOpacity'
const SongItem: React.FC<{
song: Song
onPress?: (event: GestureResponderEvent) => void
showArt?: boolean
subtitle?: 'artist' | 'album'
}> = ({ song, onPress, showArt, subtitle }) => {
const currentTrack = useAtomValue(currentTrackAtom)
const [starred, setStarred] = useState(false)
subtitle = subtitle || 'artist'
const playing = currentTrack?.id === song.id
return (
<View style={styles.container}>
<PressableOpacity onPress={onPress} style={styles.item}>
{showArt ? <CoverArt coverArtUri={song.coverArtThumbUri} style={styles.art} /> : <></>}
<View style={styles.text}>
<View style={styles.textLine}>
{playing ? <IconFA5 name="play" size={10} color={colors.accent} style={styles.playingIcon} /> : <></>}
<Text style={[styles.title, { color: playing ? colors.accent : colors.text.primary }]}>{song.title}</Text>
</View>
<View style={styles.textLine}>
{starred ? (
<IconMat
name="file-download-done"
size={17}
color={colors.text.secondary}
style={styles.downloadedIcon}
/>
) : (
<></>
)}
<Text style={styles.subtitle}>{song[subtitle]}</Text>
</View>
</View>
</PressableOpacity>
<View style={styles.controls}>
<PressableOpacity onPress={() => setStarred(!starred)}>
{starred ? (
<IconFA name="star" size={26} color={colors.accent} />
) : (
<IconFA name="star-o" size={26} color={colors.text.secondary} />
)}
</PressableOpacity>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
marginBottom: 14,
minHeight: 50,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
item: {
flex: 1,
flexDirection: 'row',
justifyContent: 'flex-start',
},
art: {
marginRight: 10,
height: 50,
width: 50,
},
text: {
flex: 1,
},
textLine: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
},
title: {
fontSize: 16,
fontFamily: font.semiBold,
},
playingIcon: {
marginRight: 5,
marginLeft: 1,
},
downloadedIcon: {
marginRight: 2,
marginLeft: -3,
},
subtitle: {
fontSize: 14,
fontFamily: font.regular,
color: colors.text.secondary,
},
controls: {
flexDirection: 'row',
alignItems: 'center',
marginLeft: 16,
},
more: {
marginLeft: 8,
},
})
export default React.memo(SongItem)

View File

@ -1,34 +1,32 @@
export interface Artist { export interface Artist {
itemType: 'artist'
id: string id: string
name: string name: string
starred?: Date starred?: Date
coverArt?: string
} }
export interface ArtistInfo extends Artist { export interface ArtistInfo extends Artist {
albums: Album[] albums: Album[]
smallImageUrl?: string
mediumImageUrl?: string mediumImageUrl?: string
largeImageUrl?: string largeImageUrl?: string
albumCoverUris: string[]
topSongs: Song[] topSongs: Song[]
} }
export interface ArtistArt {
uri?: string
albumCoverUris: string[]
}
export interface AlbumListItem { export interface AlbumListItem {
itemType: 'album'
id: string id: string
name: string name: string
artist?: string artist?: string
starred?: Date starred?: Date
coverArtThumbUri?: string coverArt?: string
} }
export interface Album extends AlbumListItem { export interface Album extends AlbumListItem {
coverArtUri?: string coverArt?: string
year?: number year?: number
} }
@ -36,19 +34,27 @@ export interface AlbumWithSongs extends Album {
songs: Song[] songs: Song[]
} }
export interface SearchResults {
artists: Artist[]
albums: AlbumListItem[]
songs: Song[]
}
export interface PlaylistListItem { export interface PlaylistListItem {
itemType: 'playlist'
id: string id: string
name: string name: string
comment?: string comment?: string
coverArtThumbUri?: string coverArt?: string
} }
export interface PlaylistWithSongs extends PlaylistListItem { export interface PlaylistWithSongs extends PlaylistListItem {
songs: Song[] songs: Song[]
coverArtUri?: string coverArt?: string
} }
export interface Song { export interface Song {
itemType: 'song'
id: string id: string
album?: string album?: string
artist?: string artist?: string
@ -58,8 +64,7 @@ export interface Song {
starred?: Date starred?: Date
streamUri: string streamUri: string
coverArtUri?: string coverArt?: string
coverArtThumbUri?: string
} }
export type DownloadedSong = { export type DownloadedSong = {

View File

@ -1,10 +1,10 @@
import BottomTabBar from '@app/navigation/BottomTabBar' import BottomTabBar from '@app/navigation/BottomTabBar'
import LibraryTopTabNavigator from '@app/navigation/LibraryTopTabNavigator' import LibraryTopTabNavigator from '@app/navigation/LibraryTopTabNavigator'
import AlbumView from '@app/screens/AlbumView' import AlbumView from '@app/screens/AlbumView'
import ArtistsList from '@app/screens/ArtistsList'
import ArtistView from '@app/screens/ArtistView' import ArtistView from '@app/screens/ArtistView'
import Home from '@app/screens/Home' import Home from '@app/screens/Home'
import PlaylistView from '@app/screens/PlaylistView' import PlaylistView from '@app/screens/PlaylistView'
import Search from '@app/screens/Search'
import SettingsView from '@app/screens/Settings' import SettingsView from '@app/screens/Settings'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import font from '@app/styles/font' import font from '@app/styles/font'
@ -98,7 +98,7 @@ function createTabStackNavigator(Component: React.ComponentType<any>) {
const HomeTab = createTabStackNavigator(Home) const HomeTab = createTabStackNavigator(Home)
const LibraryTab = createTabStackNavigator(LibraryTopTabNavigator) const LibraryTab = createTabStackNavigator(LibraryTopTabNavigator)
const SearchTab = createTabStackNavigator(ArtistsList) const SearchTab = createTabStackNavigator(Search)
const Tab = createBottomTabNavigator() const Tab = createBottomTabNavigator()

View File

@ -3,8 +3,8 @@ import GradientBackground from '@app/components/GradientBackground'
import ImageGradientScrollView from '@app/components/ImageGradientScrollView' import ImageGradientScrollView from '@app/components/ImageGradientScrollView'
import ListPlayerControls from '@app/components/ListPlayerControls' import ListPlayerControls from '@app/components/ListPlayerControls'
import NothingHere from '@app/components/NothingHere' import NothingHere from '@app/components/NothingHere'
import SongItem from '@app/components/SongItem' import ListItem from '@app/components/ListItem'
import { albumAtomFamily } from '@app/state/music' import { albumAtomFamily, useCoverArtUri } from '@app/state/music'
import { useSetQueue } from '@app/state/trackplayer' import { useSetQueue } from '@app/state/trackplayer'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import font from '@app/styles/font' import font from '@app/styles/font'
@ -17,6 +17,7 @@ const AlbumDetails: React.FC<{
id: string id: string
}> = ({ id }) => { }> = ({ id }) => {
const album = useAtomValue(albumAtomFamily(id)) const album = useAtomValue(albumAtomFamily(id))
const coverArtUri = useCoverArtUri()
const setQueue = useSetQueue() const setQueue = useSetQueue()
if (!album) { if (!album) {
@ -36,7 +37,7 @@ const AlbumDetails: React.FC<{
} }
}) })
.map((s, i) => ( .map((s, i) => (
<SongItem key={i} song={s} onPress={() => setQueue(album.songs, album.name, i)} /> <ListItem key={i} item={s} subtitle={s.artist} onPress={() => setQueue(album.songs, album.name, i)} />
))} ))}
</View> </View>
</> </>
@ -44,11 +45,11 @@ const AlbumDetails: React.FC<{
return ( return (
<ImageGradientScrollView <ImageGradientScrollView
imageUri={album.coverArtThumbUri} imageUri={coverArtUri(album.coverArt)}
imageKey={`${album.name}${album.artist}`} imageKey={`${album.name}${album.artist}`}
style={styles.container}> style={styles.container}>
<View style={styles.content}> <View style={styles.content}>
<CoverArt coverArtUri={album.coverArtUri} style={styles.cover} /> <CoverArt coverArt={album.coverArt} style={styles.cover} imageSize="original" />
<Text style={styles.title}>{album.name}</Text> <Text style={styles.title}>{album.name}</Text>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
{album.artist} {album.artist}

View File

@ -1,8 +1,8 @@
import ArtistArt from '@app/components/ArtistArt'
import CoverArt from '@app/components/CoverArt' import CoverArt from '@app/components/CoverArt'
import GradientScrollView from '@app/components/GradientScrollView' import GradientScrollView from '@app/components/GradientScrollView'
import Header from '@app/components/Header'
import PressableOpacity from '@app/components/PressableOpacity' import PressableOpacity from '@app/components/PressableOpacity'
import SongItem from '@app/components/SongItem' import ListItem from '@app/components/ListItem'
import { Album } from '@app/models/music' import { Album } from '@app/models/music'
import { artistInfoAtomFamily } from '@app/state/music' import { artistInfoAtomFamily } from '@app/state/music'
import { useSetQueue } from '@app/state/trackplayer' import { useSetQueue } from '@app/state/trackplayer'
@ -26,7 +26,7 @@ const AlbumItem = React.memo<{
<PressableOpacity <PressableOpacity
onPress={() => navigation.navigate('AlbumView', { id: album.id, title: album.name })} onPress={() => navigation.navigate('AlbumView', { id: album.id, title: album.name })}
style={[styles.albumItem, { width }]}> style={[styles.albumItem, { width }]}>
<CoverArt coverArtUri={album.coverArtThumbUri} style={{ height, width }} /> <CoverArt coverArt={album.coverArt} style={{ height, width }} />
<Text style={styles.albumTitle}>{album.name}</Text> <Text style={styles.albumTitle}>{album.name}</Text>
<Text style={styles.albumYear}> {album.year ? album.year : ''}</Text> <Text style={styles.albumYear}> {album.year ? album.year : ''}</Text>
</PressableOpacity> </PressableOpacity>
@ -47,25 +47,19 @@ const ArtistDetails: React.FC<{ id: string }> = ({ id }) => {
const TopSongs = () => ( const TopSongs = () => (
<> <>
<Text style={styles.header}>Top Songs</Text> <Header>Top Songs</Header>
{artist.topSongs.map((s, i) => ( {artist.topSongs.map((s, i) => (
<SongItem <ListItem
key={i} key={i}
song={s} item={s}
showArt={true} showArt={true}
subtitle="album" subtitle={s.album}
onPress={() => setQueue(artist.topSongs, `Top Songs: ${artist.name}`, i)} onPress={() => setQueue(artist.topSongs, `Top Songs: ${artist.name}`, i)}
/> />
))} ))}
</> </>
) )
const ArtistCoverFallback = () => (
<View style={styles.artistCover}>
<ArtistArt id={artist.id} round={false} height={artistCoverHeight} width={coverLayout.width} />
</View>
)
return ( return (
<GradientScrollView <GradientScrollView
onLayout={coverLayout.onLayout} onLayout={coverLayout.onLayout}
@ -73,17 +67,18 @@ const ArtistDetails: React.FC<{ id: string }> = ({ id }) => {
style={styles.scroll} style={styles.scroll}
contentContainerStyle={styles.scrollContent}> contentContainerStyle={styles.scrollContent}>
<CoverArt <CoverArt
FallbackComponent={ArtistCoverFallback} artistId={artist.id}
coverArtUri={artist.largeImageUrl}
style={styles.artistCover} style={styles.artistCover}
resizeMode={FastImage.resizeMode.cover} resizeMode={FastImage.resizeMode.cover}
round={false}
imageSize="original"
/> />
<View style={styles.titleContainer}> <View style={styles.titleContainer}>
<Text style={styles.title}>{artist.name}</Text> <Text style={styles.title}>{artist.name}</Text>
</View> </View>
<View style={styles.container}> <View style={styles.container}>
{artist.topSongs.length > 0 ? <TopSongs /> : <></>} {artist.topSongs.length > 0 ? <TopSongs /> : <></>}
<Text style={styles.header}>Albums</Text> <Header>Albums</Header>
<View style={styles.albums} onLayout={albumsLayout.onLayout}> <View style={styles.albums} onLayout={albumsLayout.onLayout}>
{artist.albums.map(a => ( {artist.albums.map(a => (
<AlbumItem key={a.id} album={a} height={albumSize} width={albumSize} /> <AlbumItem key={a.id} album={a} height={albumSize} width={albumSize} />
@ -140,13 +135,6 @@ const styles = StyleSheet.create({
paddingHorizontal: 10, paddingHorizontal: 10,
marginBottom: 10, marginBottom: 10,
}, },
header: {
fontFamily: font.bold,
fontSize: 24,
color: colors.text.primary,
marginTop: 20,
marginBottom: 14,
},
artistCover: { artistCover: {
position: 'absolute', position: 'absolute',
height: artistCoverHeight, height: artistCoverHeight,

View File

@ -1,5 +1,6 @@
import CoverArt from '@app/components/CoverArt' import CoverArt from '@app/components/CoverArt'
import GradientScrollView from '@app/components/GradientScrollView' import GradientScrollView from '@app/components/GradientScrollView'
import Header from '@app/components/Header'
import NothingHere from '@app/components/NothingHere' import NothingHere from '@app/components/NothingHere'
import PressableOpacity from '@app/components/PressableOpacity' import PressableOpacity from '@app/components/PressableOpacity'
import { AlbumListItem } from '@app/models/music' import { AlbumListItem } from '@app/models/music'
@ -30,7 +31,7 @@ const AlbumItem = React.memo<{
onPress={() => navigation.navigate('AlbumView', { id: album.id, title: album.name })} onPress={() => navigation.navigate('AlbumView', { id: album.id, title: album.name })}
key={album.id} key={album.id}
style={styles.item}> style={styles.item}>
<CoverArt coverArtUri={album.coverArtThumbUri} style={{ height: styles.item.width, width: styles.item.width }} /> <CoverArt coverArt={album.coverArt} style={{ height: styles.item.width, width: styles.item.width }} />
<Text style={styles.title} numberOfLines={1}> <Text style={styles.title} numberOfLines={1}>
{album.name} {album.name}
</Text> </Text>
@ -66,7 +67,7 @@ const Category = React.memo<{
return ( return (
<View style={styles.category}> <View style={styles.category}>
<Text style={styles.categoryHeader}>{name}</Text> <Header style={styles.header}>{name}</Header>
{data.length > 0 ? <Albums /> : <Nothing />} {data.length > 0 ? <Albums /> : <Nothing />}
</View> </View>
) )
@ -111,24 +112,19 @@ const styles = StyleSheet.create({
content: { content: {
paddingBottom: 20, paddingBottom: 20,
}, },
category: { header: {
marginTop: 12,
},
categoryHeader: {
fontFamily: font.bold,
fontSize: 24,
color: colors.text.primary,
paddingHorizontal: 20, paddingHorizontal: 20,
marginTop: 4, },
category: {
// marginTop: 12,
}, },
nothingHereContent: { nothingHereContent: {
width: '100%', width: '100%',
height: 200, height: 190,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
artScroll: { artScroll: {
marginTop: 10,
height: 190, height: 190,
}, },
artScrollContent: { artScrollContent: {

View File

@ -17,15 +17,15 @@ const AlbumItem = React.memo<{
size: number size: number
height: number height: number
artist?: string artist?: string
coverArtUri?: string coverArt?: string
}>(({ id, name, artist, size, height, coverArtUri }) => { }>(({ id, name, artist, size, height, coverArt }) => {
const navigation = useNavigation() const navigation = useNavigation()
return ( return (
<PressableOpacity <PressableOpacity
style={[styles.item, { maxWidth: size, height }]} style={[styles.item, { maxWidth: size, height }]}
onPress={() => navigation.navigate('AlbumView', { id, title: name })}> onPress={() => navigation.navigate('AlbumView', { id, title: name })}>
<CoverArt coverArtUri={coverArtUri} style={{ height: size, width: size }} /> <CoverArt coverArt={coverArt} style={{ height: size, width: size }} />
<View style={styles.itemDetails}> <View style={styles.itemDetails}>
<Text style={styles.title} numberOfLines={1}> <Text style={styles.title} numberOfLines={1}>
{name} {name}
@ -43,7 +43,7 @@ const AlbumListRenderItem: React.FC<{
}> = ({ item }) => ( }> = ({ item }) => (
<AlbumItem <AlbumItem
id={item.album.id} id={item.album.id}
coverArtUri={item.album.coverArtThumbUri} coverArt={item.album.coverArt}
name={item.album.name} name={item.album.name}
artist={item.album.artist} artist={item.album.artist}
size={item.size} size={item.size}

View File

@ -1,25 +1,23 @@
import ArtistArt from '@app/components/ArtistArt'
import GradientFlatList from '@app/components/GradientFlatList' import GradientFlatList from '@app/components/GradientFlatList'
import PressableOpacity from '@app/components/PressableOpacity' import ListItem from '@app/components/ListItem'
import { Artist } from '@app/models/music' import { Artist } from '@app/models/music'
import { artistsAtom, artistsUpdatingAtom, useUpdateArtists } from '@app/state/music' import { artistsAtom, artistsUpdatingAtom, useUpdateArtists } from '@app/state/music'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { useAtomValue } from 'jotai/utils' import { useAtomValue } from 'jotai/utils'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { StyleSheet, Text } from 'react-native' import { StyleSheet } from 'react-native'
const ArtistItem = React.memo<{ item: Artist }>(({ item }) => { const ArtistItem = React.memo<{ item: Artist }>(({ item }) => {
const navigation = useNavigation() const navigation = useNavigation()
return ( return (
<PressableOpacity <ListItem
style={styles.item} item={item}
onPress={() => navigation.navigate('ArtistView', { id: item.id, title: item.name })}> showArt={true}
<ArtistArt id={item.id} width={styles.art.width} height={styles.art.height} /> showStar={false}
<Text style={styles.title}>{item.name}</Text> listStyle="big"
</PressableOpacity> onPress={() => navigation.navigate('ArtistView', { id: item.id, title: item.name })}
/>
) )
}) })
@ -52,23 +50,8 @@ const ArtistsList = () => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
listContent: { listContent: {
minHeight: '100%', minHeight: '100%',
}, paddingHorizontal: 10,
item: { paddingTop: 6,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
marginVertical: 6,
marginLeft: 10,
},
title: {
fontFamily: font.semiBold,
fontSize: 16,
color: colors.text.primary,
marginLeft: 10,
},
art: {
height: 70,
width: 70,
}, },
}) })

View File

@ -1,36 +1,24 @@
import CoverArt from '@app/components/CoverArt'
import GradientFlatList from '@app/components/GradientFlatList' import GradientFlatList from '@app/components/GradientFlatList'
import PressableOpacity from '@app/components/PressableOpacity' import ListItem from '@app/components/ListItem'
import { PlaylistListItem } from '@app/models/music' import { PlaylistListItem } from '@app/models/music'
import { playlistsAtom, playlistsUpdatingAtom, useUpdatePlaylists } from '@app/state/music' import { playlistsAtom, playlistsUpdatingAtom, useUpdatePlaylists } from '@app/state/music'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { useAtomValue } from 'jotai/utils' import { useAtomValue } from 'jotai/utils'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { StyleSheet, Text, View } from 'react-native' import { StyleSheet } from 'react-native'
const PlaylistItem = React.memo<{ item: PlaylistListItem }>(({ item }) => { const PlaylistItem = React.memo<{ item: PlaylistListItem }>(({ item }) => {
const navigation = useNavigation() const navigation = useNavigation()
return ( return (
<PressableOpacity <ListItem
style={styles.item} item={item}
onPress={() => navigation.navigate('PlaylistView', { id: item.id, title: item.name })}> showArt={true}
<CoverArt coverArtUri={item.coverArtThumbUri} style={styles.art} /> showStar={false}
<View style={styles.text}> listStyle="big"
<Text style={styles.title} numberOfLines={1}> subtitle={item.comment}
{item.name} onPress={() => navigation.navigate('PlaylistView', { id: item.id, title: item.name })}
</Text> />
{item.comment ? (
<Text style={styles.subtitle} numberOfLines={1}>
{item.comment}
</Text>
) : (
<></>
)}
</View>
</PressableOpacity>
) )
}) })
@ -63,30 +51,8 @@ const PlaylistsList = () => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
listContent: { listContent: {
minHeight: '100%', minHeight: '100%',
}, paddingHorizontal: 10,
item: { paddingTop: 6,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
marginVertical: 6,
marginLeft: 10,
},
text: {
marginLeft: 10,
},
title: {
fontFamily: font.semiBold,
fontSize: 16,
color: colors.text.primary,
},
subtitle: {
fontFamily: font.regular,
fontSize: 14,
color: colors.text.secondary,
},
art: {
height: 70,
width: 70,
}, },
}) })

View File

@ -75,7 +75,7 @@ const SongCoverArt = () => {
return ( return (
<View style={coverArtStyles.container}> <View style={coverArtStyles.container}>
<CoverArt coverArtUri={track?.artwork as string} style={coverArtStyles.image} /> <CoverArt coverArt={track?.coverArt} style={coverArtStyles.image} imageSize="original" />
</View> </View>
) )
} }
@ -318,7 +318,7 @@ const NowPlayingLayout: React.FC<NowPlayingProps> = ({ navigation }) => {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<ImageGradientBackground imageUri={track?.artworkThumb as string} imageKey={`${track?.album}${track?.artist}`} /> <ImageGradientBackground imageUri={track?.artwork as string} imageKey={`${track?.album}${track?.artist}`} />
<NowPlayingHeader /> <NowPlayingHeader />
<View style={styles.content}> <View style={styles.content}>
<SongCoverArt /> <SongCoverArt />

View File

@ -3,8 +3,8 @@ import GradientBackground from '@app/components/GradientBackground'
import ImageGradientScrollView from '@app/components/ImageGradientScrollView' import ImageGradientScrollView from '@app/components/ImageGradientScrollView'
import ListPlayerControls from '@app/components/ListPlayerControls' import ListPlayerControls from '@app/components/ListPlayerControls'
import NothingHere from '@app/components/NothingHere' import NothingHere from '@app/components/NothingHere'
import SongItem from '@app/components/SongItem' import ListItem from '@app/components/ListItem'
import { playlistAtomFamily } from '@app/state/music' import { playlistAtomFamily, useCoverArtUri } from '@app/state/music'
import { useSetQueue } from '@app/state/trackplayer' import { useSetQueue } from '@app/state/trackplayer'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import font from '@app/styles/font' import font from '@app/styles/font'
@ -18,6 +18,7 @@ const PlaylistDetails: React.FC<{
}> = ({ id }) => { }> = ({ id }) => {
const playlist = useAtomValue(playlistAtomFamily(id)) const playlist = useAtomValue(playlistAtomFamily(id))
const setQueue = useSetQueue() const setQueue = useSetQueue()
const coverArtUri = useCoverArtUri()
if (!playlist) { if (!playlist) {
return <></> return <></>
@ -33,7 +34,13 @@ const PlaylistDetails: React.FC<{
/> />
<View style={styles.songs}> <View style={styles.songs}>
{playlist.songs.map((s, i) => ( {playlist.songs.map((s, i) => (
<SongItem key={i} song={s} showArt={true} onPress={() => setQueue(playlist.songs, playlist.name, i)} /> <ListItem
key={i}
item={s}
subtitle={s.artist}
showArt={true}
onPress={() => setQueue(playlist.songs, playlist.name, i)}
/>
))} ))}
</View> </View>
</> </>
@ -41,11 +48,11 @@ const PlaylistDetails: React.FC<{
return ( return (
<ImageGradientScrollView <ImageGradientScrollView
imageUri={playlist.coverArtThumbUri} imageUri={coverArtUri(playlist.coverArt)}
imageKey={`${playlist.id}${playlist.name}`} imageKey={`${playlist.id}${playlist.name}`}
style={styles.container}> style={styles.container}>
<View style={styles.content}> <View style={styles.content}>
<CoverArt coverArtUri={playlist.coverArtUri} style={styles.cover} /> <CoverArt coverArt={playlist.coverArt} style={styles.cover} imageSize="original" />
<Text style={styles.title}>{playlist.name}</Text> <Text style={styles.title}>{playlist.name}</Text>
{playlist.comment ? <Text style={styles.subtitle}>{playlist.comment}</Text> : <></>} {playlist.comment ? <Text style={styles.subtitle}>{playlist.comment}</Text> : <></>}
{playlist.songs.length > 0 ? <Songs /> : <NothingHere height={350} width={250} />} {playlist.songs.length > 0 ? <Songs /> : <NothingHere height={350} width={250} />}

75
app/screens/Search.tsx Normal file
View File

@ -0,0 +1,75 @@
import GradientScrollView from '@app/components/GradientScrollView'
import Header from '@app/components/Header'
import ListItem from '@app/components/ListItem'
import { searchResultsAtom, useUpdateSearchResults } from '@app/state/music'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { useAtomValue } from 'jotai/utils'
import React, { useState } from 'react'
import { StatusBar, StyleSheet, View, TextInput } from 'react-native'
const Search = () => {
const [text, setText] = useState('')
const updateSearch = useUpdateSearchResults()
const results = useAtomValue(searchResultsAtom)
const onSubmitEditing = () => {
console.log(text)
updateSearch(text)
}
return (
<GradientScrollView style={styles.scroll} contentContainerStyle={styles.scrollContentContainer}>
<View style={styles.content}>
<TextInput
style={styles.textInput}
placeholder="Search"
placeholderTextColor="grey"
selectionColor={colors.text.secondary}
value={text}
onChangeText={setText}
onSubmitEditing={onSubmitEditing}
/>
<Header>Artists</Header>
{results.artists.map(a => (
<ListItem key={a.id} item={a} showArt={true} showStar={false} />
))}
<Header>Albums</Header>
{results.albums.map(a => (
<ListItem key={a.id} item={a} showArt={true} showStar={false} subtitle={a.artist} />
))}
<Header>Songs</Header>
{results.songs.map(a => (
<ListItem key={a.id} item={a} showArt={true} showStar={false} subtitle={a.artist} />
))}
</View>
</GradientScrollView>
)
}
const styles = StyleSheet.create({
scroll: {
flex: 1,
},
scrollContentContainer: {
paddingTop: StatusBar.currentHeight,
},
content: {
paddingHorizontal: 20,
},
textInput: {
backgroundColor: '#515151',
fontFamily: font.regular,
fontSize: 18,
color: colors.text.primary,
marginTop: 20,
paddingHorizontal: 12,
},
itemText: {
color: colors.text.primary,
fontFamily: font.regular,
fontSize: 14,
},
})
export default Search

View File

@ -3,10 +3,10 @@ import {
AlbumListItem, AlbumListItem,
AlbumWithSongs, AlbumWithSongs,
Artist, Artist,
ArtistArt,
ArtistInfo, ArtistInfo,
PlaylistListItem, PlaylistListItem,
PlaylistWithSongs, PlaylistWithSongs,
SearchResults,
Song, Song,
} from '@app/models/music' } from '@app/models/music'
import { activeServerAtom, homeListTypesAtom } from '@app/state/settings' import { activeServerAtom, homeListTypesAtom } from '@app/state/settings'
@ -19,7 +19,7 @@ import {
PlaylistElement, PlaylistElement,
PlaylistWithSongsElement, PlaylistWithSongsElement,
} from '@app/subsonic/elements' } from '@app/subsonic/elements'
import { GetAlbumList2Type } from '@app/subsonic/params' import { GetAlbumList2Type, GetCoverArtParams } from '@app/subsonic/params'
import { GetArtistResponse } from '@app/subsonic/responses' import { GetArtistResponse } from '@app/subsonic/responses'
import { atom, useAtom } from 'jotai' import { atom, useAtom } from 'jotai'
import { atomFamily, useAtomValue, useUpdateAtom } from 'jotai/utils' import { atomFamily, useAtomValue, useUpdateAtom } from 'jotai/utils'
@ -87,7 +87,7 @@ export const useUpdateHomeLists = () => {
for (const type of types) { for (const type of types) {
promises.push( promises.push(
client.getAlbumList2({ type: type as GetAlbumList2Type, size: 20 }).then(response => { client.getAlbumList2({ type: type as GetAlbumList2Type, size: 20 }).then(response => {
updateHomeList({ type, albums: response.data.albums.map(a => mapAlbumID3toAlbumListItem(a, client)) }) updateHomeList({ type, albums: response.data.albums.map(mapAlbumID3toAlbumListItem) })
}), }),
) )
} }
@ -97,6 +97,40 @@ export const useUpdateHomeLists = () => {
} }
} }
export const searchResultsUpdatingAtom = atom(false)
export const searchResultsAtom = atom<SearchResults>({
artists: [],
albums: [],
songs: [],
})
export const useUpdateSearchResults = () => {
const server = useAtomValue(activeServerAtom)
const updateList = useUpdateAtom(searchResultsAtom)
const [updating, setUpdating] = useAtom(searchResultsUpdatingAtom)
if (!server) {
return async () => {}
}
return async (query: string) => {
if (updating) {
return
}
setUpdating(true)
const client = new SubsonicApiClient(server)
const response = await client.search3({ query })
updateList({
artists: response.data.artists.map(mapArtistID3toArtist),
albums: response.data.albums.map(mapAlbumID3toAlbumListItem),
songs: response.data.songs.map(a => mapChildToSong(a, client)),
})
setUpdating(false)
}
}
export const playlistsUpdatingAtom = atom(false) export const playlistsUpdatingAtom = atom(false)
export const playlistsAtom = atom<PlaylistListItem[]>([]) export const playlistsAtom = atom<PlaylistListItem[]>([])
@ -118,7 +152,7 @@ export const useUpdatePlaylists = () => {
const client = new SubsonicApiClient(server) const client = new SubsonicApiClient(server)
const response = await client.getPlaylists() const response = await client.getPlaylists()
updateList(response.data.playlists.map(a => mapPlaylistListItem(a, client))) updateList(response.data.playlists.map(mapPlaylistListItem))
setUpdating(false) setUpdating(false)
} }
} }
@ -157,7 +191,7 @@ export const useUpdateAlbumList = () => {
const client = new SubsonicApiClient(server) const client = new SubsonicApiClient(server)
const response = await client.getAlbumList2({ type: 'alphabeticalByArtist', size: 500 }) const response = await client.getAlbumList2({ type: 'alphabeticalByArtist', size: 500 })
updateList(response.data.albums.map(a => mapAlbumID3toAlbumListItem(a, client))) updateList(response.data.albums.map(mapAlbumID3toAlbumListItem))
setUpdating(false) setUpdating(false)
} }
} }
@ -193,36 +227,32 @@ export const artistInfoAtomFamily = atomFamily((id: string) =>
}), }),
) )
export const artistArtAtomFamily = atomFamily((id: string) => export const useCoverArtUri = () => {
atom<ArtistArt | undefined>(async get => { const server = useAtomValue(activeServerAtom)
const artistInfo = get(artistInfoAtomFamily(id))
if (!artistInfo) { if (!server) {
return undefined return () => undefined
} }
const albumCoverUris = artistInfo.albums const client = new SubsonicApiClient(server)
.filter(a => a.coverArtThumbUri !== undefined)
.sort((a, b) => {
if (b.year && a.year) {
return b.year - a.year
} else {
return a.name.localeCompare(b.name)
}
})
.map(a => a.coverArtThumbUri) as string[]
return { return (coverArt?: string, size: 'thumbnail' | 'original' = 'thumbnail') => {
albumCoverUris, const params: GetCoverArtParams = { id: coverArt || '-1' }
uri: artistInfo.largeImageUrl, if (size === 'thumbnail') {
params.size = '256'
}
return client.getCoverArtUri(params)
}
} }
}),
)
function mapArtistID3toArtist(artist: ArtistID3Element): Artist { function mapArtistID3toArtist(artist: ArtistID3Element): Artist {
return { return {
itemType: 'artist',
id: artist.id, id: artist.id,
name: artist.name, name: artist.name,
starred: artist.starred, starred: artist.starred,
coverArt: artist.coverArt,
} }
} }
@ -234,63 +264,40 @@ function mapArtistInfo(
): ArtistInfo { ): ArtistInfo {
const { artist, albums } = artistResponse const { artist, albums } = artistResponse
const mappedAlbums = albums.map(a => mapAlbumID3toAlbum(a, client)) const mappedAlbums = albums.map(mapAlbumID3toAlbum)
const albumCoverUris = mappedAlbums
.sort((a, b) => {
if (a.year && b.year) {
return b.year - a.year
} else {
return a.name.localeCompare(b.name) - 9000
}
})
.map(a => a.coverArtThumbUri)
.filter(a => a !== undefined) as string[]
return { return {
...mapArtistID3toArtist(artist), ...mapArtistID3toArtist(artist),
albums: mappedAlbums, albums: mappedAlbums,
albumCoverUris, smallImageUrl: info.smallImageUrl,
mediumImageUrl: info.mediumImageUrl, mediumImageUrl: info.mediumImageUrl,
largeImageUrl: info.largeImageUrl, largeImageUrl: info.largeImageUrl,
topSongs: topSongs.map(c => mapChildToSong(c, client)).slice(0, 5), topSongs: topSongs.map(s => mapChildToSong(s, client)).slice(0, 5),
} }
} }
function mapCoverArtUri(item: { coverArt?: string }, client: SubsonicApiClient) { function mapAlbumID3toAlbumListItem(album: AlbumID3Element): AlbumListItem {
return {
coverArtUri: item.coverArt ? client.getCoverArtUri({ id: item.coverArt }) : client.getCoverArtUri({ id: '-1' }),
}
}
function mapCoverArtThumbUri(item: { coverArt?: string }, client: SubsonicApiClient) {
return {
coverArtThumbUri: item.coverArt
? client.getCoverArtUri({ id: item.coverArt, size: '256' })
: client.getCoverArtUri({ id: '-1', size: '256' }),
}
}
function mapAlbumID3toAlbumListItem(album: AlbumID3Element, client: SubsonicApiClient): AlbumListItem {
return { return {
itemType: 'album',
id: album.id, id: album.id,
name: album.name, name: album.name,
artist: album.artist, artist: album.artist,
starred: album.starred, starred: album.starred,
...mapCoverArtThumbUri(album, client), coverArt: album.coverArt,
} }
} }
function mapAlbumID3toAlbum(album: AlbumID3Element, client: SubsonicApiClient): Album { function mapAlbumID3toAlbum(album: AlbumID3Element): Album {
return { return {
...mapAlbumID3toAlbumListItem(album, client), ...mapAlbumID3toAlbumListItem(album),
...mapCoverArtUri(album, client), coverArt: album.coverArt,
...mapCoverArtThumbUri(album, client),
year: album.year, year: album.year,
} }
} }
function mapChildToSong(child: ChildElement, client: SubsonicApiClient): Song { function mapChildToSong(child: ChildElement, client: SubsonicApiClient): Song {
return { return {
itemType: 'song',
id: child.id, id: child.id,
album: child.album, album: child.album,
artist: child.artist, artist: child.artist,
@ -298,9 +305,8 @@ function mapChildToSong(child: ChildElement, client: SubsonicApiClient): Song {
track: child.track, track: child.track,
duration: child.duration, duration: child.duration,
starred: child.starred, starred: child.starred,
coverArt: child.coverArt,
streamUri: client.streamUri({ id: child.id }), streamUri: client.streamUri({ id: child.id }),
...mapCoverArtUri(child, client),
...mapCoverArtThumbUri(child, client),
} }
} }
@ -310,24 +316,25 @@ function mapAlbumID3WithSongstoAlbunWithSongs(
client: SubsonicApiClient, client: SubsonicApiClient,
): AlbumWithSongs { ): AlbumWithSongs {
return { return {
...mapAlbumID3toAlbum(album, client), ...mapAlbumID3toAlbum(album),
songs: songs.map(s => mapChildToSong(s, client)), songs: songs.map(s => mapChildToSong(s, client)),
} }
} }
function mapPlaylistListItem(playlist: PlaylistElement, client: SubsonicApiClient): PlaylistListItem { function mapPlaylistListItem(playlist: PlaylistElement): PlaylistListItem {
return { return {
itemType: 'playlist',
id: playlist.id, id: playlist.id,
name: playlist.name, name: playlist.name,
comment: playlist.comment, comment: playlist.comment,
...mapCoverArtThumbUri(playlist, client), coverArt: playlist.coverArt,
} }
} }
function mapPlaylistWithSongs(playlist: PlaylistWithSongsElement, client: SubsonicApiClient): PlaylistWithSongs { function mapPlaylistWithSongs(playlist: PlaylistWithSongsElement, client: SubsonicApiClient): PlaylistWithSongs {
return { return {
...mapPlaylistListItem(playlist, client), ...mapPlaylistListItem(playlist),
songs: playlist.songs.map(s => mapChildToSong(s, client)), songs: playlist.songs.map(s => mapChildToSong(s, client)),
...mapCoverArtUri(playlist, client), coverArt: playlist.coverArt,
} }
} }

View File

@ -6,10 +6,11 @@ import { atom } from 'jotai'
import { useAtomCallback, useAtomValue, useUpdateAtom } from 'jotai/utils' import { useAtomCallback, useAtomValue, useUpdateAtom } from 'jotai/utils'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
import TrackPlayer, { State, Track } from 'react-native-track-player' import TrackPlayer, { State, Track } from 'react-native-track-player'
import { useCoverArtUri } from './music'
type TrackExt = Track & { type TrackExt = Track & {
id: string id: string
artworkThumb?: string coverArt?: string
} }
type OptionalTrackExt = TrackExt | undefined type OptionalTrackExt = TrackExt | undefined
@ -316,6 +317,7 @@ export const useSetQueue = () => {
const setQueueName = useUpdateAtom(queueNameWriteAtom) const setQueueName = useUpdateAtom(queueNameWriteAtom)
const reset = useReset(false) const reset = useReset(false)
const getQueueShuffled = useAtomCallback(useCallback(get => get(queueShuffledAtom), [])) const getQueueShuffled = useAtomCallback(useCallback(get => get(queueShuffledAtom), []))
const coverArtUri = useCoverArtUri()
return async (songs: Song[], name: string, playTrack?: number, shuffle?: boolean) => return async (songs: Song[], name: string, playTrack?: number, shuffle?: boolean) =>
trackPlayerCommands.enqueue(async () => { trackPlayerCommands.enqueue(async () => {
@ -328,7 +330,7 @@ export const useSetQueue = () => {
return return
} }
let queue = songs.map(mapSongToTrack) let queue = songs.map(s => mapSongToTrack(s, coverArtUri))
if (shuffled) { if (shuffled) {
const { tracks, shuffleOrder } = shuffleTracks(queue, playTrack) const { tracks, shuffleOrder } = shuffleTracks(queue, playTrack)
@ -371,15 +373,15 @@ export const useProgress = () => {
return progress return progress
} }
function mapSongToTrack(song: Song): TrackExt { function mapSongToTrack(song: Song, coverArtUri: (coverArt?: string) => string | undefined): TrackExt {
return { return {
id: song.id, id: song.id,
title: song.title, title: song.title,
artist: song.artist || 'Unknown Artist', artist: song.artist || 'Unknown Artist',
album: song.album || 'Unknown Album', album: song.album || 'Unknown Album',
url: song.streamUri, url: song.streamUri,
artwork: song.coverArtUri, artwork: coverArtUri(song.coverArt),
artworkThumb: song.coverArtThumbUri, coverArt: song.coverArt,
duration: song.duration, duration: song.duration,
} }
} }

View File

@ -13,6 +13,7 @@ import {
GetPlaylistParams, GetPlaylistParams,
GetPlaylistsParams, GetPlaylistsParams,
GetTopSongsParams, GetTopSongsParams,
Search3Params,
StreamParams, StreamParams,
} from '@app/subsonic/params' } from '@app/subsonic/params'
import { import {
@ -28,6 +29,7 @@ import {
GetPlaylistResponse, GetPlaylistResponse,
GetPlaylistsResponse, GetPlaylistsResponse,
GetTopSongsResponse, GetTopSongsResponse,
Search3Response,
SubsonicResponse, SubsonicResponse,
} from '@app/subsonic/responses' } from '@app/subsonic/responses'
import { Server } from '@app/models/settings' import { Server } from '@app/models/settings'
@ -220,4 +222,13 @@ export class SubsonicApiClient {
streamUri(params: StreamParams): string { streamUri(params: StreamParams): string {
return this.buildUrl('stream', params) return this.buildUrl('stream', params)
} }
//
// Searching
//
async search3(params: Search3Params): Promise<SubsonicResponse<Search3Response>> {
const xml = await this.apiGetXml('search3', params)
return new SubsonicResponse<Search3Response>(xml, new Search3Response(xml))
}
} }

View File

@ -99,3 +99,18 @@ export type StreamParams = {
format?: string format?: string
estimateContentLength?: boolean estimateContentLength?: boolean
} }
//
// Searching
//
export type Search3Params = {
query: string
artistCount?: number
artistOffset?: number
albumCount?: number
albumOffset?: number
songCount?: number
songOffset?: number
musicFolderId?: string
}

View File

@ -178,3 +178,30 @@ export class GetPlaylistResponse {
this.playlist = new PlaylistWithSongsElement(xml.getElementsByTagName('playlist')[0]) this.playlist = new PlaylistWithSongsElement(xml.getElementsByTagName('playlist')[0])
} }
} }
//
// Searching
//
export class Search3Response {
artists: ArtistID3Element[] = []
albums: AlbumID3Element[] = []
songs: ChildElement[] = []
constructor(xml: Document) {
const artistElements = xml.getElementsByTagName('artist')
for (let i = 0; i < artistElements.length; i++) {
this.artists.push(new ArtistID3Element(artistElements[i]))
}
const albumElements = xml.getElementsByTagName('album')
for (let i = 0; i < albumElements.length; i++) {
this.albums.push(new AlbumID3Element(albumElements[i]))
}
const songElements = xml.getElementsByTagName('song')
for (let i = 0; i < songElements.length; i++) {
this.songs.push(new ChildElement(songElements[i]))
}
}
}