Restyle the downloads screen
This commit is contained in:
@@ -7,13 +7,16 @@ import { THEME_COLOR } from 'CONSTANTS';
|
|||||||
import styled, { css } from 'styled-components/native';
|
import styled, { css } from 'styled-components/native';
|
||||||
import useDefaultStyles from './Colors';
|
import useDefaultStyles from './Colors';
|
||||||
|
|
||||||
|
type ButtonSize = 'default' | 'small';
|
||||||
|
|
||||||
interface ButtonProps extends PressableProps {
|
interface ButtonProps extends PressableProps {
|
||||||
icon?: React.FC<SvgProps>;
|
icon?: React.FC<SvgProps>;
|
||||||
title: string;
|
title?: string;
|
||||||
style?: ViewProps['style'];
|
style?: ViewProps['style'];
|
||||||
|
size?: ButtonSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BaseButton = styled.Pressable`
|
const BaseButton = styled.Pressable<{ size: ButtonSize }>`
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -24,16 +27,25 @@ const BaseButton = styled.Pressable`
|
|||||||
${(props) => props.disabled && css`
|
${(props) => props.disabled && css`
|
||||||
opacity: 0.25;
|
opacity: 0.25;
|
||||||
`}
|
`}
|
||||||
|
|
||||||
|
${(props) => props.size === 'small' && css`
|
||||||
|
flex-grow: 0;
|
||||||
|
padding: 10px;
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ButtonText = styled.Text<{ active?: boolean }>`
|
const ButtonText = styled.Text<{ active?: boolean, size: ButtonSize }>`
|
||||||
color: ${THEME_COLOR};
|
color: ${THEME_COLOR};
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
||||||
|
${(props) => props.size === 'small' && css`
|
||||||
|
font-size: 12px;
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Button = React.forwardRef<View, ButtonProps>(function Button(props, ref) {
|
const Button = React.forwardRef<View, ButtonProps>(function Button(props, ref) {
|
||||||
const { icon: Icon, title, disabled, ...rest } = props;
|
const { icon: Icon, title, disabled, size = 'default', ...rest } = props;
|
||||||
const defaultStyles = useDefaultStyles();
|
const defaultStyles = useDefaultStyles();
|
||||||
const [isPressed, setPressed] = useState(false);
|
const [isPressed, setPressed] = useState(false);
|
||||||
const handlePressIn = useCallback(() => setPressed(true), []);
|
const handlePressIn = useCallback(() => setPressed(true), []);
|
||||||
@@ -48,8 +60,12 @@ const Button = React.forwardRef<View, ButtonProps>(function Button(props, ref) {
|
|||||||
onPressOut={handlePressOut}
|
onPressOut={handlePressOut}
|
||||||
style={[
|
style={[
|
||||||
props.style,
|
props.style,
|
||||||
{ backgroundColor: isPressed ? defaultStyles.activeBackground.backgroundColor : defaultStyles.button.backgroundColor }
|
{ backgroundColor: isPressed
|
||||||
|
? defaultStyles.activeBackground.backgroundColor
|
||||||
|
: defaultStyles.button.backgroundColor
|
||||||
|
}
|
||||||
]}
|
]}
|
||||||
|
size={size}
|
||||||
>
|
>
|
||||||
{Icon &&
|
{Icon &&
|
||||||
<Icon
|
<Icon
|
||||||
@@ -57,11 +73,13 @@ const Button = React.forwardRef<View, ButtonProps>(function Button(props, ref) {
|
|||||||
height={14}
|
height={14}
|
||||||
fill={THEME_COLOR}
|
fill={THEME_COLOR}
|
||||||
style={{
|
style={{
|
||||||
marginRight: 8,
|
marginRight: title ? 8 : 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
<ButtonText active={isPressed}>{title}</ButtonText>
|
{title ? (
|
||||||
|
<ButtonText active={isPressed} size={size}>{title}</ButtonText>
|
||||||
|
) : undefined}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import useDefaultStyles from 'components/Colors';
|
import useDefaultStyles from 'components/Colors';
|
||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import { FlatListProps, Text, TouchableOpacity, View } from 'react-native';
|
import { FlatListProps, View } from 'react-native';
|
||||||
import { FlatList } from 'react-native-gesture-handler';
|
import { FlatList } from 'react-native-gesture-handler';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useTypedSelector } from 'store';
|
import { useTypedSelector } from 'store';
|
||||||
import formatBytes from 'utility/formatBytes';
|
import formatBytes from 'utility/formatBytes';
|
||||||
import TrashIcon from 'assets/icons/trash.svg';
|
import TrashIcon from 'assets/icons/trash.svg';
|
||||||
import ArrowClockwise from 'assets/icons/arrow-clockwise.svg';
|
import ArrowClockwise from 'assets/icons/arrow-clockwise.svg';
|
||||||
import { THEME_COLOR } from 'CONSTANTS';
|
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { EntityId } from '@reduxjs/toolkit';
|
import { EntityId } from '@reduxjs/toolkit';
|
||||||
import { queueTrackForDownload, removeDownloadedTrack } from 'store/downloads/actions';
|
import { queueTrackForDownload, removeDownloadedTrack } from 'store/downloads/actions';
|
||||||
@@ -15,6 +14,10 @@ import Button from 'components/Button';
|
|||||||
import { t } from 'i18n-js';
|
import { t } from 'i18n-js';
|
||||||
import DownloadIcon from 'components/DownloadIcon';
|
import DownloadIcon from 'components/DownloadIcon';
|
||||||
import styled from 'styled-components/native';
|
import styled from 'styled-components/native';
|
||||||
|
import { Text } from 'components/Typography';
|
||||||
|
import FastImage from 'react-native-fast-image';
|
||||||
|
import { useGetImage } from 'utility/JellyfinApi';
|
||||||
|
import { ShadowWrapper } from 'components/Shadow';
|
||||||
|
|
||||||
const DownloadedTrack = styled.View`
|
const DownloadedTrack = styled.View`
|
||||||
flex: 1 0 auto;
|
flex: 1 0 auto;
|
||||||
@@ -24,9 +27,16 @@ const DownloadedTrack = styled.View`
|
|||||||
margin: 0 20px;
|
margin: 0 20px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const AlbumImage = styled(FastImage)`
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
`;
|
||||||
|
|
||||||
function Downloads() {
|
function Downloads() {
|
||||||
const defaultStyles = useDefaultStyles();
|
const defaultStyles = useDefaultStyles();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const getImage = useGetImage();
|
||||||
|
|
||||||
const { entities, ids } = useTypedSelector((state) => state.downloads);
|
const { entities, ids } = useTypedSelector((state) => state.downloads);
|
||||||
const tracks = useTypedSelector((state) => state.music.tracks.entities);
|
const tracks = useTypedSelector((state) => state.music.tracks.entities);
|
||||||
@@ -64,57 +74,68 @@ function Downloads() {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const ListHeaderComponent = useMemo(() => (
|
const ListHeaderComponent = useMemo(() => (
|
||||||
<View style={{ marginHorizontal: 20, marginBottom: 12 }}>
|
<View style={[{ paddingHorizontal: 20, paddingBottom: 12, borderBottomWidth: 0.5 }, defaultStyles.border]}>
|
||||||
<Text style={[{ textAlign: 'center', marginVertical: 6 }, defaultStyles.textHalfOpacity]}>
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
{t('total-download-size')}: {formatBytes(totalDownloadSize)}
|
<Text
|
||||||
</Text>
|
style={[
|
||||||
<Button
|
defaultStyles.textHalfOpacity,
|
||||||
icon={TrashIcon}
|
{ marginRight: 8, flex: 1, fontSize: 12 },
|
||||||
title={t('delete-all-tracks')}
|
]}
|
||||||
onPress={handleDeleteAllTracks}
|
numberOfLines={1}
|
||||||
disabled={!ids.length}
|
>
|
||||||
style={{ marginTop: 8 }}
|
{t('total-download-size')}: {formatBytes(totalDownloadSize)}
|
||||||
/>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
icon={ArrowClockwise}
|
icon={TrashIcon}
|
||||||
title={t('retry-failed-downloads')}
|
title={t('delete-all-tracks')}
|
||||||
onPress={handleRetryFailed}
|
onPress={handleDeleteAllTracks}
|
||||||
disabled={failedIds.length === 0}
|
disabled={!ids.length}
|
||||||
style={{ marginTop: 4 }}
|
size="small"
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
|
{failedIds.length > 0 && (
|
||||||
|
<Button
|
||||||
|
icon={ArrowClockwise}
|
||||||
|
title={t('retry-failed-downloads')}
|
||||||
|
onPress={handleRetryFailed}
|
||||||
|
disabled={failedIds.length === 0}
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
), [totalDownloadSize, defaultStyles, failedIds.length, handleRetryFailed, handleDeleteAllTracks, ids.length]);
|
), [totalDownloadSize, defaultStyles, failedIds.length, handleRetryFailed, handleDeleteAllTracks, ids.length]);
|
||||||
|
|
||||||
const renderItem = useCallback<NonNullable<FlatListProps<EntityId>['renderItem']>>(({ item }) => (
|
const renderItem = useCallback<NonNullable<FlatListProps<EntityId>['renderItem']>>(({ item }) => (
|
||||||
<DownloadedTrack>
|
<DownloadedTrack>
|
||||||
<View style={{ marginRight: 12 }}>
|
<View style={{ marginRight: 12 }}>
|
||||||
<DownloadIcon trackId={item} />
|
<ShadowWrapper size="small">
|
||||||
|
<AlbumImage source={{ uri: getImage(item as string) }} style={defaultStyles.imageBackground} />
|
||||||
|
</ShadowWrapper>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flexShrink: 1, marginRight: 8 }}>
|
<View style={{ flexShrink: 1, marginRight: 8 }}>
|
||||||
<Text style={[{ fontSize: 16, marginBottom: 4 }, defaultStyles.text]} numberOfLines={1}>
|
<Text style={[{ fontSize: 16, marginBottom: 4 }, defaultStyles.text]} numberOfLines={1}>
|
||||||
{tracks[item]?.Name}
|
{tracks[item]?.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[{ flexShrink: 1, fontSize: 11 }, defaultStyles.textHalfOpacity]} numberOfLines={1}>
|
<Text style={[{ flexShrink: 1, fontSize: 11 }, defaultStyles.textHalfOpacity]} numberOfLines={1}>
|
||||||
{tracks[item]?.AlbumArtist} ({tracks[item]?.Album})
|
{tracks[item]?.AlbumArtist} {tracks[item]?.Album ? `— ${tracks[item]?.Album}` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ marginLeft: 'auto', flexDirection: 'row', alignItems: 'center' }}>
|
<View style={{ marginLeft: 'auto', flexDirection: 'row', alignItems: 'center' }}>
|
||||||
{entities[item]?.isComplete && entities[item]?.size ? (
|
{entities[item]?.isComplete && entities[item]?.size ? (
|
||||||
<Text style={[defaultStyles.textHalfOpacity, { marginRight: 6, fontSize: 12 }]}>
|
<Text style={[defaultStyles.textQuarterOpacity, { marginRight: 12, fontSize: 12 }]}>
|
||||||
{formatBytes(entities[item]?.size || 0)}
|
{formatBytes(entities[item]?.size || 0)}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
<TouchableOpacity onPress={() => handleDelete(item)}>
|
<View style={{ marginRight: 12 }}>
|
||||||
<TrashIcon height={24} width={24} fill={THEME_COLOR} />
|
<DownloadIcon trackId={item} />
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
|
<Button onPress={() => handleDelete(item)} size="small" icon={TrashIcon} />
|
||||||
{!entities[item]?.isComplete && (
|
{!entities[item]?.isComplete && (
|
||||||
<TouchableOpacity onPress={() => retryTrack(item)}>
|
<Button onPress={() => retryTrack(item)} size="small" icon={ArrowClockwise} style={{ marginLeft: 4 }} />
|
||||||
<ArrowClockwise height={18} width={18} fill={THEME_COLOR} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</DownloadedTrack>
|
</DownloadedTrack>
|
||||||
), [entities, retryTrack, handleDelete, defaultStyles, tracks]);
|
), [entities, retryTrack, handleDelete, defaultStyles, tracks, getImage]);
|
||||||
|
|
||||||
// If no tracks have been downloaded, show a short message describing this
|
// If no tracks have been downloaded, show a short message describing this
|
||||||
if (!ids.length) {
|
if (!ids.length) {
|
||||||
@@ -129,11 +150,11 @@ function Downloads() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1 }}>
|
<SafeAreaView style={{ flex: 1 }}>
|
||||||
|
{ListHeaderComponent}
|
||||||
<FlatList
|
<FlatList
|
||||||
data={ids}
|
data={ids}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1, paddingTop: 12 }}
|
||||||
contentContainerStyle={{ flexGrow: 1 }}
|
contentContainerStyle={{ flexGrow: 1 }}
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a number of bytes to a human-readable string
|
* Convert a number of bytes to a human-readable string
|
||||||
|
|||||||
Reference in New Issue
Block a user