mirror of
https://github.com/austinried/subtracks.git
synced 2025-12-27 00:59:28 +01:00
artist art pull from last.fm or album covers
This commit is contained in:
parent
379779735e
commit
23e6c0caf0
34
src/components/common/AlbumArt.tsx
Normal file
34
src/components/common/AlbumArt.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import colors from '../../styles/colors';
|
||||
import CoverArt from './CoverArt';
|
||||
|
||||
const AlbumArt: React.FC<{
|
||||
height: number,
|
||||
width: number,
|
||||
coverArtUri?: string
|
||||
}> = ({ height, width, coverArtUri }) => {
|
||||
const Placeholder = () => (
|
||||
<LinearGradient
|
||||
colors={[colors.accent, colors.accentLow]}
|
||||
>
|
||||
<FastImage
|
||||
source={require('../../../res/record.png')}
|
||||
style={{ height, width }}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
</LinearGradient>
|
||||
);
|
||||
|
||||
return (
|
||||
<CoverArt
|
||||
PlaceholderComponent={Placeholder}
|
||||
height={height}
|
||||
width={width}
|
||||
coverArtUri={coverArtUri}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(AlbumArt);
|
||||
@ -2,56 +2,13 @@ import { useNavigation } from '@react-navigation/native';
|
||||
import { useAtomValue } from 'jotai/utils';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { GestureResponderEvent, Image, Pressable, ScrollView, Text, useWindowDimensions, View } from 'react-native';
|
||||
import { TrackPlayerEvents } from 'react-native-track-player';
|
||||
import { useCurrentTrackId, useSetQueue } from '../../hooks/player';
|
||||
import { albumAtomFamily } from '../../state/music';
|
||||
import colors from '../../styles/colors';
|
||||
import text from '../../styles/text';
|
||||
import AlbumCover from './AlbumCover';
|
||||
import GradientBackground from './GradientBackground';
|
||||
|
||||
function secondsToTime(s: number): string {
|
||||
const seconds = s % 60;
|
||||
const minutes = Math.floor(s / 60) % 60;
|
||||
const hours = Math.floor(s / 60 / 60);
|
||||
|
||||
let time = `${minutes.toString().padStart(1, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
if (hours > 0) {
|
||||
time = `${hours}:${time}`;
|
||||
}
|
||||
return time;
|
||||
}
|
||||
|
||||
const Button: React.FC<{
|
||||
title: string;
|
||||
onPress: (event: GestureResponderEvent) => void;
|
||||
}> = ({ title, onPress }) => {
|
||||
const [opacity, setOpacity] = useState(1);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onPressIn={() => setOpacity(0.6)}
|
||||
onPressOut={() => setOpacity(1)}
|
||||
onLongPress={() => setOpacity(1)}
|
||||
style={{
|
||||
backgroundColor: colors.accent,
|
||||
paddingHorizontal: 24,
|
||||
minHeight: 42,
|
||||
justifyContent: 'center',
|
||||
borderRadius: 1000,
|
||||
opacity,
|
||||
}}
|
||||
>
|
||||
<Text style={{ ...text.button }}>{title}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const songEvents = [
|
||||
TrackPlayerEvents.PLAYBACK_STATE,
|
||||
TrackPlayerEvents.PLAYBACK_TRACK_CHANGED,
|
||||
]
|
||||
import AlbumArt from './AlbumArt';
|
||||
import Button from './Button';
|
||||
import GradientScrollView from './GradientScrollView';
|
||||
|
||||
const SongItem: React.FC<{
|
||||
id: string;
|
||||
@ -134,7 +91,7 @@ const AlbumDetails: React.FC<{
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
<GradientScrollView
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
@ -142,10 +99,8 @@ const AlbumDetails: React.FC<{
|
||||
alignItems: 'center',
|
||||
paddingTop: coverSize / 8,
|
||||
}}
|
||||
overScrollMode='never'
|
||||
>
|
||||
<GradientBackground />
|
||||
<AlbumCover
|
||||
<AlbumArt
|
||||
height={coverSize}
|
||||
width={coverSize}
|
||||
coverArtUri={album.coverArtUri}
|
||||
@ -199,11 +154,10 @@ const AlbumDetails: React.FC<{
|
||||
))}
|
||||
</View>
|
||||
|
||||
</ScrollView>
|
||||
</GradientScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const AlbumView: React.FC<{
|
||||
id: string,
|
||||
title: string;
|
||||
|
||||
193
src/components/common/ArtistArt.tsx
Normal file
193
src/components/common/ArtistArt.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import colors from '../../styles/colors';
|
||||
import CoverArt from './CoverArt';
|
||||
|
||||
const PlaceholderContainer: React.FC<{
|
||||
height: number,
|
||||
width: number,
|
||||
}> = ({ height, width, children}) => (
|
||||
<LinearGradient
|
||||
colors={[colors.accent, colors.accentLow]}
|
||||
style={{
|
||||
height, width,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LinearGradient>
|
||||
);
|
||||
|
||||
const FourUp: React.FC<{
|
||||
height: number,
|
||||
width: number,
|
||||
coverArtUris: string[];
|
||||
}> = ({ height, width, coverArtUris }) => {
|
||||
const halfHeight = height / 2;
|
||||
const halfWidth = width / 2;
|
||||
|
||||
return (
|
||||
<PlaceholderContainer height={height} width={width}>
|
||||
<View style={{ width, height: halfHeight, flexDirection: 'row' }}>
|
||||
<FastImage
|
||||
source={{ uri: coverArtUris[0] }}
|
||||
style={{ height: halfHeight, width: halfWidth }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
<FastImage
|
||||
source={{ uri: coverArtUris[1] }}
|
||||
style={{ height: halfHeight, width: halfWidth }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ width, height: halfHeight, flexDirection: 'row' }}>
|
||||
<FastImage
|
||||
source={{ uri: coverArtUris[2] }}
|
||||
style={{ height: halfHeight, width: halfWidth }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
<FastImage
|
||||
source={{ uri: coverArtUris[3] }}
|
||||
style={{ height: halfHeight, width: halfWidth }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
</View>
|
||||
</PlaceholderContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreeUp: React.FC<{
|
||||
height: number,
|
||||
width: number,
|
||||
coverArtUris: string[];
|
||||
}> = ({ height, width, coverArtUris }) => {
|
||||
const halfHeight = height / 2;
|
||||
const halfWidth = width / 2;
|
||||
|
||||
return (
|
||||
<PlaceholderContainer height={height} width={width}>
|
||||
<View style={{ width, height: halfHeight, flexDirection: 'row' }}>
|
||||
<FastImage
|
||||
source={{ uri: coverArtUris[0] }}
|
||||
style={{ height: halfHeight, width }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ width, height: halfHeight, flexDirection: 'row' }}>
|
||||
<FastImage
|
||||
source={{ uri: coverArtUris[1] }}
|
||||
style={{ height: halfHeight, width: halfWidth }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
<FastImage
|
||||
source={{ uri: coverArtUris[2] }}
|
||||
style={{ height: halfHeight, width: halfWidth }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
</View>
|
||||
</PlaceholderContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const TwoUp: React.FC<{
|
||||
height: number,
|
||||
width: number,
|
||||
coverArtUris: string[];
|
||||
}> = ({ height, width, coverArtUris }) => {
|
||||
const halfHeight = height / 2;
|
||||
|
||||
return (
|
||||
<PlaceholderContainer height={height} width={width}>
|
||||
<View style={{ width, height: halfHeight, flexDirection: 'row' }}>
|
||||
<FastImage
|
||||
source={{ uri: coverArtUris[0] }}
|
||||
style={{ height: halfHeight, width }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ width, height: halfHeight, flexDirection: 'row' }}>
|
||||
<FastImage
|
||||
source={{ uri: coverArtUris[1] }}
|
||||
style={{ height: halfHeight, width }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
</View>
|
||||
</PlaceholderContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const OneUp: React.FC<{
|
||||
height: number,
|
||||
width: number,
|
||||
coverArtUris: string[];
|
||||
}> = ({ height, width, coverArtUris }) => {
|
||||
return (
|
||||
<PlaceholderContainer height={height} width={width}>
|
||||
<FastImage
|
||||
source={{ uri: coverArtUris[0] }}
|
||||
style={{ height, width }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
</PlaceholderContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const NoneUp: React.FC<{
|
||||
height: number,
|
||||
width: number,
|
||||
}> = ({ height, width }) => {
|
||||
return (
|
||||
<PlaceholderContainer height={height} width={width}>
|
||||
<FastImage
|
||||
source={require('../../../res/mic_on-fill.png')}
|
||||
style={{
|
||||
height: height - height / 4,
|
||||
width: width - width / 4,
|
||||
}}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
</PlaceholderContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const ArtistArt: React.FC<{
|
||||
height: number,
|
||||
width: number,
|
||||
mediumImageUrl?: string;
|
||||
coverArtUris?: string[]
|
||||
}> = ({ height, width, mediumImageUrl, coverArtUris }) => {
|
||||
const Placeholder = () => {
|
||||
if (coverArtUris && coverArtUris.length >= 4) {
|
||||
return <FourUp height={height} width={width} coverArtUris={coverArtUris} />;
|
||||
}
|
||||
if (coverArtUris && coverArtUris.length === 3) {
|
||||
return <ThreeUp height={height} width={width} coverArtUris={coverArtUris} />;
|
||||
}
|
||||
if (coverArtUris && coverArtUris.length === 2) {
|
||||
return <TwoUp height={height} width={width} coverArtUris={coverArtUris} />;
|
||||
}
|
||||
if (coverArtUris && coverArtUris.length === 1) {
|
||||
return <OneUp height={height} width={width} coverArtUris={coverArtUris} />;
|
||||
}
|
||||
return <NoneUp height={height} width={width} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{
|
||||
borderRadius: height / 2,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<CoverArt
|
||||
PlaceholderComponent={Placeholder}
|
||||
height={height}
|
||||
width={width}
|
||||
coverArtUri={mediumImageUrl}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ArtistArt);
|
||||
55
src/components/common/ArtistView.tsx
Normal file
55
src/components/common/ArtistView.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useAtomValue } from 'jotai/utils';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Text } from 'react-native';
|
||||
import { artistInfoAtomFamily } from '../../state/music';
|
||||
import text from '../../styles/text';
|
||||
import ArtistArt from './ArtistArt';
|
||||
import GradientScrollView from './GradientScrollView';
|
||||
|
||||
const ArtistDetails: React.FC<{ id: string }> = ({ id }) => {
|
||||
const artist = useAtomValue(artistInfoAtomFamily(id));
|
||||
|
||||
if (!artist) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<GradientScrollView
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
contentContainerStyle={{
|
||||
alignItems: 'center',
|
||||
// paddingTop: coverSize / 8,
|
||||
}}
|
||||
>
|
||||
<Text style={text.paragraph}>{artist.name}</Text>
|
||||
<ArtistArt
|
||||
height={200}
|
||||
width={200}
|
||||
mediumImageUrl={artist.mediumImageUrl}
|
||||
coverArtUris={artist.coverArtUris}
|
||||
/>
|
||||
</GradientScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const ArtistView: React.FC<{
|
||||
id: string,
|
||||
title: string;
|
||||
}> = ({ id, title }) => {
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({ title });
|
||||
});
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={<Text>Loading...</Text>}>
|
||||
<ArtistDetails id={id} />
|
||||
</React.Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ArtistView);
|
||||
32
src/components/common/Button.tsx
Normal file
32
src/components/common/Button.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React, { useState } from 'react';
|
||||
import { GestureResponderEvent, Pressable, Text } from 'react-native';
|
||||
import colors from '../../styles/colors';
|
||||
import text from '../../styles/text';
|
||||
|
||||
const Button: React.FC<{
|
||||
title: string;
|
||||
onPress: (event: GestureResponderEvent) => void;
|
||||
}> = ({ title, onPress }) => {
|
||||
const [opacity, setOpacity] = useState(1);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onPressIn={() => setOpacity(0.6)}
|
||||
onPressOut={() => setOpacity(1)}
|
||||
onLongPress={() => setOpacity(1)}
|
||||
style={{
|
||||
backgroundColor: colors.accent,
|
||||
paddingHorizontal: 24,
|
||||
minHeight: 42,
|
||||
justifyContent: 'center',
|
||||
borderRadius: 1000,
|
||||
opacity,
|
||||
}}
|
||||
>
|
||||
<Text style={{ ...text.button }}>{title}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export default Button;
|
||||
@ -1,14 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ActivityIndicator, View } from 'react-native';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import colors from '../../styles/colors';
|
||||
|
||||
const AlbumCover: React.FC<{
|
||||
const CoverArt: React.FC<{
|
||||
PlaceholderComponent: () => JSX.Element,
|
||||
height: number,
|
||||
width: number,
|
||||
coverArtUri?: string
|
||||
}> = ({ height, width, coverArtUri }) => {
|
||||
}> = ({ PlaceholderComponent, height, width, coverArtUri }) => {
|
||||
const [placeholderVisible, setPlaceholderVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@ -16,23 +16,15 @@ const AlbumCover: React.FC<{
|
||||
const halfIndicatorHeight = indicatorSize === 'large' ? 18 : 10;
|
||||
|
||||
const Placeholder: React.FC<{ visible: boolean }> = ({ visible }) => (
|
||||
<LinearGradient
|
||||
colors={[colors.accent, colors.accentLow]}
|
||||
style={{
|
||||
height, width,
|
||||
<View style={{
|
||||
opacity: visible ? 100 : 0,
|
||||
}}
|
||||
>
|
||||
<FastImage
|
||||
source={require('../../../res/record.png')}
|
||||
style={{ height, width }}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
</LinearGradient>
|
||||
}}>
|
||||
<PlaceholderComponent />
|
||||
</View>
|
||||
);
|
||||
|
||||
const CoverArt = () => (
|
||||
<View>
|
||||
<>
|
||||
<Placeholder visible={placeholderVisible} />
|
||||
<ActivityIndicator
|
||||
animating={loading}
|
||||
@ -49,17 +41,20 @@ const AlbumCover: React.FC<{
|
||||
marginTop: -height - halfIndicatorHeight * 2,
|
||||
}}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
onError={() => setPlaceholderVisible(true)}
|
||||
onError={() => {
|
||||
setLoading(false);
|
||||
setPlaceholderVisible(true);
|
||||
}}
|
||||
onLoadEnd={() => setLoading(false)}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ height, width }}>
|
||||
{!coverArtUri ? <Placeholder visible={placeholderVisible} /> : <CoverArt />}
|
||||
{!coverArtUri ? <Placeholder visible={true} /> : <CoverArt />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(AlbumCover);
|
||||
export default React.memo(CoverArt);
|
||||
15
src/components/common/GradientScrollView.tsx
Normal file
15
src/components/common/GradientScrollView.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { ScrollView, ScrollViewProps } from 'react-native';
|
||||
import GradientBackground from './GradientBackground';
|
||||
|
||||
const GradientScrollView: React.FC<ScrollViewProps> = (props) => (
|
||||
<ScrollView
|
||||
overScrollMode='never'
|
||||
{...props}
|
||||
>
|
||||
<GradientBackground />
|
||||
{props.children}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
export default GradientScrollView;
|
||||
@ -5,7 +5,7 @@ import { Pressable, Text, View } from 'react-native';
|
||||
import { Album } from '../../models/music';
|
||||
import { albumsAtom, albumsUpdatingAtom, useUpdateAlbums } from '../../state/music';
|
||||
import textStyles from '../../styles/text';
|
||||
import AlbumCover from '../common/AlbumCover';
|
||||
import AlbumArt from '../common/AlbumArt';
|
||||
import GradientFlatList from '../common/GradientFlatList';
|
||||
|
||||
const AlbumItem: React.FC<{
|
||||
@ -27,7 +27,7 @@ const AlbumItem: React.FC<{
|
||||
}}
|
||||
onPress={() => navigation.navigate('AlbumView', { id, title: name })}
|
||||
>
|
||||
<AlbumCover
|
||||
<AlbumArt
|
||||
width={size}
|
||||
height={size}
|
||||
coverArtUri={coverArtUri}
|
||||
|
||||
@ -1,30 +1,53 @@
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useAtomValue } from 'jotai/utils';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Pressable } from 'react-native';
|
||||
import { Image, Text, View } from 'react-native';
|
||||
import { Artist } from '../../models/music';
|
||||
import { artistsAtom, artistsUpdatingAtom, useUpdateArtists } from '../../state/music';
|
||||
import { artistInfoAtomFamily, artistsAtom, artistsUpdatingAtom, useUpdateArtists } from '../../state/music';
|
||||
import textStyles from '../../styles/text';
|
||||
import ArtistArt from '../common/ArtistArt';
|
||||
import GradientFlatList from '../common/GradientFlatList';
|
||||
|
||||
const ArtistItem: React.FC<{ item: Artist } > = ({ item }) => (
|
||||
<View style={{
|
||||
const ArtistItem: React.FC<{ item: Artist }> = ({ item }) => {
|
||||
const navigation = useNavigation();
|
||||
const artistInfo = useAtomValue(artistInfoAtomFamily(item.id));
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginVertical: 6,
|
||||
marginLeft: 6,
|
||||
}}>
|
||||
<Image
|
||||
source={item.coverArt ? { uri: 'https://reactnative.dev/img/tiny_logo.png' } : require('../../../res/mic_on-fill.png')}
|
||||
}}
|
||||
onPress={() => navigation.navigate('ArtistView', { id: item.id, title: item.name })}
|
||||
>
|
||||
<ArtistArt
|
||||
width={56}
|
||||
height={56}
|
||||
mediumImageUrl={artistInfo?.mediumImageUrl}
|
||||
coverArtUris={artistInfo?.coverArtUris}
|
||||
/>
|
||||
{/* <Image
|
||||
source={{ uri: 'https://reactnative.dev/img/tiny_logo.png' }}
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
}}
|
||||
/>
|
||||
/> */}
|
||||
<Text style={{
|
||||
...textStyles.paragraph,
|
||||
marginLeft: 12,
|
||||
}}>{item.name}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const ArtistItemLoader: React.FC<{ item: Artist }> = (props) => (
|
||||
<React.Suspense fallback={<Text>Loading...</Text>}>
|
||||
<ArtistItem { ...props } />
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
const ArtistsList = () => {
|
||||
@ -39,7 +62,7 @@ const ArtistsList = () => {
|
||||
});
|
||||
|
||||
const renderItem: React.FC<{ item: Artist }> = ({ item }) => (
|
||||
<ArtistItem item={item} />
|
||||
<ArtistItemLoader item={item} />
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -10,16 +10,12 @@ import { RouteProp } from '@react-navigation/native';
|
||||
import text from '../../styles/text';
|
||||
import colors from '../../styles/colors';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
import ArtistView from '../common/ArtistView';
|
||||
|
||||
const Tab = createMaterialTopTabNavigator();
|
||||
|
||||
const LibraryTopTabNavigator = () => (
|
||||
<Tab.Navigator tabBarOptions={{
|
||||
// scrollEnabled: true,
|
||||
tabStyle: {
|
||||
// width: 100,
|
||||
// paddingHorizontal: 0,
|
||||
},
|
||||
style: {
|
||||
height: 48,
|
||||
backgroundColor: colors.gradient.high,
|
||||
@ -53,6 +49,7 @@ const LibraryTopTabNavigator = () => (
|
||||
type LibraryStackParamList = {
|
||||
LibraryTopTabs: undefined,
|
||||
AlbumView: { id: string, title: string };
|
||||
ArtistView: { id: string, title: string };
|
||||
}
|
||||
|
||||
type AlbumScreenNavigationProp = StackNavigationProp<LibraryStackParamList, 'AlbumView'>;
|
||||
@ -66,20 +63,20 @@ const AlbumScreen: React.FC<AlbumScreenProps> = ({ route }) => (
|
||||
<AlbumView id={route.params.id} title={route.params.title} />
|
||||
);
|
||||
|
||||
type ArtistScreenNavigationProp = StackNavigationProp<LibraryStackParamList, 'ArtistView'>;
|
||||
type ArtistScreenRouteProp = RouteProp<LibraryStackParamList, 'ArtistView'>;
|
||||
type ArtistScreenProps = {
|
||||
route: ArtistScreenRouteProp,
|
||||
navigation: ArtistScreenNavigationProp,
|
||||
};
|
||||
|
||||
const ArtistScreen: React.FC<ArtistScreenProps> = ({ route }) => (
|
||||
<ArtistView id={route.params.id} title={route.params.title} />
|
||||
);
|
||||
|
||||
const Stack = createStackNavigator<LibraryStackParamList>();
|
||||
|
||||
const LibraryStackNavigator = () => (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Stack.Navigator>
|
||||
<Stack.Screen
|
||||
name='LibraryTopTabs'
|
||||
component={LibraryTopTabNavigator}
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='AlbumView'
|
||||
component={AlbumScreen}
|
||||
options={{
|
||||
const itemScreenOptions = {
|
||||
title: '',
|
||||
headerStyle: {
|
||||
height: 50,
|
||||
@ -99,7 +96,25 @@ const LibraryStackNavigator = () => (
|
||||
tintColor={colors.text.primary}
|
||||
style={{ height: 22, width: 22 }}
|
||||
/>,
|
||||
}}
|
||||
}
|
||||
|
||||
const LibraryStackNavigator = () => (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Stack.Navigator>
|
||||
<Stack.Screen
|
||||
name='LibraryTopTabs'
|
||||
component={LibraryTopTabNavigator}
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='AlbumView'
|
||||
component={AlbumScreen}
|
||||
options={itemScreenOptions}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='ArtistView'
|
||||
component={ArtistScreen}
|
||||
options={itemScreenOptions}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
</View>
|
||||
|
||||
@ -9,6 +9,7 @@ function mapSongToTrack(song: Song): Track {
|
||||
artist: song.artist || 'Unknown Artist',
|
||||
url: song.streamUri,
|
||||
artwork: song.coverArtUri,
|
||||
duration: song.duration,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,8 +2,14 @@ export interface Artist {
|
||||
id: string;
|
||||
name: string;
|
||||
starred?: Date;
|
||||
coverArt?: string;
|
||||
coverArtUri?: string,
|
||||
}
|
||||
|
||||
export interface ArtistInfo extends Artist {
|
||||
albums: Album[];
|
||||
|
||||
mediumImageUrl?: string;
|
||||
largeImageUrl?: string;
|
||||
coverArtUris: string[];
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { atom, useAtom } from 'jotai';
|
||||
import { atomFamily, useAtomValue, useUpdateAtom } from 'jotai/utils';
|
||||
import { Album as Album, AlbumWithSongs, Artist, Song } from '../models/music';
|
||||
import { Album as Album, AlbumWithSongs, Artist, ArtistInfo, Song } from '../models/music';
|
||||
import { SubsonicApiClient } from '../subsonic/api';
|
||||
import { AlbumID3Element, ChildElement } from '../subsonic/elements';
|
||||
import { AlbumID3Element, ArtistID3Element, ArtistInfo2Element, ChildElement } from '../subsonic/elements';
|
||||
import { GetArtistResponse } from '../subsonic/responses';
|
||||
import { activeServerAtom } from './settings';
|
||||
|
||||
export const artistsAtom = atom<Artist[]>([]);
|
||||
@ -30,8 +31,6 @@ export const useUpdateArtists = () => {
|
||||
id: x.id,
|
||||
name: x.name,
|
||||
starred: x.starred,
|
||||
coverArt: x.coverArt,
|
||||
coverArtUri: x.coverArt ? client.getCoverArtUri({ id: x.coverArt }) : undefined,
|
||||
})));
|
||||
setUpdating(false);
|
||||
}
|
||||
@ -74,6 +73,49 @@ export const albumAtomFamily = atomFamily((id: string) => atom<AlbumWithSongs |
|
||||
return mapAlbumID3WithSongs(response.data.album, response.data.songs, client);
|
||||
}));
|
||||
|
||||
export const artistInfoAtomFamily = atomFamily((id: string) => atom<ArtistInfo | undefined>(async (get) => {
|
||||
const server = get(activeServerAtom);
|
||||
if (!server) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const client = new SubsonicApiClient(server);
|
||||
const [artistResponse, artistInfoResponse] = await Promise.all([
|
||||
client.getArtist({ id }),
|
||||
client.getArtistInfo2({ id }),
|
||||
]);
|
||||
return mapArtistInfo(artistResponse.data, artistInfoResponse.data.artistInfo, client);
|
||||
}));
|
||||
|
||||
function mapArtistInfo(
|
||||
artistResponse: GetArtistResponse,
|
||||
artistInfo: ArtistInfo2Element,
|
||||
client: SubsonicApiClient
|
||||
): ArtistInfo {
|
||||
const info = { ...artistInfo } as any;
|
||||
delete info.similarArtists;
|
||||
|
||||
const { artist, albums } = artistResponse
|
||||
|
||||
const mappedAlbums = albums.map(a => mapAlbumID3(a, client));
|
||||
const coverArtUris = mappedAlbums
|
||||
.sort((a, b) => {
|
||||
if (a.year && b.year) {
|
||||
return a.year - b.year;
|
||||
} else {
|
||||
return a.name.localeCompare(b.name) - 9000;
|
||||
}
|
||||
})
|
||||
.map(a => a.coverArtThumbUri);
|
||||
|
||||
return {
|
||||
...artist,
|
||||
...info,
|
||||
albums: mappedAlbums,
|
||||
coverArtUris,
|
||||
}
|
||||
}
|
||||
|
||||
function mapAlbumID3(album: AlbumID3Element, client: SubsonicApiClient): Album {
|
||||
return {
|
||||
...album,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { DOMParser } from 'xmldom';
|
||||
import RNFS from 'react-native-fs';
|
||||
import { GetAlbumList2Params, GetAlbumListParams, GetAlbumParams, GetArtistInfo2Params, GetArtistInfoParams, GetCoverArtParams, GetIndexesParams, GetMusicDirectoryParams, StreamParams } from './params';
|
||||
import { GetAlbumList2Response, GetAlbumListResponse, GetAlbumResponse, GetArtistInfo2Response, GetArtistInfoResponse, GetArtistsResponse, GetIndexesResponse, GetMusicDirectoryResponse, SubsonicResponse } from './responses';
|
||||
import { GetAlbumList2Params, GetAlbumListParams, GetAlbumParams, GetArtistInfo2Params, GetArtistInfoParams, GetArtistParams, GetCoverArtParams, GetIndexesParams, GetMusicDirectoryParams, StreamParams } from './params';
|
||||
import { GetAlbumList2Response, GetAlbumListResponse, GetAlbumResponse, GetArtistInfo2Response, GetArtistInfoResponse, GetArtistResponse, GetArtistsResponse, GetIndexesResponse, GetMusicDirectoryResponse, SubsonicResponse } from './responses';
|
||||
import { Server } from '../models/settings';
|
||||
import paths from '../paths';
|
||||
|
||||
@ -167,6 +167,11 @@ export class SubsonicApiClient {
|
||||
return new SubsonicResponse<GetArtistInfo2Response>(xml, new GetArtistInfo2Response(xml));
|
||||
}
|
||||
|
||||
async getArtist(params: GetArtistParams): Promise<SubsonicResponse<GetArtistResponse>> {
|
||||
const xml = await this.apiGetXml('getArtist', params);
|
||||
return new SubsonicResponse<GetArtistResponse>(xml, new GetArtistResponse(xml));
|
||||
}
|
||||
|
||||
//
|
||||
// Album/song lists
|
||||
//
|
||||
|
||||
@ -72,6 +72,53 @@ export class ArtistElement extends BaseArtistElement {
|
||||
}
|
||||
}
|
||||
|
||||
export class BaseArtistInfoElement<T> {
|
||||
similarArtists: T[] = [];
|
||||
biography?: string;
|
||||
musicBrainzId?: string;
|
||||
lastFmUrl?: string;
|
||||
smallImageUrl?: string;
|
||||
mediumImageUrl?: string;
|
||||
largeImageUrl?: string;
|
||||
|
||||
constructor(e: Element, artistType: new (e: Element) => T) {
|
||||
if (e.getElementsByTagName('biography').length > 0) {
|
||||
this.biography = e.getElementsByTagName('biography')[0].textContent as string;
|
||||
}
|
||||
if (e.getElementsByTagName('musicBrainzId').length > 0) {
|
||||
this.musicBrainzId = e.getElementsByTagName('musicBrainzId')[0].textContent as string;
|
||||
}
|
||||
if (e.getElementsByTagName('lastFmUrl').length > 0) {
|
||||
this.lastFmUrl = e.getElementsByTagName('lastFmUrl')[0].textContent as string;
|
||||
}
|
||||
if (e.getElementsByTagName('smallImageUrl').length > 0) {
|
||||
this.smallImageUrl = e.getElementsByTagName('smallImageUrl')[0].textContent as string;
|
||||
}
|
||||
if (e.getElementsByTagName('mediumImageUrl').length > 0) {
|
||||
this.mediumImageUrl = e.getElementsByTagName('mediumImageUrl')[0].textContent as string;
|
||||
}
|
||||
if (e.getElementsByTagName('largeImageUrl').length > 0) {
|
||||
this.largeImageUrl = e.getElementsByTagName('largeImageUrl')[0].textContent as string;
|
||||
}
|
||||
|
||||
const similarArtistElements = e.getElementsByTagName('similarArtist');
|
||||
for (let i = 0; i < similarArtistElements.length; i++) {
|
||||
this.similarArtists.push(new artistType(similarArtistElements[i]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ArtistInfoElement extends BaseArtistInfoElement<ArtistElement> {
|
||||
constructor(e: Element) {
|
||||
super(e, ArtistElement);
|
||||
}
|
||||
}
|
||||
export class ArtistInfo2Element extends BaseArtistInfoElement<ArtistID3Element> {
|
||||
constructor(e: Element) {
|
||||
super(e, ArtistID3Element);
|
||||
}
|
||||
}
|
||||
|
||||
export class DirectoryElement {
|
||||
id: string;
|
||||
parent?: string;
|
||||
|
||||
@ -23,6 +23,10 @@ export type GetAlbumParams = {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type GetArtistParams = {
|
||||
id: string;
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Album/song lists
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { AlbumID3Element, ArtistElement, ArtistID3Element, BaseArtistElement, ChildElement, DirectoryElement } from "./elements";
|
||||
import { AlbumID3Element, ArtistElement, ArtistID3Element, ArtistInfo2Element, ArtistInfoElement, BaseArtistElement, BaseArtistInfoElement, ChildElement, DirectoryElement } from "./elements";
|
||||
|
||||
export type ResponseStatus = 'ok' | 'failed';
|
||||
|
||||
@ -32,6 +32,20 @@ export class GetArtistsResponse {
|
||||
}
|
||||
}
|
||||
|
||||
export class GetArtistResponse {
|
||||
artist: ArtistID3Element;
|
||||
albums: AlbumID3Element[] = [];
|
||||
|
||||
constructor(xml: Document) {
|
||||
this.artist = new ArtistID3Element(xml.getElementsByTagName('artist')[0]);
|
||||
|
||||
const albumElements = xml.getElementsByTagName('album');
|
||||
for (let i = 0; i < albumElements.length; i++) {
|
||||
this.albums.push(new AlbumID3Element(albumElements[i]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class GetIndexesResponse {
|
||||
ignoredArticles: string;
|
||||
lastModified: number;
|
||||
@ -50,51 +64,19 @@ export class GetIndexesResponse {
|
||||
}
|
||||
}
|
||||
|
||||
class BaseGetArtistInfoResponse<T extends BaseArtistElement> {
|
||||
similarArtists: T[] = [];
|
||||
biography?: string;
|
||||
musicBrainzId?: string;
|
||||
lastFmUrl?: string;
|
||||
smallImageUrl?: string;
|
||||
mediumImageUrl?: string;
|
||||
largeImageUrl?: string;
|
||||
export class GetArtistInfoResponse {
|
||||
artistInfo: ArtistInfoElement;
|
||||
|
||||
constructor(xml: Document, artistType: new (e: Element) => T) {
|
||||
if (xml.getElementsByTagName('biography').length > 0) {
|
||||
this.biography = xml.getElementsByTagName('biography')[0].textContent as string;
|
||||
}
|
||||
if (xml.getElementsByTagName('musicBrainzId').length > 0) {
|
||||
this.musicBrainzId = xml.getElementsByTagName('musicBrainzId')[0].textContent as string;
|
||||
}
|
||||
if (xml.getElementsByTagName('lastFmUrl').length > 0) {
|
||||
this.lastFmUrl = xml.getElementsByTagName('lastFmUrl')[0].textContent as string;
|
||||
}
|
||||
if (xml.getElementsByTagName('smallImageUrl').length > 0) {
|
||||
this.smallImageUrl = xml.getElementsByTagName('smallImageUrl')[0].textContent as string;
|
||||
}
|
||||
if (xml.getElementsByTagName('mediumImageUrl').length > 0) {
|
||||
this.mediumImageUrl = xml.getElementsByTagName('mediumImageUrl')[0].textContent as string;
|
||||
}
|
||||
if (xml.getElementsByTagName('largeImageUrl').length > 0) {
|
||||
this.largeImageUrl = xml.getElementsByTagName('largeImageUrl')[0].textContent as string;
|
||||
}
|
||||
|
||||
const similarArtistElements = xml.getElementsByTagName('similarArtist');
|
||||
for (let i = 0; i < similarArtistElements.length; i++) {
|
||||
this.similarArtists.push(new artistType(similarArtistElements[i]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class GetArtistInfoResponse extends BaseGetArtistInfoResponse<ArtistElement> {
|
||||
constructor(xml: Document) {
|
||||
super(xml, ArtistElement);
|
||||
this.artistInfo = new ArtistInfoElement(xml.getElementsByTagName('artistInfo')[0]);
|
||||
}
|
||||
}
|
||||
|
||||
export class GetArtistInfo2Response extends BaseGetArtistInfoResponse<ArtistID3Element> {
|
||||
export class GetArtistInfo2Response {
|
||||
artistInfo: ArtistInfo2Element;
|
||||
|
||||
constructor(xml: Document) {
|
||||
super(xml, ArtistID3Element);
|
||||
this.artistInfo = new ArtistInfo2Element(xml.getElementsByTagName('artistInfo2')[0]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
11
src/util.ts
Normal file
11
src/util.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export function formatDuration(seconds: number): string {
|
||||
const s = seconds % 60;
|
||||
const m = Math.floor(seconds / 60) % 60;
|
||||
const h = Math.floor(seconds / 60 / 60);
|
||||
|
||||
let time = `${m.toString().padStart(1, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
if (h > 0) {
|
||||
time = `${h}:${time}`;
|
||||
}
|
||||
return time;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user