Merge branch 'feature/old-working-version' of github.com:anultravioletaurora/Jellify

This commit is contained in:
Violet Caulfield
2024-11-22 06:45:09 -06:00
88 changed files with 7992 additions and 1465 deletions

25
App.tsx
View File

@@ -1,20 +1,35 @@
import './gesture-handler';
import "./global.css";
import React from 'react';
import "react-native-url-polyfill/auto";
import Jellify from './components/jellify';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { GluestackUIProvider } from './components/gluestack-ui-provider';
import { createTamagui, TamaguiProvider, Theme } from 'tamagui';
import { ToastProvider } from '@tamagui/toast'
import defaultConfig from '@tamagui/config/v3';
import { useColorScheme } from 'react-native';
const config = createTamagui(defaultConfig);
export default function App(): React.JSX.Element {
const queryClient = new QueryClient();
const isDarkMode = useColorScheme() === 'dark';
return (
<QueryClientProvider client={queryClient}>
<GluestackUIProvider mode={'dark'}>
<Jellify />
</GluestackUIProvider>
<TamaguiProvider config={config}>
<Theme name={isDarkMode ? 'dark' : 'light'}>
<ToastProvider
swipeDirection='down'
native={false}
>
<Jellify />
</ToastProvider>
</Theme>
</TamaguiProvider>
</QueryClientProvider>
);
}

View File

@@ -1 +1,19 @@
// TODO: Create client singleton here
import { Api, Jellyfin } from "@jellyfin/sdk";
import { getDeviceNameSync, getUniqueIdSync } from "react-native-device-info";
import { name, version } from "../package.json"
export const client : Jellyfin = new Jellyfin({
clientInfo: {
name: name,
version: version
},
deviceInfo: {
name: getDeviceNameSync(),
id: getUniqueIdSync()
}
});
export function buildApiClient (serverUrl : string): Api {
let jellyfin = new Jellyfin(client);
return jellyfin.createApi(serverUrl);
}

View File

@@ -1,18 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { usePublicApi } from "../queries";
import { useServerUrl } from "../queries/storage";
import { JellyfinCredentials } from "../types/jellyfin-credentials";
import { MutationKeys } from "../../enums/mutation-keys";
import { createPublicApi } from "../queries/functions/api";
import { fetchServerUrl } from "../queries/functions/storage";
export const authenticateWithCredentials = useMutation({
mutationKey: [MutationKeys.AuthenticationWithCredentials],
mutationFn: async (credentials: JellyfinCredentials) => {
createPublicApi(await fetchServerUrl())
.authenticateUserByName(credentials.username, credentials.password!);
},
onSuccess(data, credentials, context) {
},
})

View File

