diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4981149..a8ca692 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -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"> diff --git a/app/components/ArtistArt.tsx b/app/components/ArtistArt.tsx deleted file mode 100644 index 56c9a76..0000000 --- a/app/components/ArtistArt.tsx +++ /dev/null @@ -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 = ({ height, width, children }) => { - const layout = useLayout() - - return ( - - - {children} - - ) -} - -const FourUp = React.memo(({ height, width, albumCoverUris }) => { - const halfHeight = height / 2 - const halfWidth = width / 2 - - return ( - - - - - - - - - - - ) -}) - -const ThreeUp = React.memo(({ height, width, albumCoverUris }) => { - const halfHeight = height / 2 - const halfWidth = width / 2 - - return ( - - - - - - - - - - ) -}) - -const TwoUp = React.memo(({ height, width, albumCoverUris }) => { - const halfHeight = height / 2 - - return ( - - - - - - - - - ) -}) - -const OneUp = React.memo(({ height, width, albumCoverUris }) => ( - - - -)) - -const NoneUp = React.memo(({ height, width }) => ( - -)) - -const ArtistArt = React.memo(({ id, height, width, round }) => { - const artistArt = useAtomValue(artistArtAtomFamily(id)) - - round = round === undefined ? true : round - - const Placeholder = () => { - if (!artistArt) { - return - } - const { albumCoverUris } = artistArt - - if (albumCoverUris.length >= 4) { - return - } - if (albumCoverUris.length === 3) { - return - } - if (albumCoverUris.length === 2) { - return - } - if (albumCoverUris.length === 1) { - return - } - - return - } - - return ( - - - - ) -}) - -const ArtistArtFallback = React.memo(({ height, width }) => ( - - - -)) - -const ArtistArtLoader: React.FC = props => ( - }> - - -) - -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) diff --git a/app/components/CoverArt.tsx b/app/components/CoverArt.tsx index b568bdd..440b88c 100644 --- a/app/components/CoverArt.tsx +++ b/app/components/CoverArt.tsx @@ -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(({ uri, style, resizeMode, onProgress, onLoadEnd, onError }) => ( - -)) +type BaseImageProps = BaseProps & { + enableLoading: () => void + disableLoading: () => void +} -const Fallback = React.memo<{}>(({}) => { - return +type ArtistIdProp = { + artistId: string +} + +type CoverArtProp = { + coverArt?: string +} + +type ArtistIdImageProps = BaseImageProps & ArtistIdProp +type CoverArtImageProps = BaseImageProps & CoverArtProp + +type CoverArtProps = BaseProps & CoverArtProp & Partial + +const ArtistIdImageLoaded = React.memo( + ({ artistId, imageSize, style, imageStyle, resizeMode, enableLoading, disableLoading }) => { + const artistInfo = useAtomValue(artistInfoAtomFamily(artistId)) + + const uri = imageSize === 'thumbnail' ? artistInfo?.smallImageUrl : artistInfo?.largeImageUrl + + return ( + + ) + }, +) + +const ArtistIdImageFallback: React.FC<{ + enableLoading: () => void +}> = ({ enableLoading }) => { + useEffect(() => { + enableLoading() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + return <> +} + +const ArtistIdImage = React.memo(props => { + return ( + }> + + + ) }) -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( + ({ coverArt, imageSize, style, imageStyle, resizeMode, enableLoading, disableLoading }) => { + const coverArtUri = useCoverArtUri() + + return ( + + ) + }, +) + +const CoverArt: React.FC = ({ 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 ( - - - {fallbackVisible ? ( - FallbackComponent ? ( - - - - ) : ( - - ) + + {artistId ? ( + ) : ( - <> + )} @@ -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%', diff --git a/app/components/GradientFlatList.tsx b/app/components/GradientFlatList.tsx index 25a6cbc..f6d85b8 100644 --- a/app/components/GradientFlatList.tsx +++ b/app/components/GradientFlatList.tsx @@ -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(props: FlatListProps) { const layout = useWindowDimensions() + const contentContainerStyle = StyleSheet.flatten(props.contentContainerStyle) + return ( (props: FlatListProps) { ListHeaderComponent={() => } ListHeaderComponentStyle={{ marginBottom: -layout.height, + marginHorizontal: -(contentContainerStyle.paddingHorizontal || 0), + top: -(contentContainerStyle.paddingTop || 0), }} /> ) diff --git a/app/components/Header.tsx b/app/components/Header.tsx new file mode 100644 index 0000000..0ae6ac2 --- /dev/null +++ b/app/components/Header.tsx @@ -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 {children} +} + +const styles = StyleSheet.create({ + text: { + fontFamily: font.bold, + fontSize: 24, + color: colors.text.primary, + marginTop: 18, + marginBottom: 12, + }, +}) + +export default Header diff --git a/app/components/ListItem.tsx b/app/components/ListItem.tsx new file mode 100644 index 0000000..28c2e6b --- /dev/null +++ b/app/components/ListItem.tsx @@ -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 ( + + {playing ? : <>} + {title} + + ) +}) + +const TitleText = React.memo<{ + title?: string +}>(({ title }) => { + return ( + + {title} + + ) +}) + +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 ( + + + {showArt ? : <>} + + {item.itemType === 'song' ? ( + + ) : ( + + )} + {subtitle ? ( + + {starred ? ( + + ) : ( + <> + )} + {subtitle} + + ) : ( + <> + )} + + + + {showStar ? ( + setStarred(!starred)} style={styles.controlItem}> + {starred ? ( + + ) : ( + + )} + + ) : ( + <> + )} + + + ) +} + +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) diff --git a/app/components/NowPlayingBar.tsx b/app/components/NowPlayingBar.tsx index 0c06b4e..6c2a67d 100644 --- a/app/components/NowPlayingBar.tsx +++ b/app/components/NowPlayingBar.tsx @@ -70,7 +70,7 @@ const NowPlayingBar = () => { diff --git a/app/components/SongItem.tsx b/app/components/SongItem.tsx deleted file mode 100644 index e61b255..0000000 --- a/app/components/SongItem.tsx +++ /dev/null @@ -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 ( - - - {showArt ? : <>} - - - {playing ? : <>} - {song.title} - - - {starred ? ( - - ) : ( - <> - )} - {song[subtitle]} - - - - - setStarred(!starred)}> - {starred ? ( - - ) : ( - - )} - - - - ) -} - -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) diff --git a/app/models/music.ts b/app/models/music.ts index 5b80cad..26946dd 100644 --- a/app/models/music.ts +++ b/app/models/music.ts @@ -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 = { diff --git a/app/navigation/BottomTabNavigator.tsx b/app/navigation/BottomTabNavigator.tsx index 62d58ff..c88c798 100644 --- a/app/navigation/BottomTabNavigator.tsx +++ b/app/navigation/BottomTabNavigator.tsx @@ -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) { const HomeTab = createTabStackNavigator(Home) const LibraryTab = createTabStackNavigator(LibraryTopTabNavigator) -const SearchTab = createTabStackNavigator(ArtistsList) +const SearchTab = createTabStackNavigator(Search) const Tab = createBottomTabNavigator() diff --git a/app/screens/AlbumView.tsx b/app/screens/AlbumView.tsx index 755d145..cf0aa89 100644 --- a/app/screens/AlbumView.tsx +++ b/app/screens/AlbumView.tsx @@ -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) => ( - setQueue(album.songs, album.name, i)} /> + setQueue(album.songs, album.name, i)} /> ))} @@ -44,11 +45,11 @@ const AlbumDetails: React.FC<{ return ( - + {album.name} {album.artist} diff --git a/app/screens/ArtistView.tsx b/app/screens/ArtistView.tsx index f9533a1..dcf40ce 100644 --- a/app/screens/ArtistView.tsx +++ b/app/screens/ArtistView.tsx @@ -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<{ navigation.navigate('AlbumView', { id: album.id, title: album.name })} style={[styles.albumItem, { width }]}> - + {album.name} {album.year ? album.year : ''} @@ -47,25 +47,19 @@ const ArtistDetails: React.FC<{ id: string }> = ({ id }) => { const TopSongs = () => ( <> - Top Songs +
Top Songs
{artist.topSongs.map((s, i) => ( - setQueue(artist.topSongs, `Top Songs: ${artist.name}`, i)} /> ))} ) - const ArtistCoverFallback = () => ( - - - - ) - return ( = ({ id }) => { style={styles.scroll} contentContainerStyle={styles.scrollContent}> {artist.name} {artist.topSongs.length > 0 ? : <>} - Albums +
Albums
{artist.albums.map(a => ( @@ -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, diff --git a/app/screens/Home.tsx b/app/screens/Home.tsx index 7c7d834..4689234 100644 --- a/app/screens/Home.tsx +++ b/app/screens/Home.tsx @@ -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}> - + {album.name} @@ -66,7 +67,7 @@ const Category = React.memo<{ return ( - {name} +
{name}
{data.length > 0 ? : }
) @@ -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: { diff --git a/app/screens/LibraryAlbums.tsx b/app/screens/LibraryAlbums.tsx index 03b1888..4f99090 100644 --- a/app/screens/LibraryAlbums.tsx +++ b/app/screens/LibraryAlbums.tsx @@ -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 ( navigation.navigate('AlbumView', { id, title: name })}> - + {name} @@ -43,7 +43,7 @@ const AlbumListRenderItem: React.FC<{ }> = ({ item }) => ( (({ item }) => { const navigation = useNavigation() return ( - navigation.navigate('ArtistView', { id: item.id, title: item.name })}> - - {item.name} - + 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, }, }) diff --git a/app/screens/LibraryPlaylists.tsx b/app/screens/LibraryPlaylists.tsx index b6761db..53a350f 100644 --- a/app/screens/LibraryPlaylists.tsx +++ b/app/screens/LibraryPlaylists.tsx @@ -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 ( - navigation.navigate('PlaylistView', { id: item.id, title: item.name })}> - - - - {item.name} - - {item.comment ? ( - - {item.comment} - - ) : ( - <> - )} - - + 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, }, }) diff --git a/app/screens/NowPlayingLayout.tsx b/app/screens/NowPlayingLayout.tsx index 420599b..49b6330 100644 --- a/app/screens/NowPlayingLayout.tsx +++ b/app/screens/NowPlayingLayout.tsx @@ -75,7 +75,7 @@ const SongCoverArt = () => { return ( - + ) } @@ -318,7 +318,7 @@ const NowPlayingLayout: React.FC = ({ navigation }) => { return ( - + diff --git a/app/screens/PlaylistView.tsx b/app/screens/PlaylistView.tsx index ee65798..bb755b7 100644 --- a/app/screens/PlaylistView.tsx +++ b/app/screens/PlaylistView.tsx @@ -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<{ /> {playlist.songs.map((s, i) => ( - setQueue(playlist.songs, playlist.name, i)} /> + setQueue(playlist.songs, playlist.name, i)} + /> ))} @@ -41,11 +48,11 @@ const PlaylistDetails: React.FC<{ return ( - + {playlist.name} {playlist.comment ? {playlist.comment} : <>} {playlist.songs.length > 0 ? : } diff --git a/app/screens/Search.tsx b/app/screens/Search.tsx new file mode 100644 index 0000000..8714d3f --- /dev/null +++ b/app/screens/Search.tsx @@ -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 ( + + + +
Artists
+ {results.artists.map(a => ( + + ))} +
Albums
+ {results.albums.map(a => ( + + ))} +
Songs
+ {results.songs.map(a => ( + + ))} +
+
+ ) +} + +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 diff --git a/app/state/music.ts b/app/state/music.ts index 250053f..4bff476 100644 --- a/app/state/music.ts +++ b/app/state/music.ts @@ -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({ + 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([]) @@ -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(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, } } diff --git a/app/state/trackplayer.ts b/app/state/trackplayer.ts index 67e157b..487850a 100644 --- a/app/state/trackplayer.ts +++ b/app/state/trackplayer.ts @@ -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, } } diff --git a/app/subsonic/api.ts b/app/subsonic/api.ts index 2f32fbe..4ca626a 100644 --- a/app/subsonic/api.ts +++ b/app/subsonic/api.ts @@ -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> { + const xml = await this.apiGetXml('search3', params) + return new SubsonicResponse(xml, new Search3Response(xml)) + } } diff --git a/app/subsonic/params.ts b/app/subsonic/params.ts index 5f9110f..46376ef 100644 --- a/app/subsonic/params.ts +++ b/app/subsonic/params.ts @@ -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 +} diff --git a/app/subsonic/responses.ts b/app/subsonic/responses.ts index 658a9e2..4c29d76 100644 --- a/app/subsonic/responses.ts +++ b/app/subsonic/responses.ts @@ -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])) + } + } +}