Enhancements to Offline (#279)

This commit is contained in:
Violet Caulfield
2025-04-23 13:53:07 -05:00
committed by GitHub
parent af7e9f562f
commit c3403bc080
17 changed files with 368 additions and 22033 deletions

View File

@@ -24,7 +24,7 @@ jobs:
run: npm run pod:install
- name: Version Up
run: npx react-native bump-version --type patch
run: npx react-native bump-version --type minor
- name: 💬 Echo package.json version to Github ENV

View File

@@ -1 +1 @@
npx lint-staged
yarn lint-staged

View File

@@ -1,10 +1,9 @@
import { usePlayerContext } from '../../../player/provider'
import React from 'react'
import { getToken, getTokens, Spacer, Theme, useTheme, XStack, YStack } from 'tamagui'
import { getToken, getTokens, Theme, useTheme, XStack, YStack } from 'tamagui'
import { Text } from '../helpers/text'
import { RunTimeTicks } from '../helpers/time-codes'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import Icon from '../helpers/icon'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { StackParamList } from '../../../components/types'
@@ -14,10 +13,9 @@ import FavoriteIcon from './favorite-icon'
import { Image } from 'expo-image'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import Client from '../../../api/client'
import { QueryKeys } from '../../../enums/query-keys'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { getAudioCache } from '../../../components/Network/offlineModeUtils'
import { networkStatusTypes } from '../../../components/Network/internetConnectionWatcher'
import { useNetworkContext } from '../../../components/Network/provider'
interface TrackProps {
track: BaseItemDto
@@ -52,10 +50,11 @@ export default function Track({
}: TrackProps): React.JSX.Element {
const theme = useTheme()
const { nowPlaying, playQueue, usePlayNewQueue, usePlayNewQueueOffline } = usePlayerContext()
const { data: networkStatus } = useQuery({ queryKey: [QueryKeys.NetworkStatus] })
const { downloadedTracks, networkStatus } = useNetworkContext()
const isPlaying = nowPlaying?.item.Id === track.Id
const offlineAudio = getAudioCache().find((t) => t.item.Id === track.Id)
const offlineAudio = downloadedTracks?.find((t) => t.item.Id === track.Id)
const isDownloaded = offlineAudio?.item?.Id
const isOffline = networkStatus === networkStatusTypes.DISCONNECTED

View File

@@ -3,6 +3,7 @@ import { StackParamList } from '../../../components/types'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import {
Circle,
getToken,
getTokens,
ListItem,
@@ -17,7 +18,7 @@ import { QueuingType } from '../../../enums/queuing-type'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import IconButton from '../../../components/Global/helpers/icon-button'
import { Text } from '../../../components/Global/helpers/text'
import React, { useState } from 'react'
import React from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { AddToPlaylistMutation } from '../types'
import { addToPlaylist } from '../../../api/mutations/functions/playlists'
@@ -31,8 +32,7 @@ import * as Burnt from 'burnt'
import { Image } from 'expo-image'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import Client from '../../../api/client'
import { mapDtoToTrack } from '../../../helpers/mappings'
import { getAudioCache, saveAudio } from '../../../components/Network/offlineModeUtils'
import { useNetworkContext } from '../../../components/Network/provider'
interface TrackOptionsProps {
track: BaseItemDto
@@ -54,22 +54,14 @@ export default function TrackOptions({
queryFn: () => fetchItem(track.AlbumId!),
})
const [isDownloading, setIsDownloading] = useState(false)
const jellifyTrack = mapDtoToTrack(track)
const { useDownload, useRemoveDownload, downloadedTracks } = useNetworkContext()
const onDownloadPress = async () => {
setIsDownloading(true)
await saveAudio(jellifyTrack, queryClient, true)
setIsDownloading(false)
}
const isDownloaded = !!getAudioCache().find((t) => t.item.Id === track.Id)?.item?.Id
const isDownloaded = downloadedTracks?.find((t) => t.item.Id === track.Id)?.item?.Id
const {
data: playlists,
isPending: playlistsFetchPending,
isSuccess: playlistsFetchSuccess,
refetch,
} = useQuery({
queryKey: [QueryKeys.UserPlaylists],
queryFn: () => fetchUserPlaylists(),
@@ -169,18 +161,23 @@ export default function TrackOptions({
}}
size={width / 6}
/>
<IconButton
disabled={isDownloaded || isDownloading}
circular
name={isDownloaded ? 'check' : 'download'}
title={
isDownloaded ? 'Downloaded' : isDownloading ? 'Downloading...' : 'Download'
}
onPress={() => {
onDownloadPress()
}}
size={width / 6}
/>
{useDownload.isPending ? (
<Circle size={width / 6} disabled>
<Spinner marginHorizontal={10} size='small' color={'$amethyst'} />
</Circle>
) : (
<IconButton
disabled={!!isDownloaded}
circular
name={isDownloaded ? 'delete' : 'download'}
title={isDownloaded ? 'Remove Download' : 'Download'}
onPress={() => {
(isDownloaded ? useRemoveDownload : useDownload).mutate(track)
}}
size={width / 6}
/>
)}
</XStack>
<Spacer />

View File

@@ -2,7 +2,7 @@ import NetInfo from '@react-native-community/netinfo'
import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { Platform } from 'react-native'
import { YStack } from 'tamagui'
import { getTokenValue, YStack } from 'tamagui'
import Animated, {
useSharedValue,
useAnimatedStyle,
@@ -35,7 +35,10 @@ const InternetConnectionWatcher = () => {
const opacity = useSharedValue(0)
const animateBannerIn = () => {
bannerHeight.value = withTiming(40, { duration: 300, easing: Easing.out(Easing.ease) })
bannerHeight.value = withTiming(getTokenValue('$8'), {
duration: 300,
easing: Easing.out(Easing.ease),
})
opacity.value = withTiming(1, { duration: 300 })
}
@@ -110,7 +113,7 @@ const InternetConnectionWatcher = () => {
return (
<Animated.View style={[{ overflow: 'hidden' }, animatedStyle]}>
<YStack
height={'$4'}
height={'$1.5'}
justifyContent='center'
alignContent='center'
backgroundColor={

View File

@@ -1,10 +1,12 @@
import { MMKV } from 'react-native-mmkv'
import RNFS from 'react-native-fs'
import { JellifyTrack } from '@/types/JellifyTrack'
import { JellifyTrack } from '../../types/JellifyTrack'
import axios from 'axios'
import { QueryClient } from '@tanstack/react-query'
import { JellifyDownload } from '../../types/JellifyDownload'
import DownloadProgress from '../../types/DownloadProgress'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
export async function downloadJellyfinFile(
url: string,
@@ -29,8 +31,7 @@ export async function downloadJellyfinFile(
const fileName = `${name}.${extension}`
const downloadDest = `${RNFS.DocumentDirectoryPath}/${fileName}`
/* eslint-disable @typescript-eslint/no-explicit-any */
queryClient.setQueryData(['downloads'], (prev: any = {}) => ({
queryClient.setQueryData(['downloads'], (prev: DownloadProgress) => ({
...prev,
[url]: { progress: 0, name: fileName, songName: songName },
}))
@@ -47,8 +48,7 @@ export async function downloadJellyfinFile(
progress: (data: any) => {
const percent = +(data.bytesWritten / data.contentLength).toFixed(2)
/* eslint-disable @typescript-eslint/no-explicit-any */
queryClient.setQueryData(['downloads'], (prev: any = {}) => ({
queryClient.setQueryData(['downloads'], (prev: DownloadProgress) => ({
...prev,
[url]: { progress: percent, name: fileName, songName: songName },
}))
@@ -59,6 +59,7 @@ export async function downloadJellyfinFile(
const result = await RNFS.downloadFile(options).promise
console.log('Download complete:', result)
return `file://${downloadDest}`
} catch (error) {
console.error('Download failed:', error)
@@ -125,6 +126,24 @@ export const saveAudio = async (
mmkv.set(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE, JSON.stringify(existingArray))
}
export const deleteAudio = async (trackItem: BaseItemDto) => {
const downloads = getAudioCache()
const download = downloads.filter((download) => download.item.Id === trackItem.Id)
if (download.length === 1) {
RNFS.unlink(`${RNFS.DocumentDirectoryPath}/${download[0].item.Id}`)
setAudioCache([
...downloads.slice(0, downloads.indexOf(download[0])),
...downloads.slice(downloads.indexOf(download[0]) + 1, downloads.length - 1),
])
}
}
const setAudioCache = (downloads: JellifyDownload[]) => {
mmkv.set(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE, JSON.stringify(downloads))
}
export const getAudioCache = (): JellifyDownload[] => {
const existingRaw = mmkv.getString(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE)
let existingArray: JellifyDownload[] = []

View File

@@ -0,0 +1,141 @@
import React, { createContext, ReactNode, useContext } from 'react'
import { JellifyDownload } from '../../types/JellifyDownload'
import {
QueryObserverResult,
RefetchOptions,
useMutation,
UseMutationResult,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { mapDtoToTrack } from '../../helpers/mappings'
import { deleteAudio, getAudioCache, saveAudio } from './offlineModeUtils'
import { QueryKeys } from '../../enums/query-keys'
import { networkStatusTypes } from './internetConnectionWatcher'
import DownloadProgress from '../../types/DownloadProgress'
interface NetworkContext {
useDownload: UseMutationResult<void, Error, BaseItemDto, unknown>
useRemoveDownload: UseMutationResult<void, Error, BaseItemDto, unknown>
downloadedTracks: JellifyDownload[] | undefined
activeDownloads: DownloadProgress[] | undefined
networkStatus: networkStatusTypes | undefined
}
const NetworkContextInitializer = () => {
const queryClient = useQueryClient()
const useDownload = useMutation({
mutationFn: (trackItem: BaseItemDto) => {
const track = mapDtoToTrack(trackItem)
return saveAudio(track, queryClient, false)
},
onSuccess: (data, variables) => {
console.debug(`Downloaded ${variables.Id} successfully`)
refetchDownloadedTracks()
return data
},
})
const useRemoveDownload = useMutation({
mutationFn: (trackItem: BaseItemDto) => deleteAudio(trackItem),
onSuccess: (data, { Id }) => {
console.debug(`Removed ${Id} from storage`)
refetchDownloadedTracks()
},
})
const { data: networkStatus } = useQuery<networkStatusTypes>({
queryKey: [QueryKeys.NetworkStatus],
})
const { data: activeDownloads } = useQuery<DownloadProgress[]>({
queryKey: ['downloads'],
initialData: [],
})
const { data: downloadedTracks, refetch: refetchDownloadedTracks } = useQuery({
queryKey: [QueryKeys.AudioCache],
queryFn: getAudioCache,
staleTime: 1000 * 60, // 1 minute
})
return {
useDownload,
useRemoveDownload,
activeDownloads,
downloadedTracks,
networkStatus,
}
}
const NetworkContext = createContext<NetworkContext>({
useDownload: {
mutate: () => {},
mutateAsync: async () => {},
data: undefined,
error: null,
variables: undefined,
isError: false,
isIdle: true,
isPaused: false,
isPending: false,
isSuccess: false,
status: 'idle',
reset: () => {},
context: {},
failureCount: 0,
failureReason: null,
submittedAt: 0,
},
useRemoveDownload: {
mutate: () => {},
mutateAsync: async () => {},
data: undefined,
error: null,
variables: undefined,
isError: false,
isIdle: true,
isPaused: false,
isPending: false,
isSuccess: false,
status: 'idle',
reset: () => {},
context: {},
failureCount: 0,
failureReason: null,
submittedAt: 0,
},
downloadedTracks: [],
activeDownloads: [],
networkStatus: networkStatusTypes.ONLINE,
})
export const NetworkContextProvider: ({
children,
}: {
children: ReactNode
}) => React.JSX.Element = ({ children }: { children: ReactNode }) => {
const { useDownload, useRemoveDownload, downloadedTracks, activeDownloads, networkStatus } =
NetworkContextInitializer()
return (
<NetworkContext.Provider
value={{
useDownload,
useRemoveDownload,
activeDownloads,
downloadedTracks,
networkStatus,
}}
>
{children}
</NetworkContext.Provider>
)
}
export const useNetworkContext = () => useContext(NetworkContext)

View File

@@ -1,16 +1,15 @@
import React, { useEffect, useMemo, useState } from 'react'
import React, { useEffect, useState } from 'react'
import { useProgress } from 'react-native-track-player'
import { HorizontalSlider } from '../../../components/Global/helpers/slider'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { trigger } from 'react-native-haptic-feedback'
import { getToken, XStack, YStack } from 'tamagui'
import { XStack, YStack } from 'tamagui'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import { usePlayerContext } from '../../../player/provider'
import { RunTimeSeconds } from '../../../components/Global/helpers/time-codes'
import { UPDATE_INTERVAL } from '../../../player/config'
import { ProgressMultiplier } from '../component.config'
import Icon from '../../../components/Global/helpers/icon'
import PlayPauseButton from './buttons'
import { useSharedValue } from 'react-native-reanimated'
const scrubGesture = Gesture.Pan()

View File

@@ -1,7 +1,7 @@
import { StackParamList } from '../../../components/types'
import { usePlayerContext } from '../../../player/provider'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import React, { useEffect, useMemo, useState } from 'react'
import React, { useMemo } from 'react'
import { SafeAreaView, useSafeAreaFrame } from 'react-native-safe-area-context'
import { YStack, XStack, Spacer, getTokens } from 'tamagui'
import { Text } from '../../../components/Global/helpers/text'
@@ -15,14 +15,6 @@ import Controls from '../helpers/controls'
import { Image } from 'expo-image'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import Client from '../../../api/client'
import {
saveAudio,
getAudioCache,
purneAudioCache,
} from '../../../components/Network/offlineModeUtils'
import { useActiveTrack } from 'react-native-track-player'
import { ActivityIndicator, Alert } from 'react-native'
import { useQueryClient } from '@tanstack/react-query'
export default function PlayerScreen({
navigation,
@@ -31,30 +23,8 @@ export default function PlayerScreen({
}): React.JSX.Element {
const { nowPlayingIsFavorite, setNowPlayingIsFavorite, nowPlaying, queue } = usePlayerContext()
const [isDownloading, setIsDownloading] = useState(false)
const isDownloaded = getAudioCache().find((item) => item?.item?.Id === nowPlaying?.item.Id)
?.item?.Id
const activeTrack = useActiveTrack()
const queryClient = useQueryClient()
const { width } = useSafeAreaFrame()
const downloadAudio = async (url: string) => {
if (!nowPlaying) {
return
}
setIsDownloading(true)
await saveAudio(nowPlaying, queryClient)
setIsDownloading(false)
purneAudioCache()
}
useEffect(() => {
if (!isDownloaded) {
downloadAudio(nowPlaying!.url)
}
}, [])
return (
<SafeAreaView edges={['right', 'left']}>
{nowPlaying && (
@@ -200,17 +170,8 @@ export default function PlayerScreen({
<Icon name='speaker-multiple' />
<Spacer />
{isDownloading ? (
<ActivityIndicator />
) : (
<Icon
name={!isDownloaded ? 'download' : 'check'}
onPress={() => {
downloadAudio(nowPlaying!.url)
}}
disabled={isDownloading || !!isDownloaded}
/>
)}
<Spacer />
<Spacer />

View File

@@ -1,10 +1,11 @@
import React, { useEffect, useState } from 'react'
import { View, Text, StyleSheet, Pressable, Alert, FlatList } from 'react-native'
import { useQuery } from '@tanstack/react-query'
import RNFS from 'react-native-fs'
import MaterialIcons from 'react-native-vector-icons/MaterialIcons'
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated'
import { deleteAudioCache, getAudioCache } from '../Network/offlineModeUtils'
import { deleteAudioCache } from '../Network/offlineModeUtils'
import { useNetworkContext } from '../Network/provider'
import DownloadProgress from '../../types/DownloadProgress'
import Icon from '../Global/helpers/icon'
// 🔹 Single Download Item with animated progress bar
const DownloadItem = ({
@@ -41,10 +42,7 @@ export const StorageBar = () => {
const [used, setUsed] = useState(0)
const [total, setTotal] = useState(1)
const { data: downloads } = useQuery({
queryKey: ['downloads'],
initialData: {},
})
const { downloadedTracks, activeDownloads } = useNetworkContext()
const usageShared = useSharedValue(0)
const percentUsed = used / total
@@ -71,8 +69,7 @@ export const StorageBar = () => {
}
const deleteAllDownloads = async () => {
const files = getAudioCache()
for (const file of files) {
for (const file of downloadedTracks ?? []) {
await RNFS.unlink(file.url).catch(() => {})
}
Alert.alert('Deleted', 'All downloads removed.')
@@ -84,11 +81,6 @@ export const StorageBar = () => {
refreshStats()
}, [])
const downloadList = Object.entries(downloads || {}) as [
string,
{ name: string; progress: number; songName: string },
][]
return (
<View style={styles.container}>
{/* Storage Usage */}
@@ -101,16 +93,19 @@ export const StorageBar = () => {
</View>
{/* Active Downloads */}
{downloadList.length > 0 && (
{(activeDownloads ?? []).length > 0 && (
<>
<Text style={[styles.title, { marginTop: 24 }]}> Active Downloads</Text>
<FlatList
data={downloadList}
keyExtractor={([url]) => url}
data={activeDownloads}
keyExtractor={(download) => download.name}
renderItem={({ item }) => {
const [url, { name, progress, songName }] = item
return (
<DownloadItem name={name} progress={progress} fileName={songName} />
<DownloadItem
name={item.name}
progress={item.progress}
fileName={item.songName}
/>
)
}}
contentContainerStyle={{ paddingBottom: 40 }}
@@ -120,7 +115,7 @@ export const StorageBar = () => {
{/* Delete All Downloads */}
<Pressable style={styles.deleteButton} onPress={deleteAllDownloads}>
<MaterialIcons name='delete-outline' size={20} color='#ff4d4f' />
<Icon name='delete-outline' small color='#ff4d4f' />
<Text style={styles.deleteText}> Delete Downloads</Text>
</Pressable>
</View>

View File

@@ -9,6 +9,7 @@ import { PortalProvider } from '@tamagui/portal'
import { JellifyProvider, useJellifyContext } from './provider'
import { ToastProvider } from '@tamagui/toast'
import { JellifyUserDataProvider } from './user-data-provider'
import { NetworkContextProvider } from './Network/provider'
export default function Jellify(): React.JSX.Element {
return (
@@ -28,9 +29,11 @@ function App(): React.JSX.Element {
return loggedIn ? (
<JellifyUserDataProvider>
<PlayerProvider>
<Navigation />
</PlayerProvider>
<NetworkContextProvider>
<PlayerProvider>
<Navigation />
</PlayerProvider>
</NetworkContextProvider>
</JellifyUserDataProvider>
) : (
<JellyfinAuthenticationProvider>

View File

@@ -66,4 +66,5 @@ export enum QueryKeys {
Audio = 'Audio',
RecentlyAdded = 'RecentlyAdded',
SimilarItems = 'SimilarItems',
AudioCache = 'AudioCache',
}

21787
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,122 +1,122 @@
{
"name": "jellify",
"version": "0.10.113",
"private": true,
"scripts": {
"init": "npm i",
"init:ios": "npm i && npm run pod:install",
"reinstall": "rm -rf ./node_modules && npm i",
"android": "react-native run-android",
"ios": "react-native run-ios",
"lint": "eslint .",
"start": "react-native start",
"test": "jest",
"clean:ios": "cd ios && pod deintegrate",
"clean:android": "cd android && rm -rf app/ build/",
"pod:install": "cd ios && bundle install && RCT_NEW_ARCH_ENABLED=0 bundle exec pod install",
"pod:install-new-arch": "cd ios && bundle install && RCT_NEW_ARCH_ENABLED=1 bundle exec pod install",
"fastlane:ios:build": "cd ios && bundle exec fastlane build",
"fastlane:ios:beta": "cd ios && bundle exec fastlane beta",
"fastlane:android:build": "cd android && bundle install && bundle exec fastlane build",
"androidBuild": "cd android && ./gradlew clean && ./gradlew assembleRelease && cd .. && echo 'find apk in android/app/build/outputs/apk/release'",
"prepare": "husky",
"format:check": "prettier --check .",
"format": "prettier --write .",
"postinstall": "patch-package"
},
"dependencies": {
"@jellyfin/sdk": "^0.11.0",
"@react-native-community/blur": "^4.4.1",
"@react-native-community/cli": "^15.1.3",
"@react-native-community/netinfo": "^11.4.1",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-navigation/bottom-tabs": "^7.2.0",
"@react-navigation/material-top-tabs": "^7.2.10",
"@react-navigation/native": "^7.1.6",
"@react-navigation/native-stack": "^7.1.1",
"@react-navigation/stack": "^7.1.0",
"@tamagui/config": "^1.125.34",
"@tamagui/toast": "^1.125.34",
"@tanstack/query-sync-storage-persister": "^5.73.3",
"@tanstack/react-query": "^5.73.3",
"@tanstack/react-query-persist-client": "^5.73.3",
"axios": "^1.7.9",
"bundle": "^2.1.0",
"burnt": "^0.12.2",
"expo": "^52.0.0",
"expo-image": "^2.0.7",
"gem": "^2.4.3",
"invert-color": "^2.0.0",
"jest-expo": "^52.0.6",
"lodash": "^4.17.21",
"npm-bundle": "^3.0.3",
"patch-package": "^8.0.0",
"react": "18.3.1",
"react-freeze": "^1.0.4",
"react-native": "0.77.0",
"react-native-background-actions": "^4.0.1",
"react-native-blurhash": "^2.1.1",
"react-native-boost": "^0.5.5",
"react-native-carplay": "^2.4.1-beta.0",
"react-native-device-info": "^14.0.4",
"react-native-draggable-flatlist": "^4.0.1",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "^2.23.0",
"react-native-haptic-feedback": "^2.3.3",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "^6.7.0",
"react-native-reanimated": "^3.17.4",
"react-native-safe-area-context": "^5.2.0",
"react-native-screens": "^4.6.0",
"react-native-swipeable-item": "^2.0.9",
"react-native-text-ticker": "^1.14.0",
"react-native-track-player": "^4.1.1",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.3",
"react-native-vector-icons": "^10.2.0",
"ruby": "^0.6.1",
"tamagui": "^1.125.34"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.3",
"@babel/runtime": "^7.25.0",
"@react-native-community/cli-platform-android": "15.1.3",
"@react-native-community/cli-platform-ios": "15.1.3",
"@react-native/babel-preset": "0.77.0",
"@react-native/eslint-config": "0.77.0",
"@react-native/metro-config": "0.77.0",
"@react-native/typescript-config": "0.77.0",
"@types/jest": "^29.5.13",
"@types/lodash": "^4.17.10",
"@types/react": "^18.2.6",
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^8.29.1",
"@typescript-eslint/parser": "^8.29.1",
"babel-plugin-module-resolver": "^5.0.2",
"eslint": "^8.57.1",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-native": "^5.0.0",
"husky": "^9.1.7",
"jest": "^29.6.3",
"jscodeshift": "^0.15.2",
"lint-staged": "^15.5.0",
"prettier": "^2.8.8",
"react-native-cli-bump-version": "^1.5.1",
"react-test-renderer": "18.3.1",
"typescript": "5.7.3"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"prettier --write",
"eslint --fix"
]
},
"engines": {
"node": ">=18"
}
}
"name": "jellify",
"version": "0.10.113",
"private": true,
"scripts": {
"init": "npm i",
"init:ios": "npm i && npm run pod:install",
"reinstall": "rm -rf ./node_modules && npm i",
"android": "react-native run-android",
"ios": "react-native run-ios",
"lint": "eslint .",
"start": "react-native start",
"test": "jest",
"clean:ios": "cd ios && pod deintegrate",
"clean:android": "cd android && rm -rf app/ build/",
"pod:install": "cd ios && bundle install && RCT_NEW_ARCH_ENABLED=0 bundle exec pod install",
"pod:install-new-arch": "cd ios && bundle install && RCT_NEW_ARCH_ENABLED=1 bundle exec pod install",
"fastlane:ios:build": "cd ios && bundle exec fastlane build",
"fastlane:ios:beta": "cd ios && bundle exec fastlane beta",
"fastlane:android:build": "cd android && bundle install && bundle exec fastlane build",
"androidBuild": "cd android && ./gradlew clean && ./gradlew assembleRelease && cd .. && echo 'find apk in android/app/build/outputs/apk/release'",
"prepare": "husky",
"format:check": "prettier --check .",
"format": "prettier --write .",
"postinstall": "patch-package"
},
"dependencies": {
"@jellyfin/sdk": "^0.11.0",
"@react-native-community/blur": "^4.4.1",
"@react-native-community/cli": "^15.1.3",
"@react-native-community/netinfo": "^11.4.1",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-navigation/bottom-tabs": "^7.2.0",
"@react-navigation/material-top-tabs": "^7.2.10",
"@react-navigation/native": "^7.1.6",
"@react-navigation/native-stack": "^7.1.1",
"@react-navigation/stack": "^7.1.0",
"@tamagui/config": "^1.125.34",
"@tamagui/toast": "^1.125.34",
"@tanstack/query-sync-storage-persister": "^5.73.3",
"@tanstack/react-query": "^5.73.3",
"@tanstack/react-query-persist-client": "^5.73.3",
"axios": "^1.7.9",
"bundle": "^2.1.0",
"burnt": "^0.12.2",
"expo": "^52.0.0",
"expo-image": "^2.0.7",
"gem": "^2.4.3",
"invert-color": "^2.0.0",
"jest-expo": "^52.0.6",
"lodash": "^4.17.21",
"npm-bundle": "^3.0.3",
"patch-package": "^8.0.0",
"react": "18.3.1",
"react-freeze": "^1.0.4",
"react-native": "0.77.0",
"react-native-background-actions": "^4.0.1",
"react-native-blurhash": "^2.1.1",
"react-native-boost": "^0.5.5",
"react-native-carplay": "^2.4.1-beta.0",
"react-native-device-info": "^14.0.4",
"react-native-draggable-flatlist": "^4.0.1",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "^2.23.0",
"react-native-haptic-feedback": "^2.3.3",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "^6.7.0",
"react-native-reanimated": "^3.17.4",
"react-native-safe-area-context": "^5.2.0",
"react-native-screens": "^4.6.0",
"react-native-swipeable-item": "^2.0.9",
"react-native-text-ticker": "^1.14.0",
"react-native-track-player": "^4.1.1",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.3",
"react-native-vector-icons": "^10.2.0",
"ruby": "^0.6.1",
"tamagui": "^1.125.34"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.3",
"@babel/runtime": "^7.25.0",
"@react-native-community/cli-platform-android": "15.1.3",
"@react-native-community/cli-platform-ios": "15.1.3",
"@react-native/babel-preset": "0.77.0",
"@react-native/eslint-config": "0.77.0",
"@react-native/metro-config": "0.77.0",
"@react-native/typescript-config": "0.77.0",
"@types/jest": "^29.5.13",
"@types/lodash": "^4.17.10",
"@types/react": "^18.2.6",
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^8.29.1",
"@typescript-eslint/parser": "^8.29.1",
"babel-plugin-module-resolver": "^5.0.2",
"eslint": "^8.57.1",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-native": "^5.0.0",
"husky": "^9.1.7",
"jest": "^29.6.3",
"jscodeshift": "^0.15.2",
"lint-staged": "^15.5.0",
"prettier": "^2.8.8",
"react-native-cli-bump-version": "^1.5.1",
"react-test-renderer": "18.3.1",
"typescript": "5.7.3"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"prettier --write",
"eslint --fix"
]
},
"engines": {
"node": ">=18"
}
}

View File

@@ -36,6 +36,7 @@ import { markItemPlayed } from '../api/mutations/functions/item'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api'
import { SKIP_TO_PREVIOUS_THRESHOLD } from './config'
import { useNetworkContext } from '../components/Network/provider'
interface PlayerContext {
initialized: boolean
@@ -327,6 +328,7 @@ const PlayerContextInitializer = () => {
//#region RNTP Setup
const { state: playbackState } = usePlaybackState()
const { useDownload, downloadedTracks } = useNetworkContext()
useTrackPlayerEvents(
[
@@ -364,6 +366,16 @@ const PlayerContextInitializer = () => {
nowPlaying!,
event,
)
// Cache playing track at 20 seconds if it's not already downloaded
if (
Math.floor(event.position) === 20 &&
downloadedTracks?.filter(
(download) => download.item.Id === nowPlaying!.item.Id,
).length === 0
)
useDownload.mutate(nowPlaying!.item)
break
}

View File

@@ -0,0 +1,5 @@
export default interface DownloadProgress {
progress: number
name: string
songName: string
}

View File

@@ -4620,14 +4620,6 @@ buffer@^5.4.3, buffer@^5.5.0:
base64-js "^1.3.1"
ieee754 "^1.1.13"
buffer@^6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6"
integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==
dependencies:
base64-js "^1.3.1"
ieee754 "^1.2.1"
bundle@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/bundle/-/bundle-2.1.0.tgz#ab47ccc48eb688f706e6ecd323d01db6854aa25e"
@@ -6767,7 +6759,7 @@ iconv-lite@0.6.3:
dependencies:
safer-buffer ">= 2.1.2 < 3.0.0"
ieee754@^1.1.13, ieee754@^1.2.1:
ieee754@^1.1.13:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
@@ -9447,11 +9439,6 @@ react-native-draggable-flatlist@^4.0.1:
dependencies:
"@babel/preset-typescript" "^7.17.12"
react-native-file-access@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/react-native-file-access/-/react-native-file-access-3.1.1.tgz#023fc8a82ccc0e49761a76e87760f674f1695eb0"
integrity sha512-4KUpBAsnWJa+AQf1tUbLdHO+1pyiZMTeq3NPf5XOGdz1O5CwIrVkrzl+gkN7ffmUa5JyoYHyXUtwScmA+z0Tlg==
react-native-fs@^2.20.0:
version "2.20.0"
resolved "https://registry.yarnpkg.com/react-native-fs/-/react-native-fs-2.20.0.tgz#05a9362b473bfc0910772c0acbb73a78dbc810f6"
@@ -9489,7 +9476,7 @@ react-native-pager-view@^6.7.0:
resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.7.0.tgz#88f2520e85f07ce55f3f56c57d6637249a215160"
integrity sha512-sutxKiMqBuQrEyt4mLaLNzy8taIC7IuYpxfcwQBXfSYBSSpAa0qE9G1FXlP/iXqTSlFgBXyK7BESsl9umOjECQ==
react-native-reanimated@^3.17.2:
react-native-reanimated@^3.17.4:
version "3.17.4"
resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-3.17.4.tgz#a8c95ea7c2a089b6ca8f513c7bbff4e450986c7c"
integrity sha512-vmkG/N5KZrexHr4v0rZB7ohPVseGVNaCXjGxoRo+NYKgC9+mIZAkg/QIfy9xxfJ73FfTrryO9iYUrxks3ZfKbA==