Offline Mode MVP (#272)

* OfflineMode

* Template addition and Fixes

* More Changes

* More Changes

* smol updates to provider

run yarn format

* update internet connection watcher colors and verbiage

remove react native file access dependency

* Offline changes

* UI tweaks for offline indicator

* get jest to pass

---------

Co-authored-by: Ritesh Shukla <ritesh.shukla2@129net231.unica.it>
Co-authored-by: Ritesh Shukla <ritesh.shukla2@M-LD4JMWLW26.local>
Co-authored-by: Ritesh Shukla <75062358+riteshshukla04@users.noreply.github.com>
This commit is contained in:
Violet Caulfield
2025-04-22 12:07:55 -05:00
committed by GitHub
parent 1c61a752a0
commit a08e66546c
32 changed files with 12775 additions and 234 deletions

View File

@@ -15,6 +15,7 @@ import { createWorkletRuntime } from 'react-native-reanimated'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { NavigationContainer } from '@react-navigation/native'
import { JellifyDarkTheme, JellifyLightTheme } from './components/theme'
import { requestStoragePermission } from './helpers/permisson-helpers'
export const backgroundRuntime = createWorkletRuntime('background')
@@ -46,7 +47,13 @@ export default function App(): React.JSX.Element {
)
.finally(() => {
setPlayerIsReady(true)
requestStoragePermission()
})
const getActiveTrack = async () => {
const track = await TrackPlayer.getActiveTrack()
console.log('playerIsReady', track)
}
getActiveTrack()
return (
<SafeAreaProvider>

View File

@@ -3,6 +3,8 @@
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application
android:name=".MainApplication"
android:label="@string/app_name"

View File

@@ -23,6 +23,6 @@ export async function downloadTrack(itemId: string): Promise<void> {
})
}
function getTrackFilePath(itemId: string) {
export function getTrackFilePath(itemId: string) {
return `${Dirs.DocumentDir}/downloads/${itemId}`
}

View File

@@ -14,6 +14,10 @@ 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'
interface TrackProps {
track: BaseItemDto
@@ -47,10 +51,15 @@ export default function Track({
onRemove,
}: TrackProps): React.JSX.Element {
const theme = useTheme()
const { nowPlaying, playQueue, usePlayNewQueue } = usePlayerContext()
const { nowPlaying, playQueue, usePlayNewQueue, usePlayNewQueueOffline } = usePlayerContext()
const { data: networkStatus } = useQuery({ queryKey: [QueryKeys.NetworkStatus] })
const isPlaying = nowPlaying?.item.Id === track.Id
const offlineAudio = getAudioCache().find((t) => t.item.Id === track.Id)
const isDownloaded = offlineAudio?.item?.Id
const isOffline = networkStatus === networkStatusTypes.DISCONNECTED
return (
<Theme name={invertedColors ? 'inverted_purple' : undefined}>
<XStack
@@ -61,6 +70,10 @@ export default function Track({
if (onPress) {
onPress()
} else {
if (isOffline && isDownloaded) {
usePlayNewQueueOffline.mutate({ trackListOffline: offlineAudio })
return
}
usePlayNewQueue.mutate({
track,
index,
@@ -114,7 +127,15 @@ export default function Track({
<YStack alignContent='center' justifyContent='flex-start' flex={6}>
<Text
bold
color={isPlaying ? getTokens().color.telemagenta : theme.color}
color={
isPlaying
? getTokens().color.telemagenta
: isOffline
? isDownloaded
? theme.color
: '$purpleGray'
: theme.color
}
lineBreakStrategyIOS='standard'
numberOfLines={1}
>

View File

@@ -11,6 +11,7 @@ interface IconButtonProps {
circular?: boolean | undefined
size?: number
largeIcon?: boolean | undefined
disabled?: boolean | undefined
}
export default function IconButton({
@@ -20,6 +21,7 @@ export default function IconButton({
circular,
size,
largeIcon,
disabled,
}: IconButtonProps): React.JSX.Element {
return (
<Theme name={'inverted_purple'}>
@@ -38,7 +40,13 @@ export default function IconButton({
justifyContent='center'
backgroundColor={'$background'}
>
<Icon large={largeIcon} small={!largeIcon} name={name} color={'$color'} />
<Icon
large={largeIcon}
small={!largeIcon}
name={name}
color={'$color'}
disabled={disabled}
/>
{title && <Text>{title}</Text>}
</Square>

View File

@@ -16,12 +16,14 @@ export default function Icon({
small,
large,
extraLarge,
disabled,
color,
}: {
name: string
onPress?: () => void
small?: boolean
large?: boolean
disabled?: boolean
extraLarge?: boolean
color?: string | undefined
}): React.JSX.Element {
@@ -33,6 +35,7 @@ export default function Icon({
color={color ? color : theme.color.val}
name={name}
onPress={onPress}
disabled={disabled}
size={size}
/>
)

View File

@@ -10,6 +10,7 @@ import AddPlaylist from '../Library/components/add-playlist'
import ArtistsScreen from '../Artists/screen'
import TracksScreen from '../Tracks/screen'
import { ArtistScreen } from '../Artist'
import { OfflineList } from '../offlineList'
const Stack = createNativeStackNavigator<StackParamList>()

View File

@@ -17,7 +17,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 from 'react'
import React, { useState } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { AddToPlaylistMutation } from '../types'
import { addToPlaylist } from '../../../api/mutations/functions/playlists'
@@ -31,6 +31,8 @@ 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'
interface TrackOptionsProps {
track: BaseItemDto
@@ -52,6 +54,17 @@ export default function TrackOptions({
queryFn: () => fetchItem(track.AlbumId!),
})
const [isDownloading, setIsDownloading] = useState(false)
const jellifyTrack = mapDtoToTrack(track)
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 {
data: playlists,
isPending: playlistsFetchPending,
@@ -156,6 +169,18 @@ 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}
/>
</XStack>
<Spacer />

View File

@@ -0,0 +1,130 @@
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 Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
Easing,
runOnJS,
} from 'react-native-reanimated'
import { QueryKeys } from '../../enums/query-keys'
import { Text } from '../Global/helpers/text'
const internetConnectionWatcher = {
NO_INTERNET: 'You are offline',
BACK_ONLINE: "And we're back!",
}
export enum networkStatusTypes {
ONLINE = 'ONLINE',
DISCONNECTED = 'DISCONNECTED',
}
const isAndroid = Platform.OS === 'android'
const InternetConnectionWatcher = () => {
const [networkStatus, setNetworkStatus] = useState<keyof typeof networkStatusTypes | null>(null)
const lastNetworkStatus = useRef<keyof typeof networkStatusTypes | null>()
const queryClient = useQueryClient()
const bannerHeight = useSharedValue(0)
const opacity = useSharedValue(0)
const animateBannerIn = () => {
bannerHeight.value = withTiming(40, { duration: 300, easing: Easing.out(Easing.ease) })
opacity.value = withTiming(1, { duration: 300 })
}
const animateBannerOut = () => {
bannerHeight.value = withTiming(0, { duration: 300, easing: Easing.in(Easing.ease) })
opacity.value = withTiming(0, { duration: 200 })
}
const animatedStyle = useAnimatedStyle(() => {
return {
height: bannerHeight.value,
opacity: opacity.value,
}
})
const changeNetworkStatus = () => {
if (lastNetworkStatus.current !== networkStatusTypes.DISCONNECTED) {
setNetworkStatus(null)
}
}
const internetConnectionBack = () => {
setNetworkStatus(networkStatusTypes.ONLINE)
setTimeout(() => {
runOnJS(changeNetworkStatus)() // hide text after 3s
}, 3000)
}
useEffect(() => {
lastNetworkStatus.current = networkStatus
}, [networkStatus])
useEffect(() => {
if (networkStatus) {
queryClient.setQueryData([QueryKeys.NetworkStatus], networkStatus)
}
if (networkStatus === networkStatusTypes.DISCONNECTED) {
animateBannerIn()
} else if (networkStatus === networkStatusTypes.ONLINE) {
animateBannerIn()
setTimeout(() => {
animateBannerOut()
}, 2800)
} else if (networkStatus === null) {
animateBannerOut()
}
}, [networkStatus])
useEffect(() => {
const networkWatcherListener = NetInfo.addEventListener(
({ isConnected, isInternetReachable }) => {
const isNetworkDisconnected = !(
isConnected && (isAndroid ? isInternetReachable : true)
)
if (isNetworkDisconnected) {
setNetworkStatus(networkStatusTypes.DISCONNECTED)
} else if (
!isNetworkDisconnected &&
lastNetworkStatus.current === networkStatusTypes.DISCONNECTED
) {
internetConnectionBack()
}
},
)
return () => {
networkWatcherListener()
}
}, [])
return (
<Animated.View style={[{ overflow: 'hidden' }, animatedStyle]}>
<YStack
height={'$4'}
justifyContent='center'
alignContent='center'
backgroundColor={
networkStatus === networkStatusTypes.ONLINE ? '$success' : '$danger'
}
>
<Text textAlign='center' color='$purpleDark'>
{networkStatus === networkStatusTypes.ONLINE
? internetConnectionWatcher.BACK_ONLINE
: internetConnectionWatcher.NO_INTERNET}
</Text>
</YStack>
</Animated.View>
)
}
export default InternetConnectionWatcher

View File

@@ -0,0 +1,184 @@
import { MMKV } from 'react-native-mmkv'
import RNFS from 'react-native-fs'
import { JellifyTrack } from '@/types/JellifyTrack'
import axios from 'axios'
import { QueryClient } from '@tanstack/react-query'
import { JellifyDownload } from '../../types/JellifyDownload'
export async function downloadJellyfinFile(
url: string,
name: string,
songName: string,
queryClient: QueryClient,
) {
try {
// Fetch the file
const headRes = await axios.head(url)
const contentType = headRes.headers['content-type']
console.log('Content-Type:', contentType)
// Step 2: Get extension from content-type
let extension = 'mp3' // default
if (contentType && contentType.includes('/')) {
const parts = contentType.split('/')
extension = parts[1].split(';')[0] // handles "audio/m4a; charset=utf-8"
}
// Step 3: Build path
const fileName = `${name}.${extension}`
const downloadDest = `${RNFS.DocumentDirectoryPath}/${fileName}`
/* eslint-disable @typescript-eslint/no-explicit-any */
queryClient.setQueryData(['downloads'], (prev: any = {}) => ({
...prev,
[url]: { progress: 0, name: fileName, songName: songName },
}))
// Step 4: Start download with progress
const options = {
fromUrl: url,
toFile: downloadDest,
/* eslint-disable @typescript-eslint/no-explicit-any */
begin: (res: any) => {
console.log('Download started')
},
progress: (data: any) => {
const percent = +(data.bytesWritten / data.contentLength).toFixed(2)
/* eslint-disable @typescript-eslint/no-explicit-any */
queryClient.setQueryData(['downloads'], (prev: any = {}) => ({
...prev,
[url]: { progress: percent, name: fileName, songName: songName },
}))
},
background: true,
progressDivider: 1,
}
const result = await RNFS.downloadFile(options).promise
console.log('Download complete:', result)
return `file://${downloadDest}`
} catch (error) {
console.error('Download failed:', error)
throw error
}
}
const mmkv = new MMKV({
id: 'offlineMode',
encryptionKey: 'offlineMode',
})
const MMKV_OFFLINE_MODE_KEYS = {
AUDIO_CACHE: 'audioCache',
}
export const saveAudio = async (
track: JellifyTrack,
queryClient: QueryClient,
isAutoDownloaded: boolean = true,
) => {
const existingRaw = mmkv.getString(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE)
let existingArray: JellifyDownload[] = []
try {
if (existingRaw) {
existingArray = JSON.parse(existingRaw)
}
} catch (error) {
//Ignore
}
try {
console.log('Downloading audio', track)
const downloadtrack = await downloadJellyfinFile(
track.url,
track.item.Id as string,
track.title as string,
queryClient,
)
const dowloadalbum = await downloadJellyfinFile(
track.artwork as string,
track.item.Id as string,
track.title as string,
queryClient,
)
console.log('downloadtrack', downloadtrack)
if (downloadtrack) {
track.url = downloadtrack
track.artwork = dowloadalbum
}
} catch (error) {
console.error(error)
}
const index = existingArray.findIndex((t) => t.item.Id === track.item.Id)
if (index >= 0) {
// Replace existing
existingArray[index] = { ...track, savedAt: new Date().toISOString(), isAutoDownloaded }
} else {
// Add new
existingArray.push({ ...track, savedAt: new Date().toISOString(), isAutoDownloaded })
}
mmkv.set(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE, JSON.stringify(existingArray))
}
export const getAudioCache = (): JellifyDownload[] => {
const existingRaw = mmkv.getString(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE)
let existingArray: JellifyDownload[] = []
try {
if (existingRaw) {
existingArray = JSON.parse(existingRaw)
}
} catch (error) {
//Ignore
}
return existingArray
}
export const deleteAudioCache = async () => {
mmkv.delete(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE)
}
const AUDIO_CACHE_LIMIT = 20 // change as needed
export const purneAudioCache = async () => {
const existingRaw = mmkv.getString(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE)
if (!existingRaw) return
let existingArray: JellifyDownload[] = []
try {
existingArray = JSON.parse(existingRaw)
} catch (e) {
return
}
const autoDownloads = existingArray
.filter((item) => item.isAutoDownloaded)
.sort((a, b) => new Date(a.savedAt).getTime() - new Date(b.savedAt).getTime()) // oldest first
const excess = autoDownloads.length - AUDIO_CACHE_LIMIT
if (excess <= 0) return
// Remove the oldest `excess` files
const itemsToDelete = autoDownloads.slice(0, excess)
for (const item of itemsToDelete) {
// Delete audio file
if (item.url && (await RNFS.exists(item.url))) {
await RNFS.unlink(item.url).catch(() => {})
}
// Delete artwork
if (item.artwork && (await RNFS.exists(item.artwork))) {
await RNFS.unlink(item.artwork).catch(() => {})
}
// Remove from the existingArray
existingArray = existingArray.filter((i) => i.item.Id !== item.item.Id)
}
mmkv.set(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE, JSON.stringify(existingArray))
}

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, { useMemo } from 'react'
import React, { useEffect, useMemo, useState } 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,6 +15,14 @@ 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,
@@ -23,8 +31,30 @@ 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 && (
@@ -169,6 +199,19 @@ export default function PlayerScreen({
<XStack justifyContent='space-evenly' marginVertical={'$7'}>
<Icon name='speaker-multiple' />
<Spacer />
{isDownloading ? (
<ActivityIndicator />
) : (
<Icon
name={!isDownloaded ? 'download' : 'check'}
onPress={() => {
downloadAudio(nowPlaying!.url)
}}
disabled={isDownloading || !!isDownloaded}
/>
)}
<Spacer />
<Icon

View File

@@ -7,6 +7,7 @@ import { useSafeAreaFrame } from 'react-native-safe-area-context'
import { FlatList } from 'react-native'
import IconCard from '../Global/helpers/icon-card'
import Categories from './categories'
import { StorageBar } from '../Storage'
export default function Root({
navigation,
@@ -31,7 +32,12 @@ export default function Root({
largeIcon
/>
)}
ListFooterComponent={<SignOut />}
ListFooterComponent={
<>
<StorageBar />
<SignOut />
</>
}
/>
)
}

View File

@@ -1,16 +1,7 @@
import { Dirs, FileSystem } from 'react-native-file-access'
import Button from '../../../components/Global/helpers/button'
import { ScrollView } from 'tamagui'
import { useMutation } from '@tanstack/react-query'
export default function DevTools(): React.JSX.Element {
const cleanImageDirectory = useMutation({
mutationFn: () => FileSystem.unlink(`${Dirs.CacheDir}/images/*`),
})
return (
<ScrollView contentInsetAdjustmentBehavior='automatic' removeClippedSubviews>
<Button onPress={cleanImageDirectory.mutate}>Clean Image Cache</Button>
</ScrollView>
<ScrollView contentInsetAdjustmentBehavior='automatic' removeClippedSubviews></ScrollView>
)
}

View File

@@ -3,9 +3,13 @@ import Button from '../../Global/helpers/button'
import Client from '../../../api/client'
import { useJellifyContext } from '../../../components/provider'
import TrackPlayer from 'react-native-track-player'
import { StackParamList } from '@/components/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useNavigation } from '@react-navigation/native'
export default function SignOut(): React.JSX.Element {
const { setLoggedIn } = useJellifyContext()
const navigation = useNavigation<NativeStackNavigationProp<StackParamList>>()
return (
<Button
@@ -13,6 +17,7 @@ export default function SignOut(): React.JSX.Element {
setLoggedIn(false)
Client.signOut()
TrackPlayer.reset()
// navigation.navigate('Offline')
}}
>
Sign Out

View File

@@ -0,0 +1,60 @@
// DownloadProgressBar.tsx
import React from 'react'
import { View, Text, StyleSheet } from 'react-native'
import { useQueryClient, useQuery } from '@tanstack/react-query'
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated'
export const DownloadProgressBar = () => {
const { data: downloads } = useQuery({
queryKey: ['downloads'],
initialData: {},
})
return (
<View style={styles.container}>
{Object.entries(downloads || {}).map(([url, item]: any) => {
const animatedWidth = useSharedValue(item.progress)
animatedWidth.value = withTiming(item.progress, { duration: 200 })
const animatedStyle = useAnimatedStyle(() => ({
width: `${animatedWidth.value * 100}%`,
}))
return (
<View key={url} style={styles.item}>
<Text style={styles.label}>{item.name}</Text>
<View style={styles.bar}>
<Animated.View style={[styles.fill, animatedStyle]} />
</View>
</View>
)
})}
</View>
)
}
const styles = StyleSheet.create({
container: {
padding: 12,
backgroundColor: '#111',
},
item: {
marginBottom: 12,
},
label: {
color: '#fff',
marginBottom: 4,
fontSize: 14,
},
bar: {
height: 8,
backgroundColor: '#333',
borderRadius: 4,
overflow: 'hidden',
},
fill: {
height: 8,
backgroundColor: '#00bcd4',
borderRadius: 4,
},
})

View File

@@ -0,0 +1,190 @@
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'
// 🔹 Single Download Item with animated progress bar
const DownloadItem = ({
name,
progress,
fileName,
}: {
name: string
progress: number
fileName: string
}) => {
const progressValue = useSharedValue(progress)
useEffect(() => {
progressValue.value = withTiming(progress, { duration: 300 })
}, [progress])
const animatedStyle = useAnimatedStyle(() => ({
width: `${progressValue.value * 100}%`,
}))
return (
<View style={styles.item}>
<Text style={styles.label}>{fileName}</Text>
<View style={styles.downloadBar}>
<Animated.View style={[styles.downloadFill, animatedStyle]} />
</View>
</View>
)
}
// 🔹 Main UI Component
export const StorageBar = () => {
const [used, setUsed] = useState(0)
const [total, setTotal] = useState(1)
const { data: downloads } = useQuery({
queryKey: ['downloads'],
initialData: {},
})
const usageShared = useSharedValue(0)
const percentUsed = used / total
const storageBarStyle = useAnimatedStyle(() => ({
width: `${usageShared.value * 100}%`,
}))
useEffect(() => {
usageShared.value = withTiming(percentUsed, { duration: 500 })
}, [percentUsed])
// Refresh storage info
const refreshStats = async () => {
const files = await RNFS.readDir(RNFS.DocumentDirectoryPath)
let usedBytes = 0
for (const file of files) {
const stat = await RNFS.stat(file.path)
usedBytes += Number(stat.size)
}
const info = await RNFS.getFSInfo()
setUsed(usedBytes)
setTotal(info.totalSpace)
}
const deleteAllDownloads = async () => {
const files = getAudioCache()
for (const file of files) {
await RNFS.unlink(file.url).catch(() => {})
}
Alert.alert('Deleted', 'All downloads removed.')
deleteAudioCache()
refreshStats()
}
useEffect(() => {
refreshStats()
}, [])
const downloadList = Object.entries(downloads || {}) as [
string,
{ name: string; progress: number; songName: string },
][]
return (
<View style={styles.container}>
{/* Storage Usage */}
<Text style={styles.title}>📦 Storage Usage</Text>
<Text style={styles.usage}>
{(used / 1024 / 1024).toFixed(2)} MB / {(total / 1024 / 1024 / 1024).toFixed(2)} GB
</Text>
<View style={styles.progressBackground}>
<Animated.View style={[styles.progressFill, storageBarStyle]} />
</View>
{/* Active Downloads */}
{downloadList.length > 0 && (
<>
<Text style={[styles.title, { marginTop: 24 }]}> Active Downloads</Text>
<FlatList
data={downloadList}
keyExtractor={([url]) => url}
renderItem={({ item }) => {
const [url, { name, progress, songName }] = item
return (
<DownloadItem name={name} progress={progress} fileName={songName} />
)
}}
contentContainerStyle={{ paddingBottom: 40 }}
/>
</>
)}
{/* Delete All Downloads */}
<Pressable style={styles.deleteButton} onPress={deleteAllDownloads}>
<MaterialIcons name='delete-outline' size={20} color='#ff4d4f' />
<Text style={styles.deleteText}> Delete Downloads</Text>
</Pressable>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
backgroundColor: '#1c1c2e',
},
title: {
color: 'white',
fontSize: 18,
fontWeight: '600',
marginBottom: 4,
},
usage: {
color: '#aaa',
fontSize: 14,
marginBottom: 12,
},
progressBackground: {
height: 10,
backgroundColor: '#333',
borderRadius: 5,
overflow: 'hidden',
},
progressFill: {
height: 10,
backgroundColor: '#ff2d75',
borderRadius: 5,
},
item: {
marginTop: 16,
},
label: {
color: '#ccc',
fontSize: 14,
marginBottom: 4,
},
downloadBar: {
height: 8,
backgroundColor: '#2e2e3f',
borderRadius: 4,
overflow: 'hidden',
},
downloadFill: {
height: 8,
backgroundColor: '#00bcd4',
},
deleteButton: {
marginTop: 30,
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'center',
padding: 12,
backgroundColor: '#2a0f13',
borderRadius: 8,
},
deleteText: {
color: '#ff4d4f',
fontSize: 15,
fontWeight: '600',
},
})

View File

@@ -4,6 +4,7 @@ import { Tabs } from './tabs'
import { StackParamList } from './types'
import { useTheme } from 'tamagui'
import DetailsScreen from './ItemDetail/screen'
import { OfflineList } from './offlineList'
export default function Navigation(): React.JSX.Element {
const RootStack = createNativeStackNavigator<StackParamList>()
@@ -28,6 +29,14 @@ export default function Navigation(): React.JSX.Element {
presentation: 'modal',
}}
/>
<RootStack.Screen
name='Offline'
component={OfflineList}
options={{
headerShown: false,
presentation: 'modal',
}}
/>
</RootStack.Navigator>
)
}

View File

@@ -0,0 +1,68 @@
import { JellifyTrack } from '@/types/JellifyTrack'
import React from 'react'
import { FlatList, Pressable } from 'react-native'
import Animated, { FadeIn, FadeOut, Layout } from 'react-native-reanimated'
import { Image, Text, View, XStack, YStack } from 'tamagui'
import { getAudioCache } from '../Network/offlineModeUtils'
import { usePlayerContext } from '../../player/provider'
import { useNavigation } from '@react-navigation/native'
interface Props {
tracks: JellifyTrack[]
onPress: (track: JellifyTrack) => void
}
export function OfflineList() {
const tracks = getAudioCache()
const navigation = useNavigation()
const { usePlayNewQueueOffline } = usePlayerContext()
const onPress = (track: JellifyTrack) => {
console.log('onPress', track)
usePlayNewQueueOffline.mutate({ trackListOffline: track })
navigation.navigate('Player')
}
const renderItem = ({ item }: { item: JellifyTrack }) => (
<Animated.View
entering={FadeIn.duration(300)}
exiting={FadeOut}
layout={Layout.springify()}
style={{
overflow: 'hidden',
marginVertical: 6,
borderRadius: 16,
backgroundColor: '#1c1c1e',
}}
>
<Pressable onPress={() => onPress(item)}>
<XStack padding={12} gap={12} alignItems='center'>
<Image
source={{ uri: item.artwork }}
style={{ width: 64, height: 64, borderRadius: 12 }}
/>
<YStack flex={1}>
<Text fontWeight='700' color='#fff' numberOfLines={1}>
{item.title || 'Unknown Title'}
</Text>
<Text color='#bbb' numberOfLines={1}>
{item.artist || 'Unknown Artist'}
</Text>
</YStack>
</XStack>
</Pressable>
</Animated.View>
)
return (
<FlatList
data={tracks}
keyExtractor={(item) => item.item.Id as string}
renderItem={renderItem}
contentContainerStyle={{
padding: 16,
paddingBottom: 100,
}}
showsVerticalScrollIndicator={false}
/>
)
}

View File

@@ -10,7 +10,8 @@ import { getToken, getTokens, Separator } from 'tamagui'
import { usePlayerContext } from '../player/provider'
import SearchStack from './Search/stack'
import LibraryStack from './Library/stack'
import { useColorScheme } from 'react-native'
import { useColorScheme, View } from 'react-native'
import InternetConnectionWatcher from './Network/internetConnectionWatcher'
const Tab = createBottomTabNavigator()
@@ -19,95 +20,98 @@ export function Tabs(): React.JSX.Element {
const { nowPlaying } = usePlayerContext()
return (
<Tab.Navigator
initialRouteName='Home'
screenOptions={{
lazy: false,
animation: 'shift',
tabBarActiveTintColor: getTokens().color.telemagenta.val,
tabBarInactiveTintColor: isDarkMode
? getToken('$color.amethyst')
: getToken('$color.purpleGray'),
}}
tabBar={(props) => (
<>
{nowPlaying && (
/* Hide miniplayer if the queue is empty */
<>
<Separator />
<Miniplayer navigation={props.navigation} />
</>
)}
<BottomTabBar {...props} />
</>
)}
>
<Tab.Screen
name='Home'
component={Home}
options={{
headerShown: false,
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons
name='jellyfish-outline'
color={color}
size={size}
/>
),
<View style={{ flex: 1 }}>
<Tab.Navigator
initialRouteName='Home'
screenOptions={{
lazy: false,
animation: 'shift',
tabBarActiveTintColor: getTokens().color.telemagenta.val,
tabBarInactiveTintColor: isDarkMode
? getToken('$color.amethyst')
: getToken('$color.purpleGray'),
}}
/>
tabBar={(props) => (
<>
{nowPlaying && (
/* Hide miniplayer if the queue is empty */
<>
<Separator />
<Miniplayer navigation={props.navigation} />
</>
)}
<BottomTabBar {...props} />
</>
)}
>
<Tab.Screen
name='Home'
component={Home}
options={{
headerShown: false,
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons
name='jellyfish-outline'
color={color}
size={size}
/>
),
}}
/>
<Tab.Screen
name='Library'
component={LibraryStack}
options={{
headerShown: false,
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons
name='book-music-outline'
color={color}
size={size}
/>
),
}}
/>
<Tab.Screen
name='Library'
component={LibraryStack}
options={{
headerShown: false,
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons
name='book-music-outline'
color={color}
size={size}
/>
),
}}
/>
<Tab.Screen
name='Search'
component={SearchStack}
options={{
headerShown: false,
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons name='magnify' color={color} size={size} />
),
}}
/>
<Tab.Screen
name='Search'
component={SearchStack}
options={{
headerShown: false,
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons name='magnify' color={color} size={size} />
),
}}
/>
<Tab.Screen
name='Discover'
component={Discover}
options={{
headerShown: false,
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons
name='music-box-multiple-outline'
color={color}
size={size}
/>
),
}}
/>
<Tab.Screen
name='Discover'
component={Discover}
options={{
headerShown: false,
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons
name='music-box-multiple-outline'
color={color}
size={size}
/>
),
}}
/>
<Tab.Screen
name='Settings'
component={Settings}
options={{
headerShown: false,
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons name='dip-switch' color={color} size={size} />
),
}}
/>
</Tab.Navigator>
<Tab.Screen
name='Settings'
component={Settings}
options={{
headerShown: false,
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons name='dip-switch' color={color} size={size} />
),
}}
/>
</Tab.Navigator>
<InternetConnectionWatcher />
</View>
)
}

View File

@@ -81,6 +81,7 @@ export type StackParamList = {
item: BaseItemDto
isNested: boolean | undefined
}
Offline: undefined
}
export type ServerAddressProps = NativeStackScreenProps<StackParamList, 'ServerAddress'>

View File

@@ -32,6 +32,7 @@ export enum QueryKeys {
ArtistImage = 'ArtistImage',
PlaybackStateChange = 'PlaybackStateChange',
Player = 'Player',
NetworkStatus = 'NetworkStatus',
/**
* Query representing the fetching of a user's created playlist.

View File

@@ -0,0 +1,26 @@
import { PermissionsAndroid, Platform } from 'react-native'
export const requestStoragePermission = async () => {
if (Platform.OS === 'android') {
try {
const granted = await PermissionsAndroid.requestMultiple([
PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE,
PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE,
])
const readGranted =
granted['android.permission.READ_EXTERNAL_STORAGE'] ===
PermissionsAndroid.RESULTS.GRANTED
const writeGranted =
granted['android.permission.WRITE_EXTERNAL_STORAGE'] ===
PermissionsAndroid.RESULTS.GRANTED
return readGranted && writeGranted
} catch (err) {
console.warn(err)
return false
}
} else {
return true
}
}

View File

@@ -7,6 +7,7 @@ module.exports = {
'./jest/setup-carplay.js',
'./jest/setup-blurhash.js',
'./jest/setup-reanimated.js',
'./jest/setup-rnfs.js',
'./tamagui.config.ts',
],
extensionsToTreatAsEsm: ['.ts', '.tsx'],

45
jest/setup-rnfs.js Normal file
View File

@@ -0,0 +1,45 @@
jest.mock('react-native-fs', () => {
return {
mkdir: jest.fn(),
moveFile: jest.fn(),
copyFile: jest.fn(),
pathForBundle: jest.fn(),
pathForGroup: jest.fn(),
getFSInfo: jest.fn(),
getAllExternalFilesDirs: jest.fn(),
unlink: jest.fn(),
exists: jest.fn(),
stopDownload: jest.fn(),
resumeDownload: jest.fn(),
isResumable: jest.fn(),
stopUpload: jest.fn(),
completeHandlerIOS: jest.fn(),
readDir: jest.fn(),
readDirAssets: jest.fn(),
existsAssets: jest.fn(),
readdir: jest.fn(),
setReadable: jest.fn(),
stat: jest.fn(),
readFile: jest.fn(),
read: jest.fn(),
readFileAssets: jest.fn(),
hash: jest.fn(),
copyFileAssets: jest.fn(),
copyFileAssetsIOS: jest.fn(),
copyAssetsVideoIOS: jest.fn(),
writeFile: jest.fn(),
appendFile: jest.fn(),
write: jest.fn(),
downloadFile: jest.fn(),
uploadFiles: jest.fn(),
touch: jest.fn(),
MainBundlePath: jest.fn(),
CachesDirectoryPath: jest.fn(),
DocumentDirectoryPath: jest.fn(),
ExternalDirectoryPath: jest.fn(),
ExternalStorageDirectoryPath: jest.fn(),
TemporaryDirectoryPath: jest.fn(),
LibraryDirectoryPath: jest.fn(),
PicturesDirectoryPath: jest.fn(),
}
})

View File

@@ -54,6 +54,7 @@ jest.mock('react-native-track-player', () => {
getQueue: jest.fn(),
getTrack: jest.fn(),
getActiveTrackIndex: jest.fn(),
getActiveTrack: jest.fn(),
getCurrentTrack: jest.fn(),
getVolume: jest.fn(),
getDuration: jest.fn(),

View File

@@ -1,9 +0,0 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
export class ArtistModel {
name?: string | undefined | null
constructor(itemDto: BaseItemDto) {
this.name = itemDto.Name
}
}

View File

@@ -1,121 +1,122 @@
{
"name": "jellify",
"version": "0.10.111",
"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-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-file-access": "^3.1.1",
"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.111",
"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

@@ -9,6 +9,7 @@ export interface QueueMutation {
tracklist: BaseItemDto[]
queue: Queue
queuingType?: QueuingType | undefined
trackListOffline?: JellifyTrack
}
export interface AddToQueueMutation {

View File

@@ -54,7 +54,9 @@ interface PlayerContext {
useSkip: UseMutationResult<void, Error, number | undefined, unknown>
usePrevious: UseMutationResult<void, Error, void, unknown>
usePlayNewQueue: UseMutationResult<void, Error, QueueMutation, unknown>
usePlayNewQueueOffline: UseMutationResult<void, Error, QueueMutation, unknown>
playbackState: State | undefined
setNowPlaying: (track: JellifyTrack) => void
}
const PlayerContextInitializer = () => {
@@ -71,6 +73,7 @@ const PlayerContextInitializer = () => {
const [nowPlaying, setNowPlaying] = useState<JellifyTrack | undefined>(
nowPlayingJson ? JSON.parse(nowPlayingJson) : undefined,
)
const [isSkipping, setIsSkipping] = useState<boolean>(false)
const [playQueue, setPlayQueue] = useState<JellifyTrack[]>(
@@ -98,6 +101,28 @@ const PlayerContextInitializer = () => {
})
}
/**
* Takes a {@link BaseItemDto} of a track on Jellyfin, and updates it's
* position in the {@link queue}
*
*
* @param track The Jellyfin track object to update and replace in the queue
*/
const replaceQueueItem: (track: BaseItemDto) => Promise<void> = async (track: BaseItemDto) => {
const queue = (await TrackPlayer.getQueue()) as JellifyTrack[]
const queueItemIndex = queue.findIndex((queuedTrack) => queuedTrack.item.Id === track.Id!)
// Update queued item at index if found, else silently do nothing
if (queueItemIndex !== -1) {
const queueItem = queue[queueItemIndex]
TrackPlayer.remove([queueItemIndex]).then(() => {
TrackPlayer.add(mapDtoToTrack(track, queueItem.QueuingType), queueItemIndex)
})
}
}
const resetQueue = async (hideMiniplayer?: boolean | undefined) => {
console.debug('Clearing queue')
await TrackPlayer.setQueue([])
@@ -242,6 +267,7 @@ const PlayerContextInitializer = () => {
setIsSkipping(true)
// Optimistically set now playing
setNowPlaying(
mapDtoToTrack(mutation.tracklist[mutation.index ?? 0], QueuingType.FromSelection),
)
@@ -268,6 +294,34 @@ const PlayerContextInitializer = () => {
},
})
const usePlayNewQueueOffline = useMutation({
mutationFn: async (mutation: QueueMutation) => {
trigger('effectDoubleClick')
setIsSkipping(true)
// Optimistically set now playing
setNowPlaying(mutation.trackListOffline)
await resetQueue(false)
await addToQueue([mutation.trackListOffline as JellifyTrack])
setQueue('Recently Played')
},
onSuccess: async (data, mutation: QueueMutation) => {
setIsSkipping(false)
await play(0)
if (typeof mutation.queue === 'object') await markItemPlayed(queue as BaseItemDto)
},
onError: async () => {
setIsSkipping(false)
setNowPlaying((await TrackPlayer.getActiveTrack()) as JellifyTrack)
},
})
//#endregion
//#region RNTP Setup
@@ -387,6 +441,7 @@ const PlayerContextInitializer = () => {
getQueueSectionData,
useAddToQueue,
useClearQueue,
setNowPlaying,
useReorderQueue,
useRemoveFromQueue,
useTogglePlayback,
@@ -395,6 +450,7 @@ const PlayerContextInitializer = () => {
usePrevious,
usePlayNewQueue,
playbackState,
usePlayNewQueueOffline,
}
//#endregion return
}
@@ -405,6 +461,7 @@ export const PlayerContext = createContext<PlayerContext>({
nowPlayingIsFavorite: false,
setNowPlayingIsFavorite: () => {},
nowPlaying: undefined,
setNowPlaying: () => {},
playQueue: [],
queue: 'Recently Played',
getQueueSectionData: () => [],
@@ -570,6 +627,24 @@ export const PlayerContext = createContext<PlayerContext>({
failureReason: null,
submittedAt: 0,
},
usePlayNewQueueOffline: {
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,
},
playbackState: undefined,
})
//#endregion Create PlayerContext
@@ -595,6 +670,8 @@ export const PlayerProvider: ({ children }: { children: ReactNode }) => React.JS
useSeekTo,
useSkip,
usePrevious,
usePlayNewQueueOffline,
setNowPlaying,
usePlayNewQueue,
playbackState,
} = PlayerContextInitializer()
@@ -606,6 +683,7 @@ export const PlayerProvider: ({ children }: { children: ReactNode }) => React.JS
nowPlayingIsFavorite,
setNowPlayingIsFavorite,
nowPlaying,
usePlayNewQueueOffline,
playQueue,
queue,
getQueueSectionData,
@@ -617,6 +695,7 @@ export const PlayerProvider: ({ children }: { children: ReactNode }) => React.JS
useSeekTo,
useSkip,
usePrevious,
setNowPlaying,
usePlayNewQueue,
playbackState,
}}

View File

@@ -5,8 +5,9 @@ import { headingFont, bodyFont } from './fonts.config'
const tokens = createTokens({
...TamaguiTokens,
color: {
danger: '#ff0000',
danger: '#ff9966',
purpleDark: '#0C0622',
success: '#c1fefe',
purple: '#100538',
purpleGray: '#66617B',
amethyst: '#7E72AF',

6
types/JellifyDownload.ts Normal file
View File

@@ -0,0 +1,6 @@
import { JellifyTrack } from './JellifyTrack'
export type JellifyDownload = JellifyTrack & {
savedAt: string
isAutoDownloaded: boolean
}

11630
yarn.lock Normal file

File diff suppressed because it is too large Load Diff