Login Flow Backend Refactor, Haptic Feedback Setting Enablement (#502)

Refactors haptic feedback usage to use a hook that will determine based on the user input if a haptic feedback event should be triggered. This fixes the issue where users would get haptic feedback even if the toggle for reducing it was enabled

Refactors login flow to use external hooks to clean up components and split display vs functional logic

Fixes DNS lookups in login flow. Now when a hostname connection fails, Jellify will properly perform a DNS lookup to determine the proper IP address of the Jellyfin server

This should address issues with users connecting over Tailscale or relying on a custom DNS entry configured locally

* update react navigation
This commit is contained in:
Violet Caulfield
2025-08-31 11:06:47 -05:00
committed by GitHub
parent 2392d562d9
commit fac98f2777
27 changed files with 366 additions and 274 deletions

View File

@@ -41,10 +41,10 @@
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native-picker/picker": "^2.11.1",
"@react-native-vector-icons/material-design-icons": "^12.3.0",
"@react-navigation/bottom-tabs": "^7.4.6",
"@react-navigation/material-top-tabs": "^7.3.6",
"@react-navigation/bottom-tabs": "^7.4.7",
"@react-navigation/material-top-tabs": "^7.3.7",
"@react-navigation/native": "^7.1.17",
"@react-navigation/native-stack": "^7.3.25",
"@react-navigation/native-stack": "^7.3.26",
"@sentry/react-native": "6.17.0",
"@shopify/flash-list": "^2.0.3",
"@tamagui/config": "^1.132.23",

View File

@@ -0,0 +1,54 @@
import { AxiosResponse } from 'axios'
import { JellyfinCredentials } from '../../types/jellyfin-credentials'
import { AuthenticationResult } from '@jellyfin/sdk/lib/generated-client'
import { useMutation } from '@tanstack/react-query'
import { useJellifyContext } from '../../../providers'
import { JellifyUser } from '../../../types/JellifyUser'
import { isUndefined } from 'lodash'
interface AuthenticateUserByNameMutation {
onSuccess?: () => void
onError?: () => void
}
const useAuthenticateUserByName = ({ onSuccess, onError }: AuthenticateUserByNameMutation) => {
const { api, setUser } = useJellifyContext()
return useMutation({
mutationFn: async (credentials: JellyfinCredentials) => {
return await api!.authenticateUserByName(credentials.username, credentials.password)
},
onSuccess: async (authResult: AxiosResponse<AuthenticationResult>) => {
console.log(`Received auth response from server`)
if (isUndefined(authResult))
return Promise.reject(new Error('Authentication result was empty'))
if (authResult.status >= 400 || isUndefined(authResult.data.AccessToken))
return Promise.reject(new Error('Invalid credentials'))
if (isUndefined(authResult.data.User))
return Promise.reject(new Error('Unable to login'))
console.log(`Successfully signed in to server`)
const user: JellifyUser = {
id: authResult.data.User!.Id!,
name: authResult.data.User!.Name!,
accessToken: authResult.data.AccessToken as string,
}
setUser(user)
if (onSuccess) onSuccess()
},
onError: async (error: Error) => {
console.error('An error occurred connecting to the Jellyfin instance', error)
if (onError) onError()
},
retry: 0,
gcTime: 0,
})
}
export default useAuthenticateUserByName

View File

@@ -1,100 +0,0 @@
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api'
import { http } from '../../components/Login/utils/constants'
import { Jellyfin } from '@jellyfin/sdk/lib/jellyfin'
import { JellyfinInfo } from '../info'
import { https } from '../../components/Login/utils/constants'
import { PublicSystemInfo } from '@jellyfin/sdk/lib/generated-client/models'
import { getIpAddressesForHostname } from 'react-native-dns-lookup'
/**
* Attempts to connect to a Jellyfin server.
*
* @param serverAddress The server address to connect to.
* @param useHttps Whether to use HTTPS.
* @returns The public system info response.
*/
export function connectToServer(
serverAddress: string,
useHttps: boolean,
): Promise<{
publicSystemInfoResponse: PublicSystemInfo
connectionType: 'hostname' | 'ipAddress'
}> {
return new Promise((resolve, reject) => {
if (!serverAddress) return reject(new Error('Server address was empty'))
const serverAddressContainsProtocol =
serverAddress.includes(http) || serverAddress.includes(https)
const jellyfin = new Jellyfin(JellyfinInfo)
const api = jellyfin.createApi(
`${serverAddressContainsProtocol ? '' : useHttps ? https : http}${serverAddress}`,
)
const connectViaHostnamePromise = () =>
new Promise<{
publicSystemInfoResponse: PublicSystemInfo
connectionType: 'hostname'
}>((resolve, reject) => {
getSystemApi(api)
.getPublicSystemInfo()
.then((response) => {
if (!response.data.Version)
return reject(
new Error(
'Jellyfin instance did not respond to our hostname request',
),
)
return resolve({
publicSystemInfoResponse: response.data,
connectionType: 'hostname',
})
})
.catch((error) => {
console.error('An error occurred getting public system info', error)
return reject(new Error('Unable to connect to Jellyfin via hostname'))
})
})
const connectViaLocalNetworkPromise = () =>
new Promise<{
publicSystemInfoResponse: PublicSystemInfo
connectionType: 'ipAddress'
}>((resolve, reject) =>
getIpAddressesForHostname(serverAddress.split(':')[0]).then((ipAddress) => {
const ipAddressApi = jellyfin.createApi(
`${serverAddressContainsProtocol ? '' : useHttps ? https : http}${ipAddress[0]}:${serverAddress.split(':')[1]}`,
)
getSystemApi(ipAddressApi)
.getPublicSystemInfo()
.then((response) => {
if (!response.data.Version)
return reject(
new Error(
'Jellyfin instance did not respond to our IP Address request',
),
)
return resolve({
publicSystemInfoResponse: response.data,
connectionType: 'ipAddress',
})
})
.catch((error) => {
console.error('An error occurred getting public system info', error)
return reject(new Error('Unable to connect to Jellyfin via IP Address'))
})
}),
)
connectViaHostnamePromise()
.then((response) => resolve(response))
.catch(() =>
connectViaLocalNetworkPromise()
.then((response) => resolve(response))
.catch(reject),
)
})
}

View File

@@ -0,0 +1,55 @@
import { useMutation } from '@tanstack/react-query'
import { connectToServer } from './utils'
import { JellifyServer } from '@/src/types/JellifyServer'
import serverAddressContainsProtocol from './utils/parsing'
import HTTPS, { HTTP } from '../../../constants/protocols'
import { useJellifyContext } from '../../../providers'
interface PublicSystemInfoMutation {
serverAddress: string
useHttps: boolean
}
interface PublicSystemInfoHook {
onSuccess?: (server: JellifyServer) => void
onError?: (error: Error) => void
}
const usePublicSystemInfo = ({ onSuccess, onError }: PublicSystemInfoHook) => {
const { setServer } = useJellifyContext()
return useMutation({
mutationFn: ({ serverAddress, useHttps }: PublicSystemInfoMutation) =>
connectToServer(serverAddress!, useHttps),
onSuccess: ({ publicSystemInfoResponse, connectionType }, { serverAddress, useHttps }) => {
console.debug(`Got public system info response`)
if (!publicSystemInfoResponse.Version)
throw new Error(`Jellyfin instance did not respond`)
const server: JellifyServer = {
url:
connectionType === 'hostname'
? `${serverAddressContainsProtocol(serverAddress) ? '' : useHttps ? HTTPS : HTTP}${serverAddress!}`
: publicSystemInfoResponse.LocalAddress!,
address: serverAddress!,
name: publicSystemInfoResponse.ServerName!,
version: publicSystemInfoResponse.Version!,
startUpComplete: publicSystemInfoResponse.StartupWizardCompleted!,
}
setServer(server)
if (onSuccess) onSuccess(server)
},
onError: (error: Error) => {
console.error('An error occurred connecting to the Jellyfin instance', error)
setServer(undefined)
if (onError) onError(error)
},
})
}
export default usePublicSystemInfo

View File

@@ -0,0 +1,79 @@
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api'
import { Jellyfin } from '@jellyfin/sdk/lib/jellyfin'
import { JellyfinInfo } from '../../../info'
import { PublicSystemInfo } from '@jellyfin/sdk/lib/generated-client/models'
import { getIpAddressesForHostname } from 'react-native-dns-lookup'
import { Api } from '@jellyfin/sdk'
import HTTPS, { HTTP } from '../../../../constants/protocols'
type ConnectionType = 'hostname' | 'ipAddress'
/**
* Attempts to connect to a Jellyfin server.
*
* @param serverAddress The server address to connect to.
* @param useHttps Whether to use HTTPS.
* @returns The public system info response.
*/
export function connectToServer(
serverAddress: string,
useHttps: boolean,
): Promise<{
publicSystemInfoResponse: PublicSystemInfo
connectionType: ConnectionType
}> {
return new Promise((resolve, reject) => {
if (!serverAddress) return reject(new Error('Server address was empty'))
const serverAddressContainsProtocol =
serverAddress.includes(HTTP) || serverAddress.includes(HTTPS)
const jellyfin = new Jellyfin(JellyfinInfo)
const hostnameApi = jellyfin.createApi(
`${serverAddressContainsProtocol ? '' : useHttps ? HTTPS : HTTP}${serverAddress}`,
)
const connectViaIpAddress = () => {
return getIpAddressesForHostname(serverAddress.split(':')[0])
.then((ipAddress) => {
const ipAddressApi = jellyfin.createApi(
`${serverAddressContainsProtocol ? '' : useHttps ? HTTPS : HTTP}${ipAddress[0]}:${serverAddress.split(':')[1]}`,
)
return connect(ipAddressApi, `ipAddress`)
})
.catch(() => {
throw new Error(`Unable to lookup IP Addresses for Hostname`)
})
}
return connect(hostnameApi, 'hostname')
.then((response) => resolve(response))
.catch(() =>
connectViaIpAddress()
.then((response) => resolve(response))
.catch(reject),
)
})
}
function connect(api: Api, connectionType: ConnectionType) {
return getSystemApi(api)
.getPublicSystemInfo()
.then((response) => {
if (!response.data.Version)
throw new Error(
`Jellyfin instance did not respond to our ${connectionType} request`,
)
return {
publicSystemInfoResponse: response.data,
connectionType,
}
})
.catch((error) => {
console.error('An error occurred getting public system info', error)
throw new Error(`Unable to connect to Jellyfin via ${connectionType}`)
})
}

View File

@@ -0,0 +1,9 @@
import HTTPS, { HTTP } from '../../../../constants/protocols'
import { isUndefined } from 'lodash'
export default function serverAddressContainsProtocol(serverAddress: string | undefined) {
return (
!isUndefined(serverAddress) &&
(serverAddress.includes(HTTP) || serverAddress.includes(HTTPS))
)
}

View File

@@ -8,7 +8,6 @@ import QueryConfig from '../../api/queries/query.config'
import { queryClient } from '../../constants/query-client'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { useMemo } from 'react'
import { trigger } from 'react-native-haptic-feedback'
import Toast from 'react-native-toast-message'
import {
YStack,
@@ -28,10 +27,13 @@ import ItemImage from '../Global/components/image'
import TextTicker from 'react-native-text-ticker'
import { TextTickerConfig } from '../Player/component.config'
import { getItemName } from '../../utils/text'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
export default function AddToPlaylist({ track }: { track: BaseItemDto }): React.JSX.Element {
const { api, user, library } = useJellifyContext()
const trigger = useHapticFeedback()
const {
data: playlists,
isPending: playlistsFetchPending,

View File

@@ -25,7 +25,6 @@ import { StackActions } from '@react-navigation/native'
import TextTicker from 'react-native-text-ticker'
import { TextTickerConfig } from '../Player/component.config'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { trigger } from 'react-native-haptic-feedback'
import { useAddToQueue } from '../../providers/Player/hooks/mutations'
import { useNetworkStatus } from '../../stores/network'
import { useNetworkContext } from '../../providers/Network'
@@ -33,6 +32,7 @@ import { mapDtoToTrack } from '../../utils/mappings'
import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../../stores/device-profile'
import { useIsDownloaded } from '../../api/queries/download'
import { useDeleteDownloads } from '../../api/mutations/download'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
type StackNavigation = Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'>
@@ -55,6 +55,8 @@ export default function ItemContext({
const { bottom } = useSafeAreaInsets()
const trigger = useHapticFeedback()
const isArtist = item.Type === BaseItemKind.MusicArtist
const isAlbum = item.Type === BaseItemKind.MusicAlbum
const isTrack = item.Type === BaseItemKind.Audio

View File

@@ -10,10 +10,9 @@ import Animated, {
} from 'react-native-reanimated'
import { Text } from '../helpers/text'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import { trigger } from 'react-native-haptic-feedback'
import { useReducedHapticsSetting } from '../../../stores/settings/app'
import { UseInfiniteQueryResult, useMutation } from '@tanstack/react-query'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import useHapticFeedback from '../../../hooks/use-haptic-feedback'
const alphabet = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
/**
@@ -31,9 +30,9 @@ export default function AZScroller({
}: {
onLetterSelect: (letter: string) => void
}) {
const { width, height } = useSafeAreaFrame()
const { width } = useSafeAreaFrame()
const theme = useTheme()
const [reducedHaptics] = useReducedHapticsSetting()
const trigger = useHapticFeedback()
const overlayOpacity = useSharedValue(0)
@@ -122,7 +121,7 @@ export default function AZScroller({
}
useEffect(() => {
if (!reducedHaptics && overlayLetter) trigger('impactLight')
trigger('impactLight')
}, [overlayLetter])
return (

View File

@@ -1,8 +1,8 @@
import { SizeTokens, XStack, Separator, Switch, styled, getToken } from 'tamagui'
import { Label } from './text'
import { useEffect } from 'react'
import { trigger } from 'react-native-haptic-feedback'
import { usePreviousValue } from '../../../hooks/use-previous-value'
import useHapticFeedback from '../../../hooks/use-haptic-feedback'
interface SwitchWithLabelProps {
onCheckedChange: (value: boolean) => void
@@ -22,6 +22,8 @@ export function SwitchWithLabel(props: SwitchWithLabelProps) {
const previousChecked = usePreviousValue(props.checked)
const trigger = useHapticFeedback()
useEffect(() => {
if (previousChecked !== props.checked) {
trigger('impactMedium')

View File

@@ -6,14 +6,13 @@ import { useLibrarySortAndFilterContext } from '../../providers/Library/sorting-
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 { useReducedHapticsSetting } from '../../stores/settings/app'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
function LibraryTabBar(props: MaterialTopTabBarProps) {
const { isFavorites, setIsFavorites, isDownloaded, setIsDownloaded } =
useLibrarySortAndFilterContext()
const [reducedHaptics] = useReducedHapticsSetting()
const trigger = useHapticFeedback()
const insets = useSafeAreaInsets()
@@ -37,7 +36,7 @@ function LibraryTabBar(props: MaterialTopTabBarProps) {
<XStack
flex={1}
onPress={() => {
if (!reducedHaptics) trigger('impactLight')
trigger('impactLight')
props.navigation.navigate('AddPlaylist')
}}
alignItems={'center'}
@@ -51,7 +50,7 @@ function LibraryTabBar(props: MaterialTopTabBarProps) {
<XStack
flex={1}
onPress={() => {
if (!reducedHaptics) trigger('impactLight')
trigger('impactLight')
setIsFavorites(!isUndefined(isFavorites) ? undefined : true)
}}
alignItems={'center'}
@@ -72,7 +71,7 @@ function LibraryTabBar(props: MaterialTopTabBarProps) {
<XStack
flex={1}
onPress={() => {
if (!reducedHaptics) trigger('impactLight')
trigger('impactLight')
setIsDownloaded(!isDownloaded)
}}
alignItems={'center'}

View File

@@ -1,2 +0,0 @@
export const http = 'http://'
export const https = 'https://'

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'
import { HorizontalSlider } from '../../../components/Global/helpers/slider'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { trigger } from 'react-native-haptic-feedback'
import { XStack, YStack } from 'tamagui'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import { useSeekTo } from '../../../providers/Player/hooks/mutations'
@@ -11,7 +10,7 @@ import { ProgressMultiplier } from '../component.config'
import { useNowPlaying, useProgress } from '../../../providers/Player/hooks/queries'
import QualityBadge from './quality-badge'
import { useDisplayAudioQualityBadge } from '../../../stores/settings/player'
import { useReducedHapticsSetting } from '../../../stores/settings/app'
import useHapticFeedback from '../../../hooks/use-haptic-feedback'
// Create a simple pan gesture
const scrubGesture = Gesture.Pan().runOnJS(true)
@@ -20,7 +19,8 @@ export default function Scrubber(): React.JSX.Element {
const { mutate: seekTo, isPending: seekPending, mutateAsync: seekToAsync } = useSeekTo()
const { data: nowPlaying } = useNowPlaying()
const { width } = useSafeAreaFrame()
const reducedHaptics = useReducedHapticsSetting()
const trigger = useHapticFeedback()
// 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"
@@ -116,9 +116,7 @@ export default function Scrubber(): React.JSX.Element {
},
onSlideMove: (event: unknown, value: number) => {
// Throttled haptic feedback for better performance
if (!reducedHaptics) {
trigger('clockTick')
}
trigger('clockTick')
// Update position with proper clamping
const clampedValue = Math.max(0, Math.min(value, maxDuration))
@@ -139,7 +137,7 @@ export default function Scrubber(): React.JSX.Element {
})
},
}),
[maxDuration, reducedHaptics, handleSeek, calculatedPosition, width],
[maxDuration, handleSeek, calculatedPosition, width],
)
return (

View File

@@ -4,7 +4,6 @@ import { RootStackParamList } from '../../screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import DraggableFlatList, { RenderItemParams } from 'react-native-draggable-flatlist'
import { Separator, XStack } from 'tamagui'
import { trigger } from 'react-native-haptic-feedback'
import { isUndefined } from 'lodash'
import { useLayoutEffect, useCallback, useMemo } from 'react'
import JellifyTrack from '../../types/JellifyTrack'
@@ -15,6 +14,7 @@ import {
useReorderQueue,
useSkip,
} from '../../providers/Player/hooks/mutations'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
export default function Queue({
navigation,
@@ -30,6 +30,8 @@ export default function Queue({
const { mutate: reorderQueue } = useReorderQueue()
const { mutate: skip } = useSkip()
const trigger = useHapticFeedback()
useLayoutEffect(() => {
navigation.setOptions({
headerRight: () => {

View File

@@ -1,7 +1,6 @@
import { Separator, XStack } from 'tamagui'
import Track from '../Global/components/track'
import Icon from '../Global/components/icon'
import { trigger } from 'react-native-haptic-feedback'
import { RefreshControl } from 'react-native'
import { PlaylistProps } from './interfaces'
import PlayliistTracklistHeader from './components/header'
@@ -11,6 +10,7 @@ import AnimatedDraggableFlatList from '../Global/components/animated-draggable-f
import { useNavigation } from '@react-navigation/native'
import { RootStackParamList } from '../../screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
export default function Playlist({
playlist,
@@ -28,6 +28,8 @@ export default function Playlist({
useRemoveFromPlaylist,
} = usePlaylistContext()
const trigger = useHapticFeedback()
const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
const scrollOffsetHandler = useAnimatedScrollHandler({

View File

@@ -6,7 +6,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useNavigation } from '@react-navigation/native'
import { Text } from '../../Global/helpers/text'
import SettingsListGroup from './settings-list-group'
import { https } from '../../Login/utils/constants'
import HTTPS from '../../../constants/protocols'
export default function AccountTab(): React.JSX.Element {
const { user, library, server } = useJellifyContext()
@@ -35,8 +35,8 @@ export default function AccountTab(): React.JSX.Element {
{
title: server?.name ?? 'Untitled Server',
subTitle: server?.version ?? 'Unknown Jellyfin Version',
iconName: server?.url.includes(https) ? 'lock' : 'lock-open',
iconColor: server?.url.includes(https) ? '$success' : '$borderColor',
iconName: server?.url.includes(HTTPS) ? 'lock' : 'lock-open',
iconColor: server?.url.includes(HTTPS) ? '$success' : '$borderColor',
children: <Text>{server?.address ?? 'Unknown Server'}</Text>,
},
]}

View File

@@ -0,0 +1,5 @@
const HTTPS = 'https://'
export const HTTP = 'http://'
export default HTTPS

View File

@@ -0,0 +1,16 @@
import { HapticFeedbackTypes, trigger } from 'react-native-haptic-feedback'
import { useReducedHapticsSetting } from '../stores/settings/app'
const useHapticFeedback: () => (
type?: keyof typeof HapticFeedbackTypes | HapticFeedbackTypes,
) => void = () => {
const [reducedHaptics] = useReducedHapticsSetting()
return (type?: keyof typeof HapticFeedbackTypes | HapticFeedbackTypes) => {
if (!reducedHaptics) {
trigger(type)
}
}
}
export default useHapticFeedback

View File

@@ -1,7 +1,6 @@
import { useMutation } from '@tanstack/react-query'
import TrackPlayer, { RepeatMode, State } from 'react-native-track-player'
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, QueueMutation, QueueOrderMutation } from '../interfaces'
@@ -25,6 +24,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { RootStackParamList } from '../../../screens/types'
import { useNavigation } from '@react-navigation/native'
import { useAllDownloadedTracks } from '../../../api/queries/download'
import useHapticFeedback from '../../../hooks/use-haptic-feedback'
const PLAYER_MUTATION_OPTIONS = {
retry: false,
@@ -60,11 +60,14 @@ export const useInitialization = () =>
/**
* A mutation to handle starting playback
*/
export const usePlay = () =>
useMutation({
export const usePlay = () => {
const trigger = useHapticFeedback()
return useMutation({
onMutate: () => trigger('impactLight'),
mutationFn: TrackPlayer.play,
})
}
/**
* A mutation to handle toggling the playback state
@@ -75,6 +78,8 @@ export const useTogglePlayback = () => {
usePlayerEngineStore((state) => state.playerEngineData) === PlayerEngine.GOOGLE_CAST
const remoteClient = useRemoteMediaClient()
const trigger = useHapticFeedback()
return useMutation({
mutationFn: async () => {
trigger('impactMedium')
@@ -115,8 +120,10 @@ export const useTogglePlayback = () => {
})
}
export const useToggleRepeatMode = () =>
useMutation({
export const useToggleRepeatMode = () => {
const trigger = useHapticFeedback()
return useMutation({
onMutate: () => trigger('impactLight'),
mutationFn: async () => {
const repeatMode = await TrackPlayer.getRepeatMode()
@@ -134,6 +141,7 @@ export const useToggleRepeatMode = () =>
},
onSettled: invalidateRepeatMode,
})
}
/**
* A mutation to handle seeking to a specific position in the track
@@ -143,6 +151,8 @@ export const useSeekTo = () => {
usePlayerEngineStore((state) => state.playerEngineData) === PlayerEngine.GOOGLE_CAST
const remoteClient = useRemoteMediaClient()
const trigger = useHapticFeedback()
return useMutation({
onMutate: () => trigger('impactLight'),
mutationFn: async (position: number) => {
@@ -162,7 +172,9 @@ export const useSeekTo = () => {
/**
* A mutation to handle seeking to a specific position in the track
*/
const useSeekBy = () =>
const useSeekBy = () => {
const trigger = useHapticFeedback()
useMutation({
mutationFn: async (seekSeconds: number) => {
trigger('clockTick')
@@ -170,10 +182,13 @@ const useSeekBy = () =>
await TrackPlayer.seekBy(seekSeconds)
},
})
}
export const useAddToQueue = () => {
const downloadedTracks = useAllDownloadedTracks().data
const trigger = useHapticFeedback()
return useMutation({
mutationFn: (variables: AddToQueueMutation) =>
variables.queuingType === QueuingType.PlayingNext
@@ -215,6 +230,8 @@ export const useLoadNewQueue = () => {
const { data: downloadedTracks } = useAllDownloadedTracks()
const trigger = useHapticFeedback()
return useMutation({
onMutate: async () => {
trigger('impactLight')
@@ -253,8 +270,10 @@ export const usePrevious = () =>
},
})
export const useSkip = () =>
useMutation({
export const useSkip = () => {
const trigger = useHapticFeedback()
return useMutation({
onMutate: (index?: number | undefined) => {
trigger('impactMedium')
@@ -271,9 +290,12 @@ export const useSkip = () =>
console.error('Failed to skip to next track:', error)
},
})
}
export const useRemoveFromQueue = () =>
useMutation({
export const useRemoveFromQueue = () => {
const trigger = useHapticFeedback()
return useMutation({
onMutate: () => trigger('impactMedium'),
mutationFn: async (index: number) => TrackPlayer.remove([index]),
onSuccess: async (data: void, index: number) => {
@@ -284,9 +306,12 @@ export const useRemoveFromQueue = () =>
},
onSettled: refetchPlayerQueue,
})
}
export const useRemoveUpcomingTracks = () =>
useMutation({
export const useRemoveUpcomingTracks = () => {
const trigger = useHapticFeedback()
return useMutation({
mutationFn: TrackPlayer.removeUpcomingTracks,
onSuccess: () => trigger('notificationSuccess'),
onError: async (error: Error) => {
@@ -295,9 +320,12 @@ export const useRemoveUpcomingTracks = () =>
},
onSettled: refetchPlayerQueue,
})
}
export const useReorderQueue = () =>
useMutation({
export const useReorderQueue = () => {
const trigger = useHapticFeedback()
return useMutation({
mutationFn: async ({ from, to }: QueueOrderMutation) => {
console.debug(
`TrackPlayer.move(${from}, ${to}) - Queue before move:`,
@@ -310,7 +338,6 @@ export const useReorderQueue = () =>
console.debug(`Reordering queue from ${from} to ${to}`)
},
onSuccess: async (_, { from, to }: { from: number; to: number }) => {
trigger('notificationSuccess')
console.debug(`Reordered queue from ${from} to ${to} successfully`)
},
onError: async (error: Error) => {
@@ -319,6 +346,7 @@ export const useReorderQueue = () =>
},
onSettled: refetchPlayerQueue,
})
}
export const useResetQueue = () =>
useMutation({
@@ -331,8 +359,10 @@ export const useResetQueue = () =>
onSettled: refetchPlayerQueue,
})
export const useToggleShuffle = () =>
useMutation({
export const useToggleShuffle = () => {
const trigger = useHapticFeedback()
return useMutation({
onMutate: () => trigger('impactLight'),
mutationFn: async (shuffled: boolean | undefined) =>
shuffled ? await handleDeshuffle() : await handleShuffle(),
@@ -345,6 +375,7 @@ export const useToggleShuffle = () =>
},
onSuccess: refetchPlayerQueue,
})
}
export const useAudioNormalization = () =>
useMutation({

View File

@@ -4,10 +4,10 @@ import { QueryKeys } from '../../enums/query-keys'
import { useJellifyContext } from '..'
import { createContext, ReactNode, useContext, useEffect, useState } from 'react'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { trigger } from 'react-native-haptic-feedback'
import { removeFromPlaylist, updatePlaylist } from '../../api/mutations/playlists'
import { RemoveFromPlaylistMutation } from '../../components/Playlist/interfaces'
import { SharedValue, useSharedValue } from 'react-native-reanimated'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
interface PlaylistContext {
playlist: BaseItemDto
@@ -40,6 +40,8 @@ const PlaylistContextInitializer = (playlist: BaseItemDto) => {
const scroll = useSharedValue(0)
const trigger = useHapticFeedback()
const {
data: tracks,
isPending,

View File

@@ -1,13 +1,13 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api'
import { useMutation } from '@tanstack/react-query'
import { createContext, ReactNode, SetStateAction, useContext } from 'react'
import { createContext, ReactNode, useContext } from 'react'
import { trigger } from 'react-native-haptic-feedback'
import { queryClient } from '../../constants/query-client'
import { QueryKeys } from '../../enums/query-keys'
import Toast from 'react-native-toast-message'
import { useJellifyContext } from '..'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
interface SetFavoriteMutation {
item: BaseItemDto
@@ -20,6 +20,9 @@ interface JellifyUserDataContext {
const JellifyUserDataContextInitializer = () => {
const { api } = useJellifyContext()
const trigger = useHapticFeedback()
const useSetFavorite = useMutation({
mutationFn: async (mutation: SetFavoriteMutation) => {
return getUserLibraryApi(api!).markFavoriteItem({

View File

@@ -4,16 +4,15 @@ import React, { useState } from 'react'
import { View, XStack } from 'tamagui'
import Button from '../../components/Global/helpers/button'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { RootStackParamList } from '../types'
import { useMutation } from '@tanstack/react-query'
import { createPlaylist } from '../../api/mutations/playlists'
import { trigger } from 'react-native-haptic-feedback'
import { queryClient } from '../../constants/query-client'
import { QueryKeys } from '../../enums/query-keys'
import Toast from 'react-native-toast-message'
import { useJellifyContext } from '../../providers'
import Icon from '../../components/Global/components/icon'
import LibraryStackParamList from './types'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
export default function AddPlaylist({
navigation,
@@ -23,6 +22,8 @@ export default function AddPlaylist({
const { api, user } = useJellifyContext()
const [name, setName] = useState<string>('')
const trigger = useHapticFeedback()
const useAddPlaylist = useMutation({
mutationFn: ({ name }: { name: string }) => createPlaylist(api, user, name),
onSuccess: (data: void, { name }: { name: string }) => {

View File

@@ -4,19 +4,21 @@ import { H5, Text } from '../../components/Global/helpers/text'
import { useMutation } from '@tanstack/react-query'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { deletePlaylist } from '../../api/mutations/playlists'
import { trigger } from 'react-native-haptic-feedback'
import { queryClient } from '../../constants/query-client'
import { QueryKeys } from '../../enums/query-keys'
import { useJellifyContext } from '../../providers'
import Icon from '../../components/Global/components/icon'
import { LibraryDeletePlaylistProps } from './types'
// import * as Burnt from 'burnt'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
export default function DeletePlaylist({
navigation,
route,
}: LibraryDeletePlaylistProps): React.JSX.Element {
const { api, user, library } = useJellifyContext()
const trigger = useHapticFeedback()
const useDeletePlaylist = useMutation({
mutationFn: (playlist: BaseItemDto) => deletePlaylist(api, playlist.Id!),
onSuccess: (data: void, playlist: BaseItemDto) => {

View File

@@ -1,24 +1,21 @@
import React, { useEffect, useState } from 'react'
import _, { isUndefined } from 'lodash'
import { useMutation } from '@tanstack/react-query'
import { JellifyServer } from '../../types/JellifyServer'
import { isEmpty, isUndefined } from 'lodash'
import { Input, ListItem, Separator, Spinner, XStack, YGroup, YStack } from 'tamagui'
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 { RootStackParamList } from '../types'
import Toast from 'react-native-toast-message'
import { useJellifyContext } from '../../providers'
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'
import usePublicSystemInfo from '../../api/mutations/public-system-info'
import HTTPS, { HTTP } from '../../constants/protocols'
import { JellifyServer } from '@/src/types/JellifyServer'
export default function ServerAddress({
navigation,
@@ -32,65 +29,31 @@ export default function ServerAddress({
const [useHttps, setUseHttps] = useState<boolean>(true)
const [serverAddress, setServerAddress] = useState<string | undefined>(undefined)
const { server, setServer, signOut } = useJellifyContext()
const { signOut } = useJellifyContext()
const [sendMetrics, setSendMetrics] = useSendMetricsSetting()
useEffect(() => {
setServerAddressContainsProtocol(
!isUndefined(serverAddress) &&
(serverAddress.includes(http) || serverAddress.includes(https)),
(serverAddress.includes(HTTP) || serverAddress.includes(HTTPS)),
)
setServerAddressContainsHttps(!isUndefined(serverAddress) && serverAddress.includes(https))
setServerAddressContainsHttps(!isUndefined(serverAddress) && serverAddress.includes(HTTPS))
}, [serverAddress])
useEffect(() => {
sleepify(1000).then(() => signOut())
}, [])
const useServerMutation = useMutation({
mutationFn: () => connectToServer(serverAddress!, useHttps),
onSuccess: ({
publicSystemInfoResponse,
connectionType,
}: {
publicSystemInfoResponse: PublicSystemInfo
connectionType: 'hostname' | 'ipAddress'
}) => {
if (!publicSystemInfoResponse.Version)
throw new Error('Jellyfin instance did not respond')
console.debug(`Connected to Jellyfin via ${connectionType}`, publicSystemInfoResponse)
console.log(`Connected to Jellyfin ${publicSystemInfoResponse.Version!}`)
const server: JellifyServer = {
url:
connectionType === 'hostname'
? `${serverAddressContainsProtocol ? '' : useHttps ? https : http}${serverAddress!}`
: publicSystemInfoResponse.LocalAddress!,
address: serverAddress!,
name: publicSystemInfoResponse.ServerName!,
version: publicSystemInfoResponse.Version!,
startUpComplete: publicSystemInfoResponse.StartupWizardCompleted!,
}
setServer(server)
const { mutate: connectToServer, isPending } = usePublicSystemInfo({
onSuccess: (server: JellifyServer) => {
navigation.navigate('ServerAuthentication')
},
onError: async (error: Error) => {
console.error('An error occurred connecting to the Jellyfin instance', error)
setServer(undefined)
// Burnt.toast({
// title: 'Unable to connect',
// preset: 'error',
// // message: `Unable to connect to Jellyfin at ${useHttps ? https : http}${serverAddress}`,
// })
onError: () => {
Toast.show({
text1: 'Unable to connect',
text2: `Unable to connect to Jellyfin at ${
serverAddressContainsProtocol ? '' : useHttps ? https : http
text2: ` at ${
serverAddressContainsProtocol ? '' : useHttps ? HTTPS : HTTP
}${serverAddress}`,
type: 'error',
})
@@ -121,7 +84,7 @@ export default function ServerAddress({
textAlign='center'
verticalAlign={'center'}
>
{useHttps ? https : http}
{useHttps ? HTTPS : HTTP}
</Text>
)}
@@ -200,13 +163,14 @@ export default function ServerAddress({
</YGroup.Item>
</YGroup>
{useServerMutation.isPending ? (
{isPending ? (
<Spinner />
) : (
<Button
disabled={_.isEmpty(serverAddress)}
disabled={isEmpty(serverAddress)}
onPress={() => {
useServerMutation.mutate()
if (!isUndefined(serverAddress))
connectToServer({ serverAddress, useHttps })
}}
testID='connect_button'
>

View File

@@ -1,69 +1,37 @@
import React, { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import _ from 'lodash'
import { JellyfinCredentials } from '../../api/types/jellyfin-credentials'
import { getToken, H6, Spacer, Spinner, XStack, YStack } from 'tamagui'
import { H2, H5, Text } from '../../components/Global/helpers/text'
import { H6, Spacer, Spinner, XStack, YStack } from 'tamagui'
import { H2 } 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 { RootStackParamList } from '../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 { AxiosResponse } from 'axios'
import { AuthenticationResult } from '@jellyfin/sdk/lib/generated-client/models'
import LoginStackParamList from './types'
import useAuthenticateUserByName from '../../api/mutations/authentication'
export default function ServerAuthentication({
navigation,
}: {
navigation: NativeStackNavigationProp<LoginStackParamList>
}): React.JSX.Element {
const { api } = useJellifyContext()
const [username, setUsername] = useState<string | undefined>(undefined)
const [password, setPassword] = React.useState<string | undefined>(undefined)
const { server, setUser, setServer } = useJellifyContext()
const useApiMutation = useMutation({
mutationFn: async (credentials: JellyfinCredentials) => {
return await api!.authenticateUserByName(credentials.username, credentials.password)
},
onSuccess: async (authResult: AxiosResponse<AuthenticationResult>) => {
console.log(`Received auth response from server`)
if (_.isUndefined(authResult))
return Promise.reject(new Error('Authentication result was empty'))
if (authResult.status >= 400 || _.isEmpty(authResult.data.AccessToken))
return Promise.reject(new Error('Invalid credentials'))
if (_.isUndefined(authResult.data.User))
return Promise.reject(new Error('Unable to login'))
console.log(`Successfully signed in to server`)
const user: JellifyUser = {
id: authResult.data.User!.Id!,
name: authResult.data.User!.Name!,
accessToken: authResult.data.AccessToken as string,
}
setUser(user)
const { server } = useJellifyContext()
const { mutate: authenticateUserByName, isPending } = useAuthenticateUserByName({
onSuccess: () => {
navigation.navigate('LibrarySelection')
},
onError: async (error: Error) => {
console.error('An error occurred connecting to the Jellyfin instance', error)
onError: () => {
Toast.show({
text1: `Unable to sign in to ${server!.name}`,
type: 'error',
})
return Promise.reject(`An error occured signing into ${server!.name}`)
},
})
@@ -79,7 +47,7 @@ export default function ServerAuthentication({
</YStack>
<YStack marginHorizontal={'$4'}>
<Input
prependElement={<Icon name='human-greeting-variant' color={'$borderColor'} />}
prependElement={<Icon name='human-greeting-variant' color={'$primary'} />}
placeholder='Username'
value={username}
style={
@@ -95,7 +63,7 @@ export default function ServerAuthentication({
<Spacer />
<Input
prependElement={<Icon name='lock-outline' color={'$borderColor'} />}
prependElement={<Icon name='lock-outline' color={'$primary'} />}
placeholder='Password'
value={password}
testID='password_input'
@@ -116,23 +84,23 @@ export default function ServerAuthentication({
icon={() => <Icon name='chevron-left' small />}
bordered={0}
onPress={() => {
navigation.navigate('ServerAddress', undefined, { pop: true })
navigation.popTo('ServerAddress', undefined)
}}
>
Switch Server
</Button>
{useApiMutation.isPending ? (
{isPending ? (
<Spinner />
) : (
<Button
marginVertical={0}
disabled={_.isEmpty(username) || useApiMutation.isPending}
disabled={_.isEmpty(username) || isPending}
icon={() => <Icon name='chevron-right' small />}
testID='sign_in_button'
onPress={() => {
if (!_.isUndefined(username)) {
console.log(`Signing in...`)
useApiMutation.mutate({ username, password })
authenticateUserByName({ username, password })
}
}}
>

View File

@@ -12,7 +12,7 @@ export default function ServerLibrary({
}: {
navigation: NativeStackNavigationProp<LoginStackParamList>
}): React.JSX.Element {
const { setUser, setLibrary } = useJellifyContext()
const { setLibrary } = useJellifyContext()
const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
@@ -32,7 +32,6 @@ export default function ServerLibrary({
}
const handleCancel = () => {
setUser(undefined)
navigation.navigate('ServerAuthentication', undefined, {
pop: true,
})

View File

@@ -2133,12 +2133,12 @@
invariant "^2.2.4"
nullthrows "^1.1.1"
"@react-navigation/bottom-tabs@^7.4.6":
version "7.4.6"
resolved "https://registry.yarnpkg.com/@react-navigation/bottom-tabs/-/bottom-tabs-7.4.6.tgz#a7c7b4347d9349babc1d481fc59f814dbe7d7222"
integrity sha512-f4khxwcL70O5aKfZFbxyBo5RnzPFnBNSXmrrT7q9CRmvN4mHov9KFKGQ3H4xD5sLonsTBtyjvyvPfyEC4G7f+g==
"@react-navigation/bottom-tabs@^7.4.7":
version "7.4.7"
resolved "https://registry.yarnpkg.com/@react-navigation/bottom-tabs/-/bottom-tabs-7.4.7.tgz#c6fb80bfe25f47db27491918a764e01877f7efeb"
integrity sha512-SQ4KuYV9yr3SV/thefpLWhAD0CU2CrBMG1l0w/QKl3GYuGWdN5OQmdQdmaPZGtsjjVOb+N9Qo7Tf6210P4TlpA==
dependencies:
"@react-navigation/elements" "^2.6.3"
"@react-navigation/elements" "^2.6.4"
color "^4.2.3"
"@react-navigation/core@^7.12.4":
@@ -2154,30 +2154,30 @@
use-latest-callback "^0.2.4"
use-sync-external-store "^1.5.0"
"@react-navigation/elements@^2.6.3":
version "2.6.3"
resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-2.6.3.tgz#77cc4d989c0831ec59dc87b982f18bc644ac8e67"
integrity sha512-hcPXssZg5bFD5oKX7FP0D9ZXinRgPUHkUJbTegpenSEUJcPooH1qzWJkEP22GrtO+OPDLYrCVZxEX8FcMrn4pA==
"@react-navigation/elements@^2.6.4":
version "2.6.4"
resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-2.6.4.tgz#f1dc8548b1289588fabcd2f0342c1391c689a49f"
integrity sha512-O3X9vWXOEhAO56zkQS7KaDzL8BvjlwZ0LGSteKpt1/k6w6HONG+2Wkblrb057iKmehTkEkQMzMLkXiuLmN5x9Q==
dependencies:
color "^4.2.3"
use-latest-callback "^0.2.4"
use-sync-external-store "^1.5.0"
"@react-navigation/material-top-tabs@^7.3.6":
version "7.3.6"
resolved "https://registry.yarnpkg.com/@react-navigation/material-top-tabs/-/material-top-tabs-7.3.6.tgz#39f7b37c948c24a65b5a5229f8d65e86a65e0403"
integrity sha512-94r6euJ0VFnJ6Ixp4BWO9sTQjuh7dq6nEBirMRLqVZXMVZS6nsB2olw7cA8vWjQCXIM3nLNIa2t/hIzRH2yR6Q==
"@react-navigation/material-top-tabs@^7.3.7":
version "7.3.7"
resolved "https://registry.yarnpkg.com/@react-navigation/material-top-tabs/-/material-top-tabs-7.3.7.tgz#16e8efaf7bc3be89c2c1e5a4f48124149ec0db3a"
integrity sha512-lVgcJON9kqCETE628/E5da0jbSB1QxHdYaKetxAIEFCgcANebcg1wU80P2B3PBFp2qRrLfrPS9oHTUfaUIjLSA==
dependencies:
"@react-navigation/elements" "^2.6.3"
"@react-navigation/elements" "^2.6.4"
color "^4.2.3"
react-native-tab-view "^4.1.3"
"@react-navigation/native-stack@^7.3.25":
version "7.3.25"
resolved "https://registry.yarnpkg.com/@react-navigation/native-stack/-/native-stack-7.3.25.tgz#426179dd10f90977480c7d7720f094ef64c840bb"
integrity sha512-jGcgUpif0dDGwuqag6rKTdS78MiAVAy8vmQppyaAgjS05VbCfDX+xjhc8dUxSClO5CoWlDoby1c8Hw4kBfL2UA==
"@react-navigation/native-stack@^7.3.26":
version "7.3.26"
resolved "https://registry.yarnpkg.com/@react-navigation/native-stack/-/native-stack-7.3.26.tgz#a08ee0626e49428a808da9d810f24db5b08deae9"
integrity sha512-EjaBWzLZ76HJGOOcWCFf+h/M+Zg7M1RalYioDOb6ZdXHz7AwYNidruT3OUAQgSzg3gVLqvu5OYO0jFsNDPCZxQ==
dependencies:
"@react-navigation/elements" "^2.6.3"
"@react-navigation/elements" "^2.6.4"
warn-once "^0.1.1"
"@react-navigation/native@^7.1.17":