From ade4c4c67ec87fe475a69fbab65926edd312be17 Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Sun, 23 Apr 2023 17:27:47 +0300 Subject: [PATCH] [mobile] fetch albums, artists and tracks from API --- graphql/src/schema/library.rs | 9 +- mobile/src/Components/AlbumRow/AlbumRow.tsx | 89 +++++++++ mobile/src/Components/Albums/Albums.tsx | 175 ++++++++++++++++++ .../src/Components/Albums/AlbumsWithData.tsx | 26 +++ .../Components/Artists/ArtistsWithData.tsx | 25 +++ .../src/Components/MiniPlayer/MiniPlayer.tsx | 151 +++++++++++++++ mobile/src/Components/Songs/Songs.tsx | 158 ++++++++++++++++ mobile/src/Components/Songs/SongsWithData.tsx | 34 ++++ mobile/src/Components/TrackRow/TrackRow.tsx | 108 +++++++++++ mobile/src/Hooks/useCover.tsx | 7 + mobile/src/Hooks/useFormat.tsx | 15 ++ storage/src/repo/album.rs | 29 +-- storage/src/repo/artist.rs | 29 +-- storage/src/repo/track.rs | 31 ++-- 14 files changed, 844 insertions(+), 42 deletions(-) create mode 100644 mobile/src/Components/AlbumRow/AlbumRow.tsx create mode 100644 mobile/src/Components/Albums/Albums.tsx create mode 100644 mobile/src/Components/Albums/AlbumsWithData.tsx create mode 100644 mobile/src/Components/Artists/ArtistsWithData.tsx create mode 100644 mobile/src/Components/MiniPlayer/MiniPlayer.tsx create mode 100644 mobile/src/Components/Songs/Songs.tsx create mode 100644 mobile/src/Components/Songs/SongsWithData.tsx create mode 100644 mobile/src/Components/TrackRow/TrackRow.tsx create mode 100644 mobile/src/Hooks/useCover.tsx create mode 100644 mobile/src/Hooks/useFormat.tsx diff --git a/graphql/src/schema/library.rs b/graphql/src/schema/library.rs index 916f5e3..a7eb278 100644 --- a/graphql/src/schema/library.rs +++ b/graphql/src/schema/library.rs @@ -103,13 +103,8 @@ impl LibraryQuery { .collect()); } - let db = ctx.data::().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::().unwrap(); + let results = ArtistRepository::new(db.get_connection()); async fn albums( &self, diff --git a/mobile/src/Components/AlbumRow/AlbumRow.tsx b/mobile/src/Components/AlbumRow/AlbumRow.tsx new file mode 100644 index 0000000..9b58ee2 --- /dev/null +++ b/mobile/src/Components/AlbumRow/AlbumRow.tsx @@ -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 = props => { + const {album, onSelected} = props; + const cover = useCover(album.cover); + return ( + onSelected()}> + + {album.cover && } + {!album.cover && ( + + + + )} + + + {album.title} + + + {album.artist} + + + + + + ); +}; + +export default AlbumRow; diff --git a/mobile/src/Components/Albums/Albums.tsx b/mobile/src/Components/Albums/Albums.tsx new file mode 100644 index 0000000..f9dee18 --- /dev/null +++ b/mobile/src/Components/Albums/Albums.tsx @@ -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 = ({album, onPress}) => { + const cover = useCover(album.cover); + return ( + <> + {!album.cover && ( + onPress(album)}> + + + + + + + {album.title} + + + {album.artist} + + + + + )} + {album.cover && ( + onPress(album)}> + + + + + {album.title} + + + {album.artist} + + + + + )} + + ); +}; + +export type AlbumsProps = { + albums: any; + onSeeAll: () => void; + onAlbumPress: (album: any) => void; +}; + +const Albums: FC = props => { + const {albums, onSeeAll, onAlbumPress} = props; + return ( + +
+ Albums + {albums.length > 0 && ( + + See All + + )} +
+ <> + {albums.length > 0 && ( + ( + + )} + /> + )} + {albums.length === 0 && ( + + No Albums + + )} + +
+ ); +}; + +export default Albums; diff --git a/mobile/src/Components/Albums/AlbumsWithData.tsx b/mobile/src/Components/Albums/AlbumsWithData.tsx new file mode 100644 index 0000000..6dd591c --- /dev/null +++ b/mobile/src/Components/Albums/AlbumsWithData.tsx @@ -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 ( + ({ + id: album.id, + title: album.title, + artist: album.artist, + cover: album.cover, + }))} + onAlbumPress={() => {}} + onSeeAll={() => {}} + /> + ); +}; + +export default AlbumsWithData; diff --git a/mobile/src/Components/Artists/ArtistsWithData.tsx b/mobile/src/Components/Artists/ArtistsWithData.tsx new file mode 100644 index 0000000..2593e3a --- /dev/null +++ b/mobile/src/Components/Artists/ArtistsWithData.tsx @@ -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 ( + ({ + id: artist.id, + name: artist.name, + cover: artist.picture, + }))} + onSeeAll={() => {}} + onArtistPress={() => {}} + /> + ); +}; + +export default ArtistsWithData; diff --git a/mobile/src/Components/MiniPlayer/MiniPlayer.tsx b/mobile/src/Components/MiniPlayer/MiniPlayer.tsx new file mode 100644 index 0000000..b1ae5bf --- /dev/null +++ b/mobile/src/Components/MiniPlayer/MiniPlayer.tsx @@ -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 = props => { + return ( + + + + ); +}; + +export type MiniPlayerProps = { + track: any; + playing: boolean; + progress: number; + onPlay: () => void; + onPause: () => void; + onSkipNext: () => void; + onOpenPlayer: () => void; +}; + +const MiniPlayer: FC = 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 ( + <> + + + <> + {!track.cover && ( + + + + )} + {track.cover && } + + + {track.title} + {track.artist} + + <> + {!playing && ( + + )} + {playing && ( + + )} + + + + + ); +}; + +export default MiniPlayer; diff --git a/mobile/src/Components/Songs/Songs.tsx b/mobile/src/Components/Songs/Songs.tsx new file mode 100644 index 0000000..e60636c --- /dev/null +++ b/mobile/src/Components/Songs/Songs.tsx @@ -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 = props => { + const {item, active, onPress} = props; + const cover = useCover(item.cover); + return ( + + onPress(item)}> + + {item.cover && } + {!item.cover && ( + + + + )} + + + {item.title} + + + {item.artist} + + + + + + + ); +}; + +export type SongsProps = { + tracks: TrackType[]; + currentTrack?: TrackType; + onSeeAll: () => void; + onPressTrack: (item: any) => void; +}; + +const Songs: FC = props => { + const {tracks, currentTrack, onSeeAll, onPressTrack} = props; + return ( + +
+ Tracks + + See All + +
+ {tracks.map((item: any) => ( + + ))} +
+ ); +}; + +export default Songs; diff --git a/mobile/src/Components/Songs/SongsWithData.tsx b/mobile/src/Components/Songs/SongsWithData.tsx new file mode 100644 index 0000000..b24774a --- /dev/null +++ b/mobile/src/Components/Songs/SongsWithData.tsx @@ -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 ( + ({ + 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; diff --git a/mobile/src/Components/TrackRow/TrackRow.tsx b/mobile/src/Components/TrackRow/TrackRow.tsx new file mode 100644 index 0000000..4a149b2 --- /dev/null +++ b/mobile/src/Components/TrackRow/TrackRow.tsx @@ -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 = props => { + const {track, currentTrack, onPlay} = props; + const cover = useCover(track.cover); + return ( + + onPlay(track)}> + + {track.cover && } + {!track.cover && ( + + + + )} + + + {track.title} + + + {track.artist} + + + + + + + ); +}; + +export default TrackRow; diff --git a/mobile/src/Hooks/useCover.tsx b/mobile/src/Hooks/useCover.tsx new file mode 100644 index 0000000..1657b34 --- /dev/null +++ b/mobile/src/Hooks/useCover.tsx @@ -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}`; +}; diff --git a/mobile/src/Hooks/useFormat.tsx b/mobile/src/Hooks/useFormat.tsx new file mode 100644 index 0000000..de7f884 --- /dev/null +++ b/mobile/src/Hooks/useFormat.tsx @@ -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}; +}; diff --git a/storage/src/repo/album.rs b/storage/src/repo/album.rs index 8458656..55df1c4 100644 --- a/storage/src/repo/album.rs +++ b/storage/src/repo/album.rs @@ -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, + offset: Option, + limit: Option, ) -> Result, 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) } } diff --git a/storage/src/repo/artist.rs b/storage/src/repo/artist.rs index 32d4824..d71f51b 100644 --- a/storage/src/repo/artist.rs +++ b/storage/src/repo/artist.rs @@ -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, + offset: Option, + limit: Option, ) -> Result, 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) } } diff --git a/storage/src/repo/track.rs b/storage/src/repo/track.rs index 7540461..10f039e 100644 --- a/storage/src/repo/track.rs +++ b/storage/src/repo/track.rs @@ -42,21 +42,24 @@ impl TrackRepository { pub async fn find_all( &self, filter: Option, + offset: Option, limit: u64, ) -> Result, 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)> = - 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)> = query + .order_by_asc(track_entity::Column::Title) + .find_also_related(album_entity::Entity) + .all(&self.db) + .await?; let albums: Vec> = albums .into_iter()