add library flow, organize login files (#456)

This commit is contained in:
Violet Caulfield
2025-07-24 03:45:25 -05:00
committed by GitHub
parent dae111076c
commit 9df5b0aa7f
17 changed files with 183 additions and 107 deletions

68
App.tsx
View File

@@ -19,6 +19,7 @@ import { requestStoragePermission } from './src/utils/permisson-helpers'
import ErrorBoundary from './src/components/ErrorBoundary'
import OTAUpdateScreen from './src/components/OtaUpdates'
import { usePerformanceMonitor } from './src/hooks/use-performance-monitor'
import { SettingsProvider, useSettingsContext } from './src/providers/Settings'
export const backgroundRuntime = createWorkletRuntime('background')
@@ -27,7 +28,6 @@ export default function App(): React.JSX.Element {
const performanceMetrics = usePerformanceMonitor('App', 3)
const [playerIsReady, setPlayerIsReady] = useState<boolean>(false)
const isDarkMode = useColorScheme() === 'dark'
TrackPlayer.setupPlayer({
autoHandleInterruptions: true,
@@ -65,31 +65,51 @@ export default function App(): React.JSX.Element {
<SafeAreaProvider>
<OTAUpdateScreen />
<ErrorBoundary reloader={reloader} onRetry={handleRetry}>
<NavigationContainer theme={isDarkMode ? JellifyDarkTheme : JellifyLightTheme}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: clientPersister,
/**
* Infinity, since data can remain the
* same forever on the server
*/
maxAge: Infinity,
buster: '0.10.99',
}}
>
<GestureHandlerRootView>
<TamaguiProvider config={jellifyConfig}>
<Theme name={isDarkMode ? 'dark' : 'light'}>
{playerIsReady && <Jellify />}
</Theme>
</TamaguiProvider>
</GestureHandlerRootView>
</PersistQueryClientProvider>
</NavigationContainer>
<SettingsProvider>
<Container playerIsReady={playerIsReady} />
</SettingsProvider>
</ErrorBoundary>
</SafeAreaProvider>
</React.StrictMode>
)
}
function Container({ playerIsReady }: { playerIsReady: boolean }): React.JSX.Element {
const { theme } = useSettingsContext()
const isDarkMode = useColorScheme() === 'dark'
return (
<NavigationContainer
theme={
theme === 'system'
? isDarkMode
? JellifyDarkTheme
: JellifyLightTheme
: theme === 'dark'
? JellifyDarkTheme
: JellifyLightTheme
}
>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: clientPersister,
/**
* Infinity, since data can remain the
* same forever on the server
*/
maxAge: Infinity,
buster: '0.10.99',
}}
>
<GestureHandlerRootView>
<TamaguiProvider config={jellifyConfig}>
{playerIsReady && <Jellify />}
</TamaguiProvider>
</GestureHandlerRootView>
</PersistQueryClientProvider>
</NavigationContainer>
)
}

View File

@@ -1,3 +1,4 @@
appId: com.jellify
---
- runFlow: ../tests/library.yaml
- runFlow: ../tests/settings.yaml

View File

@@ -0,0 +1,17 @@
appId: com.jellify
---
# Wait for app to be ready, then navigate to Settings tab
- assertVisible:
id: "library-tab-button"
# Navigate to Library tab using text
- tapOn:
id: "library-tab-button"
# Verify we're on the library page
- assertVisible:
id: "library-artists-tab-button"
# Navigate to Artists tab
- tapOn:
id: "library-artists-tab-button"

View File

@@ -3,7 +3,6 @@ appId: com.jellify
# Wait for app to be ready, then navigate to Settings tab
- assertVisible:
id: "settings-tab-button"
# timeout: 5000
# Navigate to Settings tab using text
- tapOn:
@@ -12,7 +11,6 @@ appId: com.jellify
# Verify we're on the settings page
- assertVisible:
text: "App"
# timeout: 3000
# Test App (Preferences) Tab - should already be selected
- assertVisible:
@@ -23,6 +21,14 @@ appId: com.jellify
text: "Reduce Haptics"
- assertVisible:
text: "Reduce haptic feedback"
- assertVisible:
text: "Theme"
- assertVisible:
text: "System"
- assertVisible:
text: "Light"
- assertVisible:
text: "Dark"
# Test Player (Playback) Tab
- tapOn:

View File

