song list for album view

adjusted placeholder to only show when no cover art
This commit is contained in:
austinried 2021-06-29 12:04:32 +09:00
parent f9f016b932
commit 666e1e3e69
11 changed files with 305 additions and 81 deletions

BIN
res/more_vertical.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
res/record.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
res/star-fill.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

BIN
res/star.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@ -0,0 +1,65 @@
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<{
height: number,
width: number,
coverArtUri?: string
}> = ({ height, width, coverArtUri }) => {
const [placeholderVisible, setPlaceholderVisible] = useState(false);
const [loading, setLoading] = useState(true);
const indicatorSize = height > 130 ? 'large' : 'small';
const halfIndicatorHeight = indicatorSize === 'large' ? 18 : 10;
const Placeholder: React.FC<{ visible: boolean }> = ({ visible }) => (
<LinearGradient
colors={[colors.accent, colors.accentLow]}
style={{
height, width,
opacity: visible ? 100 : 0,
}}
>
<FastImage
source={require('../../../res/record.png')}
style={{ height, width }}
resizeMode={FastImage.resizeMode.contain}
/>
</LinearGradient>
);
const CoverArt = () => (
<View>
<Placeholder visible={placeholderVisible} />
<ActivityIndicator
animating={loading}
size={indicatorSize}
color={colors.accent}
style={{
top: -height / 2 - halfIndicatorHeight,
}}
/>
<FastImage
source={{ uri: coverArtUri, priority: 'high' }}
style={{
height, width,
marginTop: -height - halfIndicatorHeight * 2,
}}
resizeMode={FastImage.resizeMode.contain}
onError={() => setPlaceholderVisible(true)}
onLoadEnd={() => setLoading(false)}
/>
</View>
);
return (
<View style={{ height, width }}>
{!coverArtUri ? <Placeholder visible={placeholderVisible} /> : <CoverArt />}
</View>
);
}
export default React.memo(AlbumCover);

View File

@ -1,40 +1,172 @@
import { useNavigation } from '@react-navigation/native';
import { useAtomValue } from 'jotai/utils';
import React, { useEffect } from 'react';
import { View, Text } from 'react-native';
import { ScrollView, Text, useWindowDimensions, View, Image, Pressable, GestureResponderEvent } from 'react-native';
import { albumAtomFamily } from '../../state/music';
import colors from '../../styles/colors';
import text from '../../styles/text';
import AlbumCover from './AlbumCover';
import TopTabContainer from './TopTabContainer';
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 }) => {
return (
<Pressable
onPress={onPress}
style={{
backgroundColor: colors.accent,
paddingHorizontal: 24,
minHeight: 42,
justifyContent: 'center',
borderRadius: 1000,
}}
>
<Text style={{ ...text.button }}>{title}</Text>
</Pressable>
);
}
const AlbumDetails: React.FC<{
id: string,
}> = ({ id }) => {
const navigation = useNavigation();
const album = useAtomValue(albumAtomFamily(id));
const layout = useWindowDimensions();
useEffect(() => {
if (!album) {
return;
}
navigation.setOptions({ title: album.name });
});
const coverSize = layout.width - layout.width / 2;
return (
<>
<Text>Name: {album?.name}</Text>
<Text>Artist: {album?.artist}</Text>
</>
<ScrollView
style={{
flex: 1,
}}
contentContainerStyle={{
alignItems: 'center',
paddingTop: coverSize / 8,
}}
>
<AlbumCover
height={coverSize}
width={coverSize}
coverArtUri={album?.coverArtUri}
/>
<Text style={{
...text.title,
marginTop: 12,
width: layout.width - layout.width / 8,
textAlign: 'center',
}}>{album?.name}</Text>
<Text style={{
...text.itemSubtitle,
fontSize: 14,
marginTop: 4,
marginBottom: 20,
width: layout.width - layout.width / 8,
textAlign: 'center',
}}>{album?.artist}{album?.year ? `${album.year}` : ''}</Text>
<View style={{
flexDirection: 'row'
}}>
<Button
title='Play Album'
onPress={() => null}
/>
{/* <View style={{ width: 6, }}></View>
<Button
title='S'
onPress={() => null}
/> */}
</View>
<View style={{
width: layout.width - (layout.width / 20),
marginTop: 20,
marginBottom: 30,
}}>
{album?.songs
.sort((a, b) => (a.track as number) - (b.track as number))
.map(s => (
<View key={s.id} style={{
marginTop: 20,
marginLeft: 4,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<View style={{
flexShrink: 1,
}}>
<Text style={text.songListTitle}>{s.title}</Text>
<Text style={text.songListSubtitle}>{s.artist}</Text>
</View>
<View style={{
flexDirection: 'row',
alignItems: 'center',
marginLeft: 10,
}}>
{/* <Text style={text.songListSubtitle}>{secondsToTime(s.duration || 0)}</Text> */}
<Image
source={require('../../../res/star.png')}
style={{
height: 28,
width: 28,
tintColor: colors.text.secondary,
marginLeft: 10,
}}
/>
<Image
source={require('../../../res/more_vertical.png')}
style={{
height: 28,
width: 28,
tintColor: colors.text.secondary,
marginLeft: 12,
marginRight: 2,
}}
/>
</View>
</View>
))}
</View>
</ScrollView>
);
}
const AlbumView: React.FC<{
id: string,
}> = ({ id }) => (
<TopTabContainer>
<Text>{id}</Text>
<React.Suspense fallback={<Text>Loading...</Text>}>
<AlbumDetails id={id} />
</React.Suspense>
</TopTabContainer>
);
title: string;
}> = ({ id, title }) => {
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({ title });
});
return (
<TopTabContainer>
<React.Suspense fallback={<Text>Loading...</Text>}>
<AlbumDetails id={id} />
</React.Suspense>
</TopTabContainer>
);
};
export default React.memo(AlbumView);

