Merge pull request #17 from anultravioletaurora/13-support-favorites-and-build-library-screen

13 support favorites and build library screen
This commit is contained in:
Violet Caulfield
2025-01-22 14:55:39 -06:00
committed by GitHub
114 changed files with 2569 additions and 6783 deletions

12
App.tsx
View File

@@ -1,17 +1,16 @@
import './gesture-handler';
import "./global.css";
import React from 'react';
import "react-native-url-polyfill/auto";
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import Jellify from './components/jellify';
import { TamaguiProvider, Theme } from 'tamagui';
import { ToastProvider } from '@tamagui/toast'
import { useColorScheme } from 'react-native';
import jellifyConfig from './tamagui.config';
import { clientPersister } from './constants/storage';
import { queryClient } from './constants/query-client';
import { CacheManager } from '@georstat/react-native-image-cache';
import { Dirs } from "react-native-file-access";
import { EventProvider } from "react-native-outside-press";
CacheManager.config = {
baseDir: `${Dirs.CacheDir}/images_cache/`,
@@ -35,12 +34,9 @@ export default function App(): React.JSX.Element {
}}>
<TamaguiProvider config={jellifyConfig}>
<Theme name={isDarkMode ? 'dark' : 'light'}>
<ToastProvider
swipeDirection='down'
native={false}
>
<Jellify />
</ToastProvider>
<EventProvider>
<Jellify />
</EventProvider>
</Theme>
</TamaguiProvider>
</PersistQueryClientProvider>

View File

