mirror of
https://github.com/Jellify-Music/App.git
synced 2026-01-07 03:20:19 -06:00
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:
7
App.tsx
7
App.tsx
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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>()
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
130
components/Network/internetConnectionWatcher.tsx
Normal file
130
components/Network/internetConnectionWatcher.tsx
Normal 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
|
||||
184
components/Network/offlineModeUtils.ts
Normal file
184
components/Network/offlineModeUtils.ts
Normal 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))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
60
components/Storage/DownloadProgressBar.tsx
Normal file
60
components/Storage/DownloadProgressBar.tsx
Normal 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,
|
||||
},
|
||||
})
|
||||
190
components/Storage/index.tsx
Normal file
190
components/Storage/index.tsx
Normal 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',
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
68
components/offlineList/index.tsx
Normal file
68
components/offlineList/index.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
1
components/types.d.ts
vendored
1
components/types.d.ts
vendored
@@ -81,6 +81,7 @@ export type StackParamList = {
|
||||
item: BaseItemDto
|
||||
isNested: boolean | undefined
|
||||
}
|
||||
Offline: undefined
|
||||
}
|
||||
|
||||
export type ServerAddressProps = NativeStackScreenProps<StackParamList, 'ServerAddress'>
|
||||
|
||||
@@ -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.
|
||||
|
||||
26
helpers/permisson-helpers.ts
Normal file
26
helpers/permisson-helpers.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
45
jest/setup-rnfs.js
Normal 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(),
|
||||
}
|
||||
})
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
241
package.json
241
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface QueueMutation {
|
||||
tracklist: BaseItemDto[]
|
||||
queue: Queue
|
||||
queuingType?: QueuingType | undefined
|
||||
trackListOffline?: JellifyTrack
|
||||
}
|
||||
|
||||
export interface AddToQueueMutation {
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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
6
types/JellifyDownload.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { JellifyTrack } from './JellifyTrack'
|
||||
|
||||
export type JellifyDownload = JellifyTrack & {
|
||||
savedAt: string
|
||||
isAutoDownloaded: boolean
|
||||
}
|
||||
Reference in New Issue
Block a user