[mobile] fetch albums, artists and tracks from API

This commit is contained in:
Tsiry Sandratraina
2023-04-23 17:27:47 +03:00
parent a02024aed9
commit ade4c4c67e
14 changed files with 844 additions and 42 deletions

View File

@@ -103,13 +103,8 @@ impl LibraryQuery {
.collect());
}
let db = ctx.data::<Database>().unwrap();
let results = ArtistRepository::new(db.get_connection())
.find_all(filter, offset.map(|x| x as u64), limit.map(|x| x as u64))
.await?;
Ok(results.into_iter().map(Into::into).collect())
}
let db = ctx.data::<Database>().unwrap();
let results = ArtistRepository::new(db.get_connection());
async fn albums(
&self,

View File

@@ -0,0 +1,89 @@
import React, {FC} from 'react';
import {Album} from '../../Types';
import styled from '@emotion/native';
import Feather from 'react-native-vector-icons/Feather';
import {TouchableWithoutFeedback} from 'react-native';
import Ionicons from 'react-native-vector-icons/Ionicons';
import {useCover} from '../../Hooks/useCover';
const Container = styled.View`
height: 80px;
width: 100%;
flex-direction: row;
align-items: center;
padding-left: 20px;
`;
const Cover = styled.Image`
width: 60px;
height: 60px;
`;
const NoAlbumCover = styled.View`
width: 60px;
height: 60px;
background-color: #161515;
align-items: center;
justify-content: center;
`;
const Title = styled.Text`
color: #fff;
font-family: 'Gilroy-Bold';
font-size: 16px;
`;
const Artist = styled.Text`
color: #a7a7a9;
font-family: 'Gilroy-Bold';
font-size: 14px;
margin-top: 2px;
`;
const AlbumInfo = styled.View`
flex-direction: column;
margin-left: 15px;
flex: 1;
`;
const Button = styled.TouchableOpacity`
height: 40px;
width: 40px;
align-items: center;
justify-content: center;
`;
export type AlbumRowProps = {
album: Album;
onSelected: () => void;
};
const AlbumRow: FC<AlbumRowProps> = props => {
const {album, onSelected} = props;
const cover = useCover(album.cover);
return (
<TouchableWithoutFeedback onPress={() => onSelected()}>
<Container>
{album.cover && <Cover source={{uri: cover}} />}
{!album.cover && (
<NoAlbumCover>
<Feather name="disc" size={40} color="#a7a7a9" />
</NoAlbumCover>
)}
<AlbumInfo>
<Title numberOfLines={1} ellipsizeMode="tail">
{album.title}
</Title>
<Artist numberOfLines={1} ellipsizeMode="tail">
{album.artist}
</Artist>
</AlbumInfo>
<Button>
<Ionicons name="ellipsis-vertical" color={'#ffffff99'} size={18} />
</Button>
</Container>
</TouchableWithoutFeedback>
);
};
export default AlbumRow;

View File

@@ -0,0 +1,175 @@
import styled from '@emotion/native';
import React, {FC} from 'react';
import {FlatList, TouchableWithoutFeedback} from 'react-native';
import Feather from 'react-native-vector-icons/Feather';
import {useCover} from '../../Hooks/useCover';
const Container = styled.View`
width: 100%;
margin-bottom: 50px;
`;
const Placeholder = styled.View`
width: 100%;
height: 228px;
align-items: center;
justify-content: center;
`;
const PlaceholderText = styled.Text`
font-family: 'Gilroy-Bold';
font-size: 16px;
color: #a7a7a9;
`;
const Header = styled.View`
margin: 0 20px;
flex-direction: row;
align-items: center;
margin-bottom: 15px;
justify-content: space-between;
`;
const Title = styled.Text`
color: #fff;
font-family: 'Gilroy-Bold';
font-size: 20px;
`;
const NoAlbumCover = styled.View`
width: 180px;
height: 180px;
background-color: #161515;
margin-right: 8px;
margin-left: 8px;
border-radius: 3px;
align-items: center;
justify-content: center;
`;
const AlbumCover = styled.View`
flex-direction: column;
`;
const Cover = styled.Image`
width: 180px;
height: 180px;
margin-right: 8px;
margin-left: 8px;
border-radius: 3px;
`;
const AlbumTitle = styled.Text`
color: #fff;
font-family: 'Gilroy-Bold';
font-size: 16px;
width: 180px;
`;
const Artist = styled.Text`
color: #a7a7a9;
font-family: 'Gilroy-Bold';
font-size: 14px;
width: 180px;
`;
const AlbumInfo = styled.View`
margin-left: 8px;
margin-top: 10px;
`;
const SeeAll = styled.Text`
color: #a7a7a9;
font-family: 'Gilroy-Bold';
font-size: 14px;
`;
export type AlbumProps = {
album: any;
onPress: (album: any) => void;
};
const Album: FC<AlbumProps> = ({album, onPress}) => {
const cover = useCover(album.cover);
return (
<>
{!album.cover && (
<TouchableWithoutFeedback onPress={() => onPress(album)}>
<AlbumCover>
<NoAlbumCover>
<Feather name="disc" size={100} color="#bdb9b958" />
</NoAlbumCover>
<AlbumInfo>
<AlbumTitle ellipsizeMode="tail" numberOfLines={1}>
{album.title}
</AlbumTitle>
<Artist ellipsizeMode="tail" numberOfLines={2}>
{album.artist}
</Artist>
</AlbumInfo>
</AlbumCover>
</TouchableWithoutFeedback>
)}
{album.cover && (
<TouchableWithoutFeedback onPress={() => onPress(album)}>
<AlbumCover>
<Cover
source={{
uri: cover,
}}
/>
<AlbumInfo>
<AlbumTitle ellipsizeMode="tail" numberOfLines={1}>
{album.title}
</AlbumTitle>
<Artist ellipsizeMode="tail" numberOfLines={2}>
{album.artist}
</Artist>
</AlbumInfo>
</AlbumCover>
</TouchableWithoutFeedback>
)}
</>
);
};
export type AlbumsProps = {
albums: any;
onSeeAll: () => void;
onAlbumPress: (album: any) => void;
};
const Albums: FC<AlbumsProps> = props => {
const {albums, onSeeAll, onAlbumPress} = props;
return (
<Container>
<Header>
<Title>Albums</Title>
{albums.length > 0 && (
<TouchableWithoutFeedback onPress={onSeeAll}>
<SeeAll>See All</SeeAll>
</TouchableWithoutFeedback>
)}
</Header>
<>
{albums.length > 0 && (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={albums}
renderItem={({item}) => (
<Album key={item.id} album={item} onPress={onAlbumPress} />
)}
/>
)}
{albums.length === 0 && (
<Placeholder>
<PlaceholderText>No Albums</PlaceholderText>
</Placeholder>
)}
</>
</Container>
);
};
export default Albums;

View File

@@ -0,0 +1,26 @@
import React, {FC} from 'react';
import Albums from './Albums';
import {useGetAlbumsQuery} from '../../Hooks/GraphQL';
const AlbumsWithData: FC = () => {
const {data, loading} = useGetAlbumsQuery({
variables: {
limit: 10,
},
});
const albums = !loading && data ? data.albums : [];
return (
<Albums
albums={albums.map(album => ({
id: album.id,
title: album.title,
artist: album.artist,
cover: album.cover,
}))}
onAlbumPress={() => {}}
onSeeAll={() => {}}
/>
);
};
export default AlbumsWithData;

View File

@@ -0,0 +1,25 @@
import React, {FC} from 'react';
import Artists from './Artists';
import {useGetArtistsQuery} from '../../Hooks/GraphQL';
const ArtistsWithData: FC = () => {
const {data, loading} = useGetArtistsQuery({
variables: {
limit: 10,
},
});
const artists = !loading && data ? data.artists : [];
return (
<Artists
artists={artists.map(artist => ({
id: artist.id,
name: artist.name,
cover: artist.picture,
}))}
onSeeAll={() => {}}
onArtistPress={() => {}}
/>
);
};
export default ArtistsWithData;

View File

@@ -0,0 +1,151 @@
import styled from '@emotion/native';
import React, {FC, useState, useEffect} from 'react';
import Feather from 'react-native-vector-icons/Feather';
import Ionicons from 'react-native-vector-icons/Ionicons';
import {Keyboard} from 'react-native';
import {useCover} from '../../Hooks/useCover';
const Container = styled.TouchableOpacity`
flex-direction: row;
height: 60px;
width: 100%;
background-color: #000;
`;
const TrackTitle = styled.Text`
color: #fff;
font-family: 'Gilroy-Bold';
font-size: 16px;
`;
const TrackArtist = styled.Text`
color: #a7a7a9;
font-family: 'Gilroy-Bold';
font-size: 14px;
margin-top: 2px;
`;
const TrackInfo = styled.View`
flex-direction: column;
margin-left: 20px;
justify-content: center;
flex: 1;
`;
const Cover = styled.Image`
width: 60px;
height: 60px;
border-radius: 2px;
`;
const NoCover = styled.View`
width: 60px;
height: 60px;
background-color: #161515;
align-items: center;
justify-content: center;
border-radius: 3px;
`;
const ProgressbarContainer = styled.View`
width: 100%;
height: 1.5px;
background-color: #4f4f4f;
`;
const ProgressbarFill = styled.View<{progress: number}>`
width: 0%;
height: 100%;
background-color: #ab28fc;
${({progress}) => `width: ${progress}%`};
`;
const Button = styled.TouchableOpacity`
width: 60px;
height: 60px;
align-items: center;
justify-content: center;
`;
export type ProgressbarProps = {
progress: number;
};
const Progressbar: FC<ProgressbarProps> = props => {
return (
<ProgressbarContainer>
<ProgressbarFill {...props} />
</ProgressbarContainer>
);
};
export type MiniPlayerProps = {
track: any;
playing: boolean;
progress: number;
onPlay: () => void;
onPause: () => void;
onSkipNext: () => void;
onOpenPlayer: () => void;
};
const MiniPlayer: FC<MiniPlayerProps> = props => {
const {track, playing, progress, onPlay, onPause, onSkipNext, onOpenPlayer} =
props;
const [keyboardVisible, setKeyboardVisible] = useState(false);
const cover = useCover(track.cover);
// listen to keyboard events
useEffect(() => {
const subscription = Keyboard.addListener('keyboardDidShow', () => {
setKeyboardVisible(true);
});
const subscription2 = Keyboard.addListener('keyboardDidHide', () => {
setKeyboardVisible(false);
});
return () => {
subscription.remove();
subscription2.remove();
};
}, []);
if (keyboardVisible) {
return null;
}
return (
<>
<Progressbar progress={progress} />
<Container onPress={onOpenPlayer}>
<>
{!track.cover && (
<NoCover>
<Feather name="music" size={30} color="#a7a7a9" />
</NoCover>
)}
{track.cover && <Cover source={{uri: cover}} />}
</>
<TrackInfo>
<TrackTitle numberOfLines={1}>{track.title}</TrackTitle>
<TrackArtist numberOfLines={1}>{track.artist}</TrackArtist>
</TrackInfo>
<>
{!playing && (
<Button onPress={onPlay}>
<Ionicons name="play" size={24} color="#fff" />
</Button>
)}
{playing && (
<Button onPress={onPause}>
<Ionicons name="pause" size={24} color="#fff" />
</Button>
)}
</>
<Button onPress={onSkipNext}>
<Ionicons name="play-skip-forward" size={24} color="#fff" />
</Button>
</Container>
</>
);
};
export default MiniPlayer;

View File

@@ -0,0 +1,158 @@
import styled from '@emotion/native';
import React, {FC} from 'react';
import {TouchableWithoutFeedback} from 'react-native';
import Feather from 'react-native-vector-icons/Feather';
import Ionicons from 'react-native-vector-icons/Ionicons';
import {Track as TrackType} from '../../Types';
import {useCover} from '../../Hooks/useCover';
const Container = styled.View`
width: 100%;
`;
const Header = styled.View`
margin: 0 20px;
flex-direction: row;
align-items: center;
margin-bottom: 15px;
justify-content: space-between;
`;
const Title = styled.Text`
color: #fff;
font-family: 'Gilroy-Bold';
font-size: 24px;
`;
const SeeAll = styled.Text`
color: #a7a7a9;
font-family: 'Gilroy-Bold';
font-size: 14px;
`;
const TrackRow = styled.View`
flex-direction: row;
align-items: center;
margin-left: 20px;
height: 60px;
`;
const TrackTitle = styled.Text<{active?: boolean}>`
color: #fff;
font-family: 'Gilroy-Bold';
font-size: 16px;
${props => props.active && 'color: #ab28fc;'}
`;
const TrackArtist = styled.Text`
color: #a7a7a9;
font-family: 'Gilroy-Bold';
font-size: 14px;
margin-top: 2px;
`;
const TrackInfo = styled.View`
flex-direction: column;
margin-left: 20px;
flex: 1;
`;
const Cover = styled.Image`
width: 60px;
height: 60px;
border-radius: 3px;
`;
const NoCover = styled.View`
width: 60px;
height: 60px;
background-color: #161515;
align-items: center;
justify-content: center;
border-radius: 3px;
`;
const TrackWrapper = styled.View`
background-color: #232323;
`;
const TouchableTrack = styled.TouchableOpacity`
background-color: #000;
justify-content: center;
height: 70px;
`;
const Button = styled.TouchableOpacity`
height: 40px;
width: 40px;
align-items: center;
justify-content: center;
z-index: 1;
`;
export type TrackProps = {
item: any;
active: boolean;
onPress: (item: any) => void;
};
const Track: FC<TrackProps> = props => {
const {item, active, onPress} = props;
const cover = useCover(item.cover);
return (
<TrackWrapper>
<TouchableTrack onPress={() => onPress(item)}>
<TrackRow>
{item.cover && <Cover source={{uri: cover}} />}
{!item.cover && (
<NoCover>
<Feather name="music" size={30} color="#a7a7a9" />
</NoCover>
)}
<TrackInfo>
<TrackTitle active={active} ellipsizeMode="tail" numberOfLines={1}>
{item.title}
</TrackTitle>
<TrackArtist ellipsizeMode="tail" numberOfLines={1}>
{item.artist}
</TrackArtist>
</TrackInfo>
<Button>
<Ionicons name="ellipsis-vertical" color={'#ffffff99'} size={18} />
</Button>
</TrackRow>
</TouchableTrack>
</TrackWrapper>
);
};
export type SongsProps = {
tracks: TrackType[];
currentTrack?: TrackType;
onSeeAll: () => void;
onPressTrack: (item: any) => void;
};
const Songs: FC<SongsProps> = props => {
const {tracks, currentTrack, onSeeAll, onPressTrack} = props;
return (
<Container>
<Header>
<Title>Tracks</Title>
<TouchableWithoutFeedback onPress={onSeeAll}>
<SeeAll>See All</SeeAll>
</TouchableWithoutFeedback>
</Header>
{tracks.map((item: any) => (
<Track
key={item.id}
item={item}
active={item.id === currentTrack?.id}
onPress={onPressTrack}
/>
))}
</Container>
);
};
export default Songs;

View File

@@ -0,0 +1,34 @@
import React, {FC} from 'react';
import Songs from './Songs';
import {useRecoilState} from 'recoil';
import {currentTrackState} from '../CurrentTrack/CurrentTrackState';
import {useGetTracksQuery} from '../../Hooks/GraphQL';
const SongsWithData: FC = () => {
const {data, loading} = useGetTracksQuery({
variables: {
limit: 10,
},
});
const tracks = !loading && data ? data.tracks : [];
const [currentTrack, setCurrentTrack] = useRecoilState(currentTrackState);
return (
<Songs
tracks={tracks.map(track => ({
id: track.id,
title: track.title,
artist: track.artist,
album: track.album.title,
duration: track.duration!,
cover: track.album.cover || undefined,
artistId: track.artists[0].id,
albumId: track.album.id,
}))}
currentTrack={currentTrack}
onPressTrack={setCurrentTrack}
onSeeAll={() => {}}
/>
);
};
export default SongsWithData;

View File

@@ -0,0 +1,108 @@
import React, {FC} from 'react';
import {Track} from '../../Types';
import styled, {css} from '@emotion/native';
import Feather from 'react-native-vector-icons/Feather';
import Ionicons from 'react-native-vector-icons/Ionicons';
import {useCover} from '../../Hooks/useCover';
const Container = styled.View`
height: 80px;
width: 100%;
flex-direction: row;
align-items: center;
padding-left: 20px;
`;
const Cover = styled.Image`
width: 60px;
height: 60px;
`;
const NoAlbumCover = styled.View`
width: 60px;
height: 60px;
background-color: #161515;
align-items: center;
justify-content: center;
`;
const Title = styled.Text<{active: boolean}>`
color: #fff;
font-family: 'Gilroy-Bold';
font-size: 16px;
${props =>
props.active &&
css`
color: #ab28fc;
`}
`;
const Artist = styled.Text`
color: #a7a7a9;
font-family: 'Gilroy-Bold';
font-size: 14px;
margin-top: 2px;
`;
const AlbumInfo = styled.View`
flex-direction: column;
margin-left: 15px;
flex: 1;
`;
const Button = styled.TouchableOpacity`
height: 40px;
width: 40px;
align-items: center;
justify-content: center;
z-index: 1;
`;
const TrackWrapper = styled.View`
background-color: #232323;
`;
const TouchableTrack = styled.TouchableOpacity`
background-color: #000;
`;
export type TrackRowProps = {
track: Track;
currentTrack?: Track;
onPlay: (item: Track) => void;
};
const TrackRow: FC<TrackRowProps> = props => {
const {track, currentTrack, onPlay} = props;
const cover = useCover(track.cover);
return (
<TrackWrapper>
<TouchableTrack onPress={() => onPlay(track)}>
<Container>
{track.cover && <Cover source={{uri: cover}} />}
{!track.cover && (
<NoAlbumCover>
<Feather name="disc" size={40} color="#a7a7a9" />
</NoAlbumCover>
)}
<AlbumInfo>
<Title
numberOfLines={1}
ellipsizeMode="tail"
active={track.id === currentTrack?.id}>
{track.title}
</Title>
<Artist numberOfLines={1} ellipsizeMode="tail">
{track.artist}
</Artist>
</AlbumInfo>
<Button>
<Ionicons name="ellipsis-vertical" color={'#ffffff99'} size={18} />
</Button>
</Container>
</TouchableTrack>
</TrackWrapper>
);
};
export default TrackRow;

View File

@@ -0,0 +1,7 @@
import Config from 'react-native-config';
export const useCover = (cover?: string) => {
return cover?.startsWith('http')
? cover
: `${Config.API_URL?.replace('/graphql', '/covers')}/${cover}`;
};

View File

@@ -0,0 +1,15 @@
export const useTimeFormat = () => {
const formatTime = (millis: number) => {
let minutes = Math.floor(millis / 60000);
const seconds = ((millis % 60000) / 1000).toFixed(0);
const secondsDisplay = seconds.length === 1 ? `0${seconds}` : seconds;
if (seconds === '60') {
minutes += 1;
return `${minutes < 10 ? `0${minutes}` : minutes}:00`;
} else {
return `${minutes < 10 ? `0${minutes}` : minutes}:${secondsDisplay}`;
}
};
return {formatTime};
};

View File

@@ -1,6 +1,8 @@
use anyhow::Error;
use music_player_entity::{album as album_entity, artist as artist_entity, track as track_entity};
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter, QueryOrder};
use sea_orm::{
ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter, QueryOrder, QuerySelect,
};
pub struct AlbumRepository {
db: DatabaseConnection,
@@ -38,28 +40,33 @@ impl AlbumRepository {
pub async fn find_all(
&self,
filter: Option<String>,
offset: Option<u64>,
limit: Option<u64>,
) -> Result<Vec<album_entity::Model>, Error> {
let mut query = match offset {
Some(offset) => album_entity::Entity::find()
.order_by_asc(album_entity::Column::Title)
.offset(offset),
None => album_entity::Entity::find().order_by_asc(album_entity::Column::Title),
};
query = match limit {
Some(limit) => query.limit(limit),
None => query,
};
match filter {
Some(filter) => {
if filter.is_empty() {
let results = album_entity::Entity::find()
.order_by_asc(album_entity::Column::Title)
.all(&self.db)
.await?;
let results = query.all(&self.db).await?;
return Ok(results);
}
let results = album_entity::Entity::find()
let results = query
.filter(album_entity::Column::Title.like(format!("%{}%", filter).as_str()))
.order_by_asc(album_entity::Column::Title)
.all(&self.db)
.await?;
Ok(results)
}
None => {
let results = album_entity::Entity::find()
.order_by_asc(album_entity::Column::Title)
.all(&self.db)
.await?;
let results = query.all(&self.db).await?;
Ok(results)
}
}

View File

@@ -1,6 +1,6 @@
use anyhow::Error;
use music_player_entity::{album as album_entity, artist as artist_entity, track as track_entity};
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder};
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
pub struct ArtistRepository {
db: DatabaseConnection,
@@ -50,28 +50,35 @@ impl ArtistRepository {
pub async fn find_all(
&self,
filter: Option<String>,
offset: Option<u64>,
limit: Option<u64>,
) -> Result<Vec<artist_entity::Model>, Error> {
let mut query = match offset {
Some(offset) => artist_entity::Entity::find()
.order_by_asc(artist_entity::Column::Name)
.offset(offset),
None => artist_entity::Entity::find().order_by_asc(artist_entity::Column::Name),
};
query = match limit {
Some(limit) => query.limit(limit),
None => query,
};
match filter {
Some(filter) => {
if filter.is_empty() {
let results = artist_entity::Entity::find()
.order_by_asc(artist_entity::Column::Name)
.all(&self.db)
.await?;
let results = query.all(&self.db).await?;
return Ok(results);
}
let results = artist_entity::Entity::find()
let results = query
.filter(artist_entity::Column::Name.like(format!("%{}%", filter).as_str()))
.order_by_asc(artist_entity::Column::Name)
.all(&self.db)
.await?;
Ok(results)
}
None => {
let results = artist_entity::Entity::find()
.order_by_asc(artist_entity::Column::Name)
.all(&self.db)
.await?;
let results = query.all(&self.db).await?;
Ok(results)
}
}

View File

@@ -42,21 +42,24 @@ impl TrackRepository {
pub async fn find_all(
&self,
filter: Option<String>,
offset: Option<u64>,
limit: u64,
) -> Result<Vec<track_entity::Model>, Error> {
let query = match offset {
Some(offset) => track_entity::Entity::find().offset(offset).limit(limit),
None => track_entity::Entity::find().limit(limit),
};
let results = match filter {
Some(filter) => {
if filter.is_empty() {
track_entity::Entity::find()
.limit(limit)
query
.order_by_asc(track_entity::Column::Title)
.find_with_related(artist_entity::Entity)
.all(&self.db)
.await?
} else {
track_entity::Entity::find()
query
.filter(track_entity::Column::Title.like(format!("%{}%", filter).as_str()))
.limit(limit)
.order_by_asc(track_entity::Column::Title)
.find_with_related(artist_entity::Entity)
.all(&self.db)
@@ -64,8 +67,7 @@ impl TrackRepository {
}
}
None => {
track_entity::Entity::find()
.limit(limit)
query
.order_by_asc(track_entity::Column::Title)
.find_with_related(artist_entity::Entity)
.all(&self.db)
@@ -73,13 +75,16 @@ impl TrackRepository {
}
};
let albums: Vec<(track_entity::Model, Option<album_entity::Model>)> =
track_entity::Entity::find()
.limit(limit)
.order_by_asc(track_entity::Column::Title)
.find_also_related(album_entity::Entity)
.all(&self.db)
.await?;
let query = match offset {
Some(offset) => track_entity::Entity::find().offset(offset).limit(limit),
None => track_entity::Entity::find().limit(limit),
};
let albums: Vec<(track_entity::Model, Option<album_entity::Model>)> = query
.order_by_asc(track_entity::Column::Title)
.find_also_related(album_entity::Entity)
.all(&self.db)
.await?;
let albums: Vec<Option<album_entity::Model>> = albums
.into_iter()