@@ -36,4 +36,7 @@ Designed to be lightweight, fast, and support ***extremely*** large music librar
- Captures anonymous logging if and only if the user opts into it. This can be toggled at anytime
### Love
This is undoubtedly a passion project of [mine](https://github.com/anultravioletaurora), and I've learned a lot from working on it (and the many failed attempts before it). I hope you enjoy using it! Feature requests and bug reports are welcome :)
This is undoubtedly a passion project of [mine](https://github.com/anultravioletaurora), and I've learned a lot from working on it (and the many failed attempts before it). I hope you enjoy using it! Feature requests and bug reports are welcome :)
## Running Locally
Clone the repository and run ```npm i``` to install the dependencies

View File

@@ -1,36 +1,163 @@
import { Api, Jellyfin } from "@jellyfin/sdk";
import { getDeviceNameSync, getUniqueIdSync } from "react-native-device-info";
import { name, version } from "../package.json"
import { capitalize } from "lodash";
import { Api } from "@jellyfin/sdk/lib/api";
import { JellyfinInfo } from "./info";
import { JellifyServer } from "../types/JellifyServer";
import { JellifyUser } from "../types/JellifyUser";
import { storage } from '../constants/storage';
import { MMKVStorageKeys } from "../enums/mmkv-storage-keys";
import uuid from 'react-native-uuid';
import { JellifyLibrary } from "../types/JellifyLibrary";
/**
* Client object that represents Jellify on the Jellyfin server.
*/
export const jellifyClient: Jellyfin = new Jellyfin({
clientInfo: {
name: capitalize(name),
version: version
},
deviceInfo: {
name: getDeviceNameSync(),
id: getUniqueIdSync()
export default class Client {
static #instance: Client;
private api : Api | undefined;
private user : JellifyUser | undefined;
private server : JellifyServer | undefined;
private library : JellifyLibrary | undefined;
private sessionId : string = uuid.v4();
private constructor(
api?: Api | undefined,
user?: JellifyUser | undefined,
server?: JellifyServer | undefined,
library?: JellifyLibrary | undefined
) {
const userJson = storage.getString(MMKVStorageKeys.User)
const serverJson = storage.getString(MMKVStorageKeys.Server);
const libraryJson = storage.getString(MMKVStorageKeys.Library);
if (user)
this.setAndPersistUser(user)
else if (userJson)
this.user = JSON.parse(userJson)
if (server)
this.setAndPersistServer(server)
else if (serverJson)
this.server = JSON.parse(serverJson);
if (library)
this.setAndPersistLibrary(library)
else if (libraryJson)
this.library = JSON.parse(libraryJson)
if (api)
this.api = api
else if (this.user && this.server)
this.api = new Api(this.server.url, JellyfinInfo.clientInfo, JellyfinInfo.deviceInfo, this.user.accessToken);
}
});
/**
* Uses the jellifyClient to create a public Jellyfin API instance.
* @param serverUrl The URL of the Jellyfin server
* @returns
*/
export function buildPublicApiClient(serverUrl : string) : Api {
return jellifyClient.createApi(serverUrl);
}
public static get instance(): Client {
if (!Client.#instance) {
Client.#instance = new Client();
}
/**
*
* @param serverUrl The URL of the Jellyfin server
* @param accessToken The assigned accessToken for the Jellyfin user
*/
export function buildAuthenticatedApiClient(serverUrl: string, accessToken: string) : Api {
return jellifyClient.createApi(serverUrl, accessToken);
return Client.#instance;
}
public static get api(): Api | undefined {
return Client.#instance.api;
}
public static get server(): JellifyServer | undefined {
return Client.#instance.server;
}
public static get user(): JellifyUser | undefined {
return Client.#instance.user;
}
public static get library(): JellifyLibrary | undefined {
return Client.#instance.library;
}
public static get sessionId(): string {
return Client.#instance.sessionId;
}
public static signOut(): void {
Client.#instance.removeCredentials()
}
public static switchServer() : void {
Client.#instance.removeServer();
}
public static switchUser(): void {
Client.#instance.removeUser();
}
public static setUser(user: JellifyUser): void {
Client.#instance.setAndPersistUser(user);
}
private setAndPersistUser(user: JellifyUser) {
this.user = user;
// persist user details
storage.set(MMKVStorageKeys.User, JSON.stringify(user));
}
private setAndPersistServer(server : JellifyServer) {
this.server = server;
storage.set(MMKVStorageKeys.Server, JSON.stringify(server));
}
private setAndPersistLibrary(library : JellifyLibrary) {
this.library = library;
storage.set(MMKVStorageKeys.Library, JSON.stringify(library))
}
private removeCredentials() {
this.library = undefined;
this.library = undefined;
this.server = undefined;
this.user = undefined;
storage.delete(MMKVStorageKeys.Server)
storage.delete(MMKVStorageKeys.Library)
storage.delete(MMKVStorageKeys.User)
}
private removeServer() {
this.server = undefined;
storage.delete(MMKVStorageKeys.Server)
}
private removeUser() {
this.user = undefined;
storage.delete(MMKVStorageKeys.User)
}
/**
* Uses the jellifyClient to create a public Jellyfin API instance.
* @param serverUrl The URL of the Jellyfin server
* @returns
*/
public static setPublicApiClient(server : JellifyServer) : void {
const api = JellyfinInfo.createApi(server.url);
Client.#instance = new Client(api, undefined, server, undefined)
}
/**
*
* @param serverUrl The URL of the Jellyfin server
* @param accessToken The assigned accessToken for the Jellyfin user
*/
public static setPrivateApiClient(server : JellifyServer, user : JellifyUser) : void {
const api = JellyfinInfo.createApi(server.url, user.accessToken);
Client.#instance = new Client(api, user, server, undefined);
}
public static setLibrary(library : JellifyLibrary) : void {
Client.#instance = new Client(undefined, undefined, undefined, library);
}
}

18
api/info.ts Normal file
View File

@@ -0,0 +1,18 @@
import { Jellyfin } from "@jellyfin/sdk";
import { getModel, getUniqueIdSync } from "react-native-device-info";
import { name, version } from "../package.json"
import { capitalize } from "lodash";
/**
* Client object that represents Jellify on the Jellyfin server.
*/
export const JellyfinInfo: Jellyfin = new Jellyfin({
clientInfo: {
name: capitalize(name),
version: version
},
deviceInfo: {
name: getModel(),
id: getUniqueIdSync()
}
});

View File

@@ -1,13 +1,13 @@
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"
import { BaseItemKind, ItemSortBy, SortOrder } from "@jellyfin/sdk/lib/generated-client/models"
import Client from "../client"
export const useArtistAlbums = (artistId: string, api: Api) => useQuery({
queryKey: [QueryKeys.ArtistAlbums, artistId, api],
export const useArtistAlbums = (artistId: string) => useQuery({
queryKey: [QueryKeys.ArtistAlbums, artistId],
queryFn: ({ queryKey }) => {
return getItemsApi(queryKey[2] as Api).getItems({
return getItemsApi(Client.api!).getItems({
includeItemTypes: [BaseItemKind.MusicAlbum],
recursive: true,
excludeItemIds: [queryKey[1] as string],
@@ -26,10 +26,10 @@ export const useArtistAlbums = (artistId: string, api: Api) => useQuery({
})
export const useArtistFeaturedOnAlbums = (artistId: string, api: Api) => useQuery({
queryKey: [QueryKeys.ArtistFeaturedAlbums, artistId, api],
export const useArtistFeaturedOnAlbums = (artistId: string) => useQuery({
queryKey: [QueryKeys.ArtistFeaturedAlbums, artistId],
queryFn: ({ queryKey }) => {
return getItemsApi(queryKey[2] as Api).getItems({
return getItemsApi(Client.api!).getItems({
includeItemTypes: [BaseItemKind.MusicAlbum],
recursive: true,
excludeItemIds: [queryKey[1] as string],

29
api/queries/favorites.ts Normal file
View File

@@ -0,0 +1,29 @@
import { QueryKeys } from "../../enums/query-keys";
import { useQuery } from "@tanstack/react-query";
import { fetchFavoriteAlbums, fetchFavoriteArtists, fetchFavoriteTracks, fetchUserData } from "./functions/favorites";
export const useFavoriteArtists = () => useQuery({
queryKey: [QueryKeys.FavoriteArtists],
queryFn: () => {
return fetchFavoriteArtists()
}
});
export const useFavoriteAlbums = () => useQuery({
queryKey: [QueryKeys.FavoriteAlbums],
queryFn: () => {
return fetchFavoriteAlbums()
}
});
export const useFavoriteTracks = () => useQuery({
queryKey: [QueryKeys.FavoriteTracks],
queryFn: () => fetchFavoriteTracks()
});
export const useUserData = (itemId: string) => useQuery({
queryKey: [QueryKeys.UserData, itemId],
queryFn: () => fetchUserData(itemId)
});

View File

@@ -1,5 +1,5 @@
import { Api } from "@jellyfin/sdk";
import { jellifyClient } from "../../client";
import { JellyfinInfo } from "../../info";
import _ from "lodash";
export function createApi(serverUrl?: string, username?: string, password?: string, accessToken?: string): Promise<Api> {
@@ -12,22 +12,22 @@ export function createApi(serverUrl?: string, username?: string, password?: stri
if (!_.isUndefined(accessToken)) {
console.info("Creating API with accessToken")
return resolve(jellifyClient.createApi(serverUrl, accessToken));
return resolve(JellyfinInfo.createApi(serverUrl, accessToken));
}
if (_.isUndefined(username) && _.isUndefined(password)) {
console.info("Creating public API for server url")
return resolve(jellifyClient.createApi(serverUrl));
return resolve(JellyfinInfo.createApi(serverUrl));
}
console.log("Signing into Jellyfin")
let authResult = await jellifyClient.createApi(serverUrl).authenticateUserByName(username!, password);
let authResult = await JellyfinInfo.createApi(serverUrl).authenticateUserByName(username!, password);
if (authResult.data.AccessToken) {
console.info("Signed into Jellyfin successfully")
return resolve(jellifyClient.createApi(serverUrl, authResult.data.AccessToken));
return resolve(JellyfinInfo.createApi(serverUrl, authResult.data.AccessToken));
}
return reject("Unable to sign in");

View File

@@ -0,0 +1,116 @@
import Client from "../../client";
import { BaseItemDto, BaseItemKind, ItemSortBy, SortOrder, UserItemDataDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
export function fetchFavoriteArtists(): Promise<BaseItemDto[]> {
console.debug(`Fetching user's favorite artists`);
return new Promise(async (resolve, reject) => {
getItemsApi(Client.api!)
.getItems({
includeItemTypes: [
BaseItemKind.MusicArtist
],
isFavorite: true,
parentId: Client.library!.musicLibraryId,
recursive: true,
sortBy: [
ItemSortBy.SortName
],
sortOrder: [
SortOrder.Ascending
]
})
.then((response) => {
console.debug(`Received favorite artist response`, response);
if (response.data.Items)
resolve(response.data.Items)
else
resolve([]);
}).catch((error) => {
console.error(error);
reject(error);
})
})
}
export function fetchFavoriteAlbums(): Promise<BaseItemDto[]> {
console.debug(`Fetching user's favorite albums`);
return new Promise(async (resolve, reject) => {
getItemsApi(Client.api!)
.getItems({
includeItemTypes: [
BaseItemKind.MusicAlbum
],
isFavorite: true,
parentId: Client.library!.musicLibraryId!,
recursive: true,
sortBy: [
ItemSortBy.SortName
],
sortOrder: [
SortOrder.Ascending
]
})
.then((response) => {
console.debug(`Received favorite album response`, response);
if (response.data.Items)
resolve(response.data.Items)
else
resolve([]);
}).catch((error) => {
console.error(error);
reject(error);
})
})
}
export function fetchFavoriteTracks(): Promise<BaseItemDto[]> {
console.debug(`Fetching user's favorite tracks`);
return new Promise(async (resolve, reject) => {
getItemsApi(Client.api!)
.getItems({
includeItemTypes: [
BaseItemKind.Audio
],
isFavorite: true,
parentId: Client.library!.musicLibraryId,
recursive: true,
sortBy: [
ItemSortBy.SortName
],
sortOrder: [
SortOrder.Ascending
]
})
.then((response) => {
console.debug(`Received favorite artist response`, response);
if (response.data.Items)
resolve(response.data.Items)
else
resolve([]);
}).catch((error) => {
console.error(error);
reject(error);
})
})
}
export function fetchUserData(itemId: string): Promise<UserItemDataDto> {
return new Promise(async (resolve, reject) => {
getItemsApi(Client.api!)
.getItemUserData({
itemId
}).then((response) => {
resolve(response.data)
}).catch((error) => {
console.error(error);
reject(error);
})
});
}

View File

@@ -1,71 +1,23 @@
import { Api } from "@jellyfin/sdk/lib/api"
import { ImageFormat, ImageType } from "@jellyfin/sdk/lib/generated-client/models"
import { getImageApi } from "@jellyfin/sdk/lib/utils/api"
import _ from "lodash"
import { queryConfig } from "../query.config"
import Client from "../../../api/client"
import { queryConfig } from "../query.config";
export function fetchImage(api: Api, itemId: string, imageType?: ImageType) : Promise<string> {
return new Promise(async (resolve) => {
let imageResponse = await api.axiosInstance
.get(getImageApi(api).getItemImageUrlById(
itemId,
imageType,
{
format: queryConfig.images.format,
fillHeight: queryConfig.images.fillHeight,
fillWidth: queryConfig.images.fillWidth
}
))
console.debug(convertFileToBase64(imageResponse.data));
console.debug(typeof imageResponse.data)
resolve(convertFileToBase64(imageResponse.data));
});
}
export function fetchArtistImage(api: Api, artistId: string, imageType?: ImageType) : Promise<string> {
return new Promise(async (resolve, reject) => {
let response = await getImageApi(api).getArtistImage({
name: "",
imageIndex: 1,
imageType: imageType ? imageType : ImageType.Primary
})
console.log(response.data)
if (_.isEmpty(response.data))
reject(new Error("No image for artist"))
resolve(convertFileToBase64(response.data));
});
}
export function fetchItemImage(api: Api, itemId: string, imageType?: ImageType, width?: number) {
export function fetchItemImage(itemId: string, imageType?: ImageType, size?: number) {
return getImageApi(api).getItemImage({
itemId,
imageType: imageType ? imageType : ImageType.Primary,
format: ImageFormat.Jpg
})
.then((response) => {
console.log(convertFileToBase64(response.data))
return convertFileToBase64(response.data);
})
}
function base64toJpeg(encode: string) : string {
return `data:image/jpeg;base64,${encode}`;
}
function convertFileToBase64(file: any): string {
console.debug("Converting file to base64", file)
let encode = base64toJpeg(Buffer.from(file, 'binary').toString('base64'));
console.debug(encode);
return encode;
return getImageApi(Client.api!)
.getItemImage({
itemId,
imageType: imageType ? imageType : ImageType.Primary,
format: ImageFormat.Jpg,
height: size ?? queryConfig.images.height,
width: size ?? queryConfig.images.width
}, {
responseType: 'blob'
})
.then((response) => {
console.log(response)
return URL.createObjectURL(response.data)
});
}

View File

@@ -1,14 +1,14 @@
import { Api } from "@jellyfin/sdk";
import Client from "../../client";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
import { isUndefined } from "lodash";
export function fetchMusicLibraries(api: Api): Promise<BaseItemDto[]> {
export function fetchMusicLibraries(): Promise<BaseItemDto[]> {
return new Promise(async (resolve, reject) => {
console.debug("Fetching music libraries from Jellyfin");
let libraries = await getItemsApi(api).getItems({
let libraries = await getItemsApi(Client.api!).getItems({
includeItemTypes: ['CollectionFolder']
});
@@ -24,11 +24,11 @@ export function fetchMusicLibraries(api: Api): Promise<BaseItemDto[]> {
});
}
export function fetchPlaylistLibrary(api: Api): Promise<BaseItemDto> {
export function fetchPlaylistLibrary(): Promise<BaseItemDto> {
return new Promise(async (resolve, reject) => {
console.debug("Fetching playlist library from Jellyfin");
let libraries = await getItemsApi(api).getItems({
let libraries = await getItemsApi(Client.api!).getItems({
includeItemTypes: ['ManualPlaylistsFolder'],
excludeItemTypes: ['CollectionFolder']
});

View File

@@ -1,15 +1,16 @@
import Client from "../../client";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto, ItemSortBy, SortOrder } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
export function fetchUserPlaylists(api: Api, userId: string, playlistLibraryId: string): Promise<BaseItemDto[]> {
export function fetchUserPlaylists(): Promise<BaseItemDto[]> {
console.debug("Fetching user playlists");
return new Promise(async (resolve, reject) => {
getItemsApi(api)
getItemsApi(Client.api!)
.getItems({
userId: userId,
parentId: playlistLibraryId,
userId: Client.user!.id,
parentId: Client.library!.playlistLibraryId!,
fields: [
"Path"
],
@@ -36,13 +37,13 @@ export function fetchUserPlaylists(api: Api, userId: string, playlistLibraryId:
})
}
export function fetchPublicPlaylists(api: Api, playlistLibraryId: string): Promise<BaseItemDto[]> {
export function fetchPublicPlaylists(): Promise<BaseItemDto[]> {
console.debug("Fetching public playlists");
return new Promise(async (resolve, reject) => {
getItemsApi(api)
getItemsApi(Client.api!)
.getItems({
parentId: playlistLibraryId,
parentId: Client.library!.playlistLibraryId!,
sortBy: [
ItemSortBy.IsFolder,
ItemSortBy.SortName

View File

@@ -1,20 +1,20 @@
import { Api } from "@jellyfin/sdk/lib/api";
import { BaseItemDto, BaseItemKind, ItemSortBy, SortOrder } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
import { queryConfig } from "../query.config";
import Client from "../../client";
export function fetchRecentlyPlayed(api: Api, libraryId: string): Promise<BaseItemDto[]> {
export function fetchRecentlyPlayed(): Promise<BaseItemDto[]> {
console.debug("Fetching recently played items", api);
console.debug("Fetching recently played items");
return new Promise(async (resolve, reject) => {
getItemsApi(api)
getItemsApi(Client.api!)
.getItems({
includeItemTypes: [
BaseItemKind.Audio
],
limit: queryConfig.limits.recents,
parentId: libraryId,
parentId: Client.library!.musicLibraryId,
recursive: true,
sortBy: [
ItemSortBy.DatePlayed
@@ -37,4 +37,17 @@ export function fetchRecentlyPlayed(api: Api, libraryId: string): Promise<BaseIt
reject(error);
})
})
}
export function fetchRecentlyPlayedArtists() : Promise<BaseItemDto[]> {
return fetchRecentlyPlayed()
.then((tracks) => {
return getItemsApi(Client.api!)
.getItems({
ids: tracks.map(track => track.ArtistItems![0].Id!)
})
.then((recentArtists) => {
return recentArtists.data.Items!
});
});
}

View File

@@ -1,20 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { QueryKeys } from "../../enums/query-keys";
import { Api } from "@jellyfin/sdk";
import { fetchArtistImage, fetchImage, fetchItemImage } from "./functions/images";
import { fetchItemImage } from "./functions/images";
import { ImageType } from "@jellyfin/sdk/lib/generated-client/models";
export const useImage = (api: Api, itemId: string, imageType?: ImageType) => useQuery({
queryKey: [QueryKeys.ItemImage, api, itemId, imageType],
queryFn: ({ queryKey }) => fetchImage(queryKey[1] as Api, queryKey[2] as string, queryKey[3] as ImageType | undefined)
});
export const useArtistImage = (api: Api, artistName: string, imageType?: ImageType) => useQuery({
queryKey: [QueryKeys.ArtistImage, api, artistName, imageType],
queryFn: ({ queryKey }) => fetchArtistImage(queryKey[1] as Api, queryKey[2] as string, queryKey[3] as ImageType | undefined)
})
export const useItemImage = (api: Api, itemId: string, imageType?: ImageType, width?: number) => useQuery({
queryKey: [QueryKeys.ItemImage, api, itemId, imageType, width],
queryFn: ({ queryKey }) => fetchItemImage(queryKey[1] as Api, queryKey[2] as string, queryKey[3] as ImageType | undefined, queryKey[4] as number | undefined)
export const useItemImage = (itemId: string, imageType?: ImageType, size?: number) => useQuery({
queryKey: [QueryKeys.ItemImage, itemId, imageType, size],
queryFn: () => fetchItemImage(itemId, imageType, size)
});

View File

@@ -1,24 +1,13 @@
import { QueryKeys } from "@/enums/query-keys";
import { Api } from "@jellyfin/sdk";
import { QueryKeys } from "../../enums/query-keys";
import { useQuery } from "@tanstack/react-query";
import { fetchMusicLibraries, fetchPlaylistLibrary } from "./functions/libraries";
export const useMusicLibraries = (api: Api) => useQuery({
queryKey: [QueryKeys.Libraries, api],
queryFn: async ({ queryKey }) => {
const api : Api = queryKey[1] as Api;
return await fetchMusicLibraries(api)
}
export const useMusicLibraries = () => useQuery({
queryKey: [QueryKeys.Libraries],
queryFn: () => fetchMusicLibraries()
});
export const usePlaylistLibrary = (api: Api) => useQuery({
queryKey: [QueryKeys.Playlist, api],
queryFn: async ({ queryKey }) => {
const api : Api = queryKey[1] as Api;
return await fetchPlaylistLibrary(api)
}
export const usePlaylistLibrary = () => useQuery({
queryKey: [QueryKeys.Playlist],
queryFn: () => fetchPlaylistLibrary()
});

View File

@@ -1,16 +1,9 @@
import { QueryKeys } from "@/enums/query-keys";
import { Api } from "@jellyfin/sdk";
import { QueryKeys } from "../../enums/query-keys";
import { useQuery } from "@tanstack/react-query";
import { fetchUserPlaylists } from "./functions/playlists";
export const useUserPlaylists = (api: Api, userId: string, playlistLibraryId: string) => useQuery({
queryKey: [QueryKeys.UserPlaylists, api, userId, playlistLibraryId],
queryFn: ({ queryKey }) => {
const api: Api = queryKey[1] as Api;
const userId: string = queryKey[2] as string;
const playlistLibraryId: string = queryKey[3] as string;
return fetchUserPlaylists(api, userId, playlistLibraryId);
}
})
export const useUserPlaylists = () => useQuery({
queryKey: [QueryKeys.UserPlaylists],
queryFn: () => fetchUserPlaylists()
});

View File

@@ -5,8 +5,8 @@ export const queryConfig = {
recents: 50 // TODO: Adjust this when we add a list navigator to the end of the recents
},
images: {
fillHeight: 300,
fillWidth: 300,
height: 300,
width: 300,
format: ImageFormat.Jpg
},
banners: {
@@ -23,5 +23,6 @@ export const queryConfig = {
fillHeight: 1000,
fillWidth: 1000,
format: ImageFormat.Jpg
}
},
staleTime: 1000 * 60
}

View File

@@ -1,32 +1,13 @@
import { Api } from "@jellyfin/sdk";
import { useQuery } from "@tanstack/react-query";
import { QueryKeys } from "../../enums/query-keys";
import { fetchRecentlyPlayed } from "./functions/recents";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"
import { fetchRecentlyPlayed, fetchRecentlyPlayedArtists } from "./functions/recents";
export const useRecentlyPlayed = (api: Api, libraryId: string) => useQuery({
queryKey: [QueryKeys.RecentlyPlayed, api, libraryId],
queryFn: ({ queryKey }) => {
const api : Api = queryKey[1] as Api;
const libraryId : string = queryKey[2] as string;
return fetchRecentlyPlayed(api, libraryId)
}
export const useRecentlyPlayed = () => useQuery({
queryKey: [QueryKeys.RecentlyPlayed],
queryFn: () => fetchRecentlyPlayed()
});
export const useRecentlyPlayedArtists = (api: Api, libraryId: string) => useQuery({
queryKey: [QueryKeys.RecentlyPlayedArtists, api, libraryId],
queryFn: ({ queryKey }) => {
return fetchRecentlyPlayed(queryKey[1] as Api, queryKey[2] as string)
.then((tracks) => {
return getItemsApi(api)
.getItems({
ids: tracks.map(track => track.ArtistItems![0].Id!)
})
.then((recentArtists) => {
return recentArtists.data.Items!
});
});
}
export const useRecentlyPlayedArtists = () => useQuery({
queryKey: [QueryKeys.RecentlyPlayedArtists],
queryFn: () => fetchRecentlyPlayedArtists()
});

View File

@@ -1,16 +1,15 @@
import { QueryKeys } from "@/enums/query-keys";
import { Api } from "@jellyfin/sdk";
import { QueryKeys } from "../../enums/query-keys";
import { ItemSortBy } from "@jellyfin/sdk/lib/generated-client/models/item-sort-by";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
import { useQuery } from "@tanstack/react-query";
import { queryConfig } from "./query.config";
import Client from "../client";
export const useItemTracks = (itemId: string, api: Api, sort: boolean = false) => useQuery({
queryKey: [QueryKeys.ItemTracks, itemId, api, sort],
queryFn: ({ queryKey }) => {
export const useItemTracks = (itemId: string, sort: boolean = false) => useQuery({
queryKey: [QueryKeys.ItemTracks, itemId, sort],
queryFn: () => {
const itemId : string = queryKey[1] as string;
const api : Api = queryKey[2] as Api;
const sort : boolean = queryKey[3] as boolean;
console.debug(`Fetching item tracks ${sort ? "sorted" : "unsorted"}`)
let sortBy: ItemSortBy[] = [];
@@ -22,12 +21,13 @@ export const useItemTracks = (itemId: string, api: Api, sort: boolean = false) =
]
}
return getItemsApi(api).getItems({
return getItemsApi(Client.api!).getItems({
parentId: itemId,
sortBy
})
.then((response) => {
return response.data.Items ? response.data.Items! : [];
})
}
},
staleTime: queryConfig.staleTime
})

View File

@@ -1,5 +1,5 @@
module.exports = {
presets: ['babel-preset-expo'],
presets: ['module:@react-native/babel-preset'],
plugins: [
// react-native-reanimated/plugin has to be listed last

View File

@@ -3,7 +3,6 @@ import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { ScrollView, YStack, XStack } from "tamagui";
import { CachedImage } from "@georstat/react-native-image-cache";
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
import { useApiClientContext } from "../jellyfin-api-provider";
import { BaseItemDto, ImageType } from "@jellyfin/sdk/lib/generated-client/models";
import { queryConfig } from "../../api/queries/query.config";
import { H4, H5, Text } from "../Global/helpers/text";
@@ -11,8 +10,11 @@ import { FlatList } from "react-native";
import { usePlayerContext } from "../../player/provider";
import { RunTimeTicks } from "../Global/helpers/time-codes";
import Track from "../Global/components/track";
import { useItemTracks } from "@/api/queries/tracks";
import { useItemTracks } from "../../api/queries/tracks";
import { SafeAreaView, useSafeAreaFrame } from "react-native-safe-area-context";
import FavoriteButton from "../Global/components/favorite-button";
import { useEffect } from "react";
import Client from "../../api/client";
interface AlbumProps {
album: BaseItemDto,
@@ -21,19 +23,32 @@ interface AlbumProps {
export default function Album(props: AlbumProps): React.JSX.Element {
const { apiClient } = useApiClientContext();
const { nowPlaying } = usePlayerContext();
props.navigation.setOptions({
headerRight: () => {
return (
<FavoriteButton item={props.album} />
)
}
})
const { nowPlaying, nowPlayingIsFavorite } = usePlayerContext();
const { width } = useSafeAreaFrame();
const { data: tracks, isLoading } = useItemTracks(props.album.Id!, apiClient!, true);
const { data: tracks, isLoading, refetch } = useItemTracks(props.album.Id!, true);
useEffect(() => {
refetch();
}, [
nowPlayingIsFavorite
])
return (
<SafeAreaView edges={["right", "left"]}>
<ScrollView contentInsetAdjustmentBehavior="automatic">
<YStack alignItems="center" minHeight={width / 1.1}>
<CachedImage
source={getImageApi(apiClient!)
source={getImageApi(Client.api!)
.getItemImageUrlById(
props.album.Id!,
ImageType.Primary,
@@ -60,6 +75,7 @@ export default function Album(props: AlbumProps): React.JSX.Element {
track={track}
tracklist={tracks!}
index={index}
navigation={props.navigation}
/>
)

View File

@@ -1,10 +1,10 @@
import { RouteProp } from "@react-navigation/native";
import Album from "../../Album/component";
import Album from "../component";
import { StackParamList } from "../../types";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
export function HomeAlbumScreen({ route, navigation } : { route: RouteProp<StackParamList, "Album">, navigation: NativeStackNavigationProp<StackParamList> }): React.JSX.Element {
export function AlbumScreen({ route, navigation } : { route: RouteProp<StackParamList, "Album">, navigation: NativeStackNavigationProp<StackParamList> }): React.JSX.Element {
return (
<Album
album={route.params.album }

View File

@@ -0,0 +1,41 @@
import { useFavoriteAlbums } from "../../api/queries/favorites";
import { AlbumsProps } from "../types";
import { SafeAreaView, useSafeAreaFrame } from "react-native-safe-area-context";
import { ItemCard } from "../Global/helpers/item-card";
import { FlatList, RefreshControl } from "react-native";
export default function Albums({ navigation }: AlbumsProps) : React.JSX.Element {
const { data: albums, refetch, isPending } = useFavoriteAlbums();
const { width } = useSafeAreaFrame();
return (
<SafeAreaView edges={["left", "right"]}>
<FlatList
contentInsetAdjustmentBehavior="automatic"
numColumns={2}
data={albums}
refreshControl={
<RefreshControl
refreshing={isPending}
onRefresh={refetch}
/>
}
renderItem={({ index, item: album}) => {
return (
<ItemCard
itemId={album.Id!}
caption={album.Name ?? "Untitled Album"}
subCaption={album.ProductionYear?.toString() ?? ""}
cornered
onPress={() => {
navigation.navigate("Album", { album })
}}
width={width / 2.1}
/>
)
}}
/>
</SafeAreaView>
)
}

View File

@@ -0,0 +1,17 @@
import React from "react"
import { StackParamList } from "../types"
import { RouteProp } from "@react-navigation/native"
import { NativeStackNavigationProp } from "@react-navigation/native-stack"
import Albums from "./component"
export default function AlbumsScreen({
route,
navigation
} : {
route: RouteProp<StackParamList, "Albums">,
navigation: NativeStackNavigationProp<StackParamList, "Albums", undefined>
}) : React.JSX.Element {
return (
<Albums route={route} navigation={navigation}/>
)
}

View File

@@ -1,80 +1,90 @@
import { ScrollView, useWindowDimensions } from "tamagui";
import { ScrollView, YStack } from "tamagui";
import { useArtistAlbums } from "../../api/queries/artist";
import { useApiClientContext } from "../jellyfin-api-provider";
import { FlatList } from "react-native";
import { Card } from "../Global/helpers/card";
import { ItemCard } from "../Global/helpers/item-card";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { StackParamList } from "../types";
import { H2 } from "../Global/helpers/text";
import { useState } from "react";
import { CachedImage } from "@georstat/react-native-image-cache";
import { ImageType } from "@jellyfin/sdk/lib/generated-client/models";
import { queryConfig } from "@/api/queries/query.config";
import { BaseItemDto, ImageType } from "@jellyfin/sdk/lib/generated-client/models";
import { queryConfig } from "../../api/queries/query.config";
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
import { SafeAreaView, useSafeAreaFrame } from "react-native-safe-area-context";
import FavoriteButton from "../Global/components/favorite-button";
import Client from "../../api/client";
interface ArtistProps {
artistId: string,
artistName: string,
artist: BaseItemDto
navigation: NativeStackNavigationProp<StackParamList>
}
export default function Artist(props: ArtistProps): React.JSX.Element {
props.navigation.setOptions({
headerRight: () => {
return (
<FavoriteButton item={props.artist} />
)
}
});
const [columns, setColumns] = useState<number>(2);
const { apiClient } = useApiClientContext();
const { height, width } = useSafeAreaFrame();
const { width } = useSafeAreaFrame();
const bannerHeight = height / 6;
const { data: albums } = useArtistAlbums(props.artistId, apiClient!);
const { data: albums } = useArtistAlbums(props.artist.Id!);
return (
<SafeAreaView edges={["top", "right", "left"]}>
<SafeAreaView style={{ flex: 1 }} edges={["top", "right", "left"]}>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
alignContent="center">
<CachedImage
source={getImageApi(apiClient!)
.getItemImageUrlById(
props.artistId,
ImageType.Primary,
{ ...queryConfig.banners})
}
imageStyle={{
width: 500,
height: 350,
resizeMode: "cover",
position: "relative"
}}
/>
<YStack alignContent="center" justifyContent="center" minHeight={bannerHeight}>
<CachedImage
source={getImageApi(Client.api!)
.getItemImageUrlById(
props.artist.Id!,
ImageType.Primary,
{ ...queryConfig.banners})
}
imageStyle={{
width: width,
height: bannerHeight,
alignSelf: "center",
resizeMode: "cover",
position: "relative"
}}
/>
</YStack>
<H2>Albums</H2>
<FlatList
contentContainerStyle={{
flexGrow: 1,
justifyContent: 'center',
alignItems: "center"
}}
data={albums}
numColumns={columns} // TODO: Make this adjustable
renderItem={({ item: album }) => {
return (
<Card
caption={album.Name}
subCaption={album.ProductionYear?.toString()}
width={(width / 1.1) / columns}
cornered
itemId={album.Id!}
onPress={() => {
props.navigation.navigate('Album', {
album
})
}}
/>
)
}}
/>
<FlatList
contentContainerStyle={{
flexGrow: 1,
alignContent: 'center'
}}
data={albums}
numColumns={columns} // TODO: Make this adjustable
renderItem={({ item: album }) => {
return (
<ItemCard
caption={album.Name}
subCaption={album.ProductionYear?.toString()}
width={(width / 1.1) / columns}
cornered
itemId={album.Id!}
onPress={() => {
props.navigation.navigate('Album', {
album
})
}}
/>
)
}}
/>
</ScrollView>
</SafeAreaView>
)

View File

@@ -1,16 +1,19 @@
import { RouteProp } from "@react-navigation/native";
import Artist from "../../Artist/component";
import Artist from "../component";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { StackParamList } from "../../types";
export function HomeArtistScreen({ route, navigation } : {
export function ArtistScreen({
route,
navigation
} : {
route: RouteProp<StackParamList, "Artist">,
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
return (
<Artist
artistId={route.params.artistId}
artistName={route.params.artistName}
artist={route.params.artist}
navigation={navigation}
/>
);

View File

@@ -0,0 +1,42 @@
import { useFavoriteArtists } from "../../api/queries/favorites";
import { SafeAreaView, useSafeAreaFrame } from "react-native-safe-area-context";
import React from "react";
import { FlatList, RefreshControl } from "react-native";
import { ItemCard } from "../Global/helpers/item-card";
import { ArtistsProps } from "../types";
export default function Artists({ navigation }: ArtistsProps): React.JSX.Element {
const { data: artists, refetch, isPending } = useFavoriteArtists();
const { width } = useSafeAreaFrame();
return (
<SafeAreaView edges={["left", "right"]}>
<FlatList
contentInsetAdjustmentBehavior="automatic"
numColumns={2}
data={artists}
refreshControl={
<RefreshControl
refreshing={isPending}
onRefresh={refetch}
/>
}
renderItem={({ index, item: artist}) => {
return (
<ItemCard
artistName={artist.Name!}
itemId={artist.Id!}
caption={artist.Name ?? "Unknown Artist"}
onPress={() => {
navigation.navigate("Artist", { artist })
}}
width={width / 2.1}
/>
)
}}
/>
</SafeAreaView>
)
}

View File

@@ -0,0 +1,17 @@
import React from "react"
import { StackParamList } from "../types"
import { RouteProp } from "@react-navigation/native"
import { NativeStackNavigationProp } from "@react-navigation/native-stack"
import Artists from "./component"
export default function ArtistsScreen({
route,
navigation
} : {
route: RouteProp<StackParamList, "Artists">,
navigation: NativeStackNavigationProp<StackParamList, "Artists", undefined>
}) : React.JSX.Element {
return (
<Artists route={route} navigation={navigation}/>
)
}

View File

@@ -0,0 +1,34 @@
import React, {useEffect} from 'react';
import {Text, View} from 'react-native';
import {CarPlay, NowPlayingTemplate} from 'react-native-carplay';
export function NowPlaying() {
useEffect(() => {
const template = new NowPlayingTemplate({
albumArtistButtonEnabled: true,
upNextButtonEnabled: false,
onUpNextButtonPressed() {
console.log('up next was pressed');
},
onButtonPressed(e) {
console.log(e);
},
});
CarPlay.enableNowPlaying(true);
CarPlay.pushTemplate(template);
return () => {};
}, []);
return (
<View style={{flex: 1, alignItems: 'center', justifyContent: 'center'}}>
<Text>Now Playing</Text>
</View>
);
}
NowPlaying.navigationOptions = {
headerTitle: 'Now Playing Template',
};

View File

@@ -0,0 +1,105 @@
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import React from "react";
import { StackParamList } from "../types";
import FavoritesScreen from "./screens";
import { ArtistScreen } from "../Artist/screens";
import { AlbumScreen } from "../Album/screens";
import { PlaylistScreen } from "../Playlist/screens";
import ArtistsScreen from "../Artists/screen";
import AlbumsScreen from "../Albums/screen";
import TracksScreen from "../Tracks/screen";
import DetailsScreen from "../ItemDetail/screen";
const LibraryStack = createNativeStackNavigator<StackParamList>();
export default function Library(): React.JSX.Element {
return (
<LibraryStack.Navigator
id="Favorites"
initialRouteName="Favorites"
>
<LibraryStack.Screen
name="Favorites"
component={FavoritesScreen}
options={{
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold'
}
}}
/>
<LibraryStack.Screen
name="Artist"
component={ArtistScreen}
options={({ route }) => ({
title: route.params.artist.Name ?? "Unknown Artist",
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold'
}
})}
/>
<LibraryStack.Screen
name="Artists"
component={ArtistsScreen}
options={({ route }) => ({
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold'
}
})}
/>
<LibraryStack.Screen
name="Album"
component={AlbumScreen}
options={({ route }) => ({
headerShown: true,
headerTitle: ""
})}
/>
<LibraryStack.Screen
name="Albums"
component={AlbumsScreen}
options={{
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold'
}
}}
/>
<LibraryStack.Screen
name="Tracks"
component={TracksScreen}
options={{
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold'
}
}}
/>
<LibraryStack.Screen
name="Playlist"
component={PlaylistScreen}
options={({ route }) => ({
headerShown: true,
headerTitle: ""
})}
/>
<LibraryStack.Screen
name="Details"
component={DetailsScreen}
options={{
headerShown: false,
presentation: "modal"
}}
/>
</LibraryStack.Navigator>
)
}

View File

@@ -0,0 +1,15 @@
interface CategoryRoute {
name: any; // ¯\_(ツ)_/¯
iconName: string;
};
const Categories : CategoryRoute[] = [
{ name: "Artists", iconName: "microphone-variant" },
{ name: "Albums", iconName: "music-box-multiple" },
{ name: "Tracks", iconName: "music-note"},
{ name: "Playlists", iconName: "playlist-music"},
{ name: "Genres", iconName: "guitar-electric"}
];
export default Categories;

View File

@@ -0,0 +1,40 @@
import { FlatList } from "react-native";
import { SafeAreaView, useSafeAreaFrame } from "react-native-safe-area-context";
import Categories from "./categories";
import IconCard from "../../../components/Global/helpers/icon-card";
import { StackParamList } from "../../../components/types";
import { RouteProp } from "@react-navigation/native";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
export default function FavoritesScreen({
route,
navigation
} : {
route: RouteProp<StackParamList, "Favorites">,
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { width } = useSafeAreaFrame();
return (
<SafeAreaView style={{ flex: 1 }} edges={["top", "right", "left"]}>
<FlatList
contentInsetAdjustmentBehavior="automatic"
data={Categories}
numColumns={2}
renderItem={({ index, item }) => {
return (
<IconCard
name={item.iconName}
caption={item.name}
width={width / 2.1}
onPress={() => {
navigation.navigate(item.name)
}}
/>
)
}}
/>
</SafeAreaView>
)
}

View File

@@ -0,0 +1,99 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useEffect, useState } from "react";
import Icon from "../helpers/icon";
import { Colors } from "../../../enums/colors";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useMutation } from "@tanstack/react-query";
import { isUndefined } from "lodash";
import { useUserData } from "../../../api/queries/favorites";
import { Spinner } from "tamagui";
import Client from "../../../api/client";
import { usePlayerContext } from "../../..//player/provider";
interface SetFavoriteMutation {
item: BaseItemDto,
}
export default function FavoriteButton({
item,
onToggle
}: {
item: BaseItemDto;
onToggle?: () => void
}) : React.JSX.Element {
const { nowPlaying, nowPlayingIsFavorite } = usePlayerContext();
const [isFavorite, setIsFavorite] = useState<boolean>(isFavoriteItem(item));
const { data, isFetching, isFetched, refetch } = useUserData(item.Id!);
const useSetFavorite = useMutation({
mutationFn: async (mutation: SetFavoriteMutation) => {
return getUserLibraryApi(Client.api!)
.markFavoriteItem({
itemId: mutation.item.Id!
})
},
onSuccess: () => {
setIsFavorite(true);
onToggle ? onToggle() : {};
}
})
const useRemoveFavorite = useMutation({
mutationFn: async (mutation: SetFavoriteMutation) => {
return getUserLibraryApi(Client.api!)
.unmarkFavoriteItem({
itemId: mutation.item.Id!
})
},
onSuccess: () => {
setIsFavorite(false);
onToggle ? onToggle(): {};
}
})
const toggleFavorite = () => {
if (isFavorite)
useRemoveFavorite.mutate({ item })
else
useSetFavorite.mutate({ item })
}
useEffect(() => {
if (isFetched
&& !isUndefined(data)
&& !isUndefined(data.IsFavorite)
)
setIsFavorite(data.IsFavorite)
}, [
isFetched,
data
])
useEffect(() => {
refetch();
}, [
item
]);
return (
isFetching && isUndefined(item.UserData) ? (
<Spinner />
) : (
<Icon
name={isFavorite ? "heart" : "heart-outline"}
color={Colors.Primary}
onPress={toggleFavorite}
/>
)
)
}
export function isFavoriteItem(item: BaseItemDto) : boolean {
return isUndefined(item.UserData) ? false
: isUndefined(item.UserData.IsFavorite) ? false
: item.UserData.IsFavorite
}

View File

@@ -1,27 +1,32 @@
import { usePlayerContext } from "@/player/provider";
import { usePlayerContext } from "../../../player/provider";
import React from "react";
import { Separator, View, XStack, YStack } from "tamagui";
import { Separator, Spacer, View, XStack, YStack } from "tamagui";
import { Text } from "../helpers/text";
import { RunTimeTicks } from "../helpers/time-codes";
import { BaseItemDto, ImageType } from "@jellyfin/sdk/lib/generated-client/models";
import { Colors } from "@/enums/colors";
import { Colors } from "../../../enums/colors";
import { CachedImage } from "@georstat/react-native-image-cache";
import { getImageApi } from "@jellyfin/sdk/lib/utils/api/image-api";
import { useApiClientContext } from "@/components/jellyfin-api-provider";
import { queryConfig } from "@/api/queries/query.config";
import { queryConfig } from "../../../api/queries/query.config";
import { useSafeAreaFrame } from "react-native-safe-area-context";
import Icon from "../helpers/icon";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { StackParamList } from "../../../components/types";
import Client from "../../../api/client";
interface TrackProps {
track: BaseItemDto;
tracklist: BaseItemDto[];
index: number;
navigation: NativeStackNavigationProp<StackParamList>;
index: number | undefined;
showArtwork?: boolean | undefined;
onPress?: () => void | undefined
onPress?: () => void | undefined;
}
export default function Track({
track,
tracklist,
navigation,
index,
queueName,
showArtwork,
@@ -29,14 +34,14 @@ export default function Track({
} : {
track: BaseItemDto,
tracklist: BaseItemDto[],
index: number,
navigation: NativeStackNavigationProp<StackParamList>;
index?: number | undefined,
queueName?: string | undefined,
showArtwork?: boolean | undefined,
onPress?: () => void | undefined
}) : React.JSX.Element {
const { width } = useSafeAreaFrame();
const { apiClient } = useApiClientContext();
const { nowPlaying, usePlayNewQueue } = usePlayerContext();
const isPlaying = nowPlaying?.item.Id === track.Id;
@@ -70,7 +75,7 @@ export default function Track({
>
{ showArtwork ? (
<CachedImage
source={getImageApi(apiClient!)
source={getImageApi(Client.api!)
.getItemImageUrlById(
track.AlbumId ?? "",
ImageType.Primary,
@@ -92,7 +97,7 @@ export default function Track({
)}
</XStack>
<YStack alignContent="center" justifyContent="flex-start" flex={6}>
<YStack alignContent="center" justifyContent="flex-start" flex={5}>
<Text
bold
color={isPlaying ? Colors.Primary : Colors.White}
@@ -108,11 +113,41 @@ export default function Track({
</YStack>
<XStack
justifyContent="center"
alignItems="center"
justifyContent="space-between"
alignContent="center"
flex={1}
flex={2}
>
<RunTimeTicks>{ track.RunTimeTicks }</RunTimeTicks>
<YStack
alignContent="center"
justifyContent="center"
minWidth={24}
>
{ track.UserData?.IsFavorite ? (
<Icon small name="heart" color={Colors.Primary} />
) : (
<Spacer />
)}
</YStack>
<YStack
alignContent="center"
justifyContent="space-around"
>
<RunTimeTicks>{ track.RunTimeTicks }</RunTimeTicks>
</YStack>
<YStack
alignContent="center"
justifyContent="center"
>
<Icon small name="dots-vertical" onPress={() => {
navigation.push("Details", {
item: track
})
}} />
</YStack>
</XStack>
</XStack>
</View>

View File

@@ -1,8 +1,8 @@
import type { AvatarProps as TamaguiAvatarProps } from "tamagui";
import { Avatar as TamaguiAvatar, YStack } from "tamagui"
import { Text } from "./text"
import { useApiClientContext } from "@/components/jellyfin-api-provider";
import { Colors } from "react-native/Libraries/NewAppScreen";
import Client from "../../../api/client";
interface AvatarProps extends TamaguiAvatarProps {
itemId: string;
@@ -11,8 +11,6 @@ interface AvatarProps extends TamaguiAvatarProps {
export default function Avatar(props: AvatarProps): React.JSX.Element {
const { server } = useApiClientContext();
return (
<YStack alignItems="center" marginHorizontal={10}>
<TamaguiAvatar
@@ -20,7 +18,7 @@ export default function Avatar(props: AvatarProps): React.JSX.Element {
borderRadius={!!!props.circular ? 4 : 'unset'}
{...props}
>
<TamaguiAvatar.Image src={`${server!.url}/Items/${props.itemId!}/Images/Primary`} />
<TamaguiAvatar.Image src={`${Client.server!.url}/Items/${props.itemId!}/Images/Primary`} />
<TamaguiAvatar.Fallback backgroundColor={Colors.Secondary}/>
</TamaguiAvatar>
{ props.children && (

View File

@@ -1,12 +0,0 @@
import { Blurhash } from "react-native-blurhash";
import { ImageSourcePropType } from "react-native/Libraries/Image/Image";
const BlurhashLoading = (props: any) => {
return (
<Blurhash blurhash={props}>
</Blurhash>
)
}
export default BlurhashLoading;

View File

@@ -0,0 +1,40 @@
import { BaseItemDto, ImageType } from "@jellyfin/sdk/lib/generated-client/models";
import { useItemImage } from "../../../api/queries/image";
import { Blurhash } from "react-native-blurhash";
import { Image, View } from "tamagui";
import { isEmpty } from "lodash";
interface BlurhashLoadingProps {
item: BaseItemDto;
size: number
}
export default function BlurhashedImage({ item, size, type }: { item: BaseItemDto, size: number, type?: ImageType }) : React.JSX.Element {
const { data: image, isSuccess } = useItemImage(item.Id!, type);
const blurhash = !isEmpty(item.ImageBlurHashes)
&& !isEmpty(item.ImageBlurHashes.Primary)
? Object.values(item.ImageBlurHashes.Primary)[0]
: undefined;
return (
<View minHeight={size} minWidth={size}>
{ isSuccess ? (
<Image
source={{
uri: image
}}
style={{
height: size,
width: size,
}}
/>
) : blurhash && (
<Blurhash blurhash={blurhash!} style={{ flex: 1 }} />
)
}
</View>
)
}

View File

@@ -4,6 +4,7 @@ interface ButtonProps {
children?: Element | string | undefined;
onPress?: () => void | undefined;
disabled?: boolean | undefined;
danger?: boolean | undefined;
}
export default function Button(props: ButtonProps): React.JSX.Element {

View File

@@ -0,0 +1,44 @@
import { Card, View } from "tamagui";
import { H2 } from "./text";
import { Colors } from "../../../enums/colors";
import Icon from "./icon";
export default function IconCard({
name,
onPress,
width,
caption,
}: {
name: string,
onPress: () => void,
width?: number | undefined,
caption?: string | undefined,
}) : React.JSX.Element {
return (
<View
alignItems="center"
margin={5}
>
<Card
elevate
borderRadius={"$7"}
animation="bouncy"
hoverStyle={{ scale: 0.925 }}
pressStyle={{ scale: 0.875 }}
width={width ? width : 150}
height={width ? width : 150}
onPress={onPress}
>
<Card.Header>
<Icon color={Colors.Background} name={name} large />
</Card.Header>
<Card.Footer padded>
<H2 color={Colors.Background}>{ caption }</H2>
</Card.Footer>
<Card.Background backgroundColor={Colors.Primary}>
</Card.Background>
</Card>
</View>
)
}

View File

@@ -1,23 +1,24 @@
import React from "react"
import MaterialCommunityIcons from "react-native-vector-icons/MaterialCommunityIcons"
import { Colors } from "../../../enums/colors"
import { useColorScheme } from "react-native";
const smallSize = 24;
const regularSize = 36;
const largeSize = 48
export default function Icon({ name, onPress, large }: { name: string, onPress?: Function, large?: boolean }) : React.JSX.Element {
export default function Icon({ name, onPress, small, large, color }: { name: string, onPress?: () => void, small?: boolean, large?: boolean, color?: Colors }) : React.JSX.Element {
let size = large ? largeSize : regularSize
const isDarkMode = useColorScheme() === "dark"
let size = large ? largeSize : small ? smallSize : regularSize
return (
<MaterialCommunityIcons
color={Colors.White}
color={color ? color : isDarkMode ? Colors.White : Colors.Background}
name={name}
onPress={() => {
if (onPress)
onPress();
}}
onPress={onPress}
size={size}
/>
)

View File

@@ -1,6 +1,6 @@
import { Colors } from '../../../enums/colors';
import React, { SetStateAction } from 'react';
import { StyleProp } from 'react-native';
import { Input as TamaguiInput, TextStyle} from 'tamagui';
import { Input as TamaguiInput} from 'tamagui';
interface InputProps {
onChangeText: React.Dispatch<SetStateAction<string | undefined>>,
@@ -14,6 +14,8 @@ export default function Input(props: InputProps): React.JSX.Element {
return (
<TamaguiInput
backgroundColor={Colors.Background}
borderColor={Colors.Borders}
placeholder={props.placeholder}
onChangeText={props.onChangeText}
value={props.value}

View File

@@ -1,7 +1,6 @@
import React, { } from "react";
import type { CardProps as TamaguiCardProps } from "tamagui"
import { H5, Card as TamaguiCard, View } from "tamagui";
import { useApiClientContext } from "../../jellyfin-api-provider";
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
import { ImageType } from "@jellyfin/sdk/lib/generated-client/models";
import { CachedImage } from "@georstat/react-native-image-cache";
@@ -9,6 +8,7 @@ import invert from "invert-color"
import { Blurhash } from "react-native-blurhash"
import { queryConfig } from "../../../api/queries/query.config";
import { Text } from "./text";
import Client from "../../../api/client";
interface CardProps extends TamaguiCardProps {
artistName?: string;
@@ -19,16 +19,14 @@ interface CardProps extends TamaguiCardProps {
cornered?: boolean;
}
export function Card(props: CardProps) {
const { apiClient } = useApiClientContext();
export function ItemCard(props: CardProps) {
const dimensions = props.width && typeof(props.width) === "number" ? { width: props.width, height: props.width } : { width: 150, height: 150 };
const cardTextColor = props.blurhash ? invert(Blurhash.getAverageColor(props.blurhash)!, true) : undefined;
const logoDimensions = props.width && typeof(props.width) === "number" ? { width: props.width / 2, height: props.width / 2 }: { width: 100, height: 100 };
const cardLogoSource = getImageApi(apiClient!).getItemImageUrlById(props.itemId, ImageType.Logo);
const cardLogoSource = getImageApi(Client.api!).getItemImageUrlById(props.itemId, ImageType.Logo);
return (
<View
@@ -50,7 +48,7 @@ export function Card(props: CardProps) {
</TamaguiCard.Header>
<TamaguiCard.Footer padded>
<CachedImage
source={getImageApi(apiClient!)
source={getImageApi(Client.api!)
.getItemImageUrlById(
props.itemId,
ImageType.Logo,
@@ -70,7 +68,7 @@ export function Card(props: CardProps) {
</TamaguiCard.Footer>
<TamaguiCard.Background>
<CachedImage
source={getImageApi(apiClient!)
source={getImageApi(Client.api!)
.getItemImageUrlById(
props.itemId,
ImageType.Primary,

View File

@@ -1,4 +1,4 @@
import { Colors } from "@/enums/colors";
import { Colors } from "../../../enums/colors";
import React from "react";
import { SliderProps as TamaguiSliderProps, SliderVerticalProps, Slider as TamaguiSlider, styled, Slider } from "tamagui";
@@ -11,11 +11,11 @@ interface SliderProps {
const JellifySliderThumb = styled(Slider.Thumb, {
backgroundColor: Colors.Primary,
borderColor: Colors.Background
borderColor: Colors.Background,
})
const JellifySliderTrack = styled(Slider.Track, {
backgroundColor: Colors.Secondary
backgroundColor: Colors.Borders
});
const JellifyActiveSliderTrack = styled(Slider.TrackActive, {

View File

@@ -1,5 +1,6 @@
import { SizeTokens, XStack, Separator, Switch, ColorTokens } from "tamagui";
import { Label } from "./text";
import { Colors } from "react-native/Libraries/NewAppScreen";
interface SwitchWithLabelProps {
onCheckedChange: (value: boolean) => void,
@@ -26,7 +27,7 @@ export function SwitchWithLabel(props: SwitchWithLabelProps) {
size={props.size}
checked={props.checked}
onCheckedChange={(checked: boolean) => props.onCheckedChange(checked)}
backgroundColor={props.backgroundColor}
backgroundColor={props.backgroundColor ?? Colors.Primary}
>
<Switch.Thumb animation="quicker" />
</Switch>

View File

@@ -1,5 +1,5 @@
import { convertRunTimeTicksToSeconds } from "../../../helpers/runtimeticks";
import { Text } from "./text";
import { convertRunTimeTicksToSeconds } from "@/helpers/runtimeticks";
import React from "react";
export function RunTimeSeconds({ children }: { children: number }) : React.JSX.Element {

View File

@@ -1,25 +1,17 @@
import { H3, ScrollView, Separator, XStack, YStack } from "tamagui";
import _ from "lodash";
import RecentlyPlayed from "./helpers/recently-played";
import { useApiClientContext } from "../jellyfin-api-provider";
import RecentArtists from "./helpers/recent-artists";
import { RefreshControl } from "react-native";
import { HomeProvider, useHomeContext } from "./provider";
import { HomeProvider } from "./provider";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { StackParamList, ProvidedHomeProps } from "../types";
import { HomeArtistScreen } from "./screens/artist";
import Avatar from "../Global/helpers/avatar";
import { HomeAlbumScreen } from "./screens/album";
import Playlists from "./helpers/playlists";
import { SafeAreaView } from "react-native-safe-area-context";
import { HomePlaylistScreen } from "./screens/playlist";
import { StackParamList } from "../types";
import { ArtistScreen } from "../Artist/screens";
import { AlbumScreen } from "../Album/screens";
import { PlaylistScreen } from "../Playlist/screens";
import { ProvidedHome } from "./screens";
import DetailsScreen from "../ItemDetail/screen";
const HomeStack = createNativeStackNavigator<StackParamList>();
export default function Home(): React.JSX.Element {
const { user } = useApiClientContext();
return (
<HomeProvider>
<HomeStack.Navigator
@@ -28,87 +20,61 @@ export default function Home(): React.JSX.Element {
screenOptions={{
}}
>
<HomeStack.Screen
name="Home"
component={ProvidedHome}
options={{
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold'
}
}}
/>
<HomeStack.Group>
<HomeStack.Screen
name="Home"
component={ProvidedHome}
options={{
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold'
}
}}
/>
<HomeStack.Screen
name="Artist"
component={HomeArtistScreen}
options={({ route }) => ({
title: route.params.artistName,
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold'
}
})}
/>
<HomeStack.Screen
name="Artist"
component={ArtistScreen}
options={({ route }) => ({
title: route.params.artist.Name ?? "Unknown Artist",
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold'
}
})}
/>
<HomeStack.Screen
name="Album"
component={HomeAlbumScreen}
options={({ route }) => ({
headerShown: true,
headerTitle: ""
})}
/>
<HomeStack.Screen
name="Album"
component={AlbumScreen}
options={({ route }) => ({
headerShown: true,
headerTitle: ""
})}
/>
<HomeStack.Screen
name="Playlist"
component={HomePlaylistScreen}
options={({ route }) => ({
headerShown: true,
headerTitle: ""
})}
/>
<HomeStack.Screen
name="Playlist"
component={PlaylistScreen}
options={({ route }) => ({
headerShown: true,
headerTitle: ""
})}
/>
</HomeStack.Group>
<HomeStack.Group screenOptions={{ presentation: "modal"}}>
<HomeStack.Screen
name="Details"
component={DetailsScreen}
options={{
headerShown: false,
presentation: "modal"
}}
/>
</HomeStack.Group>
</HomeStack.Navigator>
</HomeProvider>
);
}
function ProvidedHome({ route, navigation }: ProvidedHomeProps): React.JSX.Element {
const { user } = useApiClientContext();
const { refreshing: refetching, onRefresh: onRefetch } = useHomeContext()
return (
<SafeAreaView edges={["top", "right", "left"]}>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl
refreshing={refetching}
onRefresh={onRefetch}
/>
}>
<YStack alignContent='flex-start'>
<XStack margin={"$2"}>
<H3>{`Hi, ${user!.name}`}</H3>
<YStack />
<Avatar maxHeight={30} itemId={user!.id} />
</XStack>
<Separator marginVertical={"$2"} />
<RecentArtists route={route} navigation={navigation} />
<Separator marginVertical={"$3"} />
<RecentlyPlayed />
<Separator marginVertical={"$3"} />
<Playlists route={route} navigation={navigation}/>
</YStack>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -1,17 +1,15 @@
import { useUserPlaylists } from "@/api/queries/playlist";
import { Card } from "@/components/Global/helpers/card";
import { H2 } from "@/components/Global/helpers/text";
import { useApiClientContext } from "@/components/jellyfin-api-provider";
import { ProvidedHomeProps } from "@/components/types";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useUserPlaylists } from "../../../api/queries/playlist";
import { ItemCard } from "../../../components/Global/helpers/item-card";
import { H2 } from "../../../components/Global/helpers/text";
import { StackParamList } from "../../../components/types";
import React from "react";
import { FlatList } from "react-native";
import { View } from "tamagui";
export default function Playlists({ navigation }: ProvidedHomeProps) : React.JSX.Element {
export default function Playlists({ navigation }: { navigation: NativeStackNavigationProp<StackParamList>}) : React.JSX.Element {
const { apiClient, user, library } = useApiClientContext();
const { data: playlists } = useUserPlaylists(apiClient!, user!.id, library!.playlistLibraryId);
const { data: playlists } = useUserPlaylists();
return (
<View>
@@ -20,7 +18,7 @@ export default function Playlists({ navigation }: ProvidedHomeProps) : React.JSX
data={playlists}
renderItem={({ item: playlist }) => {
return (
<Card
<ItemCard
itemId={playlist.Id!}
caption={playlist.Name ?? "Untitled Playlist"}
onPress={() => {

View File

@@ -1,13 +1,14 @@
import React, { useEffect } from "react";
import React from "react";
import { View } from "tamagui";
import { useHomeContext } from "../provider";
import { H2 } from "../../Global/helpers/text";
import { ProvidedHomeProps } from "../../types";
import { StackParamList } from "../../types";
import { FlatList } from "react-native";
import { Card } from "../../Global/helpers/card";
import { ItemCard } from "../../Global/helpers/item-card";
import { getPrimaryBlurhashFromDto } from "../../../helpers/blurhash";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
export default function RecentArtists({ navigation }: ProvidedHomeProps): React.JSX.Element {
export default function RecentArtists({ navigation }: { navigation: NativeStackNavigationProp<StackParamList>}): React.JSX.Element {
const { recentArtists } = useHomeContext();
@@ -18,7 +19,7 @@ export default function RecentArtists({ navigation }: ProvidedHomeProps): React.
data={recentArtists}
renderItem={({ item: recentArtist}) => {
return (
<Card
<ItemCard
artistName={recentArtist.Name!}
blurhash={getPrimaryBlurhashFromDto(recentArtist)}
itemId={recentArtist.Id!}
@@ -26,12 +27,11 @@ export default function RecentArtists({ navigation }: ProvidedHomeProps): React.
onPress={() => {
navigation.navigate('Artist',
{
artistId: recentArtist.Id!,
artistName: recentArtist.Name ?? "Unknown Artist"
artist: recentArtist,
}
)}
}>
</Card>
</ItemCard>
)
}}
/>

View File

@@ -2,14 +2,19 @@ import React from "react";
import { ScrollView, View } from "tamagui";
import { useHomeContext } from "../provider";
import { H2 } from "../../Global/helpers/text";
import { Card } from "../../Global/helpers/card";
import { useApiClientContext } from "../../jellyfin-api-provider";
import { ItemCard } from "../../Global/helpers/item-card";
import { usePlayerContext } from "../../../player/provider";
import { StackParamList } from "../../../components/types";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { trigger } from "react-native-haptic-feedback";
export default function RecentlyPlayed(): React.JSX.Element {
export default function RecentlyPlayed({
navigation
} : {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { usePlayNewQueue } = usePlayerContext();
const { apiClient, sessionId } = useApiClientContext();
const { recentTracks } = useHomeContext();
return (
@@ -18,7 +23,7 @@ export default function RecentlyPlayed(): React.JSX.Element {
<ScrollView horizontal>
{ recentTracks && recentTracks.map((recentlyPlayedTrack, index) => {
return (
<Card
<ItemCard
caption={recentlyPlayedTrack.Name}
subCaption={`${recentlyPlayedTrack.Artists?.join(", ")}`}
cornered
@@ -32,6 +37,12 @@ export default function RecentlyPlayed(): React.JSX.Element {
queueName: "Recently Played"
});
}}
onLongPress={() => {
trigger("impactLight");
navigation.push("Details", {
item: recentlyPlayedTrack
})
}}
/>
)
})}

View File

@@ -1,5 +1,4 @@
import React, { createContext, ReactNode, useContext, useState } from "react";
import { useApiClientContext } from "../jellyfin-api-provider";
import { useRecentlyPlayed, useRecentlyPlayedArtists } from "../../api/queries/recently-played";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
@@ -13,10 +12,8 @@ interface HomeContext {
const HomeContextInitializer = () => {
const [refreshing, setRefreshing] = useState<boolean>(false);
const { apiClient, library } = useApiClientContext();
const { data : recentTracks, refetch : refetchRecentTracks } = useRecentlyPlayed(apiClient!, library!.musicLibraryId);
const { data : recentArtists, refetch : refetchRecentArtists } = useRecentlyPlayedArtists(apiClient!, library!.musicLibraryId);
const { data : recentTracks, refetch : refetchRecentTracks } = useRecentlyPlayed();
const { data : recentArtists, refetch : refetchRecentArtists } = useRecentlyPlayedArtists();
const onRefresh = async () => {
await Promise.all([

View File

@@ -0,0 +1,64 @@
import { ProvidedHomeProps, StackParamList } from "../../../components/types";
import { ScrollView, RefreshControl } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { YStack, XStack, Separator } from "tamagui";
import Playlists from "../helpers/playlists";
import RecentArtists from "../helpers/recent-artists";
import RecentlyPlayed from "../helpers/recently-played";
import { useHomeContext } from "../provider";
import { H3 } from "../../../components/Global/helpers/text";
import Avatar from "../../../components/Global/helpers/avatar";
import Client from "../../../api/client";
import { usePlayerContext } from "../../../player/provider";
import { useEffect } from "react";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
export function ProvidedHome({
navigation
} : {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { refreshing: refetching, onRefresh: onRefetch } = useHomeContext()
const { nowPlayingIsFavorite } = usePlayerContext();
useEffect(() => {
onRefetch()
}, [
nowPlayingIsFavorite
])
return (
<SafeAreaView edges={["top", "right", "left"]}>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl
refreshing={refetching}
onRefresh={onRefetch}
/>
}>
<YStack alignContent='flex-start'>
<XStack margin={"$2"}>
<H3>{`Hi, ${Client.user!.name}`}</H3>
<YStack />
<Avatar maxHeight={30} itemId={Client.user!.id!} />
</XStack>
<Separator marginVertical={"$2"} />
<RecentArtists navigation={navigation} />
<Separator marginVertical={"$3"} />
<RecentlyPlayed navigation={navigation} />
<Separator marginVertical={"$3"} />
<Playlists navigation={navigation}/>
</YStack>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,100 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { SafeAreaView, useSafeAreaFrame } from "react-native-safe-area-context";
import { StackParamList } from "../types";
import TrackOptions from "./helpers/TrackOptions";
import { Spacer, View, XStack, YStack } from "tamagui";
import BlurhashedImage from "../Global/helpers/blurhashed-image";
import { Text } from "../Global/helpers/text";
import { Colors } from "../../enums/colors";
import FavoriteButton from "../Global/components/favorite-button";
export default function ItemDetail({
item,
navigation
} : {
item: BaseItemDto,
navigation: NativeStackNavigationProp<StackParamList>
}) : React.JSX.Element {
let options: React.JSX.Element | undefined = undefined;
const { width } = useSafeAreaFrame();
switch (item.Type) {
case "Audio": {
options = TrackOptions({ item, navigation });
break;
}
case "MusicAlbum" : {
break;
}
case "MusicArtist" : {
break;
}
case "Playlist" : {
break;
}
default : {
break;
}
}
return (
<SafeAreaView edges={["top", "right", "left"]}>
<XStack>
<BlurhashedImage
item={item}
size={width / 2}
/>
<YStack
marginLeft={"$0.5"}
justifyContent="flex-start"
alignContent="space-between"
>
<Text bold fontSize={"$6"}>
{ item.Name ?? "Untitled Track" }
</Text>
<Text
fontSize={"$6"}
color={Colors.Primary}
onPress={() => {
if (item.ArtistItems) {
navigation.goBack(); // Dismiss modal if exists
navigation.getParent()!.navigate("Artist", {
artist: item.ArtistItems[0]
});
}
}}>
{ item.Artists?.join(", ") ?? "Unknown Artist"}
</Text>
<Text
fontSize={"$6"}
color={"$gray10"}
>
{ item.Album ?? "" }
</Text>
<Spacer />
<FavoriteButton item={item} />
<Spacer />
{ options ?? <View /> }
</YStack>
</XStack>
</SafeAreaView>
)
}

View File

@@ -0,0 +1,22 @@
import Icon from "../../../components/Global/helpers/icon";
import { StackParamList } from "../../../components/types";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { XStack } from "tamagui";
export default function TrackOptions({
item,
navigation
} : {
item: BaseItemDto,
navigation: NativeStackNavigationProp<StackParamList>
}) : React.JSX.Element {
return (
<XStack justifyContent="space-evenly">
<Icon name="table-column-plus-before" />
<Icon name="table-column-plus-after" />
</XStack>
)
}

View File

@@ -0,0 +1,20 @@
import ItemDetail from "../../components/ItemDetail/component";
import { StackParamList } from "../../components/types";
import { RouteProp } from "@react-navigation/native";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import React from "react";
export default function DetailsScreen({
route,
navigation
} : {
route: RouteProp<StackParamList, "Details">,
navigation: NativeStackNavigationProp<StackParamList>
}) : React.JSX.Element {
return (
<ItemDetail
item={route.params.item}
navigation={navigation}
/>
)
}

View File

@@ -1,8 +0,0 @@
import React from "react";
import { View } from "tamagui";
export default function Library(): React.JSX.Element {
return (
<View></View>
)
}

View File

@@ -5,19 +5,16 @@ 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 { triggerAuth, setTriggerAuth } = useAuthenticationContext();
const { server, user } = useApiClientContext();
const { user, server, triggerAuth, setTriggerAuth } = useAuthenticationContext();
const Stack = createStackNavigator();
useEffect(() => {
setTriggerAuth(false);
})
});
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>

View File

@@ -1,36 +1,35 @@
import React, { useState } from "react";
import _ from "lodash";
import { useMutation } from "@tanstack/react-query";
import { MMKVStorageKeys } from "../../../enums/mmkv-storage-keys";
import { JellifyServer } from "../../../types/JellifyServer";
import { useApiClientContext } from "../../jellyfin-api-provider";
import { Spacer, Spinner, View, XStack, ZStack } from "tamagui";
import { Spacer, Spinner, XStack, ZStack } from "tamagui";
import { SwitchWithLabel } from "../../Global/helpers/switch-with-label";
import { H1 } from "../../Global/helpers/text";
import Input from "../../Global/helpers/input";
import Button from "../../Global/helpers/button";
import { http, https } from "../utils/constants";
import { storage } from "../../../constants/storage";
import { jellifyClient } from "../../../api/client";
import { JellyfinInfo } from "../../../api/info";
import { Jellyfin } from "@jellyfin/sdk/lib/jellyfin";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api";
import { SafeAreaView } from "react-native-safe-area-context";
import Client from "../../../api/client";
import { useAuthenticationContext } from "../provider";
export default function ServerAddress(): React.JSX.Element {
const { setServer } = useApiClientContext();
const [useHttps, setUseHttps] = useState<boolean>(true);
const [serverAddress, setServerAddress] = useState<string | undefined>(undefined);
const { server, setServer } = useAuthenticationContext();
const useServerMutation = useMutation({
mutationFn: async () => {
let jellyfin = new Jellyfin(jellifyClient);
let jellyfin = new Jellyfin(JellyfinInfo);
if (!!!serverAddress)
throw new Error("Server address was empty");
let api = jellyfin.createApi(`${useHttps ? https : http}${serverAddress}`);
let api = jellyfin.createApi(`${useHttps ? https : http}${serverAddress}`);
return getSystemApi(api).getPublicSystemInfo();
},
@@ -41,7 +40,7 @@ export default function ServerAddress(): React.JSX.Element {
console.debug("REMOVE THIS::onSuccess variable", publicSystemInfoResponse.data);
console.log(`Connected to Jellyfin ${publicSystemInfoResponse.data.Version!}`);
let jellifyServer: JellifyServer = {
const server: JellifyServer = {
url: `${useHttps ? https : http}${serverAddress!}`,
address: serverAddress!,
name: publicSystemInfoResponse.data.ServerName!,
@@ -49,11 +48,13 @@ export default function ServerAddress(): React.JSX.Element {
startUpComplete: publicSystemInfoResponse.data.StartupWizardCompleted!
}
setServer(jellifyServer);
Client.setPublicApiClient(server);
setServer(server);
},
onError: async (error: Error) => {
console.error("An error occurred connecting to the Jellyfin instance", error);
return storage.set(MMKVStorageKeys.Server, "");
Client.signOut();
setServer(undefined);
}
});

View File

@@ -1,24 +1,26 @@
import React from "react";
import React, { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useApiClientContext } from "../../jellyfin-api-provider";
import _ from "lodash";
import { JellyfinCredentials } from "../../../api/types/jellyfin-credentials";
import { Spinner, View, YStack, ZStack } from "tamagui";
import { Spinner, YStack, ZStack } from "tamagui";
import { useAuthenticationContext } from "../provider";
import { H1 } from "../../Global/helpers/text";
import Button from "../../Global/helpers/button";
import Input from "../../Global/helpers/input";
import { SafeAreaView } from "react-native-safe-area-context";
import Client from "../../../api/client";
import { JellifyUser } from "../../../types/JellifyUser";
export default function ServerAuthentication(): React.JSX.Element {
const { username, setUsername } = useAuthenticationContext();
const [password, setPassword] = React.useState<string | undefined>('');
const { server, setServer, setUser, apiClient } = useApiClientContext();
const [username, setUsername] = useState<string | undefined>(undefined);
const [password, setPassword] = React.useState<string | undefined>(undefined);
const { setUser, server, setServer } = useAuthenticationContext();
const useApiMutation = useMutation({
mutationFn: async (credentials: JellyfinCredentials) => {
return await apiClient!.authenticateUserByName(credentials.username, credentials.password!);
return await Client.api!.authenticateUserByName(credentials.username, credentials.password!);
},
onSuccess: async (authResult) => {
@@ -32,16 +34,20 @@ export default function ServerAuthentication(): React.JSX.Element {
if (_.isUndefined(authResult.data.User))
return Promise.reject(new Error("Unable to login"));
console.log(`Successfully signed in to server`)
return setUser({
console.log(`Successfully signed in to server`);
const user : JellifyUser = {
id: authResult.data.User!.Id!,
name: authResult.data.User!.Name!,
accessToken: (authResult.data.AccessToken as string)
})
}
Client.setUser(user);
return setUser(user);
},
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 Promise.reject(`An error occured signing into ${Client.server!.name}`);
}
});
@@ -50,10 +56,10 @@ export default function ServerAuthentication(): React.JSX.Element {
<H1>
{ `Sign in to ${server?.name ?? "Jellyfin"}`}
</H1>
<Button
onPress={() => {
setServer(undefined);
}}>
<Button onPress={() => {
Client.switchServer()
setServer(undefined);
}}>
Switch Server
</Button>

View File

@@ -1,33 +1,23 @@
import React, { useEffect } from "react";
import { useApiClientContext } from "../../jellyfin-api-provider";
import { Spinner, Text, ToggleGroup, View } from "tamagui";
import React, { useState } from "react";
import { Spinner, Text, ToggleGroup } from "tamagui";
import { useAuthenticationContext } from "../provider";
import { H1, Label } from "../../Global/helpers/text";
import Button from "../../Global/helpers/button";
import _ from "lodash";
import { useMusicLibraries, usePlaylistLibrary } from "@/api/queries/libraries";
import { useMusicLibraries, usePlaylistLibrary } from "../../../api/queries/libraries";
import { SafeAreaView } from "react-native-safe-area-context";
import Client from "../../../api/client";
import { useJellifyContext } from "../../../components/provider";
export default function ServerLibrary(): React.JSX.Element {
const { libraryId, setLibraryId } = useAuthenticationContext();
const { apiClient, setUser, setLibrary } = useApiClientContext();
const { setLoggedIn } = useJellifyContext()
const { setUser } = useAuthenticationContext();
const { data : libraries, isError, isPending, refetch: refetchMusicLibraries } = useMusicLibraries(apiClient!);
const { data : playlistLibrary, refetch: refetchPlaylistLibrary } = usePlaylistLibrary(apiClient!);
const [libraryId, setLibraryId] = useState<string | undefined>(undefined);
useEffect(() => {
refetchMusicLibraries();
refetchPlaylistLibrary();
}, [
apiClient
])
useEffect(() => {
console.log(libraries)
}, [
libraries
])
const { data : libraries, isError, isPending, refetch: refetchMusicLibraries } = useMusicLibraries();
const { data : playlistLibrary, refetch: refetchPlaylistLibrary } = usePlaylistLibrary();
return (
<SafeAreaView>
@@ -59,19 +49,22 @@ export default function ServerLibrary(): React.JSX.Element {
<Button disabled={!!!libraryId}
onPress={() => {
setLibrary({
Client.setLibrary({
musicLibraryId: libraryId!,
musicLibraryName: libraries?.filter((library) => library.Id == libraryId)[0].Name ?? "No library name",
musicLibraryPrimaryImageId: libraries?.filter((library) => library.Id == libraryId)[0].ImageTags!.Primary,
playlistLibraryId: playlistLibrary!.Id!,
playlistLibraryPrimaryImageId: playlistLibrary!.ImageTags!.Primary,
})
});
setLoggedIn(true);
}}>
Let's Go!
</Button>
<Button onPress={() => setUser(undefined)}>
<Button onPress={() => {
Client.switchUser();
setUser(undefined);
}}>
Switch User
</Button>
</SafeAreaView>

View File

@@ -1,44 +1,36 @@
import React, { createContext, ReactNode, SetStateAction, useContext, useState } from "react";
import _ from "lodash";
import { JellifyServer } from "../../types/JellifyServer";
import Client from "../../api/client";
import { JellifyUser } from "../../types/JellifyUser";
import { JellifyLibrary } from "../../types/JellifyLibrary";
interface JellyfinAuthenticationContext {
username: string | undefined;
setUsername: React.Dispatch<SetStateAction<string | undefined>>;
serverAddress: string | undefined;
setServerAddress: React.Dispatch<SetStateAction<string | undefined>>;
libraryName: string | undefined;
setLibraryName: React.Dispatch<React.SetStateAction<string | undefined>>;
libraryId: string | undefined;
setLibraryId: React.Dispatch<React.SetStateAction<string | undefined>>;
server: JellifyServer | undefined;
setServer: React.Dispatch<React.SetStateAction<JellifyServer | undefined>>;
user: JellifyUser | undefined;
setUser: React.Dispatch<React.SetStateAction<JellifyUser | undefined>>;
library: JellifyLibrary | undefined;
setLibrary: React.Dispatch<React.SetStateAction<JellifyLibrary | undefined>>;
triggerAuth: boolean;
setTriggerAuth: React.Dispatch<React.SetStateAction<boolean>>;
}
const JellyfinAuthenticationContextInitializer = () => {
const [username, setUsername] = useState<string | undefined>(undefined);
const [useHttp, setUseHttp] = useState<boolean>(false);
const [useHttps, setUseHttps] = useState<boolean>(true);
const [serverAddress, setServerAddress] = useState<string | undefined>(undefined);
const [libraryName, setLibraryName] = useState<string | undefined>(undefined);
const [libraryId, setLibraryId] = useState<string | undefined>(undefined);
const [server, setServer] = useState<JellifyServer | undefined>(Client.server)
const [user, setUser] = useState<JellifyUser | undefined>(Client.user)
const [library, setLibrary] = useState<JellifyLibrary | undefined>(Client.library);
const [triggerAuth, setTriggerAuth] = useState<boolean>(true);
return {
username,
setUsername,
useHttp,
setUseHttp,
useHttps,
setUseHttps,
serverAddress,
setServerAddress,
libraryName,
setLibraryName,
libraryId,
setLibraryId,
user,
setUser,
server,
setServer,
library,
setLibrary,
triggerAuth,
setTriggerAuth,
};
@@ -46,14 +38,12 @@ const JellyfinAuthenticationContextInitializer = () => {
const JellyfinAuthenticationContext =
createContext<JellyfinAuthenticationContext>({
username: undefined,
setUsername: () => {},
serverAddress: undefined,
setServerAddress: () => {},
libraryName: undefined,
setLibraryName: () => {},
libraryId: undefined,
setLibraryId: () => {},
user: undefined,
setUser: () => {},
server: undefined,
setServer: () => {},
library: undefined,
setLibrary: () => {},
triggerAuth: true,
setTriggerAuth: () => {},
});
@@ -63,28 +53,24 @@ export const JellyfinAuthenticationProvider: ({ children }: {
}) => React.JSX.Element = ({ children }: { children: ReactNode }) => {
const {
username,
setUsername,
serverAddress,
setServerAddress,
libraryName,
setLibraryName,
libraryId,
setLibraryId,
user,
setUser,
server,
setServer,
library,
setLibrary,
triggerAuth,
setTriggerAuth,
} = JellyfinAuthenticationContextInitializer();
return (
<JellyfinAuthenticationContext.Provider value={{
username,
setUsername,
serverAddress,
setServerAddress,
libraryName,
setLibraryName,
libraryId,
setLibraryId,
user,
setUser,
server,
setServer,
library,
setLibrary,
triggerAuth,
setTriggerAuth,
}}>

View File

@@ -2,7 +2,7 @@ import { State } from "react-native-track-player";
import { Colors } from "react-native/Libraries/NewAppScreen";
import { Spinner, View } from "tamagui";
import Icon from "../../Global/helpers/icon";
import { usePlayerContext } from "@/player/provider";
import { usePlayerContext } from "../../../player/provider";
export default function PlayPauseButton() : React.JSX.Element {

View File

@@ -1,9 +1,8 @@
import React, { } from "react";
import { XStack, YStack } from "tamagui";
import { View, XStack, YStack } from "tamagui";
import { usePlayerContext } from "../../player/provider";
import { BottomTabNavigationEventMap } from "@react-navigation/bottom-tabs";
import { NavigationHelpers, ParamListBase } from "@react-navigation/native";
import { BlurView } from "@react-native-community/blur";
import Icon from "../Global/helpers/icon";
import { Text } from "../Global/helpers/text";
import { Colors } from "../../enums/colors";
@@ -11,21 +10,19 @@ import { CachedImage } from "@georstat/react-native-image-cache";
import { ImageType } from "@jellyfin/sdk/lib/generated-client/models";
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
import { queryConfig } from "../../api/queries/query.config";
import { useApiClientContext } from "../jellyfin-api-provider";
import TextTicker from 'react-native-text-ticker';
import PlayPauseButton from "./helpers/buttons";
import { useSafeAreaFrame } from "react-native-safe-area-context";
import Client from "../../api/client";
export function Miniplayer({ navigation }: { navigation : NavigationHelpers<ParamListBase, BottomTabNavigationEventMap> }) : React.JSX.Element {
const { nowPlaying, useSkip } = usePlayerContext();
const { apiClient } = useApiClientContext();
const { width } = useSafeAreaFrame();
return (
<BlurView overlayColor={Colors.Background}>
<View style={{ backgroundColor: Colors.Background, borderColor: Colors.Borders }}>
{ nowPlaying && (
<XStack
@@ -39,7 +36,7 @@ export function Miniplayer({ navigation }: { navigation : NavigationHelpers<Para
alignContent="center"
flex={1}>
<CachedImage
source={getImageApi(apiClient!)
source={getImageApi(Client.api!)
.getItemImageUrlById(
nowPlaying!.item.AlbumId ?? "",
ImageType.Primary,
@@ -57,8 +54,13 @@ export function Miniplayer({ navigation }: { navigation : NavigationHelpers<Para
</YStack>
<YStack alignContent="flex-start" flex={4} maxWidth={"$20"}>
<TextTicker
<YStack
alignContent="flex-start"
marginLeft={"$0.5"}
flex={4}
maxWidth={"$20"}
>
<TextTicker
duration={5000}
loop
repeatSpacer={20}
@@ -91,6 +93,6 @@ export function Miniplayer({ navigation }: { navigation : NavigationHelpers<Para
</XStack>
</XStack>
)}
</BlurView>
</View>
)
}

View File

@@ -1,32 +1,37 @@
import { queryConfig } from "@/api/queries/query.config";
import { HorizontalSlider } from "@/components/Global/helpers/slider";
import { RunTimeSeconds } from "@/components/Global/helpers/time-codes";
import { useApiClientContext } from "@/components/jellyfin-api-provider";
import { StackParamList } from "@/components/types";
import { usePlayerContext } from "@/player/provider";
import { CachedImage } from "@georstat/react-native-image-cache";
import { ImageType } from "@jellyfin/sdk/lib/generated-client/models";
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
import { HorizontalSlider } from "../../../components/Global/helpers/slider";
import { RunTimeSeconds } from "../../../components/Global/helpers/time-codes";
import { StackParamList } from "../../../components/types";
import { usePlayerContext } from "../../../player/provider";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import React, { useState, useEffect } from "react";
import { SafeAreaView, useSafeAreaFrame } from "react-native-safe-area-context";
import { YStack, XStack, Spacer } from "tamagui";
import PlayPauseButton from "../helpers/buttons";
import { H5, Text } from "@/components/Global/helpers/text";
import Icon from "@/components/Global/helpers/icon";
import { Colors } from "@/enums/colors";
import { State } from "react-native-track-player";
import { H5, Text } from "../../../components/Global/helpers/text";
import Icon from "../../../components/Global/helpers/icon";
import { Colors } from "../../../enums/colors";
import FavoriteButton from "../../Global/components/favorite-button";
import BlurhashedImage from "../../../components/Global/helpers/blurhashed-image";
export default function PlayerScreen({ navigation }: { navigation: NativeStackNavigationProp<StackParamList>}): React.JSX.Element {
const { apiClient } = useApiClientContext();
const { playbackState, nowPlaying, progress, useSeekTo, useSkip, usePrevious, queueName } = usePlayerContext();
const {
useTogglePlayback,
playbackState,
nowPlayingIsFavorite,
setNowPlayingIsFavorite,
nowPlaying,
progress,
useSeekTo,
useSkip,
usePrevious,
queueName,
} = usePlayerContext();
const [seeking, setSeeking] = useState<boolean>(false);
const [progressState, setProgressState] = useState<number>(progress!.position);
const { width, height } = useSafeAreaFrame();
const { width } = useSafeAreaFrame();
// Prevent gesture event to close player if we're seeking
useEffect(() => {
@@ -55,12 +60,19 @@ export default function PlayerScreen({ navigation }: { navigation: NativeStackNa
</YStack>
<XStack
animation={"quick"}
justifyContent="center"
alignContent="center"
minHeight={width / 1.1}
onPress={() => {
useTogglePlayback.mutate(undefined)
}}
>
<CachedImage
source={getImageApi(apiClient!)
<BlurhashedImage
item={nowPlaying!.item}
size={width / 1.1}
/>
{/* <CachedImage
source={getImageApi(Client.api!)
.getItemImageUrlById(
nowPlaying!.item.AlbumId ?? "",
ImageType.Primary,
@@ -74,7 +86,7 @@ export default function PlayerScreen({ navigation }: { navigation: NativeStackNa
height: playbackState === State.Playing ? width / 1.1 : width / 1.4,
borderRadius: 2
}}
/>
/> */}
</XStack>
<XStack marginHorizontal={20} paddingVertical={5}>
@@ -83,18 +95,18 @@ export default function PlayerScreen({ navigation }: { navigation: NativeStackNa
bold
fontSize={"$6"}
>
{nowPlaying?.title ?? "Untitled Track"}
{nowPlaying!.title ?? "Untitled Track"}
</Text>
<Text
fontSize={"$6"}
color={Colors.Primary}
onPress={() => {
navigation.goBack(); // Dismiss player modal
navigation.push("Artist", {
artistName: nowPlaying!.item.ArtistItems![0].Name ?? "Untitled",
artistId: nowPlaying!.item.ArtistItems![0].Id!,
})
if (nowPlaying!.item.ArtistItems) {
navigation.navigate("Artist", {
artist: nowPlaying!.item.ArtistItems![0],
});
}
}}
>
{nowPlaying.artist ?? "Unknown Artist"}
@@ -108,9 +120,28 @@ export default function PlayerScreen({ navigation }: { navigation: NativeStackNa
</Text>
</YStack>
<XStack alignItems="center" flex={1}>
<XStack
justifyContent="flex-end"
alignItems="center"
flex={1}
>
{/* Buttons for favorites, song menu go here */}
<Icon
name="dots-horizontal-circle-outline"
onPress={() => {
navigation.navigate("Details", {
item: nowPlaying!.item
});
}}
/>
<Spacer />
<FavoriteButton
item={nowPlaying!.item}
onToggle={() => setNowPlayingIsFavorite(!nowPlayingIsFavorite)}
/>
</XStack>
</XStack>
@@ -159,7 +190,13 @@ export default function PlayerScreen({ navigation }: { navigation: NativeStackNa
<XStack justifyContent="space-evenly" marginVertical={"$3"}>
<Icon
name="rewind-15"
onPress={() => useSeekTo.mutate(progress!.position - 15)}
onPress={() => {
setSeeking(true);
setProgressState(progressState - 15);
useSeekTo.mutate(progress!.position - 15);
setSeeking(false);
}}
/>
<Icon
@@ -178,7 +215,12 @@ export default function PlayerScreen({ navigation }: { navigation: NativeStackNa
<Icon
name="fast-forward-15"
onPress={() => useSeekTo.mutate(progress!.position + 15)}
onPress={() => {
setSeeking(true);
setProgressState(progressState + 15);
useSeekTo.mutate(progress!.position + 15);
setSeeking(false);
}}
/>
</XStack>

View File

@@ -1,22 +1,31 @@
import Track from "@/components/Global/components/track";
import { usePlayerContext } from "@/player/provider";
import Track from "../../../components/Global/components/track";
import { StackParamList } from "../../../components/types";
import { usePlayerContext } from "../../../player/provider";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { FlatList } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { SafeAreaView, useSafeAreaFrame } from "react-native-safe-area-context";
export default function Queue(): React.JSX.Element {
export default function Queue({ navigation }: { navigation: NativeStackNavigationProp<StackParamList>}): React.JSX.Element {
const { width } = useSafeAreaFrame();
const { queue, useSkip, nowPlaying } = usePlayerContext();
const scrollIndex = queue.findIndex(queueItem => queueItem.item.Id! === nowPlaying!.item.Id!)
return (
<SafeAreaView edges={["right", "left"]}>
<FlatList
data={queue}
extraData={nowPlaying}
initialScrollIndex={queue.indexOf(nowPlaying!)}
getItemLayout={(data, index) => (
{ length: width / 9, offset: width / 9 * index, index}
)}
initialScrollIndex={scrollIndex !== -1 ? scrollIndex: 0}
numColumns={1}
renderItem={({ item: queueItem, index }) => {
return (
<Track
navigation={navigation}
track={queueItem.item}
tracklist={queue.map((track) => track.item)}
index={index}

View File

@@ -3,6 +3,7 @@ import { createNativeStackNavigator, NativeStackNavigationProp } from "@react-na
import { StackParamList } from "../types";
import PlayerScreen from "./screens";
import Queue from "./screens/queue";
import DetailsScreen from "../ItemDetail/screen";
export const PlayerStack = createNativeStackNavigator<StackParamList>();
@@ -30,6 +31,14 @@ export default function Player({ navigation }: { navigation: NativeStackNavigati
}}
/>
<PlayerStack.Screen
name="Details"
component={DetailsScreen}
options={{
headerTitle: ""
}}
/>
</PlayerStack.Navigator>
);
}

View File

@@ -2,17 +2,18 @@ import { BaseItemDto, ImageType } from "@jellyfin/sdk/lib/generated-client/model
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { StackParamList } from "../types";
import { ScrollView, XStack, YStack } from "tamagui";
import { useApiClientContext } from "../jellyfin-api-provider";
import { usePlayerContext } from "@/player/provider";
import { useItemTracks } from "@/api/queries/tracks";
import { usePlayerContext } from "../../player/provider";
import { useItemTracks } from "../../api/queries/tracks";
import { RunTimeTicks } from "../Global/helpers/time-codes";
import { H4, H5, Text } from "../Global/helpers/text";
import Track from "../Global/components/track";
import { FlatList } from "react-native";
import { queryConfig } from "@/api/queries/query.config";
import { queryConfig } from "../../api/queries/query.config";
import { getImageApi } from "@jellyfin/sdk/lib/utils/api/image-api";
import { CachedImage } from "@georstat/react-native-image-cache";
import { SafeAreaView } from "react-native-safe-area-context";
import { useEffect } from "react";
import Client from "../../api/client";
interface PlaylistProps {
playlist: BaseItemDto;
@@ -21,18 +22,22 @@ interface PlaylistProps {
export default function Playlist(props: PlaylistProps): React.JSX.Element {
const { apiClient, sessionId } = useApiClientContext();
const { nowPlaying, nowPlayingIsFavorite } = usePlayerContext();
const { nowPlaying } = usePlayerContext();
const { data: tracks, isLoading, refetch } = useItemTracks(props.playlist.Id!);
const { data: tracks, isLoading } = useItemTracks(props.playlist.Id!, apiClient!);
useEffect(() => {
refetch();
}, [
nowPlayingIsFavorite
]);
return (
<SafeAreaView edges={["right", "left"]}>
<ScrollView contentInsetAdjustmentBehavior="automatic">
<YStack alignItems="center">
<CachedImage
source={getImageApi(apiClient!)
source={getImageApi(Client.api!)
.getItemImageUrlById(
props.playlist.Id!,
ImageType.Primary,
@@ -56,6 +61,7 @@ export default function Playlist(props: PlaylistProps): React.JSX.Element {
return (
<Track
navigation={props.navigation}
track={track}
tracklist={tracks!}
index={index}

View File

@@ -1,10 +1,10 @@
import Playlist from "@/components/Playlist/component";
import { StackParamList } from "@/components/types";
import Playlist from "../../../components/Playlist/component";
import { StackParamList } from "../../../components/types";
import { RouteProp } from "@react-navigation/native";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import React from "react";
export function HomePlaylistScreen({ route, navigation }: {
export function PlaylistScreen({ route, navigation }: {
route: RouteProp<StackParamList, "Playlist">,
navigation: NativeStackNavigationProp<StackParamList>
}) : React.JSX.Element {

View File

@@ -1,20 +1,18 @@
import { XStack } from "@tamagui/stacks";
import React from "react";
import { useApiClientContext } from "../../jellyfin-api-provider";
import Avatar from "@/components/Global/helpers/avatar";
import { Text } from "@/components/Global/helpers/text";
import Icon from "@/components/Global/helpers/icon";
import Avatar from "../../../components/Global/helpers/avatar";
import { Text } from "../../../components/Global/helpers/text";
import Icon from "../../../components/Global/helpers/icon";
import Client from "../../../api/client";
export default function AccountDetails(): React.JSX.Element {
const { user } = useApiClientContext();
return (
<XStack alignItems="center">
<Icon name="account-music-outline" />
<Text>{user!.name}</Text>
<Avatar itemId={user!.id} circular />
<Text>{Client.user!.name}</Text>
<Avatar itemId={Client.user!.id} circular />
</XStack>
)
}

View File

@@ -1,16 +1,14 @@
import { Text } from "@/components/Global/helpers/text";
import { useApiClientContext } from "@/components/jellyfin-api-provider";
import Client from "../../../api/client";
import { Text } from "../../../components/Global/helpers/text";
import React from "react";
import { View } from "tamagui";
export default function LibraryDetails() : React.JSX.Element {
const { library } = useApiClientContext();
return (
<View>
<Text>{ `LibraryID: ${library!.musicLibraryId}` }</Text>
<Text>{ `Playlist LibraryID: ${library!.playlistLibraryId}` }</Text>
<Text>{ `LibraryID: ${Client.library!.musicLibraryId}` }</Text>
<Text>{ `Playlist LibraryID: ${Client.library!.playlistLibraryId}` }</Text>
</View>
)
}

View File

@@ -1,27 +1,25 @@
import React from "react";
import { useApiClientContext } from "../../jellyfin-api-provider";
import { XStack, YStack } from "tamagui";
import Icon from "../../Global/helpers/icon";
import { H5, Text } from "@/components/Global/helpers/text";
import { H5, Text } from "../../../components/Global/helpers/text";
import Client from "../../../api/client";
export default function ServerDetails() : React.JSX.Element {
const { apiClient } = useApiClientContext();
return (
<YStack>
<YStack>
<H5>Access Token</H5>
<XStack>
<Icon name="hand-coin-outline" />
<Text>{apiClient!.accessToken}</Text>
<Text>{Client.api!.accessToken}</Text>
</XStack>
</YStack>
<YStack>
<H5>Jellyfin Server</H5>
<XStack>
<Icon name="server-network" />
<Text>{apiClient!.basePath}</Text>
<Text>{Client.api!.basePath}</Text>
</XStack>
</YStack>
</YStack>

View File

@@ -1,16 +1,18 @@
import React from "react";
import Button from "../../Global/helpers/button";
import { useApiClientContext } from "../../jellyfin-api-provider";
import { stop } from "react-native-track-player/lib/src/trackPlayer";
import Client from "../../../api/client";
import { useJellifyContext } from "../../../components/provider";
export default function SignOut(): React.JSX.Element {
const { signOut } = useApiClientContext();
const { setLoggedIn } = useJellifyContext()
return (
<Button onPress={() => {
stop();
signOut();
Client.signOut();
setLoggedIn(false);
}}>
Sign Out
</Button>

View File

@@ -0,0 +1,13 @@
import { StackParamList } from "../../../components/types";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
export default function AccountDetails({
navigation
} : {
navigation: NativeStackNavigationProp<StackParamList>
}) : React.JSX.Element {
return (
<AccountDetails navigation={navigation} />
)
}

View File

@@ -1,17 +1,40 @@
import React from "react";
import { SafeAreaView } from "react-native";
import { ScrollView, Separator } from "tamagui";
import AccountDetails from "../helpers/account-details";
import { ListItem, ScrollView, Separator, YGroup } from "tamagui";
import SignOut from "../helpers/sign-out";
import ServerDetails from "../helpers/server-details";
import LibraryDetails from "../helpers/library-details";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { StackParamList } from "../../../components/types";
export default function Root({
navigation
}: {
navigation: NativeStackNavigationProp<StackParamList>
}) : React.JSX.Element {
export default function Root() : React.JSX.Element {
return (
<SafeAreaView>
<ScrollView contentInsetAdjustmentBehavior="automatic">
<AccountDetails />
<Separator marginVertical={15} />
<YGroup
alignSelf="center"
bordered
width={240}
size="$5"
>
<YGroup.Item>
<ListItem
hoverTheme
pressTheme
title="Account Details"
subTitle="Everything is about you, man"
onPress={() => {
navigation.push("AccountDetails")
}}
/>
</YGroup.Item>
</YGroup>
<ServerDetails />
<Separator marginVertical={15} />
<LibraryDetails />

View File

@@ -1,6 +1,7 @@
import React from "react";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import Root from "./screens/root";
import AccountDetails from "./screens/account-details";
export const SettingsStack = createNativeStackNavigator();
@@ -18,6 +19,17 @@ export default function Settings(): React.JSX.Element {
}
}}
/>
<SettingsStack.Screen
name="Account"
component={AccountDetails}
options={{
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold'
}
}}
/>
</SettingsStack.Navigator>
)
}

View File

@@ -0,0 +1,41 @@
import { useFavoriteTracks } from "../../api/queries/favorites";
import { StackParamList } from "../types";
import { SafeAreaView, useSafeAreaFrame } from "react-native-safe-area-context";
import { FlatList, RefreshControl } from "react-native";
import Track from "../Global/components/track";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
export default function Tracks({ navigation }: { navigation: NativeStackNavigationProp<StackParamList> }) : React.JSX.Element {
const { data: tracks, refetch, isPending } = useFavoriteTracks();
const { width } = useSafeAreaFrame();
return (
<SafeAreaView edges={["right", "left"]}>
<FlatList
contentInsetAdjustmentBehavior="automatic"
numColumns={1}
data={tracks}
refreshControl={
<RefreshControl
refreshing={isPending}
onRefresh={refetch}
/>
}
renderItem={({ index, item: track}) => {
return (
<Track
navigation={navigation}
showArtwork
track={track}
tracklist={tracks?.slice(index, index + 50) ?? []}
queueName="Favorite Tracks"
/>
)
}}
/>
</SafeAreaView>
)
}

View File

@@ -0,0 +1,17 @@
import { RouteProp } from "@react-navigation/native";
import { StackParamList } from "../types";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import React from "react";
import Tracks from "./component";
export default function TracksScreen({
route,
navigation
} : {
route: RouteProp<StackParamList, "Tracks">,
navigation: NativeStackNavigationProp<StackParamList>
}) : React.JSX.Element {
return (
<Tracks navigation={navigation} />
)
}

13
components/carplay.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { createStackNavigator } from "@react-navigation/stack"
import { NowPlaying } from "./CarPlay/NowPlaying";
const Stack = createStackNavigator();
export default function JellifyCarplay(): React.JSX.Element {
return (
<Stack.Navigator>
<Stack.Screen name="NowPlaying" component={NowPlaying} />
</Stack.Navigator>
)
}

View File

@@ -1,6 +1,5 @@
import _ from "lodash";
import { JellyfinApiClientProvider, useApiClientContext } from "./jellyfin-api-provider";
import React from "react";
import React, { useEffect, useState } from "react";
import { NavigationContainer } from "@react-navigation/native";
import Navigation from "./navigation";
import Login from "./Login/component";
@@ -8,28 +7,70 @@ import { JellyfinAuthenticationProvider } from "./Login/provider";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { JellifyDarkTheme, JellifyLightTheme } from "./theme";
import { PlayerProvider } from "../player/provider";
import { useColorScheme } from "react-native";
import { Text, useColorScheme, View } from "react-native";
import { PortalProvider } from "tamagui";
import Client from "../api/client";
import { JellifyProvider, useJellifyContext } from "./provider";
import { CarPlay } from "react-native-carplay"
import JellifyCarplay from "./carplay";
export default function Jellify(): React.JSX.Element {
return (
<JellyfinApiClientProvider>
<App />
</JellyfinApiClientProvider>
<PortalProvider shouldAddRootHost>
<JellifyProvider>
<App />
</JellifyProvider>
</PortalProvider>
);
}
function App(): React.JSX.Element {
// If library hasn't been set, we haven't completed the auth flow
const { server, library } = useApiClientContext();
const isDarkMode = useColorScheme() === "dark";
const { loggedIn } = useJellifyContext();
const [carPlayConnected, setCarPlayConnected] = useState(CarPlay.connected);
return (
useEffect(() => {
console.debug("Client instance changed")
}, [
Client.instance
])
useEffect(() => {
function onConnect() {
setCarPlayConnected(true)
}
function onDisconnect() {
setCarPlayConnected(false)
}
CarPlay.registerOnConnect(onConnect);
CarPlay.registerOnDisconnect(onDisconnect);
return () => {
CarPlay.unregisterOnConnect(onConnect)
CarPlay.unregisterOnDisconnect(onDisconnect)
};
});
return carPlayConnected ? (
<NavigationContainer>
{ loggedIn ? (
<JellifyCarplay />
) : (
<View>
<Text>Please login in the app before using CarPlay</Text>
</View>
)}
</NavigationContainer>
) : (
<NavigationContainer theme={isDarkMode ? JellifyDarkTheme : JellifyLightTheme}>
<SafeAreaProvider>
{ server && library ? (
{ loggedIn ? (
<PlayerProvider>
<Navigation />
</PlayerProvider>
@@ -40,5 +81,5 @@ function App(): React.JSX.Element {
)}
</SafeAreaProvider>
</NavigationContainer>
);
)
}

View File

@@ -1,154 +0,0 @@
import { Api } from '@jellyfin/sdk';
import React, { createContext, ReactNode, SetStateAction, useContext, useEffect, useState } from 'react';
import { useApi } from '../api/queries';
import { isUndefined } from 'lodash';
import { storage } from '../constants/storage';
import { MMKVStorageKeys } from '../enums/mmkv-storage-keys';
import { JellifyServer } from '../types/JellifyServer';
import { JellifyLibrary } from '../types/JellifyLibrary';
import { JellifyUser } from '../types/JellifyUser';
import uuid from 'react-native-uuid';
import { buildAuthenticatedApiClient, buildPublicApiClient } from '@/api/client';
interface JellyfinApiClientContext {
apiClient: Api | undefined;
sessionId: string;
server: JellifyServer | undefined;
setServer: React.Dispatch<SetStateAction<JellifyServer | undefined>>;
user: JellifyUser | undefined;
setUser: React.Dispatch<SetStateAction<JellifyUser | undefined>>;
library: JellifyLibrary | undefined;
setLibrary: React.Dispatch<SetStateAction<JellifyLibrary | undefined>>;
signOut: () => void
}
const JellyfinApiClientContextInitializer = () => {
const userJson = storage.getString(MMKVStorageKeys.User)
const serverJson = storage.getString(MMKVStorageKeys.Server);
const libraryJson = storage.getString(MMKVStorageKeys.Library);
const [sessionId, setSessionId] = useState<string>(uuid.v4())
const [user, setUser] = useState<JellifyUser | undefined>(userJson ? (JSON.parse(userJson) as JellifyUser) : undefined);
const [server, setServer] = useState<JellifyServer | undefined>(serverJson ? (JSON.parse(serverJson) as JellifyServer) : undefined);
const [library, setLibrary] = useState<JellifyLibrary | undefined>(libraryJson ? (JSON.parse(libraryJson) as JellifyLibrary) : undefined);
const [apiClient, setApiClient] = useState<Api | undefined>(!isUndefined(server) && !isUndefined(user) ? buildAuthenticatedApiClient(server!.url, user!.accessToken) : undefined);
const signOut = () => {
console.debug("Signing out of Jellify");
setUser(undefined);
setServer(undefined);
setLibrary(undefined);
}
useEffect(() => {
if (server && user)
setApiClient(buildAuthenticatedApiClient(server.url, user.accessToken));
else if (server)
setApiClient(buildPublicApiClient(server.url));
else
setApiClient(undefined);
}, [
server,
user
]);
useEffect(() => {
if (server) {
console.debug("Storing new server configuration")
storage.set(MMKVStorageKeys.Server, JSON.stringify(server))
}
else {
console.debug("Deleting server configuration from storage");
storage.delete(MMKVStorageKeys.Server)
}
}, [
server
])
useEffect(() => {
if (user) {
console.debug("Storing new user profile")
storage.set(MMKVStorageKeys.User, JSON.stringify(user));
}
else {
console.debug("Deleting access token from storage");
storage.delete(MMKVStorageKeys.User);
}
}, [
user
])
useEffect(() => {
console.debug("Library changed")
if (library) {
console.debug("Setting library");
storage.set(MMKVStorageKeys.Library, JSON.stringify(library));
} else
storage.delete(MMKVStorageKeys.Library)
}, [
library
])
return {
apiClient,
sessionId,
server,
setServer,
user,
setUser,
library,
setLibrary,
signOut
};
}
export const JellyfinApiClientContext =
createContext<JellyfinApiClientContext>({
apiClient: undefined,
sessionId: "",
server: undefined,
setServer: () => {},
user: undefined,
setUser: () => {},
library: undefined,
setLibrary: () => {},
signOut: () => {}
});
export const JellyfinApiClientProvider: ({ children }: {
children: ReactNode;
}) => React.JSX.Element = ({ children }: { children: ReactNode }) => {
const {
apiClient,
sessionId,
server,
setServer,
user,
setUser,
library,
setLibrary,
signOut
} = JellyfinApiClientContextInitializer();
// Add your logic to check if credentials are stored and initialize the API client here.
return (
<JellyfinApiClientContext.Provider value={{
apiClient,
sessionId,
server,
setServer,
user,
setUser,
library,
setLibrary,
signOut
}}>
{children}
</JellyfinApiClientContext.Provider>
);
};
export const useApiClientContext = () => useContext(JellyfinApiClientContext)

View File

@@ -1,5 +1,5 @@
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import Player from "./Player/component";
import Player from "./Player/stack";
import { Tabs } from "./tabs";
import { StackParamList } from "./types";

49
components/provider.tsx Normal file
View File

@@ -0,0 +1,49 @@
import Client from "../api/client";
import { isUndefined } from "lodash";
import { createContext, ReactNode, useContext, useState } from "react";
interface JellifyContext {
loggedIn: boolean;
setLoggedIn: React.Dispatch<React.SetStateAction<boolean>>;
}
const JellifyContextInitializer = () => {
const [loggedIn, setLoggedIn] = useState<boolean>(
!isUndefined(Client.api) &&
!isUndefined(Client.user) &&
!isUndefined(Client.server)
);
return {
loggedIn,
setLoggedIn,
}
}
const JellifyContext = createContext<JellifyContext>({
loggedIn: false,
setLoggedIn: () => {}
});
export const JellifyProvider: ({ children }: {
children: ReactNode
}) => React.JSX.Element = ({ children }: { children: ReactNode }) => {
const {
loggedIn,
setLoggedIn
} = JellifyContextInitializer();
return (
<JellifyContext.Provider
value={{
loggedIn,
setLoggedIn
}}
>
{children}
</JellifyContext.Provider>
)
}
export const useJellifyContext = () => useContext(JellifyContext);

View File

@@ -5,8 +5,8 @@ import MaterialCommunityIcons from "react-native-vector-icons/MaterialCommunityI
import { useColorScheme } from "react-native";
import { Colors } from "../enums/colors";
import Search from "./Search/component";
import Library from "./Library/component";
import Settings from "./Settings/component";
import Favorites from "./Favorites/component";
import Settings from "./Settings/stack";
import { Discover } from "./Discover/component";
import { Miniplayer } from "./Player/mini-player";
import { Separator } from "tamagui";
@@ -51,8 +51,8 @@ export function Tabs() : React.JSX.Element {
/>
<Tab.Screen
name="Library"
component={Library}
name="Favorites"
component={Favorites}
options={{
headerShown: false,
tabBarIcon: ({color, size }) => (

View File

@@ -5,7 +5,7 @@ export const JellifyDarkTheme = {
colors: {
...DarkTheme.colors,
card: Colors.Background,
border: Colors.Secondary,
border: Colors.Borders,
background: Colors.Background,
primary: Colors.Primary,
},

View File

@@ -4,19 +4,37 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack";
export type StackParamList = {
Home: undefined;
Discover: undefined;
Tabs: undefined,
Player: undefined,
Queue: undefined,
Favorites: undefined;
Artists: undefined;
Albums: undefined;
Tracks: undefined;
Genres: undefined;
Playlists: undefined;
Search: undefined;
Settings: undefined;
AccountDetails: undefined;
Tabs: undefined;
Player: undefined;
Queue: undefined;
Artist: {
artistId: string,
artistName: string
artist: BaseItemDto
};
Album: {
album: BaseItemDto
};
Playlist: {
playlist: BaseItemDto
};
Details: {
item: BaseItemDto
}
}
@@ -34,4 +52,18 @@ export type HomeAlbumProps = NativeStackScreenProps<StackParamList, 'Album'>;
export type HomePlaylistProps = NativeStackScreenProps<StackParamList, "Playlist">;
export type QueueProps = NativeStackScreenProps<StackParamList, "Queue">;
export type QueueProps = NativeStackScreenProps<StackParamList, "Queue">;
export type LibraryProps = NativeStackScreenProps<StackParamList, "Favorites">;
export type ArtistsProps = NativeStackScreenProps<StackParamList, "Artists">;
export type AlbumsProps = NativeStackScreenProps<StackParamList, "Albums">;
export type TracksProps = NativeStackScreenProps<StackParamList, "Tracks">;
export type GenresProps = NativeStackScreenProps<StackParamList, "Genres">;
export type DetailsProps = NativeStackScreenProps<StackParamList, "Details">;
export type AccountDetailsProps = NativeStackScreenProps<StackParamList, "AccountDetails">;

View File

@@ -1,7 +1,9 @@
export enum Colors {
White = "#ffffff",
Primary = "#cc2f71",
Secondary = "#100538",
Black = "#000000",
Background = "#070217"
Primary = "#cc2f71", // Telemagenta
Secondary = "#514C63", // English Violet
Borders = "#100538", // Russian Violet
Background = "#070217", // Rich Black
White = "#ffffff", // Uh-huh
Black = "#000000", // Yep
}

View File

@@ -30,4 +30,9 @@ export enum QueryKeys {
UserPlaylists = "UserPlaylists",
ItemTracks = "ItemTracks",
RefreshHome = "RefreshHome",
FavoriteArtists = "FavoriteArtists",
FavoriteAlbums = "FavoriteAlbums",
FavoriteTracks = "FavoriteTracks",
UserData = "UserData",
UpdatePlayerOptions = "UpdatePlayerOptions",
}

View File

@@ -7,7 +7,7 @@ const aileronFace = {
300: { normal: 'Aileron-Light', italic: 'Aileron Light Italic' },
400: { normal: 'Aileron-Regular', italic: 'Aileron Italic'} ,
500: { normal: 'Aileron-Regular', italic: 'Aileron Italic' },
600: { normal: 'Aileron-SemiBold', italic: 'Aileron SemiBold Italic' },
600: { normal: 'Aileron SemiBold', italic: 'Aileron SemiBold Italic' },
700: { normal: 'Aileron-Bold', italic: 'Aileron Bold Italic' },
800: { normal: 'Aileron-Heavy', italic: 'Aileron Heavy Italic' },
900: { normal: 'Aileron-Black', italic: 'Aileron-BlackItalic' }

View File

@@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -1,17 +1,19 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { JellifyTrack } from "../types/JellifyTrack";
import { TrackType } from "react-native-track-player";
import { RatingType, TrackType } from "react-native-track-player";
import { Api } from "@jellyfin/sdk";
import { QueuingType } from "../enums/queuing-type";
import querystring from "querystring"
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
import Client from "../api/client";
import { isUndefined } from "lodash";
const container = "opus,mp3,aac,m4a,flac,webma,webm,wav,ogg,mpa,wma";
// TODO: Make this configurable
const transcodingContainer = "m4a";
export function mapDtoToTrack(api: Api, sessionId: string, item: BaseItemDto, queuingType?: QueuingType) {
export function mapDtoToTrack(item: BaseItemDto, queuingType?: QueuingType) : JellifyTrack {
const urlParams = {
"Container": container,
@@ -19,23 +21,26 @@ export function mapDtoToTrack(api: Api, sessionId: string, item: BaseItemDto, qu
"TranscodingProtocol": "hls",
"EnableRemoteMedia": true,
"EnableRedirection": true,
"api_key": api.accessToken,
"api_key": Client.api!.accessToken,
"StartTimeTicks": 0,
"PlaySessionId": sessionId,
"PlaySessionId": Client.sessionId,
}
const isFavorite = !isUndefined(item.UserData) && (item.UserData.IsFavorite ?? false);
return {
url: `${api.basePath}/Audio/${item.Id!}/universal?${querystring.stringify(urlParams)}`,
url: `${Client.api!.basePath}/Audio/${item.Id!}/universal?${querystring.stringify(urlParams)}`,
type: TrackType.HLS,
headers: {
"X-Emby-Token": api.accessToken
"X-Emby-Token": Client.api!.accessToken
},
title: item.Name,
album: item.Album,
artist: item.Artists?.join(", "),
duration: item.RunTimeTicks,
artwork: getImageApi(api).getItemImageUrlById(item.Id!),
artwork: getImageApi(Client.api!).getItemImageUrlById(item.Id!),
rating: isFavorite ? RatingType.Heart : undefined,
item,
QueuingType: queuingType ?? QueuingType.DirectlyQueued
} as JellifyTrack

View File

@@ -4,6 +4,8 @@ import App from './App';
import {name as appName} from './app.json';
import { PlaybackService } from './player/service'
import TrackPlayer from 'react-native-track-player';
import Client from './api/client';
AppRegistry.registerComponent(appName, () => App);
TrackPlayer.registerPlaybackService(() => PlaybackService);
TrackPlayer.registerPlaybackService(() => PlaybackService);
Client.instance;

63
ios/AppDelegate.swift Normal file
View File

@@ -0,0 +1,63 @@
// ios/AppDelegate.swift
import UIKit
import CarPlay
import React
#if DEBUG
#if FB_SONARKIT_ENABLED
import FlipperKit
#endif
#endif
@main
class AppDelegate: UIResponder, UIApplicationDelegate, RCTBridgeDelegate {
var window: UIWindow?
var bridge: RCTBridge?;
var rootView: RCTRootView?;
static var shared: AppDelegate { return UIApplication.shared.delegate as! AppDelegate }
func sourceURL(for bridge: RCTBridge!) -> URL! {
#if DEBUG
return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index");
#else
return Bundle.main.url(forResource:"main", withExtension:"jsbundle")
#endif
}
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
initializeFlipper(with: application)
self.bridge = RCTBridge.init(delegate: self, launchOptions: launchOptions)
self.rootView = RCTRootView.init(bridge: self.bridge!, moduleName: "Jellify", initialProperties: nil)
return true
}
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
if (connectingSceneSession.role == UISceneSession.Role.carTemplateApplication) {
let scene = UISceneConfiguration(name: "CarPlay", sessionRole: connectingSceneSession.role)
scene.delegateClass = CarSceneDelegate.self
return scene
} else {
let scene = UISceneConfiguration(name: "Phone", sessionRole: connectingSceneSession.role)
scene.delegateClass = PhoneSceneDelegate.self
return scene
}
}
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
}
private func initializeFlipper(with application: UIApplication) {
#if DEBUG
#if FB_SONARKIT_ENABLED
let client = FlipperClient.shared()
let layoutDescriptorMapper = SKDescriptorMapper(defaults: ())
client?.add(FlipperKitLayoutPlugin(rootNode: application, with: layoutDescriptorMapper!))
client?.add(FKUserDefaultsPlugin(suiteName: nil))
client?.add(FlipperKitReactPlugin())
client?.add(FlipperKitNetworkPlugin(networkAdapter: SKIOSNetworkAdapter()))
client?.start()
#endif
#endif
}
}

14
ios/CarScene.swift Normal file
View File

@@ -0,0 +1,14 @@
// ios/CarScene.swift
import Foundation
import CarPlay
class CarSceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController) {
RNCarPlay.connect(with: interfaceController, window: templateApplicationScene.carWindow);
}
func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didDisconnectInterfaceController interfaceController: CPInterfaceController) {
RNCarPlay.disconnect()
}
}

View File

@@ -1,5 +1,12 @@
#import <Expo/Expo.h>
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//
#import "RNCarPlay.h"
#ifdef DEBUG
#ifdef FB_SONARKIT_ENABLED
#import <FlipperKit/FlipperClient.h>
#import <FlipperKitLayoutPlugin/FlipperKitLayoutPlugin.h>
#import <FlipperKitUserDefaultsPlugin/FKUserDefaultsPlugin.h>
#import <FlipperKitNetworkPlugin/FlipperKitNetworkPlugin.h>
#import <SKIOSNetworkPlugin/SKIOSNetworkAdapter.h>
#import <FlipperKitReactPlugin/FlipperKitReactPlugin.h>
#endif
#endif

View File

@@ -8,14 +8,11 @@
/* Begin PBXBuildFile section */
00E356F31AD99517003FC87E /* JellifyTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 00E356F21AD99517003FC87E /* JellifyTests.m */; };
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
172B159DEECEA03B59A47B38 /* Pods_Jellify.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 38B09B5C902A2F3CC48BD0A0 /* Pods_Jellify.framework */; };
217EBE16A3E8C5FBF476C905 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = F757EB73303E0AC21EF34F64 /* PrivacyInfo.xcprivacy */; };
65C2C865DA10C69EF1958E92 /* libPods-Jellify.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 132610DE1F5A5CBDAB889AF5 /* libPods-Jellify.a */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
8AC3ECB3A690B29771C29318 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6259C8F5B99EE317280940 /* ExpoModulesProvider.swift */; };
9FE4A57E31DB40AA046B375D /* libPods-Jellify-JellifyTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 172599E13E36ADBF5E44D55E /* libPods-Jellify-JellifyTests.a */; };
881EBE3FE9362EB8E8594837 /* Pods_Jellify_JellifyTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E68D0EA500FA5AB01EC4D6E6 /* Pods_Jellify_JellifyTests.framework */; };
CF620D0C2CF2BB210045E433 /* Aileron-Italic.otf in Resources */ = {isa = PBXBuildFile; fileRef = CF620CFC2CF2BB1F0045E433 /* Aileron-Italic.otf */; };
CF620D0D2CF2BB210045E433 /* Aileron-Thin.otf in Resources */ = {isa = PBXBuildFile; fileRef = CF620CFD2CF2BB1F0045E433 /* Aileron-Thin.otf */; };
CF620D0E2CF2BB210045E433 /* Aileron-HeavyItalic.otf in Resources */ = {isa = PBXBuildFile; fileRef = CF620CFE2CF2BB1F0045E433 /* Aileron-HeavyItalic.otf */; };
@@ -33,7 +30,9 @@
CF620D1A2CF2BB210045E433 /* Aileron-ThinItalic.otf in Resources */ = {isa = PBXBuildFile; fileRef = CF620D0A2CF2BB200045E433 /* Aileron-ThinItalic.otf */; };
CF620D1B2CF2BB210045E433 /* Aileron-SemiBold.otf in Resources */ = {isa = PBXBuildFile; fileRef = CF620D0B2CF2BB200045E433 /* Aileron-SemiBold.otf */; };
CF71790B2CBC486C0021BCA3 /* dummy-rntp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF71790A2CBC486C0021BCA3 /* dummy-rntp.swift */; };
FD44E03C057499699671A391 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E2736241344FDDCC9D455D9 /* ExpoModulesProvider.swift */; };
CF98CA472D3E99E0003D88B7 /* CarScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF98CA452D3E99DF003D88B7 /* CarScene.swift */; };
CF98CA482D3E99E0003D88B7 /* PhoneScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF98CA462D3E99DF003D88B7 /* PhoneScene.swift */; };
CF98CA492D3E99E0003D88B7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF98CA442D3E99DF003D88B7 /* AppDelegate.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -50,16 +49,11 @@
00E356EE1AD99517003FC87E /* JellifyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JellifyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
00E356F21AD99517003FC87E /* JellifyTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = JellifyTests.m; sourceTree = "<group>"; };
132610DE1F5A5CBDAB889AF5 /* libPods-Jellify.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Jellify.a"; sourceTree = BUILT_PRODUCTS_DIR; };
13B07F961A680F5B00A75B9A /* Jellify.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Jellify.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Jellify/AppDelegate.h; sourceTree = "<group>"; };
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Jellify/AppDelegate.mm; sourceTree = "<group>"; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Jellify/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Jellify/Info.plist; sourceTree = "<group>"; };
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Jellify/main.m; sourceTree = "<group>"; };
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = Jellify/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
172599E13E36ADBF5E44D55E /* libPods-Jellify-JellifyTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Jellify-JellifyTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
1E2736241344FDDCC9D455D9 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Jellify-JellifyTests/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
38B09B5C902A2F3CC48BD0A0 /* Pods_Jellify.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Jellify.framework; sourceTree = BUILT_PRODUCTS_DIR; };
3B6622EAB81CD73F7F18C4A4 /* Pods-Jellify.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Jellify.debug.xcconfig"; path = "Target Support Files/Pods-Jellify/Pods-Jellify.debug.xcconfig"; sourceTree = "<group>"; };
47F8F23DBB1E6482816F1E11 /* Pods-Jellify-JellifyTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Jellify-JellifyTests.debug.xcconfig"; path = "Target Support Files/Pods-Jellify-JellifyTests/Pods-Jellify-JellifyTests.debug.xcconfig"; sourceTree = "<group>"; };
61D59F7A2E3DAFA5DF396506 /* Pods-Jellify-JellifyTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Jellify-JellifyTests.release.xcconfig"; path = "Target Support Files/Pods-Jellify-JellifyTests/Pods-Jellify-JellifyTests.release.xcconfig"; sourceTree = "<group>"; };
@@ -83,8 +77,11 @@
CF6588752D25C12400AECE18 /* Jellify.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = Jellify.entitlements; path = Jellify/Jellify.entitlements; sourceTree = "<group>"; };
CF7179092CBC486C0021BCA3 /* Jellify-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Jellify-Bridging-Header.h"; sourceTree = "<group>"; };
CF71790A2CBC486C0021BCA3 /* dummy-rntp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "dummy-rntp.swift"; sourceTree = "<group>"; };
CF98CA442D3E99DF003D88B7 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
CF98CA452D3E99DF003D88B7 /* CarScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarScene.swift; sourceTree = "<group>"; };
CF98CA462D3E99DF003D88B7 /* PhoneScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneScene.swift; sourceTree = "<group>"; };
D0DF149772F72EE36A16DED9 /* Pods-Jellify.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Jellify.release.xcconfig"; path = "Target Support Files/Pods-Jellify/Pods-Jellify.release.xcconfig"; sourceTree = "<group>"; };
DA6259C8F5B99EE317280940 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Jellify/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
E68D0EA500FA5AB01EC4D6E6 /* Pods_Jellify_JellifyTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Jellify_JellifyTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
F757EB73303E0AC21EF34F64 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Jellify/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -94,7 +91,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
9FE4A57E31DB40AA046B375D /* libPods-Jellify-JellifyTests.a in Frameworks */,
881EBE3FE9362EB8E8594837 /* Pods_Jellify_JellifyTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -102,7 +99,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
65C2C865DA10C69EF1958E92 /* libPods-Jellify.a in Frameworks */,
172B159DEECEA03B59A47B38 /* Pods_Jellify.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -129,13 +126,13 @@
13B07FAE1A68108700A75B9A /* Jellify */ = {
isa = PBXGroup;
children = (
CF98CA442D3E99DF003D88B7 /* AppDelegate.swift */,
CF98CA452D3E99DF003D88B7 /* CarScene.swift */,
CF98CA462D3E99DF003D88B7 /* PhoneScene.swift */,
CF6588752D25C12400AECE18 /* Jellify.entitlements */,
13B07FAF1A68108700A75B9A /* AppDelegate.h */,
13B07FB01A68108700A75B9A /* AppDelegate.mm */,
13B07FB51A68108700A75B9A /* Images.xcassets */,
13B07FB61A68108700A75B9A /* Info.plist */,
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */,
13B07FB71A68108700A75B9A /* main.m */,
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */,
F757EB73303E0AC21EF34F64 /* PrivacyInfo.xcprivacy */,
CF71790A2CBC486C0021BCA3 /* dummy-rntp.swift */,
@@ -148,8 +145,8 @@
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
132610DE1F5A5CBDAB889AF5 /* libPods-Jellify.a */,
172599E13E36ADBF5E44D55E /* libPods-Jellify-JellifyTests.a */,
38B09B5C902A2F3CC48BD0A0 /* Pods_Jellify.framework */,
E68D0EA500FA5AB01EC4D6E6 /* Pods_Jellify_JellifyTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -186,7 +183,6 @@
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
BBD78D7AC51CEA395F1C20DB /* Pods */,
FD77E19D77A9C2B467098F57 /* ExpoModulesProviders */,
);
indentWidth = 2;
sourceTree = "<group>";
@@ -202,22 +198,6 @@
name = Products;
sourceTree = "<group>";
};
8416CE4219E7CC11004894B4 /* JellifyTests */ = {
isa = PBXGroup;
children = (
1E2736241344FDDCC9D455D9 /* ExpoModulesProvider.swift */,
);
name = JellifyTests;
sourceTree = "<group>";
};
89BACC168326BC5320E81D79 /* Jellify */ = {
isa = PBXGroup;
children = (
DA6259C8F5B99EE317280940 /* ExpoModulesProvider.swift */,
);
name = Jellify;
sourceTree = "<group>";
};
BBD78D7AC51CEA395F1C20DB /* Pods */ = {
isa = PBXGroup;
children = (
@@ -229,15 +209,6 @@
path = Pods;
sourceTree = "<group>";
};
FD77E19D77A9C2B467098F57 /* ExpoModulesProviders */ = {
isa = PBXGroup;
children = (
89BACC168326BC5320E81D79 /* Jellify */,
8416CE4219E7CC11004894B4 /* JellifyTests */,
);
name = ExpoModulesProviders;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -246,7 +217,6 @@
buildConfigurationList = 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "JellifyTests" */;
buildPhases = (
1ECAB9805E862FBBEC2A87E4 /* [CP] Check Pods Manifest.lock */,
D7C145763168E210648CCBD4 /* [Expo] Configure project */,
00E356EA1AD99517003FC87E /* Sources */,
00E356EB1AD99517003FC87E /* Frameworks */,
00E356EC1AD99517003FC87E /* Resources */,
@@ -268,7 +238,6 @@
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Jellify" */;
buildPhases = (
307C2C88C87D3ECD7996604B /* [CP] Check Pods Manifest.lock */,
9B9571B1B8AF79E561D2815F /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
@@ -372,7 +341,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli')\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n";
shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n";
};
1ECAB9805E862FBBEC2A87E4 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
@@ -469,44 +438,6 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-resources.sh\"\n";
showEnvVarsInLog = 0;
};
9B9571B1B8AF79E561D2815F /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "[Expo] Configure project";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Jellify/expo-configure-project.sh\"\n";
};
D7C145763168E210648CCBD4 /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "[Expo] Configure project";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Jellify-JellifyTests/expo-configure-project.sh\"\n";
};
ED95B28BB20B2A35D6318E80 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -532,7 +463,6 @@
buildActionMask = 2147483647;
files = (
00E356F31AD99517003FC87E /* JellifyTests.m in Sources */,
FD44E03C057499699671A391 /* ExpoModulesProvider.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -540,10 +470,10 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
13B07FC11A68108700A75B9A /* main.m in Sources */,
CF98CA472D3E99E0003D88B7 /* CarScene.swift in Sources */,
CF98CA482D3E99E0003D88B7 /* PhoneScene.swift in Sources */,
CF98CA492D3E99E0003D88B7 /* AppDelegate.swift in Sources */,
CF71790B2CBC486C0021BCA3 /* dummy-rntp.swift in Sources */,
8AC3ECB3A690B29771C29318 /* ExpoModulesProvider.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -635,7 +565,8 @@
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
OTHER_SWIFT_FLAGS = "$(inherited)";
"OTHER_SWIFT_FLAGS[arch=*]" = "DEBUG FB_SONARKIT_ENABLED";
PRODUCT_BUNDLE_IDENTIFIER = com.cosmonautical.jellify;
PRODUCT_NAME = Jellify;
SWIFT_OBJC_BRIDGING_HEADER = "Jellify-Bridging-Header.h";
@@ -666,7 +597,7 @@
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
OTHER_SWIFT_FLAGS = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = com.cosmonautical.jellify;
PRODUCT_NAME = Jellify;
SWIFT_OBJC_BRIDGING_HEADER = "Jellify-Bridging-Header.h";
@@ -726,6 +657,17 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
HEADER_SEARCH_PATHS = (
"$(inherited)",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx",
"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios",
);
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
LD = "";
LDPLUSPLUS = "";
@@ -748,7 +690,10 @@
"-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
);
OTHER_LDFLAGS = "$(inherited) ";
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
@@ -801,6 +746,17 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
HEADER_SEARCH_PATHS = (
"$(inherited)",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx",
"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios",
);
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
LD = "";
LDPLUSPLUS = "";
@@ -822,7 +778,10 @@
"-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
);
OTHER_LDFLAGS = "$(inherited) ";
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;

View File

@@ -1,7 +0,0 @@
#import <RCTAppDelegate.h>
#import <Expo/Expo.h>
#import <UIKit/UIKit.h>
@interface AppDelegate : EXAppDelegateWrapper
@end

View File

@@ -1,31 +0,0 @@
#import "AppDelegate.h"
#import <React/RCTBundleURLProvider.h>
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
self.moduleName = @"Jellify";
// You can add your custom initial props in the dictionary below.
// They will be passed down to the ViewController used by React Native.
self.initialProps = @{};
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
return [self bundleURL];
}
- (NSURL *)bundleURL
{
#if DEBUG
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@".expo/.virtual-metro-entry"];
#else
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif
}
@end

View File

@@ -0,0 +1,63 @@
// ios/AppDelegate.swift
import UIKit
import CarPlay
import React
#if DEBUG
#if FB_SONARKIT_ENABLED
import FlipperKit
#endif
#endif
@main
class AppDelegate: UIResponder, UIApplicationDelegate, RCTBridgeDelegate {
var window: UIWindow?
var bridge: RCTBridge?;
var rootView: RCTRootView?;
static var shared: AppDelegate { return UIApplication.shared.delegate as! AppDelegate }
func sourceURL(for bridge: RCTBridge!) -> URL! {
#if DEBUG
return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index");
#else
return Bundle.main.url(forResource:"main", withExtension:"jsbundle")
#endif
}
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
initializeFlipper(with: application)
self.bridge = RCTBridge.init(delegate: self, launchOptions: launchOptions)
self.rootView = RCTRootView.init(bridge: self.bridge!, moduleName: "Jellify", initialProperties: nil)
return true
}
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
if (connectingSceneSession.role == UISceneSession.Role.carTemplateApplication) {
let scene = UISceneConfiguration(name: "CarPlay", sessionRole: connectingSceneSession.role)
scene.delegateClass = CarSceneDelegate.self
return scene
} else {
let scene = UISceneConfiguration(name: "Phone", sessionRole: connectingSceneSession.role)
scene.delegateClass = PhoneSceneDelegate.self
return scene
}
}
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
}
private func initializeFlipper(with application: UIApplication) {
#if DEBUG
#if FB_SONARKIT_ENABLED
let client = FlipperClient.shared()
let layoutDescriptorMapper = SKDescriptorMapper(defaults: ())
client?.add(FlipperKitLayoutPlugin(rootNode: application, with: layoutDescriptorMapper!))
client?.add(FKUserDefaultsPlugin(suiteName: nil))
client?.add(FlipperKitReactPlugin())
client?.add(FlipperKitNetworkPlugin(networkAdapter: SKIOSNetworkAdapter()))
client?.start()
#endif
#endif
}
}

View File

@@ -0,0 +1,14 @@
// ios/CarScene.swift
import Foundation
import CarPlay
class CarSceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController) {
RNCarPlay.connect(with: interfaceController, window: templateApplicationScene.carWindow);
}
func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didDisconnectInterfaceController interfaceController: CPInterfaceController) {
RNCarPlay.disconnect()
}
}

View File

@@ -90,5 +90,35 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
<key>UISceneConfigurations</key>
<dict>
<key>CPTemplateApplicationSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>CPTemplateApplicationScene</string>
<key>UISceneConfigurationName</key>
<string>CarPlay</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).CarSceneDelegate</string>
</dict>
</array>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>Phone</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).PhoneSceneDelegate</string>
</dict>
</array>
</dict>
</dict>
</dict>
</plist>

Some files were not shown because too many files have changed in this diff Show More