mirror of
https://github.com/austinried/subtracks.git
synced 2025-12-27 09:09:29 +01:00
song list for album view
adjusted placeholder to only show when no cover art
This commit is contained in:
parent
f9f016b932
commit
666e1e3e69
BIN
res/more_vertical.png
Normal file
BIN
res/more_vertical.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
BIN
res/record.png
Normal file
BIN
res/record.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
res/star-fill.png
Normal file
BIN
res/star-fill.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
BIN
res/star.png
Normal file
BIN
res/star.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
65
src/components/common/AlbumCover.tsx
Normal file
65
src/components/common/AlbumCover.tsx
Normal 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);
|
||||
@ -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);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user