mirror of
https://github.com/Jellify-Music/App.git
synced 2026-04-21 00:58:32 -05:00
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:
@@ -85,3 +85,4 @@ web-build/
|
||||
video.mp4
|
||||
.github/copilot-instructions.md
|
||||
screenshots/
|
||||
CLAUDE.md
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user