diff --git a/App.tsx b/App.tsx index 6f803fbc..0a7cdd3e 100644 --- a/App.tsx +++ b/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 ( diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cfab3240..b58c9106 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ + + { }) } -function getTrackFilePath(itemId: string) { +export function getTrackFilePath(itemId: string) { return `${Dirs.DocumentDir}/downloads/${itemId}` } diff --git a/components/Global/components/track.tsx b/components/Global/components/track.tsx index 24764814..2efed589 100644 --- a/components/Global/components/track.tsx +++ b/components/Global/components/track.tsx @@ -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 ( diff --git a/components/Global/helpers/icon-button.tsx b/components/Global/helpers/icon-button.tsx index 0ece8025..70bf58ba 100644 --- a/components/Global/helpers/icon-button.tsx +++ b/components/Global/helpers/icon-button.tsx @@ -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 ( @@ -38,7 +40,13 @@ export default function IconButton({ justifyContent='center' backgroundColor={'$background'} > - + {title && {title}} diff --git a/components/Global/helpers/icon.tsx b/components/Global/helpers/icon.tsx index f5ebb891..33414fe8 100644 --- a/components/Global/helpers/icon.tsx +++ b/components/Global/helpers/icon.tsx @@ -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} /> ) diff --git a/components/Home/stack.tsx b/components/Home/stack.tsx index dc065405..e0d60546 100644 --- a/components/Home/stack.tsx +++ b/components/Home/stack.tsx @@ -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() diff --git a/components/ItemDetail/helpers/TrackOptions.tsx b/components/ItemDetail/helpers/TrackOptions.tsx index c9870515..6eb7ab3b 100644 --- a/components/ItemDetail/helpers/TrackOptions.tsx +++ b/components/ItemDetail/helpers/TrackOptions.tsx @@ -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} /> + { + onDownloadPress() + }} + size={width / 6} + /> diff --git a/components/Network/internetConnectionWatcher.tsx b/components/Network/internetConnectionWatcher.tsx new file mode 100644 index 00000000..9ca2493d --- /dev/null +++ b/components/Network/internetConnectionWatcher.tsx @@ -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(null) + const lastNetworkStatus = useRef() + 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 ( + + + + {networkStatus === networkStatusTypes.ONLINE + ? internetConnectionWatcher.BACK_ONLINE + : internetConnectionWatcher.NO_INTERNET} + + + + ) +} + +export default InternetConnectionWatcher diff --git a/components/Network/offlineModeUtils.ts b/components/Network/offlineModeUtils.ts new file mode 100644 index 00000000..2b7bb5bc --- /dev/null +++ b/components/Network/offlineModeUtils.ts @@ -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)) +} diff --git a/components/Player/screens/index.tsx b/components/Player/screens/index.tsx index 00c4d482..31bd7778 100644 --- a/components/Player/screens/index.tsx +++ b/components/Player/screens/index.tsx @@ -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 ( {nowPlaying && ( @@ -169,6 +199,19 @@ export default function PlayerScreen({ + + {isDownloading ? ( + + ) : ( + { + downloadAudio(nowPlaying!.url) + }} + disabled={isDownloading || !!isDownloaded} + /> + )} + )} - ListFooterComponent={} + ListFooterComponent={ + <> + + + + } /> ) } diff --git a/components/Settings/helpers/dev-tools.tsx b/components/Settings/helpers/dev-tools.tsx index 7ff74f37..ff0b22e2 100644 --- a/components/Settings/helpers/dev-tools.tsx +++ b/components/Settings/helpers/dev-tools.tsx @@ -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 ( - - - + ) } diff --git a/components/Settings/helpers/sign-out.tsx b/components/Settings/helpers/sign-out.tsx index 42372530..d0862dab 100644 --- a/components/Settings/helpers/sign-out.tsx +++ b/components/Settings/helpers/sign-out.tsx @@ -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>() return (