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: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" />

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)

View File

@ -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 = {

View File

@ -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()

View File

@ -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}

View File

@ -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,

View File

@ -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: {

View File

@ -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}

View File

@ -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,
},
})

View File

@ -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,
},
})

View File

@ -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 />

View File

@ -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
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,
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,
}
}

View File

@ -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,
}
}

View File

@ -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))
}
}

View File

@ -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
}

View File

@ -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]))
}
}
}