mirror of
https://github.com/austinried/subtracks.git
synced 2025-12-27 00:59:28 +01:00
redid cover art (again...) and impl a ListItem
This commit is contained in:
parent
6dd17f2797
commit
fbf6060db4
@ -16,7 +16,7 @@
|
||||
android:label="@string/app_name"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
android:windowSoftInputMode="adjustPan">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
||||
@ -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)
|
||||
@ -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%',
|
||||
|
||||
@ -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
22
app/components/Header.tsx
Normal 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
171
app/components/ListItem.tsx
Normal 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)
|
||||
@ -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}>
|
||||
|
||||
@ -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)
|
||||
@ -1,34 +1,32 @@
|
||||
export interface Artist {
|
||||
itemType: 'artist'
|
||||
id: string
|
||||
name: string
|
||||
starred?: Date
|
||||
coverArt?: string
|
||||
}
|
||||
|
||||
export interface ArtistInfo extends Artist {
|
||||
albums: Album[]
|
||||
|
||||
smallImageUrl?: string
|
||||
mediumImageUrl?: string
|
||||
largeImageUrl?: string
|
||||
albumCoverUris: string[]
|
||||
|
||||
topSongs: Song[]
|
||||
}
|
||||
|
||||
export interface ArtistArt {
|
||||
uri?: string
|
||||
albumCoverUris: string[]
|
||||
}
|
||||
|
||||
export interface AlbumListItem {
|
||||
itemType: 'album'
|
||||
id: string
|
||||
name: string
|
||||
artist?: string
|
||||
starred?: Date
|
||||
coverArtThumbUri?: string
|
||||
coverArt?: string
|
||||
}
|
||||
|
||||
export interface Album extends AlbumListItem {
|
||||
coverArtUri?: string
|
||||
coverArt?: string
|
||||
year?: number
|
||||
}
|
||||
|
||||
@ -36,19 +34,27 @@ export interface AlbumWithSongs extends Album {
|
||||
songs: Song[]
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
artists: Artist[]
|
||||
albums: AlbumListItem[]
|
||||
songs: Song[]
|
||||
}
|
||||
|
||||
export interface PlaylistListItem {
|
||||
itemType: 'playlist'
|
||||
id: string
|
||||
name: string
|
||||
comment?: string
|
||||
coverArtThumbUri?: string
|
||||
coverArt?: string
|
||||
}
|
||||
|
||||
export interface PlaylistWithSongs extends PlaylistListItem {
|
||||
songs: Song[]
|
||||
coverArtUri?: string
|
||||
coverArt?: string
|
||||
}
|
||||
|
||||
export interface Song {
|
||||
itemType: 'song'
|
||||
id: string
|
||||
album?: string
|
||||
artist?: string
|
||||
@ -58,8 +64,7 @@ export interface Song {
|
||||
starred?: Date
|
||||
|
||||
streamUri: string
|
||||
coverArtUri?: string
|
||||
coverArtThumbUri?: string
|
||||
coverArt?: string
|
||||
}
|
||||
|
||||
export type DownloadedSong = {
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import BottomTabBar from '@app/navigation/BottomTabBar'
|
||||
import LibraryTopTabNavigator from '@app/navigation/LibraryTopTabNavigator'
|
||||
import AlbumView from '@app/screens/AlbumView'
|
||||
import ArtistsList from '@app/screens/ArtistsList'
|
||||
import ArtistView from '@app/screens/ArtistView'
|
||||
import Home from '@app/screens/Home'
|
||||
import PlaylistView from '@app/screens/PlaylistView'
|
||||
import Search from '@app/screens/Search'
|
||||
import SettingsView from '@app/screens/Settings'
|
||||
import colors from '@app/styles/colors'
|
||||
import font from '@app/styles/font'
|
||||
@ -98,7 +98,7 @@ function createTabStackNavigator(Component: React.ComponentType<any>) {
|
||||
|
||||
const HomeTab = createTabStackNavigator(Home)
|
||||
const LibraryTab = createTabStackNavigator(LibraryTopTabNavigator)
|
||||
const SearchTab = createTabStackNavigator(ArtistsList)
|
||||
const SearchTab = createTabStackNavigator(Search)
|
||||
|
||||
const Tab = createBottomTabNavigator()
|
||||
|
||||
|
||||
@ -3,8 +3,8 @@ import GradientBackground from '@app/components/GradientBackground'
|
||||
import ImageGradientScrollView from '@app/components/ImageGradientScrollView'
|
||||
import ListPlayerControls from '@app/components/ListPlayerControls'
|
||||
import NothingHere from '@app/components/NothingHere'
|
||||
import SongItem from '@app/components/SongItem'
|
||||
import { albumAtomFamily } from '@app/state/music'
|
||||
import ListItem from '@app/components/ListItem'
|
||||
import { albumAtomFamily, useCoverArtUri } from '@app/state/music'
|
||||
import { useSetQueue } from '@app/state/trackplayer'
|
||||
import colors from '@app/styles/colors'
|
||||
import font from '@app/styles/font'
|
||||
@ -17,6 +17,7 @@ const AlbumDetails: React.FC<{
|
||||
id: string
|
||||
}> = ({ id }) => {
|
||||
const album = useAtomValue(albumAtomFamily(id))
|
||||
const coverArtUri = useCoverArtUri()
|
||||
const setQueue = useSetQueue()
|
||||
|
||||
if (!album) {
|
||||
@ -36,7 +37,7 @@ const AlbumDetails: React.FC<{
|
||||
}
|
||||
})
|
||||
.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>
|
||||
</>
|
||||
@ -44,11 +45,11 @@ const AlbumDetails: React.FC<{
|
||||
|
||||
return (
|
||||
<ImageGradientScrollView
|
||||
imageUri={album.coverArtThumbUri}
|
||||
imageUri={coverArtUri(album.coverArt)}
|
||||
imageKey={`${album.name}${album.artist}`}
|
||||
style={styles.container}>
|
||||
<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.subtitle}>
|
||||
{album.artist}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import ArtistArt from '@app/components/ArtistArt'
|
||||
import CoverArt from '@app/components/CoverArt'
|
||||
import GradientScrollView from '@app/components/GradientScrollView'
|
||||
import Header from '@app/components/Header'
|
||||
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 { artistInfoAtomFamily } from '@app/state/music'
|
||||
import { useSetQueue } from '@app/state/trackplayer'
|
||||
@ -26,7 +26,7 @@ const AlbumItem = React.memo<{
|
||||
<PressableOpacity
|
||||
onPress={() => navigation.navigate('AlbumView', { id: album.id, title: album.name })}
|
||||
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.albumYear}> {album.year ? album.year : ''}</Text>
|
||||
</PressableOpacity>
|
||||
@ -47,25 +47,19 @@ const ArtistDetails: React.FC<{ id: string }> = ({ id }) => {
|
||||
|
||||
const TopSongs = () => (
|
||||
<>
|
||||
<Text style={styles.header}>Top Songs</Text>
|
||||
<Header>Top Songs</Header>
|
||||
{artist.topSongs.map((s, i) => (
|
||||
<SongItem
|
||||
<ListItem
|
||||
key={i}
|
||||
song={s}
|
||||
item={s}
|
||||
showArt={true}
|
||||
subtitle="album"
|
||||
subtitle={s.album}
|
||||
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 (
|
||||
<GradientScrollView
|
||||
onLayout={coverLayout.onLayout}
|
||||
@ -73,17 +67,18 @@ const ArtistDetails: React.FC<{ id: string }> = ({ id }) => {
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.scrollContent}>
|
||||
<CoverArt
|
||||
FallbackComponent={ArtistCoverFallback}
|
||||
coverArtUri={artist.largeImageUrl}
|
||||
artistId={artist.id}
|
||||
style={styles.artistCover}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
round={false}
|
||||
imageSize="original"
|
||||
/>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.title}>{artist.name}</Text>
|
||||
</View>
|
||||
<View style={styles.container}>
|
||||
{artist.topSongs.length > 0 ? <TopSongs /> : <></>}
|
||||
<Text style={styles.header}>Albums</Text>
|
||||
<Header>Albums</Header>
|
||||
<View style={styles.albums} onLayout={albumsLayout.onLayout}>
|
||||
{artist.albums.map(a => (
|
||||
<AlbumItem key={a.id} album={a} height={albumSize} width={albumSize} />
|
||||
@ -140,13 +135,6 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 10,
|
||||
marginBottom: 10,
|
||||
},
|
||||
header: {
|
||||
fontFamily: font.bold,
|
||||
fontSize: 24,
|
||||
color: colors.text.primary,
|
||||
marginTop: 20,
|
||||
marginBottom: 14,
|
||||
},
|
||||
artistCover: {
|
||||
position: 'absolute',
|
||||
height: artistCoverHeight,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
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 PressableOpacity from '@app/components/PressableOpacity'
|
||||
import { AlbumListItem } from '@app/models/music'
|
||||
@ -30,7 +31,7 @@ const AlbumItem = React.memo<{
|
||||
onPress={() => navigation.navigate('AlbumView', { id: album.id, title: album.name })}
|
||||
key={album.id}
|
||||
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}>
|
||||
{album.name}
|
||||
</Text>
|
||||
@ -66,7 +67,7 @@ const Category = React.memo<{
|
||||
|
||||
return (
|
||||
<View style={styles.category}>
|
||||
<Text style={styles.categoryHeader}>{name}</Text>
|
||||
<Header style={styles.header}>{name}</Header>
|
||||
{data.length > 0 ? <Albums /> : <Nothing />}
|
||||
</View>
|
||||
)
|
||||
@ -111,24 +112,19 @@ const styles = StyleSheet.create({
|
||||
content: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
category: {
|
||||
marginTop: 12,
|
||||
},
|
||||
categoryHeader: {
|
||||
fontFamily: font.bold,
|
||||
fontSize: 24,
|
||||
color: colors.text.primary,
|
||||
header: {
|
||||
paddingHorizontal: 20,
|
||||
marginTop: 4,
|
||||
},
|
||||
category: {
|
||||
// marginTop: 12,
|
||||
},
|
||||
nothingHereContent: {
|
||||
width: '100%',
|
||||
height: 200,
|
||||
height: 190,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
artScroll: {
|
||||
marginTop: 10,
|
||||
height: 190,
|
||||
},
|
||||
artScrollContent: {
|
||||
|
||||
@ -17,15 +17,15 @@ const AlbumItem = React.memo<{
|
||||
size: number
|
||||
height: number
|
||||
artist?: string
|
||||
coverArtUri?: string
|
||||
}>(({ id, name, artist, size, height, coverArtUri }) => {
|
||||
coverArt?: string
|
||||
}>(({ id, name, artist, size, height, coverArt }) => {
|
||||
const navigation = useNavigation()
|
||||
|
||||
return (
|
||||
<PressableOpacity
|
||||
style={[styles.item, { maxWidth: size, height }]}
|
||||
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}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{name}
|
||||
@ -43,7 +43,7 @@ const AlbumListRenderItem: React.FC<{
|
||||
}> = ({ item }) => (
|
||||
<AlbumItem
|
||||
id={item.album.id}
|
||||
coverArtUri={item.album.coverArtThumbUri}
|
||||
coverArt={item.album.coverArt}
|
||||
name={item.album.name}
|
||||
artist={item.album.artist}
|
||||
size={item.size}
|
||||
|
||||
@ -1,25 +1,23 @@
|
||||
import ArtistArt from '@app/components/ArtistArt'
|
||||
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 { 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 { useAtomValue } from 'jotai/utils'
|
||||
import React, { useEffect } from 'react'
|
||||
import { StyleSheet, Text } from 'react-native'
|
||||
import { StyleSheet } from 'react-native'
|
||||
|
||||
const ArtistItem = React.memo<{ item: Artist }>(({ item }) => {
|
||||
const navigation = useNavigation()
|
||||
|
||||
return (
|
||||
<PressableOpacity
|
||||
style={styles.item}
|
||||
onPress={() => navigation.navigate('ArtistView', { id: item.id, title: item.name })}>
|
||||
<ArtistArt id={item.id} width={styles.art.width} height={styles.art.height} />
|
||||
<Text style={styles.title}>{item.name}</Text>
|
||||
</PressableOpacity>
|
||||
<ListItem
|
||||
item={item}
|
||||
showArt={true}
|
||||
showStar={false}
|
||||
listStyle="big"
|
||||
onPress={() => navigation.navigate('ArtistView', { id: item.id, title: item.name })}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@ -52,23 +50,8 @@ const ArtistsList = () => {
|
||||
const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
minHeight: '100%',
|
||||
},
|
||||
item: {
|
||||
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,
|
||||
paddingHorizontal: 10,
|
||||
paddingTop: 6,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -1,36 +1,24 @@
|
||||
import CoverArt from '@app/components/CoverArt'
|
||||
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 { 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 { useAtomValue } from 'jotai/utils'
|
||||
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 navigation = useNavigation()
|
||||
|
||||
return (
|
||||
<PressableOpacity
|
||||
style={styles.item}
|
||||
onPress={() => navigation.navigate('PlaylistView', { id: item.id, title: item.name })}>
|
||||
<CoverArt coverArtUri={item.coverArtThumbUri} style={styles.art} />
|
||||
<View style={styles.text}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
{item.comment ? (
|
||||
<Text style={styles.subtitle} numberOfLines={1}>
|
||||
{item.comment}
|
||||
</Text>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</View>
|
||||
</PressableOpacity>
|
||||
<ListItem
|
||||
item={item}
|
||||
showArt={true}
|
||||
showStar={false}
|
||||
listStyle="big"
|
||||
subtitle={item.comment}
|
||||
onPress={() => navigation.navigate('PlaylistView', { id: item.id, title: item.name })}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@ -63,30 +51,8 @@ const PlaylistsList = () => {
|
||||
const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
minHeight: '100%',
|
||||
},
|
||||
item: {
|
||||
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,
|
||||
paddingHorizontal: 10,
|
||||
paddingTop: 6,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -75,7 +75,7 @@ const SongCoverArt = () => {
|
||||
|
||||
return (
|
||||
<View style={coverArtStyles.container}>
|
||||
<CoverArt coverArtUri={track?.artwork as string} style={coverArtStyles.image} />
|
||||
<CoverArt coverArt={track?.coverArt} style={coverArtStyles.image} imageSize="original" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@ -318,7 +318,7 @@ const NowPlayingLayout: React.FC<NowPlayingProps> = ({ navigation }) => {
|
||||
|
||||
return (
|
||||
<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 />
|
||||
<View style={styles.content}>
|
||||
<SongCoverArt />
|
||||
|
||||
@ -3,8 +3,8 @@ import GradientBackground from '@app/components/GradientBackground'
|
||||
import ImageGradientScrollView from '@app/components/ImageGradientScrollView'
|
||||
import ListPlayerControls from '@app/components/ListPlayerControls'
|
||||
import NothingHere from '@app/components/NothingHere'
|
||||
import SongItem from '@app/components/SongItem'
|
||||
import { playlistAtomFamily } from '@app/state/music'
|
||||
import ListItem from '@app/components/ListItem'
|
||||
import { playlistAtomFamily, useCoverArtUri } from '@app/state/music'
|
||||
import { useSetQueue } from '@app/state/trackplayer'
|
||||
import colors from '@app/styles/colors'
|
||||
import font from '@app/styles/font'
|
||||
@ -18,6 +18,7 @@ const PlaylistDetails: React.FC<{
|
||||
}> = ({ id }) => {
|
||||
const playlist = useAtomValue(playlistAtomFamily(id))
|
||||
const setQueue = useSetQueue()
|
||||
const coverArtUri = useCoverArtUri()
|
||||
|
||||
if (!playlist) {
|
||||
return <></>
|
||||
@ -33,7 +34,13 @@ const PlaylistDetails: React.FC<{
|
||||
/>
|
||||
<View style={styles.songs}>
|
||||
{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>
|
||||
</>
|
||||
@ -41,11 +48,11 @@ const PlaylistDetails: React.FC<{
|
||||
|
||||
return (
|
||||
<ImageGradientScrollView
|
||||
imageUri={playlist.coverArtThumbUri}
|
||||
imageUri={coverArtUri(playlist.coverArt)}
|
||||
imageKey={`${playlist.id}${playlist.name}`}
|
||||
style={styles.container}>
|
||||
<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>
|
||||
{playlist.comment ? <Text style={styles.subtitle}>{playlist.comment}</Text> : <></>}
|
||||
{playlist.songs.length > 0 ? <Songs /> : <NothingHere height={350} width={250} />}
|
||||
|
||||
75
app/screens/Search.tsx
Normal file
75
app/screens/Search.tsx
Normal 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
|
||||
@ -3,10 +3,10 @@ import {
|
||||
AlbumListItem,
|
||||
AlbumWithSongs,
|
||||
Artist,
|
||||
ArtistArt,
|
||||
ArtistInfo,
|
||||
PlaylistListItem,
|
||||
PlaylistWithSongs,
|
||||
SearchResults,
|
||||
Song,
|
||||
} from '@app/models/music'
|
||||
import { activeServerAtom, homeListTypesAtom } from '@app/state/settings'
|
||||
@ -19,7 +19,7 @@ import {
|
||||
PlaylistElement,
|
||||
PlaylistWithSongsElement,
|
||||
} from '@app/subsonic/elements'
|
||||
import { GetAlbumList2Type } from '@app/subsonic/params'
|
||||
import { GetAlbumList2Type, GetCoverArtParams } from '@app/subsonic/params'
|
||||
import { GetArtistResponse } from '@app/subsonic/responses'
|
||||
import { atom, useAtom } from 'jotai'
|
||||
import { atomFamily, useAtomValue, useUpdateAtom } from 'jotai/utils'
|
||||
@ -87,7 +87,7 @@ export const useUpdateHomeLists = () => {
|
||||
for (const type of types) {
|
||||
promises.push(
|
||||
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 playlistsAtom = atom<PlaylistListItem[]>([])
|
||||
|
||||
@ -118,7 +152,7 @@ export const useUpdatePlaylists = () => {
|
||||
const client = new SubsonicApiClient(server)
|
||||
const response = await client.getPlaylists()
|
||||
|
||||
updateList(response.data.playlists.map(a => mapPlaylistListItem(a, client)))
|
||||
updateList(response.data.playlists.map(mapPlaylistListItem))
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
@ -157,7 +191,7 @@ export const useUpdateAlbumList = () => {
|
||||
const client = new SubsonicApiClient(server)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -193,36 +227,32 @@ export const artistInfoAtomFamily = atomFamily((id: string) =>
|
||||
}),
|
||||
)
|
||||
|
||||
export const artistArtAtomFamily = atomFamily((id: string) =>
|
||||
atom<ArtistArt | undefined>(async get => {
|
||||
const artistInfo = get(artistInfoAtomFamily(id))
|
||||
if (!artistInfo) {
|
||||
return undefined
|
||||
export const useCoverArtUri = () => {
|
||||
const server = useAtomValue(activeServerAtom)
|
||||
|
||||
if (!server) {
|
||||
return () => undefined
|
||||
}
|
||||
|
||||
const client = new SubsonicApiClient(server)
|
||||
|
||||
return (coverArt?: string, size: 'thumbnail' | 'original' = 'thumbnail') => {
|
||||
const params: GetCoverArtParams = { id: coverArt || '-1' }
|
||||
if (size === 'thumbnail') {
|
||||
params.size = '256'
|
||||
}
|
||||
|
||||
const albumCoverUris = artistInfo.albums
|
||||
.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 {
|
||||
albumCoverUris,
|
||||
uri: artistInfo.largeImageUrl,
|
||||
}
|
||||
}),
|
||||
)
|
||||
return client.getCoverArtUri(params)
|
||||
}
|
||||
}
|
||||
|
||||
function mapArtistID3toArtist(artist: ArtistID3Element): Artist {
|
||||
return {
|
||||
itemType: 'artist',
|
||||
id: artist.id,
|
||||
name: artist.name,
|
||||
starred: artist.starred,
|
||||
coverArt: artist.coverArt,
|
||||
}
|
||||
}
|
||||
|
||||
@ -234,63 +264,40 @@ function mapArtistInfo(
|
||||
): ArtistInfo {
|
||||
const { artist, albums } = artistResponse
|
||||
|
||||
const mappedAlbums = albums.map(a => mapAlbumID3toAlbum(a, client))
|
||||
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[]
|
||||
const mappedAlbums = albums.map(mapAlbumID3toAlbum)
|
||||
|
||||
return {
|
||||
...mapArtistID3toArtist(artist),
|
||||
albums: mappedAlbums,
|
||||
albumCoverUris,
|
||||
smallImageUrl: info.smallImageUrl,
|
||||
mediumImageUrl: info.mediumImageUrl,
|
||||
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) {
|
||||
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 {
|
||||
function mapAlbumID3toAlbumListItem(album: AlbumID3Element): AlbumListItem {
|
||||
return {
|
||||
itemType: 'album',
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
artist: album.artist,
|
||||
starred: album.starred,
|
||||
...mapCoverArtThumbUri(album, client),
|
||||
coverArt: album.coverArt,
|
||||
}
|
||||
}
|
||||
|
||||
function mapAlbumID3toAlbum(album: AlbumID3Element, client: SubsonicApiClient): Album {
|
||||
function mapAlbumID3toAlbum(album: AlbumID3Element): Album {
|
||||
return {
|
||||
...mapAlbumID3toAlbumListItem(album, client),
|
||||
...mapCoverArtUri(album, client),
|
||||
...mapCoverArtThumbUri(album, client),
|
||||
...mapAlbumID3toAlbumListItem(album),
|
||||
coverArt: album.coverArt,
|
||||
year: album.year,
|
||||
}
|
||||
}
|
||||
|
||||
function mapChildToSong(child: ChildElement, client: SubsonicApiClient): Song {
|
||||
return {
|
||||
itemType: 'song',
|
||||
id: child.id,
|
||||
album: child.album,
|
||||
artist: child.artist,
|
||||
@ -298,9 +305,8 @@ function mapChildToSong(child: ChildElement, client: SubsonicApiClient): Song {
|
||||
track: child.track,
|
||||
duration: child.duration,
|
||||
starred: child.starred,
|
||||
coverArt: child.coverArt,
|
||||
streamUri: client.streamUri({ id: child.id }),
|
||||
...mapCoverArtUri(child, client),
|
||||
...mapCoverArtThumbUri(child, client),
|
||||
}
|
||||
}
|
||||
|
||||
@ -310,24 +316,25 @@ function mapAlbumID3WithSongstoAlbunWithSongs(
|
||||
client: SubsonicApiClient,
|
||||
): AlbumWithSongs {
|
||||
return {
|
||||
...mapAlbumID3toAlbum(album, client),
|
||||
...mapAlbumID3toAlbum(album),
|
||||
songs: songs.map(s => mapChildToSong(s, client)),
|
||||
}
|
||||
}
|
||||
|
||||
function mapPlaylistListItem(playlist: PlaylistElement, client: SubsonicApiClient): PlaylistListItem {
|
||||
function mapPlaylistListItem(playlist: PlaylistElement): PlaylistListItem {
|
||||
return {
|
||||
itemType: 'playlist',
|
||||
id: playlist.id,
|
||||
name: playlist.name,
|
||||
comment: playlist.comment,
|
||||
...mapCoverArtThumbUri(playlist, client),
|
||||
coverArt: playlist.coverArt,
|
||||
}
|
||||
}
|
||||
|
||||
function mapPlaylistWithSongs(playlist: PlaylistWithSongsElement, client: SubsonicApiClient): PlaylistWithSongs {
|
||||
return {
|
||||
...mapPlaylistListItem(playlist, client),
|
||||
...mapPlaylistListItem(playlist),
|
||||
songs: playlist.songs.map(s => mapChildToSong(s, client)),
|
||||
...mapCoverArtUri(playlist, client),
|
||||
coverArt: playlist.coverArt,
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,10 +6,11 @@ import { atom } from 'jotai'
|
||||
import { useAtomCallback, useAtomValue, useUpdateAtom } from 'jotai/utils'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import TrackPlayer, { State, Track } from 'react-native-track-player'
|
||||
import { useCoverArtUri } from './music'
|
||||
|
||||
type TrackExt = Track & {
|
||||
id: string
|
||||
artworkThumb?: string
|
||||
coverArt?: string
|
||||
}
|
||||
|
||||
type OptionalTrackExt = TrackExt | undefined
|
||||
@ -316,6 +317,7 @@ export const useSetQueue = () => {
|
||||
const setQueueName = useUpdateAtom(queueNameWriteAtom)
|
||||
const reset = useReset(false)
|
||||
const getQueueShuffled = useAtomCallback(useCallback(get => get(queueShuffledAtom), []))
|
||||
const coverArtUri = useCoverArtUri()
|
||||
|
||||
return async (songs: Song[], name: string, playTrack?: number, shuffle?: boolean) =>
|
||||
trackPlayerCommands.enqueue(async () => {
|
||||
@ -328,7 +330,7 @@ export const useSetQueue = () => {
|
||||
return
|
||||
}
|
||||
|
||||
let queue = songs.map(mapSongToTrack)
|
||||
let queue = songs.map(s => mapSongToTrack(s, coverArtUri))
|
||||
|
||||
if (shuffled) {
|
||||
const { tracks, shuffleOrder } = shuffleTracks(queue, playTrack)
|
||||
@ -371,15 +373,15 @@ export const useProgress = () => {
|
||||
return progress
|
||||
}
|
||||
|
||||
function mapSongToTrack(song: Song): TrackExt {
|
||||
function mapSongToTrack(song: Song, coverArtUri: (coverArt?: string) => string | undefined): TrackExt {
|
||||
return {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist || 'Unknown Artist',
|
||||
album: song.album || 'Unknown Album',
|
||||
url: song.streamUri,
|
||||
artwork: song.coverArtUri,
|
||||
artworkThumb: song.coverArtThumbUri,
|
||||
artwork: coverArtUri(song.coverArt),
|
||||
coverArt: song.coverArt,
|
||||
duration: song.duration,
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
GetPlaylistParams,
|
||||
GetPlaylistsParams,
|
||||
GetTopSongsParams,
|
||||
Search3Params,
|
||||
StreamParams,
|
||||
} from '@app/subsonic/params'
|
||||
import {
|
||||
@ -28,6 +29,7 @@ import {
|
||||
GetPlaylistResponse,
|
||||
GetPlaylistsResponse,
|
||||
GetTopSongsResponse,
|
||||
Search3Response,
|
||||
SubsonicResponse,
|
||||
} from '@app/subsonic/responses'
|
||||
import { Server } from '@app/models/settings'
|
||||
@ -220,4 +222,13 @@ export class SubsonicApiClient {
|
||||
streamUri(params: StreamParams): string {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -99,3 +99,18 @@ export type StreamParams = {
|
||||
format?: string
|
||||
estimateContentLength?: boolean
|
||||
}
|
||||
|
||||
//
|
||||
// Searching
|
||||
//
|
||||
|
||||
export type Search3Params = {
|
||||
query: string
|
||||
artistCount?: number
|
||||
artistOffset?: number
|
||||
albumCount?: number
|
||||
albumOffset?: number
|
||||
songCount?: number
|
||||
songOffset?: number
|
||||
musicFolderId?: string
|
||||
}
|
||||
|
||||
@ -178,3 +178,30 @@ export class GetPlaylistResponse {
|
||||
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]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user