View File

@ -6,42 +6,10 @@ import FastImage from 'react-native-fast-image';
import LinearGradient from 'react-native-linear-gradient';
import { Album } from '../../models/music';
import { albumsAtom, albumsUpdatingAtom, useUpdateAlbums } from '../../state/music';
import colors from '../../styles/colors';
import textStyles from '../../styles/text';
import AlbumCover from '../common/AlbumCover';
import TopTabContainer from '../common/TopTabContainer';
const AlbumArt: React.FC<{
height: number,
width: number,
coverArtUri?: string
}> = ({ height, width, coverArtUri }) => {
const Placeholder = (
<LinearGradient
colors={[colors.accent, colors.accentLow]}
style={{ height, width }}
>
<FastImage
source={require('../../../res/record-m.png')}
style={{ height, width }}
resizeMode={FastImage.resizeMode.contain}
/>
</LinearGradient>
);
const CoverArt = (
<View style={{ height, width }}>
<FastImage
source={{ uri: coverArtUri }}
style={{ height, width }}
resizeMode={FastImage.resizeMode.contain}
/>
</View>
);
return coverArtUri ? CoverArt : Placeholder;
}
const MemoAlbumArt = React.memo(AlbumArt);
const AlbumItem: React.FC<{
id: string;
name: string,
@ -59,9 +27,9 @@ const AlbumItem: React.FC<{
marginVertical: 8,
flex: 1/3,
}}
onPress={() => navigation.navigate('AlbumView', { id })}
onPress={() => navigation.navigate('AlbumView', { id, title: name })}
>
<MemoAlbumArt
<AlbumCover
width={size}
height={size}
coverArtUri={coverArtUri}

View File

