feat: add extra metadata to the album view

This commit is contained in:
Lei Nelissen
2023-04-23 01:04:30 +02:00
parent c3c32ae565
commit dba87247d8
15 changed files with 135 additions and 30 deletions

View File

@@ -25,3 +25,9 @@ export const SubHeader = styled(Text)`
font-weight: 400; font-weight: 400;
opacity: 0.5; opacity: 0.5;
`; `;
export const Paragraph = styled(Text)`
opacity: 0.5;
font-size: 12px;
line-height: 20px;
`;

View File

@@ -59,5 +59,6 @@
"you-are-offline-message": "You are currently offline. You can only play previously downloaded music.", "you-are-offline-message": "You are currently offline. You can only play previously downloaded music.",
"playing-on": "Playing on", "playing-on": "Playing on",
"local-playback": "Local playback", "local-playback": "Local playback",
"streaming": "Streaming" "streaming": "Streaming",
"total-duration": "Total duration"
} }

View File

@@ -59,5 +59,6 @@
"you-are-offline-message": "Je bent op dit moment offline. Je kunt alleen eerder gedownloade nummers afspelen.", "you-are-offline-message": "Je bent op dit moment offline. Je kunt alleen eerder gedownloade nummers afspelen.",
"playing-on": "Speelt af op", "playing-on": "Speelt af op",
"local-playback": "Lokaal afspelen", "local-playback": "Lokaal afspelen",
"streaming": "Streamen" "streaming": "Streamen",
"total-duration": "Totale duur"
} }

View File

@@ -58,3 +58,4 @@ export type LocaleKeys = 'play-next'
| 'playing-on' | 'playing-on'
| 'local-playback' | 'local-playback'
| 'streaming' | 'streaming'
| 'total-duration'

View File

@@ -1,15 +1,56 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useRoute, RouteProp } from '@react-navigation/native'; import { useRoute, RouteProp, useNavigation } from '@react-navigation/native';
import { useAppDispatch, useTypedSelector } from 'store'; import { useAppDispatch, useTypedSelector } from 'store';
import TrackListView from './components/TrackListView'; import TrackListView from './components/TrackListView';
import { fetchTracksByAlbum } from 'store/music/actions'; import { fetchAlbum, fetchTracksByAlbum } from 'store/music/actions';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS'; import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
import { t } from '@localisation'; import { t } from '@localisation';
import { StackParams } from 'screens/types'; import { NavigationProp, StackParams } from 'screens/types';
import { SubHeader, Text } from 'components/Typography';
import { ScrollView } from 'react-native-gesture-handler';
import { useGetImage } from 'utility/JellyfinApi';
import styled from 'styled-components';
import FastImage from 'react-native-fast-image';
import { Dimensions, Pressable, useColorScheme } from 'react-native';
import { Container } from '@shopify/react-native-skia/lib/typescript/src/renderer/Container';
import AlbumImage from './components/AlbumImage';
type Route = RouteProp<StackParams, 'Album'>; type Route = RouteProp<StackParams, 'Album'>;
const Screen = Dimensions.get('screen');
const Cover = styled(AlbumImage)`
height: ${Screen.width / 2.8};
width: ${Screen.width / 2.8};
border-radius: 12px;
`;
function SimilarAlbum({ id }: { id: string }) {
const navigation = useNavigation<NavigationProp>();
const getImage = useGetImage();
const album = useTypedSelector((state) => state.music.albums.entities[id]);
const handlePress = useCallback(() => {
album && navigation.push('Album', { id, album });
}, [id, album, navigation]);
return (
<Pressable
style={({ pressed }) => ({
opacity: pressed ? 0.5 : 1.0,
width: Screen.width / 2.8,
marginRight: 12
})}
onPress={handlePress}
>
<Cover key={id} source={{ uri: getImage(id) }} />
<Text numberOfLines={1} style={{ fontSize: 13 }}>{album?.Name}</Text>
<Text numberOfLines={1} style={{ opacity: 0.5, fontSize: 13 }}>{album?.Artists.join(', ')}</Text>
</Pressable>
);
}
const Album: React.FC = () => { const Album: React.FC = () => {
const { params: { id } } = useRoute<Route>(); const { params: { id } } = useRoute<Route>();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -19,7 +60,10 @@ const Album: React.FC = () => {
const albumTracks = useTypedSelector((state) => state.music.tracks.byAlbum[id]); const albumTracks = useTypedSelector((state) => state.music.tracks.byAlbum[id]);
// Define a function for refreshing this entity // Define a function for refreshing this entity
const refresh = useCallback(() => { dispatch(fetchTracksByAlbum(id)); }, [id, dispatch]); const refresh = useCallback(() => {
dispatch(fetchTracksByAlbum(id));
dispatch(fetchAlbum(id));
}, [id, dispatch]);
// Auto-fetch the track data periodically // Auto-fetch the track data periodically
useEffect(() => { useEffect(() => {
@@ -39,7 +83,21 @@ const Album: React.FC = () => {
shuffleButtonText={t('shuffle-album')} shuffleButtonText={t('shuffle-album')}
downloadButtonText={t('download-album')} downloadButtonText={t('download-album')}
deleteButtonText={t('delete-album')} deleteButtonText={t('delete-album')}
/> >
{album?.Overview && (
<Text style={{ opacity: 0.5, lineHeight: 20, fontSize: 12, paddingBottom: 24 }}>{album?.Overview}</Text>
)}
{album?.Similar && (
<>
<SubHeader>Similar albums</SubHeader>
<ScrollView horizontal style={{ marginLeft: -24, marginTop: 8, marginBottom: 36 }} contentContainerStyle={{ paddingLeft: 24 }} showsHorizontalScrollIndicator={false}>
{album.Similar.map((id) => (
<SimilarAlbum id={id} key={id} />
))}
</ScrollView>
</>
)}
</TrackListView>
); );
}; };

View File

@@ -27,7 +27,7 @@ function AlbumImage(props: FastImageProps) {
if (!props.source || hasError) { if (!props.source || hasError) {
return ( return (
<Container source={colorScheme === 'light' ? require('assets/images/empty-album-light.png') : require('assets/images/empty-album-dark.png')} /> <Container {...props} source={colorScheme === 'light' ? require('assets/images/empty-album-light.png') : require('assets/images/empty-album-dark.png')} />
); );
} }

View File

@@ -1,4 +1,4 @@
import React, { useCallback } from 'react'; import React, { PropsWithChildren, useCallback, useMemo } from 'react';
import { ScrollView, RefreshControl, StyleSheet, View } from 'react-native'; import { ScrollView, RefreshControl, StyleSheet, View } from 'react-native';
import { useGetImage } from 'utility/JellyfinApi'; import { useGetImage } from 'utility/JellyfinApi';
import styled, { css } from 'styled-components/native'; import styled, { css } from 'styled-components/native';
@@ -26,6 +26,7 @@ import { Text } from 'components/Typography';
import CoverImage from 'components/CoverImage'; import CoverImage from 'components/CoverImage';
import ticksToDuration from 'utility/ticksToDuration'; import ticksToDuration from 'utility/ticksToDuration';
import { useNavigatorPadding } from 'utility/SafeNavigatorView'; import { useNavigatorPadding } from 'utility/SafeNavigatorView';
import { t } from '@localisation';
const styles = StyleSheet.create({ const styles = StyleSheet.create({
index: { index: {
@@ -55,7 +56,7 @@ const TrackContainer = styled.View<{ isPlaying: boolean }>`
`} `}
`; `;
interface TrackListViewProps { export interface TrackListViewProps extends PropsWithChildren<{}> {
title?: string; title?: string;
artist?: string; artist?: string;
trackIds: EntityId[]; trackIds: EntityId[];
@@ -81,6 +82,7 @@ const TrackListView: React.FC<TrackListViewProps> = ({
deleteButtonText, deleteButtonText,
listNumberingStyle = 'album', listNumberingStyle = 'album',
itemDisplayStyle = 'album', itemDisplayStyle = 'album',
children
}) => { }) => {
const defaultStyles = useDefaultStyles(); const defaultStyles = useDefaultStyles();
const navigatorPadding = useNavigatorPadding(); const navigatorPadding = useNavigatorPadding();
@@ -89,6 +91,11 @@ const TrackListView: React.FC<TrackListViewProps> = ({
const tracks = useTypedSelector((state) => state.music.tracks.entities); const tracks = useTypedSelector((state) => state.music.tracks.entities);
const isLoading = useTypedSelector((state) => state.music.tracks.isLoading); const isLoading = useTypedSelector((state) => state.music.tracks.isLoading);
const downloadedTracks = useTypedSelector(selectDownloadedTracks(trackIds)); const downloadedTracks = useTypedSelector(selectDownloadedTracks(trackIds));
const totalDuration = useMemo(() => (
trackIds.reduce<number>((sum, trackId) => (
sum + (tracks[trackId]?.RunTimeTicks || 0)
), 0)
), [trackIds, tracks]);
// Retrieve helpers // Retrieve helpers
const getImage = useGetImage(); const getImage = useGetImage();
@@ -157,7 +164,7 @@ const TrackListView: React.FC<TrackListViewProps> = ({
? i + 1 ? i + 1
: tracks[trackId]?.IndexNumber} : tracks[trackId]?.IndexNumber}
</Text> </Text>
<View> <View style={{ flexShrink: 1 }}>
<Text <Text
style={{ style={{
...currentTrack?.backendId === trackId && styles.activeText, ...currentTrack?.backendId === trackId && styles.activeText,
@@ -197,6 +204,7 @@ const TrackListView: React.FC<TrackListViewProps> = ({
</TrackContainer> </TrackContainer>
</TouchableHandler> </TouchableHandler>
)} )}
<Text style={{ paddingTop: 24, paddingBottom: 12, textAlign: 'center', opacity: 0.5 }}>{t('total-duration')}: {ticksToDuration(totalDuration)}</Text>
<WrappableButtonRow style={{ marginTop: 24 }}> <WrappableButtonRow style={{ marginTop: 24 }}>
<WrappableButton <WrappableButton
icon={CloudDownArrow} icon={CloudDownArrow}
@@ -214,6 +222,7 @@ const TrackListView: React.FC<TrackListViewProps> = ({
/> />
</WrappableButtonRow> </WrappableButtonRow>
</View> </View>
{children}
</ScrollView> </ScrollView>
); );
}; };

View File

@@ -4,7 +4,7 @@ import music from 'store/music';
import { t } from '@localisation'; import { t } from '@localisation';
import Button from 'components/Button'; import Button from 'components/Button';
import styled from 'styled-components/native'; import styled from 'styled-components/native';
import { Text } from 'components/Typography'; import { Paragraph } from 'components/Typography';
import { useAppDispatch } from 'store'; import { useAppDispatch } from 'store';
import { useHeaderHeight } from '@react-navigation/elements'; import { useHeaderHeight } from '@react-navigation/elements';
@@ -30,7 +30,7 @@ export default function CacheSettings() {
return ( return (
<Container contentInset={{ top: headerHeight }}> <Container contentInset={{ top: headerHeight }}>
<Text>{t('setting-cache-description')}</Text> <Paragraph>{t('setting-cache-description')}</Paragraph>
<ClearCache title={t('reset-cache')} onPress={handleClearCache} /> <ClearCache title={t('reset-cache')} onPress={handleClearCache} />
</Container> </Container>
); );

View File

@@ -6,7 +6,7 @@ import { NavigationProp } from '../..';
import { useTypedSelector } from 'store'; import { useTypedSelector } from 'store';
import { t } from '@localisation'; import { t } from '@localisation';
import Button from 'components/Button'; import Button from 'components/Button';
import { Text } from 'components/Typography'; import { Paragraph } from 'components/Typography';
import { useHeaderHeight } from '@react-navigation/elements'; import { useHeaderHeight } from '@react-navigation/elements';
@@ -34,15 +34,15 @@ export default function LibrarySettings() {
return ( return (
<Container contentInset={{ top: headerHeight }}> <Container contentInset={{ top: headerHeight }}>
<InputContainer> <InputContainer>
<Text style={defaultStyles.text}>{t('jellyfin-server-url')}</Text> <Paragraph style={defaultStyles.text}>{t('jellyfin-server-url')}</Paragraph>
<Input placeholder="https://jellyfin.yourserver.com/" value={jellyfin?.uri} editable={false} style={defaultStyles.input} /> <Input placeholder="https://jellyfin.yourserver.com/" value={jellyfin?.uri} editable={false} style={defaultStyles.input} />
</InputContainer> </InputContainer>
<InputContainer> <InputContainer>
<Text style={defaultStyles.text}>{t('jellyfin-access-token')}</Text> <Paragraph style={defaultStyles.text}>{t('jellyfin-access-token')}</Paragraph>
<Input placeholder="deadbeefdeadbeefdeadbeef" value={jellyfin?.access_token} editable={false} style={defaultStyles.input} /> <Input placeholder="deadbeefdeadbeefdeadbeef" value={jellyfin?.access_token} editable={false} style={defaultStyles.input} />
</InputContainer> </InputContainer>
<InputContainer> <InputContainer>
<Text style={defaultStyles.text}>{t('jellyfin-user-id')}</Text> <Paragraph style={defaultStyles.text}>{t('jellyfin-user-id')}</Paragraph>
<Input placeholder="deadbeefdeadbeefdeadbeef" value={jellyfin?.user_id} editable={false} style={defaultStyles.input} /> <Input placeholder="deadbeefdeadbeefdeadbeef" value={jellyfin?.user_id} editable={false} style={defaultStyles.input} />
</InputContainer> </InputContainer>
<Button title={t('set-jellyfin-server')} onPress={handleSetLibrary} /> <Button title={t('set-jellyfin-server')} onPress={handleSetLibrary} />

View File

@@ -1,4 +1,4 @@
import { Text } from 'components/Typography'; import { Paragraph, Text } from 'components/Typography';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Switch } from 'react-native-gesture-handler'; import { Switch } from 'react-native-gesture-handler';
@@ -92,7 +92,7 @@ function renderHeader(question: Question, index: number, isActive: boolean) {
function renderContent(question: Question) { function renderContent(question: Question) {
return ( return (
<ContentContainer> <ContentContainer>
<Text>{question.content}</Text> <Paragraph>{question.content}</Paragraph>
</ContentContainer> </ContentContainer>
); );
} }
@@ -112,9 +112,9 @@ export default function Sentry() {
return ( return (
<ScrollView contentInset={{ top: headerHeight }}> <ScrollView contentInset={{ top: headerHeight }}>
<Container> <Container>
<Text>{t('error-reporting-description')}</Text> <Paragraph>{t('error-reporting-description')}</Paragraph>
<Text /> <Paragraph />
<Text>{t('error-reporting-rationale')}</Text> <Paragraph>{t('error-reporting-rationale')}</Paragraph>
<SwitchContainer> <SwitchContainer>
<Label>{t('error-reporting')}</Label> <Label>{t('error-reporting')}</Label>

View File

@@ -46,6 +46,14 @@ export const fetchTracksByAlbum = createAsyncThunk<AlbumTrack[], string, AsyncTh
} }
); );
export const fetchAlbum = createAsyncThunk<Album, string, AsyncThunkAPI>(
'/albums/single',
async (ItemId, thunkAPI) => {
const credentials = thunkAPI.getState().settings.jellyfin;
return retrieveAlbum(credentials, ItemId) as Promise<Album>;
}
);
type SearchAndFetchResults = { type SearchAndFetchResults = {
albums: Album[]; albums: Album[];
results: (Album | AlbumTrack)[]; results: (Album | AlbumTrack)[];

View File

@@ -7,7 +7,8 @@ import {
searchAndFetchAlbums, searchAndFetchAlbums,
playlistAdapter, playlistAdapter,
fetchAllPlaylists, fetchAllPlaylists,
fetchTracksByPlaylist fetchTracksByPlaylist,
fetchAlbum
} from './actions'; } from './actions';
import { createSlice, Dictionary, EntityId } from '@reduxjs/toolkit'; import { createSlice, Dictionary, EntityId } from '@reduxjs/toolkit';
import { Album, AlbumTrack, Playlist } from './types'; import { Album, AlbumTrack, Playlist } from './types';
@@ -70,6 +71,15 @@ const music = createSlice({
builder.addCase(fetchAllAlbums.pending, (state) => { state.albums.isLoading = true; }); builder.addCase(fetchAllAlbums.pending, (state) => { state.albums.isLoading = true; });
builder.addCase(fetchAllAlbums.rejected, (state) => { state.albums.isLoading = false; }); builder.addCase(fetchAllAlbums.rejected, (state) => { state.albums.isLoading = false; });
/**
* Fetch single album
*/
builder.addCase(fetchAlbum.fulfilled, (state, { payload }) => {
albumAdapter.upsertOne(state.albums, payload);
});
builder.addCase(fetchAlbum.pending, (state) => { state.albums.isLoading = true; });
builder.addCase(fetchAlbum.rejected, (state) => { state.albums.isLoading = false; });
/** /**
* Fetch most recent albums * Fetch most recent albums
*/ */

View File

@@ -43,6 +43,8 @@ export interface Album {
Tracks?: string[]; Tracks?: string[];
lastRefreshed?: number; lastRefreshed?: number;
DateCreated: string; DateCreated: string;
Overview?: string;
Similar?: string[];
} }
export interface AlbumTrack { export interface AlbumTrack {
@@ -95,3 +97,7 @@ export interface Playlist {
Tracks?: string[]; Tracks?: string[];
lastRefreshed?: number; lastRefreshed?: number;
} }
export interface SimilarAlbum {
Id: string;
}

View File

@@ -1,6 +1,6 @@
import { Track } from 'react-native-track-player'; import { Track } from 'react-native-track-player';
import { AppState, useTypedSelector } from 'store'; import { AppState, useTypedSelector } from 'store';
import { Album, AlbumTrack } from 'store/music/types'; import { Album, AlbumTrack, SimilarAlbum } from 'store/music/types';
type Credentials = AppState['settings']['jellyfin']; type Credentials = AppState['settings']['jellyfin'];
@@ -86,8 +86,14 @@ export async function retrieveAllAlbums(credentials: Credentials) {
*/ */
export async function retrieveAlbum(credentials: Credentials, id: string): Promise<Album> { export async function retrieveAlbum(credentials: Credentials, id: string): Promise<Album> {
const config = generateConfig(credentials); const config = generateConfig(credentials);
const Similar = await fetch(`${credentials?.uri}/Items/${id}/Similar?userId=${credentials?.user_id}&limit=12`, config)
.then(response => response.json() as Promise<{ Items: SimilarAlbum[] }>)
.then((albums) => albums.Items.map((a) => a.Id));
return fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items/${id}`, config) return fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items/${id}`, config)
.then(response => response.json()); .then(response => response.json() as Promise<Album>)
.then(album => ({ ...album, Similar }));
} }
const latestAlbumsOptions = { const latestAlbumsOptions = {
@@ -96,7 +102,6 @@ const latestAlbumsOptions = {
SortOrder: 'Ascending', SortOrder: 'Ascending',
}; };
/** /**
* Retrieve the most recently added albums on the Jellyfin server * Retrieve the most recently added albums on the Jellyfin server
*/ */

View File

@@ -1,7 +1,7 @@
function ticksToDuration(ticks: number) { function ticksToDuration(ticks: number) {
const seconds = Math.round(ticks / 10000000); const seconds = Math.round(ticks / 10000000);
const minutes = Math.round(seconds / 60); const minutes = Math.floor(seconds / 60);
const hours = Math.round(minutes / 60); const hours = Math.floor(minutes / 60);
return `${hours > 0 ? hours + ':' : ''}${minutes}:${(seconds % 60).toString().padStart(2, '0')}`; return `${hours > 0 ? hours + ':' : ''}${minutes}:${(seconds % 60).toString().padStart(2, '0')}`;
} }