feat(settings): add collapsible settings section component and integrate into vertical settings

feat(downloads): implement functions to clear and cancel pending downloads in downloads store
This commit is contained in:
skalthoff
2026-01-12 14:26:05 -08:00
parent a677ffc602
commit 1aeb742952
8 changed files with 1272 additions and 494 deletions
+1
View File
@@ -85,3 +85,4 @@ web-build/
video.mp4
.github/copilot-instructions.md
screenshots/
CLAUDE.md
+6 -3
View File
@@ -9,15 +9,18 @@ interface ButtonProps extends TamaguiButtonProps {
}
export default function Button(props: ButtonProps): React.JSX.Element {
const { marginVertical = '$2', pressStyle, ...restProps } = props
return (
<TamaguiButton
opacity={props.disabled ? 0.5 : 1}
animation={'quick'}
marginVertical={marginVertical}
pressStyle={{
scale: 0.9,
opacity: 0.7,
...pressStyle,
}}
{...props}
marginVertical={'$2'}
{...restProps}
/>
)
}
+3 -123
View File
@@ -1,126 +1,6 @@
import React, { Suspense, lazy } from 'react'
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'
import { getToken, getTokenValue, useTheme, Spinner, YStack } from 'tamagui'
import SettingsTabBar from './tab-bar'
// Lazy load tab components to improve initial render
const PreferencesTab = lazy(() => import('./components/preferences-tab'))
const PlaybackTab = lazy(() => import('./components/playback-tab'))
const StorageTab = lazy(() => import('./components/usage-tab'))
const AccountTab = lazy(() => import('./components/account-tab'))
const InfoTab = lazy(() => import('./components/info-tab'))
const SettingsTabsNavigator = createMaterialTopTabNavigator()
function TabFallback() {
return (
<YStack flex={1} alignItems='center' justifyContent='center' backgroundColor='$background'>
<Spinner size='large' color='$primary' />
</YStack>
)
}
// Wrap lazy components with Suspense
function LazyPreferencesTab() {
return (
<Suspense fallback={<TabFallback />}>
<PreferencesTab />
</Suspense>
)
}
function LazyPlaybackTab() {
return (
<Suspense fallback={<TabFallback />}>
<PlaybackTab />
</Suspense>
)
}
function LazyStorageTab() {
return (
<Suspense fallback={<TabFallback />}>
<StorageTab />
</Suspense>
)
}
function LazyAccountTab() {
return (
<Suspense fallback={<TabFallback />}>
<AccountTab />
</Suspense>
)
}
function LazyInfoTab() {
return (
<Suspense fallback={<TabFallback />}>
<InfoTab />
</Suspense>
)
}
import React from 'react'
import VerticalSettings from './components/vertical-settings'
export default function Settings(): React.JSX.Element {
const theme = useTheme()
return (
<SettingsTabsNavigator.Navigator
screenOptions={{
tabBarIndicatorStyle: {
borderColor: theme.background.val,
borderBottomWidth: getTokenValue('$2'),
},
tabBarActiveTintColor: theme.background.val,
tabBarInactiveTintColor: theme.background50.val,
tabBarStyle: {
backgroundColor: theme.primary.val,
},
tabBarLabelStyle: {
fontFamily: 'Figtree-Bold',
},
tabBarPressOpacity: 0.5,
lazy: true,
lazyPreloadDistance: 0, // Only load the active tab
}}
tabBar={(props) => <SettingsTabBar {...props} />}
>
<SettingsTabsNavigator.Screen
name='Settings'
component={LazyPreferencesTab}
options={{
title: 'App',
}}
/>
<SettingsTabsNavigator.Screen
name='Playback'
component={LazyPlaybackTab}
options={{
title: 'Player',
}}
/>
<SettingsTabsNavigator.Screen name='Usage' component={LazyStorageTab} />
<SettingsTabsNavigator.Screen name='User' component={LazyAccountTab} />
<SettingsTabsNavigator.Screen name='About' component={LazyInfoTab} />
{/*
<SettingsTabsNavigator.Screen
name='Labs'
component={LabsTab}
options={{
tabBarIcon: ({ focused, color }) => (
<Icon
name='flask'
color={focused ? '$primary' : '$borderColor'}
small
/>
),
}}
/>
) */}
</SettingsTabsNavigator.Navigator>
)
return <VerticalSettings />
}
@@ -0,0 +1,74 @@
import React, { useState } from 'react'
import { YStack, XStack, SizableText, Card, Separator } from 'tamagui'
import Icon from '../../Global/components/icon'
import { ThemeTokens } from 'tamagui'
interface SettingsSectionProps {
title: string
icon: string
iconColor?: ThemeTokens
children: React.ReactNode
defaultExpanded?: boolean
collapsible?: boolean
}
export default function SettingsSection({
title,
icon,
iconColor = '$borderColor',
children,
defaultExpanded = false,
collapsible = true,
}: SettingsSectionProps): React.JSX.Element {
const [expanded, setExpanded] = useState(defaultExpanded)
const toggleExpanded = () => {
if (collapsible) {
setExpanded(!expanded)
}
}
const showContent = !collapsible || expanded
return (
<Card
bordered
backgroundColor='$background'
marginHorizontal='$3'
marginVertical='$1.5'
padding='$0'
>
<XStack
paddingHorizontal='$3'
paddingVertical='$3'
alignItems='center'
justifyContent='space-between'
onPress={collapsible ? toggleExpanded : undefined}
pressStyle={collapsible ? { opacity: 0.7 } : undefined}
hitSlop={8}
>
<XStack alignItems='center' gap='$3' flex={1}>
<Icon name={icon} color={iconColor} />
<SizableText size='$5' fontWeight='600'>
{title}
</SizableText>
</XStack>
{collapsible && (
<Icon
name={expanded ? 'chevron-up' : 'chevron-down'}
color='$borderColor'
small
/>
)}
</XStack>
{showContent && (
<>
<Separator />
<YStack padding='$3' gap='$3'>
{children}
</YStack>
</>
)}
</Card>
)
}
@@ -0,0 +1,736 @@
import React, { useState } from 'react'
import {
ScrollView,
YStack,
XStack,
SizableText,
Card,
RadioGroup,
Paragraph,
Avatar,
Spinner,
} from 'tamagui'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Linking, Alert } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import SettingsSection from './settings-section'
import Icon from '../../Global/components/icon'
import { Text } from '../../Global/helpers/text'
import Button from '../../Global/helpers/button'
import Input from '../../Global/helpers/input'
import { SwitchWithLabel } from '../../Global/helpers/switch-with-label'
import { RadioGroupItemWithLabel } from '../../Global/helpers/radio-group-item-with-label'
import StatusBar from '../../Global/helpers/status-bar'
import { SettingsStackParamList } from '../../../screens/Settings/types'
import { useJellifyUser, useJellifyLibrary, useJellifyServer } from '../../../stores'
import {
ThemeSetting,
useHideRunTimesSetting,
useReducedHapticsSetting,
useSendMetricsSetting,
useThemeSetting,
} from '../../../stores/settings/app'
import { useSwipeSettingsStore } from '../../../stores/settings/swipe'
import {
useDisplayAudioQualityBadge,
useEnableAudioNormalization,
useStreamingQuality,
} from '../../../stores/settings/player'
import {
DownloadQuality,
useAutoDownload,
useDownloadQuality,
} from '../../../stores/settings/usage'
import { useDeveloperOptionsEnabled, usePrId } from '../../../stores/settings/developer'
import { useAllDownloadedTracks } from '../../../api/queries/download'
import {
usePendingDownloads,
useCurrentDownloads,
useClearAllPendingDownloads,
} from '../../../stores/network/downloads'
import usePatrons from '../../../api/queries/patrons'
import StreamingQuality from '../../../enums/audio-quality'
import HTTPS from '../../../constants/protocols'
import { version } from '../../../../package.json'
import { getStoredOtaVersion } from 'react-native-nitro-ota'
import { downloadUpdate } from '../../OtaUpdates'
import { downloadPRUpdate } from '../../OtaUpdates/otaPR'
import { useInfoCaption } from '../../../hooks/use-caption'
type ThemeOptionConfig = {
value: ThemeSetting
label: string
icon: string
}
const THEME_OPTIONS: ThemeOptionConfig[] = [
{ value: 'system', label: 'Match Device', icon: 'theme-light-dark' },
{ value: 'light', label: 'Light', icon: 'white-balance-sunny' },
{ value: 'dark', label: 'Dark', icon: 'weather-night' },
{ value: 'oled', label: 'OLED Black', icon: 'invert-colors' },
]
function ThemeOptionCard({
option,
isSelected,
onPress,
}: {
option: ThemeOptionConfig
isSelected: boolean
onPress: () => void
}) {
return (
<XStack
onPress={onPress}
pressStyle={{ scale: 0.97 }}
animation='quick'
borderWidth='$1'
borderColor={isSelected ? '$primary' : '$borderColor'}
backgroundColor={isSelected ? '$background25' : '$background'}
borderRadius='$4'
padding='$2.5'
alignItems='center'
gap='$2'
flex={1}
minWidth='45%'
>
<Icon small name={option.icon} color={isSelected ? '$primary' : '$borderColor'} />
<SizableText size='$3' fontWeight='600' flex={1}>
{option.label}
</SizableText>
{isSelected && <Icon small name='check-circle-outline' color='$primary' />}
</XStack>
)
}
function ActionChip({
active,
label,
icon,
onPress,
}: {
active: boolean
label: string
icon: string
onPress: () => void
}) {
return (
<Button
pressStyle={{ backgroundColor: '$neutral' }}
onPress={onPress}
backgroundColor={active ? '$success' : 'transparent'}
borderColor={active ? '$success' : '$borderColor'}
borderWidth='$0.5'
color={active ? '$background' : '$color'}
paddingHorizontal='$2.5'
size='$2'
borderRadius='$10'
icon={<Icon name={icon} color={active ? '$background' : '$color'} small />}
>
<SizableText color={active ? '$background' : '$color'} size='$2'>
{label}
</SizableText>
</Button>
)
}
function PatronsList({ patrons }: { patrons: { fullName: string }[] | undefined }) {
if (!patrons?.length) return null
return (
<XStack flexWrap='wrap' gap='$2' marginTop='$2'>
{patrons.map((patron, index) => (
<XStack key={index} alignItems='flex-start' maxWidth='$20'>
<Text numberOfLines={1} lineBreakStrategyIOS='standard'>
{patron.fullName}
</Text>
</XStack>
))}
</XStack>
)
}
export default function VerticalSettings(): React.JSX.Element {
const { top } = useSafeAreaInsets()
const navigation = useNavigation<NativeStackNavigationProp<SettingsStackParamList>>()
// User/Server state
const [server] = useJellifyServer()
const [user] = useJellifyUser()
const [library] = useJellifyLibrary()
// App settings
const [themeSetting, setThemeSetting] = useThemeSetting()
const [hideRunTimes, setHideRunTimes] = useHideRunTimesSetting()
const [reducedHaptics, setReducedHaptics] = useReducedHapticsSetting()
const [sendMetrics, setSendMetrics] = useSendMetricsSetting()
// Swipe settings
const left = useSwipeSettingsStore((s) => s.left)
const right = useSwipeSettingsStore((s) => s.right)
const toggleLeft = useSwipeSettingsStore((s) => s.toggleLeft)
const toggleRight = useSwipeSettingsStore((s) => s.toggleRight)
// Player settings
const [streamingQuality, setStreamingQuality] = useStreamingQuality()
const [enableAudioNormalization, setEnableAudioNormalization] = useEnableAudioNormalization()
const [displayAudioQualityBadge, setDisplayAudioQualityBadge] = useDisplayAudioQualityBadge()
// Storage settings
const [autoDownload, setAutoDownload] = useAutoDownload()
const [downloadQuality, setDownloadQuality] = useDownloadQuality()
const { data: downloadedTracks } = useAllDownloadedTracks()
const pendingDownloads = usePendingDownloads()
const currentDownloads = useCurrentDownloads()
const clearAllPendingDownloads = useClearAllPendingDownloads()
const activeDownloadsCount = pendingDownloads.length + currentDownloads.length
// Developer settings
const [developerOptionsEnabled, setDeveloperOptionsEnabled] = useDeveloperOptionsEnabled()
const [prId, setPrId] = usePrId()
const [localPrId, setLocalPrId] = useState(prId)
// About
const patrons = usePatrons()
const { data: caption } = useInfoCaption()
const otaVersion = getStoredOtaVersion()
const handleSubmitPr = () => {
if (localPrId.trim()) {
setPrId(localPrId.trim())
downloadPRUpdate(Number(localPrId.trim()))
} else {
Alert.alert('Error', 'Please enter a valid PR ID')
}
}
const isSecure = server?.url.includes(HTTPS)
return (
<YStack flex={1} backgroundColor='$background'>
<YStack height={top} backgroundColor='$primary' />
<StatusBar invertColors />
<ScrollView
contentContainerStyle={{ paddingBottom: 160 }}
showsVerticalScrollIndicator={false}
>
{/* User Profile Header */}
<Card
backgroundColor='$primary'
borderRadius={0}
paddingHorizontal='$4'
paddingVertical='$4'
marginBottom='$2'
>
<XStack alignItems='center' gap='$3'>
<Avatar circular size='$6' backgroundColor='$background25'>
<Avatar.Fallback>
<Icon name='account-music' color='$background' />
</Avatar.Fallback>
</Avatar>
<YStack flex={1}>
<SizableText size='$6' fontWeight='bold' color='$background'>
{user?.name ?? 'Unknown User'}
</SizableText>
<XStack alignItems='center' gap='$1.5'>
<Icon
name={isSecure ? 'lock' : 'lock-open'}
color={isSecure ? '$background50' : '$warning'}
small
/>
<SizableText size='$3' color='$background50'>
{server?.name ?? 'Unknown Server'}
</SizableText>
</XStack>
</YStack>
<Button
size='$3'
backgroundColor='$background25'
borderColor='$background50'
borderWidth='$0.5'
onPress={() => navigation.navigate('LibrarySelection')}
icon={<Icon name='book-music' color='$background' small />}
>
<SizableText color='$background' size='$2'>
{library?.musicLibraryName ?? 'Library'}
</SizableText>
</Button>
</XStack>
</Card>
{/* Appearance Section */}
<SettingsSection
title='Appearance'
icon='palette'
iconColor='$primary'
defaultExpanded
>
<YStack gap='$2'>
<SizableText size='$3' color='$borderColor'>
Theme
</SizableText>
<XStack flexWrap='wrap' gap='$2'>
{THEME_OPTIONS.map((option) => (
<ThemeOptionCard
key={option.value}
option={option}
isSelected={themeSetting === option.value}
onPress={() => setThemeSetting(option.value)}
/>
))}
</XStack>
</YStack>
<XStack alignItems='center' justifyContent='space-between'>
<YStack flex={1}>
<SizableText size='$4'>Hide Runtimes</SizableText>
<SizableText size='$2' color='$borderColor'>
Hide track duration lengths
</SizableText>
</YStack>
<SwitchWithLabel
checked={hideRunTimes}
onCheckedChange={setHideRunTimes}
size='$2'
label=''
/>
</XStack>
</SettingsSection>
{/* Gestures Section */}
<SettingsSection title='Gestures' icon='gesture-swipe' iconColor='$success'>
<Paragraph color='$borderColor' size='$2'>
Single selection triggers on reveal; multiple selections show a menu.
</Paragraph>
<YStack gap='$2'>
<SizableText size='$3'>Swipe Left</SizableText>
<XStack gap='$2' flexWrap='wrap'>
<ActionChip
active={left.includes('ToggleFavorite')}
label='Favorite'
icon='heart'
onPress={() => toggleLeft('ToggleFavorite')}
/>
<ActionChip
active={left.includes('AddToPlaylist')}
label='Add to Playlist'
icon='playlist-plus'
onPress={() => toggleLeft('AddToPlaylist')}
/>
<ActionChip
active={left.includes('AddToQueue')}
label='Add to Queue'
icon='playlist-play'
onPress={() => toggleLeft('AddToQueue')}
/>
</XStack>
</YStack>
<YStack gap='$2'>
<SizableText size='$3'>Swipe Right</SizableText>
<XStack gap='$2' flexWrap='wrap'>
<ActionChip
active={right.includes('ToggleFavorite')}
label='Favorite'
icon='heart'
onPress={() => toggleRight('ToggleFavorite')}
/>
<ActionChip
active={right.includes('AddToPlaylist')}
label='Add to Playlist'
icon='playlist-plus'
onPress={() => toggleRight('AddToPlaylist')}
/>
<ActionChip
active={right.includes('AddToQueue')}
label='Add to Queue'
icon='playlist-play'
onPress={() => toggleRight('AddToQueue')}
/>
</XStack>
</YStack>
</SettingsSection>
{/* Playback Section */}
<SettingsSection title='Playback' icon='play-circle' iconColor='$warning'>
<YStack gap='$2'>
<SizableText size='$4'>Streaming Quality</SizableText>
<SizableText size='$2' color='$borderColor'>
Changes apply to new tracks
</SizableText>
<RadioGroup
value={streamingQuality}
onValueChange={(value) =>
setStreamingQuality(value as StreamingQuality)
}
>
<RadioGroupItemWithLabel
size='$3'
value={StreamingQuality.Original}
label='Original Quality'
/>
<RadioGroupItemWithLabel
size='$3'
value={StreamingQuality.High}
label='High (320kbps)'
/>
<RadioGroupItemWithLabel
size='$3'
value={StreamingQuality.Medium}
label='Medium (192kbps)'
/>
<RadioGroupItemWithLabel
size='$3'
value={StreamingQuality.Low}
label='Low (128kbps)'
/>
</RadioGroup>
</YStack>
<XStack alignItems='center' justifyContent='space-between'>
<YStack flex={1}>
<SizableText size='$4'>Audio Normalization</SizableText>
<SizableText size='$2' color='$borderColor'>
Normalize volume between tracks
</SizableText>
</YStack>
<SwitchWithLabel
checked={enableAudioNormalization}
onCheckedChange={setEnableAudioNormalization}
size='$2'
label=''
/>
</XStack>
<XStack alignItems='center' justifyContent='space-between'>
<YStack flex={1}>
<SizableText size='$4'>Quality Badge</SizableText>
<SizableText size='$2' color='$borderColor'>
Display audio quality in player
</SizableText>
</YStack>
<SwitchWithLabel
checked={displayAudioQualityBadge}
onCheckedChange={setDisplayAudioQualityBadge}
size='$2'
label=''
/>
</XStack>
</SettingsSection>
{/* Storage Section */}
<SettingsSection title='Storage' icon='harddisk' iconColor='$primary'>
{activeDownloadsCount > 0 && (
<Card
backgroundColor='$backgroundFocus'
borderRadius='$4'
borderWidth={1}
borderColor='$primary'
padding='$3'
>
<XStack alignItems='center' justifyContent='space-between'>
<XStack alignItems='center' gap='$2'>
<Spinner size='small' color='$primary' />
<YStack>
<SizableText size='$4' fontWeight='600'>
{activeDownloadsCount}{' '}
{activeDownloadsCount === 1 ? 'download' : 'downloads'}{' '}
in progress
</SizableText>
<SizableText size='$2' color='$borderColor'>
{currentDownloads.length} active,{' '}
{pendingDownloads.length} queued
</SizableText>
</YStack>
</XStack>
{pendingDownloads.length > 0 && (
<Button
size='$2'
backgroundColor='$warning'
color='$background'
onPress={clearAllPendingDownloads}
icon={
<Icon name='close-circle' color='$background' small />
}
>
<SizableText color='$background' size='$2'>
Cancel
</SizableText>
</Button>
)}
</XStack>
</Card>
)}
<XStack
alignItems='center'
justifyContent='space-between'
onPress={() => navigation.navigate('StorageManagement')}
pressStyle={{ opacity: 0.7 }}
>
<YStack flex={1}>
<SizableText size='$4'>Downloaded Tracks</SizableText>
<SizableText size='$2' color='$borderColor'>
{downloadedTracks?.length ?? 0}{' '}
{downloadedTracks?.length === 1 ? 'song' : 'songs'} stored offline
</SizableText>
</YStack>
<Icon name='chevron-right' color='$borderColor' />
</XStack>
<XStack alignItems='center' justifyContent='space-between'>
<YStack flex={1}>
<SizableText size='$4'>Auto-Download Tracks</SizableText>
<SizableText size='$2' color='$borderColor'>
Download tracks as they are played
</SizableText>
</YStack>
<SwitchWithLabel
checked={autoDownload}
onCheckedChange={() => setAutoDownload(!autoDownload)}
size='$2'
label=''
/>
</XStack>
<YStack gap='$2'>
<SizableText size='$4'>Download Quality</SizableText>
<RadioGroup
value={downloadQuality}
onValueChange={(value) => setDownloadQuality(value as DownloadQuality)}
>
<RadioGroupItemWithLabel
size='$3'
value='original'
label='Original Quality'
/>
<RadioGroupItemWithLabel
size='$3'
value='high'
label='High (320kbps)'
/>
<RadioGroupItemWithLabel
size='$3'
value='medium'
label='Medium (192kbps)'
/>
<RadioGroupItemWithLabel size='$3' value='low' label='Low (128kbps)' />
</RadioGroup>
</YStack>
</SettingsSection>
{/* Privacy Section */}
<SettingsSection title='Privacy' icon='shield-account' iconColor='$success'>
<XStack alignItems='center' justifyContent='space-between'>
<YStack flex={1}>
<SizableText size='$4'>Send Analytics</SizableText>
<SizableText size='$2' color='$borderColor'>
Send usage and crash data
</SizableText>
</YStack>
<SwitchWithLabel
checked={sendMetrics}
onCheckedChange={setSendMetrics}
size='$2'
label=''
/>
</XStack>
<XStack alignItems='center' justifyContent='space-between'>
<YStack flex={1}>
<SizableText size='$4'>Reduce Haptics</SizableText>
<SizableText size='$2' color='$borderColor'>
Reduce haptic feedback intensity
</SizableText>
</YStack>
<SwitchWithLabel
checked={reducedHaptics}
onCheckedChange={setReducedHaptics}
size='$2'
label=''
/>
</XStack>
</SettingsSection>
{/* Developer Section */}
<SettingsSection title='Developer' icon='code-braces' iconColor='$borderColor'>
<XStack alignItems='center' justifyContent='space-between'>
<YStack flex={1}>
<SizableText size='$4'>Developer Options</SizableText>
<SizableText size='$2' color='$borderColor'>
Enable advanced developer features
</SizableText>
</YStack>
<SwitchWithLabel
checked={developerOptionsEnabled}
onCheckedChange={setDeveloperOptionsEnabled}
size='$2'
label=''
/>
</XStack>
{developerOptionsEnabled && (
<YStack gap='$2' paddingTop='$1'>
<SizableText size='$2' color='$borderColor'>
Enter PR ID to test pull request builds
</SizableText>
<XStack gap='$2' alignItems='center'>
<Input
flex={1}
placeholder='Enter PR ID'
value={localPrId}
onChangeText={setLocalPrId}
keyboardType='numeric'
size='$3'
/>
<Button
size='$3'
backgroundColor='$primary'
color='$background'
onPress={handleSubmitPr}
circular
icon={<Icon name='check' color='$background' small />}
/>
</XStack>
{prId && (
<SizableText color='$success' size='$2'>
Current PR ID: {prId}
</SizableText>
)}
</YStack>
)}
</SettingsSection>
{/* About Section */}
<SettingsSection title='About' icon='information' iconColor='$primary'>
<YStack gap='$1'>
<XStack alignItems='center' gap='$2'>
<Icon name='jellyfish' color='$primary' />
<SizableText size='$5' fontWeight='bold'>
Jellify {version}
</SizableText>
</XStack>
{caption && (
<SizableText size='$2' color='$borderColor'>
{caption}
</SizableText>
)}
{otaVersion && (
<SizableText size='$2' color='$borderColor'>
OTA Version: {otaVersion}
</SizableText>
)}
</YStack>
<XStack gap='$4' flexWrap='wrap'>
<XStack
alignItems='center'
gap='$1'
onPress={() => Linking.openURL('https://github.com/Jellify-Music/App')}
pressStyle={{ opacity: 0.7 }}
>
<Icon name='code-tags' small color='$borderColor' />
<Text>View Source</Text>
</XStack>
<XStack
alignItems='center'
gap='$1'
onPress={() => downloadUpdate(true)}
pressStyle={{ opacity: 0.7 }}
>
<Icon name='cellphone-arrow-down' small color='$borderColor' />
<Text>Update</Text>
</XStack>
</XStack>
<YStack gap='$2'>
<SizableText size='$3' fontWeight='600'>
Caught a bug?
</SizableText>
<XStack gap='$4' flexWrap='wrap'>
<XStack
alignItems='center'
gap='$1'
onPress={() =>
Linking.openURL('https://github.com/Jellify-Music/App/issues')
}
pressStyle={{ opacity: 0.7 }}
>
<Icon name='github' small color='$borderColor' />
<Text>Report Issue</Text>
</XStack>
<XStack
alignItems='center'
gap='$1'
onPress={() => Linking.openURL('https://discord.gg/yf8fBatktn')}
pressStyle={{ opacity: 0.7 }}
>
<Icon name='chat' small color='$borderColor' />
<Text>Join Discord</Text>
</XStack>
</XStack>
</YStack>
<YStack gap='$2'>
<SizableText size='$3' fontWeight='600'>
Wall of Fame
</SizableText>
<XStack gap='$4' flexWrap='wrap'>
<XStack
alignItems='center'
gap='$1'
onPress={() =>
Linking.openURL(
'https://github.com/sponsors/anultravioletaurora/',
)
}
pressStyle={{ opacity: 0.7 }}
>
<Icon name='github' small color='$borderColor' />
<Text>Sponsors</Text>
</XStack>
<XStack
alignItems='center'
gap='$1'
onPress={() =>
Linking.openURL('https://patreon.com/anultravioletaurora')
}
pressStyle={{ opacity: 0.7 }}
>
<Icon name='patreon' small color='$borderColor' />
<Text>Patreon</Text>
</XStack>
<XStack
alignItems='center'
gap='$1'
onPress={() => Linking.openURL('https://ko-fi.com/jellify')}
pressStyle={{ opacity: 0.7 }}
>
<Icon name='coffee-outline' small color='$borderColor' />
<Text>Ko-fi</Text>
</XStack>
</XStack>
<PatronsList patrons={patrons} />
</YStack>
</SettingsSection>
{/* Sign Out Button */}
<YStack paddingHorizontal='$3' paddingVertical='$4'>
<Button
size='$4'
backgroundColor='$danger'
color='$background'
onPress={() => navigation.navigate('SignOut')}
icon={<Icon name='logout' color='$background' />}
>
Sign Out
</Button>
</YStack>
</ScrollView>
</YStack>
)
}
+20 -1
View File
@@ -5,7 +5,14 @@ import {
DeleteDownloadsResult,
deleteDownloadsByIds,
} from '../../api/mutations/download/offlineModeUtils'
import { useDownloadProgress } from '../../stores/network/downloads'
import {
useDownloadProgress,
usePendingDownloads,
useCurrentDownloads,
useClearAllPendingDownloads,
useCancelPendingDownload,
} from '../../stores/network/downloads'
import JellifyTrack from '../../types/JellifyTrack'
export type StorageSummary = {
totalSpace: number
@@ -44,6 +51,10 @@ interface StorageContextValue {
refreshing: boolean
activeDownloadsCount: number
activeDownloads: JellifyDownloadProgress | undefined
pendingDownloads: JellifyTrack[]
currentDownloads: JellifyTrack[]
cancelPendingDownload: (itemId: string) => void
clearAllPendingDownloads: () => void
}
const StorageContext = createContext<StorageContextValue | undefined>(undefined)
@@ -68,6 +79,10 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem
isFetching: isFetchingStorage,
} = useStorageInUse()
const activeDownloads = useDownloadProgress()
const pendingDownloads = usePendingDownloads()
const currentDownloads = useCurrentDownloads()
const clearAllPendingDownloads = useClearAllPendingDownloads()
const cancelPendingDownload = useCancelPendingDownload()
const [selection, setSelection] = useState<StorageSelectionState>({})
const [isDeleting, setIsDeleting] = useState(false)
@@ -220,6 +235,10 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem
refreshing,
activeDownloadsCount,
activeDownloads,
pendingDownloads,
currentDownloads,
cancelPendingDownload,
clearAllPendingDownloads,
}
return <StorageContext.Provider value={value}>{children}</StorageContext.Provider>
+417 -367
View File
@@ -1,16 +1,15 @@
import React, { useState } from 'react'
import { FlashList, ListRenderItem } from '@shopify/flash-list'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Pressable, Alert } from 'react-native'
import { Card, Paragraph, Separator, SizableText, Spinner, XStack, YStack, Image } from 'tamagui'
import { YStack, XStack, SizableText, Card, Spinner, Image, ScrollView, Separator } from 'tamagui'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useStorageContext, CleanupSuggestion } from '../../../providers/Storage'
import SettingsSection from '../../../components/Settings/components/settings-section'
import Icon from '../../../components/Global/components/icon'
import Button from '../../../components/Global/helpers/button'
import { formatBytes } from '../../../utils/formatting/bytes'
import { JellifyDownload, JellifyDownloadProgress } from '../../../types/JellifyDownload'
import { useDeletionToast } from './useDeletionToast'
import { Text } from '../../../components/Global/helpers/text'
const getDownloadSize = (download: JellifyDownload) =>
(download.fileSizeBytes ?? 0) + (download.artworkSizeBytes ?? 0)
@@ -25,6 +24,7 @@ const formatSavedAt = (timestamp: string) => {
}
export default function StorageManagementScreen(): React.JSX.Element {
const { bottom } = useSafeAreaInsets()
const {
downloads,
summary,
@@ -37,11 +37,15 @@ export default function StorageManagementScreen(): React.JSX.Element {
refreshing,
activeDownloadsCount,
activeDownloads,
pendingDownloads,
currentDownloads,
cancelPendingDownload,
clearAllPendingDownloads,
} = useStorageContext()
const [applyingSuggestionId, setApplyingSuggestionId] = useState<string | null>(null)
const insets = useSafeAreaInsets()
const bottomPadding = Math.max(bottom, 16) + 140
const showDeletionToast = useDeletionToast()
@@ -128,250 +132,413 @@ export default function StorageManagementScreen(): React.JSX.Element {
],
)
const renderDownloadItem: ListRenderItem<JellifyDownload> = ({ item }) => (
<DownloadRow
download={item}
isSelected={Boolean(selection[item.item.Id as string])}
onToggle={() => toggleSelection(item.item.Id as string)}
onDelete={() => {
void handleDeleteSingle(item)
}}
/>
)
const topPadding = 16
const totalQueueCount = pendingDownloads.length + currentDownloads.length
return (
<YStack flex={1} backgroundColor={'$background'}>
<FlashList
data={sortedDownloads}
keyExtractor={(item) =>
item.item.Id ?? item.url ?? item.title ?? Math.random().toString()
}
contentContainerStyle={{
paddingBottom: insets.bottom + 48,
paddingHorizontal: 16,
paddingTop: topPadding,
}}
ItemSeparatorComponent={Separator}
ListHeaderComponent={
<YStack gap='$4'>
<XStack justifyContent='space-between' alignItems='center'>
{selectedIds.length > 0 && (
<Card
paddingHorizontal='$3'
paddingVertical='$2'
borderRadius='$4'
backgroundColor='$backgroundFocus'
<YStack flex={1} backgroundColor='$background'>
<ScrollView
contentContainerStyle={{ paddingBottom: bottomPadding }}
showsVerticalScrollIndicator={false}
>
{/* Storage Overview Section */}
<SettingsSection
title='Storage Overview'
icon='harddisk'
iconColor='$primary'
defaultExpanded
collapsible={false}
>
{summary ? (
<YStack gap='$3'>
<XStack alignItems='flex-end' justifyContent='space-between'>
<YStack gap='$1'>
<SizableText size='$8' fontWeight='700'>
{formatBytes(summary.usedByDownloads)}
</SizableText>
<SizableText size='$2' color='$borderColor'>
Used by offline music
</SizableText>
</YStack>
<YStack alignItems='flex-end' gap='$1'>
<SizableText size='$4' fontWeight='600'>
{formatBytes(summary.freeSpace)}
</SizableText>
<SizableText size='$2' color='$borderColor'>
Free on device
</SizableText>
</YStack>
</XStack>
<ProgressBar progress={summary.usedPercentage} />
<XStack flexWrap='wrap' gap='$2'>
<StatChip
label='Downloads'
value={`${summary.downloadCount}`}
icon='download'
/>
<StatChip
label='Audio'
value={formatBytes(summary.audioBytes)}
icon='music-note'
/>
<StatChip
label='Artwork'
value={formatBytes(summary.artworkBytes)}
icon='image'
/>
</XStack>
<XStack gap='$2'>
<Button
flex={1}
size='$3'
backgroundColor='transparent'
borderColor='$borderColor'
borderWidth='$0.5'
onPress={() => void refresh()}
disabled={refreshing}
icon={
refreshing ? (
<Spinner size='small' color='$color' />
) : (
<Icon name='refresh' color='$color' small />
)
}
>
<Paragraph fontWeight='600'>
{selectedIds.length} selected
</Paragraph>
</Card>
)}
<SizableText size='$3'>Refresh</SizableText>
</Button>
<Button
flex={1}
size='$3'
backgroundColor='$warning'
borderColor='$warning'
borderWidth='$0.5'
onPress={handleDeleteAll}
icon={<Icon name='broom' color='$background' small />}
>
<SizableText color='$background' size='$3'>
Clear All
</SizableText>
</Button>
</XStack>
</YStack>
) : (
<XStack alignItems='center' gap='$3' padding='$2'>
<Spinner size='small' color='$primary' />
<SizableText size='$3' color='$borderColor'>
Calculating storage usage...
</SizableText>
</XStack>
<StorageSummaryCard
summary={summary}
refreshing={refreshing}
onRefresh={() => {
void refresh()
}}
activeDownloadsCount={activeDownloadsCount}
activeDownloads={activeDownloads}
onDeleteAll={handleDeleteAll}
/>
<CleanupSuggestionsRow
suggestions={suggestions}
onApply={(suggestion) => {
void handleApplySuggestion(suggestion)
}}
busySuggestionId={applyingSuggestionId}
/>
<DownloadsSectionHeading count={downloads?.length ?? 0} />
{selectedIds.length > 0 && (
<SelectionReviewBanner
selectedCount={selectedIds.length}
selectedBytes={selectedBytes}
onDelete={handleDeleteSelection}
onClear={clearSelection}
/>
)}
</YStack>
}
ListEmptyComponent={
<EmptyState
refreshing={refreshing}
onRefresh={() => {
void refresh()
}}
/>
}
renderItem={renderDownloadItem}
/>
)}
</SettingsSection>
{/* Active Downloads Section */}
{totalQueueCount > 0 && (
<SettingsSection
title='Downloads in Progress'
icon='download'
iconColor='$success'
defaultExpanded
>
<YStack gap='$2'>
{currentDownloads.map((download) => {
const progressInfo = activeDownloads?.[download.url ?? '']
const progress = progressInfo?.progress ?? 0
return (
<XStack
key={download.item.Id}
alignItems='center'
gap='$3'
padding='$2'
backgroundColor='$backgroundFocus'
borderRadius='$3'
>
<YStack
width={36}
height={36}
borderRadius='$2'
backgroundColor='$primary'
alignItems='center'
justifyContent='center'
>
<Spinner size='small' color='$background' />
</YStack>
<YStack flex={1} gap='$1'>
<SizableText
size='$3'
fontWeight='600'
numberOfLines={1}
>
{download.title ??
download.item.Name ??
'Downloading...'}
</SizableText>
<XStack alignItems='center' gap='$2'>
<YStack
flex={1}
height={3}
borderRadius={999}
backgroundColor='$backgroundHover'
>
<YStack
height={3}
borderRadius={999}
backgroundColor='$success'
width={`${Math.min(100, Math.max(0, progress * 100))}%`}
/>
</YStack>
<SizableText size='$1' color='$borderColor'>
{Math.round(progress * 100)}%
</SizableText>
</XStack>
</YStack>
</XStack>
)
})}
{pendingDownloads.slice(0, 5).map((download) => (
<XStack
key={download.item.Id}
alignItems='center'
gap='$3'
padding='$2'
backgroundColor='$backgroundFocus'
borderRadius='$3'
>
<YStack
width={36}
height={36}
borderRadius='$2'
backgroundColor='$borderColor'
alignItems='center'
justifyContent='center'
>
<Icon name='clock-outline' color='$background' small />
</YStack>
<YStack flex={1}>
<SizableText size='$3' fontWeight='600' numberOfLines={1}>
{download.title ?? download.item.Name ?? 'Queued'}
</SizableText>
<SizableText size='$1' color='$borderColor'>
Waiting in queue
</SizableText>
</YStack>
<Button
size='$2'
circular
backgroundColor='transparent'
hitSlop={10}
icon={<Icon name='close' color='$warning' small />}
onPress={() =>
cancelPendingDownload(download.item.Id as string)
}
/>
</XStack>
))}
{pendingDownloads.length > 5 && (
<SizableText size='$2' color='$borderColor' textAlign='center'>
+{pendingDownloads.length - 5} more in queue
</SizableText>
)}
{pendingDownloads.length > 0 && (
<Button
size='$3'
backgroundColor='$warning'
borderColor='$warning'
borderWidth='$0.5'
onPress={clearAllPendingDownloads}
icon={<Icon name='close-circle' color='$background' small />}
>
<SizableText color='$background' size='$3'>
Cancel Queue
</SizableText>
</Button>
)}
</YStack>
</SettingsSection>
)}
{/* Cleanup Suggestions Section */}
{suggestions.length > 0 && (
<SettingsSection
title='Cleanup Ideas'
icon='lightbulb-outline'
iconColor='$warning'
defaultExpanded
>
<YStack gap='$3'>
{suggestions.map((suggestion) => (
<Card
key={suggestion.id}
backgroundColor='$backgroundFocus'
borderRadius='$3'
padding='$3'
>
<YStack gap='$2'>
<XStack alignItems='center' justifyContent='space-between'>
<SizableText size='$4' fontWeight='600'>
{suggestion.title}
</SizableText>
<SizableText size='$2' color='$borderColor'>
{suggestion.count} items
</SizableText>
</XStack>
<SizableText size='$2' color='$borderColor'>
{suggestion.description}
</SizableText>
<Button
size='$3'
backgroundColor='$primary'
borderColor='$primary'
borderWidth='$0.5'
disabled={applyingSuggestionId === suggestion.id}
onPress={() => void handleApplySuggestion(suggestion)}
icon={
applyingSuggestionId === suggestion.id ? (
<Spinner size='small' color='$background' />
) : (
<Icon name='broom' color='$background' small />
)
}
>
<SizableText color='$background' size='$3'>
Free {formatBytes(suggestion.freedBytes)}
</SizableText>
</Button>
</YStack>
</Card>
))}
</YStack>
</SettingsSection>
)}
{/* Selection Banner */}
{selectedIds.length > 0 && (
<Card
bordered
backgroundColor='$background'
marginHorizontal='$3'
marginVertical='$1.5'
padding='$3'
>
<YStack gap='$3'>
<XStack alignItems='center' justifyContent='space-between'>
<YStack>
<SizableText size='$4' fontWeight='600'>
{selectedIds.length} selected
</SizableText>
<SizableText size='$2' color='$borderColor'>
{formatBytes(selectedBytes)} will be freed
</SizableText>
</YStack>
<Button
size='$2'
backgroundColor='transparent'
borderColor='$borderColor'
borderWidth='$0.5'
onPress={clearSelection}
>
<SizableText size='$2'>Clear</SizableText>
</Button>
</XStack>
<Button
size='$3'
backgroundColor='$warning'
borderColor='$warning'
borderWidth='$0.5'
onPress={handleDeleteSelection}
icon={<Icon name='broom' color='$background' small />}
>
<SizableText color='$background' size='$3'>
Clear {formatBytes(selectedBytes)}
</SizableText>
</Button>
</YStack>
</Card>
)}
{/* Offline Library Section */}
<SettingsSection
title='Offline Library'
icon='music-box-multiple'
iconColor='$primary'
defaultExpanded
collapsible={false}
>
{sortedDownloads.length === 0 ? (
<YStack alignItems='center' gap='$3' padding='$4'>
<Icon name='cloud-off-outline' color='$borderColor' />
<SizableText size='$4' fontWeight='600'>
No offline music yet
</SizableText>
<SizableText size='$2' color='$borderColor' textAlign='center'>
Downloaded tracks will show up here so you can manage storage any
time.
</SizableText>
</YStack>
) : (
<YStack gap='$1'>
<SizableText size='$2' color='$borderColor' marginBottom='$2'>
{sortedDownloads.length}{' '}
{sortedDownloads.length === 1 ? 'track' : 'tracks'} cached
</SizableText>
{sortedDownloads.map((download, index) => (
<React.Fragment key={download.item.Id ?? index}>
<DownloadRow
download={download}
isSelected={Boolean(selection[download.item.Id as string])}
onToggle={() => toggleSelection(download.item.Id as string)}
onDelete={() => void handleDeleteSingle(download)}
/>
{index < sortedDownloads.length - 1 && (
<Separator marginVertical='$1' />
)}
</React.Fragment>
))}
</YStack>
)}
</SettingsSection>
</ScrollView>
</YStack>
)
}
const StorageSummaryCard = ({
summary,
refreshing,
onRefresh,
activeDownloadsCount,
activeDownloads,
onDeleteAll,
}: {
summary: ReturnType<typeof useStorageContext>['summary']
refreshing: boolean
onRefresh: () => void
activeDownloadsCount: number
activeDownloads: JellifyDownloadProgress | undefined
onDeleteAll: () => void
}) => {
return (
<Card
backgroundColor={'$backgroundFocus'}
padding='$4'
borderRadius='$6'
borderWidth={1}
borderColor={'$borderColor'}
>
<XStack justifyContent='space-between' alignItems='center' marginBottom='$3'>
<SizableText size='$5' fontWeight='600'>
Storage overview
</SizableText>
<XStack gap='$2'>
<Button
size='$2'
circular
backgroundColor='transparent'
hitSlop={10}
icon={() =>
refreshing ? (
<Spinner size='small' color='$color' />
) : (
<Icon name='refresh' color='$color' />
)
}
onPress={onRefresh}
aria-label='Refresh storage overview'
/>
<Button
size='$2'
backgroundColor='$warning'
borderColor='$warning'
borderWidth={1}
color='white'
onPress={onDeleteAll}
icon={() => <Icon name='broom' color='$background' small />}
>
<Text bold color={'$background'}>
Clear All
</Text>
</Button>
</XStack>
</XStack>
{summary ? (
<YStack gap='$4'>
<YStack gap='$1'>
<SizableText size='$8' fontWeight='700'>
{formatBytes(summary.usedByDownloads)}
</SizableText>
<Paragraph color='$borderColor'>
Used by offline music · {formatBytes(summary.freeSpace)} free on device
</Paragraph>
</YStack>
<YStack gap='$2'>
<ProgressBar progress={summary.usedPercentage} />
<Paragraph color='$borderColor'>
{summary.downloadCount} downloads · {summary.manualDownloadCount} manual
· {summary.autoDownloadCount} auto
</Paragraph>
</YStack>
<StatGrid summary={summary} />
</YStack>
) : (
<YStack gap='$2'>
<Spinner />
<Paragraph color='$borderColor'>Calculating storage usage</Paragraph>
</YStack>
)}
</Card>
)
}
const ProgressBar = ({ progress }: { progress: number }) => (
<YStack height={10} borderRadius={999} backgroundColor={'$backgroundHover'}>
<YStack height={6} borderRadius={999} backgroundColor='$backgroundHover'>
<YStack
height={10}
height={6}
borderRadius={999}
backgroundColor={'$primary'}
backgroundColor='$primary'
width={`${Math.min(1, Math.max(0, progress)) * 100}%`}
/>
</YStack>
)
const CleanupSuggestionsRow = ({
suggestions,
onApply,
busySuggestionId,
}: {
suggestions: CleanupSuggestion[]
onApply: (suggestion: CleanupSuggestion) => void
busySuggestionId: string | null
}) => {
if (!suggestions.length) return null
return (
<YStack gap='$3'>
<SizableText size='$5' fontWeight='600'>
Cleanup ideas
const StatChip = ({ label, value, icon }: { label: string; value: string; icon: string }) => (
<XStack
flex={1}
minWidth={90}
alignItems='center'
gap='$2'
padding='$2'
backgroundColor='$backgroundFocus'
borderRadius='$3'
>
<Icon name={icon} color='$borderColor' small />
<YStack>
<SizableText size='$3' fontWeight='600'>
{value}
</SizableText>
<SizableText size='$1' color='$borderColor'>
{label}
</SizableText>
<XStack gap='$3' flexWrap='wrap'>
{suggestions.map((suggestion) => (
<Card
key={suggestion.id}
padding='$3'
borderRadius='$4'
backgroundColor={'$backgroundFocus'}
borderWidth={1}
borderColor={'$borderColor'}
flexGrow={1}
flexBasis='48%'
>
<YStack gap='$2'>
<SizableText size='$4' fontWeight='600'>
{suggestion.title}
</SizableText>
<Paragraph color='$borderColor'>
{suggestion.count} items · {formatBytes(suggestion.freedBytes)}
</Paragraph>
<Paragraph color='$borderColor'>{suggestion.description}</Paragraph>
<Button
size='$3'
width='100%'
backgroundColor='$primary'
borderColor='$primary'
borderWidth={1}
color='$background'
disabled={busySuggestionId === suggestion.id}
icon={() =>
busySuggestionId === suggestion.id ? (
<Spinner size='small' color='$background' />
) : (
<Icon name='broom' color='$background' />
)
}
onPress={() => onApply(suggestion)}
>
Free {formatBytes(suggestion.freedBytes)}
</Button>
</YStack>
</Card>
))}
</XStack>
</YStack>
)
}
</XStack>
)
const DownloadRow = ({
download,
@@ -385,176 +552,59 @@ const DownloadRow = ({
onDelete: () => void
}) => (
<Pressable onPress={onToggle} accessibilityRole='button'>
<XStack padding='$3' alignItems='center' gap='$3' borderRadius='$4'>
<XStack padding='$2' alignItems='center' gap='$3' borderRadius='$3'>
<Icon
name={isSelected ? 'check-circle-outline' : 'circle-outline'}
color={isSelected ? '$color' : '$borderColor'}
color={isSelected ? '$primary' : '$borderColor'}
small
/>
{download.artwork ? (
<Image
source={{ uri: download.artwork, width: 50, height: 50 }}
width={50}
height={50}
source={{ uri: download.artwork, width: 44, height: 44 }}
width={44}
height={44}
borderRadius='$2'
/>
) : (
<YStack
width={50}
height={50}
width={44}
height={44}
borderRadius='$2'
backgroundColor='$backgroundHover'
alignItems='center'
justifyContent='center'
>
<Icon name='music-note' color='$color' />
<Icon name='music-note' color='$borderColor' small />
</YStack>
)}
<YStack flex={1} gap='$1'>
<SizableText size='$4' fontWeight='600'>
<YStack flex={1} gap='$0.5'>
<SizableText size='$3' fontWeight='600' numberOfLines={1}>
{download.title ??
download.item.Name ??
download.item.SortName ??
'Unknown track'}
</SizableText>
<Paragraph color='$borderColor'>
<SizableText size='$1' color='$borderColor' numberOfLines={1}>
{download.album ?? 'Unknown album'} · {formatBytes(getDownloadSize(download))}
</Paragraph>
<Paragraph color='$borderColor'>Saved {formatSavedAt(download.savedAt)}</Paragraph>
</SizableText>
<SizableText size='$1' color='$borderColor'>
Saved {formatSavedAt(download.savedAt)}
</SizableText>
</YStack>
<Button
size='$3'
size='$2'
circular
backgroundColor='transparent'
hitSlop={10}
icon={() => <Icon name='broom' color='$warning' />}
icon={<Icon name='broom' color='$warning' small />}
onPress={(event) => {
event.stopPropagation()
onDelete()
}}
aria-label='Clear download'
/>
</XStack>
</Pressable>
)
const EmptyState = ({ refreshing, onRefresh }: { refreshing: boolean; onRefresh: () => void }) => (
<YStack padding='$6' alignItems='center' gap='$3'>
<SizableText size='$6' fontWeight='600'>
No offline music yet
</SizableText>
<Paragraph color='$borderColor' textAlign='center'>
Downloaded tracks will show up here so you can reclaim storage any time.
</Paragraph>
<Button
borderColor='$borderColor'
borderWidth={1}
backgroundColor='$background'
onPress={onRefresh}
icon={() =>
refreshing ? (
<Spinner size='small' color='$borderColor' />
) : (
<Icon name='refresh' color='$borderColor' />
)
}
>
Refresh
</Button>
</YStack>
)
const SelectionReviewBanner = ({
selectedCount,
selectedBytes,
onDelete,
onClear,
}: {
selectedCount: number
selectedBytes: number
onDelete: () => void
onClear: () => void
}) => (
<Card
borderRadius='$6'
borderWidth={1}
borderColor='$borderColor'
backgroundColor='$backgroundFocus'
padding='$3'
>
<YStack gap='$3'>
<XStack justifyContent='space-between' alignItems='center'>
<YStack>
<SizableText size='$5' fontWeight='600'>
Ready to clean up?
</SizableText>
<Paragraph color='$borderColor'>
{selectedCount} {selectedCount === 1 ? 'track' : 'tracks'} ·{' '}
{formatBytes(selectedBytes)}
</Paragraph>
</YStack>
<Button
size='$2'
borderColor='$borderColor'
borderWidth={1}
backgroundColor='$background'
onPress={onClear}
>
Clear
</Button>
</XStack>
<Button
size='$3'
borderColor='$warning'
borderWidth={1}
color='white'
icon={() => <Icon small name='broom' color='$warning' />}
onPress={onDelete}
>
<Text bold color={'$warning'}>{`Clear ${formatBytes(selectedBytes)}`}</Text>
</Button>
</YStack>
</Card>
)
const DownloadsSectionHeading = ({ count }: { count: number }) => (
<XStack alignItems='center' justifyContent='space-between'>
<SizableText size='$5' fontWeight='600'>
Offline library
</SizableText>
<Paragraph color='$borderColor'>
{count} {count === 1 ? 'item' : 'items'} cached
</Paragraph>
</XStack>
)
const StatGrid = ({
summary,
}: {
summary: NonNullable<ReturnType<typeof useStorageContext>['summary']>
}) => (
<XStack gap='$3' flexWrap='wrap'>
<StatChip label='Audio files' value={formatBytes(summary.audioBytes)} />
<StatChip label='Artwork' value={formatBytes(summary.artworkBytes)} />
<StatChip label='Auto downloads' value={`${summary.autoDownloadCount}`} />
</XStack>
)
const StatChip = ({ label, value }: { label: string; value: string }) => (
<YStack
flexGrow={1}
flexBasis='30%'
minWidth={110}
borderWidth={1}
borderColor='$borderColor'
borderRadius='$4'
padding='$3'
backgroundColor={'$background'}
>
<SizableText size='$6' fontWeight='700'>
{value}
</SizableText>
<Paragraph color='$borderColor'>{label}</Paragraph>
</YStack>
)
+15
View File
@@ -19,6 +19,8 @@ type DownloadsStore = {
setCompletedDownloads: (items: JellifyTrack[]) => void
failedDownloads: JellifyTrack[]
setFailedDownloads: (items: JellifyTrack[]) => void
clearAllPendingDownloads: () => void
cancelPendingDownload: (itemId: string) => void
}
export const useDownloadsStore = create<DownloadsStore>()(
@@ -41,6 +43,13 @@ export const useDownloadsStore = create<DownloadsStore>()(
setCompletedDownloads: (items) => set({ completedDownloads: items }),
failedDownloads: [],
setFailedDownloads: (items) => set({ failedDownloads: items }),
clearAllPendingDownloads: () => set({ pendingDownloads: [] }),
cancelPendingDownload: (itemId) =>
set((state) => ({
pendingDownloads: state.pendingDownloads.filter(
(download) => download.item.Id !== itemId,
),
})),
}),
{
name: 'downloads-store',
@@ -58,6 +67,12 @@ export const useCurrentDownloads = () => useDownloadsStore((state) => state.curr
export const useFailedDownloads = () => useDownloadsStore((state) => state.failedDownloads)
export const useClearAllPendingDownloads = () =>
useDownloadsStore((state) => state.clearAllPendingDownloads)
export const useCancelPendingDownload = () =>
useDownloadsStore((state) => state.cancelPendingDownload)
export const useIsDownloading = (items: BaseItemDto[]) => {
const pendingDownloads = usePendingDownloads()
const currentDownloads = useCurrentDownloads()