Settings refactor to zustand + MMKV (#495)

*Mostly* backend work in the settings tab

Migrate settings context over to MMKV backed Zustand store. Update hooks as needed

Update info tab styling - making the patreon patrons list it's own section. A perfect time to remind everyone you can sponsor Jellify on GitHub or Patreon :)

Joining the patreon as a paid member will get your name displayed in the info area of the settings tab
This commit is contained in:
Violet Caulfield
2025-08-28 22:24:53 -05:00
committed by GitHub
parent bb895a580e
commit 74efea0ebc
46 changed files with 487 additions and 648 deletions

12
App.tsx
View File

@@ -6,7 +6,7 @@ import Jellify from './src/components/jellify'
import { TamaguiProvider } from 'tamagui'
import { Platform, useColorScheme } from 'react-native'
import jellifyConfig from './tamagui.config'
import { clientPersister } from './src/constants/storage'
import { queryClientPersister } from './src/constants/storage'
import { ONE_DAY, queryClient } from './src/constants/query-client'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import TrackPlayer, {
@@ -22,9 +22,9 @@ 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, useThemeSettingContext } from './src/providers/Settings'
import navigationRef from './navigation'
import { PROGRESS_UPDATE_EVENT_INTERVAL } from './src/player/config'
import { useThemeSetting } from './src/stores/settings/app'
export default function App(): React.JSX.Element {
// Add performance monitoring to track app-level re-renders
@@ -80,7 +80,7 @@ export default function App(): React.JSX.Element {
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: clientPersister,
persister: queryClientPersister,
/**
* Maximum query data age of one day
@@ -88,9 +88,7 @@ export default function App(): React.JSX.Element {
maxAge: Infinity,
}}
>
<SettingsProvider>
<Container playerIsReady={playerIsReady} />
</SettingsProvider>
<Container playerIsReady={playerIsReady} />
</PersistQueryClientProvider>
</ErrorBoundary>
</SafeAreaProvider>
@@ -99,7 +97,7 @@ export default function App(): React.JSX.Element {
}
function Container({ playerIsReady }: { playerIsReady: boolean }): React.JSX.Element {
const theme = useThemeSettingContext()
const [theme] = useThemeSetting()
const isDarkMode = useColorScheme() === 'dark'

View File

@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../../enums/query-keys'
import { useJellifyContext } from '../../../providers'
import fetchPatrons from './utils'
import { ONE_DAY } from '../../../constants/query-client'
const usePatronsQuery = () => {
const { api } = useJellifyContext()
return useQuery({
queryKey: [QueryKeys.Patrons],
queryFn: () => fetchPatrons(api),
staleTime: ONE_DAY,
})
}
const usePatrons = () => usePatronsQuery().data
export default usePatrons

View File

@@ -1,5 +1,7 @@
import { Api } from '@jellyfin/sdk'
const PATRON_API_ENDPOINT = 'https://patrons.jellify.app'
interface Patron {
fullName: string
}
@@ -9,7 +11,7 @@ export default async function fetchPatrons(api: Api | undefined): Promise<Patron
if (!api) return reject(new Error('No API instance provided'))
api.axiosInstance
.get('https://patrons.jellify.app')
.get(PATRON_API_ENDPOINT)
.then((res) => {
const patrons = res.data as Patron[]
resolve(patrons)

View File

@@ -15,7 +15,6 @@ import { useSafeAreaFrame } from 'react-native-safe-area-context'
import Icon from '../Global/components/icon'
import { mapDtoToTrack } from '../../utils/mappings'
import { useNetworkContext } from '../../providers/Network'
import { useDownloadQualityContext } from '../../providers/Settings'
import { useLoadNewQueue } from '../../providers/Player/hooks/mutations'
import { QueuingType } from '../../enums/queuing-type'
import { useAlbumContext } from '../../providers/Album'
@@ -42,7 +41,6 @@ export function Album(): React.JSX.Element {
const { api } = useJellifyContext()
const { useDownloadMultiple, pendingDownloads, networkStatus } = useNetworkContext()
const downloadQuality = useDownloadQualityContext()
const streamingDeviceProfile = useStreamingDeviceProfile()
const downloadingDeviceProfile = useDownloadingDeviceProfile()
const { mutate: loadNewQueue } = useLoadNewQueue()
@@ -66,10 +64,8 @@ export function Album(): React.JSX.Element {
loadNewQueue({
api,
downloadedTracks,
networkStatus,
deviceProfile: streamingDeviceProfile,
downloadQuality,
track: allTracks[0],
index: 0,
tracklist: allTracks,

View File

@@ -17,10 +17,8 @@ import { QueuingType } from '../../enums/queuing-type'
import { fetchAlbumDiscs } from '../../api/queries/item'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { BaseStackParamList } from '../../screens/types'
import { useDownloadQualityContext } from '../../providers/Settings'
import { useNetworkContext } from '../../providers/Network'
import useStreamingDeviceProfile from '../../stores/device-profile'
import { useAllDownloadedTracks } from '../../api/queries/download'
export default function ArtistTabBar({
stackNavigation,
@@ -35,12 +33,8 @@ export default function ArtistTabBar({
const deviceProfile = useStreamingDeviceProfile()
const downloadQuality = useDownloadQualityContext()
const { networkStatus } = useNetworkContext()
const { data: downloadedTracks } = useAllDownloadedTracks()
const { width } = useSafeAreaFrame()
const theme = useTheme()
@@ -62,10 +56,8 @@ export default function ArtistTabBar({
loadNewQueue({
api,
downloadedTracks,
networkStatus,
deviceProfile,
downloadQuality,
track: allTracks[0],
index: 0,
tracklist: allTracks,

View File

@@ -9,18 +9,14 @@ import { InfiniteData } from '@tanstack/react-query'
import { QueueMutation } from '../../providers/Player/interfaces'
import { JellifyLibrary } from '../../types/JellifyLibrary'
import { Api } from '@jellyfin/sdk'
import { JellifyDownload } from '../../types/JellifyDownload'
import { networkStatusTypes } from '../Network/internetConnectionWatcher'
import { DownloadQuality } from '../../providers/Settings'
const CarPlayHome = (
library: JellifyLibrary,
loadQueue: (mutation: QueueMutation) => void,
api: Api | undefined,
downloadedTracks: JellifyDownload[] | undefined,
networkStatus: networkStatusTypes | null,
deviceProfile: DeviceProfile | undefined,
downloadQuality: DownloadQuality,
) =>
new ListTemplate({
id: uuid.v4(),
@@ -69,10 +65,8 @@ const CarPlayHome = (
loadQueue,
'Recently Played',
api,
downloadedTracks,
networkStatus,
deviceProfile,
downloadQuality,
),
)
break
@@ -100,10 +94,8 @@ const CarPlayHome = (
loadQueue,
'On Repeat',
api,
downloadedTracks,
networkStatus,
deviceProfile,
downloadQuality,
),
)
break

View File

@@ -5,33 +5,21 @@ import uuid from 'react-native-uuid'
import { QueueMutation } from '../../providers/Player/interfaces'
import { JellifyLibrary } from '../../types/JellifyLibrary'
import { Api } from '@jellyfin/sdk'
import { JellifyDownload } from '@/src/types/JellifyDownload'
import { networkStatusTypes } from '../Network/internetConnectionWatcher'
import { DownloadQuality } from '../../providers/Settings'
import { DeviceProfile } from '@jellyfin/sdk/lib/generated-client'
const CarPlayNavigation = (
library: JellifyLibrary,
loadQueue: (mutation: QueueMutation) => void,
api: Api | undefined,
downloadedTracks: JellifyDownload[] | undefined,
networkStatus: networkStatusTypes | null,
deviceProfile: DeviceProfile | undefined,
downloadQuality: DownloadQuality,
) =>
new TabBarTemplate({
id: uuid.v4(),
title: 'Tabs',
templates: [
CarPlayHome(
library,
loadQueue,
api,
downloadedTracks,
networkStatus,
deviceProfile,
downloadQuality,
),
CarPlayHome(library, loadQueue, api, networkStatus, deviceProfile),
CarPlayDiscover,
],
onTemplateSelect(template, e) {},

View File

@@ -6,19 +6,15 @@ import { Queue } from '../../player/types/queue-item'
import { QueueMutation } from '../../providers/Player/interfaces'
import { QueuingType } from '../../enums/queuing-type'
import { Api } from '@jellyfin/sdk'
import { JellifyDownload } from '../../types/JellifyDownload'
import { networkStatusTypes } from '../Network/internetConnectionWatcher'
import { DownloadQuality } from '../../providers/Settings'
const TracksTemplate = (
items: BaseItemDto[],
loadQueue: (mutation: QueueMutation) => void,
queuingRef: Queue,
api: Api | undefined,
downloadedTracks: JellifyDownload[] | undefined,
networkStatus: networkStatusTypes | null,
deviceProfile: DeviceProfile | undefined,
downloadQuality: DownloadQuality,
) =>
new ListTemplate({
id: uuid.v4(),
@@ -38,8 +34,6 @@ const TracksTemplate = (
api,
networkStatus,
deviceProfile,
downloadQuality,
downloadedTracks,
queuingType: QueuingType.FromSelection,
index,
tracklist: items,

View File

@@ -3,13 +3,10 @@ import {
BaseItemKind,
MediaSourceInfo,
} from '@jellyfin/sdk/lib/generated-client/models'
import { getToken, ListItem, ScrollView, Spinner, View, YGroup } from 'tamagui'
import { ListItem, ScrollView, Spinner, View, YGroup } from 'tamagui'
import { BaseStackParamList, RootStackParamList } from '../../screens/types'
import { Text } from '../Global/helpers/text'
import FavoriteContextMenuRow from '../Global/components/favorite-context-menu-row'
import { useColorScheme } from 'react-native'
import { useDownloadQualityContext, useThemeSettingContext } from '../../providers/Settings'
import LinearGradient from 'react-native-linear-gradient'
import Icon from '../Global/components/icon'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useQuery } from '@tanstack/react-query'
@@ -33,7 +30,7 @@ import { useAddToQueue } from '../../providers/Player/hooks/mutations'
import { useNetworkContext } from '../../providers/Network'
import { mapDtoToTrack } from '../../utils/mappings'
import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../../stores/device-profile'
import { useAllDownloadedTracks, useIsDownloaded } from '../../api/queries/download'
import { useIsDownloaded } from '../../api/queries/download'
import { useDeleteDownloads } from '../../api/mutations/download'
type StackNavigation = Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'>
@@ -169,10 +166,6 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele
const { networkStatus } = useNetworkContext()
const { data: downloadedTracks } = useAllDownloadedTracks()
const downloadQuality = useDownloadQualityContext()
const deviceProfile = useStreamingDeviceProfile()
const { mutate: addToQueue } = useAddToQueue()
@@ -180,9 +173,7 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele
const mutation: AddToQueueMutation = {
api,
networkStatus,
downloadedTracks,
deviceProfile,
downloadQuality,
tracks,
queuingType: QueuingType.DirectlyQueued,
}
@@ -206,21 +197,6 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele
)
}
function BackgroundGradient(): React.JSX.Element {
const themeSetting = useThemeSettingContext()
const colorScheme = useColorScheme()
const isDarkMode =
(themeSetting === 'system' && colorScheme === 'dark') || themeSetting === 'dark'
const gradientColors = isDarkMode
? [getToken('$black'), getToken('$black75')]
: [getToken('$lightTranslucent'), getToken('$lightTranslucent')]
return <LinearGradient style={{ flex: 1 }} colors={gradientColors} />
}
function DownloadMenuRow({ items }: { items: BaseItemDto[] }): React.JSX.Element {
const { api } = useJellifyContext()
const { useDownloadMultiple, pendingDownloads } = useNetworkContext()

View File

@@ -11,7 +11,7 @@ import Animated, {
import { Text } from '../helpers/text'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import { trigger } from 'react-native-haptic-feedback'
import { useReducedHapticsContext } from '../../../providers/Settings'
import { useReducedHapticsSetting } from '../../../stores/settings/app'
const alphabet = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
/**
@@ -27,7 +27,7 @@ const alphabet = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
export function AZScroller({ onLetterSelect }: { onLetterSelect: (letter: string) => void }) {
const { width, height } = useSafeAreaFrame()
const theme = useTheme()
const reducedHaptics = useReducedHapticsContext()
const [reducedHaptics] = useReducedHapticsSetting()
const overlayOpacity = useSharedValue(0)

View File

@@ -14,9 +14,7 @@ import { BaseStackParamList } from '../../../screens/types'
import { useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
import { useJellifyContext } from '../../../providers'
import { useNetworkContext } from '../../../providers/Network'
import { useDownloadQualityContext } from '../../../providers/Settings'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import { useAllDownloadedTracks } from '../../../api/queries/download'
interface ItemRowProps {
item: BaseItemDto
@@ -47,12 +45,8 @@ export default function ItemRow({
const { networkStatus } = useNetworkContext()
const { data: downloadedTracks } = useAllDownloadedTracks()
const deviceProfile = useStreamingDeviceProfile()
const downloadQuality = useDownloadQualityContext()
const { mutate: loadNewQueue } = useLoadNewQueue()
const gestureCallback = () => {
@@ -60,10 +54,8 @@ export default function ItemRow({
case 'Audio': {
loadNewQueue({
api,
downloadedTracks,
networkStatus,
deviceProfile,
downloadQuality,
track: item,
tracklist: [item],
index: 0,

View File

@@ -17,11 +17,10 @@ import ItemImage from './image'
import useItemContext from '../../../hooks/use-item-context'
import { useNowPlaying, useQueue } from '../../../providers/Player/hooks/queries'
import { useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
import { useDownloadQualityContext } from '../../../providers/Settings'
import { useJellifyContext } from '../../../providers'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import useStreamedMediaInfo from '../../../api/queries/media'
import { useAllDownloadedTracks, useDownloadedTrack } from '../../../api/queries/download'
import { useDownloadedTrack } from '../../../api/queries/download'
export interface TrackProps {
track: BaseItemDto
@@ -61,8 +60,6 @@ export default function Track({
const deviceProfile = useStreamingDeviceProfile()
const downloadQuality = useDownloadQualityContext()
const { data: nowPlaying } = useNowPlaying()
const { data: playQueue } = useQueue()
const { mutate: loadNewQueue } = useLoadNewQueue()
@@ -70,8 +67,6 @@ export default function Track({
const { data: mediaInfo } = useStreamedMediaInfo(track.Id)
const { data: downloadedTracks } = useAllDownloadedTracks()
const offlineAudio = useDownloadedTrack(track.Id)
useItemContext(track)
@@ -100,9 +95,7 @@ export default function Track({
} else {
loadNewQueue({
api,
downloadedTracks,
deviceProfile,
downloadQuality,
networkStatus,
track,
index,
@@ -112,7 +105,7 @@ export default function Track({
startPlayback: true,
})
}
}, [onPress, track, index, memoizedTracklist, queue, useLoadNewQueue, downloadedTracks])
}, [onPress, track, index, memoizedTracklist, queue, useLoadNewQueue])
const handleLongPress = useCallback(() => {
if (onLongPress) {

View File

@@ -12,22 +12,16 @@ import HomeStackParamList from '../../../screens/Home/types'
import { useNavigation } from '@react-navigation/native'
import { RootStackParamList } from '../../../screens/types'
import { useJellifyContext } from '../../../providers'
import { useDownloadQualityContext } from '../../../providers/Settings'
import { useNetworkContext } from '../../../providers/Network'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import { useAllDownloadedTracks } from '../../../api/queries/download'
export default function FrequentlyPlayedTracks(): React.JSX.Element {
const { api } = useJellifyContext()
const { networkStatus } = useNetworkContext()
const { data: downloadedTracks } = useAllDownloadedTracks()
const deviceProfile = useStreamingDeviceProfile()
const downloadQuality = useDownloadQualityContext()
const {
frequentlyPlayed,
fetchNextFrequentlyPlayed,
@@ -76,8 +70,6 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
loadNewQueue({
api,
deviceProfile,
downloadQuality,
downloadedTracks,
networkStatus,
track,
index,

View File

@@ -15,21 +15,15 @@ import HomeStackParamList from '../../../screens/Home/types'
import { useNowPlaying } from '../../../providers/Player/hooks/queries'
import { useJellifyContext } from '../../../providers'
import { useNetworkContext } from '../../../providers/Network'
import { useDownloadQualityContext } from '../../../providers/Settings'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import { useAllDownloadedTracks } from '../../../api/queries/download'
export default function RecentlyPlayed(): React.JSX.Element {
const { api } = useJellifyContext()
const { networkStatus } = useNetworkContext()
const { data: downloadedTracks } = useAllDownloadedTracks()
const deviceProfile = useStreamingDeviceProfile()
const downloadQuality = useDownloadQualityContext()
const { data: nowPlaying } = useNowPlaying()
const navigation = useNavigation<NativeStackNavigationProp<HomeStackParamList>>()
@@ -76,10 +70,8 @@ export default function RecentlyPlayed(): React.JSX.Element {
onPress={() => {
loadNewQueue({
api,
downloadedTracks,
deviceProfile,
networkStatus,
downloadQuality,
track: recentlyPlayedTrack,
index: index,
tracklist: recentTracks ?? [recentlyPlayedTrack],

View File

@@ -7,13 +7,13 @@ import { Text } from '../Global/helpers/text'
import { isUndefined } from 'lodash'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { trigger } from 'react-native-haptic-feedback'
import { useReducedHapticsContext } from '../../providers/Settings'
import { useReducedHapticsSetting } from '../../stores/settings/app'
function LibraryTabBar(props: MaterialTopTabBarProps) {
const { isFavorites, setIsFavorites, isDownloaded, setIsDownloaded } =
useLibrarySortAndFilterContext()
const reducedHaptics = useReducedHapticsContext()
const [reducedHaptics] = useReducedHapticsSetting()
const insets = useSafeAreaInsets()

View File

@@ -2,11 +2,11 @@ import React, { memo } from 'react'
import { getToken, useTheme, View, YStack, ZStack } from 'tamagui'
import { useColorScheme } from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import { useThemeSettingContext } from '../../../providers/Settings'
import { getPrimaryBlurhashFromDto } from '../../../utils/blurhash'
import { Blurhash } from 'react-native-blurhash'
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
import { useNowPlaying } from '../../../providers/Player/hooks/queries'
import { useThemeSetting } from '../../../stores/settings/app'
function BlurredBackground({
width,
@@ -17,7 +17,7 @@ function BlurredBackground({
}): React.JSX.Element {
const { data: nowPlaying } = useNowPlaying()
const themeSetting = useThemeSettingContext()
const [themeSetting] = useThemeSetting()
const theme = useTheme()
const colorScheme = useColorScheme()

View File

@@ -8,10 +8,10 @@ import { useSeekTo } from '../../../providers/Player/hooks/mutations'
import { RunTimeSeconds } from '../../../components/Global/helpers/time-codes'
import { UPDATE_INTERVAL } from '../../../player/config'
import { ProgressMultiplier } from '../component.config'
import { useReducedHapticsContext } from '../../../providers/Settings'
import { useNowPlaying, useProgress } from '../../../providers/Player/hooks/queries'
import QualityBadge from './quality-badge'
import { useDisplayAudioQualityBadge } from '../../../stores/player-settings'
import { useDisplayAudioQualityBadge } from '../../../stores/settings/player'
import { useReducedHapticsSetting } from '../../../stores/settings/app'
// Create a simple pan gesture
const scrubGesture = Gesture.Pan().runOnJS(true)
@@ -20,7 +20,7 @@ export default function Scrubber(): React.JSX.Element {
const { mutate: seekTo, isPending: seekPending, mutateAsync: seekToAsync } = useSeekTo()
const { data: nowPlaying } = useNowPlaying()
const { width } = useSafeAreaFrame()
const reducedHaptics = useReducedHapticsContext()
const reducedHaptics = useReducedHapticsSetting()
// Get progress from the track player with the specified update interval
// We *don't* use the duration from this hook because it will have a value of "0"

View File

@@ -15,14 +15,12 @@ import { useNetworkContext } from '../../../../src/providers/Network'
import { ActivityIndicator } from 'react-native'
import { mapDtoToTrack } from '../../../utils/mappings'
import { QueuingType } from '../../../enums/queuing-type'
import { useDownloadQualityContext } from '../../../providers/Settings'
import { useNavigation } from '@react-navigation/native'
import LibraryStackParamList from '@/src/screens/Library/types'
import { useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
import useStreamingDeviceProfile, {
useDownloadingDeviceProfile,
} from '../../../stores/device-profile'
import { useAllDownloadedTracks } from '../../../api/queries/download'
export default function PlayliistTracklistHeader(
playlist: BaseItemDto,
@@ -152,7 +150,6 @@ function PlaylistHeaderControls({
canEdit: boolean | undefined
}): React.JSX.Element {
const { useDownloadMultiple, pendingDownloads } = useNetworkContext()
const downloadQuality = useDownloadQualityContext()
const streamingDeviceProfile = useStreamingDeviceProfile()
const downloadingDeviceProfile = useDownloadingDeviceProfile()
const { mutate: loadNewQueue } = useLoadNewQueue()
@@ -161,8 +158,6 @@ function PlaylistHeaderControls({
const { networkStatus } = useNetworkContext()
const { data: downloadedTracks } = useAllDownloadedTracks()
const navigation = useNavigation<NativeStackNavigationProp<LibraryStackParamList>>()
const downloadPlaylist = () => {
@@ -178,9 +173,7 @@ function PlaylistHeaderControls({
loadNewQueue({
api,
downloadQuality,
networkStatus,
downloadedTracks,
deviceProfile: streamingDeviceProfile,
track: playlistTracks[0],
index: 0,

View File

@@ -3,21 +3,17 @@ import { createMaterialTopTabNavigator } from '@react-navigation/material-top-ta
import { getToken, useTheme } from 'tamagui'
import AccountTab from './components/account-tab'
import Icon from '../Global/components/icon'
import LabsTab from './components/labs-tab'
import PreferencesTab from './components/preferences-tab'
import PlaybackTab from './components/playback-tab'
import InfoTab from './components/info-tab'
import SettingsTabBar from './components/tab-bar'
import StorageTab from './components/storage-tab'
import { useDevToolsContext } from '../../providers/Settings'
import StorageTab from './components/usage-tab'
import { SafeAreaView } from 'react-native-safe-area-context'
const SettingsTabsNavigator = createMaterialTopTabNavigator()
export default function Settings(): React.JSX.Element {
const theme = useTheme()
const devTools = useDevToolsContext()
return (
<SafeAreaView style={{ flex: 1 }} edges={['top']}>
<SettingsTabsNavigator.Navigator
@@ -108,7 +104,7 @@ export default function Settings(): React.JSX.Element {
),
}}
/>
{devTools && (
{/*
<SettingsTabsNavigator.Screen
name='Labs'
component={LabsTab}
@@ -122,7 +118,7 @@ export default function Settings(): React.JSX.Element {
),
}}
/>
)}
) */}
</SettingsTabsNavigator.Navigator>
</SafeAreaView>
)

View File

@@ -1,6 +1,5 @@
import React from 'react'
import { useJellifyContext } from '../../../providers'
import { SafeAreaView } from 'react-native-safe-area-context'
import SignOut from './sign-out-button'
import { SettingsStackParamList } from '../../../screens/Settings/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'

View File

@@ -1,42 +1,33 @@
import { SafeAreaView } from 'react-native-safe-area-context'
import { version } from '../../../../../package.json'
import { H5, Text } from '../../../Global/helpers/text'
import { Text } from '../../../Global/helpers/text'
import SettingsListGroup from '../settings-list-group'
import { InfoTabNativeStackNavigationProp } from './types'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../../../enums/query-keys'
import { useJellifyContext } from '../../../../providers'
import fetchPatrons from '../../../../api/queries/patrons'
import { FlatList, Linking } from 'react-native'
import { H6, ScrollView, Separator, XStack, YStack } from 'tamagui'
import { ScrollView, Separator, XStack, YStack } from 'tamagui'
import Icon from '../../../Global/components/icon'
import { useEffect, useState } from 'react'
import { useSetDevToolsContext } from '../../../../providers/Settings'
export default function InfoTabIndex({ navigation }: InfoTabNativeStackNavigationProp) {
const { api } = useJellifyContext()
import usePatrons from '../../../../api/queries/patrons'
import { useQuery } from '@tanstack/react-query'
import INFO_CAPTIONS from '../../utils/info-caption'
import { ONE_HOUR } from '../../../../constants/query-client'
import { pickRandomItemFromArray } from '../../../../utils/random'
import { FlashList } from '@shopify/flash-list'
const setDevTools = useSetDevToolsContext()
export default function InfoTabIndex() {
const patrons = usePatrons()
const [versionNumberPresses, setVersionNumberPresses] = useState(0)
const { data: patrons } = useQuery({
queryKey: [QueryKeys.Patrons],
queryFn: () => fetchPatrons(api),
const { data: caption } = useQuery({
queryKey: ['Info_Caption'],
queryFn: () => `${pickRandomItemFromArray(INFO_CAPTIONS)}!`,
staleTime: ONE_HOUR,
initialData: 'Live and in stereo',
})
useEffect(() => {
if (versionNumberPresses > 5) {
setDevTools(true)
}
}, [versionNumberPresses])
return (
<ScrollView contentInsetAdjustmentBehavior='automatic'>
<SettingsListGroup
settingsList={[
{
title: `Jellify ${version}`,
subTitle: 'Made with love',
subTitle: caption,
iconName: 'jellyfish-outline',
iconColor: '$secondary',
children: (
@@ -83,50 +74,42 @@ export default function InfoTabIndex({ navigation }: InfoTabNativeStackNavigatio
},
{
title: 'Powered by listeners like you',
subTitle: 'Sponsor Jellify on GitHub or Patreon',
subTitle: 'Sponsor on GitHub or Patreon',
iconName: 'heart',
iconColor: '$primary',
children: (
<FlatList
<XStack justifyContent='flex-start' gap={'$4'} marginVertical={'$2'}>
<XStack
alignItems='center'
onPress={() =>
Linking.openURL(
'https://github.com/sponsors/anultravioletaurora/',
)
}
>
<Icon name='github' small color='$borderColor' />
<Text>Sponsors</Text>
</XStack>
<XStack
alignItems='center'
onPress={() =>
Linking.openURL('https://patreon.com/anultravioletaurora')
}
>
<Icon name='patreon' small color='$borderColor' />
<Text>Patreon</Text>
</XStack>
</XStack>
),
},
{
title: 'Patreon Wall of Fame',
subTitle: 'Thank you to these paid members',
iconName: 'patreon',
iconColor: '$primary',
children: (
<FlashList
data={patrons}
ListHeaderComponent={
<YStack>
<XStack
justifyContent='flex-start'
gap={'$4'}
marginVertical={'$2'}
>
<XStack
alignItems='center'
gap={'$2'}
onPress={() =>
Linking.openURL(
'https://github.com/sponsors/anultravioletaurora/',
)
}
>
<Icon name='github' small color='$borderColor' />
<Text>Sponsors</Text>
</XStack>
<XStack
alignItems='center'
gap={'$2'}
onPress={() =>
Linking.openURL(
'https://patreon.com/anultravioletaurora',
)
}
>
<Icon name='patreon' small color='$borderColor' />
<Text>Patreon</Text>
</XStack>
</XStack>
<Separator marginBottom={'$3'} />
<Text fontSize={'$5'}>Patreon Wall of Fame</Text>
</YStack>
}
numColumns={1}
renderItem={({ item }) => (
<XStack alignItems='flex-start' maxWidth={'$20'}>

View File

@@ -1,20 +1,15 @@
import SettingsListGroup from './settings-list-group'
import { RadioGroup, YStack } from 'tamagui'
import { RadioGroup } from 'tamagui'
import { RadioGroupItemWithLabel } from '../../Global/helpers/radio-group-item-with-label'
import { Text } from '../../Global/helpers/text'
import {
StreamingQuality,
useSetStreamingQualityContext,
useStreamingQualityContext,
} from '../../../providers/Settings'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import { useDisplayAudioQualityBadge } from '../../../stores/player-settings'
useDisplayAudioQualityBadge,
useStreamingQuality,
} from '../../../stores/settings/player'
import { SwitchWithLabel } from '../../Global/helpers/switch-with-label'
export default function PlaybackTab(): React.JSX.Element {
const deviceProfile = useStreamingDeviceProfile()
const streamingQuality = useStreamingQualityContext()
const setStreamingQuality = useSetStreamingQualityContext()
const [streamingQuality, setStreamingQuality] = useStreamingQuality()
const [displayAudioQualityBadge, setDisplayAudioQualityBadge] = useDisplayAudioQualityBadge()
@@ -23,49 +18,45 @@ export default function PlaybackTab(): React.JSX.Element {
settingsList={[
{
title: 'Streaming Quality',
subTitle: `${deviceProfile?.Name ?? 'Not set'}`,
subTitle: `Changes apply to new tracks`,
iconName: 'radio-tower',
iconColor: '$borderColor',
iconColor:
streamingQuality === StreamingQuality.Original ? '$primary' : '$danger',
children: (
<YStack gap='$2' paddingVertical='$2'>
<Text fontSize='$3' marginBottom='$2'>
Higher quality uses more bandwidth. Changes apply to new tracks.
</Text>
<RadioGroup
value={streamingQuality}
onValueChange={(value) =>
setStreamingQuality(value as StreamingQuality)
}
>
<RadioGroupItemWithLabel
size='$3'
value='original'
label='Original Quality (Highest bandwidth)'
/>
<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>
<RadioGroup
value={streamingQuality}
onValueChange={(value) =>
setStreamingQuality(value as StreamingQuality)
}
>
<RadioGroupItemWithLabel
size='$3'
value={StreamingQuality.Original}
label='Original Quality (Highest bandwidth)'
/>
<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>
),
},
{
title: 'Show Audio Quality Badge',
subTitle: 'Displays audio quality in the player',
iconName: 'sine-wave',
iconColor: '$borderColor',
iconColor: displayAudioQualityBadge ? '$success' : '$borderColor',
children: (
<SwitchWithLabel
onCheckedChange={setDisplayAudioQualityBadge}

View File

@@ -1,24 +1,18 @@
import { RadioGroup, YStack } from 'tamagui'
import {
Theme,
useReducedHapticsContext,
useSendMetricsContext,
useSetReducedHapticsContext,
useSetSendMetricsContext,
useSetThemeSettingContext,
useThemeSettingContext,
} 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 {
ThemeSetting,
useReducedHapticsSetting,
useSendMetricsSetting,
useThemeSetting,
} from '../../../stores/settings/app'
export default function PreferencesTab(): React.JSX.Element {
const setSendMetrics = useSetSendMetricsContext()
const sendMetrics = useSendMetricsContext()
const setReducedHaptics = useSetReducedHapticsContext()
const reducedHaptics = useReducedHapticsContext()
const themeSetting = useThemeSettingContext()
const setThemeSetting = useSetThemeSettingContext()
const [sendMetrics, setSendMetrics] = useSendMetricsSetting()
const [reducedHaptics, setReducedHaptics] = useReducedHapticsSetting()
const [themeSetting, setThemeSetting] = useThemeSetting()
return (
<SettingsListGroup
@@ -32,7 +26,7 @@ export default function PreferencesTab(): React.JSX.Element {
<YStack gap='$2' paddingVertical='$2'>
<RadioGroup
value={themeSetting}
onValueChange={(value) => setThemeSetting(value as Theme)}
onValueChange={(value) => setThemeSetting(value as ThemeSetting)}
>
<RadioGroupItemWithLabel size='$3' value='system' label='System' />
<RadioGroupItemWithLabel size='$3' value='light' label='Light' />

View File

@@ -17,7 +17,12 @@ export default function SettingsListGroup({
}: SettingsListGroupProps): React.JSX.Element {
return (
<ScrollView>
<YGroup alignSelf='center'>
<YGroup
alignSelf='center'
borderWidth={'$1'}
borderColor={'$borderColor'}
margin={'$3'}
>
{settingsList.map((setting, index, self) => (
<>
<YGroup.Item key={setting.title}>

View File

@@ -1,92 +0,0 @@
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 {
DownloadQuality,
useAutoDownloadContext,
useSetAutoDownloadContext,
useDownloadQualityContext,
useSetDownloadQualityContext,
} from '../../../providers/Settings'
import { useNetworkContext } from '../../../providers/Network'
import { RadioGroup, YStack } from 'tamagui'
import { Text } from '../../Global/helpers/text'
import { getQualityLabel } from '../utils/quality'
import { useAllDownloadedTracks } from '../../../api/queries/download'
export default function StorageTab(): React.JSX.Element {
const autoDownload = useAutoDownloadContext()
const setAutoDownload = useSetAutoDownloadContext()
const downloadQuality = useDownloadQualityContext()
const setDownloadQuality = useSetDownloadQualityContext()
const { data: downloadedTracks } = useAllDownloadedTracks()
return (
<SettingsListGroup
settingsList={[
{
title: 'Downloaded Tracks',
subTitle: `${downloadedTracks?.length ?? '0'} ${
downloadedTracks?.length === 1 ? 'song' : 'songs'
} in your pocket`,
iconName: 'harddisk',
iconColor: '$borderColor',
},
{
title: 'Automatically Cache Tracks',
subTitle: 'Download tracks as they are played',
iconName: autoDownload ? 'cloud-download' : 'cloud-off-outline',
iconColor: autoDownload ? '$success' : '$borderColor',
children: (
<SwitchWithLabel
size={'$2'}
label={autoDownload ? 'Enabled' : 'Disabled'}
checked={autoDownload}
onCheckedChange={() => setAutoDownload(!autoDownload)}
/>
),
},
{
title: 'Download Quality',
subTitle: `Current: ${getQualityLabel(downloadQuality)} • For offline tracks`,
iconName: 'file-download',
iconColor: '$primary',
children: (
<YStack gap='$2' paddingVertical='$2'>
<Text fontSize='$3' marginBottom='$2'>
Quality used when saving tracks for offline use.
</Text>
<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>
),
},
]}
/>
)
}

View File

@@ -0,0 +1,74 @@
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 { RadioGroup } from 'tamagui'
import { useAllDownloadedTracks } from '../../../api/queries/download'
import {
DownloadQuality,
useAutoDownload,
useDownloadQuality,
} from '../../../stores/settings/usage'
export default function StorageTab(): React.JSX.Element {
const [autoDownload, setAutoDownload] = useAutoDownload()
const [downloadQuality, setDownloadQuality] = useDownloadQuality()
const { data: downloadedTracks } = useAllDownloadedTracks()
return (
<SettingsListGroup
settingsList={[
{
title: 'Downloaded Tracks',
subTitle: `${downloadedTracks?.length ?? '0'} ${
downloadedTracks?.length === 1 ? 'song' : 'songs'
} in your pocket`,
iconName: 'harddisk',
iconColor: '$primary',
},
{
title: 'Automatically Cache Tracks',
subTitle: 'Download tracks as they are played',
iconName: autoDownload ? 'cloud-download' : 'cloud-off-outline',
iconColor: autoDownload ? '$success' : '$borderColor',
children: (
<SwitchWithLabel
size={'$2'}
label={autoDownload ? 'Enabled' : 'Disabled'}
checked={autoDownload}
onCheckedChange={() => setAutoDownload(!autoDownload)}
/>
),
},
{
title: 'Download Quality',
subTitle: `Quality used when downloading tracks`,
iconName: 'file-download',
iconColor: '$primary',
children: (
<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>
),
},
]}
/>
)
}

View File

@@ -2,7 +2,7 @@ export type SettingsTabList = {
title: string
iconName: string
iconColor: ThemeTokens
subTitle: string
subTitle?: string
children?: React.ReactNode
onPress?: () => void
}[]

View File

@@ -0,0 +1,23 @@
const INFO_CAPTIONS = [
// Wholesome stuff
'Made with love',
// Outside jokes (that anyone can get)
'Not made with real jellyfish',
// Inside Jokes (that the internal Jellify team will get)
'Thank you, Pikachu',
// Movie Quotes
'Groovy, baby!', // Austin Powers
'Turned up to eleven!', // This is Spinal Tap
'Be excellent to each other!', // Bill and Ted's Excellent Adventure
'Party on, dude!', // Bill and Ted's Excellent Adventure
'WYLD STALLYNS!!!', // Bill and Ted's Excellent Adventure
// ASCII Art
'─=≡Σ((( つ•̀ω•́)つ',
'・:*+.\\(( °ω° ))/.:+',
]
export default INFO_CAPTIONS

View File

@@ -6,7 +6,6 @@ import { JellifyProvider, useJellifyContext } from '../providers'
import { JellifyUserDataProvider } from '../providers/UserData'
import { NetworkContextProvider } from '../providers/Network'
import { DisplayProvider } from '../providers/Display/display-provider'
import { useSendMetricsContext, useThemeSettingContext } from '../providers/Settings'
import {
createTelemetryDeck,
TelemetryDeckProvider,
@@ -21,12 +20,13 @@ import JellifyToastConfig from '../constants/toast.config'
import { useColorScheme } from 'react-native'
import { CarPlayProvider } from '../providers/CarPlay'
import { useSelectPlayerEngine } from '../stores/player-engine'
import { useSendMetricsSetting, useThemeSetting } from '../stores/settings/app'
/**
* 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 = useThemeSettingContext()
const [theme] = useThemeSetting()
const isDarkMode = useColorScheme() === 'dark'
useSelectPlayerEngine()
@@ -45,7 +45,7 @@ export default function Jellify(): React.JSX.Element {
}
function JellifyLoggingWrapper({ children }: { children: React.ReactNode }): React.JSX.Element {
const sendMetrics = useSendMetricsContext()
const [sendMetrics] = useSendMetricsSetting()
/**
* Create the TelemetryDeck instance, which is used to send telemetry data to the server
@@ -69,7 +69,7 @@ function JellifyLoggingWrapper({ children }: { children: React.ReactNode }): Rea
* @returns The {@link App} component
*/
function App(): React.JSX.Element {
const sendMetrics = useSendMetricsContext()
const [sendMetrics] = useSendMetricsSetting()
const telemetrydeck = useTelemetryDeck()
const theme = useTheme()

View File

@@ -1,11 +1,13 @@
import { MMKV } from 'react-native-mmkv'
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
import { AsyncStorage } from '@tanstack/react-query-persist-client'
import { StateStorage } from 'zustand/middleware'
console.debug(`Building MMKV storage`)
export const storage = new MMKV()
const clientStorage = {
const storageFunctions = {
setItem: (key: string, value: string) => {
storage.set(key, value)
},
@@ -18,6 +20,10 @@ const clientStorage = {
},
}
export const clientPersister = createAsyncStoragePersister({
const clientStorage: AsyncStorage<string> = storageFunctions
export const queryClientPersister = createAsyncStoragePersister({
storage: clientStorage,
})
export const stateStorage: StateStorage = storageFunctions

View File

@@ -5,9 +5,7 @@ import { CarPlay } from 'react-native-carplay'
import { useJellifyContext } from '../index'
import { useLoadNewQueue } from '../Player/hooks/mutations'
import { useNetworkContext } from '../Network'
import { useDownloadQualityContext } from '../Settings'
import useStreamingDeviceProfile from '../../stores/device-profile'
import { useAllDownloadedTracks } from '../../api/queries/download'
interface CarPlayContext {
carplayConnected: boolean
@@ -19,10 +17,7 @@ const CarPlayContextInitializer = () => {
const { networkStatus } = useNetworkContext()
const { data: downloadedTracks } = useAllDownloadedTracks()
const deviceProfile = useStreamingDeviceProfile()
const downloadQuality = useDownloadQualityContext()
const { mutate: loadNewQueue } = useLoadNewQueue()
@@ -32,15 +27,7 @@ const CarPlayContextInitializer = () => {
if (api && library) {
CarPlay.setRootTemplate(
CarPlayNavigation(
library,
loadNewQueue,
api,
downloadedTracks,
networkStatus,
deviceProfile,
downloadQuality,
),
CarPlayNavigation(library, loadNewQueue, api, networkStatus, deviceProfile),
)
if (Platform.OS === 'ios') {

View File

@@ -7,8 +7,13 @@ import { shuffleJellifyTracks } from '../utils/shuffle'
import TrackPlayer from 'react-native-track-player'
import Toast from 'react-native-toast-message'
import { findPlayQueueIndexStart } from '../utils'
import JellifyTrack from '@/src/types/JellifyTrack'
import JellifyTrack from '../../../types/JellifyTrack'
import { setPlayQueue, setQueueRef, setShuffled, setUnshuffledQueue } from '.'
import { JellifyDownload } from '../../../types/JellifyDownload'
type LoadQueueOperation = QueueMutation & {
downloadedTracks: JellifyDownload[] | undefined
}
export async function loadQueue({
index,
@@ -19,7 +24,7 @@ export async function loadQueue({
deviceProfile,
networkStatus = networkStatusTypes.ONLINE,
downloadedTracks,
}: QueueMutation) {
}: LoadQueueOperation) {
setQueueRef(queueRef)
setShuffled(shuffled)
@@ -97,6 +102,10 @@ export async function loadQueue({
return finalStartIndex
}
type PlayNextOperation = AddToQueueMutation & {
downloadedTracks: JellifyDownload[] | undefined
}
/**
* Inserts a track at the next index in the queue
*
@@ -109,7 +118,7 @@ export const playNextInQueue = async ({
downloadedTracks,
deviceProfile,
tracks,
}: AddToQueueMutation) => {
}: PlayNextOperation) => {
console.debug(`Playing item next in queue`)
const tracksToPlayNext = tracks.map((item) =>
@@ -135,12 +144,16 @@ export const playNextInQueue = async ({
})
}
type QueueOperation = AddToQueueMutation & {
downloadedTracks: JellifyDownload[] | undefined
}
export const playInQueue = async ({
api,
deviceProfile,
downloadedTracks,
tracks,
}: AddToQueueMutation) => {
}: QueueOperation) => {
const playQueue = await TrackPlayer.getQueue()
const currentIndex = await TrackPlayer.getActiveTrackIndex()

View File

@@ -4,7 +4,7 @@ import { loadQueue, playInQueue, playNextInQueue } from '../functions/queue'
import { trigger } from 'react-native-haptic-feedback'
import { isUndefined } from 'lodash'
import { previous, skip } from '../functions/controls'
import { AddToQueueMutation, QueueOrderMutation } from '../interfaces'
import { AddToQueueMutation, QueueMutation, QueueOrderMutation } from '../interfaces'
import { refetchNowPlaying, refetchPlayerQueue, invalidateRepeatMode } from '../functions/queries'
import { QueuingType } from '../../../enums/queuing-type'
import Toast from 'react-native-toast-message'
@@ -18,12 +18,13 @@ import {
import { handleDeshuffle, handleShuffle } from '../functions/shuffle'
import JellifyTrack from '@/src/types/JellifyTrack'
import calculateTrackVolume from '../utils/normalization'
import { useNowPlaying, usePlaybackState } from './queries'
import { usePlaybackState } from './queries'
import usePlayerEngineStore, { PlayerEngine } from '../../../stores/player-engine'
import { useRemoteMediaClient } from 'react-native-google-cast'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { RootStackParamList } from '../../../screens/types'
import { useNavigation } from '@react-navigation/native'
import { useAllDownloadedTracks } from '../../../api/queries/download'
const PLAYER_MUTATION_OPTIONS = {
retry: false,
@@ -170,12 +171,14 @@ const useSeekBy = () =>
},
})
export const useAddToQueue = () =>
useMutation({
export const useAddToQueue = () => {
const downloadedTracks = useAllDownloadedTracks().data
return useMutation({
mutationFn: (variables: AddToQueueMutation) =>
variables.queuingType === QueuingType.PlayingNext
? playNextInQueue(variables)
: playInQueue(variables),
? playNextInQueue({ ...variables, downloadedTracks })
: playInQueue({ ...variables, downloadedTracks }),
onSuccess: (data: void, { queuingType }: AddToQueueMutation) => {
trigger('notificationSuccess')
console.debug(
@@ -202,6 +205,7 @@ export const useAddToQueue = () =>
},
onSettled: refetchPlayerQueue,
})
}
export const useLoadNewQueue = () => {
const isCasting =
@@ -209,12 +213,14 @@ export const useLoadNewQueue = () => {
const remoteClient = useRemoteMediaClient()
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
const { data: downloadedTracks } = useAllDownloadedTracks()
return useMutation({
onMutate: async () => {
trigger('impactLight')
await TrackPlayer.pause()
},
mutationFn: loadQueue,
mutationFn: (variables: QueueMutation) => loadQueue({ ...variables, downloadedTracks }),
onSuccess: async (finalStartIndex, { startPlayback }) => {
console.debug('Successfully loaded new queue')
if (isCasting && remoteClient) {

View File

@@ -6,7 +6,6 @@ import { useEffect } from 'react'
import { useAudioNormalization, useInitialization } from './hooks/mutations'
import { useCurrentIndex, useNowPlaying, useQueue } from './hooks/queries'
import { handleActiveTrackChanged } from './functions'
import { useAutoDownloadContext } from '../Settings'
import JellifyTrack from '../../types/JellifyTrack'
import { useIsRestoring } from '@tanstack/react-query'
import {
@@ -15,6 +14,7 @@ import {
useReportPlaybackStopped,
} from '../../api/mutations/playback'
import { useDownloadAudioItem } from '../../api/mutations/download'
import { useAutoDownload } from '../../stores/settings/usage'
const PLAYER_EVENTS: Event[] = [
Event.PlaybackActiveTrackChanged,
@@ -27,7 +27,7 @@ interface PlayerContext {}
export const PlayerContext = createContext<PlayerContext>({})
export const PlayerProvider: () => React.JSX.Element = () => {
const autoDownload = useAutoDownloadContext()
const [autoDownload] = useAutoDownload()
usePerformanceMonitor('PlayerProvider', 3)

View File

@@ -4,7 +4,6 @@ import { Queue } from '../../player/types/queue-item'
import { Api } from '@jellyfin/sdk'
import { networkStatusTypes } from '../../components/Network/internetConnectionWatcher'
import { JellifyDownload } from '@/src/types/JellifyDownload'
import { DownloadQuality, StreamingQuality } from '../Settings'
/**
* A mutation to handle loading a new queue.
@@ -21,10 +20,6 @@ export interface QueueMutation {
*/
networkStatus: networkStatusTypes | null
downloadedTracks: JellifyDownload[] | undefined
downloadQuality: DownloadQuality
deviceProfile: DeviceProfile | undefined
/**
@@ -76,10 +71,6 @@ export interface AddToQueueMutation {
*/
networkStatus: networkStatusTypes | null
downloadedTracks: JellifyDownload[] | undefined
downloadQuality: DownloadQuality
deviceProfile: DeviceProfile | undefined
/**

View File

@@ -1,209 +0,0 @@
import { Platform } from 'react-native'
import { storage } from '../../constants/storage'
import { MMKVStorageKeys } from '../../enums/mmkv-storage-keys'
import { useEffect, useState, useMemo } from 'react'
import { createContext, useContextSelector } from 'use-context-selector'
import {
useDownloadingDeviceProfileStore,
useStreamingDeviceProfileStore,
} from '../../stores/device-profile'
import { getDeviceProfile } from './utils'
export type DownloadQuality = 'original' | 'high' | 'medium' | 'low'
export type StreamingQuality = 'original' | 'high' | 'medium' | 'low'
export type Theme = 'system' | 'light' | 'dark'
interface SettingsContext {
sendMetrics: boolean
setSendMetrics: React.Dispatch<React.SetStateAction<boolean>>
autoDownload: boolean
setAutoDownload: React.Dispatch<React.SetStateAction<boolean>>
devTools: boolean
setDevTools: React.Dispatch<React.SetStateAction<boolean>>
downloadQuality: DownloadQuality
setDownloadQuality: React.Dispatch<React.SetStateAction<DownloadQuality>>
streamingQuality: StreamingQuality
setStreamingQuality: React.Dispatch<React.SetStateAction<StreamingQuality>>
reducedHaptics: boolean
setReducedHaptics: React.Dispatch<React.SetStateAction<boolean>>
theme: Theme
setTheme: React.Dispatch<React.SetStateAction<Theme>>
}
/**
* Initializes the settings context
*
* By default, auto-download is enabled on iOS and Android
*
* By default, metrics and logs are not sent
*
* By default, streaming quality is set to 'high' for good balance of quality and bandwidth
*
* Settings are saved to the device storage
*
* @returns The settings context
*/
const SettingsContextInitializer = () => {
const sendMetricsInit = storage.getBoolean(MMKVStorageKeys.SendMetrics)
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,
) as DownloadQuality
const streamingQualityInit = storage.getString(
MMKVStorageKeys.StreamingQuality,
) as StreamingQuality
const [sendMetrics, setSendMetrics] = useState(sendMetricsInit ?? false)
const [autoDownload, setAutoDownload] = useState(
autoDownloadInit ?? ['ios', 'android'].includes(Platform.OS),
)
const [devTools, setDevTools] = useState(false)
const [downloadQuality, setDownloadQuality] = useState<DownloadQuality>(
downloadQualityInit ?? 'original',
)
const [streamingQuality, setStreamingQuality] = useState<StreamingQuality>(
streamingQualityInit ?? 'original',
)
const [reducedHaptics, setReducedHaptics] = useState(
reducedHapticsInit ?? (Platform.OS !== 'ios' && Math.random() > 0.7),
)
const [theme, setTheme] = useState<Theme>(themeInit ?? 'system')
const setStreamingDeviceProfile = useStreamingDeviceProfileStore(
(state) => state.setDeviceProfile,
)
const setDownloadingDeviceProfile = useDownloadingDeviceProfileStore(
(state) => state.setDeviceProfile,
)
useEffect(() => {
storage.set(MMKVStorageKeys.SendMetrics, sendMetrics)
}, [sendMetrics])
useEffect(() => {
storage.set(MMKVStorageKeys.AutoDownload, autoDownload)
}, [autoDownload])
useEffect(() => {
storage.set(MMKVStorageKeys.DownloadQuality, downloadQuality)
setDownloadingDeviceProfile(getDeviceProfile(downloadQuality, 'download'))
}, [downloadQuality])
useEffect(() => {
storage.set(MMKVStorageKeys.StreamingQuality, streamingQuality)
setStreamingDeviceProfile(getDeviceProfile(streamingQuality, 'stream'))
}, [streamingQuality])
useEffect(() => {
storage.set(MMKVStorageKeys.DevTools, devTools)
}, [devTools])
useEffect(() => {
storage.set(MMKVStorageKeys.ReducedHaptics, reducedHaptics)
}, [reducedHaptics])
useEffect(() => {
storage.set(MMKVStorageKeys.Theme, theme)
}, [theme])
return {
sendMetrics,
setSendMetrics,
autoDownload,
setAutoDownload,
devTools,
setDevTools,
downloadQuality,
setDownloadQuality,
streamingQuality,
setStreamingQuality,
reducedHaptics,
setReducedHaptics,
theme,
setTheme,
}
}
export const SettingsContext = createContext<SettingsContext>({
sendMetrics: false,
setSendMetrics: () => {},
autoDownload: false,
setAutoDownload: () => {},
devTools: false,
setDevTools: () => {},
downloadQuality: 'medium',
setDownloadQuality: () => {},
streamingQuality: 'high',
setStreamingQuality: () => {},
reducedHaptics: false,
setReducedHaptics: () => {},
theme: 'system',
setTheme: () => {},
})
export const SettingsProvider = ({ children }: { children: React.ReactNode }) => {
const context = SettingsContextInitializer()
// Memoize the context value to prevent unnecessary re-renders
const value = useMemo(
() => context,
[
context.sendMetrics,
context.autoDownload,
context.devTools,
context.downloadQuality,
context.streamingQuality,
context.reducedHaptics,
context.theme,
],
)
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>
}
export const useSendMetricsContext = () =>
useContextSelector(SettingsContext, (context) => context.sendMetrics)
export const useSetSendMetricsContext = () =>
useContextSelector(SettingsContext, (context) => context.setSendMetrics)
export const useAutoDownloadContext = () =>
useContextSelector(SettingsContext, (context) => context.autoDownload)
export const useSetAutoDownloadContext = () =>
useContextSelector(SettingsContext, (context) => context.setAutoDownload)
export const useDevToolsContext = () =>
useContextSelector(SettingsContext, (context) => context.devTools)
export const useSetDevToolsContext = () =>
useContextSelector(SettingsContext, (context) => context.setDevTools)
export const useDownloadQualityContext = () =>
useContextSelector(SettingsContext, (context) => context.downloadQuality)
export const useSetDownloadQualityContext = () =>
useContextSelector(SettingsContext, (context) => context.setDownloadQuality)
export const useStreamingQualityContext = () =>
useContextSelector(SettingsContext, (context) => context.streamingQuality)
export const useSetStreamingQualityContext = () =>
useContextSelector(SettingsContext, (context) => context.setStreamingQuality)
export const useReducedHapticsContext = () =>
useContextSelector(SettingsContext, (context) => context.reducedHaptics)
export const useSetReducedHapticsContext = () =>
useContextSelector(SettingsContext, (context) => context.setReducedHaptics)
export const useThemeSettingContext = () =>
useContextSelector(SettingsContext, (context) => context.theme)
export const useSetThemeSettingContext = () =>
useContextSelector(SettingsContext, (context) => context.setTheme)

View File

@@ -12,13 +12,13 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { RootStackParamList } from '../types'
import Toast from 'react-native-toast-message'
import { useJellifyContext } from '../../providers'
import { useSendMetricsContext, useSetSendMetricsContext } 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 LoginStackParamList from './types'
import { useSendMetricsSetting } from '../../stores/settings/app'
export default function ServerAddress({
navigation,
@@ -34,8 +34,7 @@ export default function ServerAddress({
const { server, setServer, signOut } = useJellifyContext()
const sendMetrics = useSendMetricsContext()
const setSendMetrics = useSetSendMetricsContext()
const [sendMetrics, setSendMetrics] = useSendMetricsSetting()
useEffect(() => {
setServerAddressContainsProtocol(

View File

@@ -1,6 +1,7 @@
import { DeviceProfile } from '@jellyfin/sdk/lib/generated-client'
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
import { stateStorage } from '../constants/storage'
type DeviceProfileStore = {
deviceProfile: DeviceProfile
@@ -16,6 +17,7 @@ export const useStreamingDeviceProfileStore = create<DeviceProfileStore>()(
}),
{
name: 'streaming-device-profile-storage',
storage: createJSONStorage(() => stateStorage),
},
),
),
@@ -36,6 +38,7 @@ export const useDownloadingDeviceProfileStore = create<DeviceProfileStore>()(
}),
{
name: 'downloading-device-profile-storage',
storage: createJSONStorage(() => stateStorage),
},
),
),

View File

@@ -1,39 +0,0 @@
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
type PlayerSettingsStore = {
displayAudioQualityBadge: boolean
setDisplayAudioQualityBadge: (displayAudioQualityBadge: boolean) => void
}
export const usePlayerSettingsStore = create<PlayerSettingsStore>()(
devtools(
persist(
(set) => ({
displayAudioQualityBadge: false,
setDisplayAudioQualityBadge: (displayAudioQualityBadge) =>
set({ displayAudioQualityBadge }),
}),
{
name: 'player-settings-storage',
},
),
),
)
export const useDisplayAudioQualityBadge: () => [
boolean,
(displayAudioQualityBadge: boolean) => void,
] = () => {
const displayAudioQualityBadge = usePlayerSettingsStore(
(state) => state.displayAudioQualityBadge,
)
const setDisplayAudioQualityBadge = usePlayerSettingsStore(
(state) => state.setDisplayAudioQualityBadge,
)
return [displayAudioQualityBadge, setDisplayAudioQualityBadge]
}
export const useSetDisplayAudioQualityBadge = () =>
usePlayerSettingsStore((state) => state.setDisplayAudioQualityBadge)

View File

@@ -0,0 +1,60 @@
import { stateStorage } from '../../constants/storage'
import { create } from 'zustand'
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
export type ThemeSetting = 'system' | 'light' | 'dark'
type AppSettingsStore = {
sendMetrics: boolean
setSendMetrics: (sendMetrics: boolean) => void
reducedHaptics: boolean
setReducedHaptics: (reducedHaptics: boolean) => void
theme: ThemeSetting
setTheme: (theme: ThemeSetting) => void
}
export const useAppSettingsStore = create<AppSettingsStore>()(
devtools(
persist(
(set) => ({
sendMetrics: false,
setSendMetrics: (sendMetrics) => set({ sendMetrics }),
reducedHaptics: false,
setReducedHaptics: (reducedHaptics) => set({ reducedHaptics }),
theme: 'system',
setTheme: (theme) => set({ theme }),
}),
{
name: 'app-settings-storage',
storage: createJSONStorage(() => stateStorage),
},
),
),
)
export const useThemeSetting: () => [ThemeSetting, (theme: ThemeSetting) => void] = () => {
const theme = useAppSettingsStore((state) => state.theme)
const setTheme = useAppSettingsStore((state) => state.setTheme)
return [theme, setTheme]
}
export const useReducedHapticsSetting: () => [boolean, (reducedHaptics: boolean) => void] = () => {
const reducedHaptics = useAppSettingsStore((state) => state.reducedHaptics)
const setReducedHaptics = useAppSettingsStore((state) => state.setReducedHaptics)
return [reducedHaptics, setReducedHaptics]
}
export const useSendMetricsSetting: () => [boolean, (sendMetrics: boolean) => void] = () => {
const sendMetrics = useAppSettingsStore((state) => state.sendMetrics)
const setSendMetrics = useAppSettingsStore((state) => state.setSendMetrics)
return [sendMetrics, setSendMetrics]
}

View File

@@ -0,0 +1,74 @@
import { create } from 'zustand'
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
import { stateStorage } from '../../constants/storage'
import { useStreamingDeviceProfileStore } from '../device-profile'
import { useEffect } from 'react'
import { getDeviceProfile } from '../../utils/device-profiles'
export enum StreamingQuality {
Original = 'original', // Direct Play
High = 'high', // 320
Medium = 'medium', // 256
Low = 'low', // 128
}
type PlayerSettingsStore = {
streamingQuality: StreamingQuality
setStreamingQuality: (streamingQuality: StreamingQuality) => void
displayAudioQualityBadge: boolean
setDisplayAudioQualityBadge: (displayAudioQualityBadge: boolean) => void
}
export const usePlayerSettingsStore = create<PlayerSettingsStore>()(
devtools(
persist(
(set) => ({
streamingQuality: StreamingQuality.Original,
setStreamingQuality: (streamingQuality) => set({ streamingQuality }),
displayAudioQualityBadge: false,
setDisplayAudioQualityBadge: (displayAudioQualityBadge) =>
set({ displayAudioQualityBadge }),
}),
{
name: 'player-settings-storage',
storage: createJSONStorage(() => stateStorage),
},
),
),
)
export const useStreamingQuality: () => [
StreamingQuality,
(streamingQuality: StreamingQuality) => void,
] = () => {
const streamingQuality = usePlayerSettingsStore((state) => state.streamingQuality)
const setStreamingQuality = usePlayerSettingsStore((state) => state.setStreamingQuality)
const setStreamingDeviceProfile = useStreamingDeviceProfileStore(
(state) => state.setDeviceProfile,
)
useEffect(() => {
setStreamingDeviceProfile(getDeviceProfile(streamingQuality, 'stream'))
}, [streamingQuality])
return [streamingQuality, setStreamingQuality]
}
export const useDisplayAudioQualityBadge: () => [
boolean,
(displayAudioQualityBadge: boolean) => void,
] = () => {
const displayAudioQualityBadge = usePlayerSettingsStore(
(state) => state.displayAudioQualityBadge,
)
const setDisplayAudioQualityBadge = usePlayerSettingsStore(
(state) => state.setDisplayAudioQualityBadge,
)
return [displayAudioQualityBadge, setDisplayAudioQualityBadge]
}

View File

@@ -0,0 +1,52 @@
import { create } from 'zustand'
import { StreamingQuality } from './player'
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
import { Platform } from 'react-native'
import { stateStorage } from '../../constants/storage'
export type DownloadQuality = StreamingQuality
type UsageSettingsStore = {
downloadQuality: DownloadQuality
setDownloadQuality: (downloadQuality: DownloadQuality) => void
autoDownload: boolean
setAutoDownload: (autoDownload: boolean) => void
}
export const useUsageSettingsStore = create<UsageSettingsStore>()(
devtools(
persist(
(set) => ({
downloadQuality: StreamingQuality.Original,
setDownloadQuality: (downloadQuality: DownloadQuality) => set({ downloadQuality }),
autoDownload: Platform.OS === 'android' || Platform.OS === 'ios',
setAutoDownload: (autoDownload) => set({ autoDownload }),
}),
{
name: 'usage-settings-storage',
storage: createJSONStorage(() => stateStorage),
},
),
),
)
export const useAutoDownload: () => [boolean, (autoDownload: boolean) => void] = () => {
const autoDownload = useUsageSettingsStore((state) => state.autoDownload)
const setAutoDownload = useUsageSettingsStore((state) => state.setAutoDownload)
return [autoDownload, setAutoDownload]
}
export const useDownloadQuality: () => [
DownloadQuality,
(downloadQuality: DownloadQuality) => void,
] = () => {
const downloadQuality = useUsageSettingsStore((state) => state.downloadQuality)
const setDownloadQuality = useUsageSettingsStore((state) => state.setDownloadQuality)
return [downloadQuality, setDownloadQuality]
}

View File

@@ -19,11 +19,11 @@ import {
EncodingContext,
MediaStreamProtocol,
} from '@jellyfin/sdk/lib/generated-client'
import { StreamingQuality } from '..'
import { StreamingQuality } from '../stores/settings/player'
import { Platform } from 'react-native'
import { getQualityParams } from '../../../utils/mappings'
import { getQualityParams } from './mappings'
import { capitalize } from 'lodash'
import { SourceType } from '../../../types/JellifyTrack'
import { SourceType } from '../types/JellifyTrack'
/**
* A constant that defines the options for the {@link useDeviceProfile} hook - building the

View File

@@ -13,13 +13,14 @@ import { AudioApi } from '@jellyfin/sdk/lib/generated-client/api'
import { JellifyDownload } from '../types/JellifyDownload'
import { Api } from '@jellyfin/sdk/lib/api'
import RNFS from 'react-native-fs'
import { DownloadQuality, StreamingQuality } from '../providers/Settings'
import { StreamingQuality } from '../stores/settings/player'
import { AudioQuality } from '../types/AudioQuality'
import { queryClient } from '../constants/query-client'
import { QueryKeys } from '../enums/query-keys'
import { isUndefined } from 'lodash'
import uuid from 'react-native-uuid'
import { convertRunTimeTicksToSeconds } from './runtimeticks'
import { DownloadQuality } from '../stores/settings/usage'
/**
* Gets quality-specific parameters for transcoding

3
src/utils/random.ts Normal file
View File

@@ -0,0 +1,3 @@
export function pickRandomItemFromArray<T>(array: readonly T[]): T {
return array[Math.max(0, Math.min(array.length - 1, Math.floor(Math.random() * array.length)))]
}