diff --git a/src/components/ListButton.tsx b/src/components/ListButton.tsx new file mode 100644 index 0000000..935356d --- /dev/null +++ b/src/components/ListButton.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { TouchableOpacityProps, Text } from 'react-native'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { faChevronRight } from '@fortawesome/free-solid-svg-icons'; +import styled from 'styled-components/native'; + +const Container = styled.TouchableOpacity` + padding: 18px 0; + border-bottom-width: 1px; + border-bottom-color: #eee; + flex-direction: row; + justify-content: space-between; +`; + +const ListButton: React.FC = ({ children, ...props }) => { + return ( + + {children} + + + ); +}; + +export default ListButton; \ No newline at end of file diff --git a/src/components/Typography.ts b/src/components/Typography.ts new file mode 100644 index 0000000..288f206 --- /dev/null +++ b/src/components/Typography.ts @@ -0,0 +1,7 @@ +import styled from 'styled-components/native'; + +export const Header = styled.Text` + margin: 24px 0 12px 0; + font-size: 36px; + font-weight: bold; +`; \ No newline at end of file diff --git a/src/screens/Albums/index.tsx b/src/screens/Albums/index.tsx deleted file mode 100644 index 9ba9d80..0000000 --- a/src/screens/Albums/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import { createStackNavigator } from '@react-navigation/stack'; -import { RootStackParamList } from './types'; -import Albums from './components/Albums'; -import Album from './components/Album'; - -const Stack = createStackNavigator(); - -function AlbumStack() { - return ( - - - - - ); -} - -export default AlbumStack; \ No newline at end of file diff --git a/src/screens/Music/index.tsx b/src/screens/Music/index.tsx new file mode 100644 index 0000000..086bee1 --- /dev/null +++ b/src/screens/Music/index.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { createStackNavigator } from '@react-navigation/stack'; +import { StackParams } from './types'; +import Albums from './stacks/Albums'; +import Album from './stacks/Album'; +import RecentAlbums from './stacks/RecentAlbums'; + +const Stack = createStackNavigator(); + +function MusicStack() { + return ( + + + + + + ); +} + +export default MusicStack; \ No newline at end of file diff --git a/src/screens/Albums/components/Album.tsx b/src/screens/Music/stacks/Album.tsx similarity index 100% rename from src/screens/Albums/components/Album.tsx rename to src/screens/Music/stacks/Album.tsx diff --git a/src/screens/Albums/components/Albums.tsx b/src/screens/Music/stacks/Albums.tsx similarity index 74% rename from src/screens/Albums/components/Albums.tsx rename to src/screens/Music/stacks/Albums.tsx index c39a6de..213322d 100644 --- a/src/screens/Albums/components/Albums.tsx +++ b/src/screens/Music/stacks/Albums.tsx @@ -1,43 +1,22 @@ import React, { useCallback, useEffect } from 'react'; import { useGetImage } from 'utility/JellyfinApi'; import { Album, NavigationProp } from '../types'; -import { Text, SafeAreaView, FlatList, Dimensions } from 'react-native'; -import styled from 'styled-components/native'; +import { Text, SafeAreaView, FlatList } from 'react-native'; import { useDispatch } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; -import FastImage from 'react-native-fast-image'; import { differenceInDays } from 'date-fns'; import { useTypedSelector } from 'store'; import { fetchAllAlbums } from 'store/music/actions'; import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS'; import TouchableHandler from 'components/TouchableHandler'; - -const Screen = Dimensions.get('screen'); - -const Container = styled.View` - /* flex-direction: row; - flex-wrap: wrap; - flex: 1; */ - padding: 10px; - background-color: #f6f6f6; -`; - -const AlbumItem = styled.View` - width: ${Screen.width / 2 - 10}px; - padding: 10px; -`; - -const AlbumImage = styled(FastImage)` - border-radius: 10px; - width: ${Screen.width / 2 - 40}px; - height: ${Screen.width / 2 - 40}px; - background-color: #fefefe; - margin-bottom: 5px; -`; +import ListContainer from './components/ListContainer'; +import AlbumImage, { AlbumItem } from './components/AlbumImage'; +import { useAlbumsByArtist } from 'store/music/selectors'; const Albums: React.FC = () => { // Retrieve data from store - const { ids, entities: albums } = useTypedSelector((state) => state.music.albums); + const { entities: albums } = useTypedSelector((state) => state.music.albums); + const ids = useAlbumsByArtist(); const isLoading = useTypedSelector((state) => state.music.albums.isLoading); const lastRefreshed = useTypedSelector((state) => state.music.lastRefreshed); @@ -60,7 +39,7 @@ const Albums: React.FC = () => { return ( - + { )} /> - + ); }; diff --git a/src/screens/Music/stacks/RecentAlbums.tsx b/src/screens/Music/stacks/RecentAlbums.tsx new file mode 100644 index 0000000..a7d516f --- /dev/null +++ b/src/screens/Music/stacks/RecentAlbums.tsx @@ -0,0 +1,73 @@ +import React, { useCallback, useEffect } from 'react'; +import { useGetImage } from 'utility/JellyfinApi'; +import { Album, NavigationProp } from '../types'; +import { Text, SafeAreaView, FlatList, View } from 'react-native'; +import { useDispatch } from 'react-redux'; +import { useNavigation } from '@react-navigation/native'; +import { useTypedSelector } from 'store'; +import { fetchRecentAlbums } from 'store/music/actions'; +import TouchableHandler from 'components/TouchableHandler'; +import ListContainer from './components/ListContainer'; +import AlbumImage, { AlbumItem } from './components/AlbumImage'; +import { useRecentAlbums } from 'store/music/selectors'; +import { Header } from 'components/Typography'; +import ListButton from 'components/ListButton'; + +const NavigationHeader: React.FC = () => { + const navigation = useNavigation(); + const handleAllAlbumsClick = useCallback(() => { navigation.navigate('Albums'); }, [navigation]); + + return ( + + All Albums +
Recent Albums
+
+ ); +}; + +const RecentAlbums: React.FC = () => { + // Retrieve data from store + const { entities: albums } = useTypedSelector((state) => state.music.albums); + const recentAlbums = useRecentAlbums(24); + const isLoading = useTypedSelector((state) => state.music.albums.isLoading); + + // Initialise helpers + const dispatch = useDispatch(); + const navigation = useNavigation(); + const getImage = useGetImage(); + + // Set callbacks + const retrieveData = useCallback(() => dispatch(fetchRecentAlbums()), [dispatch]); + const selectAlbum = useCallback((id: string) => navigation.navigate('Album', { id, album: albums[id] as Album }), [navigation, albums]); + + console.log(recentAlbums.map((d) => albums[d]?.DateCreated)); + + // Retrieve data on mount + useEffect(() => { retrieveData(); }, []); + + return ( + + + d} + ListHeaderComponent={NavigationHeader} + renderItem={({ item }) => ( + + + + {albums[item]?.Name} + {albums[item]?.AlbumArtist} + + + )} + /> + + + ); +}; + +export default RecentAlbums; \ No newline at end of file diff --git a/src/screens/Music/stacks/components/AlbumImage.ts b/src/screens/Music/stacks/components/AlbumImage.ts new file mode 100644 index 0000000..9735f2b --- /dev/null +++ b/src/screens/Music/stacks/components/AlbumImage.ts @@ -0,0 +1,20 @@ +import styled from 'styled-components/native'; +import FastImage from 'react-native-fast-image'; +import { Dimensions } from 'react-native'; + +const Screen = Dimensions.get('screen'); + +export const AlbumItem = styled.View` + width: ${Screen.width / 2 - 10}px; + padding: 10px; +`; + +const AlbumImage = styled(FastImage)` + border-radius: 10px; + width: ${Screen.width / 2 - 40}px; + height: ${Screen.width / 2 - 40}px; + background-color: #fefefe; + margin-bottom: 5px; +`; + +export default AlbumImage; \ No newline at end of file diff --git a/src/screens/Music/stacks/components/ListContainer.ts b/src/screens/Music/stacks/components/ListContainer.ts new file mode 100644 index 0000000..9491f8a --- /dev/null +++ b/src/screens/Music/stacks/components/ListContainer.ts @@ -0,0 +1,8 @@ +import styled from 'styled-components/native'; + +const ListContainer = styled.View` + padding: 10px; + background-color: #f6f6f6; +`; + +export default ListContainer; \ No newline at end of file diff --git a/src/screens/Albums/types.ts b/src/screens/Music/types.ts similarity index 96% rename from src/screens/Albums/types.ts rename to src/screens/Music/types.ts index 865e13f..1231a85 100644 --- a/src/screens/Albums/types.ts +++ b/src/screens/Music/types.ts @@ -40,6 +40,7 @@ export interface Album { ImageTags: ImageTags; BackdropImageTags: any[]; LocationType: string; + DateCreated: string; } export interface AlbumTrack { @@ -68,6 +69,7 @@ export interface AlbumTrack { export type StackParams = { Albums: undefined; Album: { id: string, album: Album }; + RecentAlbums: undefined; }; export type NavigationProp = StackNavigationProp; diff --git a/src/screens/index.tsx b/src/screens/index.tsx index 32da648..f7de586 100644 --- a/src/screens/index.tsx +++ b/src/screens/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { createBottomTabNavigator, BottomTabNavigationProp } from '@react-navigation/bottom-tabs'; import Player from './Player'; -import Albums from './Albums'; +import Music from './Music'; import Settings from './Settings'; import { createStackNavigator, StackNavigationProp } from '@react-navigation/stack'; import SetJellyfinServer from './modals/SetJellyfinServer'; @@ -12,15 +12,15 @@ const Tab = createBottomTabNavigator(); type Screens = { NowPlaying: undefined; - Albums: undefined; + Music: undefined; Settings: undefined; } function Screens() { return ( - - + + ); diff --git a/src/store/index.ts b/src/store/index.ts index 075707a..b835b00 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -2,7 +2,7 @@ import { configureStore, getDefaultMiddleware, combineReducers } from '@reduxjs/ import { useSelector, TypedUseSelectorHook } from 'react-redux'; import AsyncStorage from '@react-native-community/async-storage'; import { persistStore, persistReducer } from 'redux-persist'; -import logger from 'redux-logger'; +// import logger from 'redux-logger'; const persistConfig = { key: 'root', @@ -22,7 +22,7 @@ const persistedReducer = persistReducer(persistConfig, reducers); const store = configureStore({ reducer: persistedReducer, middleware: getDefaultMiddleware({ serializableCheck: false, immutableCheck: false }).concat( - logger + // logger ), }); diff --git a/src/store/music/actions.ts b/src/store/music/actions.ts index d03bb1c..ba113bd 100644 --- a/src/store/music/actions.ts +++ b/src/store/music/actions.ts @@ -1,31 +1,46 @@ import { createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit'; import { Album, AlbumTrack } from './types'; import { AsyncThunkAPI } from '..'; -import { retrieveAlbums, retrieveAlbumTracks } from 'utility/JellyfinApi'; +import { retrieveAlbums, retrieveAlbumTracks, retrieveRecentAlbums } from 'utility/JellyfinApi'; export const albumAdapter = createEntityAdapter({ selectId: album => album.Id, sortComparer: (a, b) => a.Name.localeCompare(b.Name), }); +/** + * Fetch all albums available on the jellyfin server + */ export const fetchAllAlbums = createAsyncThunk( '/albums/all', async (empty, thunkAPI) => { - console.log('RETRIEVING ALBUMS'); const credentials = thunkAPI.getState().settings.jellyfin; return retrieveAlbums(credentials) as Promise; } ); +/** + * Retrieve the most recent albums + */ +export const fetchRecentAlbums = createAsyncThunk( + '/albums/recent', + async (numberOfAlbums, thunkAPI) => { + const credentials = thunkAPI.getState().settings.jellyfin; + return retrieveRecentAlbums(credentials, numberOfAlbums) as Promise; + } +); + export const trackAdapter = createEntityAdapter({ selectId: track => track.Id, sortComparer: (a, b) => a.IndexNumber - b.IndexNumber, }); +/** + * Retrieve all tracks from a particular album + */ export const fetchTracksByAlbum = createAsyncThunk( '/tracks/byAlbum', async (ItemId, thunkAPI) => { - console.log('RETRIEVING ALBUMS'); const credentials = thunkAPI.getState().settings.jellyfin; return retrieveAlbumTracks(ItemId, credentials) as Promise; } diff --git a/src/store/music/index.ts b/src/store/music/index.ts index c5af7b0..4984216 100644 --- a/src/store/music/index.ts +++ b/src/store/music/index.ts @@ -1,4 +1,4 @@ -import { fetchAllAlbums, albumAdapter, fetchTracksByAlbum, trackAdapter } from './actions'; +import { fetchAllAlbums, albumAdapter, fetchTracksByAlbum, trackAdapter, fetchRecentAlbums } from './actions'; import { createSlice, Dictionary, EntityId } from '@reduxjs/toolkit'; import { Album, AlbumTrack } from './types'; @@ -32,6 +32,9 @@ const music = createSlice({ initialState, reducers: {}, extraReducers: builder => { + /** + * Fetch All albums + */ builder.addCase(fetchAllAlbums.fulfilled, (state, { payload }) => { albumAdapter.setAll(state.albums, payload); state.albums.isLoading = false; @@ -39,6 +42,20 @@ const music = createSlice({ }); builder.addCase(fetchAllAlbums.pending, (state) => { state.albums.isLoading = true; }); builder.addCase(fetchAllAlbums.rejected, (state) => { state.albums.isLoading = false; }); + + /** + * Fetch most recent albums + */ + builder.addCase(fetchRecentAlbums.fulfilled, (state, { payload }) => { + albumAdapter.upsertMany(state.albums, payload); + state.albums.isLoading = false; + }); + builder.addCase(fetchRecentAlbums.pending, (state) => { state.albums.isLoading = true; }); + builder.addCase(fetchRecentAlbums.rejected, (state) => { state.albums.isLoading = false; }); + + /** + * Fetch tracks by album + */ builder.addCase(fetchTracksByAlbum.fulfilled, (state, { payload }) => { trackAdapter.setAll(state.tracks, payload); diff --git a/src/store/music/selectors.ts b/src/store/music/selectors.ts new file mode 100644 index 0000000..2e8071b --- /dev/null +++ b/src/store/music/selectors.ts @@ -0,0 +1,42 @@ +import { useTypedSelector } from 'store'; +import { parseISO } from 'date-fns'; +import { Album } from './types'; + +/** + * Retrieves a list of the n most recent albums + */ +export function useRecentAlbums(amount: number) { + const albums = useTypedSelector((state) => state.music.albums.entities); + const albumIds = useTypedSelector((state) => state.music.albums.ids); + + const sorted = [...albumIds].sort((a, b) => { + const albumA = albums[a]; + const albumB = albums[b]; + const dateA = albumA ? parseISO(albumA.DateCreated).getTime() : 0; + const dateB = albumB ? parseISO(albumB.DateCreated).getTime() : 0; + return dateB - dateA; + }); + + return sorted.slice(0, amount); +} + +export function useAlbumsByArtist() { + const albums = useTypedSelector((state) => state.music.albums.entities); + const albumIds = useTypedSelector((state) => state.music.albums.ids); + + const sorted = [...albumIds].sort((a, b) => { + const albumA = albums[a]; + const albumB = albums[b]; + if ((!albumA && !albumB) || (!albumA?.AlbumArtist && !albumB?.AlbumArtist)) { + return 0; + } else if (!albumA || !albumA.AlbumArtist) { + return 1; + } else if (!albumB || !albumB.AlbumArtist) { + return -1; + } + + return albumA.AlbumArtist.localeCompare(albumB.AlbumArtist); + }); + + return sorted; +} \ No newline at end of file diff --git a/src/store/music/types.ts b/src/store/music/types.ts index 44f3f31..14b180d 100644 --- a/src/store/music/types.ts +++ b/src/store/music/types.ts @@ -35,13 +35,14 @@ export interface Album { PrimaryImageAspectRatio: number; Artists: string[]; ArtistItems: ArtistItem[]; - AlbumArtist: string; + AlbumArtist?: string; AlbumArtists: AlbumArtist[]; ImageTags: ImageTags; BackdropImageTags: any[]; LocationType: string; Tracks?: string[]; lastRefreshed?: number; + DateCreated: string; } export interface AlbumTrack { diff --git a/src/utility/JellyfinApi.ts b/src/utility/JellyfinApi.ts index 4114a0d..d3c154f 100644 --- a/src/utility/JellyfinApi.ts +++ b/src/utility/JellyfinApi.ts @@ -60,7 +60,7 @@ const albumOptions = { SortOrder: 'Ascending', IncludeItemTypes: 'MusicAlbum', Recursive: 'true', - Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo', + Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated', ImageTypeLimit: '1', EnableImageTypes: 'Primary,Backdrop,Banner,Thumb', }; @@ -72,13 +72,39 @@ const albumParams = new URLSearchParams(albumOptions).toString(); */ export async function retrieveAlbums(credentials: Credentials) { const config = generateConfig(credentials); - console.log(`${credentials?.uri}/Users/${credentials?.user_id}/Items?${albumParams}`); const albums = await fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items?${albumParams}`, config) .then(response => response.json()); return albums.Items; } +const latestAlbumsOptions = { + IncludeItemTypes: 'MusicAlbum', + Fields: 'DateCreated', + SortOrder: 'Ascending', +}; + + +/** + * Retrieve the most recently added albums on the Jellyfin server + */ +export async function retrieveRecentAlbums(credentials: Credentials, numberOfAlbums = 24) { + const config = generateConfig(credentials); + + // Generate custom config based on function input + const options = { + ...latestAlbumsOptions, + Limit: numberOfAlbums.toString(), + }; + const params = new URLSearchParams(options).toString(); + + // Retrieve albums + const albums = await fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items/Latest?${params}`, config) + .then(response => response.json()); + + return albums; +} + /** * Retrieve a single album from the Emby server */