@@ -1,13 +1,12 @@
import React, { useEffect, useRef } from 'react'
import { getToken, getTokenValue, Separator, XStack, YStack } from 'tamagui'
import { getToken, getTokenValue, Separator, useTheme, XStack, YStack } from 'tamagui'
import { Text } from '../Global/helpers/text'
import { RefreshControl } from 'react-native'
import { ArtistsProps } from '../types'
import ItemRow from '../Global/components/item-row'
import { useLibrarySortAndFilterContext } from '../../providers/Library'
import { useLibraryContext, useLibrarySortAndFilterContext } from '../../providers/Library'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import { FlashList } from '@shopify/flash-list'
import { useLibraryContext } from '../../providers/Library'
import { AZScroller } from '../Global/components/alphabetical-selector'
import { useMutation } from '@tanstack/react-query'
@@ -17,7 +16,7 @@ export default function Artists({
showAlphabeticalSelector,
}: ArtistsProps): React.JSX.Element {
const { artistPageParams } = useLibraryContext()
const theme = useTheme()
const { isFavorites } = useLibrarySortAndFilterContext()
const sectionListRef = useRef<FlashList<string | number | BaseItemDto>>(null)
@@ -32,23 +31,25 @@ export default function Artists({
console.debug(`Alphabetical Selector Callback: ${letter}`)
do {
if (artistPageParams.current.includes(letter)) break
await artistsInfiniteQuery.fetchNextPage({ cancelRefetch: true })
} while (
artistsRef.current.indexOf(letter) === -1 &&
!artistsRef.current.includes(letter) &&
artistsInfiniteQuery.hasNextPage &&
!artistsInfiniteQuery.isFetchNextPageError &&
!artistsInfiniteQuery.isFetchingNextPage
(!artistsInfiniteQuery.isFetchNextPageError || artistsInfiniteQuery.isFetchingNextPage)
)
}
const { mutate: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } = useMutation({
mutationFn: (letter: string) => alphabeticalSelectorCallback(letter),
onSuccess: (data, letter) => {
sectionListRef.current?.scrollToIndex({
index: artistsRef.current!.indexOf(letter),
viewPosition: 0.1,
animated: true,
})
setTimeout(() => {
sectionListRef.current?.scrollToIndex({
index: artistsRef.current!.indexOf(letter),
viewPosition: 0.1,
animated: true,
})
}, 500)
},
})
@@ -82,7 +83,7 @@ export default function Artists({
data={artistsInfiniteQuery.data}
refreshControl={
<RefreshControl
colors={['$primary']}
colors={[theme.primary.val]}
refreshing={artistsInfiniteQuery.isPending || isAlphabetSelectorPending}
progressViewOffset={getTokenValue('$10')}
/>

View File

@@ -44,6 +44,7 @@ export default function Library({
small
/>
),
tabBarButtonTestID: 'library-artists-tab-button',
}}
/>
@@ -58,6 +59,7 @@ export default function Library({
small
/>
),
tabBarButtonTestID: 'library-albums-tab-button',
}}
initialParams={{ navigation }}
/>
@@ -73,6 +75,7 @@ export default function Library({
small
/>
),
tabBarButtonTestID: 'library-tracks-tab-button',
}}
/>
@@ -87,6 +90,7 @@ export default function Library({
small
/>
),
tabBarButtonTestID: 'library-playlists-tab-button',
}}
initialParams={{ navigation }}
/>

View File

@@ -1,14 +1,11 @@
import { SafeAreaView } from 'react-native-safe-area-context'
import SettingsListGroup from './settings-list-group'
import { RadioGroup, YStack } from 'tamagui'
import { RadioGroupItemWithLabel } from '../../Global/helpers/radio-group-item-with-label'
import { Text } from '../../Global/helpers/text'
import { useJellifyContext } from '../../../providers/index'
import { getQualityLabel, getBandwidthEstimate } from '../utils/quality'
import { StreamingQuality, useSettingsContext } from '../../../providers/Settings'
export default function PlaybackTab(): React.JSX.Element {
const { server } = useJellifyContext()
const { streamingQuality, setStreamingQuality } = useSettingsContext()
return (
@@ -21,10 +18,7 @@ export default function PlaybackTab(): React.JSX.Element {
iconColor: getStreamingQualityIconColor(streamingQuality),
children: (
<YStack gap='$2' paddingVertical='$2'>
<Text bold fontSize='$4'>
Streaming Quality:
</Text>
<Text fontSize='$3' color='$gray11' marginBottom='$2'>
<Text fontSize='$3' marginBottom='$2'>
Higher quality uses more bandwidth. Changes apply to new tracks.
</Text>
<RadioGroup

View File

@@ -1,24 +1,32 @@
import { useSettingsContext } from '../../../providers/Settings'
import { RadioGroup, YStack } from 'tamagui'
import { Theme, useSettingsContext } from '../../../providers/Settings'
import { SwitchWithLabel } from '../../Global/helpers/switch-with-label'
import SettingsListGroup from './settings-list-group'
import { RadioGroupItemWithLabel } from '../../Global/helpers/radio-group-item-with-label'
import { Text } from '../../Global/helpers/text'
export default function PreferencesTab(): React.JSX.Element {
const { setSendMetrics, sendMetrics, setReducedHaptics, reducedHaptics } = useSettingsContext()
const { setSendMetrics, sendMetrics, setReducedHaptics, reducedHaptics, theme, setTheme } =
useSettingsContext()
return (
<SettingsListGroup
settingsList={[
{
title: 'Send Metrics and Crash Reports',
iconName: sendMetrics ? 'bug-check' : 'bug',
iconColor: sendMetrics ? '$success' : '$borderColor',
subTitle: 'Send anonymous usage and crash data',
title: 'Theme',
subTitle: `Current: ${theme}`,
iconName: 'theme-light-dark',
iconColor: `${theme === 'system' ? '$borderColor' : '$primary'}`,
children: (
<SwitchWithLabel
checked={sendMetrics}
onCheckedChange={setSendMetrics}
size={'$2'}
label={sendMetrics ? 'Enabled' : 'Disabled'}
/>
<YStack gap='$2' paddingVertical='$2'>
<RadioGroup
value={theme}
onValueChange={(value) => setTheme(value as Theme)}
>
<RadioGroupItemWithLabel size='$3' value='system' label='System' />
<RadioGroupItemWithLabel size='$3' value='light' label='Light' />
<RadioGroupItemWithLabel size='$3' value='dark' label='Dark' />
</RadioGroup>
</YStack>
),
},
{
@@ -35,6 +43,20 @@ export default function PreferencesTab(): React.JSX.Element {
/>
),
},
{
title: 'Send Metrics and Crash Reports',
iconName: sendMetrics ? 'bug-check' : 'bug',
iconColor: sendMetrics ? '$success' : '$borderColor',
subTitle: 'Send anonymous usage and crash data',
children: (
<SwitchWithLabel
checked={sendMetrics}
onCheckedChange={setSendMetrics}
size={'$2'}
label={sendMetrics ? 'Enabled' : 'Disabled'}
/>
),
},
]}
/>
)

View File

@@ -1,21 +1,15 @@
import SettingsListGroup from './settings-list-group'
import { SwitchWithLabel } from '../../Global/helpers/switch-with-label'
import { RadioGroupItemWithLabel } from '../../Global/helpers/radio-group-item-with-label'
import { useSettingsContext, DownloadQuality, StreamingQuality } from '../../../providers/Settings'
import { useSettingsContext, DownloadQuality } from '../../../providers/Settings'
import { useNetworkContext } from '../../../providers/Network'
import { RadioGroup, YStack } from 'tamagui'
import { Text } from '../../Global/helpers/text'
import { getQualityLabel } from '../utils/quality'
export default function StorageTab(): React.JSX.Element {
const {
autoDownload,
setAutoDownload,
downloadQuality,
setDownloadQuality,
streamingQuality,
setStreamingQuality,
} = useSettingsContext()
const { downloadedTracks, storageUsage } = useNetworkContext()
const { autoDownload, setAutoDownload, downloadQuality, setDownloadQuality } =
useSettingsContext()
const { downloadedTracks } = useNetworkContext()
return (
<SettingsListGroup
@@ -49,10 +43,7 @@ export default function StorageTab(): React.JSX.Element {
iconColor: '$primary',
children: (
<YStack gap='$2' paddingVertical='$2'>
<Text bold fontSize='$4'>
Download Quality:
</Text>
<Text fontSize='$3' color='$gray11' marginBottom='$2'>
<Text fontSize='$3' marginBottom='$2'>
Quality used when saving tracks for offline use.
</Text>
<RadioGroup

View File

@@ -16,18 +16,21 @@ import {
import telemetryDeckConfig from '../../telemetrydeck.json'
import glitchtipConfig from '../../glitchtip.json'
import * as Sentry from '@sentry/react-native'
import { useTheme } from 'tamagui'
import { Theme, useTheme } from 'tamagui'
import Toast from 'react-native-toast-message'
import JellifyToastConfig from '../constants/toast.config'
import { useColorScheme } from 'react-native'
/**
* The main component for the Jellify app. Children are wrapped in the {@link JellifyProvider}
* @returns The {@link Jellify} component
*/
export default function Jellify(): React.JSX.Element {
const theme = useTheme()
const { theme } = useSettingsContext()
const isDarkMode = useColorScheme() === 'dark'
return (
<SettingsProvider>
<Theme name={theme === 'system' ? (isDarkMode ? 'dark' : 'light') : theme}>
<JellifyLoggingWrapper>
<DisplayProvider>
<JellifyProvider>
@@ -35,8 +38,7 @@ export default function Jellify(): React.JSX.Element {
</JellifyProvider>
</DisplayProvider>
</JellifyLoggingWrapper>
<Toast config={JellifyToastConfig(theme)} />
</SettingsProvider>
</Theme>
)
}
@@ -67,6 +69,7 @@ function JellifyLoggingWrapper({ children }: { children: React.ReactNode }): Rea
function App(): React.JSX.Element {
const { sendMetrics } = useSettingsContext()
const telemetrydeck = useTelemetryDeck()
const theme = useTheme()
useEffect(() => {
if (sendMetrics) {
@@ -83,6 +86,7 @@ function App(): React.JSX.Element {
</PlayerProvider>
</QueueProvider>
</NetworkContextProvider>
<Toast config={JellifyToastConfig(theme)} />
</JellifyUserDataProvider>
)
}

View File

@@ -23,4 +23,5 @@ export enum MMKVStorageKeys {
Shuffled = 'Shuffled',
RepeatMode = 'RepeatMode',
ReducedHaptics = 'ReducedHaptics',
Theme = 'Theme',
}

View File

@@ -5,6 +5,7 @@ import { createContext, useContext, useEffect, useState, useMemo } from 'react'
export type DownloadQuality = 'original' | 'high' | 'medium' | 'low'
export type StreamingQuality = 'original' | 'high' | 'medium' | 'low'
export type Theme = 'system' | 'light' | 'dark'
interface SettingsContext {
sendMetrics: boolean
@@ -19,6 +20,8 @@ interface SettingsContext {
setStreamingQuality: React.Dispatch<React.SetStateAction<StreamingQuality>>
reducedHaptics: boolean
setReducedHaptics: React.Dispatch<React.SetStateAction<boolean>>
theme: Theme
setTheme: React.Dispatch<React.SetStateAction<Theme>>
}
/**
@@ -39,6 +42,7 @@ const SettingsContextInitializer = () => {
const autoDownloadInit = storage.getBoolean(MMKVStorageKeys.AutoDownload)
const devToolsInit = storage.getBoolean(MMKVStorageKeys.DevTools)
const reducedHapticsInit = storage.getBoolean(MMKVStorageKeys.ReducedHaptics)
const themeInit = storage.getString(MMKVStorageKeys.Theme) as Theme
const downloadQualityInit = storage.getString(
MMKVStorageKeys.DownloadQuality,
@@ -67,6 +71,8 @@ const SettingsContextInitializer = () => {
reducedHapticsInit ?? (Platform.OS !== 'ios' && Math.random() > 0.7),
)
const [theme, setTheme] = useState<Theme>(themeInit ?? 'system')
useEffect(() => {
storage.set(MMKVStorageKeys.SendMetrics, sendMetrics)
}, [sendMetrics])
@@ -91,6 +97,10 @@ const SettingsContextInitializer = () => {
storage.set(MMKVStorageKeys.ReducedHaptics, reducedHaptics)
}, [reducedHaptics])
useEffect(() => {
storage.set(MMKVStorageKeys.Theme, theme)
}, [theme])
return {
sendMetrics,
setSendMetrics,
@@ -104,6 +114,8 @@ const SettingsContextInitializer = () => {
setStreamingQuality,
reducedHaptics,
setReducedHaptics,
theme,
setTheme,
}
}
@@ -120,6 +132,8 @@ export const SettingsContext = createContext<SettingsContext>({
setStreamingQuality: () => {},
reducedHaptics: false,
setReducedHaptics: () => {},
theme: 'system',
setTheme: () => {},
})
export const SettingsProvider = ({ children }: { children: React.ReactNode }) => {
@@ -135,6 +149,7 @@ export const SettingsProvider = ({ children }: { children: React.ReactNode }) =>
context.downloadQuality,
context.streamingQuality,
context.reducedHaptics,
context.theme,
],
)

View File

@@ -1,8 +1,8 @@
import _, { isUndefined } from 'lodash'
import ServerAuthentication from '../../components/Login/screens/server-authentication'
import ServerAddress from '../../components/Login/screens/server-address'
import ServerAuthentication from './server-authentication'
import ServerAddress from './server-address'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import ServerLibrary from '../../components/Login/screens/server-library'
import ServerLibrary from './server-library'
import { useJellifyContext } from '../../providers'
const LoginStack = createNativeStackNavigator()

View File

@@ -1,23 +1,23 @@
import React, { useEffect, useState } from 'react'
import _, { isUndefined } from 'lodash'
import { useMutation } from '@tanstack/react-query'
import { JellifyServer } from '../../../types/JellifyServer'
import { JellifyServer } from '../../types/JellifyServer'
import { Input, ListItem, Separator, Spinner, XStack, YGroup, YStack } from 'tamagui'
import { SwitchWithLabel } from '../../Global/helpers/switch-with-label'
import { H2, Text } from '../../Global/helpers/text'
import Button from '../../Global/helpers/button'
import { http, https } from '../utils/constants'
import { SwitchWithLabel } from '../../components/Global/helpers/switch-with-label'
import { H2, Text } from '../../components/Global/helpers/text'
import Button from '../../components/Global/helpers/button'
import { http, https } from '../../components/Login/utils/constants'
import { SafeAreaView } from 'react-native-safe-area-context'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { StackParamList } from '../../types'
import { StackParamList } from '../../components/types'
import Toast from 'react-native-toast-message'
import { useJellifyContext } from '../../../providers'
import { useSettingsContext } from '../../../providers/Settings'
import Icon from '../../Global/components/icon'
import { useJellifyContext } from '../../providers'
import { useSettingsContext } from '../../providers/Settings'
import Icon from '../../components/Global/components/icon'
import { PublicSystemInfo } from '@jellyfin/sdk/lib/generated-client/models'
import { connectToServer } from '../../../api/mutations/login'
import { IS_MAESTRO_BUILD } from '../../../configs/config'
import { sleepify } from '../../../utils/sleep'
import { connectToServer } from '../../api/mutations/login'
import { IS_MAESTRO_BUILD } from '../../configs/config'
import { sleepify } from '../../utils/sleep'
export default function ServerAddress({
navigation,
@@ -44,7 +44,7 @@ export default function ServerAddress({
}, [serverAddress])
useEffect(() => {
sleepify(250).then(() => signOut())
sleepify(500).then(() => signOut())
}, [])
const useServerMutation = useMutation({

View File

@@ -1,19 +1,19 @@
import React, { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import _ from 'lodash'
import { JellyfinCredentials } from '../../../api/types/jellyfin-credentials'
import { JellyfinCredentials } from '../../api/types/jellyfin-credentials'
import { getToken, H6, Spacer, Spinner, XStack, YStack } from 'tamagui'
import { H2, H5, Text } from '../../Global/helpers/text'
import Button from '../../Global/helpers/button'
import { H2, H5, Text } from '../../components/Global/helpers/text'
import Button from '../../components/Global/helpers/button'
import { SafeAreaView } from 'react-native-safe-area-context'
import { JellifyUser } from '../../../types/JellifyUser'
import { StackParamList } from '../../types'
import Input from '../../../components/Global/helpers/input'
import Icon from '../../Global/components/icon'
import { useJellifyContext } from '../../../providers'
import { JellifyUser } from '../../types/JellifyUser'
import { StackParamList } from '../../components/types'
import Input from '../../components/Global/helpers/input'
import Icon from '../../components/Global/components/icon'
import { useJellifyContext } from '../../providers'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import Toast from 'react-native-toast-message'
import { IS_MAESTRO_BUILD } from '../../../configs/config'
import { IS_MAESTRO_BUILD } from '../../configs/config'
export default function ServerAuthentication({
navigation,

View File

@@ -1,9 +1,9 @@
import React from 'react'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { StackParamList } from '../../../components/types'
import { useJellifyContext } from '../../../providers'
import { StackParamList } from '../../components/types'
import { useJellifyContext } from '../../providers'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import LibrarySelector from '../../Global/components/library-selector'
import LibrarySelector from '../../components/Global/components/library-selector'
export default function ServerLibrary({
navigation,

View File

@@ -38,13 +38,13 @@ export default function SignOutModal({ navigation }: SignOutModalProps): React.J
color={'$danger'}
borderColor={'$danger'}
onPress={() => {
clearDownloads()
navigation.goBack()
navigation.navigate('Login', {
screen: 'ServerAddress',
})
TrackPlayer.reset()
clearDownloads()
resetQueue()
}}
>
<Text bold color={'$danger'}>