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

@@ -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 React, { useState } from 'react'
import { ActivityIndicator, StyleSheet, View } from 'react-native'
import { useAtomValue } from 'jotai/utils'
import React, { useEffect, useState } from 'react'
import { ActivityIndicator, StyleSheet, View, ViewStyle } from 'react-native'
import FastImage, { ImageStyle } from 'react-native-fast-image'
import LinearGradient from 'react-native-linear-gradient'
type CoverImageProps = {
uri?: string
style?: ImageStyle
type BaseProps = {
imageSize?: 'thumbnail' | 'original'
style?: ViewStyle
imageStyle?: ImageStyle
resizeMode?: keyof typeof FastImage.resizeMode
onProgress?: () => void
onLoadEnd?: () => void
onError?: () => void
round?: boolean
}
const CoverImage = React.memo<CoverImageProps>(({ uri, style, resizeMode, onProgress, onLoadEnd, onError }) => (
<FastImage
source={{ uri }}
style={style}
resizeMode={resizeMode || FastImage.resizeMode.contain}
onProgress={onProgress}
onLoadEnd={onLoadEnd}
onError={onError}
/>
))
type BaseImageProps = BaseProps & {
enableLoading: () => void
disableLoading: () => void
}
const Fallback = React.memo<{}>(({}) => {
return <LinearGradient colors={[colors.accent, colors.accentLow]} style={styles.fallback} />
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
source={{ uri }}
style={[{ height: style?.height, width: style?.width }, imageStyle]}
resizeMode={resizeMode || FastImage.resizeMode.contain}
onProgress={enableLoading}
onLoadEnd={disableLoading}
/>
)
},
)
const ArtistIdImageFallback: React.FC<{
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<{
FallbackComponent?: () => JSX.Element
placeholderIcon?: string
height?: string | number
width?: string | number
coverArtUri?: string
resizeMode?: keyof typeof FastImage.resizeMode
style?: ImageStyle
}> = ({ FallbackComponent, coverArtUri, resizeMode, style }) => {
const CoverArtImage = React.memo<CoverArtImageProps>(
({ coverArt, imageSize, style, imageStyle, resizeMode, enableLoading, disableLoading }) => {
const coverArtUri = useCoverArtUri()
return (
<FastImage
source={{ uri: coverArtUri(coverArt, imageSize) }}
style={[{ height: style?.height, width: style?.width }, imageStyle]}
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 [fallbackVisible, setFallbackVisible] = useState(false)
const enableLoading = React.useCallback(() => setLoading(true), [])
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 (
<View style={style}>
<CoverImage
uri={coverArtUri}
style={style}
resizeMode={resizeMode}
onProgress={enableLoading}
onLoadEnd={disableLoading}
onError={enableFallback}
/>
{fallbackVisible ? (
FallbackComponent ? (
<View style={styles.fallback}>
<FallbackComponent />
</View>
) : (
<Fallback />
)
<View style={viewStyles}>
{artistId ? (
<ArtistIdImage
artistId={artistId}
imageSize={imageSize}
style={style}
imageStyle={imageStyle}
resizeMode={resizeMode}
enableLoading={enableLoading}
disableLoading={disableLoading}
/>
) : (
<></>
<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} />
</View>
@@ -71,16 +126,9 @@ const CoverArt: React.FC<{
}
const styles = StyleSheet.create({
image: {
height: '100%',
width: '100%',
},
fallback: {
height: '100%',
width: '100%',
position: 'absolute',
justifyContent: 'center',
alignItems: 'center',
round: {
overflow: 'hidden',
borderRadius: 1000,
},
indicator: {
height: '100%',

View File

@@ -1,11 +1,13 @@
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 GradientBackground from '@app/components/GradientBackground'
function GradientFlatList<ItemT>(props: FlatListProps<ItemT>) {
const layout = useWindowDimensions()
const contentContainerStyle = StyleSheet.flatten(props.contentContainerStyle)
return (
<FlatList
{...props}
@@ -16,6 +18,8 @@ function GradientFlatList<ItemT>(props: FlatListProps<ItemT>) {
ListHeaderComponent={() => <GradientBackground position="relative" />}
ListHeaderComponentStyle={{
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}>
<CoverArt
style={{ height: styles.subContainer.height, width: styles.subContainer.height }}
coverArtUri={track?.artworkThumb}
coverArt={track?.coverArt || '-1'}
/>
<View style={styles.detailsContainer}>
<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)