@@ -1,9 +1,45 @@
import { fetchServerUrl } from "../../queries/functions/storage";
import { fetchServer } from "../../queries/functions/storage";
import { JellyfinCredentials } from "../../types/jellyfin-credentials";
import * as Keychain from "react-native-keychain"
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api";
import { JellifyServer } from "../../../types/JellifyServer";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { AsyncStorageKeys } from "../../../enums/async-storage-keys";
import { buildApiClient } from "../../client";
import _ from "lodash";
interface ServerMutationParams {
serverUrl: string,
}
export const serverMutation = async (serverUrl: string) => {
console.log("Mutating server URL");
export const mutateServerCredentials = async (credentials: JellyfinCredentials) => {
return Keychain.setInternetCredentials(await fetchServerUrl(), credentials.username, credentials.accessToken!);
if (!!!serverUrl)
throw Error("Server URL is empty")
const api = buildApiClient(serverUrl);
console.log(`Created API client for ${api.basePath}`)
return await getSystemApi(api).getPublicSystemInfo();
}
export const mutateServer = async (server?: JellifyServer) => {
if (!_.isUndefined(server))
return await AsyncStorage.setItem(AsyncStorageKeys.ServerUrl, JSON.stringify(server));
return await AsyncStorage.removeItem(AsyncStorageKeys.ServerUrl);
}
export const mutateServerCredentials = async (serverUrl: string, credentials?: JellyfinCredentials) => {
if (!_.isUndefined(credentials)) {
console.log("Setting Jellyfin credentials")
return await Keychain.setInternetCredentials(serverUrl, credentials.username, credentials.accessToken!);
}
console.log("Resetting Jellyfin credentials")
return await Keychain.resetInternetCredentials(serverUrl);
}

0
api/mutators/keychain.ts Normal file
View File

View File

@@ -1,35 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { MutationKeys } from "../../enums/mutation-keys";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { AsyncStorageKeys } from "../../enums/async-storage-keys";
import { Jellyfin } from "@jellyfin/sdk";
import { client } from "../queries";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api"
import { JellyfinCredentials } from "../types/jellyfin-credentials";
import { mutateServerCredentials } from "./functions/storage";
export const useServerUrl = useMutation({
mutationKey: [MutationKeys.ServerUrl],
mutationFn: (serverUrl: string | undefined) => {
if (!!!serverUrl)
throw Error("Server URL was empty")
let jellyfin = new Jellyfin(client);
let api = jellyfin.createApi(serverUrl);
return getSystemApi(api).getPublicSystemInfo()
},
onSuccess: (publicSystemInfoResponse, serverUrl, context) => {
if (!!!publicSystemInfoResponse.data.Version)
throw new Error("Unable to connect to Jellyfin Server");
return AsyncStorage.setItem(AsyncStorageKeys.ServerUrl, serverUrl!);
}
});
export const credentials = useMutation({
mutationKey: [MutationKeys.Credentials],
mutationFn: async (credentials: JellyfinCredentials) => {
mutateServerCredentials(credentials)
},
});

View File

@@ -1,39 +1,15 @@
import { Jellyfin } from "@jellyfin/sdk"
import { Query, useQuery } from "@tanstack/react-query";
import { getDeviceNameSync, getUniqueIdSync } from "react-native-device-info"
import { useQuery } from "@tanstack/react-query";
import { QueryKeys } from "../enums/query-keys";
import { name, version } from "../package.json"
import { createApi, createPublicApi } from "./queries/functions/api";
import { fetchServerUrl } from "./queries/functions/storage";
export const client : Jellyfin = new Jellyfin({
clientInfo: {
name: name,
version: version
},
deviceInfo: {
name: getDeviceNameSync(),
id: getUniqueIdSync()
}
export const usePublicApi = () => useQuery({
queryKey: [QueryKeys.PublicApi],
queryFn: createPublicApi
});
export const usePublicApi = (serverUrl: string) => useQuery({
queryKey: [QueryKeys.PublicApi, serverUrl],
queryFn: ({ queryKey }) => {
createPublicApi(queryKey[1])
}
})
export const useApi = (serverUrl: string) => useQuery({
queryKey: [QueryKeys.Api, serverUrl],
queryFn: async ({ queryKey }) => {
createApi(queryKey[1]);
}
})
export const useServerUrl = () => useQuery({
queryKey: [QueryKeys.ServerUrl],
queryFn: () => {
return fetchServerUrl()
}
export const useApi = () => useQuery({
queryKey: [QueryKeys.Api],
queryFn: createApi,
gcTime: 1000,
refetchInterval: false
})

View File

@@ -1,16 +1,14 @@
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api"
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { QueryKeys } from "../../enums/query-keys";
import { useApi } from "../queries";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useChildrenFromParent } from "./items";
import { fetchServerUrl } from "./functions/storage";
import { createApi } from "./functions/api";
export const useArtistAlbums : (artistId: string) => UseQueryResult<BaseItemDto[], Error> = (artistId: string) => useQuery({
queryKey: [QueryKeys.ArtistAlbums, artistId],
queryFn: async ({ queryKey }) => {
return getItemsApi(await createApi(await fetchServerUrl()))
return getItemsApi(await createApi())
.getItems({ albumArtistIds: [queryKey[1]] })
.then((result) => {
return result.data.Items

View File

@@ -1,13 +1,34 @@
import { Api } from "@jellyfin/sdk";
import { client } from "../../queries";
import { fetchCredentials } from "./keychain";
import { fetchCredentials, fetchServer } from "./storage";
import { client } from "../../client";
import _ from "lodash";
import { QueryFunctionContext, QueryKey } from "@tanstack/react-query";
/**
* A promise to build an authenticated Jellyfin API client
* @returns A Promise of the authenticated Jellyfin API client or a rejection
*/
export function createApi(): Promise<Api> {
return new Promise(async (resolve, reject) => {
let credentials = await fetchCredentials();
export const createApi: (serverUrl: string) => Promise<Api> = async (serverUrl) => {
let credentials = await fetchCredentials(serverUrl)
return client.createApi(credentials.server, credentials.password);
if (_.isUndefined(credentials)) {
console.warn("No credentials exist for user, launching login flow");
return reject("No credentials exist for the current user");
}
console.log("Signing into Jellyfin")
return resolve(client.createApi(credentials!.server, credentials!.password));
});
}
export const createPublicApi: (serverUrl: string) => Api = (serverUrl) => {
return client.createApi(serverUrl);
export function createPublicApi(): Promise<Api> {
return new Promise(async (resolve) => {
console.log("Fetching server details from storage")
const server = await fetchServer()
console.log(`Found stored server ${server.name}`)
resolve(client.createApi(server.url));
});
}

View File

@@ -1,11 +0,0 @@
import * as Keychain from "react-native-keychain"
export const fetchCredentials = (serverUrl: string) => {
return Keychain.getInternetCredentials(serverUrl)
.then((keychain) => {
if (!keychain)
throw new Error("Jellyfin server credentials not stored in keychain");
return keychain as Keychain.SharedWebCredentials
});
}

View File

@@ -0,0 +1,24 @@
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
import _ from "lodash";
export function fetchMusicLibraries(api: Api): Promise<BaseItemDto[]> {
return new Promise( async (resolve) => {
console.log("Fetching music libraries from Jellyfin");
let libraries = await getItemsApi(api).getItems();
if (_.isUndefined(libraries.data.Items)) {
console.log("No libraries found on Jellyfin");
return Promise.reject("No libraries found on Jellyfin");
}
let musicLibraries = libraries.data.Items!.filter(library => library.CollectionType == 'music');
console.log(`Found ${musicLibraries.length} music libraries`);
resolve(musicLibraries);
});
}

View File

@@ -1,14 +1,47 @@
import AsyncStorage from "@react-native-async-storage/async-storage"
import { AsyncStorageKeys } from "../../../enums/async-storage-keys"
import _ from "lodash";
import * as Keychain from "react-native-keychain"
import { JellifyServer } from "../../../types/JellifyServer";
export const fetchServerUrl : () => Promise<string> = async () => {
export const fetchCredentials : () => Promise<Keychain.SharedWebCredentials | undefined> = () => new Promise(async (resolve, reject) => {
let url = await AsyncStorage.getItem(AsyncStorageKeys.ServerUrl)!;
console.log("Attempting to use stored credentials");
if (_.isNull(url))
throw Error("Server URL was null")
let server = await fetchServer();
return url;
}
if (_.isEmpty(server.url)) {
console.warn("Unable to retrieve credentials without a server URL");
resolve(undefined);
}
const keychain = await Keychain.getInternetCredentials(server.url!);
if (!keychain) {
console.warn("No keychain for server address - signin required");
resolve(undefined);
}
console.log("Successfully retrieved keychain");
resolve(keychain as Keychain.SharedWebCredentials)
});
export const fetchServer : () => Promise<JellifyServer> = () => new Promise(async (resolve, reject) => {
console.log("Attempting to fetch server address from storage");
let serverJson = await AsyncStorage.getItem(AsyncStorageKeys.ServerUrl);
if (_.isEmpty(serverJson) || _.isNull(serverJson)) {
console.warn("No stored server address exists");
return reject(new Error("No stored server address exists"));
}
try {
let server : JellifyServer = JSON.parse(serverJson) as JellifyServer;
return resolve(server);
} catch(error: any) {
return Promise.reject(new Error(error));
}
});

View File

@@ -3,13 +3,14 @@ import { QueryKeys } from "../../enums/query-keys";
import { getImageApi } from "@jellyfin/sdk/lib/utils/api/image-api"
import { useApi } from "../queries";
import { ImageType } from "@jellyfin/sdk/lib/generated-client/models";
import { createApi } from "./functions/api";
export const useImageByItemId = (itemId: string, imageType: ImageType) => useQuery({
queryKey: [QueryKeys.ImageByItemId, itemId],
queryFn: (async ({ queryKey }) => {
let imageFile = await getImageApi(useApi.data!)
let imageFile = await getImageApi(await createApi())
.getItemImage({ itemId: queryKey[1], imageType: imageType })
.then((response) => {
// This should be returning a File per Jellyfin's docs

View File

@@ -2,11 +2,12 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
import { useQuery } from "@tanstack/react-query";
import { useApi } from "../queries";
import { QueryKeys } from "../../enums/query-keys";
import { createApi } from "./functions/api";
export const useChildrenFromParent = (queryKey: QueryKeys, parentId: string) => useQuery({
queryKey: [queryKey, parentId],
queryFn: (({ queryKey }) => {
return getItemsApi(useApi.data!)
queryFn: (async ({ queryKey }) => {
return getItemsApi(await createApi())
.getItems({ parentId: queryKey[1] })
.then((result) => {
// If our response is empty or null, return empty array

View File

@@ -1,13 +1,14 @@
import { useQuery } from "@tanstack/react-query"
import { QueryKeys } from "../../enums/query-keys"
import { fetchCredentials } from "./functions/keychain"
import AsyncStorage from "@react-native-async-storage/async-storage";
import { AsyncStorageKeys } from "../../enums/async-storage-keys";
import { fetchServerUrl } from "./functions/storage";
import _ from "lodash";
import { fetchCredentials, fetchServer } from "./functions/storage";
export const useCredentials = useQuery({
export const useCredentials = () => useQuery({
queryKey: [QueryKeys.Credentials],
queryFn: async () => {
return fetchCredentials(await fetchServerUrl())
}
});
queryFn: fetchCredentials
});
export const useServer = () => useQuery({
queryKey: [QueryKeys.ServerUrl],
queryFn: fetchServer
})

View File

@@ -2,12 +2,11 @@ import { useQuery } from "@tanstack/react-query";
import { QueryKeys } from "../../enums/query-keys";
import { getPlaylistsApi } from "@jellyfin/sdk/lib/utils/api/playlists-api"
import { createApi } from "./functions/api";
import { fetchServerUrl } from "./functions/storage";
export const usePlaylists = useQuery({
queryKey: [QueryKeys.Playlists],
queryFn: async () => {
return getPlaylistsApi(await createApi(await fetchServerUrl()))
return getPlaylistsApi(await createApi())
}
})

View File

@@ -1,6 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { QueryKeys } from "../../enums/query-keys";
import { usePublicApi } from "../queries";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api";
import { createPublicApi } from "./functions/api";

View File

@@ -1,12 +0,0 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { UseQueryResult, useQuery } from "@tanstack/react-query";
import { AsyncStorageKeys } from "../../enums/async-storage-keys";
import { QueryKeys } from "../../enums/query-keys";
export const useServerUrl: UseQueryResult<string> = useQuery({
queryKey: [QueryKeys.ServerUrl],
queryFn: (() => {
return AsyncStorage.getItem(AsyncStorageKeys.ServerUrl);
})
});

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/icon_1024pt_1x.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
assets/icon_20pt_2x.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
assets/icon_20pt_3x.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
assets/icon_29pt_2x.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
assets/icon_29pt_3x.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
assets/icon_40pt_2x.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
assets/icon_40pt_3x.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 358 KiB

BIN
assets/icon_60pt_2x.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
assets/icon_60pt_3x.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 451 KiB

View File

@@ -0,0 +1,17 @@
import { H1, ScrollView, YStack } from "tamagui";
import { useApiClientContext } from "../jellyfin-api-provider";
import _ from "lodash";
export default function Home(): React.JSX.Element {
const { apiClient } = useApiClientContext();
return (
<ScrollView paddingLeft={10}>
<YStack alignContent='flex-start'>
<H1>Hi there</H1>
</YStack>
</ScrollView>
);
}

View File

@@ -1,24 +1,59 @@
import { createStackNavigator } from "@react-navigation/stack";
import { useColorScheme } from "react-native";
import { useServerUrl } from "../../api/queries";
import _ from "lodash"
import ServerAuthentication from "./helpers/server-authentication";
import ServerAddress from "./helpers/server-address";
import { createStackNavigator } from "@react-navigation/stack";
import ServerLibrary from "./helpers/server-library";
import { useAuthenticationContext } from "./provider";
import { useEffect } from "react";
import { useApiClientContext } from "../jellyfin-api-provider";
export default function Login(): React.JSX.Element {
const isDarkMode = useColorScheme() === 'dark';
const { serverAddress, changeServer, server, username, changeUsername, triggerAuth, setTriggerAuth } = useAuthenticationContext();
const { apiClient } = useApiClientContext();
const Stack = createStackNavigator();
let { isError, isSuccess } = useServerUrl();
useEffect(() => {
setTriggerAuth(false);
})
return (
(isError ?
<ServerAddress />
:
<ServerAuthentication />
)
<Stack.Navigator screenOptions={{ headerShown: false }}>
{
(_.isUndefined(server) || changeServer) ? (
<Stack.Screen
name="ServerAddress"
options={{
headerShown: false,
animationTypeForReplace: triggerAuth ? 'push' : 'pop'
}}
component={ServerAddress}
/>
) : (
((_.isUndefined(username) || _.isUndefined(apiClient)) || changeUsername) ? (
<Stack.Screen
name="ServerAuthentication"
options={{
headerShown: false,
animationTypeForReplace: changeUsername ? 'pop' : 'push'
}}
component={ServerAuthentication}
/>
) : (
<Stack.Screen
name="LibrarySelection"
options={{
headerShown: false,
animationTypeForReplace: 'push'
}}
component={ServerLibrary}
/>
)
)
}
</Stack.Navigator>
);
}

View File

@@ -1,39 +1,74 @@
import React, { useState } from "react";
import { View, TextInput, Button, StyleSheet } from "react-native";
import { handleServerUrlChangeEvent } from "../utils/handlers";
import { useServerUrl } from "@/api/queries";
import { validateServerUrl } from "../utils/validation";
import { useServerUrl as serverUrlMutation } from "../../../api/mutators/storage";
const styles = StyleSheet.create({
input: {
height: 40,
margin: 12,
borderRadius: 1,
borderWidth: 1,
padding: 10,
}
})
import React from "react";
import _ from "lodash";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMutation } from "@tanstack/react-query";
import { AsyncStorageKeys } from "../../../enums/async-storage-keys";
import { JellifyServer } from "../../../types/JellifyServer";
import { mutateServer, serverMutation } from "../../../api/mutators/functions/storage";
import { useApiClientContext } from "../../jellyfin-api-provider";
import { View, XStack } from "tamagui";
import { SwitchWithLabel } from "../../helpers/switch-with-label";
import { useAuthenticationContext } from "../provider";
import { Heading } from "../../helpers/text";
import Input from "../../helpers/input";
import Button from "../../helpers/button";
import { http, https } from "../utils/constants";
export default function ServerAddress(): React.JSX.Element {
let [serverUrl, setServerUrl] = useState(useServerUrl().data);
const { serverAddress, setServerAddress, setChangeServer, setServer, useHttps, setUseHttps } = useAuthenticationContext();
let serverUrlIsValid = validateServerUrl(serverUrl);
const useServerMutation = useMutation({
mutationFn: serverMutation,
onSuccess: async (publicSystemInfoResponse, serverUrl) => {
if (!!!publicSystemInfoResponse.data.Version)
throw new Error("Jellyfin instance did not respond");
console.debug("REMOVE THIS::onSuccess variable", serverUrl);
console.log(`Connected to Jellyfin ${publicSystemInfoResponse.data.Version!}`);
let jellifyServer: JellifyServer = {
url: `${useHttps ? https : http}${serverAddress!}`,
address: serverAddress!,
name: publicSystemInfoResponse.data.ServerName!,
version: publicSystemInfoResponse.data.Version!,
startUpComplete: publicSystemInfoResponse.data.StartupWizardCompleted!
}
setServer(jellifyServer);
setChangeServer(false);
},
onError: async (error: Error) => {
console.error("An error occurred connecting to the Jellyfin instance", error);
return await AsyncStorage.setItem(AsyncStorageKeys.ServerUrl, "");
}
});
return (
<View>
<TextInput
style={styles.input}
value={serverUrl}
onChangeText={(value) => handleServerUrlChangeEvent(value, setServerUrl)}
/>
<Button
onPress={(event) => serverUrlMutation.mutate(serverUrl)}
disabled={!serverUrlIsValid}
title="Connect"/>
</View>
<View marginHorizontal={10} flex={1} justifyContent='center'>
<Heading>
Connect to Jellyfin
</Heading>
<XStack>
<SwitchWithLabel
checked={useHttps}
onCheckedChange={(checked) => setUseHttps(checked)}
label="Use HTTPS"
size="$2"
width={100} />
<Input
value={serverAddress}
placeholder="jellyfin.org"
onChangeText={setServerAddress} />
</XStack>
<Button
disabled={_.isEmpty(serverAddress)}
onPress={() => {
useServerMutation.mutate(`${useHttps ? "https" : "http"}://${serverAddress}`);
}}>
Connect
</Button>
</View>
)
}

View File

@@ -1,30 +1,88 @@
import React from "react";
import { View, TextInput, Button } from "react-native";
import { authenticateWithCredentials } from "../../../api/mutators/auth";
import { useMutation } from "@tanstack/react-query";
import { useApiClientContext } from "../../jellyfin-api-provider";
import _ from "lodash";
import * as Keychain from "react-native-keychain"
import { JellyfinCredentials } from "../../../api/types/jellyfin-credentials";
import { View } from "tamagui";
import { useAuthenticationContext } from "../provider";
import { Heading } from "../../helpers/text";
import Button from "../../helpers/button";
import Input from "../../helpers/input";
import { mutateServer, mutateServerCredentials } from "../../../api/mutators/functions/storage";
import { createPublicApi } from "../../../api/queries/functions/api";
import { client } from "../../../api/client";
export default function ServerAuthentication(): React.JSX.Element {
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
const { username, server, setUsername, setChangeUsername, setChangeServer } = useAuthenticationContext();
const [password, setPassword] = React.useState<string | undefined>('');
const { setApiClient } = useApiClientContext();
const useApiMutation = useMutation({
mutationFn: async (credentials: JellyfinCredentials) => {
return await client.createApi(server!.url).authenticateUserByName(credentials.username, credentials.password!);
},
onSuccess: async (authResult, credentials) => {
console.log(`Received auth response from ${server!.name}`)
if (_.isUndefined(authResult))
return Promise.reject(new Error("Authentication result was empty"))
if (authResult.status >= 400 || _.isEmpty(authResult.data.AccessToken))
return Promise.reject(new Error("Invalid credentials"))
if (_.isUndefined(authResult.data.User))
return Promise.reject(new Error("Unable to login"));
console.log(`Successfully signed in to ${server!.name}`)
setApiClient(client.createApi(server!.url, authResult.data.AccessToken as string))
setChangeUsername(false);
return await Keychain.setInternetCredentials(server!.url, credentials.username, (authResult.data.AccessToken as string));
},
onError: async (error: Error) => {
console.error("An error occurred connecting to the Jellyfin instance", error);
return Promise.reject(`An error occured signing into ${server!.name}`);
}
});
return (
<View>
<TextInput
<View marginHorizontal={10} flex={1} justifyContent='center'>
<Heading>
{ `Sign in to ${server?.name ?? "Jellyfin"}`}
</Heading>
<Button
onPress={() => {
setChangeServer(true);
mutateServerCredentials(server!.url);
}}>
Switch Server
</Button>
<Input
placeholder="Username"
value={username}
onChangeText={setUsername}
/>
<TextInput
onChangeText={(value) => setUsername(value)}
/>
<Input
placeholder="Password"
value={password}
onChangeText={setPassword}
onChangeText={(value) => setPassword(value)}
secureTextEntry
/>
<Button title="Sign in" onPress={() => signInHandler(username, password)} />
/>
<Button
disabled={_.isEmpty(username) || _.isEmpty(password)}
onPress={() => {
if (!_.isUndefined(username)) {
console.log(`Signing in to ${server!.name}`);
useApiMutation.mutate({ username, password });
}
}}
>
Sign in
</Button>
</View>
);
}
function signInHandler(username: string, password: string) {
return authenticateWithCredentials.mutate({username, password})
}

View File

@@ -1,10 +1,97 @@
import React from "react";
import { View } from "react-native";
import { useMutation, useQuery } from "@tanstack/react-query";
import React, { useEffect, useMemo, useState } from "react";
import { useApiClientContext } from "../../jellyfin-api-provider";
import { Select, View } from "tamagui";
import { JellifyLibrary } from "../../../types/JellifyLibrary";
import { mutateServerCredentials } from "../../../api/mutators/functions/storage";
import { useAuthenticationContext } from "../provider";
import { Heading } from "../../helpers/text";
import Button from "../../helpers/button";
import _ from "lodash";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models/base-item-dto";
import { Api } from "@jellyfin/sdk";
import { fetchMusicLibraries } from "../../../api/queries/functions/libraries";
import { QueryKeys } from "../../../enums/query-keys";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { ActivityIndicator } from "react-native";
export default function ServerLibrary(): React.JSX.Element {
const [musicLibrary, setMusicLibrary] = useState<JellifyLibrary | undefined>(undefined);
const { server, setUsername, setChangeUsername, libraryName, setLibraryName, libraryId, setLibraryId } = useAuthenticationContext();
const { apiClient, setApiClient } = useApiClientContext();
const useLibraries = (api: Api) => useQuery({
queryKey: [QueryKeys.Libraries, api],
queryFn: async ({ queryKey }) => await fetchMusicLibraries(queryKey[1] as Api)
});
const { data : libraries, isPending, refetch } = useLibraries(apiClient!);
const clearUser = useMutation({
mutationFn: async () => {
setChangeUsername(true);
setApiClient(undefined)
return await mutateServerCredentials(server!.url);
}
});
useEffect(() => {
refetch();
}, [
server,
apiClient
])
return (
<View>
<View marginHorizontal={10} flex={1} justifyContent='center'>
<Heading>Select Music Library</Heading>
{ isPending && (
<ActivityIndicator />
)}
{ !_.isUndefined(libraries) &&
<Select defaultValue="">
<Select.Trigger>
<Select.Value placeholder="Libraries" />
</Select.Trigger>
<Select.Content>
<Select.Viewport animation="quick">
<Select.Group>
\ <Select.Label>Music Libraries</Select.Label>
{ libraries.map((item, i) => {
return (
<Select.Item
index={i}
key={item.Name!}
value={item.Name!}
>
<Select.ItemText>{item.Name!}</Select.ItemText>
<Select.ItemIndicator marginLeft="auto">
<Icon name="check" size={16} />
</Select.ItemIndicator>
</Select.Item>
)
})}
</Select.Group>
</Select.Viewport>
</Select.Content>
</Select>
}
<Button
onPress={() => {
clearUser.mutate();
}}
>
Switch User
</Button>
<Select value={libraryName}></Select>
</View>
)
}

View File

@@ -1,8 +0,0 @@
import { Text } from "react-native";
export default function SignIn(): React.JSX.Element {
return (
<Text>Alyssa please be impressed</Text>
)
}

View File

@@ -0,0 +1,153 @@
import React, { createContext, ReactNode, SetStateAction, useContext, useEffect, useState } from "react";
import { useCredentials, useServer } from "../../api/queries/keychain";
import _ from "lodash";
import { SharedWebCredentials } from "react-native-keychain";
import { JellifyServer } from "../../types/JellifyServer";
import { QueryObserverResult, RefetchOptions } from "@tanstack/react-query";
import { mutateServer, mutateServerCredentials } from "../../api/mutators/functions/storage";
import { usePublicApi } from "../../api/queries";
import { Api } from "@jellyfin/sdk";
interface JellyfinAuthenticationContext {
username: string | undefined;
setUsername: React.Dispatch<SetStateAction<string | undefined>>;
changeUsername: boolean;
setChangeUsername: React.Dispatch<SetStateAction<boolean>>;
useHttp: boolean;
setUseHttp: React.Dispatch<SetStateAction<boolean>>;
useHttps: boolean;
setUseHttps: React.Dispatch<SetStateAction<boolean>>;
serverAddress: string | undefined;
setServerAddress: React.Dispatch<SetStateAction<string | undefined>>;
changeServer: boolean;
setChangeServer: React.Dispatch<SetStateAction<boolean>>;
server: JellifyServer | undefined;
setServer: React.Dispatch<SetStateAction<JellifyServer | undefined>>;
libraryName: string | undefined;
setLibraryName: React.Dispatch<React.SetStateAction<string | undefined>>;
libraryId: string | undefined;
setLibraryId: React.Dispatch<React.SetStateAction<string | undefined>>;
triggerAuth: boolean;
setTriggerAuth: React.Dispatch<React.SetStateAction<boolean>>;
}
const JellyfinAuthenticationContextInitializer = () => {
const [username, setUsername] = useState<string | undefined>(undefined);
const [changeUsername, setChangeUsername] = useState<boolean>(false);
const [useHttp, setUseHttp] = useState<boolean>(false);
const [useHttps, setUseHttps] = useState<boolean>(true);
const [serverAddress, setServerAddress] = useState<string | undefined>(undefined);
const [changeServer, setChangeServer] = useState<boolean>(false);
const [server, setServer] = useState<JellifyServer | undefined>(undefined);
const [libraryName, setLibraryName] = useState<string | undefined>(undefined);
const [libraryId, setLibraryId] = useState<string | undefined>(undefined);
const [triggerAuth, setTriggerAuth] = useState<boolean>(true);
return {
username,
setUsername,
changeUsername,
setChangeUsername,
useHttp,
setUseHttp,
useHttps,
setUseHttps,
serverAddress,
setServerAddress,
changeServer,
setChangeServer,
server,
setServer,
libraryName,
setLibraryName,
libraryId,
setLibraryId,
triggerAuth,
setTriggerAuth,
};
}
const JellyfinAuthenticationContext =
createContext<JellyfinAuthenticationContext>({
username: undefined,
setUsername: () => {},
changeUsername: false,
setChangeUsername: () => {},
useHttp: false,
setUseHttp: () => {},
useHttps: true,
setUseHttps: () => {},
serverAddress: undefined,
setServerAddress: () => {},
changeServer: false,
setChangeServer: () => {},
server: undefined,
setServer: () => {},
libraryName: undefined,
setLibraryName: () => {},
libraryId: undefined,
setLibraryId: () => {},
triggerAuth: true,
setTriggerAuth: () => {},
});
export const JellyfinAuthenticationProvider: ({ children }: {
children: ReactNode;
}) => React.JSX.Element = ({ children }: { children: ReactNode }) => {
const {
username,
setUsername,
changeUsername,
setChangeUsername,
useHttp,
setUseHttp,
useHttps,
setUseHttps,
serverAddress,
setServerAddress,
changeServer,
server,
setServer,
setChangeServer,
libraryName,
setLibraryName,
libraryId,
setLibraryId,
triggerAuth,
setTriggerAuth,
} = JellyfinAuthenticationContextInitializer();
return (
<JellyfinAuthenticationContext.Provider value={{
username,
setUsername,
changeUsername,
setChangeUsername,
useHttp,
setUseHttp,
useHttps,
setUseHttps,
serverAddress,
setServerAddress,
changeServer,
setChangeServer,
server,
setServer,
libraryName,
setLibraryName,
libraryId,
setLibraryId,
triggerAuth,
setTriggerAuth,
}}>
{ children }
</JellyfinAuthenticationContext.Provider>
);
};
export const useAuthenticationContext = () => useContext(JellyfinAuthenticationContext)

View File

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

View File

@@ -1,7 +0,0 @@
import { validateServerUrl } from "./validation";
export function handleServerUrlChangeEvent(serverUrl: string | undefined, setServerUrl: React.Dispatch<React.SetStateAction<string | undefined>>) : void {
if (validateServerUrl(serverUrl)) {
setServerUrl(serverUrl);
}
}

View File

@@ -1,9 +1,10 @@
import _ from "lodash"
export function validateServerUrl(serverUrl: string | undefined) {
if (serverUrl) {
if (!_.isEmpty(serverUrl)) {
// Parse
return true;
}
return false;

View File

@@ -0,0 +1,10 @@
import React from "react";
import { View } from "react-native-ui-lib";
export default function SignOut(): React.JSX.Element {
return (
<View>
</View>
)
}

View File

@@ -0,0 +1,22 @@
import { Button as TamaguiButton } from 'tamagui';
import { styles } from './text';
interface ButtonProps {
children: string;
onPress: () => void;
disabled?: boolean | undefined;
}
export default function Button(props: ButtonProps): React.JSX.Element {
return (
<TamaguiButton
style={styles.heading}
disabled={props.disabled}
marginVertical={30}
onPress={props.onPress}
>
{ props.children }
</TamaguiButton>
)
}

View File

@@ -0,0 +1,25 @@
import React from "react"
import Icon from "react-native-vector-icons/MaterialCommunityIcons"
import { CheckboxProps, XStack, Checkbox, Label } from "tamagui"
export function CheckboxWithLabel({
size,
label = 'Toggle',
...checkboxProps
}: CheckboxProps & { label?: string }) {
const id = `checkbox-${(size || '').toString().slice(1)}`
return (
<XStack width={150} alignItems="center" gap="$4">
<Checkbox id={id} size={size} {...checkboxProps}>
<Checkbox.Indicator>
<Icon name="check" />
</Checkbox.Indicator>
</Checkbox>
<Label size={size} htmlFor={id}>
{label}
</Label>
</XStack>
)
}

View File

@@ -0,0 +1,23 @@
import React, { SetStateAction } from 'react';
import { Input as TamaguiInput} from 'tamagui';
import { styles } from './text';
interface InputProps {
onChangeText: React.Dispatch<SetStateAction<string | undefined>>,
placeholder: string
value: string | undefined;
secureTextEntry?: boolean | undefined;
}
export default function Input(props: InputProps): React.JSX.Element {
return (
<TamaguiInput
style={styles.text}
placeholder={props.placeholder}
onChangeText={props.onChangeText}
value={props.value}
secureTextEntry={props.secureTextEntry}
/>
)
}

View File

@@ -0,0 +1,21 @@
import { SizeTokens, XStack, Separator, Switch } from "tamagui";
import { Label } from "./text";
export function SwitchWithLabel(props: { size: SizeTokens; checked: boolean, label: string, onCheckedChange: (value: boolean) => void, width?: number }) {
const id = `switch-${props.size.toString().slice(1)}-${props.checked ?? ''}}`
return (
<XStack alignItems="center" gap="$3">
<Label
size={props.size}
htmlFor={id}
>
{props.label}
</Label>
<Separator minHeight={20} vertical />
<Switch id={id} size={props.size} checked={props.checked} onCheckedChange={(checked: boolean) => props.onCheckedChange(checked)}>
<Switch.Thumb animation="quicker" />
</Switch>
</XStack>
)
}

View File

@@ -0,0 +1,33 @@
import { StyleSheet } from "react-native"
import { H1, SizeTokens, Label as TamaguiLabel } from "tamagui"
import { Fonts } from '../../enums/assets/fonts';
export const styles = StyleSheet.create({
heading: {
fontFamily: Fonts.Black
},
label: {
fontFamily: Fonts.Heavy
},
text: {
fontFamily: Fonts.Regular
},
});
interface LabelProps {
htmlFor: string,
children: string,
size: SizeTokens
}
export function Label(props: LabelProps): React.JSX.Element {
return (
<TamaguiLabel htmlFor={props.htmlFor} justifyContent="flex-end">{ props.children }</TamaguiLabel>
)
}
export function Heading({ children }: { children: string }): React.JSX.Element {
return (
<H1 marginVertical={30} style={styles.heading}>{ children }</H1>
)
}

View File

@@ -0,0 +1,8 @@
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
export default function ServerIcon(): React.JSX.Element {
return (
<Icon name="server-network" size={30}></Icon>
)
}

View File

@@ -1,10 +1,13 @@
import { SafeAreaView, StatusBar, useColorScheme } from "react-native";
import { NavigationContainer } from "@react-navigation/native";
import Login from "./Login/component";
import Navigation from "./navigation";
import { useColorScheme } from "react-native";
import { Colors } from "react-native/Libraries/NewAppScreen";
import { setupPlayer } from "react-native-track-player/lib/src/trackPlayer";
import { useServerUrl } from "../api/queries";
import _ from "lodash";
import { JellyfinApiClientProvider, useApiClientContext } from "./jellyfin-api-provider";
import React, { } from "react";
import { DarkTheme, DefaultTheme, NavigationContainer } from "@react-navigation/native";
import Navigation from "./navigation";
import Login from "./Login/component";
import { JellyfinAuthenticationProvider } from "./Login/provider";
export default function Jellify(): React.JSX.Element {
@@ -17,20 +20,29 @@ export default function Jellify(): React.JSX.Element {
backgroundColor: isDarkMode ? Colors.darker : Colors.lighter,
};
let { data, isLoading, isSuccess } = useServerUrl();
return (
<NavigationContainer>
<SafeAreaView style={backgroundStyle}>
<StatusBar
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
backgroundColor={backgroundStyle.backgroundColor}
/>
{ isSuccess && <Navigation /> }
{ !isSuccess && <Login /> }
</SafeAreaView>
</NavigationContainer>
<JellyfinApiClientProvider>
{conditionalHomeRender()}
</JellyfinApiClientProvider>
);
}
function conditionalHomeRender(): React.JSX.Element {
const isDarkMode = useColorScheme() === 'dark';
// If library ID hasn't been set, we haven't completed the auth flow
const { apiClient } = useApiClientContext();
return (
<NavigationContainer theme={isDarkMode ? DarkTheme : DefaultTheme}>
{ !_.isUndefined(apiClient) ? (
<Navigation />
) : (
<JellyfinAuthenticationProvider>
<Login />
</JellyfinAuthenticationProvider>
)}
</NavigationContainer>
);
}

View File

@@ -0,0 +1,62 @@
import { Api } from '@jellyfin/sdk';
import React, { createContext, ReactNode, SetStateAction, useContext, useEffect, useState } from 'react';
import { useApi } from '../api/queries';
import _ from 'lodash';
import { QueryObserverResult, RefetchOptions } from '@tanstack/react-query';
interface JellyfinApiClientContext {
apiClient: Api | undefined;
apiPending: boolean;
setApiClient: React.Dispatch<SetStateAction<Api | undefined>>;
}
const JellyfinApiClientContextInitializer = () => {
const [apiClient, setApiClient] = useState<Api | undefined>(undefined);
const { data: api, isPending: apiPending, refetch: refetchApi } = useApi();
useEffect(() => {
if (!apiPending)
console.log("Setting API client to stored values")
setApiClient(api)
}, [
apiPending
]);
return {
apiClient,
apiPending,
setApiClient,
};
}
export const JellyfinApiClientContext =
createContext<JellyfinApiClientContext>({
apiClient: undefined,
apiPending: true,
setApiClient: () => {},
});
export const JellyfinApiClientProvider: ({ children }: {
children: ReactNode;
}) => React.JSX.Element = ({ children }: { children: ReactNode }) => {
const {
apiClient,
apiPending,
setApiClient,
} = JellyfinApiClientContextInitializer();
// Add your logic to check if credentials are stored and initialize the API client here.
return (
<JellyfinApiClientContext.Provider value={{
apiClient,
apiPending,
setApiClient,
}}>
{children}
</JellyfinApiClientContext.Provider>
);
};
export const useApiClientContext = () => useContext(JellyfinApiClientContext)

View File

@@ -1,20 +1,19 @@
import { createStackNavigator } from "@react-navigation/stack";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import Home from "./Home/component";
import Player from "./Player/component";
import Login from "./Login/component";
export default function Navigation(): React.JSX.Element {
const RootStack = createStackNavigator();
const RootStack = createNativeStackNavigator()
return (
<RootStack.Navigator>
<RootStack.Group>
<RootStack.Screen name="Jellify" component={Login} />
</RootStack.Group>
<RootStack.Group screenOptions={{ presentation: 'modal' }}>
<RootStack.Screen name="Player" component={Player} />
</RootStack.Group>
</RootStack.Navigator>
<RootStack.Navigator>
<RootStack.Group>
<RootStack.Screen name="Jellify" component={Home} />
</RootStack.Group>
<RootStack.Group screenOptions={{ presentation: 'modal' }}>
<RootStack.Screen name="Player" component={Player} />
</RootStack.Group>
</RootStack.Navigator>
)
}

View File

@@ -1,7 +1,22 @@
import { StyleSheet } from "react-native";
export const jellifyStyles = StyleSheet.create({
buttons: {
paddingHorizontal: 5,
}
})
container: {
flex: 1,
justifyContent: 'center',
marginHorizontal: 16,
},
title: {
textAlign: 'center',
marginVertical: 8,
},
fixToText: {
flexDirection: 'row',
justifyContent: 'space-between',
},
separator: {
marginVertical: 8,
borderBottomColor: '#737373',
borderBottomWidth: StyleSheet.hairlineWidth,
},
});

2
components/theme.ts Normal file
View File

@@ -0,0 +1,2 @@
import { createTamagui } from "tamagui";

9
enums/assets/fonts.ts Normal file
View File

@@ -0,0 +1,9 @@
export enum Fonts {
Black = "Aileron-Black",
BlackItalic = "Aileron-BlackItalic",
Bold = "Aileron-Bold",
BoldItalic = "Aileron-BoldItalic",
Heavy = "Aileron-Heavy",
HeavyItalic = "Aileron-HeavyItalic",
Regular = "Aileron-Regular"
}

View File

@@ -6,17 +6,18 @@ export enum QueryKeys {
ArtistById = "ARTIST_BY_ID",
Credentials = "CREDENTIALS",
ImageByItemId = "IMAGE_BY_ITEM_ID",
Libraries = "LIBRARIES",
Pause = "PAUSE",
Play = "PLAY",
Playlists = "PLAYLISTS",
Progress = "PROGRESS",
PlayQueue = "PLAY_QUEUE",
PublicApi = "PUBLIC_API",
PublicSystemInfo = "PUBLIC_SYSTEM_INFO",
RemoveFromQueue = "REMOVE_FROM_QUEUE",
RemoveMultipleFromQueue = "REMOVE_MULTIPLE_FROM_QUEUE",
ReportPlaybackPosition = "REPORT_PLAYBACK_POSITION",
ReportPlaybackStarted = "REPORT_PLAYBACK_STARTED",
ReportPlaybackStopped = "REPORT_PLAYBACK_STOPPED",
ServerUrl = "SERVER_URL",
PublicSystemInfo = "PublicSystemInfo",
PublicApi = "PublicApi",
}

View File

@@ -1,7 +1,4 @@
/**
* @format
*/
import 'react-native-gesture-handler';
import {AppRegistry} from 'react-native';
import App from './App';
import {name as appName} from './app.json';

View File

@@ -1,46 +1,55 @@
{
"images" : [
{
"filename" : "icon_20pt_2x.jpg",
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "icon_20pt_3x.jpg",
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "icon_29pt_2x.jpg",
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon_29pt_3x.jpg",
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "icon_40pt_2x.jpg",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "icon_40pt_3x.jpg",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "icon_60pt_2x.jpg",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "icon_60pt_3x.jpg",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "icon_1024pt_1x.jpg",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@@ -47,5 +47,27 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>UIAppFonts</key>
<array>
<string>AntDesign.ttf</string>
<string>Entypo.ttf</string>
<string>EvilIcons.ttf</string>
<string>Feather.ttf</string>
<string>FontAwesome.ttf</string>
<string>FontAwesome5_Brands.ttf</string>
<string>FontAwesome5_Regular.ttf</string>
<string>FontAwesome5_Solid.ttf</string>
<string>FontAwesome6_Brands.ttf</string>
<string>FontAwesome6_Regular.ttf</string>
<string>FontAwesome6_Solid.ttf</string>
<string>Foundation.ttf</string>
<string>Ionicons.ttf</string>
<string>MaterialIcons.ttf</string>
<string>MaterialCommunityIcons.ttf</string>
<string>SimpleLineIcons.ttf</string>
<string>Octicons.ttf</string>
<string>Zocial.ttf</string>
<string>Fontisto.ttf</string>
</array>
</dict>
</plist>

View File

@@ -1239,7 +1239,7 @@ PODS:
- Yoga
- react-native-carplay (1.1.12):
- React
- react-native-safe-area-context (4.11.0):
- react-native-safe-area-context (4.11.1):
- React-Core
- react-native-track-player (4.1.1):
- React-Core
@@ -1913,7 +1913,7 @@ SPEC CHECKSUMS:
React-Mapbuffer: 1c08607305558666fd16678b85ef135e455d5c96
React-microtasksnativemodule: 87b8de96f937faefece8afd2cb3a518321b2ef99
react-native-carplay: f10ee458f78957798d19d712fb57ecdbd14b5fcd
react-native-safe-area-context: 851c62c48dce80ccaa5637b6aa5991a1bc36eca9
react-native-safe-area-context: 5141f11858b033636f1788b14f32eaba92cee810
react-native-track-player: 82ef1756ffeea61140fea17519ecd6d64ec3cf3e
React-nativeconfig: 57781b79e11d5af7573e6f77cbf1143b71802a6d
React-NativeModulesApple: 7ff2e2cfb2e5fa5bdedcecf28ce37e696c6ef1e1

View File

@@ -1,14 +1,20 @@
const {withNativeWind} = require('nativewind/metro');
const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require('@react-native/metro-config')
/**
* Metro configuration
* https://reactnative.dev/docs/metro
*
* @type {import('metro-config').MetroConfig}
*/
const config = mergeConfig(getDefaultConfig(__dirname), {});
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname, {
// [Web-only]: Enables CSS support in Metro.
isCSSEnabled: true,
})
module.exports = withNativeWind(config, {
input: "./global.css"
});
// Expo 49 issue: default metro config needs to include "mjs"
// https://github.com/expo/expo/issues/23180
config.resolver.sourceExts.push('mjs')
config.watchFolders = [
"components",
"api",
"player"
]
module.exports = config;

8161
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,9 +18,15 @@
"@jellyfin/sdk": "^0.10.0",
"@react-native-async-storage/async-storage": "^2.0.0",
"@react-native-community/masked-view": "^0.1.11",
"@react-native-masked-view/masked-view": "^0.3.1",
"@react-navigation/bottom-tabs": "^6.6.1",
"@react-navigation/native": "^6.1.18",
"@react-navigation/native-stack": "^6.11.0",
"@react-navigation/stack": "^6.4.1",
"@tamagui/config": "^1.115.5",
"@tamagui/toast": "^1.115.5",
"@tanstack/react-query": "^5.52.1",
"burnt": "^0.12.2",
"lodash": "^4.17.21",
"nativewind": "4.0.36",
"react": "18.3.1",
@@ -29,12 +35,13 @@
"react-native-gesture-handler": "^2.20.0",
"react-native-keychain": "^8.2.0",
"react-native-reanimated": "^3.15.5",
"react-native-safe-area-context": "^4.11.0",
"react-native-safe-area-context": "^4.11.1",
"react-native-screens": "^3.34.0",
"react-native-svg": "^13.4.0",
"react-native-track-player": "^4.1.1",
"react-native-vector-icons": "^10.1.0",
"tailwindcss": "^3.4.13"
"react-native-url-polyfill": "^2.0.0",
"react-native-vector-icons": "^10.2.0",
"tamagui": "^1.115.5"
},
"devDependencies": {
"@babel/core": "^7.20.0",
@@ -46,6 +53,7 @@
"@react-native/typescript-config": "0.75.2",
"@types/lodash": "^4.17.10",
"@types/react": "^18.2.6",
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "^18.0.0",
"babel-jest": "^29.6.3",
"babel-plugin-module-resolver": "^5.0.2",

3
react-native.config.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
assets: ['./assets/fonts/'],
};

6
types/JellifyLibrary.ts Normal file
View File

@@ -0,0 +1,6 @@
export interface JellifyLibrary {
musicLibraryId: string;
musicLibraryName: string;
playlistLibraryId: string;
}

10
types/JellifyServer.ts Normal file
View File

@@ -0,0 +1,10 @@
import { JellifyLibrary } from "./JellifyLibrary";
export interface JellifyServer {
url: string;
address: string;
name: string;
version: string;
startUpComplete: boolean;
library?: JellifyLibrary;
}