diff --git a/README.md b/README.md
index fd563aa2..7fef22f0 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,15 @@
# Jellify
-A music player for Jellyfin powered by React Native
+A music player for Jellyfin powered by React Native. Designed to be lightweight, fast, and support ***extremely*** large music libraries (i.e., > 100K songs)
+While other music players provide a WYSIWYG music player experience, *Jellify* caters to those who want a music player experience similar to what's provided by music streaming services. *Jellify* provides:
+- Quick access to previously played and favorite tracks
+- An infinitlyTM customizable home screen
+ - Select from the provided quick filters or build a custom filter
+- Support for Jellyfin mixes
+- Quick access to similar artists and items for discovering music in your library
+
+## Built with:
### Frontend
[React Native Reanimated](https://github.com/software-mansion/react-native-reanimated)
- [Marquee](https://github.com/animate-react-native/marquee)
@@ -11,10 +19,15 @@ A music player for Jellyfin powered by React Native
[React Native InApp Browser](https://github.com/proyecto26/react-native-inappbrowser)
### Backend
-[Jellyfin SDK](https://typescript-sdk.jellyfin.org/)
-[Tanstack Query](https://tanstack.com/query/latest/docs/framework/react/react-native)
-[React Native Track Player](https://github.com/doublesymmetry/react-native-track-player)
-[React Native Keychain](https://github.com/oblador/react-native-keychain)
+[Jellyfin SDK](https://typescript-sdk.jellyfin.org/)\
+[Tanstack Query](https://tanstack.com/query/latest/docs/framework/react/react-native)\
+[React Native Track Player](https://github.com/doublesymmetry/react-native-track-player)\
+[React Native Keychain](https://github.com/oblador/react-native-keychain)\
+[React Native Async Storage](https://github.com/react-native-async-storage/async-storage)
### Logging
-[GlitchTip](https://glitchtip.com/) captures anonymous logging if and only if the user opts into it. This can be toggled at anytime
\ No newline at end of file
+[GlitchTip](https://glitchtip.com/)
+- Captures anonymous logging if and only if the user opts into it. This can be toggled at anytime
+
+### Passion
+Love from [me](https://github.com/anultravioletaurora)! I've learned a lot from working on this project (and the many failed attempts before it) and I hope you enjoy using it!
\ No newline at end of file
diff --git a/api/enums/async-storage-keys.ts b/api/enums/async-storage-keys.ts
new file mode 100644
index 00000000..3c7751f3
--- /dev/null
+++ b/api/enums/async-storage-keys.ts
@@ -0,0 +1,3 @@
+export enum AsyncStorageKeys {
+ ServerUrl = "SERVER_URL"
+}
\ No newline at end of file
diff --git a/api/enums/mutation-keys.ts b/api/enums/mutation-keys.ts
new file mode 100644
index 00000000..94b5834e
--- /dev/null
+++ b/api/enums/mutation-keys.ts
@@ -0,0 +1,5 @@
+export enum MutationKeys {
+ AccessToken = "ACCESS_TOKEN",
+ Credentials = "CREDENTIALS",
+ ServerUrl = "SERVER_URL"
+}
\ No newline at end of file
diff --git a/api/enums/query-keys.ts b/api/enums/query-keys.ts
new file mode 100644
index 00000000..91e89562
--- /dev/null
+++ b/api/enums/query-keys.ts
@@ -0,0 +1,6 @@
+export enum QueryKeys {
+ Api = "API",
+ ArtistById = "ARTIST_BY_ID",
+ Credentials = "CREDENTIALS",
+ ServerUrl = "SERVER_URL"
+}
\ No newline at end of file
diff --git a/api/mutators/storage.ts b/api/mutators/storage.ts
new file mode 100644
index 00000000..727ff36a
--- /dev/null
+++ b/api/mutators/storage.ts
@@ -0,0 +1,36 @@
+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 * as Keychain from "react-native-keychain"
+import { useServerUrl } from "../queries/storage";
+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";
+
+export const serverUrl = useMutation({
+ mutationKey: [MutationKeys.ServerUrl],
+ mutationFn: (serverUrl: string) => {
+ 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: (credentials: JellyfinCredentials) => {
+ return Keychain.setInternetCredentials(useServerUrl.data!, credentials.username, credentials.accessToken);
+ },
+});
\ No newline at end of file
diff --git a/api/queries.ts b/api/queries.ts
new file mode 100644
index 00000000..14c48be8
--- /dev/null
+++ b/api/queries.ts
@@ -0,0 +1,29 @@
+import { Jellyfin } from "@jellyfin/sdk"
+import { useQuery } from "@tanstack/react-query";
+import { getDeviceNameSync, getUniqueIdSync } from "react-native-device-info"
+import { QueryKeys } from "./enums/query-keys";
+import { useServerUrl } from "./queries/storage";
+import { useCredentials } from "./queries/keychain";
+import { SharedWebCredentials } from "react-native-keychain";
+
+let clientName : string = require('root-require')('./package.json').name
+let clientVersion : string = require('root-require')('./package.json').version
+
+export const client : Jellyfin = new Jellyfin({
+ clientInfo: {
+ name: clientName,
+ version: clientVersion
+ },
+ deviceInfo: {
+ name: getDeviceNameSync(),
+ id: getUniqueIdSync()
+ }
+});
+
+export const useApi = useQuery({
+ queryKey: [QueryKeys.Api],
+ queryFn: () => {
+ let credentials = useCredentials.data!
+ return client.createApi(credentials.server, credentials.password);
+ }
+})
\ No newline at end of file
diff --git a/api/queries/artist-queries.ts b/api/queries/artist-queries.ts
deleted file mode 100644
index 5fa7c360..00000000
--- a/api/queries/artist-queries.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
-import { useQuery, UseQueryResult } from '@tanstack/react-query'
-import { JellyfinService } from '../services/jellyfin-service'
-import { ArtistModel } from '../../models/ArtistModel'
-
-namespace ArtistQueries {
-
- export function fetchArtistById(artistJellyfinId: string) : UseQueryResult {
- return useQuery({
- queryKey: ['artists', artistJellyfinId],
- queryFn: ({ queryKey }) => JellyfinService.instance.getArtistById(queryKey[1])
- })
- }
-}
diff --git a/api/queries/artists.ts b/api/queries/artists.ts
new file mode 100644
index 00000000..12a64ab5
--- /dev/null
+++ b/api/queries/artists.ts
@@ -0,0 +1,14 @@
+import { useQuery } from "@tanstack/react-query";
+import { QueryKeys } from "../enums/query-keys";
+import { Api } from "@jellyfin/sdk";
+import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api"
+import { useApi } from "../queries";
+
+
+export const useArtistById = (artistId: string) => useQuery({
+ queryKey: [QueryKeys.ArtistById, artistId],
+ queryFn: (({ queryKey }) => {
+
+ return getItemsApi(useApi.data!).getItems({ ids: [queryKey[1]]});
+ })
+})
\ No newline at end of file
diff --git a/api/queries/image-queries.ts b/api/queries/image-queries.ts
deleted file mode 100644
index 4bff0b25..00000000
--- a/api/queries/image-queries.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-
-namespace ImageQueries {
-
-}
\ No newline at end of file
diff --git a/api/queries/keychain.ts b/api/queries/keychain.ts
new file mode 100644
index 00000000..6fadf155
--- /dev/null
+++ b/api/queries/keychain.ts
@@ -0,0 +1,21 @@
+import { UseQueryResult, useQuery } from "@tanstack/react-query"
+import * as Keychain from "react-native-keychain"
+import { ArtistModel } from "../../models/ArtistModel"
+import { Api } from "@jellyfin/sdk"
+import AsyncStorage from "@react-native-async-storage/async-storage"
+import { AsyncStorageKeys } from "../enums/async-storage-keys"
+import { QueryKeys } from "../enums/query-keys"
+import { useServerUrl } from "./storage"
+
+export const useCredentials = useQuery({
+ queryKey: [QueryKeys.Credentials],
+ queryFn: () => {
+ return Keychain.getInternetCredentials(useServerUrl.data!)
+ .then((keychain) => {
+ if (!keychain)
+ throw new Error("Jellyfin server credentials not stored in keychain");
+
+ return keychain as Keychain.SharedWebCredentials
+ });
+ }
+});
\ No newline at end of file
diff --git a/api/queries/storage.ts b/api/queries/storage.ts
new file mode 100644
index 00000000..b775149b
--- /dev/null
+++ b/api/queries/storage.ts
@@ -0,0 +1,12 @@
+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 = useQuery({
+ queryKey: [QueryKeys.ServerUrl],
+ queryFn: (() => {
+ return AsyncStorage.getItem(AsyncStorageKeys.ServerUrl);
+ })
+});
diff --git a/api/services/jellyfin-service.ts b/api/services/jellyfin-service.ts
deleted file mode 100644
index d5908479..00000000
--- a/api/services/jellyfin-service.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { Api, Jellyfin } from "@jellyfin/sdk";
-import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api"
-import { getDeviceNameSync, getUniqueIdSync } from "react-native-device-info";
-import { ArtistModel } from "../../models/ArtistModel";
-import * as Keychain from "react-native-keychain"
-
-let clientName : string = require('root-require')('./package.json').name
-let clientVersion : string = require('root-require')('./package.json').version
-
-export class JellyfinService {
-
- private static _instance: JellyfinService;
-
- private api?: Api;
-
- // TODO: This should read in auth details when constructed?
- private constructor() {};
-
- private client : Jellyfin = new Jellyfin({
- clientInfo: {
- name: clientName,
- version: clientVersion
- },
- deviceInfo: {
- name: getDeviceNameSync(),
- id: getUniqueIdSync()
- }
- })
-
- public static get instance() {
-
- // TODO: Determine this makes singleton correctly
- return this._instance || (this._instance = new this())
- }
-
- public static get api() : Api {
- return this.instance.api!
- }
-
- public initConnection(serverUrl: string) : void {
- JellyfinService.instance.api = JellyfinService.instance.client.createApi(serverUrl);
- }
-
- public storeAuthDetails(serverUrl: string, username: string, sessionToken: string) : Promise {
- // Set sessionToken in API
- JellyfinService.instance.api = JellyfinService.instance.client.createApi(serverUrl, sessionToken);
-
- // Store in encrypted local storage
- return Keychain.setInternetCredentials(serverUrl, username, sessionToken)
-
- // TODO: Refresh on reopen?
- }
-
- public async getArtistById(artistJellyfinId: string) : Promise {
-
- return getItemsApi(JellyfinService.instance.api!)
- .getItems({ ids: [artistJellyfinId]})
-
- // TODO: Error handling here
- .then((response) => response.data.Items!.at(0)!)
- .then(item => new ArtistModel(item))
- }
-}
\ No newline at end of file
diff --git a/api/types/jellyfin-credentials.ts b/api/types/jellyfin-credentials.ts
new file mode 100644
index 00000000..ab817afd
--- /dev/null
+++ b/api/types/jellyfin-credentials.ts
@@ -0,0 +1,10 @@
+
+export class JellyfinCredentials {
+ username: string;
+ accessToken: string;
+
+ constructor(username: string, accessToken: string) {
+ this.username = username;
+ this.accessToken = accessToken;
+ }
+}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index d2711f66..a7a1fe3c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,9 @@
"version": "0.0.1",
"dependencies": {
"@animatereactnative/marquee": "^0.2.0",
+ "@gcores/react-native-carplay": "^1.1.12",
"@jellyfin/sdk": "^0.10.0",
+ "@react-native-async-storage/async-storage": "^2.0.0",
"@sentry/react-native": "^5.30.0",
"@tanstack/react-query": "^5.52.1",
"react": "18.3.1",
@@ -2244,6 +2246,16 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
+ "node_modules/@gcores/react-native-carplay": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/@gcores/react-native-carplay/-/react-native-carplay-1.1.12.tgz",
+ "integrity": "sha512-JavYywgXAA/XOtuW/P5fGuw+PwJC4qDvMYVRMHiIF8pJWbxl3zihautfQZzsU94ApFMoVdOPNhjHzhNXn5a8hg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/@hapi/hoek": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
@@ -3173,6 +3185,18 @@
"node": ">= 8"
}
},
+ "node_modules/@react-native-async-storage/async-storage": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.0.0.tgz",
+ "integrity": "sha512-af6H9JjfL6G/PktBfUivvexoiFKQTJGQCtSWxMdivLzNIY94mu9DdiY0JqCSg/LyPCLGKhHPUlRQhNvpu3/KVA==",
+ "license": "MIT",
+ "dependencies": {
+ "merge-options": "^3.0.4"
+ },
+ "peerDependencies": {
+ "react-native": "^0.0.0-0 || >=0.65 <1.0"
+ }
+ },
"node_modules/@react-native-community/cli": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-14.0.0.tgz",
@@ -9477,6 +9501,15 @@
"node": ">=8"
}
},
+ "node_modules/is-plain-obj": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+ "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-plain-object": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
@@ -12082,6 +12115,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
+ "node_modules/merge-options": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
+ "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-plain-obj": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
diff --git a/package.json b/package.json
index 2d89ec80..07531373 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,9 @@
},
"dependencies": {
"@animatereactnative/marquee": "^0.2.0",
+ "@gcores/react-native-carplay": "^1.1.12",
"@jellyfin/sdk": "^0.10.0",
+ "@react-native-async-storage/async-storage": "^2.0.0",
"@sentry/react-native": "^5.30.0",
"@tanstack/react-query": "^5.52.1",
"react": "18.3.1",