Merge branch 'feature/old-working-version' of github.com:anultravioletaurora/Jellify
25
App.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
},
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
24
api/queries/functions/libraries.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
});
|
||||
BIN
assets/fonts/Aileron-Black.otf
Normal file
BIN
assets/fonts/Aileron-BlackItalic.otf
Normal file
BIN
assets/fonts/Aileron-Bold.otf
Normal file
BIN
assets/fonts/Aileron-BoldItalic.otf
Normal file
BIN
assets/fonts/Aileron-Heavy.otf
Normal file
BIN
assets/fonts/Aileron-HeavyItalic.otf
Normal file
BIN
assets/fonts/Aileron-Italic.otf
Normal file
BIN
assets/fonts/Aileron-Light.otf
Normal file
BIN
assets/fonts/Aileron-LightItalic.otf
Normal file
BIN
assets/fonts/Aileron-Regular.otf
Normal file
BIN
assets/fonts/Aileron-SemiBold.otf
Normal file
BIN
assets/fonts/Aileron-SemiBoldItalic.otf
Normal file
BIN
assets/fonts/Aileron-Thin.otf
Normal file
BIN
assets/fonts/Aileron-ThinItalic.otf
Normal file
BIN
assets/fonts/Aileron-UltraLight.otf
Normal file
BIN
assets/fonts/Aileron-UltraLightItalic.otf
Normal file
BIN
assets/icon_1024pt_1x.jpg
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
assets/icon_20pt_2x.jpg
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/icon_20pt_3x.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
assets/icon_29pt_2x.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
assets/icon_29pt_3x.jpg
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
assets/icon_40pt_2x.jpg
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
assets/icon_40pt_3x.jpg
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 358 KiB |
BIN
assets/icon_60pt_2x.jpg
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
assets/icon_60pt_3x.jpg
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 451 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Text } from "react-native";
|
||||
|
||||
export default function SignIn(): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<Text>Alyssa please be impressed</Text>
|
||||
)
|
||||
}
|
||||
153
components/Login/provider.tsx
Normal 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)
|
||||
2
components/Login/utils/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const http = "http://"
|
||||
export const https = "https://"
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
|
||||
import _ from "lodash"
|
||||
|
||||
export function validateServerUrl(serverUrl: string | undefined) {
|
||||
|
||||
if (serverUrl) {
|
||||
if (!_.isEmpty(serverUrl)) {
|
||||
// Parse
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
10
components/Settings/helpers/sign-out.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
components/helpers/button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
components/helpers/checkbox-with-label.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
23
components/helpers/input.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
21
components/helpers/switch-with-label.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
33
components/helpers/text.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
8
components/icons/server-icon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
62
components/jellyfin-api-provider.tsx
Normal 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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,2 @@
|
||||
import { createTamagui } from "tamagui";
|
||||
|
||||
9
enums/assets/fonts.ts
Normal 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"
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
5
index.js
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
After Width: | Height: | Size: 80 KiB |
BIN
ios/Jellify/Images.xcassets/AppIcon.appiconset/icon_20pt_2x.jpg
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
ios/Jellify/Images.xcassets/AppIcon.appiconset/icon_20pt_3x.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
ios/Jellify/Images.xcassets/AppIcon.appiconset/icon_29pt_2x.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
ios/Jellify/Images.xcassets/AppIcon.appiconset/icon_29pt_3x.jpg
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
ios/Jellify/Images.xcassets/AppIcon.appiconset/icon_40pt_2x.jpg
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
ios/Jellify/Images.xcassets/AppIcon.appiconset/icon_40pt_3x.jpg
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
ios/Jellify/Images.xcassets/AppIcon.appiconset/icon_60pt_2x.jpg
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
ios/Jellify/Images.xcassets/AppIcon.appiconset/icon_60pt_3x.jpg
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
14
package.json
@@ -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
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
assets: ['./assets/fonts/'],
|
||||
};
|
||||
6
types/JellifyLibrary.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
export interface JellifyLibrary {
|
||||
musicLibraryId: string;
|
||||
musicLibraryName: string;
|
||||
playlistLibraryId: string;
|
||||
}
|
||||
10
types/JellifyServer.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { JellifyLibrary } from "./JellifyLibrary";
|
||||
|
||||
export interface JellifyServer {
|
||||
url: string;
|
||||
address: string;
|
||||
name: string;
|
||||
version: string;
|
||||
startUpComplete: boolean;
|
||||
library?: JellifyLibrary;
|
||||
}
|
||||