@ -52,7 +52,7 @@ const LibraryTopTabNavigator = () => (
type LibraryStackParamList = {
LibraryTopTabs: undefined,
AlbumView: { id: string };
AlbumView: { id: string, title: string };
}
type AlbumScreenNavigationProp = StackNavigationProp<LibraryStackParamList, 'AlbumView'>;
@ -63,7 +63,7 @@ type AlbumScreenProps = {
};
const AlbumScreen: React.FC<AlbumScreenProps> = ({ route }) => (
<AlbumView id={route.params.id} />
<AlbumView id={route.params.id} title={route.params.title} />
);
const Stack = createStackNavigator<LibraryStackParamList>();
@ -79,7 +79,8 @@ const LibraryStackNavigator = () => (
<Stack.Screen
name='AlbumView'
component={AlbumScreen}
options={{ title: 'Album',
options={{
title: '',
headerStyle: {
height: 50,
backgroundColor: colors.gradient.high,

View File

@ -15,13 +15,32 @@ export interface Album {
coverArt?: string;
coverArtUri?: string,
coverArtThumbUri?: string,
year?: number;
}
export interface AlbumWithSongs extends Album {
songs: Song[];
}
export interface Song {
id: string;
albumId: string;
artistId: string;
name: string;
album?: string;
artist?: string;
title: string;
track?: number;
year?: number;
genre?: string;
coverArt?: string;
size?: number;
contentType?: string;
suffix?: string;
duration?: number;
bitRate?: number;
userRating?: number;
averageRating?: number;
playCount?: number;
discNumber?: number;
created?: Date;
starred?: Date;
}

View File

@ -1,7 +1,8 @@
import { atom, useAtom } from 'jotai';
import { atomFamily, useAtomValue, useUpdateAtom } from 'jotai/utils';
import { Album, Artist } from '../models/music';
import { Album as Album, AlbumWithSongs, Artist, Song } from '../models/music';
import { SubsonicApiClient } from '../subsonic/api';
import { AlbumID3Element, ChildElement } from '../subsonic/elements';
import { activeServerAtom } from './settings';
export const artistsAtom = atom<Artist[]>([]);
@ -57,21 +58,12 @@ export const useUpdateAlbums = () => {
const client = new SubsonicApiClient(server);
const response = await client.getAlbumList2({ type: 'alphabeticalByArtist', size: 500 });
setAlbums(response.data.albums.map(x => ({
id: x.id,
artistId: x.artistId,
artist: x.artist,
name: x.name,
starred: x.starred,
coverArt: x.coverArt,
coverArtUri: x.coverArt ? client.getCoverArtUri({ id: x.coverArt }) : undefined,
coverArtThumbUri: x.coverArt ? client.getCoverArtUri({ id: x.coverArt, size: '128' }) : undefined,
})));
setAlbums(response.data.albums.map(a => mapAlbumID3(a, client)));
setUpdating(false);
}
}
export const albumAtomFamily = atomFamily((id: string) => atom<Album | undefined>(async (get) => {
export const albumAtomFamily = atomFamily((id: string) => atom<AlbumWithSongs | undefined>(async (get) => {
const server = get(activeServerAtom);
if (!server) {
return undefined;
@ -79,10 +71,28 @@ export const albumAtomFamily = atomFamily((id: string) => atom<Album | undefined
const client = new SubsonicApiClient(server);
const response = await client.getAlbum({ id });
return {
id,
name: response.data.album.name,
artist: response.data.album.artist,
};
return mapAlbumID3WithSongs(response.data.album, response.data.songs, client);
}));
function mapAlbumID3(album: AlbumID3Element, client: SubsonicApiClient): Album {
return {
...album,
coverArtUri: album.coverArt ? client.getCoverArtUri({ id: album.coverArt }) : undefined,
coverArtThumbUri: album.coverArt ? client.getCoverArtUri({ id: album.coverArt, size: '256' }) : undefined,
}
}
function mapChild(child: ChildElement): Song {
return { ...child }
}
function mapAlbumID3WithSongs(
album: AlbumID3Element,
songs: ChildElement[],
client: SubsonicApiClient
): AlbumWithSongs {
return {
...mapAlbumID3(album, client),
songs: songs.map(s => mapChild(s)),
}
}

View File

@ -1,8 +1,9 @@
import { TextStyle } from "react-native";
import { TextStyle } from 'react-native';
import colors from './colors';
const fontRegular = 'Metropolis-Regular';
const fontSemiBold = 'Metropolis-SemiBold';
const fontBold = 'Metropolis-Bold';
const paragraph: TextStyle = {
fontFamily: fontRegular,
@ -16,6 +17,12 @@ const header: TextStyle = {
fontFamily: fontSemiBold,
};
const title: TextStyle = {
...paragraph,
fontSize: 24,
fontFamily: fontBold,
};
const itemTitle: TextStyle = {
...paragraph,
fontSize: 13,
@ -28,15 +35,37 @@ const itemSubtitle: TextStyle = {
color: colors.text.secondary,
};
const songListTitle: TextStyle = {
...paragraph,
fontSize: 16,
fontFamily: fontSemiBold,
};
const songListSubtitle: TextStyle = {
...paragraph,
fontSize: 14,
color: colors.text.secondary,
};
const xsmall: TextStyle = {
...paragraph,
fontSize: 10,
};
const button: TextStyle = {
...paragraph,
fontSize: 15,
fontFamily: fontBold,
};
export default {
paragraph,
header,
title,
itemTitle,
itemSubtitle,
xsmall
songListTitle,
songListSubtitle,
xsmall,
button,
};