diff --git a/src/localisation/lang/en/locale.json b/src/localisation/lang/en/locale.json index c66c065..1069284 100644 --- a/src/localisation/lang/en/locale.json +++ b/src/localisation/lang/en/locale.json @@ -68,5 +68,6 @@ "color-scheme-description": "By default, Fintunes will follow your operating system's color scheme. You can however choose to override this to make sure Fintunes is always in dark mode or light mode.", "color-scheme-system": "System", "color-scheme-light": "Light Mode", - "color-scheme-dark": "Dark Mode" + "color-scheme-dark": "Dark Mode", + "artists": "Artists" } \ No newline at end of file diff --git a/src/localisation/types.ts b/src/localisation/types.ts index 1465209..ab39d95 100644 --- a/src/localisation/types.ts +++ b/src/localisation/types.ts @@ -66,4 +66,5 @@ export type LocaleKeys = 'play-next' | 'color-scheme-description' | 'color-scheme-system' | 'color-scheme-light' -| 'color-scheme-dark' \ No newline at end of file +| 'color-scheme-dark' +| 'artists' \ No newline at end of file diff --git a/src/screens/Music/index.tsx b/src/screens/Music/index.tsx index 7934dc3..a77acd3 100644 --- a/src/screens/Music/index.tsx +++ b/src/screens/Music/index.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { StyleSheet } from 'react-native'; import { createStackNavigator } from '@react-navigation/stack'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { THEME_COLOR } from 'CONSTANTS'; @@ -12,7 +13,8 @@ import Albums from './stacks/Albums'; import Album from './stacks/Album'; import Playlists from './stacks/Playlists'; import Playlist from './stacks/Playlist'; -import { StyleSheet } from 'react-native'; +import Artists from './stacks/Artists'; +import Artist from './stacks/Artist'; const Stack = createStackNavigator(); @@ -31,6 +33,8 @@ function MusicStack() { + + ({ headerTitle: route.params.Name })} /> diff --git a/src/screens/Music/stacks/Artist.tsx b/src/screens/Music/stacks/Artist.tsx new file mode 100644 index 0000000..707be37 --- /dev/null +++ b/src/screens/Music/stacks/Artist.tsx @@ -0,0 +1,101 @@ +import React, { useCallback, useEffect, ReactText } from 'react'; +import { useGetImage } from 'utility/JellyfinApi'; +import { View } from 'react-native'; +import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; +import { differenceInDays } from 'date-fns'; +import { useAppDispatch, useTypedSelector } from 'store'; +import { fetchAllAlbums } from 'store/music/actions'; +import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS'; +import TouchableHandler from 'components/TouchableHandler'; +import AlbumImage, { AlbumItem } from './components/AlbumImage'; +import { EntityId } from '@reduxjs/toolkit'; +import styled from 'styled-components/native'; +import useDefaultStyles from 'components/Colors'; +import { Album } from 'store/music/types'; +import { Text } from 'components/Typography'; +import { ShadowWrapper } from 'components/Shadow'; +import { NavigationProp, StackParams } from 'screens/types'; +import { SafeFlatList } from 'components/SafeNavigatorView'; +import { chunk } from 'lodash'; + +interface GeneratedAlbumItemProps { + id: ReactText; + imageUrl: string; + name: string; + artist: string; + onPress: (id: string) => void; +} + +const HalfOpacity = styled.Text` + opacity: 0.5; +`; + +const GeneratedAlbumItem = React.memo(function GeneratedAlbumItem(props: GeneratedAlbumItemProps) { + const defaultStyles = useDefaultStyles(); + const { id, imageUrl, name, artist, onPress } = props; + + return ( + + + + + + {name} + {artist} + + + ); +}); + +const Artist: React.FC = () => { + // Retrieve data from store + const { entities: albums } = useTypedSelector((state) => state.music.albums); + const isLoading = useTypedSelector((state) => state.music.albums.isLoading); + const lastRefreshed = useTypedSelector((state) => state.music.albums.lastRefreshed); + + // Initialise helpers + const dispatch = useAppDispatch(); + const navigation = useNavigation(); + const { params } = useRoute>(); + const getImage = useGetImage(); + + // Set callbacks + const retrieveData = useCallback(() => dispatch(fetchAllAlbums()), [dispatch]); + const selectAlbum = useCallback((id: string) => navigation.navigate('Album', { id, album: albums[id] as Album }), [navigation, albums]); + const generateItem = useCallback(({ item }: { item: EntityId[] }) => { + return ( + + {item.map((id) => ( + + ))} + + ); + }, [albums, getImage, selectAlbum]); + + // Retrieve data on mount + useEffect(() => { + // GUARD: Only refresh this API call every set amounts of days + if (!lastRefreshed || differenceInDays(lastRefreshed, new Date()) > ALBUM_CACHE_AMOUNT_OF_DAYS) { + retrieveData(); + } + }); + + return ( + + ); +}; + + +export default Artist; \ No newline at end of file diff --git a/src/screens/Music/stacks/Artists.tsx b/src/screens/Music/stacks/Artists.tsx new file mode 100644 index 0000000..2a7ae89 --- /dev/null +++ b/src/screens/Music/stacks/Artists.tsx @@ -0,0 +1,218 @@ +import React, { useCallback, useEffect, useRef, useMemo } from 'react'; +import { useGetImage } from 'utility/JellyfinApi'; +import { SectionList, View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { differenceInDays } from 'date-fns'; +import { useAppDispatch, useTypedSelector } from 'store'; +import { fetchAllAlbums } from 'store/music/actions'; +import { ALBUM_CACHE_AMOUNT_OF_DAYS, THEME_COLOR } from 'CONSTANTS'; +import AlbumImage from './components/AlbumImage'; +import { SectionArtistItem, SectionedArtist, selectArtists } from 'store/music/selectors'; +import AlphabetScroller from 'components/AlphabetScroller'; +import styled from 'styled-components/native'; +import useDefaultStyles, { ColoredBlurView } from 'components/Colors'; +import { Text } from 'components/Typography'; +import { NavigationProp } from 'screens/types'; +import { SafeSectionList } from 'components/SafeNavigatorView'; +import { Gap } from 'components/Utility'; + +const HeadingHeight = 50; + +function generateSection({ section }: { section: SectionedArtist }) { + return ( + + ); +} + +const SectionContainer = styled.View` + height: ${HeadingHeight}px; + justify-content: center; + padding: 0 24px; +`; + +const SectionText = styled(Text)` + font-size: 24px; + font-weight: 400; +`; + +const ArtistHeight = 32 + 8 * 2; + +const ArtistContainer = styled.Pressable` + padding: 8px 16px; + border-radius: 8px; + height: ${ArtistHeight}px; + display: flex; + flex-grow: 1; + flex-shrink: 1; + display: flex; + align-items: center; + flex-direction: row; + overflow: hidden; +`; + +const SectionHeading = React.memo(function SectionHeading(props: { label: string }) { + const { label } = props; + + return ( + + + {label} + + + ); +}); + +interface GeneratedArtistItemProps { + item: SectionArtistItem; + imageURL: string; + onPress: (payload: SectionArtistItem) => void; +} + +const GeneratedArtistItem = React.memo(function GeneratedArtistItem(props: GeneratedArtistItemProps) { + const defaultStyles = useDefaultStyles(); + const { item, imageURL, onPress } = props; + + const handlePress = useCallback(() => { + onPress(item); + }, [item, onPress]); + + return ( + [ + { borderColor: defaultStyles.divider.backgroundColor }, + pressed && defaultStyles.activeBackground, + ]} + > + {({ pressed }) => ( + <> + + + + {item.Name} + + + )} + + ); +}); + +const Artists: React.FC = () => { + // Retrieve data from store + // const { entities: albums } = useTypedSelector((state) => state.music.albums); + const isLoading = useTypedSelector((state) => state.music.albums.isLoading); + const lastRefreshed = useTypedSelector((state) => state.music.albums.lastRefreshed); + const sections = useTypedSelector(selectArtists); + + // Initialise helpers + const dispatch = useAppDispatch(); + const navigation = useNavigation(); + const getImage = useGetImage(); + const listRef = useRef>(null); + + // Create an array that computes all the height data for the entire list in + // advance. We can then use this pre-computed data to respond to + // `getItemLayout` calls, without having to compute things in place (and + // fail horribly). + // This approach was inspired by https://gist.github.com/RaphBlanchet/472ed013e05398c083caae6216b598b5 + const itemLayouts = useMemo(() => { + // Create an array in which we will store all possible outputs for + // `getItemLayout`. We will loop through each potential album and add + // items that will be in the list + const layouts: Array<{ length: number; offset: number; index: number }> = []; + + // Keep track of both the index of items and the offset (in pixels) from + // the top + let index = 0; + let offset = 0; + + // Loop through each individual section (i.e. alphabet letter) and add + // all items in that particular section. + sections.forEach((section) => { + // Each section starts with a header, so we'll need to add the item, + // as well as the offset. + layouts[index] = ({ length: HeadingHeight, offset, index }); + index++; + offset += HeadingHeight; + + // Then, loop through all the rows and add items for those as well. + section.data.forEach(() => { + offset += ArtistHeight; + layouts[index] = ({ length: ArtistHeight, offset, index }); + index++; + }); + + // The way SectionList works is that you get an item for a + // SectionHeader and a SectionFooter, no matter if you've specified + // whether you want them or not. Thus, we will need to add an empty + // footer as an item, so that we don't mismatch our indexes + layouts[index] = { length: 0, offset, index }; + index++; + }); + + // Then, store and memoize the output + return layouts; + }, [sections]); + + // Set callbacks + const retrieveData = useCallback(() => dispatch(fetchAllAlbums()), [dispatch]); + const selectArtist = useCallback((payload: SectionArtistItem) => ( + navigation.navigate('Artist', payload) + ), [navigation]); + const selectLetter = useCallback((sectionIndex: number) => { + listRef.current?.scrollToLocation({ sectionIndex, itemIndex: 0, animated: false, }); + }, [listRef]); + const generateItem = useCallback(({ item }: { item: SectionArtistItem }) => { + return ( + + + + ); + }, [getImage, selectArtist]); + + // Retrieve data on mount + useEffect(() => { + // GUARD: Only refresh this API call every set amounts of days + if (!lastRefreshed || differenceInDays(lastRefreshed, new Date()) > ALBUM_CACHE_AMOUNT_OF_DAYS) { + retrieveData(); + } + }); + + return ( + <> + + { + if (!(i in itemLayouts)) { + console.log('COuLD NOT FIND LAYOUT ITEM', i, _); + } + return itemLayouts[i] ?? { length: 0, offset: 0, index: i }; + }} + ref={listRef} + keyExtractor={(item) => item.Id} + renderSectionHeader={generateSection} + renderItem={generateItem} + /> + + ); +}; + + +export default Artists; \ No newline at end of file diff --git a/src/screens/Music/stacks/RecentAlbums.tsx b/src/screens/Music/stacks/RecentAlbums.tsx index 3ba1d25..4dfb69b 100644 --- a/src/screens/Music/stacks/RecentAlbums.tsx +++ b/src/screens/Music/stacks/RecentAlbums.tsx @@ -35,10 +35,12 @@ const NavigationHeader: React.FC = () => { const navigation = useNavigation(); const handleAllAlbumsClick = useCallback(() => { navigation.navigate('Albums'); }, [navigation]); const handlePlaylistsClick = useCallback(() => { navigation.navigate('Playlists'); }, [navigation]); + const handleArtistsClick = useCallback(() => { navigation.navigate('Artists'); }, [navigation]); return ( <> {t('all-albums')} + {t('artists')} {t('playlists')} diff --git a/src/screens/types.ts b/src/screens/types.ts index 3991a2b..020dfbb 100644 --- a/src/screens/types.ts +++ b/src/screens/types.ts @@ -1,10 +1,13 @@ import { StackNavigationProp } from '@react-navigation/stack'; +import { SectionArtistItem } from 'store/music/selectors'; import { Album } from 'store/music/types'; export type StackParams = { - [key: string]: Record | undefined; + [key: string]: Record | object | undefined; Albums: undefined; Album: { id: string, album: Album }; + Artists: undefined; + Artist: SectionArtistItem; Playlists: undefined; Playlist: { id: string }; RecentAlbums: undefined; diff --git a/src/store/music/selectors.ts b/src/store/music/selectors.ts index ddb9586..8179a95 100644 --- a/src/store/music/selectors.ts +++ b/src/store/music/selectors.ts @@ -3,6 +3,7 @@ import { parseISO } from 'date-fns'; import { ALPHABET_LETTERS } from 'CONSTANTS'; import { createSelector, EntityId } from '@reduxjs/toolkit'; import { SectionListData } from 'react-native'; +import { ArtistItem } from './types'; /** * Retrieves a list of the n most recent albums @@ -90,3 +91,64 @@ export const selectAlbumsByAlphabet = createSelector( (state: AppState) => state.music.albums, splitAlbumsByAlphabet, ); + +export type SectionArtistItem = ArtistItem & { albumIds: EntityId[] }; + +/** + * Retrieve all artists based on the available albums + */ +export function artistsFromAlbums(state: AppState['music']['albums']) { + // Loop through all albums to retrieve the AlbumArtists + const artists = state.ids.reduce((sum, id) => { + // Retrieve the album from the state + const album = state.entities[id]; + + // Then, loop through all artists + album?.ArtistItems.forEach((artist) => { + // GUARD: Check that an array already exists for this artist + if (!(artist.Name in sum)) { + sum[artist.Name] = { albumIds: [] as EntityId[], ...artist }; + } + + // Add the album id to the artist in the object + sum[artist.Name].albumIds.push(id); + }, []); + + return sum; + }, {} as Record); + + // Now, alphabetically order the object by artist names + const sortedArtists = Object.entries(artists) + .sort(([a], [b]) => a.localeCompare(b)); + + return sortedArtists; +} + +export type SectionedArtist = SectionListData; + +function splitArtistsByAlphabet(state: AppState['music']['albums']) { + const artists = artistsFromAlbums(state); + const sections: SectionedArtist[] = ALPHABET_LETTERS.split('').map((l) => ({ label: l, data: [] })); + + artists.forEach((artist) => { + const letter = artist[0].toUpperCase().charAt(0); + const index = letter ? ALPHABET_LETTERS.indexOf(letter) : 26; + + // Then find the current row in this section (note that albums are + // grouped in pairs so we can render them more easily). + const section = sections[index >= 0 ? index : 26]; + + // Add the album to the row + (section.data as unknown as SectionArtistItem[]).push(artist[1]); + }); + + return sections; +} + +/** + * Wrap splitByAlphabet into a memoized selector + */ +export const selectArtists = createSelector( + (state: AppState) => state.music.albums, + splitArtistsByAlphabet, +); diff --git a/src/utility/JellyfinApi.ts b/src/utility/JellyfinApi.ts index af85d9d..8dac6f1 100644 --- a/src/utility/JellyfinApi.ts +++ b/src/utility/JellyfinApi.ts @@ -4,6 +4,11 @@ import { Album, AlbumTrack, SimilarAlbum } from 'store/music/types'; type Credentials = AppState['settings']['jellyfin']; +/** + * This is a convenience function that converts a set of Jellyfin credentials + * from the Redux store to a HTTP Header that authenticates the user against the + * Jellyfin server. + */ function generateConfig(credentials: Credentials): RequestInit { return { headers: {